refactor(B05): 700줄 초과 잔여 2파일 분리 — Page·Profile_Panel

앞 커밋에서 남긴 두 파일을 마저 갈랐다. 상태를 모듈로 옮기면 나머지 참조가 전부
바뀌므로 상태는 제자리에 두고 **접근자만 넘기는** 방식으로 동작·공개 인터페이스를
보존했다.

- Profile_Panel 1152 → 649
  - _Profile_Layout: X축 배치(측점 칸 폭·캔버스 폭·chainage↔x 매핑). 순수 함수
  - _Profile_Heights: 그래프·유토곡선·테이블 높이 배분. 드래그 플래그·상세 유무는
    본체가 계속 들고 접근자로 읽는다(저장 높이 기준 판정 규칙 그대로)
  - _Profile_Render: 본문 재구성(캔버스·그래프·구조물 레인·테이블·유토곡선).
    그리기 시작 시 상태를 스냅숏하되 이벤트 핸들러 안에서만 현재값을 다시 읽는다
  - _Profile_Balance: 상단 균형 표시줄(절·성토·불균형·위반 경고·초기선 복원)
- Page 1031 → 685
  - _Page_Helpers: 설계폭 조회·모델 경계 변환·마커 복원·비정규 측점 보간 +
    시설 표시 이름
  - _Page_Structures: 구조물 정본과 관 지점 정본을 사이드 목록·그래프·3D에 맞추는
    다리. 두 정본을 섞는 지점이라 여기만 상태(비정규 측점 목록·판번호·저장 큐·
    타입 사전)를 팩토리 안으로 옮겼고, 본체는 bridge.irregularStations()로 읽는다

B05_Profile 전 파일이 700줄 이하가 됐다(최대 695).

검증: npm run typecheck 무오류, npm run build 성공(374 modules),
pytest tmp/tests 107 passed·7 skipped, prettier 정합.
프론트 테스트 러너가 없어 실제 화면 동작 확인은 남는다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-17 18:51:21 +09:00
co-authored by Claude Opus 5
parent 77f833bb25
commit d8aa525bb8
9 changed files with 1146 additions and 831 deletions
@@ -0,0 +1,204 @@
/* =============================================================================
* 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<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(
(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<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 };
}