Files
Aislo/B05_Profile/B05_Profile_UI_Profile_Heights.ts
T
eomsangdonandClaude Opus 5.5 acf7570da4 fix(B05): 46-6 종단 패널 높이 끌기 상한 — 부모(display: contents · 높이 0) 대신 가운데 칸 높이 기준
- 상한 식 한 벌 `profilePanelMaxHeight` (Heights) — 메인 리사이저 max · 자동 확대 상한이 같이 씀
- 기준 = `.design-page__main` 높이 × 0.9 · 없으면 부모

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MPcuokNm1ZR2kqgGGDfaJA
2026-10-05 13:14:08 +09:00

210 lines
12 KiB
TypeScript

/* =============================================================================
* 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;
/** 패널 높이 상한(px) — 메인 리사이저 max 와 자동 확대가 같이 쓴다. 기준 = 한 페이지 가운데 칸
* `.design-page__main`(바로 위 `.design-page__profile` 은 display: contents 라 높이 0). */
export function profilePanelMaxHeight(root: HTMLElement): number {
const basis = root.closest<HTMLElement>(".design-page__main") ?? root.parentElement;
return (basis?.clientHeight || window.innerHeight) * MAX_PANEL_HEIGHT_RATIO;
}
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<string, number> = {};
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(profilePanelMaxHeight(deps.root));
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<HTMLElement>(".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 };
}