/* ============================================================================= * design_shared.ts * B05 · B06 한 페이지의 **한 벌 자리**(계획서 24-3b) — 좌측 패널(칸 · 아래 단추 넷) · 종단도 · * 「구조물 배치」 폼을 페이지에 **한 번만** 만들고 두 보기가 같은 노드를 함께 쓴다. * * 한 벌이 받은 사용자 조작(종단도 측점 고름 · 구조물 폼 · 우클릭)은 **지금 보이는 보기**가 * `bindDesignView` 로 건 동작으로 간다. 값이 바뀐 알림(관 목록 · 횡단 재계산)은 숨은 보기도 * 받는다 — 다시 보일 때 같은 값을 들고 있게. * 시설 줄(알약 · 비정규 측점 · 관 id 장부)은 페이지 한 벌(`design_facility_lane`) — 관 알림 · 노선 따라감을 여기서 넘김. * [저장] · [확정]은 두 보기 몫을 모아 저장 한 줄기(`saveDesign`) 한 번으로 보낸다. 종단 보기의 * [확정]은 횡단 보기를 숨긴 채 세워 횡단 자료를 같은 페이지에서 얻는다(`ensureView`). * ========================================================================== */ import { ROUTES } from "@config/config_frontend"; import { showToast } from "@ui/ui_template_elements"; import { splitSidebarActions } from "@ui/ui_template_overlay"; import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale"; import { goToWorkflowStage, WORKFLOW_STEP_ROUTES } from "../b_workflow_nav"; import { readState, writeState } from "../b_page_state"; import { currentRoute, navigateTo, remountRoute } from "../router"; import { clearRouteLatestCache } from "../../B05_Profile/B05_Profile_Api_Fetch"; import { fetchCurrentRouteId } from "../../B06_Section/B06_Section_Api_Context"; 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 } from "../../B05_Profile/B05_Profile_UI_Profile_SideTool"; import { createDesignPanel, type DesignPanel } from "./design_panel"; import { createDesignProfile, type DesignProfile } from "./design_profile"; import { attachProfilePopout, attachSubPanelPopouts } from "./design_profile_popout"; import { saveDesign, type PartOverride } from "./design_save"; import type { SavePart, SavePartKey } from "./design_save_api"; import { markDesignClean } from "./design_leave"; import { resetDesign } from "./design_reset"; import type { DesignView } from "./design_dock"; import type { FacilityLane } from "./design_facility_lane"; import { routeDraftBridge, type PagePipelineOptions } from "./design_pipeline_page"; import { plantedBase } from "./design_pipeline_base"; import { isUserPick } from "./design_user_pick"; import { SECTION_STATION_INTERVAL_M } from "../../B05_Profile/logic/B05_Profile_Sections_Stations"; import { applyStructureAreaRows, structureAreaRows, } from "../../B06_Section/B06_Section_Structure_Layouts"; import { withDraftAdjusts } from "../../B06_Section/B06_Section_Section_Adjusts"; import { holdSectionDetail, invalidateSectionDetail, restoreSectionDetail, swapSectionStations, } from "../../B06_Section/B06_Section_Section_Store"; import { refreshCrossDesigns, type CrossRefreshInput, } from "../../B06_Section/B06_Section_Cross_Refresh"; import { bermSpansFromStructures, bermSpecAt } from "../../B06_Section/B06_Section_UI_Page_Persist"; import { registeredKeys, stationIntervalOf } from "../../B06_Section/B06_Section_Station_Keys"; import { detailCross } from "../../B05_Profile/logic/B05_Profile_Pipeline_Detail"; import type { CrossOutput, PipelineStation } from "./logic/design_pipeline_types"; 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; /** 한 본문 차례 조각 고침(472-6) — 구조물 · 상단측(종단 보기) 등. */ parts?: Partial>; /** 횡단 조각 — 횡단 보기 몫(표준 횡단 · 카드 고침 · 유토). */ crossPart?: (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; /** 계산 묶음 알림 — 묶음은 페이지가 세움(365-1) · 보기는 알림만 받음. */ pipeline?: Pick; } /** 한 벌 자리 — 페이지(`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]; /** 시설 줄 한 벌(462) — 알약 · 비정규 측점 · 흙덮기 관 · 관 id 장부 · 평면 마커 원천. 보기는 읽기만. */ lane: FacilityLane; /** 접힌 좌측 패널을 편다(바깥에서 고른 것이 폼에 실릴 때). */ 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 폼 값은 횡단 스펙(캐시)이 * 정본이라, 안 얹으면 한 벌 폼이 횡단 보기로 가는 순간 옛 값으로 돌아간다. 횡단 보기가 아직 * 안 섰으면 세션 칸에 담아 두었다가 설 때 얹는다(새로고침해도 남음 · 462). */ 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) return apply(chainageM, patch); const queue = readState("pipe-option-queue", shared.projectId) ?? []; writeState("pipe-option-queue", [...queue, [chainageM, patch]], shared.projectId); } type PipeOptionQueue = Array<[number, Record]>; /** 횡단 보기가 캐시 창구를 건다 — 담아 둔 옵션을 먼저 얹는다. */ export function bindPipeOptions( shared: DesignPageShared, apply: (chainageM: number, patch: Record) => void, ): void { bindDesignView(shared, "cross", { onPipeOptions: apply }); const queue = readState("pipe-option-queue", shared.projectId); if (!queue?.length) return; writeState("pipe-option-queue", null, shared.projectId); queue.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; } /** 횡단 설계가 다시 셈됨 — 두 보기에 알리고 좌측 시설 폼 자동 칸(관 길이 · 흙막이 수)을 공용 캐시 새 값으로 다시 * 그림(416 · 관을 옮긴 뒤 측점 횡단이 늦게 서도 B05 좌측 = B06 카드). */ function crossDesignsUpdated(shared: DesignPageShared, updated: ReadonlyArray): void { toAll(shared, "onCrossDesignsUpdated")(updated); shared.structures?.facility.recount(); } /** 종단도 한 벌 — 처음 부를 때 만들어 종단도 자리에 붙인다. */ export function sharedProfile(shared: DesignPageShared): DesignProfile { if (shared.profile) return shared.profile; const popoutButton = document.createElement("button"); const profile = createDesignProfile({ projectId: shared.projectId, view: shared.view(), side: drainageSide, toolLead: popoutButton, onSelectStation: (stationId) => bound(shared).profile?.onSelectStation(stationId), onResetAll: () => bound(shared).profile?.onResetAll?.(), callbacks: { onPipesChanged: (pipes) => { shared.lastPipes = pipes; shared.lane.set({ pipes }); // 시설 줄 먼저 — 보기 알림은 맞춘 값을 읽음 toAll(shared, "onPipesChanged")(pipes); }, onCrossDesignsUpdated: (updated) => crossDesignsUpdated(shared, updated), 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"), structureGroups: () => sharedStructures(shared).pickGroups(), onBasinSelected: toView(shared, "onBasinSelected"), onPipeSelected: toView(shared, "onPipeSelected"), }, }); shared.profileSlot.append(profile.root); attachProfilePopout(profile, shared.profileSlot, popoutButton); attachSubPanelPopouts(profile); shared.profile = profile; shared.lane.paint(); // 종단도가 서기 전 시설 줄이 먼저 셌으면 그 값 if (shared.projectId) startPagePipeline(shared, profile); return profile; } /** 계산 묶음 한 벌(365-1) — 종단도와 함께 페이지가 세움 · B05 · B06 어느 쪽으로 들어와도(새로고침도) 노선 * 초안을 셈. 편집기는 `routeDraftBridge` 로 이 다리를 받아 씀 · 보기 몫 알림은 `pipeline` 바인딩으로. */ function startPagePipeline(shared: DesignPageShared, profile: DesignProfile): void { const projectId = shared.projectId; const each = (fn: (hooks: NonNullable) => void): void => Object.values(shared.bindings).forEach((binding) => binding?.pipeline && fn(binding.pipeline)); routeDraftBridge(projectId, profile, { stationIntervalM: SECTION_STATION_INTERVAL_M, onCorridorStale: () => each((hooks) => hooks.onCorridorStale?.()), onFacilitiesFollowed: (far) => { shared.lane.follow(); // 가게 관 · 구조물 = 따라간 자리(편집은 묶음이 옮김) each((hooks) => hooks.onFacilitiesFollowed?.(far)); }, onUnstable: () => showToast(L("Design_Pipeline_Unstable"), "warning"), // 초안 소단 = 지금 구조물(따라간 자리)의 소단 구간(365-5 · 저장 노선은 다시 셈 창구가 세션 사본으로) bermAt: (structures) => { const spans = bermSpansFromStructures(Array.isArray(structures) ? structures : []); return (chainageM) => bermSpecAt(spans, chainageM); }, // 저장 노선 횡단 = 다시 셈 창구 한 벌(365-4) — 카드 · 유토곡선 · [저장] 패치가 보는 상세 설계를 늘 고치고 그 // 값을 묶음 출력으로(발자국 · 평면 횡단 판) · 측점이 상세와 다르면(갈아끼움 전) 묶음 엔진(439) savedCross: (detail, routeId, stations) => refreshSavedCross( { projectId, routeId, detail, edits: profile.pipelineSource().edits }, stations, (updated) => { if (updated.length) crossDesignsUpdated(shared, updated); profile.redraw(); }, ), plantedBase, // 저장 노선 종단 기준 = 입력이 심는 측점(386-3 · 보기 심기 무관) // 저장 상세 측점마다 등록 열쇠 — 초안 자리로 뜬 시설 측점의 입력 짝(주인 id · 457) stationKeys: (routeId, detail) => registeredKeys(projectId, routeId, detail.cross_sections, stationIntervalOf(detail)), structureAreas: (detail) => { // 초안 상세에도 저장 안 한 조절(옹벽 · 세월 · BOX · 집수 · 417 M3) — 면적 얹기 앞 const routeId = routeIdOf(shared); if (routeId !== null) withDraftAdjusts(detail, projectId, routeId); const rows = detail.cross_sections; applyStructureAreaRows(rows, structureAreaRows(rows)); }, // 초안 시작 — 초안 전 상태를 떠 둠 · 버리면 그대로(측점이 달라도 · 349-0o) · 상세가 없으면 묶음 측점으로 갈아끼움 holdDraft: (routeId, saved, seed) => { const hold = saved && holdSectionDetail(projectId, routeId, saved); if (hold) seed?.(hold.facilities); // 초안 전 관 · 구조물(440-4) return async (stations) => { if (!hold) { await swapSectionStations(projectId, routeId, stations); return "no-saved"; } const back = await restoreSectionDetail(projectId, routeId, hold, stations); return !back ? "no-cache" : back.same ? "saved" : "saved-diff"; }; }, }); } /** * 저장 노선 cross 차례(387-4 · 439) — 카드 자료(상세 설계)는 **늘** 다시 셈 · 묶음에 돌려줄 값만 측점 대조 * (상세 측점 ≠ 묶음 측점이면 null = 묶음 엔진). 대조가 앞서면 1 mm 어긋남에 카드 단추가 안 먹었다(요구 266). */ export async function refreshSavedCross( input: CrossRefreshInput, stations: ReadonlyArray, onUpdated: (updated: number[]) => void, ): Promise { onUpdated(await refreshCrossDesigns(input)); return detailCross(input.detail, stations); } /** 「구조물 배치」 폼 한 벌 — 알림은 지금 보기로, 상세 옵션은 횡단 보기에서만 그린다. */ 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), onListRelease: () => on().onListRelease?.(), onReveal: () => isUserPick(shared.projectId) && shared.revealLeft(), // 사용자 고름만(398-1) }, { 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); // 틀이 먼저 섰으면 좌측 자리가 빈 채로 지나갔다 — 붙인 지금 [스크롤 칸][아래 고정 단추]로 쪼갠다. const body = shared.left.closest(".ui-workflow-overlay__body"); if (body) splitSidebarActions(body); 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 }, }); } /** * 화면이 쥔 노선이 서버의 지금 노선과 다른가 — 다르면 아무것도 보내지 않고 페이지를 새로 세운다. * 노선 [확인] · 다른 탭으로 노선이 갈렸는데 옛 노선 id 로 저장이 나가 옛 노선을 다시 계산했다 * (PLAN 76-1). 묻기가 실패하면 그대로 저장한다 — 서버도 옛 노선 저장은 409 로 거절한다. */ async function routeChanged(shared: DesignPageShared): Promise { const shown = routeIdOf(shared); if (shown === null) return false; const current = await fetchCurrentRouteId(shared.projectId).catch(() => shown); if (current === shown) return false; showToast(L("B06_Profile_Route_Changed"), "error"); clearRouteLatestCache(shared.projectId); remountRoute(currentRoute()); return true; } /** * [저장] · [확정] 한 번 — 관(배수유역도) · 계획고는 한 벌 종단도가, 구조물 · 상단측은 종단 보기가, * 저장 앞 재계산 · 암거 옵션 · 횡단 본문은 횡단 보기가 낸다. 차례는 `saveDesign` 한 곳. */ export async function saveSharedDesign(shared: DesignPageShared, confirm: boolean): Promise { if (await routeChanged(shared)) return; // [저장] · [확정] 본문 = 횡단 보기 한 벌(표준 횡단면 · 전 측점 면적 · 카드 고침) — 들어온 화면 무관 · 숨긴 채라도 // 세움(B05 만 연 캐시 패치 길 지움 · 386-6) await shared.ensureView("cross"); const profileSave = shared.bindings.profile?.save; const crossSave = shared.bindings.cross?.save; const crossPart = crossSave?.crossPart; if (!crossPart) { showToast(L(confirm ? "B06_Profile_Confirm_Failed" : "B06_Profile_Save_Failed"), "error"); return; } const profile = shared.profile; const side = profile?.side; await saveDesign( { projectId: shared.projectId, routeId: () => routeIdOf(shared), prepare: crossSave?.prepare, parts: { // 관 본문은 세션 초안(기본 조각) — 배수유역도는 저장 뒤 저장분을 다시 받아 화면만 맞춤(433-7 · 판 // 메모리를 다시 보내면 B06 고침 직후 · 판이 안 선 [저장]에서 관을 잃음). ...(side ? { pipes: (base: SavePart | null) => ({ body: base?.body ?? null, after: async (response: Record | undefined) => { await base?.after?.(response); await side.reload(confirm); }, }), } : {}), // 계획고 초안 — 한 벌 종단도가 본문을 내고 저장 뒤 서버 선형으로 다시 그린다. ...(profile ? { alignment: () => profile.savePart() } : {}), ...profileSave?.parts, ...crossSave?.parts, }, crossPart, done: async (isConfirm) => { markDesignClean(shared.projectId); // 떠남 확인 — 지금 초안이 저장분(346-4) if (isConfirm) { // 서버가 정본을 다시 씀 — 다음 페이지(B07)가 브라우저 셈 · 옛 세션 사본 말고 정본을 받게(470 N1) invalidateSectionDetail(shared.projectId); showToast(L("B06_Profile_Confirm_Success"), "success"); goToWorkflowStage(shared.projectId, WORKFLOW_STEP_ROUTES[4]); return; } // 서버가 정본(계획선 · 횡단 · 면적)을 다시 썼다 — 공유 캐시를 한 번 비우고 두 보기가 새 상세 한 객체를 // 받음(416 · 보기마다 비우면 앞 보기가 받은 새 상세를 뒤 보기가 또 버림) invalidateSectionDetail(shared.projectId); 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 }, ); }