/* ============================================================================= * design_page.ts * B05 · B06 **한 페이지 두 보기**(계획서 24-3a · 24-3b). * * 주소 둘(`#/b05-profile` · `#/b06-section`)이 이 페이지 하나를 연다. 같은 페이지 안에서 주소만 * 바뀌면 라우터가 `show(route)` 만 부른다 — 보기를 다시 만들지 않고 숨김 · 보임만 바꾼다. * 다른 페이지로 떠나면 `dispose` 로 3D · 종단도 · 횡단 · 창 듣기를 정리한다. * * 틀은 하나 — 진행 단계 · 좌측 패널(칸 · 아래 단추) · 가운데 아래 종단도는 한 벌(한 노드)이고 * (`design_shared`), 보기는 가운데 위 칸만 채운다: * mountCenter(center, host, shared) 종단 보기 = 3D 화면 · 횡단 보기 = 횡단 카드(처음 보일 때) * 보기가 바뀌면 좌측 칸 · 아래 단추 글자 · 종단도 판 · 진행 단계 켜진 칸 · 제목이 따라간다. * * 보기는 `host.onShow` · `onHide` · `onDispose` 로 숨김 · 보임 · 떠남 때 할 일을 건다 — * 숨으면 3D 그리기 루프 · 포인터 듣기를 떼고, 보이면 가게 값으로 다시 그린다. * ========================================================================== */ import { CURRENT_PROJECT_ID_KEY, ROUTES, type RoutePath } from "@config/config_frontend"; import { showToast } from "@ui/ui_template_elements"; import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale"; import { createWorkflowLayout, type WorkflowLayoutHandle } from "@ui/ui_template_workflow_layout"; import { workflowSteps } from "../b_page_scaffold"; import { fetchWorkflowState, goToWorkflowStage, WORKFLOW_STEP_ROUTES, type WorkflowState, } from "../b_workflow_nav"; import { currentRoute, navigateTo, type MountedPage } from "../router"; import type { DesignView } from "./design_dock"; import type { DesignPageShared } from "./design_shared"; import "./design_page.css"; export type { DesignPageShared } from "./design_shared"; /** 보기 ↔ 주소. */ export const DESIGN_VIEW_ROUTES: Record = { profile: ROUTES.B05_PROFILE, cross: ROUTES.B06_SECTION, }; export function designViewOf(route: RoutePath): DesignView | null { if (route === ROUTES.B05_PROFILE) return "profile"; if (route === ROUTES.B06_SECTION) return "cross"; return null; } /** 보기마다 진행 단계 칸 · 제목. */ const VIEW_STEP: Record = { profile: 2, cross: 3 }; const VIEW_TITLE: Record = { profile: "B05_Route_Title", cross: "B06_Profile_Title", }; /** 보기가 페이지에서 받는 것 — 숨김 · 보임 · 떠남 때 할 일을 건다. */ export interface DesignPageHost { readonly view: DesignView; /** 지금 보이는가(떠났으면 false). */ visible: () => boolean; /** 떠났는가 — 늦게 온 자료가 떠난 화면을 채우지 않게 본다. */ disposed: () => boolean; /** 다시 보일 때(처음 마운트는 빼고) — 다른 보기에서 바뀐 값을 가게에서 다시 읽는다. */ onShow: (fn: () => void) => void; /** 숨을 때 — 그리기 루프 · 창 듣기를 뗀다. */ onHide: (fn: () => void) => void; /** 떠날 때 — 건 차례의 거꾸로 부른다. */ onDispose: (fn: () => void) => void; } export interface DesignPageView { /** 가운데 위 칸만 채운다 — 좌측 패널 · 단추 · 종단도는 `shared` 한 벌. 자료가 서면 끝난다. */ mountCenter: ( center: HTMLElement, host: DesignPageHost, shared: DesignPageShared, ) => void | Promise; } /** 보기 정의 — 처음 보일 때 읽는다(번들을 보기별로 나눠 둔다). */ const VIEWS: Record Promise> = { profile: async () => ({ mountCenter: (await import("./design_view_profile")).mountProfileView }), cross: async () => ({ mountCenter: (await import("./design_view_cross")).mountCrossView }), }; interface ViewSlot { box: HTMLElement; /** 자료가 섰다(실패해도 끝남). */ ready: Promise; show: () => void; hide: () => void; dispose: () => void; } const run = (fn: () => void): void => { try { fn(); } catch (error) { console.error("[design_page]", error); } }; /** 한 페이지를 세운다 — 라우터가 `MountedPage` 로 받아 같은 페이지 안 이동에 쓴다. */ export function renderDesignPage(root: HTMLElement): MountedPage { const page = document.createElement("div"); page.className = "design-page"; root.replaceChildren(page); const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY) ?? ""; // 한 벌 틀 — 좌측 자리 · 가운데(위 칸 + 아래 종단도). 진행 단계 틀은 단계 상태가 오면 감싼다. const left = document.createElement("div"); left.className = "design-page__left"; const main = document.createElement("div"); main.className = "design-page__main"; const center = document.createElement("div"); center.className = "design-page__center"; const profileSlot = document.createElement("div"); profileSlot.className = "design-page__profile"; main.append(center, profileSlot); const slots = new Map(); let current: DesignView = designViewOf(currentRoute()) ?? "profile"; let layout: WorkflowLayoutHandle | null = null; let disposedPage = false; const shared: DesignPageShared = { projectId, left, profileSlot, view: () => current, parts: {}, bindings: {}, panel: null, profile: null, structures: null, pipeOptionQueue: [], revealLeft: () => layout?.setOptionsOpen(true), ensureView: (view) => (slots.get(view) ?? mountView(view, false)).ready, }; /** 한 벌이 보기를 따라간다 — 좌측 칸 · 단추 글자 · 종단도 판 · 진행 단계 · 제목. */ const applyView = (view: DesignView): void => { main.dataset.view = view; shared.panel?.setView(view); shared.profile?.setView(view); layout?.setTitle(ui_locales[VIEW_TITLE[view]][currentLanguageIndex]); layout?.setActiveStep(VIEW_STEP[view]); layout?.root.classList.toggle("b05-route-layout", view === "profile"); layout?.root.classList.toggle("b06-profile-layout", view === "cross"); }; const buildLayout = (state: WorkflowState | null): void => { layout = createWorkflowLayout({ title: ui_locales[VIEW_TITLE[current]][currentLanguageIndex], steps: workflowSteps(), activeStep: VIEW_STEP[current], leftPanel: left, mainContent: main, stages: state?.stages, currentStage: state?.current_stage, routes: WORKFLOW_STEP_ROUTES, // 종단설계 · 횡단설계 두 칸은 같은 페이지 — 주소만 바꿔 보기만 바꾼다. onStepClick: (stepIndex) => { const route = WORKFLOW_STEP_ROUTES[stepIndex]; if (designViewOf(route)) navigateTo(route); else goToWorkflowStage(projectId, route); }, }); layout.root.classList.add("design-page__layout"); page.append(layout.root); applyView(current); }; if (!projectId) { showToast("프로젝트를 먼저 선택하세요.", "error"); buildLayout(null); } else { void fetchWorkflowState(projectId) .catch(() => null) .then((state) => { if (!disposedPage) buildLayout(state); }); } function mountView(view: DesignView, visibleAtStart: boolean): ViewSlot { const life = { show: [] as Array<() => void>, hide: [] as Array<() => void> }; const cleanups: Array<() => void> = []; let visible = visibleAtStart; let disposed = false; const box = document.createElement("div"); box.className = "design-page__view"; box.dataset.view = view; box.hidden = !visibleAtStart; center.append(box); const host: DesignPageHost = { view, visible: () => visible && !disposed, disposed: () => disposed, onShow: (fn) => life.show.push(fn), onHide: (fn) => life.hide.push(fn), onDispose: (fn) => cleanups.push(fn), }; const ready = VIEWS[view]() .then((def) => { if (disposed) return; const mounting = def.mountCenter(box, host, shared); // 부품 · 동작을 건 뒤(마운트의 동기 앞부분) 한 벌을 지금 보기로 맞춘다. if (view === current) applyView(view); return mounting; }) .catch((error) => console.error(`[design_page] 보기 마운트 실패: ${view}`, error)); const slot: ViewSlot = { box, ready, // 숨긴 채 세운 보기(종단 보기 [확정])도 처음 보일 때 이 알림으로 가게 값을 다시 읽는다. show() { visible = true; box.hidden = false; life.show.forEach(run); }, hide() { visible = false; box.hidden = true; life.hide.forEach(run); }, dispose() { disposed = true; cleanups.reverse().forEach(run); }, }; slots.set(view, slot); return slot; } const show = (view: DesignView): void => { const started = performance.now(); if (slots.has(current) && current !== view) slots.get(current)?.hide(); current = view; const slot = slots.get(view); if (slot) { slot.show(); applyView(view); } else mountView(view, true); // 검증용 — 보기 전환에 든 시간(ms)과 횟수를 화면 밖에서 수치로 본다. const probe = window as unknown as { __designPage?: { view: string; switchMs: number[] } }; probe.__designPage ??= { view, switchMs: [] }; probe.__designPage.view = view; probe.__designPage.switchMs.push(Math.round(performance.now() - started)); }; show(current); return { covers: (route) => designViewOf(route) !== null, show: (route) => { const view = designViewOf(route); if (view && view !== current) show(view); }, dispose() { disposedPage = true; slots.forEach((slot) => slot.dispose()); slots.clear(); shared.profile?.dispose(); delete (window as unknown as { __designPage?: unknown }).__designPage; }, }; }