/* ============================================================================= * B05_Profile_UI_Profile_Heights.ts * 종단 패널의 높이 배분 — 그래프·유토곡선·테이블이 한 패널 높이를 나눠 갖는 규칙. * * 패널 본체(B05_Profile_UI_Profile_Panel)가 700줄 한계에 닿아 분리했다. * 판정 기준은 **저장 높이**(리사이저 변수)이며 화면 높이가 아니다 — 화면 높이로 재면 * 줄임과 풀림이 호출마다 번갈아 도는 진동이 생긴다(2026-08-06 분석). * 드래그 플래그·상세 데이터 유무는 본체가 계속 들고 있고 여기서는 접근자로만 읽는다. * ========================================================================== */ 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"; /** 그래프가 이보다 낮아지면 종단선을 읽을 수 없다 — 어떤 배분에서도 지키는 하한(px). */ const MIN_CHART_HEIGHT = 100; /** 접힌 유토곡선 손잡이가 차지하는 바닥 여백(px). */ const MASSHAUL_HANDLE_GUTTER_PX = 13; /** 패널 자동 확대 상한 — 메인 리사이저 max와 같은 식이어야 한다(부모 높이 비율). */ const MAX_PANEL_HEIGHT_RATIO = 0.9; 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; /** 손 뗀 직후 첫 재구성까지는 자동 확대를 막는다. */ isMainDragCooldown: () => boolean; /** 상세 데이터가 아직 없으면 경량 동기화를 건너뛴다. */ hasDetail: () => boolean; } export interface HeightCascade { /** 높이를 배분하고 그래프에 남는 높이를 돌려준다. */ apply: (allowGrow: boolean) => { chartHeight: number }; /** 경량 동기화를 다음 프레임에 1회 예약한다. */ scheduleLightSync: () => void; } export function createHeightCascade(deps: HeightCascadeDeps): HeightCascade { let lightSyncPending = false; function apply(allowGrow: boolean): { chartHeight: number } { // 구조물 알약 레인이 그래프 아래 한 줄을 차지한다 — 그만큼 먼저 떼어 놓고 나눈다 // (2026-08-17 사용자 지시 6: 그래프가 밀려도 되지만 여유는 최소). const available = Math.max(120, deps.body.clientHeight - STRUCTURE_LANE_HEIGHT_PX); const massOpen = !deps.massHaul.overlay.hidden; 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())대로 종단·유토곡선·테이블을 같이 늘리고 // 줄인다. 최소에 닿은 영역은 거기서 멈추고 남은 영역끼리 다시 비례 배분한다. // 손을 떼면 clearDragFlags가 이 결과를 저장 높이로 확정한다. const dragRef = deps.mainDragRef(); if (deps.isMainDragging() && dragRef) { 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회에 끝난다. 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) 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), }; } if (deps.isSubDragging()) { // 서브패널 리사이저를 끄는 중이면 그 높이가 **사용자 의도**다 — 임시 축소(인라인)를 // 걷어 변수(드래그 값)가 그대로 보이게 하고, 자리가 모자라면 아래 deficit 처리로 // 메인 패널을 키운다. 변수는 드래그마다 갱신되므로 저장 높이 = 드래그 값이다. deps.massHaul.overlay.style.height = ""; deps.tableOverlay.overlay.style.height = ""; } else if (massDesired + 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 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; if (deficit > 1 && allowGrow && !deps.isMainDragging() && !deps.isMainDragCooldown()) { // 상한은 메인 리사이저 max와 **같은 식**이어야 한다 — 예전 window 92% 상한은 // 리사이저 상한(부모 90%)보다 높아, 자동 확대 직후 손잡이를 잡는 순간 낮은 // 상한으로 clamp되며 패널이 뚝 떨어졌다(2026-08-06 분석). const growCap = Math.round( (deps.root.parentElement?.clientHeight ?? window.innerHeight) * MAX_PANEL_HEIGHT_RATIO, ); const grown = Math.min(deps.root.offsetHeight + deficit, growCap); if (grown > deps.root.offsetHeight + 1) { deps.root.style.setProperty("--b05-profile-height", `${grown}px`); // ResizeObserver가 새 높이로 draw를 다시 부른다 — 이번 프레임은 그대로 마저 그린다. } } return { chartHeight: Math.max( MIN_CHART_HEIGHT, available - massHeight - tableOverlayHeight - MASSHAUL_HANDLE_GUTTER_PX, ), }; } /** 경량 동기화를 다음 프레임에 1회 예약 — 메인 리사이즈(ResizeObserver)와 * 서브패널 드래그(onResize 알림)가 같은 스로틀을 공유한다. */ function scheduleLightSync(): void { if (lightSyncPending) return; lightSyncPending = true; requestAnimationFrame(() => { lightSyncPending = false; syncHeightsLight(); }); } /** 리사이즈 중 프레임당 경량 동기화 — 차트·테이블 재구성 없이 높이만 맞춰 끊김을 없앤다. * SVG는 잠깐 세로 스케일되지만, 손을 떼면(디바운스) 전체 재그리기가 정확히 다시 그린다. */ function syncHeightsLight(): void { if (!deps.hasDetail()) return; // 서브패널을 끄는 중엔 즉시 메인 패널을 밀어 올려야(grow) 드래그가 자연스럽다. const { chartHeight } = apply(deps.isSubDragging()); const canvas = deps.body.firstElementChild as HTMLElement | null; if (!canvas || !canvas.classList.contains("b05-profile__canvas")) return; canvas.style.height = `${chartHeight + STRUCTURE_LANE_HEIGHT_PX}px`; const chartWrap = canvas.querySelector(".b05-profile__chart"); if (chartWrap) { chartWrap.style.height = `${chartHeight}px`; const svg = chartWrap.querySelector("svg"); if (svg) svg.style.height = `${chartHeight}px`; } } return { apply, scheduleLightSync }; }