diff --git a/A00_Common/design/design_page.css b/A00_Common/design/design_page.css index d4b2ff545..7813704b5 100644 --- a/A00_Common/design/design_page.css +++ b/A00_Common/design/design_page.css @@ -55,20 +55,13 @@ font-size: var(--btn-bar-font-size); } -/* 유토곡선 · 배수유역 판 [창 분할](계획서 325-3) — 종단 [창 분할]과 같은 양식 · 유토는 판 좌상단에 띄움. */ +/* 유토곡선 · 배수유역 판 [창 분할](계획서 325-3) — 종단 [창 분할]과 같은 양식 · 두 판 머리줄 맨 앞. */ .b05-subpanel__popout { flex: none; padding: var(--btn-bar-padding); font-size: var(--btn-bar-font-size); } -.b05-profile__masshaul-overlay > .b05-subpanel__popout { - position: absolute; - top: 4px; - left: var(--spacing-8); - z-index: 4; -} - /* 새 창으로 뗀 유토곡선 · 배수유역 판 — 창 전체를 채움 · 높이 · 폭 끌기와 접기 손잡이는 감춤. */ .b05-profile__masshaul-overlay.is-popout, .b05-drainage.is-popout { @@ -79,6 +72,7 @@ } .b05-profile__masshaul-overlay.is-popout > .ui-resizer, +.b05-profile__masshaul-overlay.is-popout .b05-profile__masshaul-handle, .b05-drainage.is-popout > .ui-resizer, .b05-drainage.is-popout > .ui-workflow-overlay__handle { display: none; diff --git a/A00_Common/design/design_profile.ts b/A00_Common/design/design_profile.ts index 18ac1dcf6..faa2750cb 100644 --- a/A00_Common/design/design_profile.ts +++ b/A00_Common/design/design_profile.ts @@ -145,8 +145,8 @@ export function createDesignProfile(ctx: DesignProfileContext) { // 절토·성토 요약 줄은 **종단 쪽 열 안**에 둔다 — 패널 전체 폭에 걸치면 우측 배수유역도 // 구분선이 그 높이만큼 끊겨 보인다(2026-08-04 사용자 보고). bodyWrap.append(balanceBar, body, progress.root); - // 유토곡선 — 패널 바닥에 붙는 오버레이 서브패널(2026-08-04 사용자 확정). - // 종단도·테이블 배치는 건드리지 않고 그 위를 덮으며, 위 경계 리사이저로 높이를 조절한다. + // 유토곡선 — 종단 칸 아래 자기 판(354-1) · 테이블 — 종단 칸 바닥 겹침 판. + // 둘 다 위 경계 리사이저로 높이를 조절한다. // 서브패널 리사이저를 끄는 동안엔 메인 드래그와 같은 **경량 동기화**만 프레임마다 돌리고 // (차트 SVG·테이블 재생성은 무거워 덜컹였다 — 2026-08-06 분석 원인 3), 전체 재구성은 // 손을 뗀 뒤(clearDragFlags) 1회만 한다. 펼침·범례 토글 등 드래그 밖 알림은 전체 draw. @@ -155,7 +155,7 @@ export function createDesignProfile(ctx: DesignProfileContext) { else draw(); }; // 유토곡선 · 테이블 오버레이 · 유토 배지는 `design_profile_overlays` 몫(700줄 한계). - const { massHaul, tableOverlay } = createProfileOverlays({ + const { massHaul, tableOverlay, column } = createProfileOverlays({ projectId, routeId: () => routeId, bodyWrap, @@ -192,7 +192,7 @@ export function createDesignProfile(ctx: DesignProfileContext) { callbacks?.onStructureTypeAdd?.(chainage, typeId), ), }) ?? null; - content.append(bodyWrap); + content.append(column); if (drainagePanel) content.append(drainagePanel.root); const drag = createProfileDrag({ root, @@ -385,7 +385,7 @@ export function createDesignProfile(ctx: DesignProfileContext) { /** * 높이 캐스케이드(2026-08-05 사용자 확정): 자리가 모자라면 ① 종단이 먼저 최소까지 - * 줄고 → ② 테이블·유토곡선이 **같이**(비례) 각자 최소까지 줄고 → ③ 셋 다 최소면 + * 줄고 → ② 테이블이 최소까지 줄고(유토곡선은 자기 판 · 354-1) → ③ 둘 다 최소면 * 메인 패널 리사이저의 동적 하한이 더 못 내려가게 막는다. 반대로 서브패널을 갑자기 * 펼쳐 자리가 모자라면 메인 패널 높이를 키운다(deficit 처리). * @@ -395,7 +395,6 @@ export function createDesignProfile(ctx: DesignProfileContext) { const heights = createHeightCascade({ root, body, - massHaul, tableOverlay, isMainDragging: drag.isMainDragging, isSubDragging: drag.isSubDragging, diff --git a/A00_Common/design/design_profile_overlays.ts b/A00_Common/design/design_profile_overlays.ts index b4c2d503a..5926afe74 100644 --- a/A00_Common/design/design_profile_overlays.ts +++ b/A00_Common/design/design_profile_overlays.ts @@ -1,7 +1,8 @@ /* ============================================================================= * design_profile_overlays.ts - * 종단도 바닥 오버레이 서브패널 — 유토곡선 · 테이블 · 유토 총괄 배지(213-3 · 700줄 한계로 뗌). - * 만들어 `bodyWrap` 에 붙이고 종단 스크롤과 잇는 데까지 — 그리기 · 높이는 종단도 몫. + * 종단도 겹침 판(테이블) · 종단 열 아래 유토곡선 판 · 유토 총괄 배지(213-3 · 700줄 한계로 뗌). + * 만들어 붙이고 종단 스크롤과 잇는 데까지 — 그리기 · 높이는 종단도 몫. + * 종단 열(`column`) = [종단 칸(`bodyWrap`), 유토곡선 판](354-1) — 종단도가 content 왼쪽에 붙인다. * ========================================================================== */ import { attachMassHaulBadgeDrag, createMassHaulBadge } from "@util/common_util_mass_haul_badge"; @@ -16,7 +17,7 @@ import { export interface ProfileOverlaysDeps { projectId: string; routeId: () => number | null; - /** 종단 열(요약 줄 · 본문 · 서클을 담은 칸) — 오버레이가 그 위를 덮는다. */ + /** 종단 칸(요약 줄 · 본문 · 서클을 담은 칸) — 표 겹침 판이 그 위를 덮는다. */ bodyWrap: HTMLElement; /** 종단 스크롤러 — 오버레이 가로 스크롤을 이것과 맞춘다. */ body: HTMLElement; @@ -54,10 +55,13 @@ export function createProfileOverlays(deps: ProfileOverlaysDeps) { }, haulPrefetch, ); - bodyWrap.append(massHaul.overlay, massHaul.handle); - // 오버레이의 가로 스크롤을 종단 스크롤러와 양방향 동기화 — 측점 세로선 정렬 유지. + // 유토곡선 = 종단 칸 아래 자기 판(354-1 · 배수유역도처럼) — 종단 [창 분할]에도 함께 감(content 안). + const column = document.createElement("div"); + column.className = "b05-route-profile__column"; + column.append(bodyWrap, massHaul.overlay); + // 판의 가로 스크롤을 종단 스크롤러와 양방향 동기화 — 측점 세로선 정렬 유지. massHaul.attachScrollSync(body); - // 테이블도 유토곡선과 같은 오버레이 서브패널 — 유토곡선 위에 쌓인다(2026-08-05 사용자 지시). + // 테이블은 종단 칸 바닥 겹침 판(2026-08-05 사용자 지시). const tableOverlay = createProfileTableOverlay(onChange); bodyWrap.append(tableOverlay.overlay, tableOverlay.handle); tableOverlay.attachScrollSync(body); @@ -65,5 +69,5 @@ export function createProfileOverlays(deps: ProfileOverlaysDeps) { // 종단 그래프의 구조물 메뉴는 chartWrap에서 이미 preventDefault 후 자체 메뉴를 띄운다. body.addEventListener("contextmenu", (event) => event.preventDefault()); massHaul.overlay.addEventListener("contextmenu", (event) => event.preventDefault()); - return { massHaul, tableOverlay }; + return { massHaul, tableOverlay, column }; } diff --git a/A00_Common/design/design_profile_popout.ts b/A00_Common/design/design_profile_popout.ts index 304bdb07f..311681235 100644 --- a/A00_Common/design/design_profile_popout.ts +++ b/A00_Common/design/design_profile_popout.ts @@ -131,7 +131,7 @@ export function attachProfilePopout( }); } -/** 유토곡선 · 배수유역 판에 [창 분할] 단추를 단다 — 유토 판 좌상단 · 배수 머리줄 맨 앞(계획서 325-3). */ +/** 유토곡선 · 배수유역 판에 [창 분할] 단추를 단다 — 두 판 머리줄 맨 앞(계획서 325-3 · 354-1). */ export function attachSubPanelPopouts(profile: DesignProfile): void { const subButton = (title: string): HTMLButtonElement => { const button = document.createElement("button"); @@ -141,23 +141,20 @@ export function attachSubPanelPopouts(profile: DesignProfile): void { }; const massOverlay = profile.root.querySelector(".b05-profile__masshaul-overlay"); - const massHandle = profile.root.querySelector(".b05-profile__masshaul-handle"); - if (massOverlay && massHandle) { + const massHead = massOverlay?.querySelector(".b05-profile__masshaul-head"); + if (massOverlay && massHead) { const button = subButton("유토곡선 창 분할"); - massOverlay.append(button); + // 판 머리줄 맨 앞 — 접어도 남아 닿는다(354-1). + massHead.prepend(button); + const column = massOverlay.parentElement; attachPopout(profile, { piece: massOverlay, button, name: "aislo-masshaul-popout", features: "popup,width=1400,height=420", docTitle: "유토곡선 — Aislo", - // 제자리 = 유토 손잡이 바로 앞(종단 본문 바닥 겹침 판). - home: () => massHandle.before(massOverlay), - onMove: (popped) => { - // 뗀 동안 메인 손잡이는 숨김 · 같이 줄이기 인라인 높이는 비움(새 창은 창 높이를 다 씀). - massHandle.hidden = popped; - massOverlay.style.height = ""; - }, + // 제자리 = 종단 열 끝(종단 칸 아래 자기 판). + home: () => column?.append(massOverlay), }); } diff --git a/B05_Profile/B05_Profile_UI_Profile_Drag.ts b/B05_Profile/B05_Profile_UI_Profile_Drag.ts index 4bb99cbf0..bb6fb193b 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Drag.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Drag.ts @@ -10,12 +10,11 @@ import { createPanelResizer } from "@ui/ui_template_resizer"; import { readStateRaw, stateKey, writeStateRaw } from "../A00_Common/b_page_state"; import { profilePanelMaxHeight } from "./B05_Profile_UI_Profile_Heights"; -import { MASSHAUL_MIN_HEIGHT } from "./B05_Profile_UI_Profile_MassHaul"; import { TABLE_OVERLAY_MIN_HEIGHT } from "./B05_Profile_UI_Profile_TableOverlay"; import { viewOf } from "../common_util/common_util_view_window"; const MIN_CHART_HEIGHT = 100; -/** 접힌 유토곡선 손잡이가 앉을 테이블 아래 전용 띠 — 곡선 행 클릭을 가리지 않게. +/** 접힌 테이블 손잡이가 앉을 본문 아래 전용 띠 — 곡선 행 클릭을 가리지 않게. * 구조물 알약 레인이 그 위에 붙은 뒤로는 사이가 떠 보여 절반으로 줄였다 * (2026-08-17 사용자 지시 2). */ const MASSHAUL_HANDLE_GUTTER_PX = 13; @@ -93,7 +92,7 @@ export function createElevationHold( }; } -/** 겹침 판(유토곡선 · 표) 중 끌기가 보는 것만. */ +/** 겹침 판(표) 중 끌기가 보는 것만. */ interface SubPanel { overlay: HTMLElement; commitHeight: (height: number) => void; @@ -102,7 +101,8 @@ interface SubPanel { export interface ProfileDragContext { root: HTMLElement; body: HTMLElement; - massHaul: SubPanel; + /** 유토곡선 판 — 그 리사이저 끌기 출처만 본다(자기 판이라 높이 나눔에 안 낌 · 354-1). */ + massHaul: { overlay: HTMLElement }; tableOverlay: SubPanel & { isOpen: () => boolean }; /** 손을 뗀 뒤 한 번 다시 세운다. */ draw: () => void; @@ -112,7 +112,7 @@ export interface ProfileDrag { resizerRoot: HTMLElement; isMainDragging: () => boolean; isSubDragging: () => boolean; - mainDragRef: () => { chart: number; mass: number; table: number } | null; + mainDragRef: () => { chart: number; table: number } | null; isMainDragCooldown: () => boolean; clearMainDragCooldown: () => void; /** 전체 재구성을 120ms 뒤로 미룬다(연속 호출은 마지막 한 번만). */ @@ -133,23 +133,21 @@ 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, cssVar: "--b05-profile-height", direction: -1, - // 동적 하한: 종단 최소 + 열린 서브패널들의 최소 높이 합(2026-08-05 사용자 확정). + // 동적 하한: 종단 최소 + 열린 표 최소 높이(2026-08-05 사용자 확정) — 유토곡선 판 높이는 + // chrome(뿌리 − 본문)에 이미 들어 있다(354-1). // 패널을 아래로 끌면 종단이 먼저 줄고 → 서브패널들이 같이 줄고(draw의 캐스케이드) → // 셋 다 최소가 되면 여기서 더는 못 내려가게 막는다(뒤로 넘어가는 역전 차단). min: () => { const chrome = Math.max(0, root.offsetHeight - body.clientHeight); - const massMin = massDocked() ? MASSHAUL_MIN_HEIGHT : 0; const tableMin = tableOverlay.isOpen() ? TABLE_OVERLAY_MIN_HEIGHT : 0; return Math.max( MIN_PANEL_HEIGHT, - chrome + MIN_CHART_HEIGHT + MASSHAUL_HANDLE_GUTTER_PX + massMin + tableMin, + chrome + MIN_CHART_HEIGHT + MASSHAUL_HANDLE_GUTTER_PX + tableMin, ); }, // 상한 = 가운데 칸 높이의 90%(위 칸 3D · 횡단 카드가 다 가려지지 않게) — Heights 와 한 식 @@ -166,19 +164,14 @@ export function createProfileDrag(ctx: ProfileDragContext): ProfileDrag { * 플래그가 이미 풀린 채 돌아, 드래그 중 금지했던 자동 확대가 손 떼는 순간 발동해 * 포인터가 정한 높이를 되돌렸다(2026-08-06 분석 원인 4). draw()가 끝나며 푼다. */ let mainDragCooldown = false; - /** 메인 드래그 시작 시점의 3영역(종단·유토곡선·테이블) 실제 높이 — 비례 연동 기준. + /** 메인 드래그 시작 시점의 두 영역(종단·테이블) 실제 높이 — 비례 연동 기준. * 드래그 중 이 비율대로 같이 늘리고 줄인다(2026-08-06 사용자 확정). */ - let mainDragRef: { chart: number; mass: number; table: number } | null = null; + let mainDragRef: { chart: number; table: number } | null = null; heightResizer.root.addEventListener("pointerdown", () => { mainPanelDragging = true; - const massH = massDocked() ? massHaul.overlay.offsetHeight : 0; const tableH = tableOverlay.isOpen() ? tableOverlay.overlay.offsetHeight : 0; mainDragRef = { - chart: Math.max( - MIN_CHART_HEIGHT, - body.clientHeight - massH - tableH - MASSHAUL_HANDLE_GUTTER_PX, - ), - mass: massH, + chart: Math.max(MIN_CHART_HEIGHT, body.clientHeight - tableH - MASSHAUL_HANDLE_GUTTER_PX), table: tableH, }; }); @@ -193,9 +186,7 @@ export function createProfileDrag(ctx: ProfileDragContext): ProfileDrag { // 비례 연동 결과를 서브패널 저장 높이로 확정한다 — 이후 모든 redraw의 판정 // 기준이 이 값이 되어 멱등성이 유지된다(확정 없이는 손을 떼는 순간 예전 // 저장 높이로 튄다). 확정 후 인라인은 걷는다(변수가 같은 값을 담는다). - if (massDocked()) massHaul.commitHeight(massHaul.overlay.offsetHeight); if (tableOverlay.isOpen()) tableOverlay.commitHeight(tableOverlay.overlay.offsetHeight); - massHaul.overlay.style.height = ""; tableOverlay.overlay.style.height = ""; } mainDragRef = null; diff --git a/B05_Profile/B05_Profile_UI_Profile_Heights.ts b/B05_Profile/B05_Profile_UI_Profile_Heights.ts index fe6035427..3e299ea31 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Heights.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Heights.ts @@ -1,6 +1,7 @@ /* ============================================================================= * B05_Profile_UI_Profile_Heights.ts - * 종단 패널의 높이 배분 — 그래프·유토곡선·테이블이 한 패널 높이를 나눠 갖는 규칙. + * 종단 패널의 높이 배분 — 그래프·테이블이 한 본문 높이를 나눠 갖는 규칙. + * 유토곡선은 종단 열 아래 자기 판이라 본문 높이에서 이미 빠져 있다 — 나눔에 안 낀다(354-1). * * 패널 본체(B05_Profile_UI_Profile_Panel)가 700줄 한계에 닿아 분리했다. * 판정 기준은 **저장 높이**(리사이저 변수)이며 화면 높이가 아니다 — 화면 높이로 재면 @@ -8,14 +9,13 @@ * 드래그 플래그·상세 데이터 유무는 본체가 계속 들고 있고 여기서는 접근자로만 읽는다. * ========================================================================== */ -import { MASSHAUL_MIN_HEIGHT } from "./B05_Profile_UI_Profile_MassHaul"; import { TABLE_OVERLAY_MIN_HEIGHT } from "./B05_Profile_UI_Profile_TableOverlay"; import { STRUCTURE_LANE_HEIGHT_PX } from "./B05_Profile_UI_Structures_Marks"; import { frameOf } from "../common_util/common_util_view_window"; /** 그래프가 이보다 낮아지면 종단선을 읽을 수 없다 — 어떤 배분에서도 지키는 하한(px). */ const MIN_CHART_HEIGHT = 100; -/** 접힌 유토곡선 손잡이가 차지하는 바닥 여백(px). */ +/** 접힌 테이블 손잡이가 차지하는 바닥 여백(px). */ const MASSHAUL_HANDLE_GUTTER_PX = 13; /** 패널 자동 확대 상한 — 메인 리사이저 max와 같은 식이어야 한다(부모 높이 비율). */ const MAX_PANEL_HEIGHT_RATIO = 0.9; @@ -32,21 +32,15 @@ export interface HeightCascadeDeps { root: HTMLElement; /** 본문(그래프+서브패널이 들어가는 스크롤 영역). */ body: HTMLElement; - massHaul: { - overlay: HTMLElement; - desiredHeight: () => number; - syncHandle: () => void; - }; tableOverlay: { overlay: HTMLElement; isOpen: () => boolean; desiredHeight: () => number; - setBottomOffset: (value: number) => void; }; isMainDragging: () => boolean; isSubDragging: () => boolean; - /** 메인 드래그 시작 시점의 3영역 비율(없으면 null). */ - mainDragRef: () => { chart: number; mass: number; table: number } | null; + /** 메인 드래그 시작 시점의 두 영역 비율(없으면 null). */ + mainDragRef: () => { chart: number; table: number } | null; /** 손 뗀 직후 첫 재구성까지는 자동 확대를 막는다. */ isMainDragCooldown: () => boolean; /** 상세 데이터가 아직 없으면 경량 동기화를 건너뛴다. */ @@ -67,21 +61,17 @@ export function createHeightCascade(deps: HeightCascadeDeps): HeightCascade { // 구조물 알약 레인이 그래프 아래 한 줄을 차지한다 — 그만큼 먼저 떼어 놓고 나눈다 // (2026-08-17 사용자 지시 6: 그래프가 밀려도 되지만 여유는 최소). const available = Math.max(120, deps.body.clientHeight - STRUCTURE_LANE_HEIGHT_PX); - // 새 창으로 뗀 유토 판은 자리를 안 차지함 — 「열림 && 뿌리 안」(325-2). - const massOpen = !deps.massHaul.overlay.hidden && deps.root.contains(deps.massHaul.overlay); const tableOpen = deps.tableOverlay.isOpen(); // 판정 기준은 항상 **저장 높이**(리사이저 변수·기본값)다 — 임시 축소가 반영된 화면 // 높이(offsetHeight)로 재면 "줄임 → 충분해 보임 → 풀림 → 부족 → 다시 줄임"이 // 호출마다 번갈아 도는 진동이 되고, 풀리는 호출의 deficit 처리가 메인 패널을 // 멋대로 키운다(2026-08-06 분석: 튐·자동 확대의 핵심 원인). 저장 높이 기준이면 // 몇 번을 호출해도 같은 답이 나온다(멱등). - const massDesired = massOpen ? deps.massHaul.desiredHeight() : 0; const tableDesired = tableOpen ? deps.tableOverlay.desiredHeight() : 0; - let massHeight = massDesired; let tableOverlayHeight = tableDesired; const budget = Math.max(0, available - MIN_CHART_HEIGHT - MASSHAUL_HANDLE_GUTTER_PX); - // ── 메인 드래그 중: 3영역 비례 연동(2026-08-06 사용자 확정) ───────────── - // 드래그 시작 시점 비율(deps.mainDragRef())대로 종단·유토곡선·테이블을 같이 늘리고 + // ── 메인 드래그 중: 비례 연동(2026-08-06 사용자 확정) ───────────── + // 드래그 시작 시점 비율(deps.mainDragRef())대로 종단·테이블을 같이 늘리고 // 줄인다. 최소에 닿은 영역은 거기서 멈추고 남은 영역끼리 다시 비례 배분한다. // 손을 떼면 clearDragFlags가 이 결과를 저장 높이로 확정한다. const dragRef = deps.mainDragRef(); @@ -89,15 +79,12 @@ export function createHeightCascade(deps: HeightCascadeDeps): HeightCascade { const content = Math.max(0, available - MASSHAUL_HANDLE_GUTTER_PX); const items = [ { key: "chart", ref: Math.max(1, dragRef.chart), min: MIN_CHART_HEIGHT }, - ...(massOpen - ? [{ key: "mass", ref: Math.max(1, dragRef.mass), min: MASSHAUL_MIN_HEIGHT }] - : []), ...(tableOpen ? [{ key: "table", ref: Math.max(1, dragRef.table), min: TABLE_OVERLAY_MIN_HEIGHT }] : []), ]; // 고정 → 재배분 반복: 축소 배율로 최소를 뚫는 항목을 최소에 고정하고, 남은 - // 공간을 나머지 항목끼리 원래 비율로 나눈다. 항목이 3개라 최대 3회에 끝난다. + // 공간을 나머지 항목끼리 원래 비율로 나눈다. 항목이 2개라 최대 2회에 끝난다. const out: Record = {}; let pool = content; let active = items; @@ -116,50 +103,34 @@ export function createHeightCascade(deps: HeightCascadeDeps): HeightCascade { active = active.filter((item) => !pinned.includes(item)); } // 서브패널 상한(개별 리사이저 max와 같은 비율)은 안전상 유지 — 넘치면 종단이 흡수. - massHeight = massOpen ? Math.min(out.mass ?? 0, Math.round(available * 0.8)) : 0; tableOverlayHeight = tableOpen ? Math.min(out.table ?? 0, Math.round(available * 0.75)) : 0; - if (massOpen) deps.massHaul.overlay.style.height = `${massHeight}px`; if (tableOpen) deps.tableOverlay.overlay.style.height = `${tableOverlayHeight}px`; - // 손잡이는 바뀐 인라인 높이의 위 경계를 즉시 따라간다. - deps.massHaul.syncHandle(); - deps.tableOverlay.setBottomOffset(massHeight); - return { - chartHeight: Math.max(MIN_CHART_HEIGHT, content - massHeight - tableOverlayHeight), - }; + return { chartHeight: Math.max(MIN_CHART_HEIGHT, content - tableOverlayHeight) }; } if (deps.isSubDragging()) { // 서브패널 리사이저를 끄는 중이면 그 높이가 **사용자 의도**다 — 임시 축소(인라인)를 // 걷어 변수(드래그 값)가 그대로 보이게 하고, 자리가 모자라면 아래 deficit 처리로 // 메인 패널을 키운다. 변수는 드래그마다 갱신되므로 저장 높이 = 드래그 값이다. - deps.massHaul.overlay.style.height = ""; deps.tableOverlay.overlay.style.height = ""; - } else if (massDesired + tableDesired > budget) { - // ② 같이 줄이기 — 줄일 수 있는 여유분에 비례해 축소, 각자 최소 높이 하한. + } else if (tableDesired > budget) { + // ② 줄이기 — 줄일 수 있는 여유분만큼 축소, 최소 높이 하한. // 자리가 다시 늘면 ratio가 매끄럽게 0으로 줄어 저장 높이로 연속 복귀한다. - const massMin = massOpen ? MASSHAUL_MIN_HEIGHT : 0; const tableMin = tableOpen ? TABLE_OVERLAY_MIN_HEIGHT : 0; - const shrinkable = massDesired - massMin + (tableDesired - tableMin); - const over = massDesired + tableDesired - budget; + const shrinkable = tableDesired - tableMin; + const over = tableDesired - budget; const ratio = shrinkable > 0 ? Math.min(1, over / shrinkable) : 1; - massHeight = Math.round(massDesired - (massDesired - massMin) * ratio); tableOverlayHeight = Math.round(tableDesired - (tableDesired - tableMin) * ratio); // 임시 축소는 인라인 높이로만 — 리사이저 저장값(--변수·세션)은 건드리지 않아 // 패널을 다시 키우면 원래 높이로 돌아온다. - if (massOpen) deps.massHaul.overlay.style.height = `${massHeight}px`; if (tableOpen) deps.tableOverlay.overlay.style.height = `${tableOverlayHeight}px`; } else { // 공간 충분 — 임시 축소 해제(저장 높이로 복귀). - deps.massHaul.overlay.style.height = ""; deps.tableOverlay.overlay.style.height = ""; } - // 접힌 유토곡선 손잡이는 바닥 고정(순서상 테이블 아래에서 나옴), 접힌 테이블 손잡이는 - // 열린 유토곡선 위 경계(TableOverlay.syncHandlePosition). - deps.tableOverlay.setBottomOffset(massHeight); // ③' 최소까지 줄여도 모자라면(서브패널 펼침·서브패널 드래그로 자리가 부족한 경우) // 메인 패널을 키운다. 단 **메인 패널을 끄는 중과 손 뗀 직후 첫 재구성까지**는 절대 // 안 된다 — 포인터가 정한 높이를 되돌려 서로 밀고 당기는 진동이 생긴다. - const deficit = - MIN_CHART_HEIGHT + massHeight + tableOverlayHeight + MASSHAUL_HANDLE_GUTTER_PX - available; + const deficit = MIN_CHART_HEIGHT + tableOverlayHeight + MASSHAUL_HANDLE_GUTTER_PX - available; if (deficit > 1 && allowGrow && !deps.isMainDragging() && !deps.isMainDragCooldown()) { // 상한은 메인 리사이저 max와 **같은 식**이어야 한다 — 예전 window 92% 상한은 // 리사이저 상한(부모 90%)보다 높아, 자동 확대 직후 손잡이를 잡는 순간 낮은 @@ -174,7 +145,7 @@ export function createHeightCascade(deps: HeightCascadeDeps): HeightCascade { return { chartHeight: Math.max( MIN_CHART_HEIGHT, - available - massHeight - tableOverlayHeight - MASSHAUL_HANDLE_GUTTER_PX, + available - tableOverlayHeight - MASSHAUL_HANDLE_GUTTER_PX, ), }; } diff --git a/B05_Profile/B05_Profile_UI_Profile_MassHaul.ts b/B05_Profile/B05_Profile_UI_Profile_MassHaul.ts index f7349e98a..201cfa586 100644 --- a/B05_Profile/B05_Profile_UI_Profile_MassHaul.ts +++ b/B05_Profile/B05_Profile_UI_Profile_MassHaul.ts @@ -1,12 +1,12 @@ /* ============================================================================= * B05_Profile_UI_Profile_MassHaul.ts - * B05 유토곡선 — 종단면 패널 바닥에 붙는 **오버레이 서브패널**. + * B05 유토곡선 — 종단 열 아래 **자기 판**(354-1 · 배수유역도처럼 손잡이 · 높이 · 접기 따로). * * ── B06과 같은 그림이어야 한다 (2026-08-03 사용자 확정) ────────────── * 이 프로그램은 파일 입력 시 기본 설정값으로 B06 몫까지 미리 계산해 둔다 — 종횡단 상세 * 조회(`fetchSectionDetail`)가 지정 설계 없는 측점에 기본 설계를 즉석 계산해 얹으므로 * (`_attach_default_designs`), B05 진입 시점에 이미 **횡단 단면적 기반 정식 곡선**을 그릴 - * 수 있다. 그래서 B06과 똑같이 `computeMassHaulSeries`(횡단 기준 + 종단 개략 비교)를 쓰고, + * 수 있다. 그래서 B06과 똑같이 `computeMassHaulSeries`(횡단 기준만 · 종단 기준 숨김 354-2)를 쓰고, * 범례·토량 분배·balloon·요약도 전부 공용 모듈 그대로 쓴다. * * 표시 토글 sessionStorage 키도 B06과 **공유**한다 — 두 화면은 같은 그림의 두 창이라 @@ -14,12 +14,11 @@ * 같은 영구저장소(`longitudinal_sections.data.mass_haul`)를 읽으므로, B06에서 횡단을 * 고쳐 임시저장/확정하면 B05는 재조회만으로 같은 곡선을 받는다. * - * ── 배치 (2026-08-04 사용자 확정: 오버레이 전환) ───────────────────── - * 예전에는 펼치면 종단 그래프와 세로 1:1 분할 + 도면 테이블 숨김이었다. 이제는 종단도· - * 테이블 배치를 **건드리지 않고**, 패널 바닥에 붙는 오버레이가 그 위를 덮는다. 높이는 - * 오버레이 위 경계의 리사이저(메인 하단 패널과 같은 `createPanelResizer`)로 조절하고 - * 세션 동안 유지한다. 곡선 SVG는 오버레이 안 자체 가로 스크롤러에 들어가며, 종단 - * 스크롤러와 scrollLeft를 양방향 동기화해 측점 세로선이 어긋나지 않게 한다. + * ── 배치 (354-1 · 요구 235-7 「유토곡선도 분리」) ───────────────────── + * 종단 열(그래프 · 표) 아래 칸에 따로 선다 — 종단도 · 표 위를 덮지 않고 높이 나눔 · 표 쌓기에도 + * 안 낀다. 높이는 판 위 경계 리사이저(`createPanelResizer`)로 조절하고 세션 동안 유지한다. + * 머리줄([창 분할] · 접기 손잡이)은 접어도 남는다. 곡선 SVG는 판 안 자체 가로 스크롤러에 + * 들어가며, 종단 스크롤러와 scrollLeft를 양방향 동기화해 측점 세로선이 어긋나지 않게 한다. * ========================================================================== */ import { readStateRaw, stateKey, writeStateRaw } from "../A00_Common/b_page_state"; @@ -56,7 +55,7 @@ import { createWorkflowPanelHandle } from "@ui/ui_template_overlay"; import { createPanelResizer } from "@ui/ui_template_resizer"; import "@util/common_util_mass_haul.css"; import "./B05_Profile_UI_Style_MassHaul.css"; -import { attachWheelHorizontalScroll } from "./B05_Profile_UI_Profile_Wheel"; +import { attachWheelHorizontalScroll, linkScrollLeft } from "./B05_Profile_UI_Profile_Wheel"; import { frameOf } from "../common_util/common_util_view_window"; /** 2차 패널 펼침 여부 — 세션 동안만 유지(패널 높이·접힘과 같은 수명). */ @@ -65,12 +64,10 @@ import { frameOf } from "../common_util/common_util_view_window"; const OVERLAY_HEIGHT_KEY = stateKey("masshaul-height") ?? ""; /** 오버레이 높이 하한/기본값(px). 곡선 + 요약 막대가 읽히는 최소 크기다. */ const OVERLAY_MIN_HEIGHT = 160; -/** 유토곡선 오버레이 최소 높이 — 메인 패널 하한 계산(Profile_Panel)이 함께 쓴다. */ -export const MASSHAUL_MIN_HEIGHT = OVERLAY_MIN_HEIGHT; const OVERLAY_DEFAULT_HEIGHT = 280; /** 전체 맞춤(토량 분배) 보기의 우측 여백(px) — 마지막 점의 값 글자가 잘리지 않을 만큼만. */ const MASS_FIT_PAD_RIGHT = 12; -/** 오버레이가 패널 본문을 다 덮지 않게 남기는 상한 비율. */ +/** 판이 종단 열을 다 차지하지 않게 남기는 상한 비율. */ const OVERLAY_MAX_RATIO = 0.8; /** 리사이저가 높이를 담는 CSS 변수 — CSS 기본값(280px)은 OVERLAY_DEFAULT_HEIGHT와 같아야 한다. */ const HEIGHT_VAR = "--b05-masshaul-height"; @@ -147,24 +144,18 @@ export interface RouteMassHaulDrawParams { } export interface RouteMassHaulPanel { - /** 종단면 패널 바닥에 붙는 2차 슬라이드 손잡이(펼치기/접기). */ + /** 판 머리줄의 접기 손잡이(펼치기/접기) — 판 안에 있다. */ handle: HTMLElement; - /** 오버레이 서브패널 — 호출한 쪽이 `position: relative`인 패널 본문 래퍼에 붙인다. */ + /** 유토곡선 판 — 호출한 쪽이 종단 열 아래(`b05-route-profile__column` 끝)에 붙인다. */ overlay: HTMLElement; isOpen(): boolean; - /** 저장된 오버레이 높이(px) — 리사이저 변수(세션)나 기본값. 임시 축소(인라인)는 무시한다. - * Panel의 높이 캐스케이드가 호출마다 같은 판정을 내리는 기준(2026-08-06 진동 수정). */ - desiredHeight(): number; - /** 저장 높이를 확정한다(리사이저와 같은 변수+세션 기록) — 메인 패널 비례 연동이 - * 드래그를 마친 높이를 앞으로의 기준으로 못 박는 데 쓴다(2026-08-06 사용자 확정). */ - commitHeight(px: number): void; - /** 손잡이를 오버레이 위 경계로 다시 맞춘다 — 캐스케이드가 인라인 높이를 바꾼 직후 호출. */ - syncHandle(): void; setContext(next: RouteMassHaulContext | null): void; /** 종단 가로 스크롤러와 scrollLeft를 양방향 동기화한다(설치 1회). */ attachScrollSync(main: HTMLElement): void; - /** 오버레이 내용을 다시 그린다. 접혀 있으면 오버레이만 숨기고 끝낸다. */ + /** 판 내용을 다시 그린다. 접혀 있으면 머리줄만 남기고 끝낸다. */ draw(params: RouteMassHaulDrawParams): void; + /** 가로 스크롤로 보이는 구간만 바뀜 — 창을 따를 때만 · 한 틀 한 번 · 다시 셈 없음(354-3). */ + drawWindow(params: RouteMassHaulDrawParams): void; } function readVisible(): Set { @@ -173,8 +164,11 @@ function readVisible(): Set { if (!raw) return new Set(DEFAULT_VISIBLE); const parsed = JSON.parse(raw) as unknown; // 기준 키는 라디오라 항상 하나로 눌러 맞춘다(B06 읽기와 같은 규칙). + // 종단 기준은 안 그림(354-2) — 저장된 범례가 종단이면 횡단으로 맞춘다. return Array.isArray(parsed) - ? normalizeVisibleBasis(new Set(parsed.map(String))) + ? normalizeVisibleBasis( + new Set(parsed.map((key) => (key === "longitudinal" ? "cross" : String(key)))), + ) : new Set(DEFAULT_VISIBLE); } catch { return new Set(DEFAULT_VISIBLE); @@ -205,8 +199,12 @@ export function createRouteMassHaulPanel( handle.className = "b05-profile__masshaul-handle"; handle.append(handleControl.root); handleControl.root.setAttribute("aria-label", "유토곡선 패널"); + // 머리줄 — 접어도 남아 [창 분할]([attachSubPanelPopouts] 가 맨 앞에 붙임) · 손잡이가 닿는다(354-1). + const head = document.createElement("div"); + head.className = "b05-profile__masshaul-head"; + head.append(handle); - // 오버레이 뼈대 — 위 경계 리사이저 + 안내 문구 자리 + 가로 스크롤러 + 범례. + // 판 뼈대 — 위 경계 리사이저 + 머리줄 + 안내 문구 자리 + 가로 스크롤러 + 범례. // (요약 막대는 2026-09-06 종단 상단줄로 올라갔다.) const overlay = document.createElement("div"); overlay.className = "b05-profile__masshaul-overlay"; @@ -233,8 +231,7 @@ export function createRouteMassHaulPanel( max: () => (overlay.parentElement?.clientHeight ?? window.innerHeight) * OVERLAY_MAX_RATIO, storageKey: OVERLAY_HEIGHT_KEY, onResize: () => { - // 손잡이는 경계를 즉시 따라가고(가벼움), 전체 redraw는 프레임당 한 번만. - syncHandlePosition(); + // 전체 redraw는 프레임당 한 번만. if (resizeRedrawPending) return; resizeRedrawPending = true; frameOf(overlay, () => { @@ -249,20 +246,14 @@ export function createRouteMassHaulPanel( pendingChip.className = "b05-profile__masshaul-pending"; pendingChip.textContent = "다시 계산 중…"; pendingChip.hidden = true; - overlay.append(resizer.root, bar, scroll, legendLayer, pendingChip); + overlay.append(resizer.root, head, bar, scroll, legendLayer, pendingChip); // 종단 그래프 영역과 같은 문법 — 세로 휠을 가로 이동으로 돌린다(2026-08-04 사용자 지시). // scrollLeft 동기화 덕에 위 종단 그래프도 함께 움직인다. Shift+휠은 브라우저 기본 그대로. attachWheelHorizontalScroll(scroll); - /** - * 손잡이를 오버레이 **위 경계**에 태운다(2026-08-04 사용자 지시 — 펼치면 같이 올라와 - * 테이블 영역을 침범해도 된다). 접혀 있으면 패널 바닥으로 돌아온다 — 유토곡선은 서브패널 - * 순서상 맨 아래라 테이블이 열려 있어도 버튼은 바닥에 남는다(2026-08-05 사용자 확정). - */ - function syncHandlePosition(): void { - handle.style.bottom = open ? `${overlay.offsetHeight}px` : "0px"; - } + /** 곡선을 그리는가 — 펼침이거나 새 창으로 뗌(뗀 창엔 접기가 없음 · 354-1). */ + const shown = (): boolean => open || overlay.classList.contains("is-popout"); function applyOpen(next: boolean): void { open = next; @@ -271,14 +262,14 @@ export function createRouteMassHaulPanel( // 툴팁은 setOpen이 일반 문구로 덮으므로 매번 유토곡선용으로 다시 밝힌다. handleControl.root.title = next ? "유토곡선 접기" : "유토곡선 펼치기"; handle.classList.toggle("is-open", next); - overlay.hidden = !next; - syncHandlePosition(); + // 접으면 머리줄만 남는다(배수유역도처럼 손잡이는 늘 닿음) + overlay.classList.toggle("is-collapsed", !next); onChanged(); } handleControl.setOpen(open); handleControl.root.title = open ? "유토곡선 접기" : "유토곡선 펼치기"; handle.classList.toggle("is-open", open); - overlay.hidden = !open; + overlay.classList.toggle("is-collapsed", !open); handleControl.root.addEventListener("click", () => applyOpen(!open)); function toggleSeries(key: string): void { @@ -288,32 +279,29 @@ export function createRouteMassHaulPanel( onChanged(); } - // 종단 스크롤러와의 양방향 동기화 — 재진입 루프는 플래그로 끊는다. + // 종단 스크롤러와의 양방향 동기화 — 메아리는 공용 도우미가 걸러 냄(354-3). // 토량 분배(전체 맞춤) 중에는 끊는다 — 유토곡선이 0 으로 붙으며 낸 scroll 이 종단 그래프 · // 표까지 0 으로 되돌렸다(PLAN 79-1). 유토곡선만 스크롤이 없는 상태. - let syncing = false; let fitted = false; - function mirror(from: HTMLElement, to: HTMLElement): void { - from.addEventListener("scroll", () => { - if (syncing || fitted) return; - syncing = true; - to.scrollLeft = from.scrollLeft; - syncing = false; - }); - } let syncedMain: HTMLElement | null = null; + let putScroll: ((to: HTMLElement, left: number) => void) | null = null; + /** 종단과 맞물려 그린 곡선이 놓인 문서 — 처음 · 분배 맞춤 뒤 · 창 옮김 뒤면 종단 자리로 맞춘다. */ + let syncedIn: Document | null = null; function attachScrollSync(main: HTMLElement): void { if (syncedMain === main) return; syncedMain = main; - mirror(main, scroll); - mirror(scroll, main); + putScroll = linkScrollLeft(main, scroll, () => fitted); } /** 곡선을 한 번이라도 그렸는지 — 재계산 대기 중 화면을 비우지 않기 위한 표시. */ let drawnOnce = false; + /** 마지막으로 낸 계열 — 가로 스크롤(보이는 구간만 바뀜)은 다시 셈 없이 이걸로 그린다(354-3). */ + let lastSeries: MassHaulSeries[] | null = null; function note(message: string): void { drawnOnce = false; + lastSeries = null; + syncedIn = null; pendingChip.hidden = true; bar.replaceChildren(); scroll.replaceChildren(); @@ -360,18 +348,33 @@ export function createRouteMassHaulPanel( ); } - /** 그리기 몫 기록(349-0s) — 묶음 마지막 차례 `work.유토곡선`. */ - function draw(params: RouteMassHaulDrawParams): void { - pipelineWork("유토곡선", () => drawNow(params)); + /** 그리기 몫 기록(349-0s) — 묶음 마지막 차례 `work.유토곡선`. `reuse` = 마지막 계열로(다시 셈 없음). */ + function draw(params: RouteMassHaulDrawParams, reuse = false): void { + pipelineWork("유토곡선", () => drawNow(params, reuse)); } - function drawNow(params: RouteMassHaulDrawParams): void { + /** 가로 스크롤로 보이는 구간만 바뀜(354-3) — 곡선이 창을 따를 때만(열림 · 분배 맞춤 아님 · 붙음) · + * 한 틀 한 번 · 다시 셈 없이 세로 창만 다시 그림. 접힘 · 분배 · 새 창은 스크롤마다 할 일 없음. */ + let windowDirty = false; + function drawWindow(params: RouteMassHaulDrawParams): void { + if (!open || fitted || overlay.classList.contains("is-popout") || !lastSeries) return; lastParams = params; - overlay.hidden = !open; - syncHandlePosition(); + // 미끄러짐 틀이 이미 잡혀 있으면 그 틀이 이 값으로 그림 + if (windowDirty || windowState.frame) return; + windowDirty = true; + frameOf(overlay, () => { + if (windowDirty && lastParams) draw(lastParams, true); + }); + } + + function drawNow(params: RouteMassHaulDrawParams, reuse: boolean): void { + lastParams = params; + windowDirty = false; + overlay.classList.toggle("is-collapsed", !open); // 접혀 있어도 상단줄에는 값이 서야 한다 — 이때는 **횡단 기준만** 적분한다. - // 펼친 경우에는 아래에서 두 기준을 내므로 그 결과로 총괄값을 낸다(계산 한 번). - if (!open) { + // 펼친 경우에는 아래에서 낸 계열로 총괄값을 낸다(계산 한 번). + if (!shown()) { + syncedIn = null; reportSummary(params); return; } @@ -400,23 +403,29 @@ export function createRouteMassHaulPanel( .map((chainage) => `${chainage.toFixed(2)} m`) .join(" · ")}${stale.length > 5 ? " …" : ""}` : "다시 계산 중…"; - // B06과 같은 계산 — 횡단 기준(정식)과 종단 기준(개략 비교)을 함께 낸다. + // B06과 같은 계산 — 횡단 기준(정식)만 낸다. 종단 기준은 안 셈 · 안 그림 · 범례 단추 없음 + // (354-2 · 계산 코드는 공용에 남김). // 결과를 아껴 두지 **않는다** — 횡단 설계가 제자리에서 갈리므로 객체 동일성으로는 // 바뀐 것을 못 잰다(2026-09-04 캐시를 넣었다가 곡선이 안 따라와 되돌림). - const series: MassHaulSeries[] = computeMassHaulSeries( - params.longitudinal, - params.crossSections, - context.conversion, - context.naturalSpoilMinSlope, - ); + // 스크롤로 창만 바뀐 그리기(`reuse`)는 직전 계열 그대로 — 같은 입력의 두 번째 적분이었다(354-3). + const series: MassHaulSeries[] = + reuse && lastSeries + ? lastSeries + : computeMassHaulSeries( + params.longitudinal, + params.crossSections, + context.conversion, + context.naturalSpoilMinSlope, + ["cross"], + ); // 총괄값은 방금 낸 계열에서 꺼낸다 — 접힘 경로처럼 따로 적분하지 않는다. - emitSummary(series); + if (series !== lastSeries) emitSummary(series); + lastSeries = series; if (!series.length) { note("횡단 설계가 아직 없어 유토곡선을 그릴 수 없습니다."); return; } bar.replaceChildren(); - legendLayer.replaceChildren(); const visible = readVisible(); // 토량 분배는 켜 둔 첫 곡선(정식 우선)에만 얹는다 — B06과 같은 규칙. const banded = series.find((entry) => visible.has(entry.key)); @@ -486,24 +495,30 @@ export function createRouteMassHaulPanel( wrap.append(chart); // 앵커는 첫 자식 — SVG 뒤면 흐름 위치가 밀려 스크롤 시 축이 안 보인다(B06과 같은 규칙). if (massAxis) wrap.prepend(buildStickyYAxis(massAxis, chartHeight)); + // 이미 종단과 맞물려 그린 곡선이면 제 자리 그대로 — 종단 값으로 되쓰면 그 사이 유토 쪽 휠이 + // 더 간 만큼 뒤로 튐(354-3). 처음 · 분배 맞춤 뒤 · 창 옮김 뒤만 종단 자리에서 시작한다. + const keepLeft = !fitAll && syncedIn === scroll.ownerDocument ? scroll.scrollLeft : null; scroll.replaceChildren(wrap); - // 종단 스크롤러가 이미 옆으로 가 있으면 새로 그린 곡선도 같은 자리에서 시작한다. // 전체 맞춤일 때는 스크롤할 것이 없으므로 왼쪽 끝에 둔다. if (fitAll) scroll.scrollLeft = 0; - else if (syncedMain) scroll.scrollLeft = syncedMain.scrollLeft; + else if (keepLeft !== null) scroll.scrollLeft = keepLeft; + else if (syncedMain) putScroll?.(scroll, syncedMain.scrollLeft); + syncedIn = fitAll ? null : scroll.ownerDocument; - legendLayer.append( - createMassHaulLegend(series, visible, toggleSeries, () => { - resetBalloonOffsets(); - onChanged(); - }), - ); + // 범례는 계열 · 켬이 바뀔 때만 — 스크롤 그리기는 그대로 둔다. + if (!reuse || !legendLayer.firstChild) + legendLayer.replaceChildren( + createMassHaulLegend(series, visible, toggleSeries, () => { + resetBalloonOffsets(); + onChanged(); + }), + ); drawnOnce = true; // 세로창이 아직 목표까지 안 갔으면 다음 프레임에 한 걸음 더 — 확 튀지 않고 미끄러진다. scheduleMassHaulSettle( windowState, () => { - if (lastParams) draw(lastParams); + if (lastParams) draw(lastParams, true); }, overlay, ); @@ -512,22 +527,12 @@ export function createRouteMassHaulPanel( return { handle, overlay, - isOpen: () => open, - desiredHeight() { - const raw = parseFloat(overlay.style.getPropertyValue(HEIGHT_VAR)); - return Number.isFinite(raw) && raw > 0 ? raw : OVERLAY_DEFAULT_HEIGHT; - }, - commitHeight(px) { - const next = Math.round(px); - overlay.style.setProperty(HEIGHT_VAR, `${next}px`); - writeStateRaw("masshaul-height", String(next)); - syncHandlePosition(); - }, - syncHandle: () => syncHandlePosition(), + isOpen: shown, setContext(next) { context = next; }, attachScrollSync, - draw, + draw: (params) => draw(params), + drawWindow, }; } diff --git a/B05_Profile/B05_Profile_UI_Profile_Render.ts b/B05_Profile/B05_Profile_UI_Profile_Render.ts index dc5f5ba8b..d0a4fad59 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Render.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Render.ts @@ -64,7 +64,10 @@ export interface ProfileRenderContext { * 돌려주는 값 = 다시 셈을 기다리는 중(false 면 남은 낡음 — 곡선은 그리고 그 측점만 표시 · 342-1). */ onStaleDesigns?: (key: string) => boolean; callbacks: RouteProfilePanelCallbacks | undefined; - massHaul: { draw: (params: RouteMassHaulDrawParams) => void }; + massHaul: { + draw: (params: RouteMassHaulDrawParams) => void; + drawWindow: (params: RouteMassHaulDrawParams) => void; + }; tableOverlay: { isOpen: () => boolean; contentHeight: () => number; @@ -452,9 +455,9 @@ export function renderProfile(ctx: ProfileRenderContext): void { // 유토곡선도 **같은 창**을 본다(2026-09-04 사용자 확정) — 스크롤할 때마다 곡선만 다시 // 그린다. 종단처럼 변환으로 옮기지 않는 이유: 곡선 위에 앉는 말풍선·EP 표·측점 점이 // 세로로 늘어나면 안 되는 것들이라, 그것만 따로 옮기는 값이 곡선 자체를 다시 그리는 - // 값보다 크다(요소 수가 종단의 1/3). + // 값보다 크다(요소 수가 종단의 1/3). 열림 · 분배 맞춤 아닐 때만 · 한 틀 한 번 · 다시 셈 없음(354-3). massHaulUpdater = (fromM, toM) => { - massHaul.draw({ + massHaul.drawWindow({ ...massHaulParams, axis: { ...massHaulParams.axis, viewRange: { fromM, toM } }, }); diff --git a/B05_Profile/B05_Profile_UI_Profile_TableOverlay.ts b/B05_Profile/B05_Profile_UI_Profile_TableOverlay.ts index e206a08f0..0ae4b90c5 100644 --- a/B05_Profile/B05_Profile_UI_Profile_TableOverlay.ts +++ b/B05_Profile/B05_Profile_UI_Profile_TableOverlay.ts @@ -4,17 +4,15 @@ * 오버레이 서브패널로 감싼다(2026-08-05 사용자 지시). * * - 접힘/펼침 표준 삼각형 손잡이 + 위 경계 리사이저 + 세션 보존. - * - 유토곡선 오버레이가 함께 열리면 그 **위**에 쌓인다 — 위→아래 순서가 - * 종단도 → 테이블 → 유토곡선이 되도록 setBottomOffset으로 바닥 간격을 받는다. - * - 접혔을 때 손잡이는 유토곡선 손잡이와 가로로 나란히(일렬) 놓인다(CSS에서 - * 가로 오프셋). 테이블 내용물은 Profile_Panel의 draw()가 만들어 setTable로 넣는다. + * - 종단 칸 바닥에 붙는다 — 유토곡선은 그 아래 자기 판이라 쌓지 않는다(354-1). + * - 테이블 내용물은 Profile_Panel의 draw()가 만들어 setTable로 넣는다. * ========================================================================== */ import { readStateRaw, stateKey, writeStateRaw } from "../A00_Common/b_page_state"; import { createWorkflowPanelHandle } from "@ui/ui_template_overlay"; import { createPanelResizer } from "@ui/ui_template_resizer"; import { TABLE_ROW_COUNT } from "./B05_Profile_UI_Profile_Layout"; -import { attachWheelHorizontalScroll } from "./B05_Profile_UI_Profile_Wheel"; +import { attachWheelHorizontalScroll, linkScrollLeft } from "./B05_Profile_UI_Profile_Wheel"; import { frameOf } from "../common_util/common_util_view_window"; /* 펼침·높이는 화면 취향(①) — 키는 등록표(`b_page_state`)가 만든다. */ @@ -54,8 +52,6 @@ export interface RouteProfileTableOverlay { setTable: (table: HTMLElement | null) => void; /** 종단 그래프 스크롤러와 가로 스크롤 양방향 동기화. */ attachScrollSync: (main: HTMLElement) => void; - /** 유토곡선 오버레이가 차지한 바닥 높이(px). 그 위로 쌓인다. */ - setBottomOffset: (px: number) => void; } export function createProfileTableOverlay(onChanged: () => void): RouteProfileTableOverlay { @@ -73,7 +69,6 @@ export function createProfileTableOverlay(onChanged: () => void): RouteProfileTa // 상태 선언은 리사이저 생성보다 먼저 — 세션 높이 복원이 생성 중 onResize를 부른다 // (유토곡선 TDZ 크래시와 같은 함정, 2026-08-04 확인). let open = readStateRaw("table-open") !== "false"; // 기본 펼침(기존 테이블 상시 표시 유지) - let bottomOffset = 0; let resizeRedrawPending = false; const resizer = createPanelResizer({ axis: "vertical", @@ -102,11 +97,9 @@ export function createProfileTableOverlay(onChanged: () => void): RouteProfileTa // 유토곡선 영역과 같은 문법 — 세로 휠을 가로 이동으로 돌린다(종단 스크롤도 함께 움직인다). attachWheelHorizontalScroll(scroll); - /** 손잡이는 오버레이 위 경계를, 접히면 열린 유토곡선의 위 경계(bottomOffset)를 따라간다 - * — 접힌 버튼이 펼쳐진 패널 수평선에 놓인다(2026-08-05 사용자 지시). */ + /** 손잡이는 오버레이 위 경계를, 접히면 종단 칸 바닥을 따라간다(2026-08-05 사용자 지시). */ function syncHandlePosition(): void { - handle.style.bottom = open ? `${bottomOffset + overlay.offsetHeight}px` : `${bottomOffset}px`; - overlay.style.bottom = `${bottomOffset}px`; + handle.style.bottom = open ? `${overlay.offsetHeight}px` : "0px"; } function applyOpen(next: boolean): void { @@ -125,16 +118,7 @@ export function createProfileTableOverlay(onChanged: () => void): RouteProfileTa overlay.hidden = !open; handleControl.root.addEventListener("click", () => applyOpen(!open)); - // 종단 스크롤러와의 양방향 동기화 — 재진입 루프는 플래그로 끊는다(유토곡선과 동일). - let syncing = false; - function mirror(from: HTMLElement, to: HTMLElement): void { - from.addEventListener("scroll", () => { - if (syncing) return; - syncing = true; - to.scrollLeft = from.scrollLeft; - syncing = false; - }); - } + // 종단 스크롤러와의 양방향 동기화 — 메아리는 공용 도우미가 걸러 냄(유토곡선과 같음 · 354-3). let syncedMain: HTMLElement | null = null; return { @@ -161,12 +145,7 @@ export function createProfileTableOverlay(onChanged: () => void): RouteProfileTa attachScrollSync(main) { if (syncedMain === main) return; syncedMain = main; - mirror(main, scroll); - mirror(scroll, main); - }, - setBottomOffset(px) { - bottomOffset = px; - syncHandlePosition(); + linkScrollLeft(main, scroll); }, }; } diff --git a/B05_Profile/B05_Profile_UI_Profile_Wheel.ts b/B05_Profile/B05_Profile_UI_Profile_Wheel.ts index f1298db83..cdbfef24f 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Wheel.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Wheel.ts @@ -39,6 +39,35 @@ const BASE_FRAME_MS = 1000 / 60; * 쓰면 한 번에 목표까지 튄다. */ const MAX_FRAME_MS = 64; +/** + * 두 스크롤러의 가로 자리를 서로 따라가게 잇는다 — 종단 그래프 ↔ 유토곡선 · 표(354-3). + * 써 넣은 값으로 **다음 틀에** 오는 scroll(메아리)은 무시한다. 동기 플래그로 막던 예전 길은 + * 메아리가 한 틀 전 값을 되써 앞서 간 쪽을 뒤로 튀게 했고, 그 튐이 휠 이동을 접어 끊겼다. + * `paused` 가 참이면 안 따라감. 돌려주는 `put` = 따라가는 쪽에 손으로 써 넣을 때(메아리 표시). + */ +export function linkScrollLeft( + a: HTMLElement, + b: HTMLElement, + paused: () => boolean = () => false, +): (to: HTMLElement, left: number) => void { + const echo = new Map(); + const put = (to: HTMLElement, left: number): void => { + const before = to.scrollLeft; + to.scrollLeft = left; + if (to.scrollLeft !== before) echo.set(to, to.scrollLeft); + }; + const follow = (from: HTMLElement, to: HTMLElement): void => + from.addEventListener("scroll", () => { + const wrote = echo.get(from); + echo.delete(from); + if (wrote !== undefined && Math.abs(from.scrollLeft - wrote) < 1) return; + if (!paused() && to.scrollLeft !== from.scrollLeft) put(to, from.scrollLeft); + }); + follow(a, b); + follow(b, a); + return put; +} + export function attachWheelHorizontalScroll(scroller: HTMLElement): void { let target = -1; /** 우리가 마지막으로 써 넣은 값 — 남이 자리를 옮겼는지 가리는 데 쓴다. */ diff --git a/B05_Profile/B05_Profile_UI_Style_MassHaul.css b/B05_Profile/B05_Profile_UI_Style_MassHaul.css index 02b120e32..ec55e8f1b 100644 --- a/B05_Profile/B05_Profile_UI_Style_MassHaul.css +++ b/B05_Profile/B05_Profile_UI_Style_MassHaul.css @@ -43,26 +43,68 @@ } /* 본문 칸을 세로로 쌓아 그래프 스크롤러 아래에 손잡이가 붙게 한다. - 오버레이의 절대배치 기준이기도 하다. */ + 테이블 겹침 판의 절대배치 기준이기도 하다. */ .b05-route-profile__body-wrap { position: relative; flex-direction: column; } -/* 오버레이 서브패널 — 패널 바닥(접기 손잡이 위)에 붙어 종단도·테이블 위를 덮는다 - (2026-08-04 사용자 확정). 높이는 위 경계 리사이저가 --b05-masshaul-height로 조절한다. */ -.b05-profile__masshaul-overlay { - position: absolute; - right: 0; - bottom: 0; /* 손잡이는 표준 삼각형(중앙, z-index 6)이라 자리를 차지하지 않는다 */ - left: 0; - z-index: 5; +/* 종단 열 = 종단 칸 + 그 아래 유토곡선 판(354-1) — 배수유역도는 열 오른쪽. */ +.b05-route-profile__column { display: flex; + flex: 1 1 auto; + flex-direction: column; + min-width: 0; + min-height: 0; +} + +/* 유토곡선 판 — 종단 칸 아래 자기 칸(354-1). 종단도 · 테이블을 덮지 않는다. + 높이는 위 경계 리사이저가 --b05-masshaul-height로 조절한다. */ +.b05-profile__masshaul-overlay { + position: relative; + display: flex; + flex: none; flex-direction: column; height: var(--b05-masshaul-height, 280px); border-top: 1px solid var(--color-border); background: var(--color-surface-raised); - box-shadow: 0 -4px 12px rgb(0 0 0 / 18%); +} + +/* 머리줄 — 펼침에선 곡선 위 여백(padTop)에 떠 자리를 안 먹고, 접으면 판에 머리줄만 남는다. */ +.b05-profile__masshaul-head { + position: absolute; + top: 4px; + right: 0; + left: var(--spacing-8); + z-index: 4; + display: flex; + align-items: center; + min-height: 24px; + pointer-events: none; +} + +.b05-profile__masshaul-head > * { + pointer-events: auto; +} + +.b05-profile__masshaul-overlay.is-collapsed:not(.is-popout) { + height: auto; +} + +.b05-profile__masshaul-overlay.is-collapsed:not(.is-popout) > :not(.b05-profile__masshaul-head) { + display: none; +} + +.b05-profile__masshaul-overlay.is-collapsed:not(.is-popout) > .b05-profile__masshaul-head { + position: relative; + top: 0; + left: 0; + padding: 2px var(--spacing-8); +} + +/* 종단 패널을 접으면 판도 함께 숨는다(배수유역도와 같음). */ +.b05-route-profile.is-collapsed .b05-profile__masshaul-overlay { + display: none; } /* 공용 리사이저는 절대배치라 오버레이 위 경계에 그대로 앉는다. */ @@ -142,13 +184,12 @@ font-size: var(--text-caption); } -/* 2차 슬라이드 손잡이 — 다른 패널들과 **같은 표준 삼각형 손잡이**를 그대로 쓴다 - (2026-08-04 사용자 지시). 패널 본문 바닥에 붙고, 오버레이(z-index 5) 위에 뜬다. - 접혔을 때 테이블 손잡이와 **일렬**로 놓이도록 중앙에서 우측으로 비켜 세운다 - (2026-08-05 사용자 지시 — 좌: 테이블, 우: 유토곡선). */ +/* 접기 손잡이 — 다른 패널들과 **같은 표준 삼각형 손잡이**를 그대로 쓴다 + (2026-08-04 사용자 지시). 판 머리줄에 앉아 접어도 닿는다(354-1). 테이블 손잡이와 + 겹치지 않게 중앙에서 우측으로 비켜 세운다(2026-08-05 사용자 지시 — 좌: 테이블, 우: 유토곡선). */ .b05-profile__masshaul-handle { position: absolute; - bottom: 0; + top: 0; left: calc(50% + 44px); z-index: 6; transform: translateX(-50%); @@ -163,8 +204,7 @@ } /* ── 테이블 오버레이 서브패널 (2026-08-05 사용자 지시) ────────────────────── - 유토곡선과 같은 뼈대. 유토곡선이 열려 있으면 그 위에 쌓인다(bottom은 JS가 조절). - 위→아래 순서: 종단도 → 테이블 → 유토곡선. */ + 종단 칸 바닥에 붙어 종단도 위를 덮는다. 유토곡선은 그 아래 자기 판(354-1). */ .b05-profile__table-overlay { position: absolute; right: 0; diff --git a/resources/tester/helper_224_1b_masshaul_docked.cjs b/resources/tester/helper_224_1b_masshaul_docked.cjs index 2c07b616a..c63b502e7 100644 --- a/resources/tester/helper_224_1b_masshaul_docked.cjs +++ b/resources/tester/helper_224_1b_masshaul_docked.cjs @@ -1,6 +1,6 @@ -/* 224-1b 유토 판 「붙어 있음」 판정 — 진짜 높이 나눔(`createHeightCascade`)과 방향키 판정 +/* 224-1b 유토 판 높이 나눔 · 방향키 판정 — 진짜 높이 나눔(`createHeightCascade`)과 방향키 판정 * (`arrowsBelongToProfile` 본문)을 가짜 DOM 위에서 돌려 JSON 으로 냄. - * · 열림 && 뿌리 안 = 자리 차지 · 열림 && 새 창(뿌리 밖) = 자리 0 · 인라인 높이 안 씀 + * · 유토곡선 = 종단 열 아래 자기 판(354-1) — 본문 높이에서 이미 빠져 나눔에 안 낌(입력에도 없음) * · 방향키 = 마지막 누름이 종단 뿌리 · 뗀 유토 · 뗀 배수 판 안이면 종단도 몫 */ const fs = require("fs"); const path = require("path"); @@ -44,33 +44,27 @@ new Function( )( 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 }, + id.includes("TableOverlay") + ? { TABLE_OVERLAY_MIN_HEIGHT: 120 } + : id.includes("Structures_Marks") + ? { STRUCTURE_LANE_HEIGHT_PX: 40 } + : { frameOf: () => 0 }, { innerHeight: 900 }, ); -function run(docked) { +function run(tableDesired) { 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 overlay = el("b05-profile__table-overlay"); const cascade = heightsExports.createHeightCascade({ root, body, - massHaul: { overlay, desiredHeight: () => 300, syncHandle() {} }, tableOverlay: { - overlay: el(), - isOpen: () => false, - desiredHeight: () => 0, - setBottomOffset() {}, + overlay, + isOpen: () => tableDesired > 0, + desiredHeight: () => tableDesired, }, isMainDragging: () => false, isSubDragging: () => false, @@ -99,8 +93,9 @@ const card = el("b06-cross-card"); process.stdout.write( JSON.stringify({ - docked: run(true), - popped: run(false), + tableClosed: run(0), + tableFits: run(200), + tableShrinks: run(300), arrows: { profile: arrows(profileChild), mass: arrows(massChild), diff --git a/resources/tester/helper_224_1c_subpanel_popout.cjs b/resources/tester/helper_224_1c_subpanel_popout.cjs index c48685322..62c799150 100644 --- a/resources/tester/helper_224_1c_subpanel_popout.cjs +++ b/resources/tester/helper_224_1c_subpanel_popout.cjs @@ -1,8 +1,8 @@ /* 224-1c 유토곡선 · 배수유역 [창 분할] — 진짜 `attachSubPanelPopouts` · `attachProfilePopout` * (design_profile_popout.ts)를 가짜 DOM · 가짜 창 위에서 돌려 JSON 으로 냄. * · 단추 자리 · 글 · 클래스 · 툴팁 · 창 이름 셋 다름 - * · 뗌 = 새 창 body · 유토 손잡이 숨김 · 인라인 높이 비움 · 배수 rehome - * · 붙임(단추 · 새 창 닫힘 · dispose) = 제자리(유토 손잡이 앞 · content 끝) + * · 단추 = 두 판 머리줄 맨 앞(유토는 접어도 남는 머리줄 · 354-1) + * · 뗌 = 새 창 body · 배수 rehome · 붙임(단추 · 새 창 닫힘 · dispose) = 제자리(종단 열 끝 · content 끝) * · 종단 새 창 안의 단추 = 그 창에서 엶 */ const fs = require("fs"); const path = require("path"); @@ -122,21 +122,25 @@ new Function("exports", "require", "window", "document", js)( mainDoc, ); -// ---- 종단 뿌리 = [content = [bodyWrap = [유토 판, 유토 손잡이], 배수 판 = [머리줄 = [제목]]]] ---- +// ---- 종단 뿌리 = [content = [열 = [종단 칸, 유토 판 = [머리줄 = [손잡이]]], 배수 판 = [머리줄 = [제목]]]] ---- const slot = el("div"); mainDoc.body.append(slot); const root = el("div", "b05-route-profile"); slot.append(root); const content = el("div", "b05-route-profile__content"); -const bodyWrap = el("div"); +const column = el("div", "b05-route-profile__column"); +const bodyWrap = el("div", "b05-route-profile__body-wrap"); const massOverlay = el("div", "b05-profile__masshaul-overlay"); +const massHead = el("div", "b05-profile__masshaul-head"); const massHandle = el("div", "b05-profile__masshaul-handle"); -bodyWrap.append(massOverlay, massHandle); +massHead.append(massHandle); +massOverlay.append(el("div", "ui-resizer"), massHead); +column.append(bodyWrap, massOverlay); const drainageRoot = el("aside", "b05-drainage"); const header = el("div", "b05-drainage__header"); header.append(el("h3")); drainageRoot.append(header); -content.append(bodyWrap, drainageRoot); +content.append(column, drainageRoot); root.append(content); let rehomes = 0; let disposed = 0; @@ -150,20 +154,18 @@ const profileButton = el("button"); mod.attachProfilePopout(profile, slot, profileButton); mod.attachSubPanelPopouts(profile); -const massButton = massOverlay.children.find((c) => c.tagName === "BUTTON"); +const massButton = massHead.children[0]; const drainButton = header.children[0]; const btn = (b) => ({ text: b.textContent, title: b.title, className: b.className, type: b.type }); const out = { mass: btn(massButton), drainage: btn(drainButton), profile: btn(profileButton) }; +out.massButtonInHead = massButton.tagName === "BUTTON" && massHead.children[1] === massHandle; // 유토 뗌 -massOverlay.style.height = "123px"; massButton.click(); const massPopup = massOverlay.parent?.doc?.defaultView; out.massOpened = { inPopupBody: massOverlay.parent === massPopup?.document.body, isPopout: massOverlay.classList.contains("is-popout"), - handleHidden: massHandle.hidden, - inline: massOverlay.style.height, text: massButton.textContent, docTitle: massPopup?.document.title, }; @@ -175,11 +177,10 @@ out.drainOpened = { rehomes, text: drainButton.textContent, }; -// 유토 새 창 닫힘 → 제자리(손잡이 바로 앞) +// 유토 새 창 닫힘 → 제자리(종단 열 끝) massPopup.fire("pagehide"); out.massClosed = { - order: bodyWrap.children.map((c) => c.className), - handleHidden: massHandle.hidden, + order: column.children.map((c) => c.className), isPopout: massOverlay.classList.contains("is-popout"), text: massButton.textContent, }; @@ -205,10 +206,8 @@ profile.dispose(); out.disposed = { closed: [profilePopup.closed, massPopup2.closed, drainPopup2.closed], profileInSlot: root.parent === slot, - massBeforeHandle: - bodyWrap.children.indexOf(massOverlay) === bodyWrap.children.indexOf(massHandle) - 1, + massInColumnEnd: column.children[column.children.length - 1] === massOverlay, drainInContent: drainageRoot.parent === content, - handleHidden: massHandle.hidden, original: disposed, }; out.opened = opened; diff --git a/resources/tester/helper_354_2_masshaul_cross_only.cjs b/resources/tester/helper_354_2_masshaul_cross_only.cjs new file mode 100644 index 000000000..8a688c9a6 --- /dev/null +++ b/resources/tester/helper_354_2_masshaul_cross_only.cjs @@ -0,0 +1,57 @@ +/* 354-2 종단 기준 숨김 — 진짜 `readVisible`(B05 유토곡선) · `normalizeVisibleBasis` · + * `computeMassHaulSeries`(공용)를 잘라 돌려 JSON 으로 냄. + * · 저장된 범례 = 종단 → 횡단으로 맞춤 · 분배 켬은 그대로 + * · 기준 ["cross"] = 횡단 계열 하나 · 기본(묶음 단계) = 두 기준 그대로 */ +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; +const block = (src, head) => { + const at = src.indexOf(head); + if (at < 0) throw new Error(`없음: ${head}`); + return src.slice(at, src.indexOf("\n}", at) + 2).replace(/^export /, ""); +}; + +const util = read("common_util", "common_util_mass_haul.ts"); +const mass = read("B05_Profile", "B05_Profile_UI_Profile_MassHaul.ts"); +let stored = null; +const src = [ + 'const SERIES_BASES = ["cross", "longitudinal"];', + 'const DEFAULT_VISIBLE = ["cross"];', + block(util, "export function normalizeVisibleBasis("), + block(mass, "function readVisible(): Set {"), + block(util, "export function computeMassHaulSeries("), +].join("\n"); +const api = new Function( + "readStateRaw", + "designedSections", + "crossAreaSamples", + "longitudinalAreaSamples", + "integrate", + `${transpile(src)}; return { readVisible, computeMassHaulSeries };`, +)( + () => stored, + (sections) => sections, + () => [{ basis: "cross" }], + () => [{ basis: "longitudinal" }], + (samples) => ({ from: samples[0].basis }), +); + +const visible = (raw) => ((stored = raw), [...api.readVisible()].sort()); +process.stdout.write( + JSON.stringify({ + longitudinal: visible('["longitudinal"]'), + longitudinalBalance: visible('["longitudinal","balance"]'), + cross: visible('["cross"]'), + empty: visible(null), + broken: visible("{"), + crossOnly: api.computeMassHaulSeries({}, [], {}, undefined, ["cross"]).map((s) => s.key), + both: api.computeMassHaulSeries({}, [], {}).map((s) => s.key), + }), +); diff --git a/resources/tester/helper_354_3_scroll_echo.cjs b/resources/tester/helper_354_3_scroll_echo.cjs new file mode 100644 index 000000000..e781836b9 --- /dev/null +++ b/resources/tester/helper_354_3_scroll_echo.cjs @@ -0,0 +1,121 @@ +/* 354-3 스크롤 동기화 메아리 재현 — 진짜 휠 이동(`attachWheelHorizontalScroll`)과 진짜 잇기 + * (`linkScrollLeft`)를 가짜 창 · 가짜 스크롤러 위에서 돌려 JSON 으로 냄. + * 가짜 창은 브라우저 차례 그대로 — scrollLeft 가 바뀌면 scroll 은 **다음 틀**에 오고(틀 앞머리), + * 그 뒤 화면 틀 콜백이 돎. 옛 잇기(동기 플래그)와 새 잇기를 같은 휠로 견줌. + * 경우 넷 = 표 켬/끔 × 분배(유토 전체 맞춤) 켬/끔 · 휠을 굴리는 쪽 = 종단 · 유토 둘. */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); + +const ROOT = path.join(__dirname, "..", ".."); +const js = ts.transpileModule( + fs.readFileSync(path.join(ROOT, "B05_Profile", "B05_Profile_UI_Profile_Wheel.ts"), "utf8"), + { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 } }, +).outputText; + +function fakeWindow() { + const win = { now: 0, frames: [], seq: 0, pending: new Set() }; + win.requestAnimationFrame = (fn) => (win.frames.push({ id: ++win.seq, fn }), win.seq); + win.cancelAnimationFrame = (id) => (win.frames = win.frames.filter((f) => f.id !== id)); + win.getSelection = () => ({ removeAllRanges() {} }); + win.frame = () => { + win.now += 16; + const targets = [...win.pending]; + win.pending.clear(); + targets.forEach((el) => el.fire("scroll")); + const due = win.frames; + win.frames = []; + due.forEach((f) => f.fn(win.now)); + }; + return win; +} + +function scroller(win, name, { hidden = false, scrollWidth = 6000 } = {}) { + const calls = {}; + let left = 0; + const el = { + name, + ownerDocument: { defaultView: win }, + clientWidth: hidden ? 0 : 1000, + scrollWidth: hidden ? 0 : scrollWidth, + scrollTop: 0, + style: {}, + addEventListener: (type, fn) => (calls[type] ??= []).push(fn), + removeEventListener() {}, + hasPointerCapture: () => false, + fire: (type, ev = {}) => { + const e = { preventDefault() {}, ...ev }; + (calls[type] ?? []).forEach((fn) => fn(e)); + }, + }; + Object.defineProperty(el, "scrollLeft", { + get: () => left, + set: (v) => { + const next = Math.min(Math.max(0, v), Math.max(0, el.scrollWidth - el.clientWidth)); + if (next !== left) win.pending.add(el); + left = next; + }, + }); + return el; +} + +/** 옛 잇기 — 유토곡선 · 표가 각자 들고 있던 동기 플래그 꼴 그대로. */ +function oldLink(a, b, paused = () => false) { + let syncing = false; + const mirror = (from, to) => + from.addEventListener("scroll", () => { + if (syncing || paused()) return; + syncing = true; + to.scrollLeft = from.scrollLeft; + syncing = false; + }); + mirror(a, b); + mirror(b, a); +} + +function run(kind, { table, balance, driver }) { + const win = fakeWindow(); + const wheel = { exports: {} }; + new Function("exports", "module", "require", "window", js)( + wheel.exports, + wheel, + () => ({ viewOf: () => win }), + win, + ); + const body = scroller(win, "body"); + // 분배 켬 = 유토곡선 전체 맞춤(스크롤 없음) · 잇기는 멈춤 + const mass = scroller(win, "mass", { scrollWidth: balance ? 1000 : 6000 }); + const tableEl = scroller(win, "table", { hidden: !table }); + const link = kind === "old" ? oldLink : wheel.exports.linkScrollLeft; + link(body, mass, () => balance); + link(body, tableEl); + const lead = driver === "mass" && !balance ? mass : body; + [body, mass, tableEl].forEach((el) => wheel.exports.attachWheelHorizontalScroll(el)); + const trace = []; + for (let i = 0; i < 60; i += 1) { + if (i < 20 && i % 2 === 0) lead.fire("wheel", { deltaY: 120 }); + win.frame(); + trace.push(body.scrollLeft); + } + let back = 0; + for (let i = 1; i < trace.length; i += 1) if (trace[i] < trace[i - 1] - 0.01) back += 1; + return { + back, + end: Math.round(body.scrollLeft), + lead: Math.round(lead.scrollLeft), + mass: Math.round(mass.scrollLeft), + table: Math.round(tableEl.scrollLeft), + }; +} + +const out = {}; +for (const kind of ["old", "new"]) + for (const driver of ["body", "mass"]) + for (const table of [true, false]) + for (const balance of [false, true]) + out[`${kind}|${driver}|table=${table}|balance=${balance}`] = run(kind, { + table, + balance, + driver, + }); +process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/test_224_1b_masshaul_docked.py b/resources/tester/test_224_1b_masshaul_docked.py index d537ad087..942ae8347 100644 --- a/resources/tester/test_224_1b_masshaul_docked.py +++ b/resources/tester/test_224_1b_masshaul_docked.py @@ -1,8 +1,8 @@ # -*- coding: utf-8 -*- -"""224-1b(PLAN 325-2) 유토곡선 판 「붙어 있음」 판정 · 방향키. +"""224-1b(PLAN 325-2 · 354-1) 유토곡선 판 높이 나눔 · 방향키. -- 높이 나눔 · 끌기는 「열림 && 종단 뿌리 안」만 자리를 줌 — 새 창으로 뗀 유토 판은 메인 자리 0 · - 임시 축소 인라인 높이도 안 씀(뗀 창 높이가 굳지 않게). +- 유토곡선 = 종단 열 아래 자기 판(354-1) — 높이 나눔 · 끌기에 안 낌(본문 높이에서 이미 빠짐) · + 인라인 임시 축소도 안 씀. 나눔은 그래프 · 표 둘. - 뗀 유토 판(`is-popout`)은 메인 보이는 구간 세로 범위(viewRange)를 안 씀 — 노선 전체 세로. - 방향키 ←→ 판정은 마지막 누름이 종단 뿌리 · 뗀 유토 · 뗀 배수 판 안이면 종단도 몫. @@ -34,13 +34,12 @@ def r(): 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): + """본문 400 − 알약 40 = 360 · 손잡이 띠 13 → 표 없음 그래프 347.""" + assert r["tableClosed"] == {"chartHeight": 347, "inline": ""} + assert r["tableFits"] == {"chartHeight": 147, "inline": ""} + # 표 300 은 예산 247 넘어 247 로 줄고 그래프 100 + assert r["tableShrinks"] == {"chartHeight": 100, "inline": "247px"} def test_방향키_판정(r): @@ -53,12 +52,15 @@ def test_방향키_판정(r): } -def test_끌기도_같은_판정(): +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 + heights = (B05 / "B05_Profile_UI_Profile_Heights.ts").read_text(encoding="utf-8") + for src in (drag, heights): + assert "massDocked" not in src and "MASSHAUL_MIN_HEIGHT" not in src + assert "mass:" not in src + # 유토 판 리사이저 끌기 출처만 봄 + assert drag.count("massHaul.overlay") == 1 + assert "massHaul" not in heights def test_뗀_유토는_노선_전체_세로(): diff --git a/resources/tester/test_224_1c_subpanel_popout.py b/resources/tester/test_224_1c_subpanel_popout.py index df926a112..eb8f2f684 100644 --- a/resources/tester/test_224_1c_subpanel_popout.py +++ b/resources/tester/test_224_1c_subpanel_popout.py @@ -1,10 +1,10 @@ # -*- coding: utf-8 -*- """224-1c(PLAN 325-3) 유토곡선 · 배수유역 [창 분할] — 종단 [창 분할]과 같은 양식 · 따로 새 창. -- 단추 = 유토 판 좌상단 · 배수 머리줄 맨 앞 · 글 [창 분할]/[창 결합] · 툴팁에 판 이름. +- 단추 = 두 판 머리줄 맨 앞(유토는 접어도 남는 머리줄 · 354-1) · 글 [창 분할]/[창 결합] · 툴팁에 판 이름. - 창 이름 셋 다름(같으면 앞 창 body 를 덮어 조각이 사라짐). -- 뗌 = 새 창 body · 유토 손잡이 숨김 · 인라인 높이 비움 · 배수 지도 관찰 rehome. -- 붙임(단추 · 새 창 닫힘 · 페이지 떠남) = 제자리 — 유토 손잡이 바로 앞 · 배수 content 끝. +- 뗌 = 새 창 body · 뗀 유토 판 손잡이 · 리사이저는 CSS 로 감춤 · 배수 지도 관찰 rehome. +- 붙임(단추 · 새 창 닫힘 · 페이지 떠남) = 제자리 — 종단 열 끝(종단 칸 아래) · 배수 content 끝. - 종단 새 창 안에서 다시 떼면 그 창이 새 창을 엶(메인 창 팝업 차단 피함). `helper_224_1c_subpanel_popout.cjs` 가 진짜 TS 를 가짜 DOM · 가짜 창 위에서 돌림. @@ -51,14 +51,13 @@ def test_단추_글_툴팁_클래스(r): "type": "button", } assert "b05-route-profile__popout" in r["profile"]["className"] + assert r["massButtonInHead"] is True def test_유토_뗌(r): assert r["massOpened"] == { "inPopupBody": True, "isPopout": True, - "handleHidden": True, - "inline": "", "text": "창 결합", "docTitle": "유토곡선 — Aislo", } @@ -70,8 +69,7 @@ def test_배수_뗌은_rehome(r): def test_새_창_닫힘과_단추로_제자리(r): assert r["massClosed"] == { - "order": ["b05-profile__masshaul-overlay", "b05-profile__masshaul-handle"], - "handleHidden": False, + "order": ["b05-route-profile__body-wrap", "b05-profile__masshaul-overlay"], "isPopout": False, "text": "창 분할", } @@ -90,9 +88,8 @@ def test_페이지_떠나면_셋_다_닫고_제자리(r): assert r["disposed"] == { "closed": [True, True, True], "profileInSlot": True, - "massBeforeHandle": True, + "massInColumnEnd": True, "drainInContent": True, - "handleHidden": False, "original": 1, } @@ -104,4 +101,5 @@ def test_배선과_양식(): block = re.search(r"\n\.b05-subpanel__popout \{([^}]*)\}", css).group(1) assert "var(--btn-bar-padding)" in block and "var(--btn-bar-font-size)" in block assert ".b05-profile__masshaul-overlay.is-popout," in css + assert ".b05-profile__masshaul-overlay.is-popout .b05-profile__masshaul-handle," in css assert ".b05-drainage.is-popout {" in css diff --git a/resources/tester/test_230_2_popout_frames.py b/resources/tester/test_230_2_popout_frames.py index a0f51bd03..b75a147be 100644 --- a/resources/tester/test_230_2_popout_frames.py +++ b/resources/tester/test_230_2_popout_frames.py @@ -67,7 +67,8 @@ def test_높이_맞춤(r): def test_표_유토곡선_크기_끌기_다시_그림(r): assert r["table"] == {"changed": 1, "handles": 2, "mainTimers": 0} - assert r["massHaul"] == {"changed": 1, "handles": 2, "mainTimers": 0} + # 유토 판 손잡이는 머리줄에 앉아 끌기 때 따라 옮길 것이 없음(354-1) + assert r["massHaul"] == {"changed": 1, "handles": 0, "mainTimers": 0} def test_유토곡선_세로창_미끄러짐(r): diff --git a/resources/tester/test_354_1_masshaul_own_panel.py b/resources/tester/test_354_1_masshaul_own_panel.py new file mode 100644 index 000000000..b92159eb4 --- /dev/null +++ b/resources/tester/test_354_1_masshaul_own_panel.py @@ -0,0 +1,85 @@ +# -*- coding: utf-8 -*- +"""354-1 유토곡선 = 배수유역도처럼 자기 판(요구 235-7). + +- 종단 열(`b05-route-profile__column`) = [종단 칸(bodyWrap), 유토 판] · content 안이라 종단 [창 분할] + (`.is-popout > :not(content)` 숨김)에도 같이 감 · B05 · B06 이 같은 `design_profile` 하나. +- 유토 판은 흐름 칸(겹침 아님) · 자기 리사이저 높이 · 접으면 머리줄만(손잡이 · [창 분할] 닿음). +- 높이 나눔 · 표 쌓기에서 빠짐(표 bottomOffset 없음) · X 동기화는 그대로. +""" + +import os +import re +from pathlib import Path + +HERE = os.path.dirname(os.path.abspath(__file__)) +ROOT = Path(HERE).parents[1] + + +def read(rel: str) -> str: + return (ROOT / rel).read_text(encoding="utf-8") + + +def css_block(css: str, selector: str) -> str: + m = re.search(r"(?:^|\n)" + re.escape(selector) + r" \{([^}]*)\}", css) + assert m, selector + return m.group(1) + + +def test_종단_열_아래_자기_칸(): + overlays = read("A00_Common/design/design_profile_overlays.ts") + assert 'column.className = "b05-route-profile__column";' in overlays + assert "column.append(bodyWrap, massHaul.overlay);" in overlays + assert "bodyWrap.append(massHaul.overlay" not in overlays + assert "massHaul.attachScrollSync(body);" in overlays + profile = read("A00_Common/design/design_profile.ts") + assert "content.append(column);" in profile + assert sum(1 for _ in profile.splitlines()) <= 700 + + +def test_종단_창_분할에도_안_숨음(): + page = read("A00_Common/design/design_page.css") + assert ".b05-route-profile.is-popout > :not(.b05-route-profile__content)" in page + # 유토 판은 뿌리 바로 아래가 아니라 content > column 안 + overlays = read("A00_Common/design/design_profile_overlays.ts") + assert "root.append" not in overlays + + +def test_흐름_칸_자기_높이_접으면_머리줄(): + css = read("B05_Profile/B05_Profile_UI_Style_MassHaul.css") + panel = css_block(css, ".b05-profile__masshaul-overlay") + assert "position: relative;" in panel and "absolute" not in panel + assert "height: var(--b05-masshaul-height, 280px);" in panel + assert "flex: none;" in panel + column = css_block(css, ".b05-route-profile__column") + assert "flex-direction: column;" in column + hide = css_block( + css, + ".b05-profile__masshaul-overlay.is-collapsed:not(.is-popout) > :not(.b05-profile__masshaul-head)", + ) + assert "display: none;" in hide + assert "display: none;" in css_block( + css, ".b05-route-profile.is-collapsed .b05-profile__masshaul-overlay" + ) + mass = read("B05_Profile/B05_Profile_UI_Profile_MassHaul.ts") + assert "head.append(handle);" in mass + assert "overlay.append(resizer.root, head, bar, scroll, legendLayer, pendingChip);" in mass + assert 'overlay.classList.toggle("is-collapsed", !next);' in mass + assert "overlay.hidden" not in mass + # 새 창으로 뗀 판은 접힘이어도 그림 + assert 'const shown = (): boolean => open || overlay.classList.contains("is-popout");' in mass + + +def test_창_분할_단추는_머리줄_맨_앞(): + popout = read("A00_Common/design/design_profile_popout.ts") + assert "massHead.prepend(button);" in popout + assert "home: () => column?.append(massOverlay)," in popout + page = read("A00_Common/design/design_page.css") + assert ".b05-profile__masshaul-overlay > .b05-subpanel__popout" not in page + + +def test_높이_나눔_표_쌓기에서_빠짐(): + heights = read("B05_Profile/B05_Profile_UI_Profile_Heights.ts") + table = read("B05_Profile/B05_Profile_UI_Profile_TableOverlay.ts") + assert "massHaul" not in heights + assert "setBottomOffset" not in heights and "setBottomOffset" not in table + assert "bottomOffset" not in table diff --git a/resources/tester/test_354_2_masshaul_cross_only.py b/resources/tester/test_354_2_masshaul_cross_only.py new file mode 100644 index 000000000..fe84be732 --- /dev/null +++ b/resources/tester/test_354_2_masshaul_cross_only.py @@ -0,0 +1,59 @@ +# -*- coding: utf-8 -*- +"""354-2 유토곡선 종단 기준 숨김 — 안 셈 · 안 그림 · 범례 단추 없음(코드 남김). + +- 그리기 = 횡단 기준만(`["cross"]`) · 범례는 낸 계열마다라 종단 단추가 안 섬. +- 저장된 범례가 종단(`longitudinal`)이면 횡단으로 맞춤 · 분배 켬은 그대로. +- 계산 묶음 단계(MassHaul_Stage)는 두 기준 그대로 · 공용 계산 코드 남김. + +`helper_354_2_masshaul_cross_only.cjs` 가 진짜 TS 를 잘라 돌림. +""" + +import json +import os +import re +import subprocess +from pathlib import Path + +import pytest + +HERE = os.path.dirname(os.path.abspath(__file__)) +ROOT = Path(HERE).parents[1] + + +@pytest.fixture(scope="module") +def r(): + proc = subprocess.run( + ["node", os.path.join(HERE, "helper_354_2_masshaul_cross_only.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["longitudinal"] == ["cross"] + assert r["longitudinalBalance"] == ["balance", "cross"] + assert r["cross"] == ["cross"] + assert r["empty"] == ["cross"] and r["broken"] == ["cross"] + + +def test_기준_횡단만_계산(r): + assert r["crossOnly"] == ["cross"] + assert r["both"] == ["cross", "longitudinal"] # 공용 기본값 = 묶음 단계 그대로 + + +def test_그리기는_횡단만_부름(): + mass = (ROOT / "B05_Profile/B05_Profile_UI_Profile_MassHaul.ts").read_text(encoding="utf-8") + calls = re.findall(r"computeMassHaulSeries\(([^;]*?)\)", mass, re.S) + assert calls and all('["cross"]' in call for call in calls) + + +def test_묶음_단계와_공용_코드는_그대로(): + stage = (ROOT / "B05_Profile/logic/B05_Profile_MassHaul_Stage.ts").read_text(encoding="utf-8") + assert '["cross"]' not in stage + util = (ROOT / "common_util/common_util_mass_haul.ts").read_text(encoding="utf-8") + assert 'const SERIES_BASES: MassHaulBasis[] = ["cross", "longitudinal"];' in util + assert "longitudinalAreaSamples(longitudinal, sections)" in util diff --git a/resources/tester/test_354_3_scroll_echo.py b/resources/tester/test_354_3_scroll_echo.py new file mode 100644 index 000000000..869f9df34 --- /dev/null +++ b/resources/tester/test_354_3_scroll_echo.py @@ -0,0 +1,87 @@ +# -*- coding: utf-8 -*- +"""354-3 종단 그래프 스크롤 끊김 — 동기화 메아리 · 스크롤마다 유토 다시 셈. + +- 메아리: scroll 은 다음 틀에 옴 → 동기 플래그로 못 막아 한 틀 전 값을 되씀 → 뒤로 튐 → + 휠 이동이 접힘. 공용 `linkScrollLeft`(Wheel.ts)가 써 넣은 값으로 온 scroll 을 무시. + 가짜 창(다음 틀 scroll) 재현 — 옛 잇기는 뒤로 튐 · 멈춤, 새 잇기는 4 경우 × 휠 쪽 둘 모두 튐 0. +- 유토 스크롤 그리기 = 열림 · 분배 맞춤 아님 · 붙음일 때만 · 한 틀 한 번 · 다시 셈 없음. + +`helper_354_3_scroll_echo.cjs` 가 진짜 휠 · 잇기 TS 를 가짜 창 위에서 돌림. +""" + +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" +CASES = [ + f"{driver}|table={table}|balance={balance}" + for driver in ("body", "mass") + for table in ("true", "false") + for balance in ("false", "true") +] + + +@pytest.fixture(scope="module") +def r(): + proc = subprocess.run( + ["node", os.path.join(HERE, "helper_354_3_scroll_echo.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): + # 표 · 분배 끔(사용자 보고 경우)에서 휠 다섯 칸(1,200px) 중 210px 에서 멈춤 + assert r["old|body|table=false|balance=false"]["back"] > 0 + assert r["old|body|table=false|balance=false"]["end"] < 1200 + # 분배 켜고 표 끔 = 잇기 없음 — 사용자 관찰(분배 켜면 매끄러움)과 같음 + assert r["old|body|table=false|balance=true"] == { + "back": 0, + "end": 1200, + "lead": 1200, + "mass": 0, + "table": 0, + } + + +@pytest.mark.parametrize("case", CASES) +def test_새_잇기는_뒤로_튐_0_끝까지(r, case): + got = r[f"new|{case}"] + assert got["back"] == 0 + assert got["end"] == 1200 and got["lead"] == 1200 + table, balance = "table=true" in case, "balance=true" in case + assert got["table"] == (1200 if table else 0) + assert got["mass"] == (0 if balance else 1200) + + +def test_유토_표_같은_도우미(): + for name in ("MassHaul", "TableOverlay"): + src = (B05 / f"B05_Profile_UI_Profile_{name}.ts").read_text(encoding="utf-8") + assert "linkScrollLeft(main, scroll" in src + assert "syncing" not in src + + +def test_유토_스크롤_그리기는_창_따를_때만_한_틀_한_번(): + mass = (B05 / "B05_Profile_UI_Profile_MassHaul.ts").read_text(encoding="utf-8") + body = mass[mass.index("function drawWindow(") :] + body = body[: body.index("\n }\n")] + assert '!open || fitted || overlay.classList.contains("is-popout") || !lastSeries' in body + assert "windowState.frame" in body and "frameOf(overlay" in body + assert "draw(lastParams, true)" in body + # 다시 셈 없음 · 총괄값 · 범례는 계열이 바뀔 때만 + assert re.search(r"reuse && lastSeries\s*\? lastSeries", mass) + assert "if (series !== lastSeries) emitSummary(series);" in mass + # 다시 그려도 제 자리 — 종단 값으로 되쓰는 것은 처음 · 맞춤 뒤 · 창 옮김 뒤만 + assert "scroll.scrollLeft = syncedMain.scrollLeft" not in mass + render = (B05 / "B05_Profile_UI_Profile_Render.ts").read_text(encoding="utf-8") + assert "massHaul.drawWindow({" in render