/* ============================================================================= * B05_Profile_UI_Profile_TableOverlay.ts * 하단 종단 패널의 12행 도면식 테이블을 **유토곡선과 같은 형태**의 바닥 고정 * 오버레이 서브패널로 감싼다(2026-08-05 사용자 지시). * * - 접힘/펼침 표준 삼각형 손잡이 + 위 경계 리사이저 + 세션 보존. * - 유토곡선 오버레이가 함께 열리면 그 **위**에 쌓인다 — 위→아래 순서가 * 종단도 → 테이블 → 유토곡선이 되도록 setBottomOffset으로 바닥 간격을 받는다. * - 접혔을 때 손잡이는 유토곡선 손잡이와 가로로 나란히(일렬) 놓인다(CSS에서 * 가로 오프셋). 테이블 내용물은 Profile_Panel의 draw()가 만들어 setTable로 넣는다. * ========================================================================== */ import { createWorkflowPanelHandle } from "@ui/ui_template_overlay"; import { createPanelResizer } from "@ui/ui_template_resizer"; const OPEN_KEY = "b05:profile:table:open"; const HEIGHT_KEY = "b05:profile:table:height"; const OVERLAY_MIN_HEIGHT = 140; /** 테이블 오버레이 최소 높이 — 메인 패널 하한 계산(Profile_Panel)이 함께 쓴다. */ export const TABLE_OVERLAY_MIN_HEIGHT = OVERLAY_MIN_HEIGHT; const OVERLAY_MAX_RATIO = 0.75; /** CSS 기본 높이 — `var(--b05-table-height, 300px)`(_Style_MassHaul.css)와 같아야 한다. */ const OVERLAY_DEFAULT_HEIGHT = 300; /** 리사이저가 높이를 담는 CSS 변수. */ const HEIGHT_VAR = "--b05-table-height"; export interface RouteProfileTableOverlay { /** 접힘 손잡이(패널 바닥 띠에 얹는다). */ handle: HTMLElement; /** 테이블을 담는 바닥 고정 오버레이. */ overlay: HTMLElement; isOpen: () => boolean; /** 저장된 오버레이 높이(px) — 리사이저 변수(세션)나 기본값. 임시 축소(인라인)는 무시한다. * Panel의 높이 캐스케이드가 호출마다 같은 판정을 내리는 기준(2026-08-06 진동 수정). */ desiredHeight: () => number; /** 저장 높이를 확정한다(리사이저와 같은 변수+세션 기록) — 메인 패널 비례 연동이 * 드래그를 마친 높이를 앞으로의 기준으로 못 박는 데 쓴다(2026-08-06 사용자 확정). */ commitHeight: (px: number) => void; /** 오버레이 내용 높이(px) — draw()가 테이블 행 높이를 정하는 기준. */ contentHeight: () => number; /** draw()가 만든 테이블 요소를 넣는다(null이면 비움). */ setTable: (table: HTMLElement | null) => void; /** 종단 그래프 스크롤러와 가로 스크롤 양방향 동기화. */ attachScrollSync: (main: HTMLElement) => void; /** 유토곡선 오버레이가 차지한 바닥 높이(px). 그 위로 쌓인다. */ setBottomOffset: (px: number) => void; } export function createProfileTableOverlay(onChanged: () => void): RouteProfileTableOverlay { const handleControl = createWorkflowPanelHandle("bottom", "down", "테이블"); const handle = document.createElement("div"); handle.className = "b05-profile__table-handle"; handle.append(handleControl.root); handleControl.root.setAttribute("aria-label", "테이블 패널"); const overlay = document.createElement("div"); overlay.className = "b05-profile__table-overlay"; const scroll = document.createElement("div"); scroll.className = "b05-profile__table-scroll"; // 상태 선언은 리사이저 생성보다 먼저 — 세션 높이 복원이 생성 중 onResize를 부른다 // (유토곡선 TDZ 크래시와 같은 함정, 2026-08-04 확인). let open = sessionStorage.getItem(OPEN_KEY) !== "false"; // 기본 펼침(기존 테이블 상시 표시 유지) let bottomOffset = 0; let resizeRedrawPending = false; const resizer = createPanelResizer({ axis: "vertical", target: overlay, cssVar: HEIGHT_VAR, direction: -1, min: OVERLAY_MIN_HEIGHT, max: () => (overlay.parentElement?.clientHeight ?? window.innerHeight) * OVERLAY_MAX_RATIO, storageKey: HEIGHT_KEY, onResize: () => { syncHandlePosition(); if (resizeRedrawPending) return; resizeRedrawPending = true; requestAnimationFrame(() => { resizeRedrawPending = false; onChanged(); }); }, }); overlay.append(resizer.root, scroll); // 테이블 영역 우클릭은 브라우저 기본 메뉴를 막는다(2026-08-05 사용자 지시). scroll.addEventListener("contextmenu", (event) => event.preventDefault()); // 유토곡선 영역과 같은 문법 — 세로 휠을 가로 이동으로 돌린다(종단 스크롤도 함께 움직인다). scroll.addEventListener( "wheel", (event) => { if (event.shiftKey || event.deltaY === 0) return; const limit = scroll.scrollWidth - scroll.clientWidth; if (limit <= 0) return; const delta = event.deltaY; if ((delta < 0 && scroll.scrollLeft <= 0) || (delta > 0 && scroll.scrollLeft >= limit)) return; scroll.scrollLeft += delta; event.preventDefault(); }, { passive: false }, ); /** 손잡이는 오버레이 위 경계를, 접히면 열린 유토곡선의 위 경계(bottomOffset)를 따라간다 * — 접힌 버튼이 펼쳐진 패널 수평선에 놓인다(2026-08-05 사용자 지시). */ function syncHandlePosition(): void { handle.style.bottom = open ? `${bottomOffset + overlay.offsetHeight}px` : `${bottomOffset}px`; overlay.style.bottom = `${bottomOffset}px`; } function applyOpen(next: boolean): void { open = next; sessionStorage.setItem(OPEN_KEY, String(next)); handleControl.setOpen(next); handleControl.root.title = next ? "테이블 접기" : "테이블 펼치기"; handle.classList.toggle("is-open", next); overlay.hidden = !next; syncHandlePosition(); onChanged(); } handleControl.setOpen(open); handleControl.root.title = open ? "테이블 접기" : "테이블 펼치기"; handle.classList.toggle("is-open", open); overlay.hidden = !open; handleControl.root.addEventListener("click", () => applyOpen(!open)); // 종단 스크롤러와의 양방향 동기화 — 재진입 루프는 플래그로 끊는다(유토곡선과 동일). let syncing = false; function mirror(from: HTMLElement, to: HTMLElement): void { from.addEventListener("scroll", () => { if (syncing) return; syncing = true; to.scrollLeft = from.scrollLeft; syncing = false; }); } let syncedMain: HTMLElement | null = null; return { handle, overlay, isOpen: () => open, desiredHeight: () => { 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; scroll.replaceChildren(...(table ? [table] : [])); scroll.scrollLeft = keepScroll; syncHandlePosition(); }, attachScrollSync(main) { if (syncedMain === main) return; syncedMain = main; mirror(main, scroll); mirror(scroll, main); }, setBottomOffset(px) { bottomOffset = px; syncHandlePosition(); }, }; }