diff --git a/B05_wf2_Route/B05_wf2_Route_UI_Profile_MassHaul.ts b/B05_wf2_Route/B05_wf2_Route_UI_Profile_MassHaul.ts index 99d108ed..314512ef 100644 --- a/B05_wf2_Route/B05_wf2_Route_UI_Profile_MassHaul.ts +++ b/B05_wf2_Route/B05_wf2_Route_UI_Profile_MassHaul.ts @@ -135,6 +135,11 @@ export interface RouteMassHaulPanel { /** 저장된 오버레이 높이(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; @@ -378,6 +383,13 @@ export function createRouteMassHaulPanel(onChanged: () => void): RouteMassHaulPa 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`); + sessionStorage.setItem(OVERLAY_HEIGHT_KEY, String(next)); + syncHandlePosition(); + }, + syncHandle: () => syncHandlePosition(), setContext(next) { context = next; }, diff --git a/B05_wf2_Route/B05_wf2_Route_UI_Profile_Panel.ts b/B05_wf2_Route/B05_wf2_Route_UI_Profile_Panel.ts index 91ef380e..38bd9f3e 100644 --- a/B05_wf2_Route/B05_wf2_Route_UI_Profile_Panel.ts +++ b/B05_wf2_Route/B05_wf2_Route_UI_Profile_Panel.ts @@ -320,8 +320,21 @@ export function createRouteProfilePanel( * 플래그가 이미 풀린 채 돌아, 드래그 중 금지했던 자동 확대가 손 떼는 순간 발동해 * 포인터가 정한 높이를 되돌렸다(2026-08-06 분석 원인 4). draw()가 끝나며 푼다. */ let mainDragCooldown = false; + /** 메인 드래그 시작 시점의 3영역(종단·유토곡선·테이블) 실제 높이 — 비례 연동 기준. + * 드래그 중 이 비율대로 같이 늘리고 줄인다(2026-08-06 사용자 확정). */ + 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 tableH = tableOverlay.isOpen() ? tableOverlay.overlay.offsetHeight : 0; + mainDragRef = { + chart: Math.max( + MIN_CHART_HEIGHT, + body.clientHeight - massH - tableH - MASSHAUL_HANDLE_GUTTER_PX, + ), + mass: massH, + table: tableH, + }; }); [massHaul.overlay, tableOverlay.overlay].forEach((overlay) => overlay.querySelector(".ui-resizer")?.addEventListener("pointerdown", () => { @@ -330,6 +343,16 @@ export function createRouteProfilePanel( ); const clearDragFlags = (): void => { if (mainPanelDragging) mainDragCooldown = true; + if (mainPanelDragging && mainDragRef) { + // 비례 연동 결과를 서브패널 저장 높이로 확정한다 — 이후 모든 redraw의 판정 + // 기준이 이 값이 되어 멱등성이 유지된다(확정 없이는 손을 떼는 순간 예전 + // 저장 높이로 튄다). 확정 후 인라인은 걷는다(변수가 같은 값을 담는다). + if (!massHaul.overlay.hidden) massHaul.commitHeight(massHaul.overlay.offsetHeight); + if (tableOverlay.isOpen()) tableOverlay.commitHeight(tableOverlay.overlay.offsetHeight); + massHaul.overlay.style.height = ""; + tableOverlay.overlay.style.height = ""; + } + mainDragRef = null; // 드래그 중엔 경량 동기화만 하므로, 손을 뗀 뒤 전체 재구성 1회를 예약한다. if (mainPanelDragging || subPanelDragging) { window.clearTimeout(resizeTimer); @@ -527,6 +550,52 @@ export function createRouteProfilePanel( let massHeight = massDesired; let tableOverlayHeight = tableDesired; const budget = Math.max(0, available - MIN_CHART_HEIGHT - MASSHAUL_HANDLE_GUTTER_PX); + // ── 메인 드래그 중: 3영역 비례 연동(2026-08-06 사용자 확정) ───────────── + // 드래그 시작 시점 비율(mainDragRef)대로 종단·유토곡선·테이블을 같이 늘리고 + // 줄인다. 최소에 닿은 영역은 거기서 멈추고 남은 영역끼리 다시 비례 배분한다. + // 손을 떼면 clearDragFlags가 이 결과를 저장 높이로 확정한다. + if (mainPanelDragging && mainDragRef) { + const content = Math.max(0, available - MASSHAUL_HANDLE_GUTTER_PX); + const items = [ + { key: "chart", ref: Math.max(1, mainDragRef.chart), min: MIN_CHART_HEIGHT }, + ...(massOpen + ? [{ key: "mass", ref: Math.max(1, mainDragRef.mass), min: MASSHAUL_MIN_HEIGHT }] + : []), + ...(tableOpen + ? [{ key: "table", ref: Math.max(1, mainDragRef.table), min: TABLE_OVERLAY_MIN_HEIGHT }] + : []), + ]; + // 고정 → 재배분 반복: 축소 배율로 최소를 뚫는 항목을 최소에 고정하고, 남은 + // 공간을 나머지 항목끼리 원래 비율로 나눈다. 항목이 3개라 최대 3회에 끝난다. + const out: Record = {}; + let pool = content; + let active = items; + while (active.length) { + const refSum = active.reduce((sum, item) => sum + item.ref, 0); + const scale = pool / refSum; + const pinned = active.filter((item) => item.ref * scale < item.min); + if (!pinned.length) { + active.forEach((item) => (out[item.key] = Math.round(item.ref * scale))); + break; + } + pinned.forEach((item) => { + out[item.key] = item.min; + pool -= item.min; + }); + 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) massHaul.overlay.style.height = `${massHeight}px`; + if (tableOpen) tableOverlay.overlay.style.height = `${tableOverlayHeight}px`; + // 손잡이는 바뀐 인라인 높이의 위 경계를 즉시 따라간다. + massHaul.syncHandle(); + tableOverlay.setBottomOffset(massHeight); + return { + chartHeight: Math.max(MIN_CHART_HEIGHT, content - massHeight - tableOverlayHeight), + }; + } if (subPanelDragging) { // 서브패널 리사이저를 끄는 중이면 그 높이가 **사용자 의도**다 — 임시 축소(인라인)를 // 걷어 변수(드래그 값)가 그대로 보이게 하고, 자리가 모자라면 아래 deficit 처리로 diff --git a/B05_wf2_Route/B05_wf2_Route_UI_Profile_TableOverlay.ts b/B05_wf2_Route/B05_wf2_Route_UI_Profile_TableOverlay.ts index 2e53dc19..96f893d3 100644 --- a/B05_wf2_Route/B05_wf2_Route_UI_Profile_TableOverlay.ts +++ b/B05_wf2_Route/B05_wf2_Route_UI_Profile_TableOverlay.ts @@ -33,6 +33,9 @@ export interface RouteProfileTableOverlay { /** 저장된 오버레이 높이(px) — 리사이저 변수(세션)나 기본값. 임시 축소(인라인)는 무시한다. * Panel의 높이 캐스케이드가 호출마다 같은 판정을 내리는 기준(2026-08-06 진동 수정). */ desiredHeight: () => number; + /** 저장 높이를 확정한다(리사이저와 같은 변수+세션 기록) — 메인 패널 비례 연동이 + * 드래그를 마친 높이를 앞으로의 기준으로 못 박는 데 쓴다(2026-08-06 사용자 확정). */ + commitHeight: (px: number) => void; /** 오버레이 내용 높이(px) — draw()가 테이블 행 높이를 정하는 기준. */ contentHeight: () => number; /** draw()가 만든 테이블 요소를 넣는다(null이면 비움). */ @@ -142,6 +145,12 @@ export function createProfileTableOverlay(onChanged: () => void): RouteProfileTa 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`); + sessionStorage.setItem(HEIGHT_KEY, String(next)); + syncHandlePosition(); + }, contentHeight: () => Math.max(0, overlay.offsetHeight - 6), setTable(table) { const keepScroll = scroll.scrollLeft;