/* ============================================================================= * design_shared.ts * B05 · B06 한 페이지의 **한 벌 자리**(계획서 24-3b) — 좌측 패널(칸 · 아래 단추 넷) · 종단도 · * 「구조물 배치」 폼을 페이지에 **한 번만** 만들고 두 보기가 같은 노드를 함께 쓴다. * * 한 벌이 받은 사용자 조작(종단도 측점 고름 · 구조물 폼 · 우클릭)은 **지금 보이는 보기**가 * `bindDesignView` 로 건 동작으로 간다. 값이 바뀐 알림(관 목록 · 횡단 재계산)은 숨은 보기도 * 받는다 — 다시 보일 때 같은 값을 들고 있게. * [저장] · [확정]은 두 보기 몫을 모아 저장 한 줄기(`saveDesign`) 한 번으로 보낸다. 종단 보기의 * [확정]은 횡단 보기를 숨긴 채 세워 횡단 자료를 같은 페이지에서 얻는다(`ensureView`). * ========================================================================== */ import { ROUTES } from "@config/config_frontend"; import { showToast } from "@ui/ui_template_elements"; import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale"; import { goToWorkflowStage, WORKFLOW_STEP_ROUTES } from "../b_workflow_nav"; import { navigateTo } from "../router"; import { createStructuresSection, type StructuresSection } from "../b_structures_section"; import type { StructuresCallbacks } from "../../B05_Profile/B05_Profile_UI_Structures_Panel_Types"; import type { RouteProfilePanelCallbacks } from "../../B05_Profile/B05_Profile_UI_Profile_Panel_Types"; import { drainageSide, drainageSideTool } from "../../B05_Profile/B05_Profile_UI_Profile_SideTool"; import { createDesignPanel, type DesignPanel } from "./design_panel"; import { createDesignProfile, type DesignProfile } from "./design_profile"; import { saveDesign, type DesignSaveStep } from "./design_save"; import { resetDesign } from "./design_reset"; import type { DesignView } from "./design_dock"; function L(key: keyof typeof ui_locales): string { return ui_locales[key][currentLanguageIndex]; } /** 종단도가 보기에게 알리는 것 — 측점 고름 · [초기선 복원] · 우클릭 · 끌기 · 판(배수유역도). */ export interface DesignProfileBinding { onSelectStation: (stationId: string | null) => void; onResetAll?: () => void; callbacks?: RouteProfilePanelCallbacks; } /** 저장 한 줄기에 보기가 보태는 몫(계획고 · 관은 한 벌 종단도가 낸다). */ export interface DesignViewSave { routeId: () => number | null; prepare?: () => Promise; steps?: Partial< Record, () => Promise> >; cross?: (confirm: boolean) => Promise; /** 저장 뒤 이 보기만의 캐시 비우기 · 다시 읽기(알림 · 다음 단계는 여기서 한다). */ done?: (confirm: boolean) => Promise | void; } export interface DesignViewBinding { profile?: DesignProfileBinding; /** 「구조물 배치」 폼 알림 — 그 보기가 보일 때만 받는다. */ structures?: Partial; save?: DesignViewSave; /** [초기화] 때 이 보기만 아는 조작 캐시를 비운다. */ onReset?: () => void; /** 다른 보기가 고친 관 옵션을 이 보기 캐시(횡단 스펙)에 얹는다. */ onPipeOptions?: (chainageM: number, patch: Record) => void; } /** 한 벌 자리 — 페이지(`design_page`)가 만들어 두 보기에 같은 객체로 넘긴다. */ export interface DesignPageShared { projectId: string; /** 좌측 패널 자리(아래 단추 넷은 좌측 패널 안). */ left: HTMLElement; /** 가운데 아래 종단도 자리. */ profileSlot: HTMLElement; /** 지금 보이는 보기. */ view: () => DesignView; /** 좌측 칸 부품 — 두 보기가 제 몫을 넣고, 칸 정의가 이름으로 꺼낸다. */ parts: Record; bindings: Partial>; /** 먼저 쓰는 쪽이 만든다 — 뒤 보기는 있는 것을 쓴다. */ panel: DesignPanel | null; profile: DesignProfile | null; structures: StructuresSection | null; /** 배수유역도가 마지막으로 알린 관 목록 — 뒤늦게 서는 보기가 처음부터 같은 관 목록을 쥔다(24-7a). */ lastPipes?: Parameters>[0]; /** 횡단 보기가 서기 전에 종단 보기가 고친 관 옵션 — 횡단 보기가 서면 캐시에 얹는다. */ pipeOptionQueue: Array<[number, Record]>; /** 접힌 좌측 패널을 편다(바깥에서 고른 것이 폼에 실릴 때). */ revealLeft: () => void; /** 그 보기를 (숨긴 채라도) 세우고 자료가 설 때까지 기다린다. */ ensureView: (view: DesignView) => Promise; } const bound = (shared: DesignPageShared): DesignViewBinding => shared.bindings[shared.view()] ?? {}; /** 보기가 제 몫을 건다 — 같은 보기가 다시 걸면 덮는다. */ export function bindDesignView( shared: DesignPageShared, view: DesignView, binding: DesignViewBinding, ): void { shared.bindings[view] = { ...shared.bindings[view], ...binding }; } /** * 종단 보기 폼에서 고친 관 옵션을 횡단 캐시에도 얹는다 — 세월교 · BOX 폼 값은 횡단 스펙(캐시)이 * 정본이라, 안 얹으면 한 벌 폼이 횡단 보기로 가는 순간 옛 값으로 돌아간다. 횡단 보기가 아직 * 안 섰으면 담아 두었다가 설 때 얹는다. */ export function forwardPipeOptions( shared: DesignPageShared, chainageM: number, patch: Record | undefined, ): void { if (!patch || !Object.keys(patch).length) return; const apply = shared.bindings.cross?.onPipeOptions; if (apply) apply(chainageM, patch); else shared.pipeOptionQueue.push([chainageM, patch]); } /** 횡단 보기가 캐시 창구를 건다 — 담아 둔 옵션을 먼저 얹는다. */ export function bindPipeOptions( shared: DesignPageShared, apply: (chainageM: number, patch: Record) => void, ): void { bindDesignView(shared, "cross", { onPipeOptions: apply }); shared.pipeOptionQueue.splice(0).forEach(([chainageM, patch]) => apply(chainageM, patch)); } /** 지금 보기의 종단도 알림으로 보낸다. */ function toView( shared: DesignPageShared, name: K, ): NonNullable { return ((...args: unknown[]) => (bound(shared).profile?.callbacks?.[name] as ((...a: unknown[]) => void) | undefined)?.( ...args, )) as NonNullable; } /** 두 보기 모두에 보낸다 — 값이 바뀐 알림. */ function toAll( shared: DesignPageShared, name: K, ): NonNullable { return ((...args: unknown[]) => Object.values(shared.bindings).forEach((binding) => (binding?.profile?.callbacks?.[name] as ((...a: unknown[]) => void) | undefined)?.(...args), )) as NonNullable; } /** 종단도 한 벌 — 처음 부를 때 만들어 종단도 자리에 붙인다. */ export function sharedProfile(shared: DesignPageShared): DesignProfile { if (shared.profile) return shared.profile; const profile = createDesignProfile({ projectId: shared.projectId, view: shared.view(), side: drainageSide, sideTool: drainageSideTool, onSelectStation: (stationId) => bound(shared).profile?.onSelectStation(stationId), onResetAll: () => bound(shared).profile?.onResetAll?.(), callbacks: { onPipesChanged: (pipes) => { shared.lastPipes = pipes; toAll(shared, "onPipesChanged")(pipes); }, onCrossDesignsUpdated: toAll(shared, "onCrossDesignsUpdated"), onStructureMove: toView(shared, "onStructureMove"), onStructureRemove: toView(shared, "onStructureRemove"), onPipeAdd: toView(shared, "onPipeAdd"), onStructureAdd: toView(shared, "onStructureAdd"), onIrregularSelect: toView(shared, "onIrregularSelect"), onStructureSelect: toView(shared, "onStructureSelect"), onStructureMarkMove: toView(shared, "onStructureMarkMove"), onStructureTypeAdd: toView(shared, "onStructureTypeAdd"), onBasinSelected: toView(shared, "onBasinSelected"), onPipeSelected: toView(shared, "onPipeSelected"), }, }); shared.profileSlot.append(profile.root); shared.profile = profile; return profile; } /** 「구조물 배치」 폼 한 벌 — 알림은 지금 보기로, 상세 옵션은 횡단 보기에서만 그린다. */ export function sharedStructures(shared: DesignPageShared): StructuresSection { if (shared.structures) return shared.structures; const on = (): Partial => bound(shared).structures ?? {}; shared.structures = createStructuresSection( { onChange: (next) => on().onChange?.(next), onSelect: (structure) => on().onSelect?.(structure), getInterval: () => on().getInterval?.() ?? 20, onPipeAdd: (chainageM, attributes) => on().onPipeAdd?.(chainageM, attributes), onPipeUpdate: (from, to, attributes) => on().onPipeUpdate?.(from, to, attributes), onPipeRemove: (chainageM) => on().onPipeRemove?.(chainageM), onPipeSelect: (chainageM) => on().onPipeSelect?.(chainageM), onReveal: () => shared.revealLeft(), }, { includeDetail: () => shared.view() === "cross", projectId: shared.projectId }, ); shared.parts.structures = shared.structures; return shared.structures; } /** 지금 두 보기가 아는 노선 — 횡단 보기가 먼저(확정 본문이 그쪽이다). */ function routeIdOf(shared: DesignPageShared): number | null { return shared.bindings.cross?.save?.routeId() ?? shared.bindings.profile?.save?.routeId() ?? null; } /** 좌측 패널 한 벌 — 부품은 두 보기가 `shared.parts` 에 넣고, 칸은 보기가 처음 보일 때 선다. */ export function sharedPanel(shared: DesignPageShared): DesignPanel { if (shared.panel) return shared.panel; const other = (): DesignView => (shared.view() === "profile" ? "cross" : "profile"); const panel = createDesignPanel( { projectId: shared.projectId, parts: shared.parts, dock: { reset: { run: () => resetDesign(shared.projectId, routeIdOf(shared), () => Object.values(shared.bindings).forEach((binding) => binding?.onReset?.()), ), }, // 자료가 서기 전에는 잠근다 — 보기가 `designReady` 로 연다. save: { run: () => saveSharedDesign(shared, false), enabled: false }, confirm: { run: () => saveSharedDesign(shared, true), enabled: false }, // [보기 전환] = 주소만 바꾼다 — 같은 페이지라 라우터가 보기만 바꾼다(다시 안 만듦). switchView: { run: () => navigateTo(other() === "profile" ? ROUTES.B05_PROFILE : ROUTES.B06_SECTION), }, }, }, shared.view(), ); shared.left.append(panel.root); shared.panel = panel; return panel; } /** 자료가 섰다 — [저장] · [확정]을 연다(두 보기 어느 쪽이 먼저든 한 번이면 된다). */ export function designReady(shared: DesignPageShared): void { shared.panel?.dock.setActions({ save: { run: () => saveSharedDesign(shared, false), enabled: true, title: L("B06_Profile_Btn_Save_Tip"), }, confirm: { run: () => saveSharedDesign(shared, true), enabled: true }, }); } /** * [저장] · [확정] 한 번 — 관(배수유역도) · 계획고는 한 벌 종단도가, 구조물 · 상단측은 종단 보기가, * 저장 앞 재계산 · 암거 옵션 · 횡단 본문은 횡단 보기가 낸다. 차례는 `saveDesign` 한 곳. */ export async function saveSharedDesign(shared: DesignPageShared, confirm: boolean): Promise { // [확정]은 횡단 본문(표준 횡단면 · 전 측점 면적)이 있어야 한다 — 숨긴 채라도 횡단 보기를 세운다. if (confirm) await shared.ensureView("cross"); const profileSave = shared.bindings.profile?.save; const crossSave = shared.bindings.cross?.save; if (confirm && !crossSave?.cross) { showToast(L("B06_Profile_Confirm_Failed"), "error"); return; } const profile = shared.profile; const side = profile?.side; await saveDesign( { projectId: shared.projectId, routeId: () => routeIdOf(shared), prepare: crossSave?.prepare, steps: { // 세션 초안이 나간 뒤 배수유역도 목록으로 한 번 더 맞춘다(시설 되붙이기 · 응답으로 화면 갱신). ...(side ? { pipes: async () => { await side.savePipes(); }, } : {}), // 계획고 초안 — 한 벌 종단도가 저장하고 다시 그린다. ...(profile ? { alignment: () => profile.save() } : {}), ...profileSave?.steps, ...crossSave?.steps, }, cross: async (isConfirm) => { if (crossSave?.cross) await crossSave.cross(isConfirm); else await profileSave?.cross?.(isConfirm); }, done: async (isConfirm) => { if (isConfirm) { showToast(L("B06_Profile_Confirm_Success"), "success"); goToWorkflowStage(shared.projectId, WORKFLOW_STEP_ROUTES[4]); return; } await profileSave?.done?.(false); await crossSave?.done?.(false); showToast(L("B06_Profile_Save_Success"), "success"); }, failed: (error, isConfirm) => { const key = isConfirm ? "B06_Profile_Confirm_Failed" : "B06_Profile_Save_Failed"; const detail = error instanceof Error ? ` ${error.message}` : ""; showToast(`${L(key)}${detail}`, "error"); }, }, { confirm }, ); }