feat(종단): 325-1 배수 판 창구 — 지도 크기 관찰을 놓인 창 것으로 바꿔 끼우는 rehome · 직전 크기 이어받아 배율 유지

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VmC34fsxxbweEG3RUjs3Mn
This commit is contained in:
2026-10-15 12:22:37 +09:00
co-authored by Claude Opus 5.5
parent b5b6264016
commit 6d5e5f911e
5 changed files with 189 additions and 8 deletions
+2 -2
View File
@@ -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(),
};
}
@@ -52,6 +52,8 @@ export interface DrainagePanel {
/** 구간형 구조물이 놓인 자리 — 계획선 위에 띠로 얹는다(계획서 3-6).
* 종단 레인의 띠와 같은 뜻이고, 목록이 바뀔 때마다 통째로 넣어 준다. */
setIntervalSpans: (spans: ReadonlyArray<RouteSpanBand>) => void;
/** 판이 다른 창(새 창 · 제자리)으로 옮겨 간 뒤 — 지도 크기 관찰을 그 창 것으로 바꿔 낌(325-1). */
rehome: () => void;
dispose: () => void;
}
+21 -6
View File
@@ -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에 맞춘다. 실제로 바뀔 때만 재할당한다(재할당 시 내용이 지워진다). */
@@ -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));
@@ -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