Files
Aislo/A00_Common/design/design_page.ts
T
eomsangdonandClaude Opus 5.5 473d959d60 refactor(B05·B06): 진짜 한 벌 · 가운데 보기 전환(PLAN 24-3b)
- 좌측 패널 · 아래 단추 넷 · 종단도 · 구조물 배치 폼을 페이지에 한 번만 만듦(design_shared) — 두 보기가 같은 노드
- 보기는 가운데 위 칸만: 종단 보기 = 3D(design_view_profile) · 횡단 보기 = 횡단 카드(design_view_cross · 처음 보일 때 그림)
- 옛 B05_Profile_UI_Page · B06_Section_UI_Page 삭제 · 한 벌이 받은 조작은 지금 보기로, 관 목록 · 횡단 재계산 알림은 두 보기 모두로
- 진행 단계 종단설계 · 횡단설계 두 칸이 보기를 따라 켜짐 · 누르면 주소만 바꿈 · 제목도 따라감(setTitle · setActiveStep)
- [저장] · [확정] 한 벌 — 두 보기 몫을 모아 saveDesign 한 번 · 종단 보기 [확정] 켬(횡단 보기를 숨긴 채 세워 횡단 자료를 얻음)
- [초기화] · 계획노선 편집은 같은 페이지라도 다시 세움(remountRoute)
- 덧붙임: 방향키는 초점 있는 쪽만(조정창 ↔ 종단도 계획고) · 횡단 보기 계획고 편집에도 [3D 업데이트] 대기 표시 · 종단 보기 폼 관 옵션을 횡단 캐시에도 얹음 · 종단 보기에서 횡단 조정 칸 가림
- 나눔: Cross_View 756→658(_Cross_View_Frame) · Page_Structures_Panel 700→547(_Structures_Spec) · 옛 B05 Page 786 → design_view_profile 638(_Page_Latest · _Page_Stations)
- 시험: test_design_view_parity 한 벌 기준(한 패널 · 보기 오가도 같은 노드) · page_single · profile_single · save_channels · save_recompute 등 옮긴 자리로

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KmELpdXFDRZHhfbGuRhSmL
2026-09-29 22:38:35 +09:00

266 lines
9.9 KiB
TypeScript

/* =============================================================================
* 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<DesignView, RoutePath> = {
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<DesignView, number> = { profile: 2, cross: 3 };
const VIEW_TITLE: Record<DesignView, keyof typeof ui_locales> = {
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<void>;
}
/** 보기 정의 — 처음 보일 때 읽는다(번들을 보기별로 나눠 둔다). */
const VIEWS: Record<DesignView, () => Promise<DesignPageView>> = {
profile: async () => ({ mountCenter: (await import("./design_view_profile")).mountProfileView }),
cross: async () => ({ mountCenter: (await import("./design_view_cross")).mountCrossView }),
};
interface ViewSlot {
box: HTMLElement;
/** 자료가 섰다(실패해도 끝남). */
ready: Promise<void>;
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<DesignView, ViewSlot>();
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;
},
};
}