diff --git a/A00_Common/design/design_dock.ts b/A00_Common/design/design_dock.ts new file mode 100644 index 00000000..cf773b57 --- /dev/null +++ b/A00_Common/design/design_dock.ts @@ -0,0 +1,127 @@ +/* ============================================================================= + * design_dock.ts + * B05 · B06 한 페이지 두 보기의 **좌측 패널 맨 아래 고정 영역**(계획서 24-1 · 24-2b). + * + * 구성 = [위에 놓을 것(구조물 목록)][구분선][단추 행]. 단추 넷은 두 보기가 **같다**: + * [초기화] [저장] [확정] [보기 전환](종단 보기에서는 「횡단 보기」 · 횡단 보기에서는 + * 「종단 보기」). 누르면 무엇을 하는지는 페이지가 `DesignDockActions` 로 건넨다 — 이 파일은 + * 단추를 만들고 켜고 끄기만 한다(저장 흐름은 `design_save.ts` 몫). + * ========================================================================== */ + +import { createButton } from "@ui/ui_template_elements"; +import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale"; + +/** 보기 이름 — 종단 보기(3D + 종단도) · 횡단 보기(종단도 + 횡단 카드). */ +export type DesignView = "profile" | "cross"; + +/** 단추 하나의 동작 — `enabled` 가 거짓이면 눌리지 않고 `title` 이 까닭을 알린다. */ +export interface DesignDockAction { + run: () => void | Promise; + enabled?: boolean; + title?: string; +} + +export interface DesignDockActions { + reset: DesignDockAction; + save: DesignDockAction; + confirm: DesignDockAction; + /** [보기 전환] — 지금은 주소 이동이다(2단계에서 같은 페이지 안 전환으로 바뀜). */ + switchView: DesignDockAction; +} + +export interface DesignDock { + root: HTMLElement; + /** 단추 넷의 켬·끔·설명을 다시 준다(자료가 늦게 오는 페이지가 부름). */ + setActions: (actions: Partial) => void; + /** 보기가 바뀌면 [보기 전환] 글자가 따라 바뀐다. */ + setView: (view: DesignView) => void; +} + +function label(key: keyof typeof ui_locales): string { + return ui_locales[key][currentLanguageIndex]; +} + +/** 보기 전환 단추 글자 — 가려는 **쪽** 이름이다. */ +export function switchViewLabel(view: DesignView): string { + return label(view === "profile" ? "Design_Btn_ToCross" : "Design_Btn_ToProfile"); +} + +/** + * 아래 고정 영역을 만든다. `above` 는 단추 행 위에 놓을 것(구조물 목록) — 목록을 「구조물 배치」 + * 본문에 두면 고른 구조물의 폼 길이에 밀려 화면 밖으로 나갔다(2026-08-18 사용자 지시). + */ +export function createDesignDock( + view: DesignView, + above: ReadonlyArray, + initial: DesignDockActions, +): DesignDock { + let current = view; + let actions = { ...initial }; + + const root = document.createElement("div"); + root.className = "b05-route__dock ui-sidebar-actions"; + const divider = document.createElement("hr"); + divider.className = "b05-structure__divider"; + const row = document.createElement("div"); + row.className = view === "profile" ? "b05-route__actions" : "b06-profile__actions"; + + const press = (name: keyof DesignDockActions) => (): void => { + void Promise.resolve(actions[name].run()); + }; + const resetButton = createButton({ + label: label("Common_Btn_Reset"), + variant: "danger", + onClick: press("reset"), + }); + const saveButton = createButton({ + label: label("B06_Profile_Btn_Save"), + variant: "ghost", + onClick: press("save"), + }); + const confirmButton = createButton({ + label: label("B06_Profile_Btn_Confirm"), + variant: "filled", + onClick: press("confirm"), + }); + const switchButton = createButton({ + label: switchViewLabel(view), + variant: "ghost", + onClick: press("switchView"), + }); + resetButton.dataset.dockAction = "reset"; + saveButton.dataset.dockAction = "save"; + confirmButton.dataset.dockAction = "confirm"; + switchButton.dataset.dockAction = "switchView"; + const buttons = { + reset: resetButton, + save: saveButton, + confirm: confirmButton, + switchView: switchButton, + }; + + const sync = (): void => { + (Object.keys(buttons) as Array).forEach((name) => { + const action = actions[name]; + buttons[name].disabled = action.enabled === false; + buttons[name].title = action.title ?? ""; + }); + switchButton.textContent = switchViewLabel(current); + row.className = current === "profile" ? "b05-route__actions" : "b06-profile__actions"; + }; + + row.append(resetButton, saveButton, confirmButton, switchButton); + root.append(...above, divider, row); + sync(); + + return { + root, + setActions: (next) => { + actions = { ...actions, ...next }; + sync(); + }, + setView: (next) => { + current = next; + sync(); + }, + }; +} diff --git a/A00_Common/design/design_panel.ts b/A00_Common/design/design_panel.ts new file mode 100644 index 00000000..76094010 --- /dev/null +++ b/A00_Common/design/design_panel.ts @@ -0,0 +1,151 @@ +/* ============================================================================= + * 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); + }, + }; +} diff --git a/ui_template/ui_template_locale_b2.ts b/ui_template/ui_template_locale_b2.ts index 9a92cdd0..bcfd5c84 100644 --- a/ui_template/ui_template_locale_b2.ts +++ b/ui_template/ui_template_locale_b2.ts @@ -144,6 +144,9 @@ export const ui_locales_b2 = { 이름이 혼선을 줬다 — [저장]으로 바꾼다(2026-08-28 사용자 지시). */ B05_Route_Btn_TempSave: ["저장", "Save"], B05_Route_Btn_GoCross: ["횡단 이동", "Go to Cross"], + /* 좌측 패널 아래 단추 [보기 전환] — 가려는 쪽 이름(계획서 24-2b). */ + Design_Btn_ToCross: ["횡단 보기", "Cross view"], + Design_Btn_ToProfile: ["종단 보기", "Profile view"], B05_Route_TempSave_Success: ["변경 내용을 저장했습니다.", "Changes saved."], B05_Route_TempSave_Failed: ["저장에 실패했습니다.", "Failed to save."], B05_Route_Reset_Confirm: [