/* ============================================================================= * design_panel.ts * B05 · B06 한 페이지 두 보기의 **좌측 패널 칸 등록부**(계획서 24-1 · 24-2b). * * 칸(구조물 배치 · 페이지 설정 · 표준 횡단면 설정 …)은 **한 번만 정의**하고 보일 보기만 적는다. * 페이지 파일은 칸도 단추도 직접 만들지 않고 `createDesignPanel(ctx, view)` 한 줄만 부른다 — * 시험(`test_design_view_parity.py`)이 이를 지킨다. * * registerPanelSection({ id, title, views, order, build }) ← 칸 정의(모듈이 읽힐 때 한 번) * createDesignPanel(ctx, view) → { root, setView } ← 페이지가 부름 * * 칸이 쓰는 페이지 쪽 부품(구조물 패널 · 입력 칸 묶음 …)은 `ctx.parts` 에서 이름으로 꺼낸다 — * 칸 정의가 B05 · B06 의 구체 타입을 몰라도 되게 하려는 것이다. * ========================================================================== */ import { attachCollapsible } from "@ui/ui_template_collapsible"; import { createDesignDock, type DesignDock, type DesignDockActions, type DesignView, } from "./design_dock"; export type { DesignView } from "./design_dock"; /** 좌측 패널 하나가 페이지에서 받는 것 — 값은 바뀌므로 부품은 이름으로, 단추 동작은 함수로. */ export interface DesignPanelContext { projectId: string | null; /** 이름 → 페이지가 만든 부품. 칸 정의가 `part()` 로 꺼낸다. */ parts: Record; /** 아래 단추 넷의 동작 — 페이지가 채운다(저장 흐름은 `design_save`). */ dock: DesignDockActions; } /** 칸 정의가 돌려주는 것 — 칸 뿌리 하나, 그리고 (있으면) 아래 고정 영역 위에 얹을 것. */ export type PanelSectionBuild = HTMLElement | { root: HTMLElement; dock?: HTMLElement }; export interface PanelSectionDef { id: string; title: string; /** 이 칸이 보일 보기 — 둘 다 적으면 한 정의로 두 보기(구조물 배치). */ views: ReadonlyArray; /** 칸 차례 — 작을수록 위. */ order: number; build: (ctx: DesignPanelContext, view: DesignView) => PanelSectionBuild; } const registry = new Map(); /** 칸 정의를 등록한다. 같은 id 는 덮어쓴다(같은 모듈이 다시 읽혀도 한 벌). */ export function registerPanelSection(def: PanelSectionDef): void { registry.set(def.id, def); } /** 그 보기에 보일 칸 정의 — 차례대로. 시험이 「두 보기가 같은 칸을 쓰는지」 볼 때도 쓴다. */ export function panelSectionsFor(view: DesignView): PanelSectionDef[] { return [...registry.values()] .filter((def) => def.views.includes(view)) .sort((a, b) => a.order - b.order); } /** 칸이 부품을 이름으로 꺼낸다 — 페이지가 안 건넸으면 바로 알리도록 던진다. */ export function part(ctx: DesignPanelContext, name: string): T { const found = ctx.parts[name]; if (found === undefined) throw new Error(`좌측 패널 부품 「${name}」 을 페이지가 건네지 않았습니다.`); return found as T; } /** * 칸 틀 — 접는 제목 + 본문. 두 보기가 **다른 클래스**를 쓰던 것(`b05-route__panel-section` · * `b06-profile__group`)을 그대로 살린다(2단계에서 한 벌로 합침). 종단 보기 본문은 별도 div, * 횡단 보기는 칸 뿌리에 바로 붙인다. */ export function createSectionFrame( view: DesignView, title: string, collapsed = false, ): { root: HTMLElement; body: HTMLElement } { const root = document.createElement("section"); const heading = document.createElement("h3"); heading.textContent = title; if (view === "profile") { // ui-sidebar-section: 사이드 컨테이너 공통 외곽선(진하게, 2026-08-05 사용자 지시). // 접힘 초기값만 여기서 준다 — 펼치기·접기 토글은 ui_template_collapsible 전역 몫. root.className = `b05-route__panel-section ui-collapsible ui-sidebar-section${collapsed ? " is-collapsed" : ""}`; heading.className = "ui-collapsible__title"; const body = document.createElement("div"); body.className = "b05-route__panel-body"; root.append(heading, body); return { root, body }; } root.className = `b06-profile__group ui-collapsible ui-sidebar-section${collapsed ? " is-collapsed" : ""}`; heading.className = "b06-profile__group-legend ui-collapsible__title"; root.append(heading); return { root, body: root }; } export interface DesignPanel { root: HTMLElement; /** 아래 고정 영역 — 단추 켬·끔을 페이지가 다시 줄 때 쓴다. */ dock: DesignDock; /** 보기가 바뀌면 그 보기에 없는 칸은 숨기고 있는 칸은 (처음이면 만들어) 보인다. */ setView: (view: DesignView) => void; } /** 좌측 패널 — 그 보기의 칸을 차례대로 세우고 아래 고정 영역을 붙인다. */ export function createDesignPanel(ctx: DesignPanelContext, view: DesignView): DesignPanel { const root = document.createElement("div"); root.className = view === "profile" ? "b05-route__panel" : "b06-profile__form"; const built = new Map(); const dockParts: HTMLElement[] = []; const dock = createDesignDock(view, dockParts, ctx.dock); const show = (target: DesignView): void => { const wanted = new Set(panelSectionsFor(target).map((def) => def.id)); for (const def of panelSectionsFor(target)) { if (built.has(def.id)) continue; const made = def.build(ctx, target); const sectionRoot = "root" in made ? made.root : made; if ("root" in made && made.dock) dock.root.prepend(made.dock); built.set(def.id, sectionRoot); root.insertBefore(sectionRoot, dock.root); } // 차례를 정의 순서로 다시 맞춘다 — 늦게 만들어진 칸이 맨 아래에 남지 않게. for (const def of panelSectionsFor(target)) { const element = built.get(def.id); if (element) root.insertBefore(element, dock.root); } built.forEach((element, id) => { // 칸이 스스로 숨긴 것(선택 포인트)은 건드리지 않고, 보기 밖 칸만 가리고 되살린다. element.style.display = wanted.has(id) ? "" : "none"; }); }; root.append(dock.root); show(view); // 컨테이너 제목 행 전체 클릭 시 본문을 접거나 편다(공용 collapsible). 내부 details 등 별도 // 접힘 항목은 손대지 않는다. attachCollapsible(root); return { root, dock, setView: (next) => { dock.setView(next); root.className = next === "profile" ? "b05-route__panel" : "b06-profile__form"; show(next); }, }; }