/* ============================================================================= * B05_Profile_UI_Profile_Drag.ts * 종단도 **높이 끌기** — 위쪽 경계 손잡이와, 메인 · 서브패널(유토곡선 · 표) 손잡이를 * 끄는 동안의 출처 추적. * * 종단도 한 벌(`A00_Common/design/design_profile.ts`)이 700줄을 넘겨 떼어낸 조각이다 * (계획서 24-2c). 본문 로직과 수치는 그대로다. * ========================================================================== */ import { createPanelResizer } from "@ui/ui_template_resizer"; import { readStateRaw, stateKey, writeStateRaw } from "../A00_Common/b_page_state"; import { MASSHAUL_MIN_HEIGHT } from "./B05_Profile_UI_Profile_MassHaul"; import { TABLE_OVERLAY_MIN_HEIGHT } from "./B05_Profile_UI_Profile_TableOverlay"; const MIN_CHART_HEIGHT = 100; /** 접힌 유토곡선 손잡이가 앉을 테이블 아래 전용 띠 — 곡선 행 클릭을 가리지 않게. * 구조물 알약 레인이 그 위에 붙은 뒤로는 사이가 떠 보여 절반으로 줄였다 * (2026-08-17 사용자 지시 2). */ const MASSHAUL_HANDLE_GUTTER_PX = 13; /** 패널을 끌어 줄일 수 있는 하한(px) — 이보다 낮으면 그래프도 테이블도 못 읽는다. */ const MIN_PANEL_HEIGHT = 180; /** 상한은 위 칸(3D · 횡단 카드)이 완전히 가려지지 않도록 부모 높이의 90%까지만 허용한다. */ const MAX_PANEL_HEIGHT_RATIO = 0.9; /** 접힘·높이는 화면 취향(①) — 두 보기가 **같은 키 하나**를 쓴다(계획서 24-2c). */ const HEIGHT_KEY = stateKey("profile-height") ?? ""; /** 옛 횡단 보기 위 종단 패널의 접힘 · 높이 값을 한 번 옮겨 받는다(새 키가 비었을 때만). */ export function adoptLegacyProfileKeys(): void { const pairs = [ ["section-panel-collapsed", "profile-collapsed"], ["section-panel-height", "profile-height"], ] as const; for (const [from, to] of pairs) { const old = readStateRaw(from); if (old === null) continue; if (readStateRaw(to) === null) writeStateRaw(to, old); writeStateRaw(from, null); } } /** 접기 손잡이 — 누르면 접고 펴며, 펼칠 때 다시 그린다. 처음 상태는 저장값. */ export function bindProfileCollapse( root: HTMLElement, handle: { root: HTMLElement; setOpen: (open: boolean) => void }, onOpen: () => void, ): void { const setCollapsed = (collapsed: boolean): void => { root.classList.toggle("is-collapsed", collapsed); handle.setOpen(!collapsed); writeStateRaw("profile-collapsed", String(collapsed)); if (!collapsed) onOpen(); }; handle.root.addEventListener("click", () => setCollapsed(!root.classList.contains("is-collapsed")), ); setCollapsed(readStateRaw("profile-collapsed") === "true"); } /** * 계획고 ▲▼ 를 **누르고 있는 동안 세로 창을 고정**한다 — 끌어 올리는 동안 축까지 따라 * 움직이면 조작 감각이 깨진다. 손을 뗀 뒤 한 번만 다시 맞춘다(`redraw`). */ export function createElevationHold( body: HTMLElement, redraw: () => void, ): { hold: (next: { min: number; max: number } | null) => { min: number; max: number } | undefined; editing: () => boolean; } { /** 세로 자동 맞춤이 지금 쓰는 Y 창. 계획고를 끄는 동안에는 이 값을 붙잡는다. */ let elevationWindow: { min: number; max: number } | undefined; let heightEditing = false; body.addEventListener("pointerdown", (event) => { if (!(event.target as HTMLElement).closest(".b05-profile-edit__btn")) return; heightEditing = true; const release = (): void => { heightEditing = false; window.removeEventListener("pointerup", release); window.removeEventListener("pointercancel", release); redraw(); }; window.addEventListener("pointerup", release); window.addEventListener("pointercancel", release); }); return { hold(next) { if (heightEditing) return elevationWindow; elevationWindow = next ?? undefined; return elevationWindow; }, editing: () => heightEditing, }; } /** 겹침 판(유토곡선 · 표) 중 끌기가 보는 것만. */ interface SubPanel { overlay: HTMLElement; commitHeight: (height: number) => void; } export interface ProfileDragContext { root: HTMLElement; body: HTMLElement; massHaul: SubPanel; tableOverlay: SubPanel & { isOpen: () => boolean }; /** 손을 뗀 뒤 한 번 다시 세운다. */ draw: () => void; } export interface ProfileDrag { resizerRoot: HTMLElement; isMainDragging: () => boolean; isSubDragging: () => boolean; mainDragRef: () => { chart: number; mass: number; table: number } | null; isMainDragCooldown: () => boolean; clearMainDragCooldown: () => void; /** 전체 재구성을 120ms 뒤로 미룬다(연속 호출은 마지막 한 번만). */ scheduleDraw: () => void; cancelDraw: () => void; dispose: () => void; } export function createProfileDrag(ctx: ProfileDragContext): ProfileDrag { const { root, body, massHaul, tableOverlay } = ctx; let resizeTimer = 0; const scheduleDraw = (): void => { window.clearTimeout(resizeTimer); resizeTimer = window.setTimeout(ctx.draw, 120); }; // 위쪽 경계를 끌어 패널 높이를 조절한다. 늘어난 만큼은 그래프만 먹고 도면 테이블은 // 처음 잡힌 높이를 지킨다(사용자 지시) — 테이블 행이 늘었다 줄었다 하면 읽기 어려워서다. const heightResizer = createPanelResizer({ axis: "vertical", target: root, cssVar: "--b05-profile-height", direction: -1, // 동적 하한: 종단 최소 + 열린 서브패널들의 최소 높이 합(2026-08-05 사용자 확정). // 패널을 아래로 끌면 종단이 먼저 줄고 → 서브패널들이 같이 줄고(draw의 캐스케이드) → // 셋 다 최소가 되면 여기서 더는 못 내려가게 막는다(뒤로 넘어가는 역전 차단). min: () => { const chrome = Math.max(0, root.offsetHeight - body.clientHeight); const massMin = massHaul.overlay.hidden ? 0 : MASSHAUL_MIN_HEIGHT; 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, ); }, max: () => (root.parentElement?.clientHeight ?? window.innerHeight) * MAX_PANEL_HEIGHT_RATIO, storageKey: HEIGHT_KEY, }); /* ── 리사이저 드래그 출처 추적(2026-08-05 진동·복귀 버그 수정) ────────────── * 메인 패널을 끄는 중엔 grow(자동 확장)가 포인터와 싸우면 안 되고, 서브패널을 끄는 * 중엔 캐스케이드의 임시 축소(인라인)가 드래그 변수 값을 덮으면 안 된다. */ let mainPanelDragging = false; let subPanelDragging = false; /** 메인 드래그 손을 뗀 뒤 **첫 전체 재구성(draw)까지** grow 금지 — 120ms 지연 draw는 * 플래그가 이미 풀린 채 돌아, 드래그 중 금지했던 자동 확대가 손 떼는 순간 발동해 * 포인터가 정한 높이를 되돌렸다(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", () => { subPanelDragging = true; }), ); 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) scheduleDraw(); mainPanelDragging = false; subPanelDragging = false; }; window.addEventListener("pointerup", clearDragFlags); window.addEventListener("pointercancel", clearDragFlags); return { resizerRoot: heightResizer.root, isMainDragging: () => mainPanelDragging, isSubDragging: () => subPanelDragging, mainDragRef: () => mainDragRef, isMainDragCooldown: () => mainDragCooldown, clearMainDragCooldown: () => { mainDragCooldown = false; }, scheduleDraw, cancelDraw: () => window.clearTimeout(resizeTimer), dispose() { window.clearTimeout(resizeTimer); heightResizer.dispose(); window.removeEventListener("pointerup", clearDragFlags); window.removeEventListener("pointercancel", clearDragFlags); }, }; }