diff --git a/B05_Profile/B05_Profile_UI_Profile_Drag.ts b/B05_Profile/B05_Profile_UI_Profile_Drag.ts index b5c22166a..4bb99cbf0 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Drag.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Drag.ts @@ -133,6 +133,8 @@ export function createProfileDrag(ctx: ProfileDragContext): ProfileDrag { }; // 위쪽 경계를 끌어 패널 높이를 조절한다. 늘어난 만큼은 그래프만 먹고 도면 테이블은 // 처음 잡힌 높이를 지킨다(사용자 지시) — 테이블 행이 늘었다 줄었다 하면 읽기 어려워서다. + // 새 창으로 뗀 유토 판은 자리를 안 차지함 — 「열림 && 뿌리 안」(325-2). + const massDocked = (): boolean => !massHaul.overlay.hidden && root.contains(massHaul.overlay); const heightResizer = createPanelResizer({ axis: "vertical", target: root, @@ -143,7 +145,7 @@ export function createProfileDrag(ctx: ProfileDragContext): ProfileDrag { // 셋 다 최소가 되면 여기서 더는 못 내려가게 막는다(뒤로 넘어가는 역전 차단). min: () => { const chrome = Math.max(0, root.offsetHeight - body.clientHeight); - const massMin = massHaul.overlay.hidden ? 0 : MASSHAUL_MIN_HEIGHT; + const massMin = massDocked() ? MASSHAUL_MIN_HEIGHT : 0; const tableMin = tableOverlay.isOpen() ? TABLE_OVERLAY_MIN_HEIGHT : 0; return Math.max( MIN_PANEL_HEIGHT, @@ -169,7 +171,7 @@ export function createProfileDrag(ctx: ProfileDragContext): ProfileDrag { let mainDragRef: { chart: number; mass: number; table: number } | null = null; heightResizer.root.addEventListener("pointerdown", () => { mainPanelDragging = true; - const massH = massHaul.overlay.hidden ? 0 : massHaul.overlay.offsetHeight; + const massH = massDocked() ? massHaul.overlay.offsetHeight : 0; const tableH = tableOverlay.isOpen() ? tableOverlay.overlay.offsetHeight : 0; mainDragRef = { chart: Math.max( @@ -191,7 +193,7 @@ export function createProfileDrag(ctx: ProfileDragContext): ProfileDrag { // 비례 연동 결과를 서브패널 저장 높이로 확정한다 — 이후 모든 redraw의 판정 // 기준이 이 값이 되어 멱등성이 유지된다(확정 없이는 손을 떼는 순간 예전 // 저장 높이로 튄다). 확정 후 인라인은 걷는다(변수가 같은 값을 담는다). - if (!massHaul.overlay.hidden) massHaul.commitHeight(massHaul.overlay.offsetHeight); + if (massDocked()) massHaul.commitHeight(massHaul.overlay.offsetHeight); if (tableOverlay.isOpen()) tableOverlay.commitHeight(tableOverlay.overlay.offsetHeight); massHaul.overlay.style.height = ""; tableOverlay.overlay.style.height = ""; diff --git a/B05_Profile/B05_Profile_UI_Profile_Heights.ts b/B05_Profile/B05_Profile_UI_Profile_Heights.ts index 3771fdf1e..fe6035427 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Heights.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Heights.ts @@ -67,7 +67,8 @@ export function createHeightCascade(deps: HeightCascadeDeps): HeightCascade { // 구조물 알약 레인이 그래프 아래 한 줄을 차지한다 — 그만큼 먼저 떼어 놓고 나눈다 // (2026-08-17 사용자 지시 6: 그래프가 밀려도 되지만 여유는 최소). const available = Math.max(120, deps.body.clientHeight - STRUCTURE_LANE_HEIGHT_PX); - const massOpen = !deps.massHaul.overlay.hidden; + // 새 창으로 뗀 유토 판은 자리를 안 차지함 — 「열림 && 뿌리 안」(325-2). + const massOpen = !deps.massHaul.overlay.hidden && deps.root.contains(deps.massHaul.overlay); const tableOpen = deps.tableOverlay.isOpen(); // 판정 기준은 항상 **저장 높이**(리사이저 변수·기본값)다 — 임시 축소가 반영된 화면 // 높이(offsetHeight)로 재면 "줄임 → 충분해 보임 → 풀림 → 부족 → 다시 줄임"이 diff --git a/B05_Profile/B05_Profile_UI_Profile_MassHaul.ts b/B05_Profile/B05_Profile_UI_Profile_MassHaul.ts index 0600b48e1..d219230b3 100644 --- a/B05_Profile/B05_Profile_UI_Profile_MassHaul.ts +++ b/B05_Profile/B05_Profile_UI_Profile_MassHaul.ts @@ -438,7 +438,12 @@ export function createRouteMassHaulPanel( window: undefined, hideStations: true, } - : { ...params.axis, window: windowState }; + : { + ...params.axis, + // 새 창으로 뗀 판은 넓어 메인 보이는 구간 세로면 잘림 — 노선 전체 세로(325-2). + viewRange: overlay.classList.contains("is-popout") ? undefined : params.axis.viewRange, + window: windowState, + }; let massAxis: { padLeft: number; ticks: Array<{ y: number; label: string }> } | null = null; const chart = createMassHaulChart( diff --git a/B06_Section/B06_Section_UI_Cross_Panel_Base.ts b/B06_Section/B06_Section_UI_Cross_Panel_Base.ts index 6833d8a20..53ff81b73 100644 --- a/B06_Section/B06_Section_UI_Cross_Panel_Base.ts +++ b/B06_Section/B06_Section_UI_Cross_Panel_Base.ts @@ -89,7 +89,10 @@ if (typeof document !== "undefined") /** 방향키가 종단도 몫인가 — 마지막으로 누른 자리가 종단도 안이면 참. */ export function arrowsBelongToProfile(): boolean { - return !!lastPointer?.closest(".b05-route-profile"); + // 새 창으로 뗀 유토 · 배수 판도 종단도 몫(325-2). + return !!lastPointer?.closest( + ".b05-route-profile, .b05-profile__masshaul-overlay, .b05-drainage", + ); } /** 조정창 없는 고름(관 · 도수로/돌붙임 띠)이 방향키를 삼키나 — 그 동안 방향키는 아무것도 안 옮김. diff --git a/resources/tester/helper_219_1_arrow_owner.cjs b/resources/tester/helper_219_1_arrow_owner.cjs index a1f70e5b9..5e95ef67b 100644 --- a/resources/tester/helper_219_1_arrow_owner.cjs +++ b/resources/tester/helper_219_1_arrow_owner.cjs @@ -102,7 +102,8 @@ tools.createPanelTools({ /** 누른 자리 — 종단도 안이면 closest 가 그 판을 줌(새 창 요소처럼 Element 가 아닌 것도). */ const spot = (inProfile) => ({ - closest: (sel) => (inProfile && sel === ".b05-route-profile" ? {} : null), + closest: (sel) => + inProfile && sel.split(",").some((s) => s.trim() === ".b05-route-profile") ? {} : null, }); const press = (target) => listeners.document.pointerdown?.forEach((fn) => fn({ target })); diff --git a/resources/tester/helper_224_1b_masshaul_docked.cjs b/resources/tester/helper_224_1b_masshaul_docked.cjs new file mode 100644 index 000000000..2c07b616a --- /dev/null +++ b/resources/tester/helper_224_1b_masshaul_docked.cjs @@ -0,0 +1,112 @@ +/* 224-1b 유토 판 「붙어 있음」 판정 — 진짜 높이 나눔(`createHeightCascade`)과 방향키 판정 + * (`arrowsBelongToProfile` 본문)을 가짜 DOM 위에서 돌려 JSON 으로 냄. + * · 열림 && 뿌리 안 = 자리 차지 · 열림 && 새 창(뿌리 밖) = 자리 0 · 인라인 높이 안 씀 + * · 방향키 = 마지막 누름이 종단 뿌리 · 뗀 유토 · 뗀 배수 판 안이면 종단도 몫 */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); + +const ROOT = path.join(__dirname, "..", ".."); +const read = (...p) => fs.readFileSync(path.join(ROOT, ...p), "utf8"); +const transpile = (code) => + ts.transpileModule(code, { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, + }).outputText; + +// ---- 가짜 요소(부모 사슬 · contains · closest) ---- +function el(className = "") { + const node = { className, parent: null, hidden: false, clientHeight: 0, offsetHeight: 0 }; + node.style = { + height: "", + setProperty: (k, v) => (node.style[k] = v), + }; + node.append = (kid) => ((kid.parent = node), kid); + node.contains = (other) => { + for (let at = other; at; at = at.parent) if (at === node) return true; + return false; + }; + node.closest = (selector) => { + const wanted = selector.split(",").map((s) => s.trim().replace(/^\./, "")); + for (let at = node; at; at = at.parent) + if (wanted.some((c) => at.className.split(/\s+/).includes(c))) return at; + return null; + }; + return node; +} + +// ---- 진짜 높이 나눔 ---- +const heightsExports = {}; +new Function( + "exports", + "require", + "window", + transpile(read("B05_Profile", "B05_Profile_UI_Profile_Heights.ts")), +)( + heightsExports, + (id) => + id.includes("MassHaul") + ? { MASSHAUL_MIN_HEIGHT: 160 } + : id.includes("TableOverlay") + ? { TABLE_OVERLAY_MIN_HEIGHT: 120 } + : id.includes("Structures_Marks") + ? { STRUCTURE_LANE_HEIGHT_PX: 40 } + : { frameOf: () => 0 }, + { innerHeight: 900 }, +); + +function run(docked) { + const root = el("b05-route-profile"); + root.offsetHeight = 500; + const body = root.append(el()); + body.clientHeight = 400; + const overlay = el("b05-profile__masshaul-overlay"); + if (docked) body.append(overlay); + else el("popup-body").append(overlay); // 새 창 body + const cascade = heightsExports.createHeightCascade({ + root, + body, + massHaul: { overlay, desiredHeight: () => 300, syncHandle() {} }, + tableOverlay: { + overlay: el(), + isOpen: () => false, + desiredHeight: () => 0, + setBottomOffset() {}, + }, + isMainDragging: () => false, + isSubDragging: () => false, + mainDragRef: () => null, + isMainDragCooldown: () => true, // 자동 키우기 끔 — 나눔만 봄 + hasDetail: () => true, + }); + const { chartHeight } = cascade.apply(false); + return { chartHeight, inline: overlay.style.height }; +} + +// ---- 진짜 방향키 판정 본문 ---- +const base = read("B06_Section", "B06_Section_UI_Cross_Panel_Base.ts"); +const at = base.indexOf("export function arrowsBelongToProfile"); +const fnSrc = base.slice(at, base.indexOf("\n}", at) + 2).replace("export ", ""); +const arrows = new Function( + `let lastPointer; ${transpile(fnSrc)}; return (p) => ((lastPointer = p), arrowsBelongToProfile());`, +)(); +const profileRoot = el("b05-route-profile"); +const profileChild = profileRoot.append(el("b05-profile__chart")); +const massPopped = el("b05-profile__masshaul-overlay is-popout"); +const massChild = massPopped.append(el("b05-profile__masshaul-scroll")); +const drainPopped = el("b05-drainage is-popout"); +const drainChild = drainPopped.append(el("b05-drainage__canvas")); +const card = el("b06-cross-card"); + +process.stdout.write( + JSON.stringify({ + docked: run(true), + popped: run(false), + arrows: { + profile: arrows(profileChild), + mass: arrows(massChild), + drainage: arrows(drainChild), + card: arrows(card), + none: arrows(null), + }, + }), +); diff --git a/resources/tester/test_224_1b_masshaul_docked.py b/resources/tester/test_224_1b_masshaul_docked.py new file mode 100644 index 000000000..d537ad087 --- /dev/null +++ b/resources/tester/test_224_1b_masshaul_docked.py @@ -0,0 +1,69 @@ +# -*- coding: utf-8 -*- +"""224-1b(PLAN 325-2) 유토곡선 판 「붙어 있음」 판정 · 방향키. + +- 높이 나눔 · 끌기는 「열림 && 종단 뿌리 안」만 자리를 줌 — 새 창으로 뗀 유토 판은 메인 자리 0 · + 임시 축소 인라인 높이도 안 씀(뗀 창 높이가 굳지 않게). +- 뗀 유토 판(`is-popout`)은 메인 보이는 구간 세로 범위(viewRange)를 안 씀 — 노선 전체 세로. +- 방향키 ←→ 판정은 마지막 누름이 종단 뿌리 · 뗀 유토 · 뗀 배수 판 안이면 종단도 몫. + +`helper_224_1b_masshaul_docked.cjs` 가 진짜 TS 를 가짜 DOM 위에서 돌림. +""" + +import json +import os +import re +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_1b_masshaul_docked.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): + """본문 400 − 알약 40 = 360 · 유토 300 은 예산 247 넘어 247 로 줄고 그래프 100.""" + assert r["docked"] == {"chartHeight": 100, "inline": "247px"} + + +def test_뗀_유토는_자리_0_인라인_없음(r): + assert r["popped"] == {"chartHeight": 347, "inline": ""} + + +def test_방향키_판정(r): + assert r["arrows"] == { + "profile": True, + "mass": True, + "drainage": True, + "card": False, + "none": False, + } + + +def test_끌기도_같은_판정(): + drag = (B05 / "B05_Profile_UI_Profile_Drag.ts").read_text(encoding="utf-8") + assert "massHaul.overlay.hidden" in drag # 판정 함수 한 곳에만 + assert drag.count("massHaul.overlay.hidden") == 1 + assert drag.count("massDocked()") == 3 + assert "root.contains(massHaul.overlay)" in drag + + +def test_뗀_유토는_노선_전체_세로(): + mass = (B05 / "B05_Profile_UI_Profile_MassHaul.ts").read_text(encoding="utf-8") + assert re.search( + r'viewRange: overlay\.classList\.contains\("is-popout"\) \? undefined : params\.axis\.viewRange', + mass, + )