diff --git a/B05_Profile/B05_Profile_UI_Drainage_Panel.ts b/B05_Profile/B05_Profile_UI_Drainage_Panel.ts index d8d32dcd5..75a2d3ab5 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Panel.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Panel.ts @@ -536,8 +536,7 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra selectBasin: (index) => selectBasin(index), }); - // 패널을 늘리면 지도를 더 보여 줄 뿐, 배율은 그대로 둔다 — 늘릴 때마다 확대되면 - // 방금 보던 자리를 다시 찾아야 한다(2026-08-02 사용자 지시). + // 패널을 늘리면 지도를 더 보여 줄 뿐, 배율은 그대로 둔다(늘릴 때마다 확대되면 보던 자리를 잃음). const resizeObserver = observeViewportSize(viewport, { meta: () => meta, anchor: () => ({ width: 0, height: 0, scale, offsetX, offsetY }), @@ -696,5 +695,6 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra resizeObserver.disconnect(); widthResizer.dispose(); }, + rehome: () => resizeObserver.rehome(), }; } diff --git a/B05_Profile/B05_Profile_UI_Drainage_Panel_Types.ts b/B05_Profile/B05_Profile_UI_Drainage_Panel_Types.ts index 7ed1fe8b7..365d8a1ef 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Panel_Types.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Panel_Types.ts @@ -52,6 +52,8 @@ export interface DrainagePanel { /** 구간형 구조물이 놓인 자리 — 계획선 위에 띠로 얹는다(계획서 3-6). * 종단 레인의 띠와 같은 뜻이고, 목록이 바뀔 때마다 통째로 넣어 준다. */ setIntervalSpans: (spans: ReadonlyArray) => void; + /** 판이 다른 창(새 창 · 제자리)으로 옮겨 간 뒤 — 지도 크기 관찰을 그 창 것으로 바꿔 낌(325-1). */ + rehome: () => void; dispose: () => void; } diff --git a/B05_Profile/B05_Profile_UI_Drainage_Parts.ts b/B05_Profile/B05_Profile_UI_Drainage_Parts.ts index 868e450c5..f6e4539ba 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Parts.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Parts.ts @@ -23,6 +23,7 @@ import type { MapContextMenu, MapContextMenuItem } from "@ui/ui_template_context import type { DetailBasin } from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; import type { PipeEditor, PipePoint } from "./B05_Profile_UI_Drainage_Pipes"; import { PIPE_MATCH_M } from "../A00_Common/design/design_selection"; +import { viewOf } from "../common_util/common_util_view_window"; function L(key: keyof typeof ui_locales): string { return ui_locales[key][currentLanguageIndex]; @@ -454,7 +455,8 @@ export function preserveViewOnResize( }; } -/** 뷰포트 크기 변화를 지켜보며 배율을 지킨다. 반환값은 정리용 `ResizeObserver`. */ +/** 뷰포트 크기 변화를 지켜보며 배율을 지킨다. `rehome` = 판이 다른 창으로 옮겨 간 뒤 그 창의 + * 관찰자로 바꿔 낌(직전 크기를 지켜 배율 유지 · 325-1) · `disconnect` = 정리. */ export function observeViewportSize( viewport: HTMLElement, ports: { @@ -463,9 +465,9 @@ export function observeViewportSize( apply: (next: { scale: number; offsetX: number; offsetY: number }) => void; redraw: () => void; }, -): ResizeObserver { +): { rehome: () => void; disconnect: () => void } { let last: { width: number; height: number } | null = null; - const observer = new ResizeObserver(() => { + const onResize = (): void => { const rect = viewport.getBoundingClientRect(); const width = Math.max(1, Math.floor(rect.width)); const height = Math.max(1, Math.floor(rect.height)); @@ -482,9 +484,22 @@ export function observeViewportSize( } last = { width, height }; ports.redraw(); - }); - observer.observe(viewport); - return observer; + }; + // 새 창 크기 변화는 그 창의 관찰자만 안다 — 판이 놓인 창의 것으로 단다. + const watch = (): ResizeObserver => { + const view = viewOf(viewport) as Window & typeof globalThis; + const next = new view.ResizeObserver(onResize); + next.observe(viewport); + return next; + }; + let observer = watch(); + return { + rehome: () => { + observer.disconnect(); + observer = watch(); + }, + disconnect: () => observer.disconnect(), + }; } /** 캔버스 버퍼를 뷰포트 크기·DPR에 맞춘다. 실제로 바뀔 때만 재할당한다(재할당 시 내용이 지워진다). */ diff --git a/resources/tester/helper_224_1a_drainage_rehome.cjs b/resources/tester/helper_224_1a_drainage_rehome.cjs new file mode 100644 index 000000000..1f3bbea85 --- /dev/null +++ b/resources/tester/helper_224_1a_drainage_rehome.cjs @@ -0,0 +1,99 @@ +/* 224-1a 배수 판 창구(rehome) — 진짜 `observeViewportSize`(B05_Profile_UI_Drainage_Parts.ts)를 + * 가짜 창 둘 위에서 돌려 JSON 으로 냄. + * · 처음 = 판이 놓인 창(메인)의 관찰자 + * · rehome = 옛 관찰자 끊고 지금 놓인 창(새 창)의 관찰자로 · 직전 크기 이어받아 배율 유지 + * · disconnect = 지금 관찰자 끊음 */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); + +const ROOT = path.join(__dirname, "..", ".."); +const cache = new Map(); +const stub = new Proxy({}, { get: () => () => "" }); + +/** TS 한 장을 CommonJS 로 — 상대 경로는 진짜(공용 창 · 지도 셈만), 나머지는 빈 함수. */ +function loadTs(full) { + if (cache.has(full)) return cache.get(full); + const js = ts.transpileModule(fs.readFileSync(full, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, + }).outputText; + const box = { exports: {} }; + cache.set(full, box.exports); + const localRequire = (request) => + /common_util_view_window|B04_PreProcess_UI_MapRender/.test(request) + ? loadTs(path.join(path.dirname(full), `${request}.ts`)) + : stub; + new Function("exports", "module", "require", js)(box.exports, box, localRequire); + return box.exports; +} + +/** 가짜 창 — 관찰자를 모아 두고 크기를 바꿔 울림. */ +function fakeWindow(name) { + const win = { name, observers: [] }; + win.ResizeObserver = class { + constructor(fn) { + this.fn = fn; + this.live = false; + win.observers.push(this); + } + observe() { + this.live = true; + } + disconnect() { + this.live = false; + } + }; + return win; +} +const main = fakeWindow("main"); +const popup = fakeWindow("popup"); +global.window = main; + +const parts = loadTs(path.join(ROOT, "B05_Profile", "B05_Profile_UI_Drainage_Parts.ts")); +const doc = { defaultView: main }; +let size = { width: 400, height: 300 }; +const viewport = { ownerDocument: doc, getBoundingClientRect: () => ({ ...size }) }; +const fire = (win) => win.observers.filter((o) => o.live).forEach((o) => o.fn()); + +let view = { scale: 2, offsetX: 10, offsetY: -5 }; +const applied = []; +let redraws = 0; +const META = { + x_min: 0, + x_max: 4000, + y_min: 0, + y_max: 3000, + width_meters: 4000, + height_meters: 3000, +}; +const watcher = parts.observeViewportSize(viewport, { + meta: () => META, + anchor: () => ({ width: 0, height: 0, ...view }), + apply: (next) => (applied.push(next), (view = next)), + redraw: () => (redraws += 1), +}); +const out = { firstOwner: main.observers.length === 1 && popup.observers.length === 0 }; +fire(main); // 처음 크기 기억 +out.firstApplied = applied.length; + +// 새 창으로 옮김 — 메인 관찰자 끊고 새 창 관찰자로 +doc.defaultView = popup; +watcher.rehome(); +out.mainLive = main.observers.some((o) => o.live); +out.popupLive = popup.observers.filter((o) => o.live).length; +size = { width: 800, height: 600 }; // 새 창에서 두 배로 커짐 +fire(popup); +out.afterGrow = { applied: applied.length, ...view }; +// 메인 창 관찰자가 울려도(이미 끊김) 아무 일 없음 +fire(main); +out.mainIgnored = applied.length; + +// 제자리 — 다시 메인 +doc.defaultView = main; +watcher.rehome(); +out.backPopupLive = popup.observers.some((o) => o.live); +out.backMainLive = main.observers.filter((o) => o.live).length; +watcher.disconnect(); +out.afterDispose = [...main.observers, ...popup.observers].some((o) => o.live); +out.redraws = redraws; +process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/test_224_1a_drainage_rehome.py b/resources/tester/test_224_1a_drainage_rehome.py new file mode 100644 index 000000000..c53de3f95 --- /dev/null +++ b/resources/tester/test_224_1a_drainage_rehome.py @@ -0,0 +1,65 @@ +# -*- coding: utf-8 -*- +"""224-1a(PLAN 325-1) 배수유역 판 창구 — 새 창으로 떼어도 지도 크기 관찰이 그 창을 따름. + +- 판 지도 크기 관찰(`observeViewportSize`)은 판이 놓인 창의 ResizeObserver 를 씀. +- `rehome()` = 옛 창 관찰자 끊고 지금 놓인 창 것으로 · 직전 크기를 이어받아 배율(화면 m 당 px) 유지. +- 판 창구(`DrainagePanel.rehome`)로 내보내고 패널 본체는 700줄 안. + +`helper_224_1a_drainage_rehome.cjs` 가 진짜 TS 를 가짜 창 둘 위에서 돌림. +""" + +import json +import os +import subprocess +from pathlib import Path + +import pytest + +HERE = os.path.dirname(os.path.abspath(__file__)) +B05 = Path(HERE).parents[1] / "B05_Profile" + + +@pytest.fixture(scope="module") +def r(): + proc = subprocess.run( + ["node", os.path.join(HERE, "helper_224_1a_drainage_rehome.cjs")], + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +def test_처음은_놓인_창_관찰자(r): + assert r["firstOwner"] is True + assert r["firstApplied"] == 0 # 처음 크기는 기억만 + + +def test_rehome_은_새_창_관찰자로(r): + assert r["mainLive"] is False + assert r["popupLive"] == 1 + + +def test_새_창에서_커져도_배율_유지(r): + """400 → 800 px · 배율 2 → 1 = 화면 m 당 px 그대로.""" + grow = r["afterGrow"] + assert grow["applied"] == 1 + assert grow["scale"] == pytest.approx(1) + assert r["mainIgnored"] == 1 # 끊긴 메인 관찰자는 안 울림 + + +def test_제자리와_정리(r): + assert r["backPopupLive"] is False + assert r["backMainLive"] == 1 + assert r["afterDispose"] is False + assert r["redraws"] == 2 + + +def test_판_창구와_700줄(): + panel = (B05 / "B05_Profile_UI_Drainage_Panel.ts").read_text(encoding="utf-8") + assert "rehome: () => resizeObserver.rehome()," in panel + assert len(panel.splitlines()) <= 700 + types = (B05 / "B05_Profile_UI_Drainage_Panel_Types.ts").read_text(encoding="utf-8") + assert "rehome: () => void;" in types