From 473d959d6070b67f4c7d3576c548c243b9a5d224 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 29 Sep 2026 22:38:35 +0900 Subject: [PATCH] =?UTF-8?q?refactor(B05=C2=B7B06):=20=EC=A7=84=EC=A7=9C=20?= =?UTF-8?q?=ED=95=9C=20=EB=B2=8C=20=C2=B7=20=EA=B0=80=EC=9A=B4=EB=8D=B0=20?= =?UTF-8?q?=EB=B3=B4=EA=B8=B0=20=EC=A0=84=ED=99=98(PLAN=2024-3b)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 좌측 패널 · 아래 단추 넷 · 종단도 · 구조물 배치 폼을 페이지에 한 번만 만듦(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 Claude-Session: https://claude.ai/code/session_01KmELpdXFDRZHhfbGuRhSmL --- A00_Common/design/design_page.css | 47 +- A00_Common/design/design_page.ts | 209 +++-- A00_Common/design/design_reset.ts | 4 +- A00_Common/design/design_shared.ts | 299 +++++++ .../design/design_view_cross.ts | 352 ++++---- A00_Common/design/design_view_profile.ts | 662 +++++++++++++++ A00_Common/router.ts | 7 + B05_Profile/B05_Profile_Api_Structures.ts | 2 +- B05_Profile/B05_Profile_UI_Drainage_Panel.ts | 3 + .../B05_Profile_UI_Drainage_Panel_Types.ts | 2 + B05_Profile/B05_Profile_UI_Page.ts | 786 ------------------ B05_Profile/B05_Profile_UI_Page_Actions.ts | 67 +- B05_Profile/B05_Profile_UI_Page_Latest.ts | 93 +++ B05_Profile/B05_Profile_UI_Page_Life.ts | 37 +- B05_Profile/B05_Profile_UI_Page_Stations.ts | 67 ++ B05_Profile/B05_Profile_UI_Panel.ts | 73 +- B05_Profile/B05_Profile_UI_Profile_Panel.ts | 28 +- .../B05_Profile_UI_Profile_Panel_Tools.ts | 2 + .../B05_Profile_UI_Structures_Panel.ts | 6 +- .../B06_Section_UI_Cross_Panel_Base.ts | 17 + .../B06_Section_UI_Cross_Structure_Panel.ts | 3 +- B06_Section/B06_Section_UI_Cross_View.ts | 100 +-- .../B06_Section_UI_Cross_View_Frame.ts | 117 +++ B06_Section/B06_Section_UI_Page_Persist.ts | 48 +- .../B06_Section_UI_Page_Structures_Panel.ts | 185 +---- .../B06_Section_UI_Page_Structures_Spec.ts | 161 ++++ B06_Section/B06_Section_UI_Section_View.ts | 32 +- .../B06_Section_UI_Section_View_Profile.ts | 106 ++- .../B06_Section_UI_Section_View_Types.ts | 3 +- .../tester/helper_design_view_parity.cjs | 98 ++- .../tester/test_b05_b06_selection_single.py | 7 +- .../tester/test_b05_stale_design_sync.py | 9 +- resources/tester/test_b06_detail_options.py | 7 +- resources/tester/test_b06_save_channels.py | 13 +- resources/tester/test_b06_save_recompute.py | 24 +- resources/tester/test_design_page_single.py | 48 +- .../tester/test_design_profile_single.py | 13 +- resources/tester/test_design_view_parity.py | 139 ++-- .../tester/test_masshaul_single_source.py | 2 +- ui_template/ui_template_workflow_layout.ts | 14 + 40 files changed, 2193 insertions(+), 1699 deletions(-) create mode 100644 A00_Common/design/design_shared.ts rename B06_Section/B06_Section_UI_Page.ts => A00_Common/design/design_view_cross.ts (63%) create mode 100644 A00_Common/design/design_view_profile.ts delete mode 100644 B05_Profile/B05_Profile_UI_Page.ts create mode 100644 B05_Profile/B05_Profile_UI_Page_Latest.ts create mode 100644 B05_Profile/B05_Profile_UI_Page_Stations.ts create mode 100644 B06_Section/B06_Section_UI_Cross_View_Frame.ts create mode 100644 B06_Section/B06_Section_UI_Page_Structures_Spec.ts diff --git a/A00_Common/design/design_page.css b/A00_Common/design/design_page.css index 99fc27b6..0aa0832d 100644 --- a/A00_Common/design/design_page.css +++ b/A00_Common/design/design_page.css @@ -1,11 +1,48 @@ -/* 한 페이지 두 보기 뼈대(계획서 24-3a) — 감싸는 칸은 자리를 차지하지 않아(display: contents) - 보기 안의 틀이 예전처럼 아울렛에 바로 붙은 것과 같게 놓인다. 숨긴 보기만 빠진다. */ +/* 한 페이지 두 보기(계획서 24-3a · 24-3b) — 감싸는 칸은 자리를 차지하지 않아(display: contents) + 진행 단계 틀이 예전처럼 아울렛에 바로 붙은 것과 같게 놓인다. 숨긴 보기만 빠진다. */ .design-page, -.design-page__view { +.design-page__view, +.design-page__profile { display: contents; } -.design-page__view[hidden], -.design-page__frame[hidden] { +.design-page__view[hidden] { + display: none; +} + +/* 가운데 = 위 칸(보기마다 다름) + 아래 종단도 한 벌. 종단 보기에서 종단도는 3D 위에 떠 있는 + 판(absolute · 3D 크기가 안 바뀜)이고, 횡단 보기에서는 카드 칸 아래 흐름에 선다. */ +.design-page__main { + position: relative; + display: flex; + flex-direction: column; + width: 100%; + height: 100%; + min-height: 0; + overflow: hidden; +} + +.design-page__center { + position: relative; + display: flex; + flex: 1 1 auto; + flex-direction: column; + min-height: 0; +} + +.design-page__center > .design-page__view > * { + flex: 1 1 auto; + min-height: 0; +} + +.design-page__main[data-view="cross"] > .design-page__profile > .b05-route-profile { + position: relative; + flex: none; +} + +/* 횡단 조정(카드 조정창 사본 · 폼 칸에 옮겨 붙은 행)은 횡단 보기 몫 — 종단 보기에서는 한 벌 폼 + 안에서 가린다(패널 뿌리 클래스가 보기를 따라 바뀐다). */ +.b05-route__panel .b06-adjust-dock, +.b05-route__panel [class*="b06-structure-panel__struct--"] { display: none; } diff --git a/A00_Common/design/design_page.ts b/A00_Common/design/design_page.ts index ae29d393..701ab2e3 100644 --- a/A00_Common/design/design_page.ts +++ b/A00_Common/design/design_page.ts @@ -1,28 +1,38 @@ /* ============================================================================= * design_page.ts - * B05 · B06 **한 페이지 두 보기** 뼈대(계획서 24-3a). + * B05 · B06 **한 페이지 두 보기**(계획서 24-3a · 24-3b). * * 주소 둘(`#/b05-profile` · `#/b06-section`)이 이 페이지 하나를 연다. 같은 페이지 안에서 주소만 * 바뀌면 라우터가 `show(route)` 만 부른다 — 보기를 다시 만들지 않고 숨김 · 보임만 바꾼다. * 다른 페이지로 떠나면 `dispose` 로 3D · 종단도 · 횡단 · 창 듣기를 정리한다. * - * 보기 약속 `DesignPageView` — 두 길 중 하나를 구현한다: - * mount(root, host) ← 중간 단계: 보기가 좌측 · 가운데 · 종단도를 통째로 그린다 - * (지금 B05 · B06 페이지 · 보기마다 한 벌씩이지만 다시 안 만듦) - * mountCenter(center, host, shared) ← 끝 모습(24-3b): 가운데 위 칸만. 좌측 패널 · 단추 · 종단도는 - * `shared` 한 벌(한 노드)을 두 보기가 함께 쓴다 + * 틀은 하나 — 진행 단계 · 좌측 패널(칸 · 아래 단추) · 가운데 아래 종단도는 한 벌(한 노드)이고 + * (`design_shared`), 보기는 가운데 위 칸만 채운다: + * mountCenter(center, host, shared) 종단 보기 = 3D 화면 · 횡단 보기 = 횡단 카드(처음 보일 때) + * 보기가 바뀌면 좌측 칸 · 아래 단추 글자 · 종단도 판 · 진행 단계 켜진 칸 · 제목이 따라간다. * * 보기는 `host.onShow` · `onHide` · `onDispose` 로 숨김 · 보임 · 떠남 때 할 일을 건다 — - * 숨으면 3D 그리기 루프 · 창 키 · 포인터 듣기를 떼고, 보이면 가게 값으로 다시 그린다. + * 숨으면 3D 그리기 루프 · 포인터 듣기를 떼고, 보이면 가게 값으로 다시 그린다. * ========================================================================== */ -import { ROUTES, type RoutePath } from "@config/config_frontend"; -import { currentRoute, type MountedPage } from "../router"; +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 { DesignPanel } from "./design_panel"; -import type { DesignProfile } from "./design_profile"; +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, @@ -35,6 +45,13 @@ export function designViewOf(route: RoutePath): DesignView | null { 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; @@ -50,20 +67,9 @@ export interface DesignPageHost { onDispose: (fn: () => void) => void; } -/** 끝 모습(24-3b)의 한 벌 자리 — 두 보기가 같은 객체 · 같은 노드를 쓴다. */ -export interface DesignPageShared { - /** 좌측 패널 자리(아래 단추 넷은 좌측 패널 안). */ - left: HTMLElement; - /** 가운데 아래 종단도 자리. */ - profileSlot: HTMLElement; - /** 먼저 마운트한 보기가 만들어 채운다 — 뒤 보기는 있는 것을 `setView` 로 바꿔 쓴다. */ - panel: DesignPanel | null; - profile: DesignProfile | null; -} - export interface DesignPageView { - mount?: (root: HTMLElement, host: DesignPageHost) => void | Promise; - mountCenter?: ( + /** 가운데 위 칸만 채운다 — 좌측 패널 · 단추 · 종단도는 `shared` 한 벌. 자료가 서면 끝난다. */ + mountCenter: ( center: HTMLElement, host: DesignPageHost, shared: DesignPageShared, @@ -72,17 +78,14 @@ export interface DesignPageView { /** 보기 정의 — 처음 보일 때 읽는다(번들을 보기별로 나눠 둔다). */ const VIEWS: Record Promise> = { - profile: async () => ({ - mount: (await import("../../B05_Profile/B05_Profile_UI_Page")).renderB05Route, - }), - cross: async () => ({ - mount: (await import("../../B06_Section/B06_Section_UI_Page")).renderB06ProfileCross, - }), + profile: async () => ({ mountCenter: (await import("./design_view_profile")).mountProfileView }), + cross: async () => ({ mountCenter: (await import("./design_view_cross")).mountCrossView }), }; interface ViewSlot { box: HTMLElement; - centered: boolean; + /** 자료가 섰다(실패해도 끝남). */ + ready: Promise; show: () => void; hide: () => void; dispose: () => void; @@ -101,40 +104,92 @@ 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 | null = null; - let frame: (DesignPageShared & { root: HTMLElement; center: HTMLElement }) | null = null; + let current: DesignView = designViewOf(currentRoute()) ?? "profile"; + let layout: WorkflowLayoutHandle | null = null; + let disposedPage = false; - /** 끝 모습의 틀 — mountCenter 보기가 처음 올 때만 만든다(틀 꾸밈은 24-3b). */ - const ensureFrame = (): NonNullable => { - if (frame) return frame; - const frameRoot = document.createElement("div"); - frameRoot.className = "design-page__frame"; - 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); - frameRoot.append(left, main); - page.append(frameRoot); - frame = { root: frameRoot, center, left, profileSlot, panel: null, profile: null }; - return frame; + 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 mountView = (view: DesignView): ViewSlot => { + /** 한 벌이 보기를 따라간다 — 좌측 칸 · 단추 글자 · 종단도 판 · 진행 단계 · 제목. */ + 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 = true; + let visible = visibleAtStart; let disposed = false; const box = document.createElement("div"); box.className = "design-page__view"; box.dataset.view = view; - page.append(box); + box.hidden = !visibleAtStart; + center.append(box); const host: DesignPageHost = { view, visible: () => visible && !disposed, @@ -143,13 +198,22 @@ export function renderDesignPage(root: HTMLElement): MountedPage { 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, - centered: false, + ready, + // 숨긴 채 세운 보기(종단 보기 [확정])도 처음 보일 때 이 알림으로 가게 값을 다시 읽는다. show() { visible = true; box.hidden = false; - if (frame) frame.root.hidden = !slot.centered; life.show.forEach(run); }, hide() { @@ -162,31 +226,19 @@ export function renderDesignPage(root: HTMLElement): MountedPage { cleanups.reverse().forEach(run); }, }; - void VIEWS[view]() - .then((def) => { - if (disposed) return; - if (def.mountCenter) { - const shared = ensureFrame(); - slot.centered = true; - shared.center.append(box); - shared.root.hidden = !visible; - return def.mountCenter(box, host, shared); - } - if (frame) frame.root.hidden = true; - return def.mount?.(box, host); - }) - .catch((error) => console.error(`[design_page] 보기 마운트 실패: ${view}`, error)); + slots.set(view, slot); return slot; - }; + } const show = (view: DesignView): void => { - if (current === view) return; const started = performance.now(); - if (current) slots.get(current)?.hide(); + if (slots.has(current) && current !== view) slots.get(current)?.hide(); current = view; const slot = slots.get(view); - if (slot) slot.show(); - else slots.set(view, mountView(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: [] }; @@ -194,18 +246,19 @@ export function renderDesignPage(root: HTMLElement): MountedPage { probe.__designPage.switchMs.push(Math.round(performance.now() - started)); }; - show(designViewOf(currentRoute()) ?? "profile"); + show(current); return { covers: (route) => designViewOf(route) !== null, show: (route) => { const view = designViewOf(route); - if (view) show(view); + if (view && view !== current) show(view); }, dispose() { + disposedPage = true; slots.forEach((slot) => slot.dispose()); slots.clear(); - current = null; + shared.profile?.dispose(); delete (window as unknown as { __designPage?: unknown }).__designPage; }, }; diff --git a/A00_Common/design/design_reset.ts b/A00_Common/design/design_reset.ts index 25a6e0f6..4c92ccb8 100644 --- a/A00_Common/design/design_reset.ts +++ b/A00_Common/design/design_reset.ts @@ -16,7 +16,7 @@ import { showToast, } from "@ui/ui_template_elements"; import { clearDrafts } from "./../b_page_state"; -import { navigateTo } from "./../router"; +import { remountRoute } from "./../router"; import { clearRouteLatestCache, resetRouteDesign } from "../../B05_Profile/B05_Profile_Api_Fetch"; import { clearAlignmentDrafts } from "../../B05_Profile/B05_Profile_UI_Profile_Edit"; import { invalidateSectionDetail } from "../../B06_Section/B06_Section_Section_Store"; @@ -50,7 +50,7 @@ export async function resetDesign( // 측점에서 원지반선과 만나지 않는다(2026-09-04 실측: 새로고침 후 최대 6.0m 어긋남). clearAlignmentDrafts(); showToast(L("B05_Route_Reset_Success"), "success"); - navigateTo(ROUTES.B05_PROFILE); + remountRoute(ROUTES.B05_PROFILE); // 같은 페이지라도 다시 세운다 — 보기만 바꾸면 옛 화면이 남음 } catch (error) { showToast(error instanceof Error ? error.message : L("B05_Route_Reset_Failed"), "error"); } finally { diff --git a/A00_Common/design/design_shared.ts b/A00_Common/design/design_shared.ts new file mode 100644 index 00000000..77815407 --- /dev/null +++ b/A00_Common/design/design_shared.ts @@ -0,0 +1,299 @@ +/* ============================================================================= + * 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; + /** 횡단 보기가 서기 전에 종단 보기가 고친 관 옵션 — 횡단 보기가 서면 캐시에 얹는다. */ + 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: toAll(shared, "onPipesChanged"), + 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" }, + ); + 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 }, + ); +} diff --git a/B06_Section/B06_Section_UI_Page.ts b/A00_Common/design/design_view_cross.ts similarity index 63% rename from B06_Section/B06_Section_UI_Page.ts rename to A00_Common/design/design_view_cross.ts index d1204544..6d10d713 100644 --- a/B06_Section/B06_Section_UI_Page.ts +++ b/A00_Common/design/design_view_cross.ts @@ -1,73 +1,95 @@ -import { createDesignSync } from "./B06_Section_UI_Page_Design_Sync"; -import { CURRENT_PROJECT_ID_KEY, ROUTES } from "@config/config_frontend"; -import { leaveForDashboard } from "../A00_Common/b_missing_data_guard"; -import { readByKey, stateKey, writeByKey } from "../A00_Common/b_page_state"; -import { navigateTo } from "../A00_Common/router"; +/* ============================================================================= + * design_view_cross.ts + * 한 페이지의 **횡단 보기**(계획서 24-3b) — 가운데 위 칸 = 횡단 카드(처음 보일 때 그린다 · + * 그 안에서 스크롤). 좌측 패널 · 아래 단추 · 종단도는 한 벌(`design_shared`)을 종단 보기와 + * 같은 노드로 쓴다. + * + * 여기서는 횡단 보기 몫만 건다 — 좌측 횡단 칸 재료(횡단 보기 설정 · 표준 횡단면) · 구조물 폼 + * 알림 · 카드 조정 제어 · 저장 몫. (옛 `B06_Section_UI_Page.ts` 의 본체다. 저장 흐름은 + * `_Page_Persist`, 설계 반영 · 재계산은 `_Page_Design_Sync` 가 맡는다.) + * + * 종단 보기의 [확정]이 횡단 자료를 얻으려고 이 보기를 **숨긴 채** 세우기도 한다 — 숨긴 칸은 + * 폭이 0 이라 카드는 그리지 않고, 처음 보일 때 보임 알림이 그린다. + * ========================================================================== */ + +import { createDesignSync } from "../../B06_Section/B06_Section_UI_Page_Design_Sync"; +import { leaveForDashboard } from "../b_missing_data_guard"; +import { readByKey, stateKey, writeByKey } from "../b_page_state"; import { showToast } from "@ui/ui_template_elements"; -import { mountMissingStationNotice } from "./B06_Section_UI_Missing_Stations"; -import type { StructureInstance, StructureType } from "../B05_Profile/B05_Profile_Api_Structures"; -import { createWorkflowLayout } from "@ui/ui_template_workflow_layout"; -import { workflowSteps } from "../A00_Common/b_page_scaffold"; -import { - fetchWorkflowState, - goToWorkflowStage, - WORKFLOW_STEP_ROUTES, - type WorkflowState, -} from "../A00_Common/b_workflow_nav"; +import { mountMissingStationNotice } from "../../B06_Section/B06_Section_UI_Missing_Stations"; +import type { + StructureInstance, + StructureType, +} from "../../B05_Profile/B05_Profile_Api_Structures"; import { fetchSectionContext, getSections, type SectionContextResponse, type SectionDetailResponse, type StandardCrossSection, -} from "./B06_Section_Api_Fetch"; -import { createStationControls } from "./B06_Section_UI_Page_Station_Controls"; +} from "../../B06_Section/B06_Section_Api_Fetch"; +import { createStationControls } from "../../B06_Section/B06_Section_UI_Page_Station_Controls"; import { - confirmCurrentSections, createCutSlopeStore, createRockBoundaryStore, - saveCurrentSections, + crossSaveParts, type SectionPersistContext, -} from "./B06_Section_UI_Page_Persist"; -import { maxToeFitHalfWidth } from "./B06_Section_UI_Cross_Fit"; -import { readStructurePick } from "../B05_Profile/B05_Profile_UI_Structure_Pick_Session"; -import { NO_SELECTION, selectDesign } from "../A00_Common/design/design_selection"; -import { applyStructurePick } from "./B06_Section_UI_Page_Structure_Pick"; -import { createSectionView } from "./B06_Section_UI_Section_View"; -import { revetWallSpec } from "./B06_Section_UI_Cross_Culvert_Const"; +} from "../../B06_Section/B06_Section_UI_Page_Persist"; +import { maxToeFitHalfWidth } from "../../B06_Section/B06_Section_UI_Cross_Fit"; +import { readStructurePick } from "../../B05_Profile/B05_Profile_UI_Structure_Pick_Session"; +import { NO_SELECTION, PIPE_MATCH_M, selectDesign } from "./design_selection"; +import { applyStructurePick } from "../../B06_Section/B06_Section_UI_Page_Structure_Pick"; +import { createSectionView } from "../../B06_Section/B06_Section_UI_Section_View"; +import { revetWallSpec } from "../../B06_Section/B06_Section_UI_Cross_Culvert_Const"; import { applyPipeOptionsToCache, type PipeOptionsContext, -} from "./B06_Section_UI_Page_Pipe_Options"; +} from "../../B06_Section/B06_Section_UI_Page_Pipe_Options"; import { createStandardPanel, effectiveStandardCross, rememberRockBoundaryDefault, type StandardPanelController, -} from "./B06_Section_UI_Standard_Panel"; +} from "../../B06_Section/B06_Section_UI_Standard_Panel"; import { createB06StructuresPanel, isWallSelecting, wireStructureSelection, -} from "./B06_Section_UI_Page_Structures_Panel"; -import "./B06_Section_UI_Style.css"; -import "./B06_Section_UI_Style_Cross.css"; -import "./B06_Section_UI_Style_Cross_Controls.css"; -import "./B06_Section_UI_Style_Cross_Areas.css"; -import { loadSectionDetail } from "./B06_Section_Section_Store"; -import { applyStructureAreaRows, structureAreaRows } from "./B06_Section_Structure_Layouts"; -import { createSampleWidener, L } from "./B06_Section_UI_Page_Common"; -import { createCrossViewPart, createStandardSlot } from "./B06_Section_UI_Page_Panel"; -import { createDesignPanel } from "../A00_Common/design/design_panel"; -import { resetDesign } from "../A00_Common/design/design_reset"; -import type { DesignPageHost } from "../A00_Common/design/design_page"; +} from "../../B06_Section/B06_Section_UI_Page_Structures_Panel"; +import "../../B06_Section/B06_Section_UI_Style.css"; +import "../../B06_Section/B06_Section_UI_Style_Cross.css"; +import "../../B06_Section/B06_Section_UI_Style_Cross_Controls.css"; +import "../../B06_Section/B06_Section_UI_Style_Cross_Areas.css"; +import { loadSectionDetail } from "../../B06_Section/B06_Section_Section_Store"; +import { + applyStructureAreaRows, + structureAreaRows, +} from "../../B06_Section/B06_Section_Structure_Layouts"; +import { createSampleWidener, L } from "../../B06_Section/B06_Section_UI_Page_Common"; +import { + createCrossViewPart, + createStandardSlot, +} from "../../B06_Section/B06_Section_UI_Page_Panel"; +import type { DesignPageHost } from "./design_page"; +import { + bindDesignView, + bindPipeOptions, + designReady, + sharedPanel, + type DesignPageShared, +} from "./design_shared"; import "@util/common_util_mass_haul.css"; -export async function renderB06ProfileCross( - root: HTMLElement, - host?: DesignPageHost, +/** 횡단면도 **표시** 반폭 기본값(m) — 2026-08-19 사용자 지시로 20 → 12. 계산·보유 샘플 폭은 + * 백엔드 기본(SECTION_CROSS_HALF_WIDTH_M = 20m) 그대로라 12는 표시 범위만 좁힌다. */ +const DISPLAY_HALF_WIDTH_DEFAULT_M = 12; + +export async function mountCrossView( + center: HTMLElement, + host: DesignPageHost, + shared: DesignPageShared, ): Promise { - const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY); + const projectId = shared.projectId || null; let currentRouteId: number | null = null; let sectionDetail: SectionDetailResponse | null = null; let stationInterval: number | undefined; @@ -76,14 +98,9 @@ export async function renderB06ProfileCross( const standardPanelSlot = createStandardSlot(); let standardPanel: StandardPanelController | null = null; - // 높이 배율 옵션은 폐지 — 항상 1배율(2026-08-06 사용자 지시). // 「횡단 보기 설정」 칸 재료(반폭 입력 + [보기 반폭 적용])는 `_Page_Panel` 이 만든다. const crossView = createCrossViewPart(() => void applyCrossHalfWidth()); const crossHalfWidthField = crossView.field; - /** 횡단면도 **표시** 반폭 기본값(m) — 2026-08-19 사용자 지시로 20 → 12. 계산·보유 - * 샘플 폭은 백엔드 기본(SECTION_CROSS_HALF_WIDTH_M = 20m) 그대로라 12는 표시 - * 범위만 좁힌다(12 ≤ 20이므로 재계산 없음). 계산 범위와 혼동 금지. */ - const DISPLAY_HALF_WIDTH_DEFAULT_M = 12; /** 목록·폼에서 고른 시설의 측점 카드를 선택·스크롤 — 가장 가까운 카드로 간다. */ const focusStationAt = (chainageM: number): void => { @@ -94,30 +111,35 @@ export async function renderB06ProfileCross( ); sectionView.focusStation(nearest.station_id); }; + /** 그 누가거리를 가진 측점 카드(관 찾는 거리 한 값). */ + const ownerAt = (chainageM: number) => + sectionDetail?.cross_sections.find( + (section) => Math.abs(section.chainage_m - chainageM) < PIPE_MATCH_M, + ); - // 「구조물 배치」 — B05와 같은 컨테이너·하단 목록 템플릿(2026-08-29 일원화). - // 하단 고정 dock 도 B05와 같은 구조: [구조물 목록][구분선][액션 버튼 행]. + // 「구조물 배치」 — 종단 보기와 같은 폼 한 벌에 횡단 보기 동작을 건다(계획서 24-3b). let structureMarksSink: ((structures: StructureInstance[], types: StructureType[]) => void) | null = null; + /** 관 목록 초안을 고치면 한 벌 배수유역도가 그 초안으로 다시 선다(잇단 조작은 한 번). */ + let pipeReloadTimer = 0; const structuresPanel = createB06StructuresPanel({ projectId, - // 횡단도·3D 넘김값에서 고른 것이 폼에 실릴 때 좌측 패널을 펼친다(2026-09-04 사용자). - reveal: () => layout.setOptionsOpen(true), - // 종단 알약 레인에 같은 목록을 넘긴다 — B05 와 같은 표기(2026-09-07 사용자 지시 4). - // 뷰는 이 패널보다 **뒤에** 만들어지므로 그때 채워지는 참조를 통해 부른다. + shared, + // 종단 알약 레인에 같은 목록을 넘긴다 — 종단 보기와 같은 표기(2026-09-07 사용자 지시 4). onMarks: (structures, types) => { structureMarksSink?.(structures, types); syncBermSpans(structures); }, - // 구조물(C군 벽)이 늘거나 줄면 그 측점 횡단 제원이 달라진다 — 캐시를 버리고 다시 - // 받아 그려야 면적·유토곡선이 따라온다(2026-09-06 사용자 확정). + // 구조물(C군 벽)이 늘거나 줄면 그 측점 횡단 제원이 달라진다 — 다시 받아 그린다(2026-09-06). onStructuresChanged: () => void refreshDetailForStructures(), + onPipesWritten: () => { + window.clearTimeout(pipeReloadTimer); + pipeReloadTimer = window.setTimeout(() => shared.profile?.side?.reload(), 200); + }, detail: () => sectionDetail, // 폼 기본 높이 = 지금 도면에 그려진 순수 높이(조정창이 보여주던 값과 같은 계산). wallHeight: (chainageM, role) => { - const owner = sectionDetail?.cross_sections.find( - (section) => Math.abs(section.chainage_m - chainageM) < 0.51, - ); + const owner = ownerAt(chainageM); const culvert = owner?.culvert; if (!owner || !culvert) return null; const spec = role === "outlet" ? culvert.outlet : culvert.inlet; @@ -128,13 +150,10 @@ export async function renderB06ProfileCross( return revetWallSpec(spec, adjust, culvert.hidden_pipe === true, culvert.diameter_m) .pureHeight; }, - // 폼 형태 = 지금 도면이 쓰는 형태. 조작(조정값 m)이 있으면 그것이 정본이라 - // 저장된 옵션보다 앞선다 — 안 맞추면 폼은 저장값, 그림은 조작값이 되어 - // "유출구 형태를 바꿔도 안 바뀐다"로 보인다(2026-08-30 사용자 보고). + // 폼 형태 = 지금 도면이 쓰는 형태. 조작(조정값 m)이 있으면 그것이 정본이라 저장된 옵션보다 + // 앞선다(2026-08-30 사용자 보고: 유출구 형태를 바꿔도 안 바뀐다). wallForm: (chainageM, role) => { - const owner = sectionDetail?.cross_sections.find( - (section) => Math.abs(section.chainage_m - chainageM) < 0.51, - ); + const owner = ownerAt(chainageM); const culvert = owner?.culvert; if (!owner || !culvert) return null; const spec = role === "outlet" ? culvert.outlet : culvert.inlet; @@ -144,9 +163,7 @@ export async function renderB06ProfileCross( }, // 좌·우 이름표 — +offset이 좌측이고 유입 벽은 오르막(계류) 쪽에 선다. wallSideLabels: (chainageM) => { - const owner = sectionDetail?.cross_sections.find( - (section) => Math.abs(section.chainage_m - chainageM) < 0.51, - ); + const owner = ownerAt(chainageM); if (!owner) return null; const inletLeft = (owner.uphill_side ?? "left") === "left"; return inletLeft ? { inlet: "좌", outlet: "우" } : { inlet: "우", outlet: "좌" }; @@ -162,29 +179,10 @@ export async function renderB06ProfileCross( applyPipeOptionsToCache(pipeOptionsContext, chainageM, patch), }); - // 좌측 패널 = 등록부가 칸을 차례대로 세우고 아래 고정 영역(단추 넷)을 붙인다. 순서: 구조물 배치 - // (최상단 — 2026-08-29 사용자 지시) → 횡단 보기 설정 → 표준. 자료가 오기 전에는 [저장]·[확정]을 - // 잠근다(`updateActionState`). 확정은 임시저장과 같은 내용 저장 후 stage 2·3을 닫고 B07 상세설계로 - // 넘어간다(2026-08-08 워크플로우 재정의). - const design = createDesignPanel( - { - projectId, - parts: { - structures: structuresPanel, - crossView, - standardSlot: standardPanelSlot, - }, - dock: { - reset: { run: () => resetDesign(projectId ?? "", currentRouteId) }, - save: { run: () => saveCurrentSections(persistContext), enabled: false }, - confirm: { run: () => confirmCurrentSections(persistContext), enabled: false }, - switchView: { run: () => navigateTo(ROUTES.B05_PROFILE) }, - }, - }, - "cross", - ); - const leftForm = design.root; - const saveTip = L("B06_Profile_Btn_Save_Tip"); + // 좌측 패널 = 한 벌 등록부 — 횡단 보기 칸 재료만 넣는다. 칸 차례: 구조물 배치(최상단 — + // 2026-08-29 사용자 지시) → 횡단 보기 설정 → 표준. 아래 단추 넷 · 저장은 한 벌이 낸다. + Object.assign(shared.parts, { crossView, standardSlot: standardPanelSlot }); + sharedPanel(shared); // 설계 선택 반영·재계산·소단 동기화는 따로 뗀 모듈이 맡는다(2026-09-13 분리). const { @@ -204,12 +202,9 @@ export async function renderB06ProfileCross( async function refreshDetailForStructures(): Promise { if (!projectId || currentRouteId === null) return; try { - if (projectId && currentRouteId !== null) { - sectionDetail = await loadSectionDetail(projectId, currentRouteId); - } + sectionDetail = await loadSectionDetail(projectId, currentRouteId); // 면적을 **먼저** 다시 얹는다 — 유토곡선은 카드보다 앞서 계산되므로, 카드 렌더가 - // 고치는 것만으로는 곡선이 옛 면적으로 남는다(2026-09-06 실측: 카드는 바뀌는데 - // 최종 누가토량이 그대로였음). + // 고치는 것만으로는 곡선이 옛 면적으로 남는다(2026-09-06 실측). if (sectionDetail) { applyStructureAreaRows( sectionDetail.cross_sections, @@ -262,23 +257,17 @@ export async function renderB06ProfileCross( refreshCard: (chainageM) => sectionView.refreshCard(chainageM), recompute: (chainageM) => recomputeIfRock(chainageM), }); - const rockOffsets = rockStore.offsets; - const rockBoundaryControl = rockStore.control; // 측점별 암 절토 경사(2026-09-07 사용자 지시) — 암 경계선과 같은 꼴의 세션 저장소다. - // 전체를 바꾸는 자리는 좌측 [표준 횡단면 설정]이고, 여기 값은 그 측점 하나만 덮는다. const cutSlopeStore = createCutSlopeStore({ sessionKey: () => stateKey("cutslope", projectId, currentRouteId), standard: () => (projectId ? effectiveStandardCross(projectId) : null), refreshCard: (chainageM) => sectionView.refreshCard(chainageM), recompute: (chainageM) => recomputeIfRock(chainageM), }); - const cutSlopeRatios = cutSlopeStore.ratios; const stationControls = createStationControls({ - // 키는 등록표(`b_page_state`)가 만든다 — 이름만 넘기면 통·범위·옛 키 이관이 따라온다. - // 형변환을 두지 않는다: 등록표에 없는 이름을 쓰면 **컴파일에서** 걸린다 - // (2026-09-06 `extraspan` 누락으로 B06 이 안 뜬 뒤 막음). + // 키는 등록표(`b_page_state`)가 만든다 — 등록표에 없는 이름을 쓰면 **컴파일에서** 걸린다. sessionKey: (kind) => stateKey(kind, projectId, currentRouteId), refreshCard: (chainageM) => sectionView.refreshCard(chainageM), detail: () => sectionDetail, @@ -288,16 +277,14 @@ export async function renderB06ProfileCross( projectId: () => projectId, onSaveError: (message) => showToast(`배수관 구간값 저장 실패 — ${message}`, "error"), }); - const { stationWidth: stationWidthControl, revetOffset: revetOffsetControl } = stationControls; - const { widths: stationWidths, inletStructures, basinAdjustments } = stationControls; const sectionView = createSectionView( (chainageM, change) => { void handleDesignChange(chainageM, change); }, - rockBoundaryControl, - stationWidthControl, - revetOffsetControl, + rockStore.control, + stationControls.stationWidth, + stationControls.revetOffset, stationControls.inletStructure, stationControls.extraWalls, stationControls.structureSpan, @@ -305,14 +292,14 @@ export async function renderB06ProfileCross( stationControls.ford, stationControls.box, cutSlopeStore.control, + shared, ); // 좌측 목록이 넘겨 준 구조물을 종단 알약 레인으로 보낸다(표시 통일). structureMarksSink = (structures, types) => sectionView.setStructureMarks(structures, types); // 계획선 편집(▲/▼)은 종단도 한 벌이 맡는다(계획서 24-2c) — 같은 초안 가게 · 같은 막기 규칙. - // 종단 그래프 우클릭 — B05 와 같은 메뉴로 넣고 뺀다(2026-09-12 사용자: B05·B06 은 한 - // 페이지라 같은 자리에서 되어야 한다). 어느 길로 들어와도 좌측 「구조물 배치」와 - // 같은 함수를 타므로 목록·폼·알약이 함께 선다. + // 종단 그래프 우클릭 — 종단 보기와 같은 메뉴로 넣고 뺀다(2026-09-12 사용자). 어느 길로 + // 들어와도 좌측 「구조물 배치」와 같은 함수를 타므로 목록·폼·알약이 함께 선다. sectionView.setStructureEdit({ addPipe: (chainageM) => structuresPanel.addPipeAt(chainageM), removePipe: (chainageM) => structuresPanel.removePipeAt(chainageM), @@ -340,7 +327,7 @@ export async function renderB06ProfileCross( stationControls, () => sectionDetail, structuresPanel, - // 횡단도에서 고른 부재도 같은 세션 칸에 남긴다(2026-09-04 — 두 화면이 한 페이지처럼). + // 횡단도에서 고른 부재도 같은 선택 칸에 남긴다(2026-09-04 — 두 화면이 한 페이지처럼). (chainageM, key) => selectDesign(projectId, structuresPanel.selectionAt(chainageM), { partKey: key, @@ -355,12 +342,12 @@ export async function renderB06ProfileCross( : null; if (!target) { structuresPanel.showAtChainage(null); - selectDesign(projectId, NO_SELECTION, { from: "b06" }); // 카드 해제 — 세션 선택도 비운다. + selectDesign(projectId, NO_SELECTION, { from: "b06" }); // 카드 해제 — 선택도 비운다. return; } const owner = stationControls.structureSpan.ownerOf(target); const opened = structuresPanel.showAtChainage(owner?.chainage_m ?? target.chainage_m); - // 카드만 고른 것도 세션에 남긴다 — B05로 돌아가면 그 시설이 그대로 열린다. + // 카드만 고른 것도 남긴다 — 종단 보기로 가면 그 시설이 그대로 열린다. if (!isWallSelecting()) selectDesign(projectId, opened, { from: "b06" }); structureSelection.syncInletStructure(); // 카드만 고른 경우엔 구조물(벽·구체) 선택을 비운다 — 조정창이 저절로 뜨면 안 된다 @@ -371,10 +358,11 @@ export async function renderB06ProfileCross( } }); - // 메인 영역: 종·횡단 도면(sectionView) 또는 안내 메시지를 표시한다. + // 가운데 위 칸: 횡단 카드(sectionView) 또는 안내 메시지. const mainArea = document.createElement("div"); mainArea.className = "b06-profile__main"; mainArea.append(sectionView.root); + center.append(mainArea); function showSectionView(): void { if (!mainArea.contains(sectionView.root)) mainArea.replaceChildren(sectionView.root); @@ -403,7 +391,7 @@ export async function renderB06ProfileCross( ); } - /** 표시 반폭 세션 키 — 페이지를 떠났다 와도 조절값이 유지되게 한다. */ + /** 표시 반폭 세션 키 — 보기를 떠났다 와도 조절값이 유지되게 한다. */ const displaySessionKey = (): string | null => stateKey("cross-display", projectId, currentRouteId); @@ -418,17 +406,8 @@ export async function renderB06ProfileCross( } } - function updateActionState(): void { - const ready = sectionDetail !== null; - design.dock.setActions({ - save: { run: () => saveCurrentSections(persistContext), enabled: ready, title: saveTip }, - confirm: { run: () => confirmCurrentSections(persistContext), enabled: ready }, - }); - } - - /** 세션에 남은 선택을 이어받는다(2026-09-04 — 두 화면이 한 페이지처럼). - * **넘김값이 바뀌었을 때만** 적용한다 — 재렌더마다 다시 돌면 사용자가 옮긴 스크롤이 - * 튀고, 진입당 한 번으로 막으면 자료가 늦게 온 경우 영영 안 열린다(2026-09-06). */ + /** 남은 선택을 이어받는다(2026-09-04 — 두 화면이 한 페이지처럼). **넘김값이 바뀌었을 때만** + * 적용한다 — 재렌더마다 다시 돌면 사용자가 옮긴 스크롤이 튄다(2026-09-06). */ let appliedPick: string | null = null; function restoreStructurePick(): void { if (!sectionDetail) return; @@ -455,21 +434,20 @@ export async function renderB06ProfileCross( } function renderSectionDetail(): void { - if (sectionDetail) { - showSectionView(); - // 높이 배율은 항상 1(2026-08-06 사용자 지시 — 옵션 폐지). - sectionView.render( - sectionDetail, - 1, - crossHalfWidth(), - stationInterval, - context?.earthwork_conversion, - context?.haul_equipment_limits, - `${projectId ?? "-"}:${currentRouteId ?? "-"}`, - context?.natural_spoil_min_ground_slope ?? undefined, - ); - restoreStructurePick(); - } + if (!sectionDetail) return; + showSectionView(); + // 높이 배율은 항상 1(2026-08-06 사용자 지시 — 옵션 폐지). + sectionView.render( + sectionDetail, + 1, + crossHalfWidth(), + stationInterval, + context?.earthwork_conversion, + context?.haul_equipment_limits, + `${projectId ?? "-"}:${currentRouteId ?? "-"}`, + context?.natural_spoil_min_ground_slope ?? undefined, + ); + restoreStructurePick(); } // [저장]·[확정]과 편집분 수집은 저장 흐름 모듈에 있다(2026-09-02 분리). @@ -484,11 +462,11 @@ export async function renderB06ProfileCross( // 실려 나가던 자리(2026-09-09 실측). reconcileDesigns: () => reconcileStaleDesigns({ force: true }), patchSources: () => ({ - rockOffsets, - cutSlopeRatios, - stationWidths, - inletStructures, - basinAdjustments, + rockOffsets: rockStore.offsets, + cutSlopeRatios: cutSlopeStore.ratios, + stationWidths: stationControls.widths, + inletStructures: stationControls.inletStructures, + basinAdjustments: stationControls.basinAdjustments, revetAdjusts: stationControls.revetAdjustsByChainage(), extraCounts: stationControls.extraCountsByChainage(), extraSpans: stationControls.extraSpansByChainage(), @@ -497,50 +475,31 @@ export async function renderB06ProfileCross( linkFlags: stationControls.linkFlagsByChainage(), }), }; + bindDesignView(shared, "cross", { save: crossSaveParts(persistContext) }); - // 한 페이지 두 보기(계획서 24-3a) — 숨으면 종단도 창 듣기를 떼고, 다시 보이면 종단 보기에서 - // 바뀐 구조물 · 관 · 계획고를 가게에서 다시 읽어 카드 · 종단도를 그린다. 떠나면 정리한다. - host?.onHide(() => sectionView.setActive(false)); - host?.onShow(() => { - sectionView.setActive(true); + // 한 페이지 두 보기(계획서 24-3a · 24-3b) — 다시 보이면 종단 보기에서 바뀐 구조물 · 관 · + // 계획고를 가게에서 다시 읽어 폼 · 카드 · 종단도를 그린다. 떠나면 카드 칸 관찰을 뗀다. + host.onShow(() => { if (currentRouteId === null) return; void structuresPanel.load().then(restoreStructurePick); void refreshDetailForStructures().then(() => reconcileStaleDesigns()); }); - host?.onDispose(() => sectionView.dispose()); - - let workflowState: WorkflowState | undefined; - let context: SectionContextResponse | null = null; - if (projectId) { - const [contextResult, workflowResult] = await Promise.allSettled([ - fetchSectionContext(projectId), - fetchWorkflowState(projectId), - ]); - if (contextResult.status === "fulfilled") context = contextResult.value; - else showToast(L("B06_Profile_Context_Failed"), "error"); - if (workflowResult.status === "fulfilled") workflowState = workflowResult.value; - } - - const layout = createWorkflowLayout({ - title: L("B06_Profile_Title"), - steps: workflowSteps(), - activeStep: 3, - leftPanel: leftForm, - mainContent: mainArea, - stages: workflowState?.stages, - currentStage: workflowState?.current_stage, - routes: WORKFLOW_STEP_ROUTES, - onStepClick: (stepIndex) => { - if (projectId) goToWorkflowStage(projectId, WORKFLOW_STEP_ROUTES[stepIndex]); - }, + host.onDispose(() => { + window.clearTimeout(pipeReloadTimer); + sectionView.dispose(); }); - layout.root.classList.add("b06-profile-layout"); - root.replaceChildren(layout.root); + let context: SectionContextResponse | null = null; if (!projectId) { renderMessage(L("B06_Profile_Error_Project")); return; } + try { + context = await fetchSectionContext(projectId); + } catch { + showToast(L("B06_Profile_Context_Failed"), "error"); + } + if (host.disposed()) return; if (!context) { renderMessage(L("B06_Profile_Context_Failed")); return; @@ -553,7 +512,6 @@ export async function renderB06ProfileCross( rockStore.setDefaults(context.rock_boundary_default_offset_m, context.rock_boundary_step_m); // 표준 횡단면 설정 패널 장착(세션값 우선, 없으면 config 기본값). - // 횡단 반폭 입력은 [전체 측점 반영] 버튼 위로 들어간다(2026-08-06 사용자 지시). standardPanel = createStandardPanel(projectId, context.standard_cross_section, applyPanelToAll); // 브라우저 횡단 계산이 옛 암 측점을 서버와 같은 기본값으로 다시 계산하게 기억해 둔다. rememberRockBoundaryDefault(projectId, context.rock_boundary_default_offset_m); @@ -569,15 +527,16 @@ export async function renderB06ProfileCross( rockStore.load(); cutSlopeStore.load(); stationControls.load(); - // 구조물 배치 데이터(타입·정본·관 지점) — 카드 로드와 병행, 화면을 잠그지 않는다. - void structuresPanel.load().then(restoreStructurePick); + // 구조물 배치 데이터(타입·정본·관 지점) — 카드 로드와 병행, 화면을 잠그지 않는다. 숨긴 채 + // 섰으면(종단 보기 [확정]) 한 벌 폼은 종단 보기 몫이라 싣지 않는다 — 보일 때 싣는다. + if (host.visible()) void structuresPanel.load().then(restoreStructurePick); try { const existing = await getSections(projectId, context.route_id); if (!existing.longitudinal) { leaveForDashboard(); return; } - // 공유 캐시 — B05가 이미 받아 뒀으면 같은 객체를 즉시 재사용한다(두 페이지 싱크의 핵심). + // 공유 캐시 — 종단 보기가 이미 받아 뒀으면 같은 객체를 즉시 재사용한다. sectionDetail = await loadSectionDetail(projectId, context.route_id); // 구조물 초안(저장 안 한 벽)은 저장소가 이미 얹어 준다. 면적만 여기서 다시 얹으면 // 첫 화면의 카드·유토곡선이 초안 기준으로 선다(2026-09-06 실측). @@ -599,16 +558,10 @@ export async function renderB06ProfileCross( // 확정 이력의 표준 횡단면 설정값 복원(진행 중 세션 편집값이 있으면 패널이 무시). if (storedOptions?.standard_cross_section) standardPanel?.applyStored(storedOptions.standard_cross_section); - /* 입력칸의 기준값은 **표시 기본 12m**(2026-08-19 사용자 지시, 위에서 이미 넣었다). - * - * ⚠ `data.options.cross_half_width_m`은 종단·횡단을 만들 때 쓴 **샘플링(계산) - * 반폭**이지 사용자가 고른 표시 폭이 아니다(엔진이 offsets 범위를 만들 때 쓰고 - * 그대로 저장한다). 그 값으로 입력칸을 덮으면 표시 기본값이 언제나 계산 기본 - * 20m으로 되돌아온다 — 2026-08-19 사용자 보고의 원인이 이것이다. 그래서 여기서는 - * 읽지 않고, 사용자가 실제로 고른 표시 폭만 되살린다: - * ① 측점 개별 표시 반폭의 저장값(design.display_half_width_m) 대표값 - * ② 세션에 남은 마지막 조절값(같은 노선으로 돌아왔을 때) - * 표시값이 보유 샘플 폭보다 크면 [전체 측점 반영]이 알아서 재생성한다. */ + /* 입력칸의 기준값은 **표시 기본 12m**. ⚠ `data.options.cross_half_width_m`은 종단·횡단을 + * 만들 때 쓴 **샘플링(계산) 반폭**이지 사용자가 고른 표시 폭이 아니다 — 그 값으로 덮으면 + * 표시 기본값이 언제나 20m로 되돌아온다(2026-08-19 사용자 보고의 원인). 사용자가 실제로 + * 고른 표시 폭만 되살린다: ① 측점 개별 표시 반폭 저장값의 대표값 ② 세션의 마지막 조절값. */ const savedDisplayWidths = sectionDetail.cross_sections .map((section) => section.design?.display_half_width_m) .filter((width): width is number => typeof width === "number" && width > 0); @@ -616,7 +569,6 @@ export async function renderB06ProfileCross( // 측점마다 다를 수 있다 — 전역 입력칸에는 가장 넓은 값을 올린다(잘림 방지). crossHalfWidthField.input.value = Math.max(...savedDisplayWidths).toFixed(1); } - // 세션에 보관된 표시 반폭이 있으면 그것이 우선한다(사용자가 마지막으로 지정한 값). const sessionDisplayKey = displaySessionKey(); if (sessionDisplayKey) { const sessionDisplay = Number(readByKey(sessionDisplayKey)); @@ -625,12 +577,16 @@ export async function renderB06ProfileCross( } if (storedOptions?.station_interval_m && storedOptions.station_interval_m > 0) stationInterval = storedOptions.station_interval_m; + // 종단 보기에서 고친 관 옵션(세월교 · BOX 스펙)을 캐시에 얹는 창구 — 담아 둔 것부터(24-3b). + bindPipeOptions(shared, (chainageM, patch) => + applyPipeOptionsToCache(pipeOptionsContext, chainageM, patch), + ); renderSectionDetail(); // 측점이 없는 구조물 알림 — 관을 나중에 놓으면 그 측점이 안 생겨 수량에서 조용히 빠진다 // (계획서 3-14 ㉯). 만드는 것은 사용자가 누를 때만. - void mountMissingStationNotice(root, projectId, refreshDetailForStructures); + void mountMissingStationNotice(mainArea, projectId, refreshDetailForStructures); void reconcileStaleDesigns(); // 옛 암 2단계 + 종단 변경 반영 자동 재계산(E-1 + N-6) - updateActionState(); + designReady(shared); } catch (error) { const detail = error instanceof Error ? ` ${error.message}` : ""; renderMessage(L("B06_Profile_Calculate_In_B05")); diff --git a/A00_Common/design/design_view_profile.ts b/A00_Common/design/design_view_profile.ts new file mode 100644 index 00000000..4753c777 --- /dev/null +++ b/A00_Common/design/design_view_profile.ts @@ -0,0 +1,662 @@ +/* ============================================================================= + * design_view_profile.ts + * 한 페이지의 **종단 보기**(계획서 24-3b) — 가운데 위 칸 = 3D 화면. 좌측 패널 · 아래 단추 · + * 종단도(유토곡선 · 배수유역도)는 한 벌(`design_shared`)을 횡단 보기와 같은 노드로 쓴다. + * + * 여기서는 종단 보기 몫만 건다 — 3D · 좌측 종단 칸 재료 · 구조물 다리 · 종단도 알림 · 저장 몫. + * (옛 `B05_Profile_UI_Page.ts` 의 본체다. 버튼 동작은 `_Page_Actions`, 최신 노선 캐시는 + * `_Page_Latest`, 3D 측점 바는 `_Page_Stations`, 숨김 · 보임은 `_Page_Life` 가 맡는다.) + * ========================================================================== */ + +import { ROUTES } from "@config/config_frontend"; +import { showToast } from "@ui/ui_template_elements"; +import { purgeOtherProjects } from "../b_asset_cache"; +import { createProgressCircle } from "@ui/ui_template_progress"; +import { + fetchConfirmedSurface, + type SurfaceConfirmedResponse, +} from "../../B04_PreProcess/B04_PreProcess_Api_Fetch"; +import { + updateContourInterval, + type RouteLatestResponse, +} from "../../B05_Profile/B05_Profile_Api_Fetch"; +import { createRoutePanel } from "../../B05_Profile/B05_Profile_UI_Panel"; +import { createRouteProfilePanel } from "../../B05_Profile/B05_Profile_UI_Profile_Panel"; +import { leaveForDashboard } from "../b_missing_data_guard"; +import { remountRoute } from "../router"; +import { openRouteEditModal } from "../../B05_Profile/B05_Profile_UI_RouteEdit"; +import { createSelectionSync } from "../../B05_Profile/B05_Profile_UI_Selection"; +import { + readStructurePick, + restoreStructurePick, + wireStructurePick, +} from "../../B05_Profile/B05_Profile_UI_Structure_Pick_Session"; +import { NO_SELECTION, nearestWithin, selectDesign } from "./design_selection"; +import type { DesignPageHost } from "./design_page"; +import { + bindDesignView, + designReady, + forwardPipeOptions, + type DesignPageShared, +} from "./design_shared"; +import { bindProfileViewLife } from "../../B05_Profile/B05_Profile_UI_Page_Life"; +import { structureAnchorM } from "../../B05_Profile/B05_Profile_Api_Structures"; +import { createStructuresBridge } from "../../B05_Profile/B05_Profile_UI_Page_Structures"; +import { createRouteViewer } from "../../B05_Profile/B05_Profile_UI_Viewer"; +import { + irregularStationId, + isPipeStation, +} from "../../B05_Profile/B05_Profile_UI_IrregularStations"; +import { + rememberRockBoundaryDefault, + rememberStandardDefaults, +} from "../../B06_Section/B06_Section_UI_Standard_Panel"; +import { + fetchSectionContext, + type SectionDetailResponse, +} from "../../B06_Section/B06_Section_Api_Fetch"; +import { loadSectionDetail } from "../../B06_Section/B06_Section_Section_Store"; +import { migrateLegacyStations } from "../../B05_Profile/B05_Profile_Api_Structures"; +import { pipeToFacilityItem } from "../../B05_Profile/B05_Profile_UI_Structures_Panel_Types"; +import { + loadCorridorIfFresh, + refreshCorridor, + saveCorridorIfDirty, +} from "../../B05_Profile/B05_Profile_UI_Corridor"; +import { + profileSaveParts, + solveRouteAction, + type PageActionContext, +} from "../../B05_Profile/B05_Profile_UI_Page_Actions"; +import { createLatestSource, panelValuesOf } from "../../B05_Profile/B05_Profile_UI_Page_Latest"; +import { stationLinesOf } from "../../B05_Profile/B05_Profile_UI_Page_Stations"; +import type { RouteProfilePanelCallbacks } from "../../B05_Profile/B05_Profile_UI_Profile_Panel_Types"; +import "../../B05_Profile/B05_Profile_UI_Style.css"; +// 700줄 제한으로 잘라낸 조각 — 본체 **다음에** 불러야 캐스케이드 순서가 같다(2026-09-04). +import "../../B05_Profile/B05_Profile_UI_Style_Table.css"; +import "../../B05_Profile/B05_Profile_UI_Style_Drainage.css"; +import "../../B05_Profile/B05_Profile_UI_Style_Structures.css"; +import { + DEFAULT_ROAD_WIDTHS, + fetchRoadWidths, + loadUphillOverrides, + restorePoints, + saveUphillOverrides, + toBounds, + toGradeClass, +} from "../../B05_Profile/B05_Profile_UI_Page_Helpers"; + +/** 구 구조물 측점 이관을 이미 시도한 노선 — 탭 수명 동안 유지한다. */ +const migratedRoutes = new Set(); + +type PipeReport = Parameters>[0]; + +export async function mountProfileView( + center: HTMLElement, + host: DesignPageHost, + shared: DesignPageShared, +): Promise { + const activeProjectId = shared.projectId; + if (!activeProjectId) return; + // 새로고침으로 바로 들어온 경우에도 다른 프로젝트 자료는 보관함에서 지운다. + void purgeOtherProjects(activeProjectId); + + const viewer = createRouteViewer(); + // 구 우클릭 메뉴의 자유 텍스트 구조물 → 레지스트리 타입(2026-08-17 이관 매핑과 동일). + const LEGACY_TYPE_IDS: Record = { + 기성막이: "revetment", + 대피로: "refuge", + 기타: "etc", + }; + /** 고른 것을 selection 한 곳에 적는다 — 횡단 보기로 이어지는 값이다(chainageM null = 해제). */ + const rememberPick = (chainageM: number | null, structureId: string | null): void => + selectDesign(activeProjectId, chainageM === null ? NO_SELECTION : { chainageM, structureId }, { + from: "b05", + }); + /** 배수유역도가 마지막으로 알린 관 목록 — 숨어 있다 다시 보일 때 폼 · 3D 에 다시 싣는다. */ + let lastPipes: PipeReport | null = null; + const applyPipes = (pipes: PipeReport): void => { + // 횡단배수는 계획선을 그 지점에 물리는 시설이라 그래프 세로 점선·계획고 틸팅 + // 버튼이 필요하다(2026-08-17 사용자 지시). 알약 레인은 종류·선택을 맡는다. + bridge.setPipes(pipes); + panel.structures.setPipeFacilities(pipes.map(pipeToFacilityItem)); + profilePanel.setMinCoverPipes(pipes); + restorePick(); // 관 목록이 실린 뒤라야 폼이 그 시설을 찾는다. + }; + // 종단도 한 벌 — 종단 보기가 보일 때 받는 알림만 건다(계획서 24-3b). + const profilePanel = createRouteProfilePanel( + shared, + (stationId) => { + viewer.markers.selectStation(stationId); + syncIrregularSelection(stationId); + }, + // [초기선 복원] 시 그래프의 배관 투영도 지운다(관 정본은 배수유역 초기화가 맡는다). + () => bridge.clearProjectedStations(), + { + // 계획선 편집 프리뷰가 횡단 설계선을 갱신해도 3D는 **자동으로 따라오지 않는다** + // (2026-09-01 사용자 지시). 갱신은 상단 [3D 업데이트] 버튼으로 모았다. + onCrossDesignsUpdated: () => panel.setCorridorPending(true), + // 관 매설 목록 → 그래프 배관 측점선·통합 목록을 한 방향으로 맞춘다. 정본은 배수유역도의 + // 관 지점이다. 숨어 있을 때는 담아만 두고 다시 보일 때 싣는다(폼은 한 벌이라 횡단 보기 몫). + onPipesChanged: (pipes) => { + lastPipes = pipes; + if (host.visible()) applyPipes(pipes); + }, + onStructureMove: (from, to, station) => { + if (isPipeStation(station)) profilePanel.drainage.movePipe(from, to); + }, + onStructureRemove: (station) => { + if (isPipeStation(station)) profilePanel.drainage.removePipe(station.chainage_m); + }, + // 우클릭 [배관 추가] — 사이드 폼에 종류·측점을 실어 주고 [추가]로 확정(2026-08-18). + onPipeAdd: (chainage) => panel.structures.addAt(chainage, "pipe"), + onStructureAdd: (chainage, type) => + panel.structures.addAt(chainage, LEGACY_TYPE_IDS[type] ?? "etc"), + onIrregularSelect: (station) => syncIrregularSelection(irregularStationId(station.id)), + // 배수유역도에서 유역·관 마커를 고르면 그 관의 측점을 그래프·3D·사이드 패널에서도 고른다. + onBasinSelected: (chainageM) => selectStationOfPipe(chainageM), + onPipeSelected: (chainageM) => selectStationOfPipe(chainageM), + // 구조물 알약 — 그래프에서 고르면 사이드 폼도 같은 항목을 연다. 계곡 통과 시설 + // (가상 id `pipe-*`)은 관 정본 소관이라 누가거리로 되돌려 보낸다(2026-08-17). + onStructureSelect: (structureId) => { + const chainage = bridge.pipeMarkChainage(structureId); + rememberPick(bridge.markChainage(structureId), chainage === null ? structureId : null); + if (chainage !== null) { + panel.structures.selectPipeByChainage(chainage); + selectStationOfPipe(chainage); + return; + } + panel.structures.selectById(structureId); + }, + onStructureMarkMove: (structureId, toChainage) => { + const chainage = bridge.pipeMarkChainage(structureId); + if (chainage !== null) { + profilePanel.drainage.movePipe(chainage, toChainage); + return; + } + panel.structures.moveById(structureId, toChainage); + }, + onStructureTypeAdd: (chainage, typeId) => panel.structures.addAt(chainage, typeId), + }, + ); + + /** 확정 지표면 — 모델 id 와 3D 로딩에 필요한 범위만 들고 있는다(2026-09-06 호출 정리). */ + let confirmedSurface: SurfaceConfirmedResponse | null = null; + let latest: RouteLatestResponse | null = null; + let roadWidths = DEFAULT_ROAD_WIDTHS; + let currentSectionDetail: SectionDetailResponse | null = null; + /** [예상형상] 표시 상태 — 측점 바를 계획고 위로 올릴지 판단한다(패널 기본 ON). */ + let corridorVisible = true; + let routeReady = false; + let restoring = true; + // 선택 동기화 재진입 가드(3D↔그래프↔사이드바 상호 갱신의 무한 재귀 차단). + let selectionSyncing = false; + + /* ── 측점 상단측(=측구 방향) 사용자 변경분 ───────────────────────────── + * solve가 자동 판정한 uphill_side를 3D 램프 클릭으로 바꾼 값. 세션에 보관했다가 + * 저장 시 uphill_overrides로 백엔드/DB(종단 정본)에 병합한다. */ + const uphillOverrides = loadUphillOverrides(activeProjectId); + const uphillKey = (chainage: number): string => chainage.toFixed(3); + + const { syncIrregularSelection, syncBasinHighlight, selectStationOfPipe } = createSelectionSync({ + stations: () => bridge.irregularStations(), + isSyncing: () => selectionSyncing, + setSyncing: (value) => { + selectionSyncing = value; + }, + selectMarker: (id) => viewer.markers.selectStation(id), + selectGraph: (id) => profilePanel.setSelectedStation(id), + selectSidebar: (chainageM) => panel.structures.selectPipeByChainage(chainageM), + selectBasin: (chainageM) => profilePanel.drainage.selectBasinByChainage(chainageM), + selectPipeForm: (chainageM) => profilePanel.drainage.selectPipeAtChainage(chainageM), + markStation: (chainageM) => profilePanel.drainage.markStation(chainageM), + selectStructure3D: (chainageM) => viewer.structurePick.selectAtChainage(chainageM), + }); + + /** 세션에 남은 구조물 선택을 되살린다 — 횡단 보기에서 고른 것도 그대로 이어 받는다 + * (2026-09-04). 이미 무언가 골라져 있으면 건드리지 않는다. */ + function restorePick(): void { + if (panel.structures.hasSelection()) return; + const pick = viewer.structurePick; + restoreStructurePick(pick, activeProjectId, panel.structures, selectStationOfPipe); + } + + const latestSource = createLatestSource(activeProjectId); + const loadLatest = latestSource.load; + + const panel = createRoutePanel( + { + onSolve: () => void solveRouteAction(actionContext), + // 계획노선 편집 — 모달 [확인]에서 서버가 배수유역부터 다시 계산하므로, 끝나면 + // 옛 노선 기준 캐시를 버리고 페이지를 새로 세운다([초기화]와 같은 뒷정리). + onEditPlannedRoute: () => + void openRouteEditModal(activeProjectId, () => remountRoute(ROUTES.B05_PROFILE), { + // 측점 눈금 간격은 좌측 패널이 쥔 값을 그대로 넘긴다 — 모달이 따로 굳히지 않는다. + stationIntervalM: panel.values().stationInterval ?? undefined, + // 바탕 등고선 — 확정 지표면이 있으면 모달이 LAS 등고선을 쓴다(계획서 0-9 ⑥). + surfaceModelId: confirmedSurface?.model_id ?? null, + contourIntervalM: latest?.surface_params.contour_interval_m, + smooth: latest?.surface_params.smooth, + }), + onContourApply: (interval) => applyContours(interval), + onSurfaceVisible: viewer.setSurfaceVisible, + onCorridorVisible: (visible) => { + corridorVisible = visible; + viewer.setCorridorVisible(visible); + // 측점 바 기준면이 바뀐다(예상형상 위 ↔ 원지반 위) — 다시 그린다. + if (currentSectionDetail) renderStationLines(currentSectionDetail); + }, + onContoursVisible: viewer.setContoursVisible, + onAxesVisible: viewer.setAxesVisible, + onStationLinesVisible: viewer.setStationLinesVisible, + onStationLabelsVisible: viewer.setStationLabelsVisible, + onSurfaceGrayscale: viewer.setSurfaceGrayscale, + onView: viewer.setView, + onResetView: () => viewer.setView("top"), + onProjection: viewer.setProjection, + // [3D 업데이트](2026-09-01) — 밀린 계획선 편집을 예상형상·측점선에 한 번에 반영한다. + onCorridorRefresh: async () => { + if (!currentSectionDetail || !latest?.route?.id) return; + const routePoints = latest.route_points ?? []; + if (routePoints.length <= 1) return; + await refreshCorridor( + viewer, + activeProjectId, + latest.route.id, + currentSectionDetail, + routePoints, + profilePanel.alignmentSamples() ?? undefined, + ); + // 측점 바·라벨·램프도 계획고를 따라 움직여야 한다(2026-08-23 지적 ③). + renderStationLines(currentSectionDetail); + // 만든 것을 **바로 올려 둔다**(2026-09-06) — 다음 진입이 네트워크 없이 선다. + void saveCorridorIfDirty(activeProjectId, latest.route.id); + }, + onMovePoint: viewer.beginMoveSelected, + onDeletePoint: viewer.markers.deleteSelected, + onRadiusChange: (radius) => viewer.markers.updateSelected({ radius_m: radius }), + onInputChange: markStale, + onStationDisplayChange: (offset) => profilePanel.setStationDisplay(offset), + onStructuresChange: (next) => bridge.applyStructures(next), + // 계곡 통과 시설(A군) — 관 지점 정본(배수유역 패널) 경유(2026-08-17 통합). + onPipeFacilityAdd: (chainage, attributes) => + profilePanel.drainage.addPipe(chainage, attributes), + onPipeFacilityUpdate: (from, to, attributes) => { + profilePanel.drainage.updatePipeFacility(from, to, attributes); + // 한 벌 폼 — 횡단 보기 캐시(세월교 · BOX 스펙)도 같은 값으로(계획서 24-3b). + forwardPipeOptions(shared, from, attributes.options as Record); + }, + onPipeFacilityRemove: (chainage) => profilePanel.drainage.removePipe(chainage), + onPipeFacilitySelect: (chainage) => { + rememberPick(chainage, null); // 목록 선택도 횡단 보기로 이어진다. + if (selectionSyncing) return; + selectionSyncing = true; + try { + // null = 사이드 재클릭 해제 — 그래프·3D·배수유역도 선택도 함께 푼다(2026-08-18). + const station = + chainage === null + ? undefined + : nearestWithin(bridge.irregularStations(), chainage, (entry) => entry.chainage_m); + const id = station ? irregularStationId(station.id) : null; + viewer.markers.selectStation(id); + profilePanel.setSelectedStation(id); + syncBasinHighlight(id); + // 배수유역 마커 선택 + 부속 옵션 폼도 연다(2026-08-17 전역 선택 동기화). + profilePanel.drainage.selectPipeAtChainage(chainage); + } finally { + selectionSyncing = false; + } + }, + onStructureSelect: (structure) => { + // 목록에서 고른 구조물도 횡단 보기로 이어진다 — 해제(null)는 여기서 적지 않는다(관 폼을 + // 여는 길이 구조물 폼을 먼저 비우며 이 콜백을 null 로 부르기 때문). + if (structure) rememberPick(structureAnchorM(structure), structure.structure_id ?? null); + if (selectionSyncing) return; + selectionSyncing = true; + try { + profilePanel.setSelectedStructure(structure?.structure_id ?? null); + } finally { + selectionSyncing = false; + } + }, + }, + shared, + ); + + wireStructurePick(viewer.structurePick, activeProjectId, panel.structures, selectStationOfPipe); + + /** 입력·마커 변경 시 측점 라인만 다시 그린다. 지형 구분·등급·기울기 세부 입력은 종단 그래프의 + * 위반 판정 기준까지 **즉시** 갈아 끼운다(2026-08-19 사용자 지시 6). */ + function markStale(): void { + if (restoring || !routeReady) return; + profilePanel.setGradeLimit(panel.gradeLimitPct()); + // 횡단배수 최소고 강제 체크는 재계산 없이 편집 가드에만 즉시 반영된다(2026-09-01). + profilePanel.setEnforceMinCover(panel.values().enforcePipeClearance); + if (currentSectionDetail) renderStationLines(currentSectionDetail); + } + + viewer.markers.onChange(markStale); + viewer.markers.onSelectionChange(panel.setSelected); + viewer.markers.onStationSelectionChange((stationId) => { + profilePanel.setSelectedStation(stationId); + syncIrregularSelection(stationId); + }); + // 3D 램프 클릭 → 그 측을 상단측(측구 방향)으로 지정하고 세션 보관 + 램프 재렌더. + viewer.markers.onUphillPick((stationId, side) => { + if (!currentSectionDetail) return; + const base = currentSectionDetail.longitudinal.stations.find( + (station) => station.station_id === stationId, + ); + let chainage = base?.chainage_m; + if (chainage === undefined) { + const prefix = irregularStationId(""); + if (stationId.startsWith(prefix)) { + const id = stationId.slice(prefix.length); + chainage = bridge.irregularStations().find((entry) => entry.id === id)?.chainage_m; + } + } + if (chainage === undefined) return; + uphillOverrides.set(uphillKey(chainage), side); + saveUphillOverrides(activeProjectId, uphillOverrides); + renderStationLines(currentSectionDetail); + }); + viewer.root.append(panel.viewControls); + + function restorePanel(next: RouteLatestResponse): void { + panel.restore(panelValuesOf(next)); + viewer.markers.setPoints(restorePoints(next)); + } + + function renderStationLines(detail: SectionDetailResponse): void { + const lines = stationLinesOf({ + detail, + irregular: bridge.irregularStations(), + routePoints: latest?.route_points ?? [], + liveSamples: profilePanel.alignmentSamples(), + corridorVisible, + uphillOverrides, + }); + viewer.renderStationLines(lines, roadWidths[panel.values().gradeClass] / 2); + } + + /** 구조물·계곡 통과 시설을 사이드 목록·그래프·3D에 맞추는 다리(정본 저장까지 맡는다). */ + const bridge = createStructuresBridge({ + projectId: activeProjectId, + panel: () => panel, + profilePanel: () => profilePanel, + renderStationLines: () => { + if (currentSectionDetail) renderStationLines(currentSectionDetail); + }, + isRestoring: () => restoring, + }); + // 3D 노선 우클릭 → 구조물 배치 메뉴(그래프·배수유역도와 동일, 2026-08-19). + bridge.mountViewerMenu(viewer, () => latest?.route_points ?? []); + + function renderSections(detail: SectionDetailResponse, routeId?: number): void { + currentSectionDetail = detail; + profilePanel.render(detail, panel.values().stationInterval ?? undefined, routeId); + // 저장된 계획선의 정책은 만들 당시 기준이라 지금 설정(임도 종류·설계속도·지형)과 + // 다를 수 있다 — 그릴 때마다 현재 기준으로 맞춘다(2026-08-19). + profilePanel.setGradeLimit(panel.gradeLimitPct()); + profilePanel.setEnforceMinCover(panel.values().enforcePipeClearance); + profilePanel.setStationDisplay(panel.stationDisplayOffset()); + profilePanel.setIrregularStations(bridge.irregularStations()); + renderStationLines(detail); + // 예상형상 코리도 — 저장본이 지금 정본과 맞을 때만 올린다. 낡았으면 [3D 업데이트] 대기 + // 표시만 켠다(2026-09-06 실측: 받아놓고 버리는 데다 다시 만드느라 전환이 16.7초까지 갔다). + const routePoints = latest?.route_points ?? []; + if (routePoints.length > 1) { + void loadCorridorIfFresh( + viewer, + activeProjectId, + routeId ?? latest?.route?.id, + detail, + routePoints, + profilePanel.alignmentSamples() ?? undefined, + ).then((fresh) => panel.setCorridorPending(!fresh)); + } + } + + async function restoreSections(routeId: number): Promise { + let detail: SectionDetailResponse; + profilePanel.setLoading("종단면 자료를 불러오는 중…"); + try { + // 공유 캐시 — 횡단 보기가 이미 받아 뒀으면 같은 객체를 재사용해 두 보기가 같은 값을 본다. + detail = await loadSectionDetail(activeProjectId, routeId); + } catch { + // 종횡단 데이터 자체가 없는 경우(생성 실패·최초 진입)는 빈 안내로 둔다. + currentSectionDetail = null; + profilePanel.setLoading(null); + profilePanel.clear(); + viewer.renderStationLines([], 0); + return; + } + profilePanel.setLoading(null); + try { + renderSections(detail, routeId); + // 구 확정분에 남은 비정규 측점(자유 텍스트)을 구조물 정본으로 1회 이관한다 — 멱등이지만 + // 탭 수명 동안 노선마다 한 번만 시도한다(2026-08-17 · 09-06 실측). + const legacy = detail.longitudinal.stations + .filter((station) => station.kind === "irregular") + .map((station) => ({ + chainage_m: station.chainage_m, + structure: station.structure ?? "", + })); + const migrateKey = `${activeProjectId}:${routeId}`; + if (legacy.length && !migratedRoutes.has(migrateKey)) { + migratedRoutes.add(migrateKey); + void migrateLegacyStations(activeProjectId, legacy) + .then((result) => { + if (result.migrated > 0) { + showToast(`구 구조물 측점 ${result.migrated}건을 구조물 목록으로 옮겼습니다.`); + return bridge.refreshFromServer().then(() => undefined); + } + return undefined; + }) + .catch(() => { + /* 이관 실패는 치명적이지 않다 — 다음 진입에서 다시 시도된다. */ + }); + } + } catch (error) { + // 렌더 실패를 "데이터 없음"으로 감추면 원인을 알 수 없게 된다 — 실패 사실을 드러낸다. + console.error("B05 종단면도 렌더 실패", error); + showToast( + error instanceof Error + ? `종단면도 표시 실패: ${error.message}` + : "종단면도 표시에 실패했습니다.", + "error", + ); + } + } + + function renderLatest(next: RouteLatestResponse): void { + latest = next; + routeReady = Boolean(next.route && next.route_points.length > 1); + profilePanel.setRoutePolyline(next.route_points ?? []); + if (next.route) { + const stored = next.route.algorithm_params ?? {}; + viewer.markers.renderRoute( + next.route_points, + (stored.curve_warning_segments as Array<{ + polyline_start_index: number; + polyline_end_index: number; + }>) ?? [], + ); + } + } + + async function applyContours(interval: number): Promise { + // 화면 전체를 잠그지 않는다 — 진행 표시는 [재적용] 버튼이 맡는다(CLAUDE.md 5장). + try { + await viewer.reloadContours(interval); + await updateContourInterval(activeProjectId, interval); + // 세션 캐시에도 반영해 다음 진입 시 옛 등고선 간격으로 복원되지 않게 한다. + const cached = latestSource.read(); + if (cached) { + latestSource.write({ + ...cached, + surface_params: { ...cached.surface_params, contour_interval_m: interval }, + }); + } + } catch (error) { + showToast(error instanceof Error ? error.message : "등고선 조회에 실패했습니다.", "error"); + } + } + + // [경로 계산] · 저장 몫은 따로 뗀 모듈이 맡는다(2026-09-02). + const actionContext: PageActionContext = { + projectId: activeProjectId, + latest: () => latest, + confirmedSurface: () => confirmedSurface, + routeReady: () => routeReady, + viewer: () => viewer, + panel: () => panel, + profilePanel: () => profilePanel, + bridge: () => bridge, + uphillOverrides, + persistUphillOverrides: () => saveUphillOverrides(activeProjectId, uphillOverrides), + loadLatest, + renderLatest, + restoreSections, + }; + // [저장] · [확정] · [초기화]에 보태는 종단 보기 몫 — 한 벌이 두 보기 몫을 모아 보낸다. + bindDesignView(shared, "profile", { + save: profileSaveParts(actionContext), + onReset: () => uphillOverrides.clear(), + }); + + // 한 페이지 두 보기 — 숨김 · 보임 · 떠남(계획서 24-3a · 24-3b). + bindProfileViewLife(host, { + projectId: activeProjectId, + viewer, + ready: () => !restoring, + // 다시 보이면 횡단 보기가 한 벌(폼 · 종단도)에 실어 둔 값을 종단 보기 값으로 되돌린다. + reload: async () => { + await bridge.load(); + if (lastPipes) applyPipes(lastPipes); + }, + syncPick: () => { + if (readStructurePick(activeProjectId)) { + restoreStructurePick( + viewer.structurePick, + activeProjectId, + panel.structures, + selectStationOfPipe, + ); + return; + } + viewer.markers.selectStation(null); + profilePanel.setSelectedStation(null); + }, + // 보기를 떠나는 자리 = 코리도 영구저장 시점(2026-08-23 사용자 확정) — 이동은 막지 않는다. + onHide: () => { + if (latest?.route?.id) void saveCorridorIfDirty(activeProjectId, latest.route.id); + }, + }); + + /* ── 진입 로딩 ───────────────────────────────────────────────────────── + * 화면 틀을 먼저 띄우고 자료가 끝나는 순서대로 채운다(2026-08-01 사용자 지시). + * 3D 지형은 **보조 자료라 로딩에 넣지 않는다**(2026-09-04 사용자 지시). 서클은 3D 뷰포트 + * 정중앙에 둔다(하단 종단 패널에 가려지는 것은 무방). */ + const LOAD_STEP_COUNT = 3; + const progress = createProgressCircle({ label: "노선 정보를 불러오는 중…", overlay: true }); + viewer.root.append(progress.root); + let loadedSteps = 0; + function advanceLoading(label: string): void { + loadedSteps += 1; + progress.set(Math.min(1, loadedSteps / LOAD_STEP_COUNT), label); + } + + const mainContent = document.createElement("div"); + mainContent.className = "b05-route__main"; + mainContent.append(viewer.root); + center.append(mainContent); + + try { + // ① 좌측 폼·노선 설정값 — 캐시가 있으면 그것으로 **먼저** 그리고 신선도는 뒤에서 확인한다 + // (2026-09-06). 캐시가 없을 때만 DB를 기다린다. + const cachedLatest = latestSource.read(); + const [latestResponse, sectionContext, configuredRoadWidths] = await Promise.all([ + cachedLatest ? Promise.resolve(cachedLatest) : loadLatest(true), + fetchSectionContext(activeProjectId), + fetchRoadWidths(activeProjectId), + ]); + if (host.disposed()) return; + roadWidths = configuredRoadWidths; + panel.restore({ + stationInterval: sectionContext.defaults.station_interval_m, + crossSampleInterval: sectionContext.defaults.cross_sample_interval_m, + longSampleInterval: sectionContext.defaults.long_sample_interval_m, + // 임도 종류는 프로젝트 등록(B02)이 정본이다 — 화면에서 고르지 않는다(2026-08-19). + gradeClass: toGradeClass(sectionContext.road_type), + }); + // 유토곡선용 설정 — config_system 정의 그대로 받아 횡단 보기와 같은 계산을 태운다. + profilePanel.setEarthworkContext({ + conversion: sectionContext.earthwork_conversion, + haulLimits: sectionContext.haul_equipment_limits, + naturalSpoilMinSlope: sectionContext.natural_spoil_min_ground_slope ?? undefined, + }); + // 표준횡단 config 기본값도 기억한다 — 계획선 편집 중 횡단 재계산이 브라우저에서 돈다 + // (2026-09-03 로컬 계산 전환). 프론트에 config 사본을 두지 않으려는 같은 원칙이다. + rememberStandardDefaults(activeProjectId, sectionContext.standard_cross_section); + rememberRockBoundaryDefault(activeProjectId, sectionContext.rock_boundary_default_offset_m); + restorePanel(latestResponse); + renderLatest(latestResponse); + advanceLoading("확정 지표면 모델을 확인하는 중…"); + + // ② 확정 지표면 — 모델 id·범위를 한 번에 받는다. + const confirmed = await fetchConfirmedSurface(activeProjectId); + confirmedSurface = confirmed.model_id === null ? null : confirmed; + if (!confirmedSurface) { + // 새 자료가 올라와 옛 결과가 지워진 상태 — 여기서 보여 줄 게 없다. + leaveForDashboard(); + return; + } + advanceLoading("종단면 자료를 불러오는 중…"); + + // ③ 종단면·횡단 자료 — 하단 패널을 3D보다 먼저 채운다. + if (latestResponse.route) await restoreSections(latestResponse.route.id); + // 구조물 타입 레지스트리·정본 — 노선이 없어도 목록은 보여 준다(추가는 노선 이후). + await bridge.load(); + advanceLoading(""); + designReady(shared); + // 캐시로 그렸다면 뒤에서 신선도만 확인한다 — 다른 탭이 자료를 갈아 끼웠을 때만 다시 그린다. + if (cachedLatest) { + void latestSource.verify(latestResponse, async (fresh) => { + renderLatest(fresh); + if (fresh.route?.id) await restoreSections(fresh.route.id); + }); + } + } catch (error) { + showToast(error instanceof Error ? error.message : "화면을 불러오지 못했습니다.", "error"); + } finally { + progress.remove(); + restoring = false; + restorePick(); // 관·구조물 목록 중 늦게 오는 쪽이 있어 여기서 한 번 더. + } + + // ④ 3D 지형 — 화면을 잡지 않고 뒤에서 올린다. 실패해도 나머지는 그대로 쓴다. + void (async () => { + const surface = confirmedSurface; + const current = latest as RouteLatestResponse | null; // 닫힘 안에서 채워져 흐름 분석이 못 봄 + if (!surface || !current || host.disposed()) return; + try { + // 범위는 ②에서 받아 둔 확정 응답에 이미 들어 있다 — 다시 부르지 않는다. + if (!surface.bounds) throw new Error("지표면 범위 정보를 찾을 수 없습니다."); + await viewer.loadSurface( + activeProjectId, + surface.model_id as number, + current.surface_params.method, + current.surface_params.smooth, + current.surface_params.contour_interval_m, + toBounds(surface.bounds), + ); + if (host.disposed()) return; // 올리는 사이 페이지를 떠났다. + renderLatest(current); // 지형이 올라온 뒤에야 마커·측점선이 지형 위에 놓인다. + if (currentSectionDetail) renderStationLines(currentSectionDetail); + showToast("3D 지형 준비 완료", "success"); + } catch (error) { + showToast(error instanceof Error ? error.message : "3D 지형을 불러오지 못했습니다.", "error"); + } + })(); +} diff --git a/A00_Common/router.ts b/A00_Common/router.ts index 80c8151c..b90ffc2c 100644 --- a/A00_Common/router.ts +++ b/A00_Common/router.ts @@ -92,6 +92,13 @@ export function showWithinPage(route: RoutePath): boolean { return true; } +/** 같은 페이지 안이라도 처음부터 다시 세운다 — [초기화] · 계획노선 편집처럼 옛 캐시를 버린 뒤. */ +export function remountRoute(route: RoutePath): void { + mounted?.dispose(); + mounted = null; + navigateTo(route); +} + /** 로그인 여부 (토큰 존재 확인) */ export async function isAuthenticated(): Promise { const { fetchSession } = await import("../A06_Login/A06_Login_Api_Fetch"); diff --git a/B05_Profile/B05_Profile_Api_Structures.ts b/B05_Profile/B05_Profile_Api_Structures.ts index 253595a3..f025a255 100644 --- a/B05_Profile/B05_Profile_Api_Structures.ts +++ b/B05_Profile/B05_Profile_Api_Structures.ts @@ -52,7 +52,7 @@ export interface StructureOptionField { * 치수까지** 받는다(2026-08-17 사용자 확정). 부르는 쪽이 정한다. */ export interface StructuresSectionOptions { /** 참이면 `phase: "detail"` 옵션(뒷길이·돌규격·형식 …)도 폼에 그린다. */ - includeDetail?: boolean; + includeDetail?: boolean | (() => boolean); } /** B05 배치 폼에 그릴 옵션인가 — 상세(detail)는 B06/B07 몫이라 숨긴다. */ diff --git a/B05_Profile/B05_Profile_UI_Drainage_Panel.ts b/B05_Profile/B05_Profile_UI_Drainage_Panel.ts index 82883c9a..a580b5f0 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Panel.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Panel.ts @@ -548,6 +548,9 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra projectId = nextProjectId; void loadLayers(); }, + reload() { + if (projectId && meta) void loadSaved(); + }, pipeChainages: () => pipeEditor.chainages(), movePipe(fromChainage, toChainage) { const index = pipeEditor diff --git a/B05_Profile/B05_Profile_UI_Drainage_Panel_Types.ts b/B05_Profile/B05_Profile_UI_Drainage_Panel_Types.ts index 9b8f1003..c6f308ff 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Panel_Types.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Panel_Types.ts @@ -18,6 +18,8 @@ export interface DrainagePanel { root: HTMLElement; /** 프로젝트가 정해지면 배경지도·도엽 레이어를 불러온다. */ load: (projectId: string) => void; + /** 세션 초안(관 목록)을 다시 읽어 세운다 — 다른 보기(횡단)가 관 목록을 고친 뒤(계획서 24-3b). */ + reload: () => void; /** 확정된 노선 평면 선형(사업지 좌표계 m)을 지도 위에 겹친다. */ setRoute: (points: ReadonlyArray) => void; /** 현재 관 매설 누가거리 목록(종단 테이블의 "배관" 구조물 라인과 맞추는 데 쓴다). */ diff --git a/B05_Profile/B05_Profile_UI_Page.ts b/B05_Profile/B05_Profile_UI_Page.ts deleted file mode 100644 index 1ffc7889..00000000 --- a/B05_Profile/B05_Profile_UI_Page.ts +++ /dev/null @@ -1,786 +0,0 @@ -import { dropStationsNear } from "./B05_Profile_Util_Station"; -import { CURRENT_PROJECT_ID_KEY, ROUTES } from "@config/config_frontend"; -import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale"; -import { showToast } from "@ui/ui_template_elements"; -import { purgeOtherProjects } from "../A00_Common/b_asset_cache"; -import { createProgressCircle } from "@ui/ui_template_progress"; -import { createWorkflowLayout } from "@ui/ui_template_workflow_layout"; -import { workflowSteps } from "../A00_Common/b_page_scaffold"; -import { - fetchWorkflowState, - goToWorkflowStage, - WORKFLOW_STEP_ROUTES, -} from "../A00_Common/b_workflow_nav"; -import { - fetchConfirmedSurface, - type SurfaceConfirmedResponse, -} from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; -import { - fetchLatestRoute, - readRouteLatestCache, - writeRouteLatestCache, - updateContourInterval, - type RouteLatestResponse, -} from "./B05_Profile_Api_Fetch"; -import { createRoutePanel, type RoutePanelValues } from "./B05_Profile_UI_Panel"; -import { createRouteProfilePanel } from "./B05_Profile_UI_Profile_Panel"; -import { leaveForDashboard } from "../A00_Common/b_missing_data_guard"; -import { navigateTo } from "../A00_Common/router"; -import { openRouteEditModal } from "./B05_Profile_UI_RouteEdit"; -import { createSelectionSync } from "./B05_Profile_UI_Selection"; -import { restoreStructurePick, wireStructurePick } from "./B05_Profile_UI_Structure_Pick_Session"; -import { NO_SELECTION, nearestWithin, selectDesign } from "../A00_Common/design/design_selection"; -import type { DesignPageHost } from "../A00_Common/design/design_page"; -import { bindProfileViewLife } from "./B05_Profile_UI_Page_Life"; -import { structureAnchorM } from "./B05_Profile_Api_Structures"; -import { createStructuresBridge } from "./B05_Profile_UI_Page_Structures"; -import { createRouteViewer } from "./B05_Profile_UI_Viewer"; -import { irregularStationId, isPipeStation } from "./B05_Profile_UI_IrregularStations"; -import { - rememberRockBoundaryDefault, - rememberStandardDefaults, -} from "../B06_Section/B06_Section_UI_Standard_Panel"; -import { - fetchSectionContext, - type SectionDetailResponse, -} from "../B06_Section/B06_Section_Api_Fetch"; -import { loadSectionDetail } from "../B06_Section/B06_Section_Section_Store"; -import { migrateLegacyStations } from "./B05_Profile_Api_Structures"; -import { pipeToFacilityItem } from "./B05_Profile_UI_Structures_Panel_Types"; -import { - loadCorridorIfFresh, - refreshCorridor, - saveCorridorIfDirty, -} from "./B05_Profile_UI_Corridor"; -import { - resetDesignAction, - solveRouteAction, - tempSaveAction, - type PageActionContext, -} from "./B05_Profile_UI_Page_Actions"; -import "./B05_Profile_UI_Style.css"; -// 700줄 제한으로 잘라낸 조각 — 본체 **다음에** 불러야 캐스케이드 순서가 같다(2026-09-04). -import "./B05_Profile_UI_Style_Table.css"; -import "./B05_Profile_UI_Style_Drainage.css"; -import "./B05_Profile_UI_Style_Structures.css"; -import { - DEFAULT_ROAD_WIDTHS, - fetchRoadWidths, - interpolateIrregularStations, - loadUphillOverrides, - restorePoints, - saveUphillOverrides, - toBounds, - toGradeClass, -} from "./B05_Profile_UI_Page_Helpers"; - -function L(key: keyof typeof ui_locales): string { - return ui_locales[key][currentLanguageIndex]; -} - -/** 구 구조물 측점 이관을 이미 시도한 노선 — 탭 수명 동안 유지한다. */ -const migratedRoutes = new Set(); - -export async function renderB05Route(root: HTMLElement, host?: DesignPageHost): Promise { - const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY); - if (!projectId) { - showToast("프로젝트를 먼저 선택하세요.", "error"); - return; - } - const activeProjectId: string = projectId; - // 새로고침으로 바로 들어온 경우에도 다른 프로젝트 자료는 보관함에서 지운다. - void purgeOtherProjects(activeProjectId); - - const viewer = createRouteViewer(); - // 구 우클릭 메뉴의 자유 텍스트 구조물 → 레지스트리 타입(2026-08-17 이관 매핑과 동일). - const LEGACY_TYPE_IDS: Record = { - 기성막이: "revetment", - 대피로: "refuge", - 기타: "etc", - }; - /** 고른 것을 selection 한 곳에 적는다 — B06 으로 이어지는 값이다(chainageM null = 해제). */ - const rememberPick = (chainageM: number | null, structureId: string | null): void => - selectDesign(activeProjectId, chainageM === null ? NO_SELECTION : { chainageM, structureId }, { - from: "b05", - }); - const profilePanel = createRouteProfilePanel( - activeProjectId, - (stationId) => { - viewer.markers.selectStation(stationId); - syncIrregularSelection(stationId); - }, - // [초기선 복원] 시 그래프의 배관 투영도 지운다(관 정본은 배수유역 초기화가 맡는다). - () => bridge.clearProjectedStations(), - { - // 계획선 편집 프리뷰가 횡단 설계선을 갱신해도 3D는 **자동으로 따라오지 않는다** - // (2026-09-01 사용자 지시). 디바운스 자동 갱신조차 편집 중 조작을 무겁게 만들어, - // 갱신은 상단 [3D 업데이트] 버튼으로 모았다. 여기서는 밀린 편집이 있다는 표시만 한다. - onCrossDesignsUpdated: () => panel.setCorridorPending(true), - // 관 매설 목록 → 그래프 배관 측점선·통합 목록을 한 방향으로 맞춘다. - // 정본은 배수유역도의 관 지점이며, 화면 목록은 그것을 실체화한 것이다. - onPipesChanged: (pipes) => { - // 횡단배수는 계획선을 그 지점에 물리는 시설이라 그래프 세로 점선·계획고 틸팅 - // 버튼이 필요하다(2026-08-17 사용자 지시). 알약 레인은 종류·선택을 맡는다. - bridge.setPipes(pipes); - panel.structures.setPipeFacilities(pipes.map(pipeToFacilityItem)); - restorePick(); // 관 목록이 실린 뒤라야 폼이 그 시설을 찾는다. - }, - // 그래프 측점선은 이제 배관(계곡 통과 시설) 투영뿐이다 — 수동 구조물은 - // 서클마크·구조물 배치 목록이 담당한다(2026-08-17 컨테이너 병합). - onStructureMove: (from, to, station) => { - if (isPipeStation(station)) profilePanel.drainage.movePipe(from, to); - }, - onStructureRemove: (station) => { - if (isPipeStation(station)) profilePanel.drainage.removePipe(station.chainage_m); - }, - // 우클릭 [배관 추가] — 즉시 추가 대신 사이드 폼에 종류·측점을 실어 주고, - // A군 임시 배치(세부유역 미리보기)로 이어진다. [추가]로 확정(2026-08-18). - onPipeAdd: (chainage) => panel.structures.addAt(chainage, "pipe"), - // 우클릭 메뉴의 구 항목(기성막이·대피로·기타) — 구조물 정본 타입으로 넣는다. - onStructureAdd: (chainage, type) => - panel.structures.addAt(chainage, LEGACY_TYPE_IDS[type] ?? "etc"), - onIrregularSelect: (station) => syncIrregularSelection(irregularStationId(station.id)), - // 배수유역도에서 유역을 고르면 그 관의 구조물 측점을 그래프·3D·사이드 패널에서도 고른다. - onBasinSelected: (chainageM) => selectStationOfPipe(chainageM), - // 관 마커 선택(유역 없는 관 포함)도 같은 경로로 전 화면을 맞춘다(2026-08-17). - onPipeSelected: (chainageM) => selectStationOfPipe(chainageM), - // 구조물 알약 — 그래프에서 고르면 사이드 폼도 같은 항목을 연다. 계곡 통과 시설 - // (가상 id `pipe-*`)은 관 정본 소관이라 누가거리로 되돌려 보낸다(2026-08-17). - onStructureSelect: (structureId) => { - // 고른 자리를 B06 으로 넘긴다 — 사이드 목록·3D 픽에는 있던 기록이 **알약에만 - // 빠져 있어** 종단에서 고른 구조물이 B06 횡단도에서 안 잡혔다(2026-09-06). - const chainage = bridge.pipeMarkChainage(structureId); - rememberPick(bridge.markChainage(structureId), chainage === null ? structureId : null); - if (chainage !== null) { - panel.structures.selectPipeByChainage(chainage); - selectStationOfPipe(chainage); - return; - } - panel.structures.selectById(structureId); - }, - onStructureMarkMove: (structureId, toChainage) => { - const chainage = bridge.pipeMarkChainage(structureId); - if (chainage !== null) { - profilePanel.drainage.movePipe(chainage, toChainage); - return; - } - panel.structures.moveById(structureId, toChainage); - }, - onStructureTypeAdd: (chainage, typeId) => panel.structures.addAt(chainage, typeId), - }, - ); - - /** - * 그래프·3D에서 비정규 측점을 고르면 사이드바 입력 폼에 로드해 수정/삭제할 수 있게 한다. - * 선택은 3D·그래프·사이드바가 서로를 갱신하므로, `selectionSyncing` 가드로 재진입을 막아 - * 무한 재귀(스택 오버플로우·프리즈)를 방지한다. - */ - /** 확정 지표면 — 모델 id 와 3D 로딩에 필요한 범위만 들고 있는다(2026-09-06 호출 정리). - * 예전에는 목록(`listSurfaceModels`)으로 id 를 찾고 3D 단계에서 `surface/confirmed` - * 를 또 불러 요청이 두 번 나갔다. 확정 응답 하나에 둘 다 들어 있다. */ - let confirmedSurface: SurfaceConfirmedResponse | null = null; - let latest: RouteLatestResponse | null = null; - let roadWidths = DEFAULT_ROAD_WIDTHS; - let currentSectionDetail: SectionDetailResponse | null = null; - /** [예상형상] 표시 상태 — 측점 바를 계획고 위로 올릴지 판단한다(패널 기본 ON). */ - let corridorVisible = true; - let routeReady = false; - let restoring = true; - // 선택 동기화 재진입 가드(3D↔그래프↔사이드바 상호 갱신의 무한 재귀 차단). - let selectionSyncing = false; - - /* ── 측점 상단측(=측구 방향) 사용자 변경분 ───────────────────────────── - * solve가 자동 판정한 uphill_side를 3D 램프 클릭으로 바꾼 값. 세션에 보관했다가 - * 경로 확정 시 uphill_overrides로 백엔드/DB(종단 정본)에 병합한다. */ - const uphillOverrides = loadUphillOverrides(activeProjectId); - const uphillKey = (chainage: number): string => chainage.toFixed(3); - - const { syncIrregularSelection, syncBasinHighlight, selectStationOfPipe } = createSelectionSync({ - stations: () => bridge.irregularStations(), - isSyncing: () => selectionSyncing, - setSyncing: (value) => { - selectionSyncing = value; - }, - selectMarker: (id) => viewer.markers.selectStation(id), - selectGraph: (id) => profilePanel.setSelectedStation(id), - // 통합 「구조물 배치」 목록의 계곡 통과 시설 항목을 강조한다(2026-08-17 통합). - selectSidebar: (chainageM) => panel.structures.selectPipeByChainage(chainageM), - selectBasin: (chainageM) => profilePanel.drainage.selectBasinByChainage(chainageM), - // 배관이면 배수유역 마커 선택 + 부속 옵션 폼까지 연다(2026-08-17 전역 선택 동기화). - selectPipeForm: (chainageM) => profilePanel.drainage.selectPipeAtChainage(chainageM), - markStation: (chainageM) => profilePanel.drainage.markStation(chainageM), - selectStructure3D: (chainageM) => viewer.structurePick.selectAtChainage(chainageM), - }); - - /** 세션에 남은 구조물 선택을 되살린다 — B06에서 고른 것도 그대로 이어 받는다 - * (2026-09-04). 이미 무언가 골라져 있으면 건드리지 않는다. */ - function restorePick(): void { - if (panel.structures.hasSelection()) return; - const pick = viewer.structurePick; - restoreStructurePick(pick, activeProjectId, panel.structures, selectStationOfPipe); - } - - function persistUphillOverrides(): void { - saveUphillOverrides(activeProjectId, uphillOverrides); - } - - /* ── 최신 경로/설정값 세션 캐시 ──────────────────────────────────────── - * 확정 이력이 있으면 매 진입마다 DB(latest) 조회 대신 브라우저 세션 캐시를 - * 우선 사용해 응답속도를 높인다. 캐시 미스면 latest를 조회해 적재하고, - * solve·확정 성공 시 신선한 값으로 갱신한다(세션 = 탭 단위, 탭 종료 시 소멸). */ - const readLatestCache = (): RouteLatestResponse | null => readRouteLatestCache(activeProjectId); - const writeLatestCache = (value: RouteLatestResponse): void => - writeRouteLatestCache(activeProjectId, value); - - /** 캐시 우선 latest 로드. forceFresh=true(solve/확정 직후)는 항상 DB를 읽고 캐시를 갱신한다. */ - async function loadLatest(forceFresh = false): Promise { - if (!forceFresh) { - const cached = readLatestCache(); - if (cached) return cached; - } - const fresh = await fetchLatestRoute(activeProjectId); - writeLatestCache(fresh); - return fresh; - } - - /** 이 응답이 "같은 자료"인지 가리는 서명 — 노선 번호·지표면 모델·지표면 설정. */ - const latestSignature = (value: RouteLatestResponse): string => - [ - value.route?.id ?? "", - value.route?.surface_model_id ?? "", - value.surface_params.source_filter, - value.surface_params.method, - String(value.surface_params.smooth), - ].join("|"); - - /** - * 캐시로 먼저 그린 뒤 **뒤에서** 신선도를 확인한다(2026-09-06 캐시·세션 일원화). - * - * 예전에는 진입 때마다 `loadLatest(true)`로 DB를 다시 읽었다 — 다른 탭의 재업로드로 - * 옛 노선번호가 남을까 봐 넣은 안전장치인데, 그 탓에 B05↔B06을 오갈 때마다 화면이 - * 처음부터 다시 섰다. 이제 서명(노선번호·지표면)이 달라졌을 때만 다시 그린다. - */ - async function verifyLatestFreshness(shown: RouteLatestResponse): Promise { - try { - const fresh = await fetchLatestRoute(activeProjectId); - writeLatestCache(fresh); - if (latestSignature(fresh) === latestSignature(shown)) return; - renderLatest(fresh); - if (fresh.route?.id) await restoreSections(fresh.route.id); - } catch { - /* 확인 실패는 조용히 넘긴다 — 화면은 캐시로 이미 서 있다. */ - } - } - - const panel = createRoutePanel({ - onSolve: () => void solveRouteAction(actionContext), - // 계획노선 편집 — 모달 [확인]에서 서버가 배수유역부터 다시 계산하므로, 끝나면 - // 옛 노선 기준 캐시를 버리고 페이지를 새로 세운다([초기화]와 같은 뒷정리). - onEditPlannedRoute: () => - void openRouteEditModal( - activeProjectId, - () => { - navigateTo(ROUTES.B05_PROFILE); - }, - { - // 측점 눈금 간격은 좌측 패널이 쥔 값을 그대로 넘긴다 — 모달이 따로 굳히지 않는다. - stationIntervalM: panel.values().stationInterval ?? undefined, - // 바탕 등고선 — 확정 지표면이 있으면 모달이 LAS 등고선을 쓴다(계획서 0-9 ⑥). - surfaceModelId: confirmedSurface?.model_id ?? null, - contourIntervalM: latest?.surface_params.contour_interval_m, - smooth: latest?.surface_params.smooth, - }, - ), - onTempSave: () => void tempSaveAction(actionContext), - onGoCross: () => { - // 페이지 이동 = 코리도 영구저장 시점(2026-08-23 사용자 확정) — 이동은 막지 않는다. - if (latest?.route?.id) void saveCorridorIfDirty(activeProjectId, latest.route.id); - navigateTo(ROUTES.B06_SECTION); - }, - onReset: () => void resetDesignAction(actionContext), - onContourApply: (interval) => applyContours(interval), - onSurfaceVisible: viewer.setSurfaceVisible, - onCorridorVisible: (visible) => { - corridorVisible = visible; - viewer.setCorridorVisible(visible); - // 측점 바 기준면이 바뀐다(예상형상 위 ↔ 원지반 위) — 다시 그린다. - if (currentSectionDetail) renderStationLines(currentSectionDetail); - }, - onContoursVisible: viewer.setContoursVisible, - onAxesVisible: viewer.setAxesVisible, - onStationLinesVisible: viewer.setStationLinesVisible, - onStationLabelsVisible: viewer.setStationLabelsVisible, - onSurfaceGrayscale: viewer.setSurfaceGrayscale, - onView: viewer.setView, - onResetView: () => viewer.setView("top"), - onProjection: viewer.setProjection, - // [3D 업데이트](2026-09-01) — 밀린 계획선 편집을 예상형상·측점선에 한 번에 반영한다. - onCorridorRefresh: async () => { - if (!currentSectionDetail || !latest?.route?.id) return; - const routePoints = latest.route_points ?? []; - if (routePoints.length <= 1) return; - await refreshCorridor( - viewer, - activeProjectId, - latest.route.id, - currentSectionDetail, - routePoints, - profilePanel.alignmentSamples() ?? undefined, - ); - // 측점 바·라벨·램프도 계획고를 따라 움직여야 한다(2026-08-23 지적 ③). - renderStationLines(currentSectionDetail); - // 만든 것을 **바로 올려 둔다**(2026-09-06) — 예전에는 B05→B06 이동 때만 올려서, - // 누른 뒤 새로고침하면 저장본이 낡은 채라 다음 사람이 또 만들어야 했다. - // 브라우저 보관함이 생긴 지금은 올려 두면 다음 진입이 네트워크 없이 선다. - void saveCorridorIfDirty(activeProjectId, latest.route.id); - }, - onMovePoint: viewer.beginMoveSelected, - onDeletePoint: viewer.markers.deleteSelected, - onRadiusChange: (radius) => viewer.markers.updateSelected({ radius_m: radius }), - onInputChange: markStale, - onStationDisplayChange: (offset) => profilePanel.setStationDisplay(offset), - onStructuresChange: (next) => bridge.applyStructures(next), - // 계곡 통과 시설(A군) — 관 지점 정본(배수유역 패널) 경유(2026-08-17 통합). - onPipeFacilityAdd: (chainage, attributes) => - profilePanel.drainage.addPipe(chainage, attributes), - onPipeFacilityUpdate: (from, to, attributes) => - profilePanel.drainage.updatePipeFacility(from, to, attributes), - onPipeFacilityRemove: (chainage) => profilePanel.drainage.removePipe(chainage), - onPipeFacilitySelect: (chainage) => { - rememberPick(chainage, null); // 목록 선택도 B06으로 이어진다. - if (selectionSyncing) return; - selectionSyncing = true; - try { - // null = 사이드 재클릭 해제 — 그래프·3D·배수유역도 선택도 함께 푼다(2026-08-18). - const station = - chainage === null - ? undefined - : nearestWithin(bridge.irregularStations(), chainage, (entry) => entry.chainage_m); - const id = station ? irregularStationId(station.id) : null; - viewer.markers.selectStation(id); - profilePanel.setSelectedStation(id); - // 통합 목록에서 배관 항목을 고르면 배수유역도의 그 유역도 함께 강조한다. - syncBasinHighlight(id); - // 배수유역 마커 선택 + 부속 옵션 폼도 연다(2026-08-17 전역 선택 동기화). - profilePanel.drainage.selectPipeAtChainage(chainage); - } finally { - selectionSyncing = false; - } - }, - // 3D·종단·유역도에서 고른 것이 폼에 실릴 때 좌측 패널을 펼친다(2026-09-04 사용자). - onStructureReveal: () => layout.setOptionsOpen(true), - onStructureSelect: (structure) => { - // 목록에서 고른 구조물도 B06 으로 이어진다 — 해제(null)는 여기서 적지 않는다(관 폼을 - // 여는 길이 구조물 폼을 먼저 비우며 이 콜백을 null 로 부르기 때문). - if (structure) rememberPick(structureAnchorM(structure), structure.structure_id ?? null); - if (selectionSyncing) return; - selectionSyncing = true; - try { - profilePanel.setSelectedStructure(structure?.structure_id ?? null); - } finally { - selectionSyncing = false; - } - }, - }); - - wireStructurePick(viewer.structurePick, activeProjectId, panel.structures, selectStationOfPipe); - - /** 입력·마커 변경 시 측점 라인만 다시 그린다 — 확정 게이트는 폐지(B06 통합 확정). - * 지형 구분·등급·기울기 세부 입력은 종단 그래프의 위반 판정 기준까지 **즉시** - * 갈아 끼운다(2026-08-19 사용자 지시 6 — 예전에는 재계산·재진입 때만 반영). */ - function markStale(): void { - if (restoring || !routeReady) return; - profilePanel.setGradeLimit(panel.gradeLimitPct()); - // 횡단배수 최소고 강제 체크는 재계산 없이 편집 가드에만 즉시 반영된다(2026-09-01). - profilePanel.setEnforceMinCover(panel.values().enforcePipeClearance); - if (currentSectionDetail) renderStationLines(currentSectionDetail); - } - - viewer.markers.onChange(markStale); - viewer.markers.onSelectionChange(panel.setSelected); - viewer.markers.onStationSelectionChange((stationId) => { - profilePanel.setSelectedStation(stationId); - syncIrregularSelection(stationId); - }); - // 3D 램프 클릭 → 그 측을 상단측(측구 방향)으로 지정하고 세션 보관 + 램프 재렌더. - viewer.markers.onUphillPick((stationId, side) => { - if (!currentSectionDetail) return; - const base = currentSectionDetail.longitudinal.stations.find( - (station) => station.station_id === stationId, - ); - let chainage = base?.chainage_m; - if (chainage === undefined) { - const prefix = irregularStationId(""); - if (stationId.startsWith(prefix)) { - const id = stationId.slice(prefix.length); - chainage = bridge.irregularStations().find((entry) => entry.id === id)?.chainage_m; - } - } - if (chainage === undefined) return; - uphillOverrides.set(uphillKey(chainage), side); - persistUphillOverrides(); - renderStationLines(currentSectionDetail); - }); - viewer.root.append(panel.viewControls); - - function restorePanel(next: RouteLatestResponse): void { - const options = next.route_params?.options ?? {}; - panel.restore({ - contourInterval: next.surface_params.contour_interval_m, - algorithm: next.route_params?.algorithm as RoutePanelValues["algorithm"] | undefined, - gradeClass: options.grade_class as RoutePanelValues["gradeClass"] | undefined, - paved: options.paved as boolean | undefined, - minCurveRadius: options.min_curve_radius_m as number | undefined, - maxUphillGrade: options.max_uphill_grade as number | undefined, - maxDownhillGrade: options.max_downhill_grade as number | undefined, - minUphillGrade: options.min_uphill_grade as number | undefined, - minDownhillGrade: options.min_downhill_grade as number | undefined, - allowAvoidPassThrough: options.allow_avoid_pass_through as boolean | undefined, - stationInterval: next.route_params?.station_interval_m ?? undefined, - crossSampleInterval: next.route_params?.cross_sample_interval_m ?? undefined, - longSampleInterval: next.route_params?.long_sample_interval_m ?? undefined, - terrainType: options.terrain_type as RoutePanelValues["terrainType"] | undefined, - designSpeed: options.design_speed_kph as RoutePanelValues["designSpeed"] | undefined, - maxGradePct: next.route_params?.max_grade_pct ?? undefined, - minVerticalRadius: next.route_params?.min_vertical_radius_m ?? undefined, - minTangentLength: next.route_params?.min_tangent_length_m ?? undefined, - startElevationOffset: next.route_params?.start_elevation_offset_m ?? undefined, - endElevationOffset: next.route_params?.end_elevation_offset_m ?? undefined, - enforcePipeClearance: - (next.route_params?.enforce_pipe_clearance as boolean | undefined) ?? undefined, - }); - viewer.markers.setPoints(restorePoints(next)); - } - - function renderStationLines(detail: SectionDetailResponse): void { - // 확정 때 종단 정본에 병합해 둔 비정규 측점은 그리지 않는다. 화면의 정본은 사이드바 목록이고 - // 관을 옮기면 그 목록만 따라오므로, 둘을 겹쳐 그리면 옮기기 전 자리가 그대로 남는다 - // (2026-08-02 사용자 보고). 저장분은 진입 시 `restoreSections`가 목록으로 되살린다. - const regular = detail.longitudinal.stations.filter((station) => station.kind !== "irregular"); - const injected = interpolateIrregularStations( - regular, - bridge.irregularStations(), - detail.longitudinal.length_m, - // 중심 좌표는 노선 폴리라인 기준 — 3D 마커가 노선 위에 정확히 얹힌다(2026-08-19). - latest?.route_points ?? [], - ); - // 예상형상이 켜져 있으면 측점 바를 **계획고** 기준으로 올린다 — 원지반 위에 - // 얹힌 예상형상에 묻혀 측점 기준선이 안 보이던 문제(2026-08-23 사용자 지시). - const designAt = (chainageM: number): number | null => { - if (!corridorVisible) return null; - // 편집 중에는 라이브 alignment 샘플이 정본보다 새것이다(2026-08-23 지적 ③). - const samples = - profilePanel.alignmentSamples() ?? detail.longitudinal.design_profiles?.[0]?.samples; - if (!samples?.length) return null; - let best = samples[0]; - for (const sample of samples) { - if (Math.abs(sample.chainage_m - chainageM) < Math.abs(best.chainage_m - chainageM)) { - best = sample; - } - } - return best.elevation_m; - }; - // 측점 바 양 끝 램프용 상단측: 사용자 변경분 → solve 자동 판정 순으로 적용. - // 구조물 측점과 0.1m 안에서 겹치는 규칙 측점은 지운다 — 3D 측점 띠도 종단 그래프와 - // 같은 규칙을 써야 두 화면의 측점이 어긋나지 않는다(2026-09-06). - const withUphill = [...dropStationsNear(regular, injected), ...injected].map((station) => { - const design = designAt(station.chainage_m); - return { - ...station, - // 절토 구간에서는 계획고가 지반보다 **아래**다(2026-09-04 사용자 지적). - // max 로 잡으면 코리도가 절취해 내려간 노면을 두고 막대만 원지반에 떠 있다. - // 코리도가 켜져 있으면(designAt 이 값을 줌) 계획고를 그대로 쓴다. - center_z: design !== null && station.center_z !== null ? design : station.center_z, - uphill_side: - uphillOverrides.get(uphillKey(station.chainage_m)) ?? station.uphill_side ?? null, - }; - }); - viewer.renderStationLines(withUphill, roadWidths[panel.values().gradeClass] / 2); - } - - /** 구조물·계곡 통과 시설을 사이드 목록·그래프·3D에 맞추는 다리(정본 저장까지 맡는다). */ - const bridge = createStructuresBridge({ - projectId: activeProjectId, - panel: () => panel, - profilePanel: () => profilePanel, - renderStationLines: () => { - if (currentSectionDetail) renderStationLines(currentSectionDetail); - }, - isRestoring: () => restoring, - }); - // 3D 노선 우클릭 → 구조물 배치 메뉴(그래프·배수유역도와 동일, 2026-08-19). - bridge.mountViewerMenu(viewer, () => latest?.route_points ?? []); - - function renderSections(detail: SectionDetailResponse, routeId?: number): void { - currentSectionDetail = detail; - profilePanel.render(detail, panel.values().stationInterval ?? undefined, routeId); - // 저장된 계획선의 정책은 만들 당시 기준이라 지금 설정(임도 종류·설계속도·지형)과 - // 다를 수 있다 — 그릴 때마다 현재 기준으로 맞춘다(2026-08-19). 이렇게 하지 않으면 - // 진입 직후 상단 표시줄이 옛 상한을 그대로 보여 준다. - profilePanel.setGradeLimit(panel.gradeLimitPct()); - // 횡단배수 최소고 강제 여부도 그릴 때마다 현재 설정으로 맞춘다(2026-09-01). - profilePanel.setEnforceMinCover(panel.values().enforcePipeClearance); - profilePanel.setStationDisplay(panel.stationDisplayOffset()); - profilePanel.setIrregularStations(bridge.irregularStations()); - renderStationLines(detail); - // 예상형상 코리도 — 현재 종횡단 정본 그대로 빌드/로드해 3D에 반영(2026-08-23). - // 노선 폴리라인이 아직 없으면 미룬다 — 세분 기준이 없고, 로드 순서상 - // renderLatest 후 재렌더가 오므로 그때 그린다(반복 빌드 방지). - const routePoints = latest?.route_points ?? []; - if (routePoints.length > 1) { - // 저장본이 지금 정본과 맞을 때만 올린다 — 낡았으면 18.6MB 를 받지도, 다시 만들지도 - // 않고 [3D 업데이트] 대기 표시만 켠다(2026-09-06 실측: 받아놓고 버리는 데다 다시 - // 만드느라 화면 전환이 16.7초까지 갔다). 3D 는 원래 수동인데 진입만 자동이었다. - void loadCorridorIfFresh( - viewer, - activeProjectId, - routeId ?? latest?.route?.id, - detail, - routePoints, - profilePanel.alignmentSamples() ?? undefined, - ).then((fresh) => panel.setCorridorPending(!fresh)); - } - } - - async function restoreSections(routeId: number): Promise { - let detail: SectionDetailResponse; - profilePanel.setLoading("종단면 자료를 불러오는 중…"); - try { - // 공유 캐시 — B06이 이미 받아 뒀으면 같은 객체를 재사용해 두 페이지가 항상 같은 값을 본다. - detail = await loadSectionDetail(activeProjectId, routeId); - } catch { - // 종횡단 데이터 자체가 없는 경우(생성 실패·최초 진입)는 빈 안내로 둔다. - currentSectionDetail = null; - profilePanel.setLoading(null); - profilePanel.clear(); - viewer.renderStationLines([], 0); - return; - } - profilePanel.setLoading(null); - try { - renderSections(detail, routeId); - // 구 확정분에 남은 비정규 측점(자유 텍스트)을 구조물 정본으로 1회 이관한다 — - // 배관은 관 지점 정본이 복원하므로 서버가 걸러내고, 멱등이라 재진입에도 안전하다 - // (2026-08-17 컨테이너 병합 3단계). - const legacy = detail.longitudinal.stations - .filter((station) => station.kind === "irregular") - .map((station) => ({ - chainage_m: station.chainage_m, - structure: station.structure ?? "", - })); - // 이관은 멱등이지만 **진입할 때마다** POST 가 나갔다(2026-09-06 실측). 탭 수명 동안 - // 노선마다 한 번만 시도한다 — 옮길 것이 남아 있으면 다음 새로고침에 다시 본다. - const migrateKey = `${activeProjectId}:${routeId}`; - if (legacy.length && !migratedRoutes.has(migrateKey)) { - migratedRoutes.add(migrateKey); - void migrateLegacyStations(activeProjectId, legacy) - .then((result) => { - if (result.migrated > 0) { - showToast(`구 구조물 측점 ${result.migrated}건을 구조물 목록으로 옮겼습니다.`); - return bridge.refreshFromServer().then(() => undefined); - } - return undefined; - }) - .catch(() => { - /* 이관 실패는 치명적이지 않다 — 다음 진입에서 다시 시도된다. */ - }); - } - } catch (error) { - // 렌더 실패를 "데이터 없음"으로 감추면 원인을 알 수 없게 된다. 조회는 성공했으므로 - // 빈 안내로 되돌리지 않고 실패 사실을 그대로 드러낸다. - console.error("B05 종단면도 렌더 실패", error); - showToast( - error instanceof Error - ? `종단면도 표시 실패: ${error.message}` - : "종단면도 표시에 실패했습니다.", - "error", - ); - } - } - - function renderLatest(next: RouteLatestResponse): void { - latest = next; - routeReady = Boolean(next.route && next.route_points.length > 1); - profilePanel.setRoutePolyline(next.route_points ?? []); - if (next.route) { - const stored = next.route.algorithm_params ?? {}; - viewer.markers.renderRoute( - next.route_points, - (stored.curve_warning_segments as Array<{ - polyline_start_index: number; - polyline_end_index: number; - }>) ?? [], - ); - } - } - - async function applyContours(interval: number): Promise { - // 화면 전체를 잠그지 않는다 — 진행 표시는 [재적용] 버튼이 맡는다(CLAUDE.md 5장). - try { - await viewer.reloadContours(interval); - await updateContourInterval(activeProjectId, interval); - // 세션 캐시에도 반영해 다음 진입 시 옛 등고선 간격으로 복원되지 않게 한다. - const cached = readLatestCache(); - if (cached) { - writeLatestCache({ - ...cached, - surface_params: { ...cached.surface_params, contour_interval_m: interval }, - }); - } - } catch (error) { - showToast(error instanceof Error ? error.message : "등고선 조회에 실패했습니다.", "error"); - } - } - - // 버튼 동작 3종([경로 계산]·[임시저장]·[초기화])은 따로 뗀 모듈이 맡는다(2026-09-02). - const actionContext: PageActionContext = { - projectId: activeProjectId, - latest: () => latest, - confirmedSurface: () => confirmedSurface, - routeReady: () => routeReady, - viewer: () => viewer, - panel: () => panel, - profilePanel: () => profilePanel, - bridge: () => bridge, - uphillOverrides, - persistUphillOverrides, - loadLatest, - renderLatest, - restoreSections, - }; - - // 한 페이지 두 보기 — 숨김 · 보임 · 떠남(계획서 24-3a). - if (host) { - bindProfileViewLife(host, { - projectId: activeProjectId, - viewer, - profile: profilePanel, - ready: () => !restoring, - reload: () => bridge.load(), - structures: () => panel.structures, - restorePick, - }); - } - - /* ── 진입 로딩 ───────────────────────────────────────────────────────── - * 화면 틀을 먼저 띄우고 자료가 끝나는 순서대로 채운다(2026-08-01 사용자 지시). - * 3D 지형은 **보조 자료라 로딩에 넣지 않는다**(2026-09-04 사용자 지시) — 네 단계가 - * 끝나면 로딩 표시를 걷어 화면을 바로 쓰게 하고, 3D는 뒤에서 올린 뒤 알린다. - * 서클은 3D 뷰포트 정중앙에 둔다(하단 종단 패널에 가려지는 것은 무방). */ - const LOAD_STEP_COUNT = 4; - const progress = createProgressCircle({ label: "화면 틀을 준비하는 중…", overlay: true }); - viewer.root.append(progress.root); - let loadedSteps = 0; - function advanceLoading(label: string): void { - loadedSteps += 1; - progress.set(Math.min(1, loadedSteps / LOAD_STEP_COUNT), label); - } - - // ① 워크플로우 상태 — 화면 틀(레이아웃·단계바)을 세우는 데 필요한 최소 자료. - const workflowState = await fetchWorkflowState(activeProjectId); - advanceLoading("노선 정보를 불러오는 중…"); - - const mainContent = document.createElement("div"); - mainContent.className = "b05-route__main"; - mainContent.append(viewer.root, profilePanel.root); - const layout = createWorkflowLayout({ - title: L("B05_Route_Title"), - steps: workflowSteps(), - activeStep: 2, - leftPanel: panel.root, - mainContent, - stages: workflowState.stages, - currentStage: workflowState.current_stage, - routes: WORKFLOW_STEP_ROUTES, - onStepClick: (stepIndex) => goToWorkflowStage(activeProjectId, WORKFLOW_STEP_ROUTES[stepIndex]), - }); - layout.root.classList.add("b05-route-layout"); - root.replaceChildren(layout.root); - - try { - // ② 좌측 폼·노선 설정값 — 도착하는 대로 폼과 3D 마커 복원에 쓴다. - // 캐시가 있으면 그것으로 **먼저** 그리고 신선도는 뒤에서 확인한다 — B05↔B06 이동에서 - // 서버를 다시 부르지 않으려는 것이다(2026-09-06). 캐시가 없을 때만 DB를 기다린다. - const cachedLatest = readLatestCache(); - const [latestResponse, sectionContext, configuredRoadWidths] = await Promise.all([ - cachedLatest ? Promise.resolve(cachedLatest) : loadLatest(true), - fetchSectionContext(activeProjectId), - fetchRoadWidths(activeProjectId), - ]); - roadWidths = configuredRoadWidths; - panel.restore({ - stationInterval: sectionContext.defaults.station_interval_m, - crossSampleInterval: sectionContext.defaults.cross_sample_interval_m, - longSampleInterval: sectionContext.defaults.long_sample_interval_m, - // 임도 종류는 프로젝트 등록(B02)이 정본이다 — 화면에서 고르지 않는다 - // (2026-08-19 사용자 확정). B02의 main/branch/fire/stream을 계획선 기준 - // 코드로 옮긴다: main=간선, fire=산불진화, 그 밖(구 지선·계류보전)은 작업. - gradeClass: toGradeClass(sectionContext.road_type), - }); - // 유토곡선용 설정 — 토량환산계수·운반장비 경계·자연방토 판정 경사를 config_system - // 정의 그대로 받아 B06과 같은 계산을 태운다(프론트 사본 금지). - profilePanel.setEarthworkContext({ - conversion: sectionContext.earthwork_conversion, - haulLimits: sectionContext.haul_equipment_limits, - naturalSpoilMinSlope: sectionContext.natural_spoil_min_ground_slope ?? undefined, - }); - // 표준횡단 config 기본값도 같은 방식으로 기억한다 — B05에는 「표준 횡단면 설정」 - // 패널이 없지만, 계획선 편집 중 횡단 재계산이 브라우저에서 돌아 이 값이 필요하다 - // (2026-09-03 로컬 계산 전환). 프론트에 config 사본을 두지 않으려는 같은 원칙이다. - rememberStandardDefaults(activeProjectId, sectionContext.standard_cross_section); - rememberRockBoundaryDefault(activeProjectId, sectionContext.rock_boundary_default_offset_m); - restorePanel(latestResponse); - renderLatest(latestResponse); - latest = latestResponse; - advanceLoading("확정 지표면 모델을 확인하는 중…"); - - // ③ 확정 지표면 — 모델 id·범위를 한 번에 받는다. - const confirmed = await fetchConfirmedSurface(activeProjectId); - confirmedSurface = confirmed.model_id === null ? null : confirmed; - if (!confirmedSurface) { - // 새 자료가 올라와 옛 결과가 지워진 상태 — 여기서 보여 줄 게 없다. - leaveForDashboard(); - return; - } - advanceLoading("종단면 자료를 불러오는 중…"); - - // ④ 종단면·횡단 자료 — 하단 패널을 3D보다 먼저 채운다. - if (latestResponse.route) await restoreSections(latestResponse.route.id); - // 구조물 타입 레지스트리·정본 — 노선이 없어도 목록은 보여 준다(추가는 노선 이후). - await bridge.load(); - advanceLoading(""); - // 캐시로 그렸다면 이제 뒤에서 신선도만 확인한다 — 화면은 이미 서 있으므로 기다리지 - // 않는다. 다른 탭이 자료를 갈아 끼웠을 때만 다시 그린다. - if (cachedLatest) void verifyLatestFreshness(latestResponse); - } catch (error) { - showToast(error instanceof Error ? error.message : "화면을 불러오지 못했습니다.", "error"); - } finally { - progress.remove(); - restoring = false; - restorePick(); // 관·구조물 목록 중 늦게 오는 쪽이 있어 여기서 한 번 더. - } - - // ⑤ 3D 지형 — 화면을 잡지 않고 뒤에서 올린다. 실패해도 나머지는 그대로 쓴다. - void (async () => { - const [surface, current] = [confirmedSurface, latest]; - if (!surface || !current || host?.disposed()) return; - try { - // 범위는 ③에서 받아 둔 확정 응답에 이미 들어 있다 — 다시 부르지 않는다. - if (!surface.bounds) throw new Error("지표면 범위 정보를 찾을 수 없습니다."); - await viewer.loadSurface( - activeProjectId, - surface.model_id as number, - current.surface_params.method, - current.surface_params.smooth, - current.surface_params.contour_interval_m, - toBounds(surface.bounds), - ); - if (host?.disposed()) return; // 올리는 사이 페이지를 떠났다. - renderLatest(current); // 지형이 올라온 뒤에야 마커·측점선이 지형 위에 놓인다. - if (currentSectionDetail) renderStationLines(currentSectionDetail); - showToast("3D 지형 준비 완료", "success"); - } catch (error) { - showToast(error instanceof Error ? error.message : "3D 지형을 불러오지 못했습니다.", "error"); - } - })(); -} diff --git a/B05_Profile/B05_Profile_UI_Page_Actions.ts b/B05_Profile/B05_Profile_UI_Page_Actions.ts index 4e4982eb..74cfd550 100644 --- a/B05_Profile/B05_Profile_UI_Page_Actions.ts +++ b/B05_Profile/B05_Profile_UI_Page_Actions.ts @@ -1,17 +1,15 @@ /* ============================================================================= * B05_Profile_UI_Page_Actions.ts - * B05 페이지의 **버튼 동작** — [경로 계산]·[임시저장]·[초기화]. + * 종단 보기의 **버튼 동작** — [경로 계산] · [저장]·[확정]에 보태는 종단 보기 몫. * - * `B05_Profile_UI_Page` 에서 떼어냈다(700줄 제한, 2026-09-02). 화면 조립·로딩 순서는 - * 페이지에 남고, 여기에는 영구저장소를 건드리는 세 갈래만 둔다(CLAUDE.md 5장: - * 세션에 쌓인 조작은 [저장]·[확정]에서만 정본으로 나간다). + * 종단 보기 본체(`design_view_profile`)에서 떼어냈다(700줄 제한, 2026-09-02). 화면 조립·로딩 + * 순서는 본체에 남고, 여기에는 영구저장소를 건드리는 갈래만 둔다(CLAUDE.md 5장: 세션에 쌓인 + * 조작은 [저장]·[확정]에서만 정본으로 나간다). [초기화]는 한 벌(`design_reset`)이 한다. * ========================================================================== */ -import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale"; import { hideLoadingOverlay, showLoadingOverlay, showToast } from "@ui/ui_template_elements"; -import { resetDesign } from "../A00_Common/design/design_reset"; import { confirmRoute, solveRoute, type RouteLatestResponse } from "./B05_Profile_Api_Fetch"; -import { saveDesign } from "../A00_Common/design/design_save"; +import type { DesignViewSave } from "../A00_Common/design/design_shared"; import { invalidateSectionDetail, saveCachedCrossPatches, @@ -23,10 +21,6 @@ import type { createRouteProfilePanel } from "./B05_Profile_UI_Profile_Panel"; import type { createStructuresBridge } from "./B05_Profile_UI_Page_Structures"; import type { createRouteViewer } from "./B05_Profile_UI_Viewer"; -function L(key: keyof typeof ui_locales): string { - return ui_locales[key][currentLanguageIndex]; -} - /** 버튼 동작이 페이지에서 받아 쓰는 창구 — 값은 바뀌므로 함수로 받는다. */ export interface PageActionContext { projectId: string; @@ -110,28 +104,20 @@ export async function solveRouteAction(ctx: PageActionContext): Promise { } } -/** [임시저장] — 현재 편집을 영구저장소에 남기되 워크플로 단계·페이지는 그대로 둔다. - * 종·횡 통합 확정은 B06 [확정]이 담당한다(2026-08-08 워크플로우 재정의). - * 차례는 저장 한 줄기(`saveDesign`)가 정한다 — B06 [저장]과 같다(계획서 24-2a). */ -export async function tempSaveAction(ctx: PageActionContext): Promise { - if (!ctx.routeReady()) return; - const latest = ctx.latest(); +/** [저장] · [확정]에 종단 보기가 보태는 몫 — 차례는 저장 한 줄기(`saveDesign`)가 정하고 + * 한 벌(`design_shared`)이 두 보기 몫을 모아 한 번에 보낸다(계획서 24-2a · 24-3b). 관(배수유역도) · + * 계획고는 한 벌 종단도가 낸다. */ +export function profileSaveParts(ctx: PageActionContext): DesignViewSave { const projectId = ctx.projectId; - await saveDesign({ - projectId, - routeId: () => latest?.route?.id ?? null, + return { + routeId: () => (ctx.routeReady() ? (ctx.latest()?.route?.id ?? null) : null), steps: { - // 세션 초안이 나간 뒤 화면 목록으로 한 번 더 맞춘다(시설 보관소 되붙이기 · 응답으로 화면 갱신). - pipes: async () => { - await ctx.profilePanel().drainage.savePipes(); - }, // 구조물 — 저장 뒤 서버 목록(새 식별자)으로 화면을 맞춘다. 실패는 자체 토스트로 알리고 // 세션에 남겨 두므로 저장 전체를 멈추지 않는다(2026-08-29 — CLAUDE.md 5장). structures: () => ctx.bridge().saveStructuresIfDirty(), - // 종단 계획선 편집은 화면에서만 계산해 두었으므로 저장 시점에 영속화한다(패널도 다시 그림). - alignment: () => ctx.profilePanel().save(), // 상단측 + 비정규 측점 — 데이터로는 확정 저장하되, 단계 완료 전이는 하지 않는다. uphill: async () => { + const latest = ctx.latest(); await confirmRoute( projectId, { @@ -156,29 +142,20 @@ export async function tempSaveAction(ctx: PageActionContext): Promise { ); }, }, - // B06에서 만져 **캐시에 얹힌** 횡단 수정분을 함께 남긴다 — 안 보내면 바로 아래 - // 캐시 비우기에서 사라진다. 계획선 저장 **뒤에** 보내야 사용자 수정 1세트가 - // 최종본으로 얹힌다(2026-08-24 사용자 지적). + // 횡단 보기를 세운 적 없이 [저장]하면 — 캐시에 얹힌 횡단 수정분을 함께 남긴다. 안 보내면 + // 바로 아래 캐시 비우기에서 사라진다. 계획선 저장 **뒤에** 보내야 사용자 수정 1세트가 + // 최종본으로 얹힌다(2026-08-24 사용자 지적). 횡단 보기가 서 있으면 그쪽 본문이 나간다. cross: async () => { - if (latest?.route?.id != null) await saveCachedCrossPatches(projectId, latest.route.id); + const routeId = ctx.latest()?.route?.id; + if (routeId != null) await saveCachedCrossPatches(projectId, routeId); }, done: async () => { - // 서버가 종단 정본의 계획선을 다시 썼다 — 공유 캐시를 비워 B06이 옛 계획선을 못 보게 한다. + // 서버가 종단 정본의 계획선을 다시 썼다 — 공유 캐시를 비워 횡단 보기가 옛 계획선을 못 보게 한다. invalidateSectionDetail(projectId); ctx.renderLatest(await ctx.loadLatest(true)); - // 임시저장 = 코리도 영구저장 시점(2026-08-23) — 실패해도 임시저장은 성공 처리. - if (latest?.route?.id) void saveCorridorIfDirty(projectId, latest.route.id); - showToast(L("B05_Route_TempSave_Success"), "success"); + // 저장 = 코리도 영구저장 시점(2026-08-23) — 실패해도 저장은 성공 처리. + const routeId = ctx.latest()?.route?.id; + if (routeId) void saveCorridorIfDirty(projectId, routeId); }, - failed: (error) => - showToast(error instanceof Error ? error.message : L("B05_Route_TempSave_Failed"), "error"), - }); -} - -/** [초기화] — 두 보기가 같은 함수를 부른다(`design_reset` · 계획서 24-2b). 페이지만 아는 - * 상단측 변경분만 여기서 비운다. */ -export async function resetDesignAction(ctx: PageActionContext): Promise { - await resetDesign(ctx.projectId, ctx.latest()?.route?.id ?? null, () => - ctx.uphillOverrides.clear(), - ); + }; } diff --git a/B05_Profile/B05_Profile_UI_Page_Latest.ts b/B05_Profile/B05_Profile_UI_Page_Latest.ts new file mode 100644 index 00000000..b8698269 --- /dev/null +++ b/B05_Profile/B05_Profile_UI_Page_Latest.ts @@ -0,0 +1,93 @@ +/* ============================================================================= + * B05_Profile_UI_Page_Latest.ts + * 종단 보기의 **최신 노선 · 설정값** 창구 — 세션 캐시 우선 읽기 · 뒤에서 신선도 확인 · 좌측 폼 되살리기 + * 값. 종단 보기 본체(`design_view_profile`)가 700줄을 넘어 여기로 뗐다(계획서 24-3b). + * ========================================================================== */ + +import { + fetchLatestRoute, + readRouteLatestCache, + writeRouteLatestCache, + type RouteLatestResponse, +} from "./B05_Profile_Api_Fetch"; +import type { RoutePanelValues } from "./B05_Profile_UI_Panel"; + +/** + * 확정 이력이 있으면 매 진입마다 DB(latest) 조회 대신 브라우저 세션 캐시를 우선 사용해 + * 응답속도를 높인다. 캐시 미스면 latest를 조회해 적재하고, solve·확정 성공 시 신선한 값으로 + * 갱신한다(세션 = 탭 단위, 탭 종료 시 소멸). + */ +export function createLatestSource(projectId: string) { + const read = (): RouteLatestResponse | null => readRouteLatestCache(projectId); + const write = (value: RouteLatestResponse): void => writeRouteLatestCache(projectId, value); + + /** 캐시 우선 latest 로드. forceFresh=true(solve/확정 직후)는 항상 DB를 읽고 캐시를 갱신한다. */ + async function load(forceFresh = false): Promise { + if (!forceFresh) { + const cached = read(); + if (cached) return cached; + } + const fresh = await fetchLatestRoute(projectId); + write(fresh); + return fresh; + } + + /** 이 응답이 "같은 자료"인지 가리는 서명 — 노선 번호·지표면 모델·지표면 설정. */ + const signature = (value: RouteLatestResponse): string => + [ + value.route?.id ?? "", + value.route?.surface_model_id ?? "", + value.surface_params.source_filter, + value.surface_params.method, + String(value.surface_params.smooth), + ].join("|"); + + /** + * 캐시로 먼저 그린 뒤 **뒤에서** 신선도를 확인한다(2026-09-06 캐시·세션 일원화). + * 서명(노선번호·지표면)이 달라졌을 때만 `onChanged` 로 다시 그린다. + */ + async function verify( + shown: RouteLatestResponse, + onChanged: (fresh: RouteLatestResponse) => Promise, + ): Promise { + try { + const fresh = await fetchLatestRoute(projectId); + write(fresh); + if (signature(fresh) === signature(shown)) return; + await onChanged(fresh); + } catch { + /* 확인 실패는 조용히 넘긴다 — 화면은 캐시로 이미 서 있다. */ + } + } + + return { read, write, load, verify }; +} + +/** 저장된 노선 설정값 → 좌측 폼 되살리기 값. */ +export function panelValuesOf(next: RouteLatestResponse): Partial { + const options = next.route_params?.options ?? {}; + return { + contourInterval: next.surface_params.contour_interval_m, + algorithm: next.route_params?.algorithm as RoutePanelValues["algorithm"] | undefined, + gradeClass: options.grade_class as RoutePanelValues["gradeClass"] | undefined, + paved: options.paved as boolean | undefined, + minCurveRadius: options.min_curve_radius_m as number | undefined, + maxUphillGrade: options.max_uphill_grade as number | undefined, + maxDownhillGrade: options.max_downhill_grade as number | undefined, + minUphillGrade: options.min_uphill_grade as number | undefined, + minDownhillGrade: options.min_downhill_grade as number | undefined, + allowAvoidPassThrough: options.allow_avoid_pass_through as boolean | undefined, + stationInterval: next.route_params?.station_interval_m ?? undefined, + crossSampleInterval: next.route_params?.cross_sample_interval_m ?? undefined, + longSampleInterval: next.route_params?.long_sample_interval_m ?? undefined, + terrainType: options.terrain_type as RoutePanelValues["terrainType"] | undefined, + designSpeed: options.design_speed_kph as RoutePanelValues["designSpeed"] | undefined, + maxGradePct: next.route_params?.max_grade_pct ?? undefined, + minVerticalRadius: next.route_params?.min_vertical_radius_m ?? undefined, + minTangentLength: next.route_params?.min_tangent_length_m ?? undefined, + startElevationOffset: next.route_params?.start_elevation_offset_m ?? undefined, + endElevationOffset: next.route_params?.end_elevation_offset_m ?? undefined, + enforcePipeClearance: + (next.route_params?.enforce_pipe_clearance as boolean | undefined) ?? undefined, + }; +} diff --git a/B05_Profile/B05_Profile_UI_Page_Life.ts b/B05_Profile/B05_Profile_UI_Page_Life.ts index 05392ec6..2f0a6d1f 100644 --- a/B05_Profile/B05_Profile_UI_Page_Life.ts +++ b/B05_Profile/B05_Profile_UI_Page_Life.ts @@ -1,48 +1,37 @@ /* ============================================================================= * B05_Profile_UI_Page_Life.ts - * 종단 보기의 숨김 · 보임 · 떠남(한 페이지 두 보기 · 계획서 24-3a). + * 종단 보기의 숨김 · 보임 · 떠남(한 페이지 두 보기 · 계획서 24-3a · 24-3b). * - * 숨으면 3D 그리기 루프 · 창 키 · 포인터 듣기를 뗀다. 다시 보이면 잇고, 다른 보기(횡단)에서 - * 바뀐 구조물 · 관 · 계획고 초안 · 고른 것을 가게에서 다시 읽는다. 떠나면 3D(WebGL 문맥까지) · - * 종단도를 정리한다. 페이지 본체가 700줄을 넘어 여기로 뗐다. + * 숨으면 3D 그리기 루프 · 포인터 듣기를 뗀다. 다시 보이면 잇고, 다른 보기(횡단)에서 바뀐 + * 구조물 · 관 · 고른 것을 가게에서 다시 읽어 한 벌 종단도 · 폼에 종단 보기 값을 다시 싣는다. + * 떠나면 3D(WebGL 문맥까지)를 정리한다 — 종단도는 한 벌이라 페이지가 정리한다. * ========================================================================== */ import type { DesignPageHost } from "../A00_Common/design/design_page"; -import { readSelection } from "../A00_Common/design/design_selection"; export interface ProfileViewLifeDeps { projectId: string; viewer: { setActive: (active: boolean) => void; dispose: () => void }; - profile: { setActive: (active: boolean) => void; dispose: () => void }; /** 첫 진입 로딩이 끝났는가 — 끝나기 전에는 다시 읽지 않는다. */ ready: () => boolean; - /** 구조물 목록을 다시 읽는다(초안 우선). */ + /** 구조물 목록 · 관 목록을 다시 싣는다(초안 우선). */ reload: () => Promise; - structures: () => { selectById: (structureId: string | null) => void }; - /** 가게에 남은 고른 것을 되살린다(이미 고른 것이 있으면 그대로). */ - restorePick: () => void; + /** 고른 것(한 곳 `selection`)을 3D · 종단도 · 배수유역도에 다시 맞춘다 — 폼은 한 벌이라 이미 같다. */ + syncPick: () => void; + /** 숨을 때 — 보기를 떠나는 자리(코리도 영구저장 시점). */ + onHide?: () => void; } export function bindProfileViewLife(host: DesignPageHost, deps: ProfileViewLifeDeps): void { - let pickAtHide = ""; - const pickNow = (): string => JSON.stringify(readSelection(deps.projectId)); host.onHide(() => { deps.viewer.setActive(false); - deps.profile.setActive(false); - pickAtHide = pickNow(); + deps.onHide?.(); }); host.onShow(() => { deps.viewer.setActive(true); - deps.profile.setActive(true); if (!deps.ready()) return; - void deps.reload().then(() => { - // 다른 보기에서 다른 것을 골랐으면 지금 고른 것을 풀고 그것을 되살린다. - if (pickNow() !== pickAtHide) deps.structures().selectById(null); - deps.restorePick(); - }); - }); - host.onDispose(() => { - deps.viewer.dispose(); - deps.profile.dispose(); + // 폼을 비우지 않는다 — 한 벌 폼이 이미 다른 보기에서 고른 것을 보이고 있다(24-3b). + void deps.reload().then(deps.syncPick); }); + host.onDispose(() => deps.viewer.dispose()); } diff --git a/B05_Profile/B05_Profile_UI_Page_Stations.ts b/B05_Profile/B05_Profile_UI_Page_Stations.ts new file mode 100644 index 00000000..b33f0762 --- /dev/null +++ b/B05_Profile/B05_Profile_UI_Page_Stations.ts @@ -0,0 +1,67 @@ +/* ============================================================================= + * B05_Profile_UI_Page_Stations.ts + * 종단 보기 3D **측점 바** 목록 — 규칙 측점 + 구조물(비정규) 측점 · 계획고 높이 · 상단측. + * 종단 보기 본체(`design_view_profile`)가 700줄을 넘어 여기로 뗐다(계획서 24-3b). + * ========================================================================== */ + +import { dropStationsNear } from "./B05_Profile_Util_Station"; +import { interpolateIrregularStations } from "./B05_Profile_UI_Page_Helpers"; +import type { IrregularStation } from "./B05_Profile_UI_IrregularStations"; +import type { SectionDetailResponse } from "../B06_Section/B06_Section_Api_Fetch"; + +export interface StationLinesInput { + detail: SectionDetailResponse; + irregular: IrregularStation[]; + routePoints: ReadonlyArray<{ x: number; y: number; z?: number | null }>; + /** 편집 중 라이브 계획선 샘플(없으면 정본 계획선). */ + liveSamples: ReadonlyArray<{ chainage_m: number; elevation_m: number }> | null; + /** [예상형상] 켜짐 — 측점 바를 계획고 위로 올린다. */ + corridorVisible: boolean; + /** 상단측(측구 방향) 사용자 변경분 — 키는 누가거리 3자리. */ + uphillOverrides: Map; +} + +export function stationLinesOf(input: StationLinesInput) { + const { detail } = input; + // 확정 때 종단 정본에 병합해 둔 비정규 측점은 그리지 않는다. 화면의 정본은 사이드바 목록이고 + // 관을 옮기면 그 목록만 따라오므로, 둘을 겹쳐 그리면 옮기기 전 자리가 그대로 남는다 + // (2026-08-02 사용자 보고). 저장분은 진입 시 `restoreSections`가 목록으로 되살린다. + const regular = detail.longitudinal.stations.filter((station) => station.kind !== "irregular"); + const injected = interpolateIrregularStations( + regular, + input.irregular, + detail.longitudinal.length_m, + // 중심 좌표는 노선 폴리라인 기준 — 3D 마커가 노선 위에 정확히 얹힌다(2026-08-19). + input.routePoints, + ); + // 예상형상이 켜져 있으면 측점 바를 **계획고** 기준으로 올린다 — 원지반 위에 + // 얹힌 예상형상에 묻혀 측점 기준선이 안 보이던 문제(2026-08-23 사용자 지시). + const designAt = (chainageM: number): number | null => { + if (!input.corridorVisible) return null; + // 편집 중에는 라이브 alignment 샘플이 정본보다 새것이다(2026-08-23 지적 ③). + const samples = input.liveSamples ?? detail.longitudinal.design_profiles?.[0]?.samples; + if (!samples?.length) return null; + let best = samples[0]; + for (const sample of samples) { + if (Math.abs(sample.chainage_m - chainageM) < Math.abs(best.chainage_m - chainageM)) { + best = sample; + } + } + return best.elevation_m; + }; + // 측점 바 양 끝 램프용 상단측: 사용자 변경분 → solve 자동 판정 순으로 적용. + // 구조물 측점과 0.1m 안에서 겹치는 규칙 측점은 지운다 — 3D 측점 띠도 종단 그래프와 + // 같은 규칙을 써야 두 화면의 측점이 어긋나지 않는다(2026-09-06). + return [...dropStationsNear(regular, injected), ...injected].map((station) => { + const design = designAt(station.chainage_m); + return { + ...station, + // 절토 구간에서는 계획고가 지반보다 **아래**다(2026-09-04 사용자 지적). + // max 로 잡으면 코리도가 절취해 내려간 노면을 두고 막대만 원지반에 떠 있다. + // 코리도가 켜져 있으면(designAt 이 값을 줌) 계획고를 그대로 쓴다. + center_z: design !== null && station.center_z !== null ? design : station.center_z, + uphill_side: + input.uphillOverrides.get(station.chainage_m.toFixed(3)) ?? station.uphill_side ?? null, + }; + }); +} diff --git a/B05_Profile/B05_Profile_UI_Panel.ts b/B05_Profile/B05_Profile_UI_Panel.ts index b72c7f06..485ea363 100644 --- a/B05_Profile/B05_Profile_UI_Panel.ts +++ b/B05_Profile/B05_Profile_UI_Panel.ts @@ -1,18 +1,23 @@ /* ============================================================================= * B05_Profile_UI_Panel.ts - * 종단 보기(B05) 좌측 패널 — 칸 재료를 모아 **공용 등록부**(`design_panel`)에 넘겨 세운다. + * 종단 보기(B05) 좌측 칸 재료 — 한 벌 좌측 패널(`design_shared`)에 종단 보기 몫을 넣는다. * * 칸 정의는 `_Panel_Sections`(종단 보기 전용) · `design_sections`(두 보기 공용), 재료는 * `_Panel_Settings` · `_Panel_Route` · `_Panel_View`, 값 · 상수 · 입력 칸 만들기는 `_Panel_Fields` * 에 있다(700줄 제한으로 나눔, 계획서 24-2b). 값을 읽고 되살리는 창구(`values` · `restore`)만 - * 여기 남는다. 아래 고정 영역(단추 넷)은 `design_dock` 이 만든다. + * 여기 남는다. 아래 고정 영역(단추 넷) · 「구조물 배치」 폼은 한 벌이 두 보기에 같은 노드로 준다. * ========================================================================== */ import type { PlacedRoutePoint } from "./B05_Profile_UI_Markers"; import type { StructureInstance } from "./B05_Profile_Api_Structures"; import type { FacilityAttributes } from "./B05_Profile_UI_Drainage_Facility"; -import { createStructuresSection, type StructuresSection } from "./B05_Profile_UI_Structures_Panel"; -import { createDesignPanel } from "../A00_Common/design/design_panel"; +import type { StructuresSection } from "./B05_Profile_UI_Structures_Panel"; +import { + bindDesignView, + sharedPanel, + sharedStructures, + type DesignPageShared, +} from "../A00_Common/design/design_shared"; import "./B05_Profile_UI_Panel_Sections"; import { STATION_INTERVAL_M, @@ -29,12 +34,6 @@ interface PanelCallbacks { onSolve: () => void; /** [계획노선 편집] — 큰 모달을 열어 노선을 고친다(계산은 모달 [확인]에서만 돈다). */ onEditPlannedRoute: () => void; - /** [임시저장] — 현재 편집(계획선 델타·관로·비정규 측점·상단측)을 확정 전이 없이 저장. */ - onTempSave: () => void; - /** [횡단 이동] — 저장 없이 B06 횡단 페이지로 이동만. */ - onGoCross: () => void; - /** [초기화] — 사용자 편집을 버리고 초기 자동 계산 상태로 롤백. */ - onReset: () => void; onContourApply: (interval: number) => void | Promise; onSurfaceVisible: (visible: boolean) => void; /** [예상형상] — 계획 코리도 서피스 + 클리핑 지형(공사 후) ↔ 원지반 완전체 전환. */ @@ -61,8 +60,6 @@ interface PanelCallbacks { onStructuresChange: (structures: StructureInstance[]) => void; /** 구조물을 목록에서 선택/해제할 때 해당 구조물(또는 null). */ onStructureSelect: (structure: StructureInstance | null) => void; - /** 바깥(3D·종단·유역도)에서 고른 것이 폼에 실릴 때 — 접힌 좌측 패널을 펼친다. */ - onStructureReveal?: () => void; /** 계곡 통과 시설 추가·수정·삭제·선택 — 관 지점 정본(배수유역 패널) 경유(2026-08-17 통합). */ onPipeFacilityAdd: (chainageM: number, attributes: FacilityAttributes) => void; onPipeFacilityUpdate: ( @@ -77,7 +74,7 @@ interface PanelCallbacks { onStationDisplayChange: (offset: { station: number; cumulative: number }) => void; } -export function createRoutePanel(callbacks: PanelCallbacks) { +export function createRoutePanel(callbacks: PanelCallbacks, shared: DesignPageShared) { const { viewControls, setCorridorPending } = createViewControls(callbacks); const settings = createSettingsPart(callbacks); const route = createRoutePart(callbacks); @@ -107,18 +104,19 @@ export function createRoutePanel(callbacks: PanelCallbacks) { minDownhillGrade, } = routeFields; - // 「구조물 배치」 — 구 비정규 측점 섹션을 흡수한 단일 섹션(2026-08-17 컨테이너 병합). - // 타입 목록과 옵션 칸은 서버 레지스트리에서 받아 그리고, 계곡 통과 시설(A군)은 관 - // 지점 정본과 연동한다. 위치 입출력은 측점번호+잔여거리 두 칸이다. - const structures = createStructuresSection({ - onChange: callbacks.onStructuresChange, - onSelect: callbacks.onStructureSelect, - getInterval: () => STATION_INTERVAL_M, - onPipeAdd: callbacks.onPipeFacilityAdd, - onPipeUpdate: callbacks.onPipeFacilityUpdate, - onPipeRemove: callbacks.onPipeFacilityRemove, - onPipeSelect: callbacks.onPipeFacilitySelect, - onReveal: () => callbacks.onStructureReveal?.(), + // 「구조물 배치」 — 두 보기가 같은 폼 한 벌을 쓴다(계획서 24-3b). 종단 보기가 보일 때 폼 알림을 + // 받는 동작만 여기서 건다. 계곡 통과 시설(A군)은 관 지점 정본(배수유역도)과 연동한다. + const structures = sharedStructures(shared); + bindDesignView(shared, "profile", { + structures: { + onChange: callbacks.onStructuresChange, + onSelect: callbacks.onStructureSelect, + getInterval: () => STATION_INTERVAL_M, + onPipeAdd: callbacks.onPipeFacilityAdd, + onPipeUpdate: callbacks.onPipeFacilityUpdate, + onPipeRemove: callbacks.onPipeFacilityRemove, + onPipeSelect: callbacks.onPipeFacilitySelect, + }, }); const inputElements = [ @@ -144,29 +142,12 @@ export function createRoutePanel(callbacks: PanelCallbacks) { ]; inputElements.forEach((input) => input.addEventListener("change", callbacks.onInputChange)); - // 좌측 패널 = 등록부가 칸을 차례대로 세우고 아래 고정 영역(단추 넷)을 붙인다. 하단 단추의 - // [확정]은 저장 흐름(`design_save`)이 이어질 때까지 꺼 둔다 — 종·횡 통합 확정은 횡단 보기에서 - // 한다(2026-08-08 워크플로우 재정의). - const design = createDesignPanel( - { - projectId: null, - parts: { structures, settings, route }, - dock: { - reset: { run: callbacks.onReset }, - save: { run: callbacks.onTempSave }, - confirm: { - run: () => undefined, - enabled: false, - title: "확정은 횡단 보기에서 합니다(저장 한 줄기가 이어지면 이 자리에서 켜집니다).", - }, - switchView: { run: callbacks.onGoCross }, - }, - }, - "profile", - ); + // 좌측 패널 = 한 벌 등록부(`design_shared`) — 종단 보기 칸 재료만 넣는다. 아래 단추 넷 · 저장 · + // [확정]은 한 벌이 두 보기 몫을 모아 낸다. + Object.assign(shared.parts, { settings, route }); + sharedPanel(shared); return { - root: design.root, viewControls, /** 「구조물 배치」 섹션 API(타입 주입·목록 교체·계곡 시설 병합·선택·추가/이동/삭제). */ structures: structures as StructuresSection, diff --git a/B05_Profile/B05_Profile_UI_Profile_Panel.ts b/B05_Profile/B05_Profile_UI_Profile_Panel.ts index b420334d..fb9f65bd 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Panel.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Panel.ts @@ -1,38 +1,34 @@ /* ============================================================================= * B05_Profile_UI_Profile_Panel.ts - * 종단 보기의 하단 종단도 — 종단도 한 벌(`A00_Common/design/design_profile.ts`)에 - * 오른쪽 판(배수유역도)을 끼워 만든다(계획서 24-2c). + * 종단 보기의 하단 종단도 — 한 페이지의 **종단도 한 벌**(`design_shared` · 오른쪽 판 배수유역도)에 + * 종단 보기 알림을 건다(계획서 24-2c · 24-3b). * - * 본체는 종단도 한 벌로 옮겼다. 여기는 종단 보기가 부르던 이름 · 모양을 지킨다 — + * 종단도는 두 보기가 같은 노드를 쓴다. 여기는 종단 보기가 부르던 이름 · 모양을 지킨다 — * 페이지는 `drainage` 로 배수유역도를 바로 부른다. * ========================================================================== */ -import { createDesignProfile } from "../A00_Common/design/design_profile"; import type { DesignSideBoard } from "../A00_Common/design/design_profile"; -import { drainageSide, drainageSideTool } from "./B05_Profile_UI_Profile_SideTool"; +import { + bindDesignView, + sharedProfile, + type DesignPageShared, +} from "../A00_Common/design/design_shared"; import type { RouteProfilePanelCallbacks } from "./B05_Profile_UI_Profile_Panel_Types"; /** 콜백 타입은 `_Panel_Types` 에 있다 — 옛 임포트 경로가 그대로 동작하도록 다시 내보낸다. */ export type { RouteProfilePanelCallbacks } from "./B05_Profile_UI_Profile_Panel_Types"; export function createRouteProfilePanel( - projectId: string, + shared: DesignPageShared, /** 측점 선택 변경 — 같은 측점 재선택·빈 곳 클릭이면 null(해제)로 알린다. */ onSelectStation: (stationId: string | null) => void, /** [초기선 복원] 클릭 시 함께 실행(비정규 측점 등 다른 조작값도 초기화하려고 Page가 넘긴다). */ onResetAll?: () => void, callbacks?: RouteProfilePanelCallbacks, ) { - const profile = createDesignProfile({ - projectId, - view: "profile", - side: drainageSide, - sideTool: drainageSideTool, - onSelectStation, - onResetAll, - callbacks, - }); + bindDesignView(shared, "profile", { profile: { onSelectStation, onResetAll, callbacks } }); + const profile = sharedProfile(shared); /** 배수유역도 패널 — 경로 확정 흐름에서 유역선 편집 저장 여부를 묻는 데 쓴다. */ const drainage = profile.side as DesignSideBoard; - return { ...profile, drainage }; + return Object.assign(profile, { drainage }); } diff --git a/B05_Profile/B05_Profile_UI_Profile_Panel_Tools.ts b/B05_Profile/B05_Profile_UI_Profile_Panel_Tools.ts index 05a49e05..d5032366 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Panel_Tools.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Panel_Tools.ts @@ -293,6 +293,8 @@ export function createPanelTools(ctx: PanelToolsContext): PanelTools { if (key !== "ArrowUp" && key !== "ArrowDown" && key !== "ArrowLeft" && key !== "ArrowRight") { return; } + // 횡단 보기 조정창이 이미 받았으면(초점이 카드 쪽) 계획고는 안 움직인다(계획서 24-3b). + if (event.defaultPrevented) return; // OS 키 반복은 무시한다 — 속도는 위 타이머가 정한다. if (event.repeat) { event.preventDefault(); diff --git a/B05_Profile/B05_Profile_UI_Structures_Panel.ts b/B05_Profile/B05_Profile_UI_Structures_Panel.ts index ec2db490..7d98beeb 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Panel.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Panel.ts @@ -174,7 +174,11 @@ export function createStructuresSection( const optionContext = { optionInputs, optionRow, - includeDetail: sectionOptions.includeDetail === true, + // 한 폼을 두 보기가 쓴다(계획서 24-3b) — 상세 옵션은 그릴 때마다 지금 보기로 가린다. + get includeDetail() { + const detail = sectionOptions.includeDetail; + return typeof detail === "function" ? detail() : detail === true; + }, currentType: () => currentType(), facilityFormKind: (type: StructureType | null) => facilityFormKind(type), liveCommit: () => liveCommit(), diff --git a/B06_Section/B06_Section_UI_Cross_Panel_Base.ts b/B06_Section/B06_Section_UI_Cross_Panel_Base.ts index 0d1fe1b6..8df6afbc 100644 --- a/B06_Section/B06_Section_UI_Cross_Panel_Base.ts +++ b/B06_Section/B06_Section_UI_Cross_Panel_Base.ts @@ -70,6 +70,22 @@ export function buildPanelShell(onClose: () => void): { return { root, head, title }; } +/** 마지막으로 누른 자리 — 방향키는 **초점 있는 쪽만** 받는다(계획서 24-3b). 횡단 보기에서 조정창과 + * 종단도(▲▼ 계획고)가 한 화면에 함께 있어, 종단도를 누른 뒤에는 조정창이 방향키를 넘긴다. */ +let lastPointer: Element | null = null; +document.addEventListener( + "pointerdown", + (event) => { + lastPointer = event.target instanceof Element ? event.target : null; + }, + true, +); + +/** 방향키가 종단도 몫인가 — 마지막으로 누른 자리가 종단도 안이면 참. */ +export function arrowsBelongToProfile(): boolean { + return !!lastPointer?.closest(".b05-route-profile"); +} + const ARROW_SLOT: Record = { ArrowUp: "up", ArrowDown: "down", @@ -94,6 +110,7 @@ export function bindArrowKeys( const slot = ARROW_SLOT[event.key]; // 보기가 숨었으면(한 페이지 두 보기 · 계획서 24-3a) 다른 보기의 방향키를 먹지 않는다. if (!slot || root.classList.contains("is-hidden") || !root.getClientRects().length) return; + if (arrowsBelongToProfile()) return; const focus = event.target as HTMLElement | null; if (focus && (focus.tagName === "SELECT" || focus.tagName === "INPUT")) return; for (const container of containers()) { diff --git a/B06_Section/B06_Section_UI_Cross_Structure_Panel.ts b/B06_Section/B06_Section_UI_Cross_Structure_Panel.ts index e24f37fa..5cea01b1 100644 --- a/B06_Section/B06_Section_UI_Cross_Structure_Panel.ts +++ b/B06_Section/B06_Section_UI_Cross_Structure_Panel.ts @@ -15,7 +15,7 @@ import { REVET_FORMS, revetHeightLimit } from "./B06_Section_UI_Cross_Culvert_Co import type { BasinAdjust, WallAdjust } from "./B06_Section_UI_Cross_Culvert_Types"; import type { SpanValues } from "./B06_Section_UI_Cross_Culvert_Wire"; import { L } from "./B06_Section_UI_Section_Common"; -import { makeButton, makeRow } from "./B06_Section_UI_Cross_Panel_Base"; +import { arrowsBelongToProfile, makeButton, makeRow } from "./B06_Section_UI_Cross_Panel_Base"; /** 조정창이 쓰는 값·동작. 카드가 자기 상태에 맞춰 물려 준다. */ export interface StructurePanelDeps { @@ -459,6 +459,7 @@ export function buildStructurePanel( const slot = ARROW_SLOT[event.key]; // 보기가 숨었으면(한 페이지 두 보기 · 계획서 24-3a) 다른 보기의 방향키를 먹지 않는다. if (!slot || root.classList.contains("is-hidden") || !root.getClientRects().length) return; + if (arrowsBelongToProfile()) return; // 종단도를 눌렀으면 방향키는 계획고 몫(24-3b) const focus = event.target as HTMLElement | null; if (focus && (focus.tagName === "SELECT" || focus.tagName === "INPUT")) return; for (const candidate of [basinRow, moveRow]) { diff --git a/B06_Section/B06_Section_UI_Cross_View.ts b/B06_Section/B06_Section_UI_Cross_View.ts index 734a9037..726b7417 100644 --- a/B06_Section/B06_Section_UI_Cross_View.ts +++ b/B06_Section/B06_Section_UI_Cross_View.ts @@ -33,9 +33,9 @@ import type { CulvertDesignTrim } from "./B06_Section_UI_Cross_Culvert"; import { appendBoxOverlay, computeBoxLayout } from "./B06_Section_UI_Cross_Box"; import type { BoxControl } from "./B06_Section_UI_Cross_Box_Panel"; import { createBodyWiring } from "./B06_Section_UI_Cross_View_Bodies"; +import { appendCardFrame, applyStructureAreas } from "./B06_Section_UI_Cross_View_Frame"; import { appendFordOverlay, computeFordLayout } from "./B06_Section_UI_Cross_Ford"; import type { FordControl } from "./B06_Section_UI_Cross_Ford_Panel"; -import { computeStructureAreas, type DesignTrim } from "@util/common_util_cross_structure_areas"; import { computeCardCulvert } from "./B06_Section_UI_Cross_Culvert_Wire"; import type { CulvertLink } from "./B06_Section_UI_Cross_Culvert_Wire"; import type { @@ -63,7 +63,6 @@ import { emptyView, L, svgElement, - svgText, validElevation, } from "./B06_Section_UI_Section_Common"; import { @@ -530,60 +529,15 @@ export function createCrossSectionCard( } } - const centerSample = valid.reduce<(typeof valid)[number] | null>((nearest, sample) => { - if (!nearest || Math.abs(sample.offset_m ?? 0) < Math.abs(nearest.offset_m ?? 0)) - return sample; - return nearest; - }, null); - const centerX = x(0); - // 십자선 = 계획 노선 자리(구 데이터는 지반고 폴백). - const centerElevation = hasDesign - ? (designElevation as number) - : (centerSample?.elevation_m ?? null); - const centerY = - centerElevation !== null - ? y(elevationMid + (centerElevation - elevationMid) * exaggeration) - : heightPx / 2; - const centerMarkerClass = `b06-chart__center-marker${hasDesign ? " b06-chart__center-marker--design" : ""}`; - plotLayer.append( - svgElement("line", { - x1: centerX, - y1: centerY - 18, - x2: centerX, - y2: centerY + 18, - class: centerMarkerClass, - }), - svgElement("line", { - x1: centerX - 18, - y1: centerY, - x2: centerX + 18, - y2: centerY, - class: centerMarkerClass, - }), - ); - svg.append( - svgElement("line", { - x1: CROSS_PAD.left, - y1: heightPx - CROSS_PAD.bottom, - x2: widthPx - CROSS_PAD.right, - y2: heightPx - CROSS_PAD.bottom, - class: "b06-chart__axis", - }), - svgElement("line", { - x1: CROSS_PAD.left, - y1: CROSS_PAD.top, - x2: CROSS_PAD.left, - y2: heightPx - CROSS_PAD.bottom, - class: "b06-chart__axis", - }), - svgText(L("B06_Profile_View_ElevationAxis"), { - x: 13, - y: heightPx / 2, - "text-anchor": "middle", - transform: `rotate(-90 13 ${heightPx / 2})`, - class: "b06-chart__axis-label", - }), - ); + // 십자선(계획 노선 자리) · 축선 · 높이 축 이름 — `_Cross_View_Frame`(700줄 제한). + appendCardFrame(svg, plotLayer, { + valid, + centerX: x(0), + designElevation: hasDesign ? (designElevation as number) : null, + toY: (elevation) => y(elevationMid + (elevation - elevationMid) * exaggeration), + widthPx, + heightPx, + }); // 확대·축소 버튼, 드래그 팬, 더블클릭 원복(E-5). 도형 레이어 transform + non-scaling-stroke. const chartWrap = document.createElement("div"); chartWrap.className = "b06-cross-card__chart-wrap"; @@ -720,37 +674,3 @@ export function createCrossSectionCard( appendCardFooter(card, section, rockBoundary, cutSlope); return card; } - -/** - * 구조물이 만든 폐회로 면적을 측점 설계에 반영한다(2026-09-06 사용자 확정). - * - * 표준 설계선만 보는 기본 계산은 구조물이 있는지 모른다 — 기슭막이·세월교·BOX가 서면 - * 성토 사면이 벽에서 끊기고 그 바깥은 벽·성토부선이 대신 그리므로 폐회로가 달라진다. - * 트림이 없으면(구조물 없는 측점) 아무것도 하지 않는다. - * - * 조작 중 즉시 반영용이다. 정본은 [저장]·[확정] 때 서버가 같은 코드 - * (`B06_Section_Structure_Areas_Node.ts`)로 다시 계산해 얹는다(2026-09-06). - */ -function applyStructureAreas(section: CrossSection, trim: DesignTrim | undefined | null): void { - const design = section.design; - if (!trim || !design || !Array.isArray(design.design_line) || design.design_line.length < 2) { - return; - } - const ground = section.samples - .filter((sample) => sample.valid !== false && typeof sample.elevation_m === "number") - .map((sample) => ({ offset: sample.offset_m ?? 0, elevation: sample.elevation_m as number })) - .sort((a, b) => a.offset - b.offset); - const areas = computeStructureAreas({ - designLine: design.design_line as Array<{ offset_m: number; elevation_m: number }>, - ground, - trim, - rockBoundaryOffsetM: - typeof design.rock_boundary_offset_m === "number" ? design.rock_boundary_offset_m : null, - }); - if (!areas) return; - design.cut_area_m2 = Number(areas.cutAreaM2.toFixed(4)); - design.fill_area_m2 = Number(areas.fillAreaM2.toFixed(4)); - if (typeof design.cut_soil_area_m2 === "number") { - design.cut_soil_area_m2 = Number(areas.cutSoilAreaM2.toFixed(4)); - } -} diff --git a/B06_Section/B06_Section_UI_Cross_View_Frame.ts b/B06_Section/B06_Section_UI_Cross_View_Frame.ts new file mode 100644 index 00000000..28c385e2 --- /dev/null +++ b/B06_Section/B06_Section_UI_Cross_View_Frame.ts @@ -0,0 +1,117 @@ +/* ============================================================================= + * B06_Section_UI_Cross_View_Frame.ts + * 횡단 카드의 **틀 그리기** — 십자선(계획 노선 자리) · 아래 · 왼쪽 축선 · 높이 축 이름, 그리고 + * 구조물 폐회로 면적 반영. `_Cross_View` 가 700줄을 넘어 여기로 뗐다(계획서 24-3b). + * ========================================================================== */ + +import type { CrossSection } from "./B06_Section_Api_Fetch"; +import { computeStructureAreas, type DesignTrim } from "@util/common_util_cross_structure_areas"; +import { CROSS_PAD, L, svgElement, svgText } from "./B06_Section_UI_Section_Common"; + +type Sample = CrossSection["samples"][number]; + +export interface CardFrameInput { + /** 표고가 있는 샘플 — 계획고가 없으면 중심에 가장 가까운 지반고로 십자선을 세운다. */ + valid: Sample[]; + centerX: number; + /** 계획고(없으면 null — 옛 자료는 지반고 폴백). */ + designElevation: number | null; + /** 표고 → 화면 y(높이 배율 반영). */ + toY: (elevation: number) => number; + widthPx: number; + heightPx: number; +} + +/** 십자선은 도형 레이어(줌·팬 따라감), 축선 · 축 이름은 틀 레이어(제자리)에 붙인다. */ +export function appendCardFrame( + svg: SVGElement, + plotLayer: SVGElement, + input: CardFrameInput, +): void { + const { valid, centerX, designElevation, toY, widthPx, heightPx } = input; + const hasDesign = designElevation !== null; + const centerSample = valid.reduce((nearest, sample) => { + if (!nearest || Math.abs(sample.offset_m ?? 0) < Math.abs(nearest.offset_m ?? 0)) return sample; + return nearest; + }, null); + // 십자선 = 계획 노선 자리(구 데이터는 지반고 폴백). + const centerElevation = hasDesign ? designElevation : (centerSample?.elevation_m ?? null); + const centerY = centerElevation !== null ? toY(centerElevation) : heightPx / 2; + const centerMarkerClass = `b06-chart__center-marker${hasDesign ? " b06-chart__center-marker--design" : ""}`; + plotLayer.append( + svgElement("line", { + x1: centerX, + y1: centerY - 18, + x2: centerX, + y2: centerY + 18, + class: centerMarkerClass, + }), + svgElement("line", { + x1: centerX - 18, + y1: centerY, + x2: centerX + 18, + y2: centerY, + class: centerMarkerClass, + }), + ); + svg.append( + svgElement("line", { + x1: CROSS_PAD.left, + y1: heightPx - CROSS_PAD.bottom, + x2: widthPx - CROSS_PAD.right, + y2: heightPx - CROSS_PAD.bottom, + class: "b06-chart__axis", + }), + svgElement("line", { + x1: CROSS_PAD.left, + y1: CROSS_PAD.top, + x2: CROSS_PAD.left, + y2: heightPx - CROSS_PAD.bottom, + class: "b06-chart__axis", + }), + svgText(L("B06_Profile_View_ElevationAxis"), { + x: 13, + y: heightPx / 2, + "text-anchor": "middle", + transform: `rotate(-90 13 ${heightPx / 2})`, + class: "b06-chart__axis-label", + }), + ); +} + +/** + * 구조물이 만든 폐회로 면적을 측점 설계에 반영한다(2026-09-06 사용자 확정). + * + * 표준 설계선만 보는 기본 계산은 구조물이 있는지 모른다 — 기슭막이·세월교·BOX가 서면 + * 성토 사면이 벽에서 끊기고 그 바깥은 벽·성토부선이 대신 그리므로 폐회로가 달라진다. + * 트림이 없으면(구조물 없는 측점) 아무것도 하지 않는다. + * + * 조작 중 즉시 반영용이다. 정본은 [저장]·[확정] 때 서버가 같은 코드 + * (`B06_Section_Structure_Areas_Node.ts`)로 다시 계산해 얹는다(2026-09-06). + */ +export function applyStructureAreas( + section: CrossSection, + trim: DesignTrim | undefined | null, +): void { + const design = section.design; + if (!trim || !design || !Array.isArray(design.design_line) || design.design_line.length < 2) { + return; + } + const ground = section.samples + .filter((sample) => sample.valid !== false && typeof sample.elevation_m === "number") + .map((sample) => ({ offset: sample.offset_m ?? 0, elevation: sample.elevation_m as number })) + .sort((a, b) => a.offset - b.offset); + const areas = computeStructureAreas({ + designLine: design.design_line as Array<{ offset_m: number; elevation_m: number }>, + ground, + trim, + rockBoundaryOffsetM: + typeof design.rock_boundary_offset_m === "number" ? design.rock_boundary_offset_m : null, + }); + if (!areas) return; + design.cut_area_m2 = Number(areas.cutAreaM2.toFixed(4)); + design.fill_area_m2 = Number(areas.fillAreaM2.toFixed(4)); + if (typeof design.cut_soil_area_m2 === "number") { + design.cut_soil_area_m2 = Number(areas.cutSoilAreaM2.toFixed(4)); + } +} diff --git a/B06_Section/B06_Section_UI_Page_Persist.ts b/B06_Section/B06_Section_UI_Page_Persist.ts index aaf97f6e..9a0b0cfd 100644 --- a/B06_Section/B06_Section_UI_Page_Persist.ts +++ b/B06_Section/B06_Section_UI_Page_Persist.ts @@ -7,8 +7,6 @@ * (CLAUDE.md 5장 데이터 3층: 영구저장은 [저장]·[확정]에서만). * ========================================================================== */ -import { showToast } from "@ui/ui_template_elements"; -import { goToWorkflowStage, WORKFLOW_STEP_ROUTES } from "../A00_Common/b_workflow_nav"; import { confirmSections, saveSections, @@ -17,7 +15,7 @@ import { type SectionDetailResponse, } from "./B06_Section_Api_Fetch"; import { invalidateSectionDetail } from "./B06_Section_Section_Store"; -import { saveDesign, type DesignSaveContext } from "../A00_Common/design/design_save"; +import type { DesignViewSave } from "../A00_Common/design/design_shared"; import { buildCrossPatches, type CrossPatchSources } from "./B06_Section_UI_Page_Patches"; import { readByKey, readState, writeByKey, writeState } from "../A00_Common/b_page_state"; import { @@ -35,7 +33,6 @@ import type { StandardCrossSection } from "./B06_Section_Api_Fetch"; import type { RockBoundaryControl } from "./B06_Section_UI_Section_View"; import type { CutSlopeControl } from "./B06_Section_UI_Cross_CutSlope"; import { balloonOffsetsPayload } from "@util/common_util_mass_haul_balance_view"; -import { L } from "./B06_Section_UI_Page_Common"; /** * 세션에 쌓인 암 경계선 오프셋(측점키 → m). 없거나 손상되면 빈 객체. @@ -417,12 +414,12 @@ export function collectSectionEdits(ctx: SectionPersistContext): { } /** - * B06 의 저장 한 줄기 창구(계획서 24-2a) — 차례는 `saveDesign` 이 정하고(B05 [임시저장]과 같음), - * 여기서는 B06 몫만 준다: 저장 앞 전 측점 재계산 · 조정창 구간값 · 횡단 본문 · 뒤처리. + * [저장] · [확정]에 횡단 보기가 보태는 몫(계획서 24-2a · 24-3b) — 차례는 `saveDesign` 이 정하고 + * 한 벌(`design_shared`)이 두 보기 몫을 모아 보낸다. 여기서는 횡단 보기 몫만 준다: 저장 앞 전 측점 + * 재계산 · 조정창 구간값 · 횡단 본문 · 뒤처리(알림 · 다음 단계는 한 벌이 한다). */ -function sectionSaveContext(ctx: SectionPersistContext, projectId: string): DesignSaveContext { +export function crossSaveParts(ctx: SectionPersistContext): DesignViewSave { return { - projectId, routeId: ctx.routeId, // ⚠ 면적을 모으기 **전에** 전 측점을 다시 계산한다 — 안 그러면 표준단면을 고친 뒤 // 안 만진 측점이 옛 면적으로 실려 나간다(2026-09-09 실측). @@ -431,49 +428,22 @@ function sectionSaveContext(ctx: SectionPersistContext, projectId: string): Desi steps: { culvertOptions: () => ctx.flushCulvertOptions() }, cross: async (confirm) => { const routeId = ctx.routeId(); - if (routeId === null) return; + if (routeId === null || !ctx.projectId) return; const edits = collectSectionEdits(ctx); await (confirm ? confirmSections : saveSections)( - projectId, + ctx.projectId, routeId, ctx.standardValues(), edits.crossPatches.length ? edits.crossPatches : undefined, edits.massHaul, ); }, - done: (confirm) => { - if (confirm) { - showToast(L("B06_Profile_Confirm_Success"), "success"); - goToWorkflowStage(projectId, WORKFLOW_STEP_ROUTES[4]); - return; - } + done: () => { // 저장으로 구조물 초안이 정본이 됐다 — 화면이 들고 있던 상세는 초안 기준으로 벽을 // 얹은 사본이라, 그대로 두면 새로 저장된 구조물의 벽이 다음 조회까지 안 붙는다 // (2026-09-06 실측: 8+0.0 흙막이가 저장 뒤 화면 사본에만 빠져 있었음). const routeId = ctx.routeId(); - if (routeId !== null) invalidateSectionDetail(projectId, routeId); - showToast(L("B06_Profile_Save_Success"), "success"); - }, - failed: (error, confirm) => { - if (confirm) { - const detail = error instanceof Error ? error.message : L("B06_Profile_Confirm_Failed"); - showToast(`${L("B06_Profile_Confirm_Failed")} ${detail}`, "error"); - return; - } - const detail = error instanceof Error ? ` ${error.message}` : ""; - showToast(`${L("B06_Profile_Save_Failed")}${detail}`, "error"); + if (ctx.projectId && routeId !== null) invalidateSectionDetail(ctx.projectId, routeId); }, }; } - -/** 임시 저장 — 저장만 하고 페이지는 그대로 둔다. */ -export async function saveCurrentSections(ctx: SectionPersistContext): Promise { - if (!ctx.projectId) return; - await saveDesign(sectionSaveContext(ctx, ctx.projectId)); -} - -/** 확정 — 저장 뒤 다음 단계(상세설계)로 넘어간다. */ -export async function confirmCurrentSections(ctx: SectionPersistContext): Promise { - if (!ctx.projectId) return; - await saveDesign(sectionSaveContext(ctx, ctx.projectId), { confirm: true }); -} diff --git a/B06_Section/B06_Section_UI_Page_Structures_Panel.ts b/B06_Section/B06_Section_UI_Page_Structures_Panel.ts index 8c40bd6b..0feeb91f 100644 --- a/B06_Section/B06_Section_UI_Page_Structures_Panel.ts +++ b/B06_Section/B06_Section_UI_Page_Structures_Panel.ts @@ -1,8 +1,7 @@ /* ============================================================================= * B06_Section_UI_Page_Structures_Panel.ts - * B06 좌측 「구조물 배치」 — B05와 **같은 컨테이너·목록 템플릿**을 세운다 - * (2026-08-29 사용자: B05/B06 인터페이스 일원화. 페이지 본체는 700줄 제한으로 - * 여기서 조립만 받아 간다). + * 횡단 보기의 「구조물 배치」 몫 — 폼 · 목록은 종단 보기와 **같은 노드 한 벌**(`design_shared`)이고, + * 여기서는 횡단 보기가 보일 때 폼 알림을 받는 동작 · 횡단 조정 이음만 건다(계획서 24-3b). * * · 폼·목록 구현 = 공용 진입점(`A00_Common/b_structures_section`) — B05 그대로. * · 구조물(B~G군) 추가·수정·삭제는 세션 미저장분(`writePendingStructures`)에 쌓고 @@ -13,7 +12,12 @@ * 로드(소유 측점 기준). 하위 [횡단 조정] 컨테이너는 Adjust_Dock이 맡는다. * ========================================================================== */ -import { createStructuresSection, type PipeFacilityItem } from "../A00_Common/b_structures_section"; +import type { PipeFacilityItem } from "../A00_Common/b_structures_section"; +import { + bindDesignView, + sharedStructures, + type DesignPageShared, +} from "../A00_Common/design/design_shared"; import { fetchStructures, fetchStructureTypes, @@ -36,7 +40,6 @@ import { type DesignSelection, } from "../A00_Common/design/design_selection"; import { showToast } from "@ui/ui_template_elements"; -import { FORD_BRIDGE_DEFAULT_WIDTH_M } from "@config/config_frontend"; import { adjustDockRoot, refreshAdjustSlots, @@ -45,6 +48,7 @@ import { } from "./B06_Section_UI_Adjust_Dock"; import type { SectionDetailResponse } from "./B06_Section_Api_Fetch"; import type { StationControls } from "./B06_Section_UI_Page_Station_Controls"; +import { withSpecDefaults } from "./B06_Section_UI_Page_Structures_Spec"; const PIPE_ADD_GUIDE = "계곡 통과 시설을 넣고 빼려면 프로젝트를 먼저 여세요."; const PIPE_MOVE_GUIDE = "기준 측점 이동은 배수유역을 다시 나눠야 해 B05(종단) 화면에서 합니다."; @@ -60,6 +64,8 @@ const PIPE_MOVE_NOTICE = export interface B06StructuresPanelDeps { projectId: string | null; + /** 한 페이지 한 벌 — 「구조물 배치」 폼은 종단 보기와 같은 노드다(계획서 24-3b). */ + shared: DesignPageShared; /** 횡단 캐시 — 폼 기본값을 조정창과 **같은 원천**(culvert 스펙)에서 읽는다 * (2026-08-29 사용자: 예전 조정창이 보여주던 값이 기본값이다). */ detail?: () => SectionDetailResponse | null; @@ -74,8 +80,6 @@ export interface B06StructuresPanelDeps { stationInterval: () => number; /** 목록·폼에서 고른 시설의 측점 카드를 선택·스크롤한다. */ focusChainage: (chainageM: number) => void; - /** 바깥(횡단도·3D 넘김값)에서 고른 것이 폼에 실릴 때 — 접힌 좌측 패널을 펼친다. */ - reveal?: () => void; /** 목록이 바뀔 때마다 종단 알약 레인에 같은 목록을 넘긴다(2026-09-07 표시 통일). */ onMarks?: (structures: StructureInstance[], types: StructureType[]) => void; /** 폼 [수정]으로 바뀐 관 옵션을 캐시에 예약한다 — [저장]·[확정]이 정본에 쓴다 @@ -87,6 +91,8 @@ export interface B06StructuresPanelDeps { /** 구조물 목록이 바뀌었다 — 벽(C군)은 횡단 제원으로 얹혀 **면적까지 달라지므로** * 화면이 횡단을 다시 받아 그려야 한다(2026-09-06 사용자 확정). */ onStructuresChanged?: () => void; + /** 관 목록 초안을 고쳤다 — 한 벌 배수유역도가 그 초안으로 다시 선다. */ + onPipesWritten?: () => void; } export interface B06StructuresPanel { @@ -126,156 +132,6 @@ export interface B06StructuresPanel { }; } -type Merged = Record; - -/** 그 종류의 등록부 기본값 `{키: 기본값}` — 스펙 값이 기본값에서 왔는지 가르는 데 씀. */ -function registryDefaults(types: StructureType[], typeId: string): Record { - const options = types.find((type) => type.type_id === typeId)?.options ?? []; - return Object.fromEntries(options.map((option) => [option.key, option.default])); -} - -/** - * 스펙 값 한 칸을 옵션에 싣는 규칙 — **저장돼 있던 칸이거나, 조작해 기본값과 달라진 칸만.** - * 기본값에서 온 값을 실으면 페이지를 열기만 해도 임시 저장·[저장]에 기본값이 확정으로 굳음 - * (2026-09-14 브레인 판정 ④ 「기본값을 몰래 확정으로 바꾸지 않는다」). 폼은 빈 칸에 제안을 보임. - */ -function keepSpec( - merged: Merged, - stored: Merged | undefined, - key: string, - spec: string | number | null | undefined, - fromDefault: unknown, -): void { - if (spec === null || spec === undefined) return; - if (stored?.[key] !== undefined || String(spec) !== String(fromDefault ?? "")) merged[key] = spec; -} - -type WingSpec = { - installed: boolean; - height_m: number | null; - length_m: number | null; - angle_deg: number | null; -}; - -function keepWings( - merged: Merged, - stored: Merged | undefined, - defaults: Record, - wings: ReadonlyArray, -): void { - for (const [wing, prefix] of wings) { - // 백엔드 `_wing_spec` 과 같은 채움 — 설치는 「없음」이 아니면 있음 · 치수는 기본 0/0/45. - const install = String(defaults[prefix] ?? "") === "없음" ? "없음" : "있음"; - keepSpec(merged, stored, prefix, wing.installed ? "있음" : "없음", install); - keepSpec( - merged, - stored, - `${prefix}_height_m`, - wing.height_m, - defaults[`${prefix}_height_m`] ?? 0, - ); - keepSpec( - merged, - stored, - `${prefix}_length_m`, - wing.length_m, - defaults[`${prefix}_length_m`] ?? 0, - ); - keepSpec( - merged, - stored, - `${prefix}_angle_deg`, - wing.angle_deg, - defaults[`${prefix}_angle_deg`] ?? 45, - ); - } -} - -/** - * 세월교 폼 옵션 — **지금 그려진 스펙**(`section.ford`) 중 조작·저장분만 싣는다. 조정창 조작이 - * 스펙을 제자리에서 고치므로 그 값은 폼에 보여야 함(2026-08-30 사용자: 수량(련) 칸이 비고 조정창은 - * 1련) — 다만 1련이 **기본값에서 온 값이면** 제안으로만(판정 ④). - */ -function withFordSpec( - options: Merged | undefined, - ford: NonNullable, - defaults: Record, -): Merged { - const merged: Merged = { ...(options ?? {}) }; - keepSpec(merged, options, "pipe_kind", ford.pipe_kind, defaults.pipe_kind); - const diameter = Math.round(ford.diameter_m * 1000); - keepSpec(merged, options, "pipe_diameter_mm", diameter, defaults.pipe_diameter_mm ?? 1000); - const count = Number(defaults.pipe_count ?? 0); - keepSpec( - merged, - options, - "pipe_count", - ford.pipe_count, - count > 0 ? Math.max(Math.trunc(count), 1) : 1, - ); - keepSpec( - merged, - options, - "ford_width_m", - ford.span_m, - defaults.ford_width_m ?? FORD_BRIDGE_DEFAULT_WIDTH_M, - ); - keepWings(merged, options, defaults, [ - [ford.wing_in, "wing_in"], - [ford.wing_out, "wing_out"], - ]); - return merged; -} - -/** BOX암거 폼 옵션 — 세월교와 같은 규칙(조작·저장분만). 구체 길이·표고는 조정 채널이라 여기 안 옴. */ -function withBoxSpec( - options: Merged | undefined, - box: NonNullable, - defaults: Record, -): Merged { - const merged: Merged = { ...(options ?? {}) }; - keepSpec(merged, options, "body_width_m", box.inner_width_m, defaults.body_width_m ?? 2); - keepSpec(merged, options, "body_height_m", box.inner_height_m, defaults.body_height_m ?? 2); - keepWings(merged, options, defaults, [ - [box.wing_in, "wing_in"], - [box.wing_out, "wing_out"], - ]); - return merged; -} - -/** - * 관 지점 옵션에 **조정창에서 조작한 값**만 싣는다 — 조정창과 폼이 어긋나지 않게(2026-08-29 사용자). - * ⚠ 스펙의 기본값(관종·관경·구조·벽 길이·전/후·집수정 길이)은 **안 실음** — 페이지를 열기만 해도 - * 임시 저장에 기본값이 확정으로 굳던 자리(2026-09-14 브레인 판정 ④). 폼 빈 칸은 제안을 보임. - */ -function withSpecDefaults( - options: Merged | undefined, - detail: SectionDetailResponse | null, - chainageM: number, - types: StructureType[], - wallHeight?: (chainageM: number, role: "inlet" | "outlet") => number | null, - wallForm?: (chainageM: number, role: "inlet" | "outlet") => string | null, -): Merged | undefined { - const owner = detail?.cross_sections.find( - (section) => Math.abs(section.chainage_m - chainageM) < PIPE_MATCH_M, - ); - // 세월교·BOX는 폼과 조정창이 **같은 캐시**를 봄 — 조작한 값은 저장된 옵션보다 앞섬(2026-08-30). - if (owner?.ford) return withFordSpec(options, owner.ford, registryDefaults(types, "ford_bridge")); - if (owner?.box) return withBoxSpec(options, owner.box, registryDefaults(types, "box_culvert")); - const culvert = owner?.culvert; - if (!culvert) return options; - const merged: Merged = { ...(options ?? {}) }; - for (const prefix of ["inlet", "outlet"] as const) { - // 형태·높이는 **조작한 값**이 이긴다 — 조정값이 저장된 옵션보다 앞서기 때문(2026-08-30 사용자: - // 배관 유출구 기슭막이 형태가 폼과 다르게 그려졌다). 조작이 없으면 부르는 쪽이 null 을 줌. - const drawnForm = wallForm?.(chainageM, prefix); - if (drawnForm) merged[`${prefix}_revet_form`] = drawnForm; - const drawnHeight = wallHeight?.(chainageM, prefix); - if (drawnHeight != null) merged[`${prefix}_revet_height_m`] = Number(drawnHeight.toFixed(1)); - } - return merged; -} - export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06StructuresPanel { let structures: StructureInstance[] = []; let pipeFacilities: PipeFacilityItem[] = []; @@ -293,8 +149,11 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc /** 종단 알약 레인에 올릴 목록 — 구조물 정본 + 관 정본(가상 구조물). */ const pushMarks = (): void => deps.onMarks?.([...structures, ...pipesToStructureMarks(pipeFacilities)], markTypes); - const section = createStructuresSection( - { + // 폼 한 벌(계획서 24-3b) — 횡단 보기가 보일 때 폼 알림을 받는 동작만 건다. 상세 옵션 칸은 + // 한 벌이 보기에 따라 그린다(횡단 보기에서만). + const section = sharedStructures(deps.shared); + bindDesignView(deps.shared, "cross", { + structures: { onChange: (next) => { structures = withLocalIds(next); section.setStructures(structures); @@ -306,7 +165,6 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc if (structure) deps.focusChainage(structureAnchorM(structure)); }, getInterval: deps.stationInterval, - onReveal: () => deps.reveal?.(), // 관 추가 — 정본은 [저장]·[확정]에서 나간다. 화면 목록·알약은 바로 세운다. onPipeAdd: (chainageM, attributes) => { if (!deps.projectId) { @@ -383,14 +241,14 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc if (chainageM !== null) deps.focusChainage(chainageM); }, }, - { includeDetail: true }, - ); + }); // 고른 것 없이 좌측 폼을 만지면 값이 아무 데도 가지 않는다(패널 실시간 반영이 // 선택된 항목에만 걸린다) — 조용히 무시되던 자리라 이유를 알린다(2026-08-30 사용자: // "형태에 따라 모양을 넣어주는 기능이 누락되는 경우가 있어"의 원인). section.facility.root.addEventListener("change", () => { - if (!section.hasSelection()) showToast(PIPE_PICK_GUIDE, "error"); + if (deps.shared.view() === "cross" && !section.hasSelection()) + showToast(PIPE_PICK_GUIDE, "error"); }); // 하위 [횡단 조정] 컨테이너 — 폼 본문 맨 아래, 기존 항목과 경계 구분(2026-08-29 @@ -519,6 +377,7 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc ...(pipe.summary_item ? { summary_item: pipe.summary_item } : {}), })), ); + deps.onPipesWritten?.(); } /** 관 옮기기 — 폼의 측점 칸과 종단 끌기가 같은 길을 탄다. 재분할은 [저장]·[확정] 뒤. */ diff --git a/B06_Section/B06_Section_UI_Page_Structures_Spec.ts b/B06_Section/B06_Section_UI_Page_Structures_Spec.ts new file mode 100644 index 00000000..e4e81cc7 --- /dev/null +++ b/B06_Section/B06_Section_UI_Page_Structures_Spec.ts @@ -0,0 +1,161 @@ +/* ============================================================================= + * B06_Section_UI_Page_Structures_Spec.ts + * 횡단 보기 「구조물 배치」 폼의 **기본값 채움** — 조정창 · 도면이 쓰는 스펙 값 중 조작 · 저장분만 + * 폼 옵션에 싣는다(2026-09-14 브레인 판정 ④ 「기본값을 몰래 확정으로 안 바꿈」). + * `_Page_Structures_Panel` 이 700줄을 넘어 여기로 뗐다(계획서 24-3b). + * ========================================================================== */ + +import { FORD_BRIDGE_DEFAULT_WIDTH_M } from "@config/config_frontend"; +import type { StructureType } from "../B05_Profile/B05_Profile_Api_Structures"; +import { PIPE_MATCH_M } from "../A00_Common/design/design_selection"; +import type { SectionDetailResponse } from "./B06_Section_Api_Fetch"; + +type Merged = Record; + +/** 그 종류의 등록부 기본값 `{키: 기본값}` — 스펙 값이 기본값에서 왔는지 가르는 데 씀. */ +function registryDefaults(types: StructureType[], typeId: string): Record { + const options = types.find((type) => type.type_id === typeId)?.options ?? []; + return Object.fromEntries(options.map((option) => [option.key, option.default])); +} + +/** + * 스펙 값 한 칸을 옵션에 싣는 규칙 — **저장돼 있던 칸이거나, 조작해 기본값과 달라진 칸만.** + * 기본값에서 온 값을 실으면 페이지를 열기만 해도 임시 저장·[저장]에 기본값이 확정으로 굳음 + * (2026-09-14 브레인 판정 ④ 「기본값을 몰래 확정으로 바꾸지 않는다」). 폼은 빈 칸에 제안을 보임. + */ +function keepSpec( + merged: Merged, + stored: Merged | undefined, + key: string, + spec: string | number | null | undefined, + fromDefault: unknown, +): void { + if (spec === null || spec === undefined) return; + if (stored?.[key] !== undefined || String(spec) !== String(fromDefault ?? "")) merged[key] = spec; +} + +type WingSpec = { + installed: boolean; + height_m: number | null; + length_m: number | null; + angle_deg: number | null; +}; + +function keepWings( + merged: Merged, + stored: Merged | undefined, + defaults: Record, + wings: ReadonlyArray, +): void { + for (const [wing, prefix] of wings) { + // 백엔드 `_wing_spec` 과 같은 채움 — 설치는 「없음」이 아니면 있음 · 치수는 기본 0/0/45. + const install = String(defaults[prefix] ?? "") === "없음" ? "없음" : "있음"; + keepSpec(merged, stored, prefix, wing.installed ? "있음" : "없음", install); + keepSpec( + merged, + stored, + `${prefix}_height_m`, + wing.height_m, + defaults[`${prefix}_height_m`] ?? 0, + ); + keepSpec( + merged, + stored, + `${prefix}_length_m`, + wing.length_m, + defaults[`${prefix}_length_m`] ?? 0, + ); + keepSpec( + merged, + stored, + `${prefix}_angle_deg`, + wing.angle_deg, + defaults[`${prefix}_angle_deg`] ?? 45, + ); + } +} + +/** + * 세월교 폼 옵션 — **지금 그려진 스펙**(`section.ford`) 중 조작·저장분만 싣는다. 조정창 조작이 + * 스펙을 제자리에서 고치므로 그 값은 폼에 보여야 함(2026-08-30 사용자: 수량(련) 칸이 비고 조정창은 + * 1련) — 다만 1련이 **기본값에서 온 값이면** 제안으로만(판정 ④). + */ +function withFordSpec( + options: Merged | undefined, + ford: NonNullable, + defaults: Record, +): Merged { + const merged: Merged = { ...(options ?? {}) }; + keepSpec(merged, options, "pipe_kind", ford.pipe_kind, defaults.pipe_kind); + const diameter = Math.round(ford.diameter_m * 1000); + keepSpec(merged, options, "pipe_diameter_mm", diameter, defaults.pipe_diameter_mm ?? 1000); + const count = Number(defaults.pipe_count ?? 0); + keepSpec( + merged, + options, + "pipe_count", + ford.pipe_count, + count > 0 ? Math.max(Math.trunc(count), 1) : 1, + ); + keepSpec( + merged, + options, + "ford_width_m", + ford.span_m, + defaults.ford_width_m ?? FORD_BRIDGE_DEFAULT_WIDTH_M, + ); + keepWings(merged, options, defaults, [ + [ford.wing_in, "wing_in"], + [ford.wing_out, "wing_out"], + ]); + return merged; +} + +/** BOX암거 폼 옵션 — 세월교와 같은 규칙(조작·저장분만). 구체 길이·표고는 조정 채널이라 여기 안 옴. */ +function withBoxSpec( + options: Merged | undefined, + box: NonNullable, + defaults: Record, +): Merged { + const merged: Merged = { ...(options ?? {}) }; + keepSpec(merged, options, "body_width_m", box.inner_width_m, defaults.body_width_m ?? 2); + keepSpec(merged, options, "body_height_m", box.inner_height_m, defaults.body_height_m ?? 2); + keepWings(merged, options, defaults, [ + [box.wing_in, "wing_in"], + [box.wing_out, "wing_out"], + ]); + return merged; +} + +/** + * 관 지점 옵션에 **조정창에서 조작한 값**만 싣는다 — 조정창과 폼이 어긋나지 않게(2026-08-29 사용자). + * ⚠ 스펙의 기본값(관종·관경·구조·벽 길이·전/후·집수정 길이)은 **안 실음** — 페이지를 열기만 해도 + * 임시 저장에 기본값이 확정으로 굳던 자리(2026-09-14 브레인 판정 ④). 폼 빈 칸은 제안을 보임. + */ +export function withSpecDefaults( + options: Merged | undefined, + detail: SectionDetailResponse | null, + chainageM: number, + types: StructureType[], + wallHeight?: (chainageM: number, role: "inlet" | "outlet") => number | null, + wallForm?: (chainageM: number, role: "inlet" | "outlet") => string | null, +): Merged | undefined { + const owner = detail?.cross_sections.find( + (section) => Math.abs(section.chainage_m - chainageM) < PIPE_MATCH_M, + ); + // 세월교·BOX는 폼과 조정창이 **같은 캐시**를 봄 — 조작한 값은 저장된 옵션보다 앞섬(2026-08-30). + if (owner?.ford) return withFordSpec(options, owner.ford, registryDefaults(types, "ford_bridge")); + if (owner?.box) return withBoxSpec(options, owner.box, registryDefaults(types, "box_culvert")); + const culvert = owner?.culvert; + if (!culvert) return options; + const merged: Merged = { ...(options ?? {}) }; + for (const prefix of ["inlet", "outlet"] as const) { + // 형태·높이는 **조작한 값**이 이긴다 — 조정값이 저장된 옵션보다 앞서기 때문(2026-08-30 사용자: + // 배관 유출구 기슭막이 형태가 폼과 다르게 그려졌다). 조작이 없으면 부르는 쪽이 null 을 줌. + const drawnForm = wallForm?.(chainageM, prefix); + if (drawnForm) merged[`${prefix}_revet_form`] = drawnForm; + const drawnHeight = wallHeight?.(chainageM, prefix); + if (drawnHeight != null) merged[`${prefix}_revet_height_m`] = Number(drawnHeight.toFixed(1)); + } + return merged; +} diff --git a/B06_Section/B06_Section_UI_Section_View.ts b/B06_Section/B06_Section_UI_Section_View.ts index 652a33d8..d44411c1 100644 --- a/B06_Section/B06_Section_UI_Section_View.ts +++ b/B06_Section/B06_Section_UI_Section_View.ts @@ -1,7 +1,7 @@ /* ============================================================================= * B06_Section_UI_Section_View.ts - * 횡단 보기 컨트롤러: 위 칸 = 횡단 카드 그리드(그 안에서 스크롤) · 가운데 아래쪽 = - * 종단도 한 벌(종단 보기와 같은 자리 · 같은 도구, 계획서 24-0 · 24-2c)을 조립하고, + * 횡단 보기 컨트롤러: 가운데 위 칸 = 횡단 카드 그리드(그 안에서 스크롤). 아래쪽 종단도는 + * 한 페이지 한 벌(종단 보기와 같은 노드, 계획서 24-0 · 24-2c · 24-3b)에 카드를 잇고, * 측점 선택/스크롤, 반응형 폭 측정(ResizeObserver), 같은 행 높이 통일, 단건 카드 갱신을 * 관장한다. * @@ -56,6 +56,7 @@ import { import type { SectionViewController } from "./B06_Section_UI_Section_View_Types"; import { createFillSlopeNotice } from "./B06_Section_UI_Cross_FillSlope_Notice"; import { createSectionProfile } from "./B06_Section_UI_Section_View_Profile"; +import type { DesignPageShared } from "../A00_Common/design/design_shared"; export type { CrossDesignChange, DesignChangeHandler, RockBoundaryControl, SectionViewController }; @@ -74,6 +75,8 @@ export function createSectionView( box?: BoxControl, /** 측점별 암 절토 경사 입력(2026-09-07). */ cutSlope?: CutSlopeControl, + /** 한 페이지 한 벌 — 종단도는 여기 것을 쓴다(계획서 24-3b). */ + shared?: DesignPageShared, ): SectionViewController { const root = document.createElement("div"); root.className = "b06-section"; @@ -109,16 +112,19 @@ export function createSectionView( selectedStationId === id ? revealCard(id, "smooth") : selectStation(id, true), ); - /** 종단도 한 벌 — 첫 그리기에서 프로젝트를 알게 되면 만든다(`_Section_View_Profile`). */ - const profile = createSectionProfile({ - selectCard: (stationId) => { - if (stationId === null) clearSelection(); - else if (stationId !== selectedStationId) selectStation(stationId, true); - else revealCard(stationId, "smooth"); + /** 종단도 한 벌 — 첫 그리기에서 횡단 보기 알림을 건다(`_Section_View_Profile`). */ + const profile = createSectionProfile( + { + selectCard: (stationId) => { + if (stationId === null) clearSelection(); + else if (stationId !== selectedStationId) selectStation(stationId, true); + else revealCard(stationId, "smooth"); + }, + edit: () => structureEdit, + refreshCards: (chainages) => refreshCards(chainages), }, - edit: () => structureEdit, - refreshCards: (chainages) => refreshCards(chainages), - }); + shared as DesignPageShared, + ); const contentWidth = (): number => { // 요소가 DOM 밖(detached)이면 computed padding이 ""라 parseFloat이 NaN을 만든다. @@ -293,8 +299,6 @@ export function createSectionView( stationInterval: cachedStationInterval, ...profileInput, }); - const profileRoot = profile.root(); - if (profileRoot && profileRoot.parentElement !== root) root.append(profileRoot); } const draw = (): void => { @@ -465,11 +469,9 @@ export function createSectionView( selectedStationId = null; cards.replaceChildren(); }, - setActive: (active) => profile.setActive(active), dispose() { window.clearTimeout(resizeTimer); resizeObserver.disconnect(); - profile.dispose(); }, }; } diff --git a/B06_Section/B06_Section_UI_Section_View_Profile.ts b/B06_Section/B06_Section_UI_Section_View_Profile.ts index 6d1f532e..5137d8e4 100644 --- a/B06_Section/B06_Section_UI_Section_View_Profile.ts +++ b/B06_Section/B06_Section_UI_Section_View_Profile.ts @@ -1,17 +1,21 @@ /* ============================================================================= * B06_Section_UI_Section_View_Profile.ts - * 횡단 보기의 종단도 — 종단 보기와 **같은 종단도 한 벌**(`design_profile`)을 가운데 - * 아래쪽에 두고, 횡단 카드와 선택 · 구조물 편집 · 재계산을 잇는다(계획서 24-2c). + * 횡단 보기의 종단도 — 종단 보기와 **같은 노드 한 벌**(`design_shared` 의 종단도)에 횡단 보기 + * 알림을 걸어 횡단 카드와 선택 · 구조물 편집 · 재계산을 잇는다(계획서 24-2c · 24-3b). * * 옛 위쪽 붙박이 종단(자체 그래프 · 유토곡선 없음 · 흙덮기 막기 없음)을 갈음한다. * 도구(▲▼ · 되돌리기 · 직선화 · 옮기기 · 확대 · 표 · 유토곡선 · 범례 · 뱃지)와 막기 규칙 * (최소 흙덮기 · 기울기 상한)은 종단도 한 벌이 맡고, 여기서는 이음만 한다. * ========================================================================== */ -import { createDesignProfile, type DesignProfile } from "../A00_Common/design/design_profile"; +import type { DesignProfile } from "../A00_Common/design/design_profile"; +import { + bindDesignView, + sharedProfile, + type DesignPageShared, +} from "../A00_Common/design/design_shared"; import { readState } from "../A00_Common/b_page_state"; import { fetchLatestRoute, type RouteLatestResponse } from "../B05_Profile/B05_Profile_Api_Fetch"; -import { drainageSide, drainageSideTool } from "../B05_Profile/B05_Profile_UI_Profile_SideTool"; import type { CrossSection, EarthworkConversion, @@ -70,8 +74,6 @@ export interface SectionProfile { redraw: () => void; /** 카드를 골랐다 — 종단도 선택 표시가 따라간다. */ showCard: (section: CrossSection | null) => void; - setActive: (active: boolean) => void; - dispose: () => void; } /** 알약 목록 중 관(계곡 통과 시설)만 종단 측점선으로 편다 — 종단 보기와 같은 규칙. */ @@ -122,9 +124,11 @@ function loadRoute(projectId: string, profile: DesignProfile): void { }); } -export function createSectionProfile(host: SectionProfileHost): SectionProfile { +export function createSectionProfile( + host: SectionProfileHost, + shared: DesignPageShared, +): SectionProfile { let profile: DesignProfile | null = null; - let projectId: string | null = null; let detail: SectionDetailResponse | null = null; let marks: ReadonlyArray = []; let types: ReadonlyArray = []; @@ -165,58 +169,55 @@ export function createSectionProfile(host: SectionProfileHost): SectionProfile { ); }; - const build = (id: string): DesignProfile => - createDesignProfile({ - projectId: id, - view: "cross", - // 오른쪽 판(배수유역도) — 종단 보기와 같은 판 · 같은 켬끔 값(계획서 24-2e). - side: drainageSide, - sideTool: drainageSideTool, - onSelectStation: (stationId) => host.selectCard(cardOf(stationId)), - callbacks: { - onCrossDesignsUpdated: (updated) => host.refreshCards(updated), - onStructureMove: (from, to, station) => { - if (isPipeStation(station)) host.edit()?.movePipe(from, to); - }, - onStructureRemove: (station) => { - if (isPipeStation(station)) host.edit()?.removePipe(station.chainage_m); - }, - onPipeAdd: (chainage) => host.edit()?.addPipe(chainage), - onStructureAdd: (chainage, type) => - host.edit()?.addStructureType(chainage, LEGACY_TYPE_IDS[type] ?? "etc"), - onStructureTypeAdd: (chainage, typeId) => host.edit()?.addStructureType(chainage, typeId), - onIrregularSelect: (station) => - host.selectCard(nearestCard(station.chainage_m)?.station_id ?? null), - // 알약을 누르면 그 자리에 가장 가까운 측점 카드를 고른다 — 목록 클릭과 같은 규칙. - onStructureSelect: (structureId) => { - const hit = marks.find((entry) => entry.structure_id === structureId); - if (hit) host.selectCard(nearestCard(structureAnchorM(hit))?.station_id ?? null); - }, - onStructureMarkMove: (structureId, toChainageM) => { - const edit = host.edit(); - if (!edit) return; - moveMarkById(structureId, toChainageM, { - structures: marks, - stations: detail?.longitudinal.stations ?? [], - edit, - }); + /** 한 벌 종단도에 횡단 보기 알림을 건다 — 횡단 보기가 보일 때만 이쪽으로 온다. */ + const build = (): DesignProfile => { + bindDesignView(shared, "cross", { + profile: { + onSelectStation: (stationId) => host.selectCard(cardOf(stationId)), + callbacks: { + onCrossDesignsUpdated: (updated) => host.refreshCards(updated), + onStructureMove: (from, to, station) => { + if (isPipeStation(station)) host.edit()?.movePipe(from, to); + }, + onStructureRemove: (station) => { + if (isPipeStation(station)) host.edit()?.removePipe(station.chainage_m); + }, + onPipeAdd: (chainage) => host.edit()?.addPipe(chainage), + onStructureAdd: (chainage, type) => + host.edit()?.addStructureType(chainage, LEGACY_TYPE_IDS[type] ?? "etc"), + onStructureTypeAdd: (chainage, typeId) => host.edit()?.addStructureType(chainage, typeId), + onIrregularSelect: (station) => + host.selectCard(nearestCard(station.chainage_m)?.station_id ?? null), + // 알약을 누르면 그 자리에 가장 가까운 측점 카드를 고른다 — 목록 클릭과 같은 규칙. + onStructureSelect: (structureId) => { + const hit = marks.find((entry) => entry.structure_id === structureId); + if (hit) host.selectCard(nearestCard(structureAnchorM(hit))?.station_id ?? null); + }, + onStructureMarkMove: (structureId, toChainageM) => { + const edit = host.edit(); + if (!edit) return; + moveMarkById(structureId, toChainageM, { + structures: marks, + stations: detail?.longitudinal.stations ?? [], + edit, + }); + }, }, }, }); + return sharedProfile(shared); + }; return { root: () => profile?.root ?? null, render(input) { const [id, route] = input.balloonScope.split(":"); if (!id || id === "-") return; - if (profile && projectId !== id) { - profile.dispose(); - profile = null; - } if (!profile) { - projectId = id; - profile = build(id); - loadRoute(id, profile); + // 종단 보기가 먼저 세웠으면 노선 · 흙덮기 설정은 이미 실려 있다(판 확대를 흔들지 않음). + const fresh = !shared.profile; + profile = build(); + if (fresh) loadRoute(id, profile); syncMarks(); } detail = input.detail; @@ -255,10 +256,5 @@ export function createSectionProfile(host: SectionProfileHost): SectionProfile { ); profile.setSelectedStation(pipe ? irregularStationId(pipe.id) : section.station_id); }, - setActive: (active) => profile?.setActive(active), - dispose() { - profile?.dispose(); - profile = null; - }, }; } diff --git a/B06_Section/B06_Section_UI_Section_View_Types.ts b/B06_Section/B06_Section_UI_Section_View_Types.ts index bc434451..8878e2e6 100644 --- a/B06_Section/B06_Section_UI_Section_View_Types.ts +++ b/B06_Section/B06_Section_UI_Section_View_Types.ts @@ -52,7 +52,6 @@ export interface SectionViewController { ) => void; /** 종단 그래프 우클릭으로 구조물을 넣고 빼는 길(2026-09-12 B05·B06 일원화). */ setStructureEdit: (edit: SectionStructureEdit | null) => void; - /** 보기가 숨으면 false — 종단도의 창 키 듣기를 떼고, 보이면 잇는다(계획서 24-3a). */ - setActive: (active: boolean) => void; + /** 떠날 때 — 카드 칸 크기 관찰을 뗀다(종단도는 한 벌이라 페이지가 정리한다). */ dispose: () => void; } diff --git a/resources/tester/helper_design_view_parity.cjs b/resources/tester/helper_design_view_parity.cjs index 20da79ff..e64fa9a8 100644 --- a/resources/tester/helper_design_view_parity.cjs +++ b/resources/tester/helper_design_view_parity.cjs @@ -1,6 +1,6 @@ -/* 두 보기 좌측 패널 **같음 지킴** 헬퍼(계획서 24-2f) — 공용 등록부(`design_panel` · `design_dock` · - * `design_sections`)와 종단 보기 칸 정의를 트랜스파일해 가짜 DOM 위에 두 보기를 같은 견본으로 세운다. - * 결과는 표준출력에 JSON 한 벌. +/* 두 보기 좌측 패널 **한 벌 지킴** 헬퍼(계획서 24-2f · 24-3b) — 공용 등록부(`design_panel` · `design_dock` · + * `design_sections`)와 두 보기 칸 정의를 트랜스파일해 가짜 DOM 위에 **패널 하나**를 세우고 + * `setView` 로 보기를 오가며 같은 노드인지 본다(한 벌 `design_shared` 가 하는 그대로). 결과는 JSON 한 벌. * * ⚠ 확장자가 `.cjs` 인 까닭은 `helper_b05_curve_radius_floor.cjs` 머리와 같다. */ @@ -141,6 +141,11 @@ const STUBS = { button.addEventListener("click", () => onClick && onClick()); return button; }, + createInputField: () => ({ root: new FakeNode("div"), input: new FakeNode("input") }), + }, + "./B06_Section_UI_Page_Common": { + L: (key) => + ({ B06_View_Title: "횡단 보기 설정", B06_Std_Title: "표준 횡단면 설정" })[key] ?? key, }, }; @@ -166,8 +171,9 @@ function loadTs(file) { const panel = loadTs(path.join(ROOT, "A00_Common", "design", "design_panel.ts")); loadTs(path.join(ROOT, "A00_Common", "design", "design_sections.ts")); loadTs(path.join(ROOT, "B05_Profile", "B05_Profile_UI_Panel_Sections.ts")); +loadTs(path.join(ROOT, "B06_Section", "B06_Section_UI_Page_Panel.ts")); -// ── 같은 견본 — 두 보기가 **같은 구조물 배치 부품**을 받는다 ───────────────────── +// ── 견본 — 두 보기 부품을 한 `parts` 에 모은다(한 벌 `design_shared` 와 같은 모양) ────────── function sample() { const root = new FakeNode("section"); root.className = @@ -182,9 +188,11 @@ function sample() { return { root, listRoot }; } const text = (t) => Object.assign(new FakeNode("div"), { _text: t }); -function context(view, calls) { - const run = (name) => () => calls.push(`${view}:${name}`); - return { +const calls = []; +let current = "profile"; +const run = (name) => () => calls.push(`${current}:${name}`); +const built = panel.createDesignPanel( + { projectId: "p", parts: { structures: sample(), @@ -197,44 +205,72 @@ function context(view, calls) { root.hidden = true; }, }, + crossView: { row: text("cross-row") }, + standardSlot: text("std"), }, dock: { reset: { run: run("reset") }, save: { run: run("save") }, - confirm: { run: run("confirm"), enabled: view === "cross" }, + confirm: { run: run("confirm") }, switchView: { run: run("switch") }, }, - }; -} + }, + "profile", +); -const calls = []; -const views = {}; -for (const view of ["profile", "cross"]) { - const built = panel.createDesignPanel(context(view, calls), view); +const titleOf = (node) => node.children.find((c) => c.tagName === "h3")?.textContent ?? ""; +const nodes = () => { const dockRoot = built.dock.root; - const buttons = dockRoot.querySelectorAll(".ui-btn"); + return { + root: built.root, + dock: dockRoot, + buttons: dockRoot.querySelectorAll(".ui-btn"), + structures: built.root.children.find((c) => c.classList.contains("b05-structure-section")), + }; +}; +function snapshot() { + const { dock, buttons, structures } = nodes(); + const shown = built.root.children.filter((c) => c !== dock && c.style.display !== "none"); buttons.forEach((b) => b.click()); - const structures = built.root.children.find((c) => c.classList.contains("b05-structure-section")); - views[view] = { + return { rootClass: built.root.className, - sectionIds: panel.panelSectionsFor(view).map((d) => d.id), + shownTitles: shown.map(titleOf), + structuresFirst: shown[0] === structures, structuresHtml: html(structures), - structuresFirst: built.root.children[0] === structures, - dockFirstChild: html(dockRoot.children[0]), - dockClass: dockRoot.className, + dockFirstChild: html(dock.children[0]), + dockIsLast: built.root.children[built.root.children.length - 1] === dock, dockLabels: buttons.map((b) => b.textContent), dockActions: buttons.map((b) => b.dataset.dockAction), dockDisabled: buttons.map((b) => b.disabled), - dockRowClass: dockRoot.children[dockRoot.children.length - 1].className, - dockIsLast: built.root.children[built.root.children.length - 1] === dockRoot, }; } -// 보기 전환 — 글자가 따라 바뀐다. -const switched = panel.createDesignPanel(context("profile", []), "profile"); -switched.setView("cross"); -const switchLabelAfter = switched.dock.root - .querySelectorAll(".ui-btn") - .map((b) => b.textContent) - .slice(-1)[0]; -process.stdout.write(JSON.stringify({ views, calls, switchLabelAfter })); +const first = nodes(); +const views = { profile: snapshot() }; +current = "cross"; +built.setView("cross"); +views.cross = snapshot(); +const afterCross = nodes(); +current = "profile"; +built.setView("profile"); +views.back = snapshot(); +const afterBack = nodes(); +const same = (a, b) => + a.root === b.root && + a.dock === b.dock && + a.structures === b.structures && + a.buttons.length === b.buttons.length && + a.buttons.every((button, index) => button === b.buttons[index]); + +process.stdout.write( + JSON.stringify({ + views, + calls, + sameNodes: { cross: same(first, afterCross), back: same(first, afterBack) }, + childCount: built.root.children.length, + sectionIds: { + profile: panel.panelSectionsFor("profile").map((d) => d.id), + cross: panel.panelSectionsFor("cross").map((d) => d.id), + }, + }), +); diff --git a/resources/tester/test_b05_b06_selection_single.py b/resources/tester/test_b05_b06_selection_single.py index 71ee5b83..13728118 100644 --- a/resources/tester/test_b05_b06_selection_single.py +++ b/resources/tester/test_b05_b06_selection_single.py @@ -57,7 +57,8 @@ def test_거리_정의는_한_곳뿐이고_옛_0_05는_없다(): assert definitions == ["design_selection.ts"] for name, folder in ( ("B05_Profile_UI_Structures_Panel.ts", "B05_Profile"), - ("B05_Profile_UI_Page.ts", "B05_Profile"), + ("design_view_profile.ts", "A00_Common/design"), + ("design_view_cross.ts", "A00_Common/design"), ("B05_Profile_UI_Selection.ts", "B05_Profile"), ("B06_Section_UI_Page_Structures_Panel.ts", "B06_Section"), ("B06_Section_UI_Page_Structure_Pick.ts", "B06_Section"), @@ -178,14 +179,14 @@ def test_횡단도_벽_구체_눌러도_폼은_같은_길_하나(): assert panel.count("panel.showAtChainage(") == 3 # 기슭막이 · 세월교 · BOX assert panel.count("panel.releasePipe()") == 3 # 벽 선택을 풀 때는 관 폼만 비움 assert "section.selectAt(" in panel - page = _read("B06_Section", "B06_Section_UI_Page.ts") + page = _read("A00_Common/design", "design_view_cross.ts") assert "writeStructurePick" not in page assert "selectDesign(" in page def test_고름은_selectDesign_한_길로_적는다(): for folder, name in ( - ("B05_Profile", "B05_Profile_UI_Page.ts"), + ("A00_Common/design", "design_view_profile.ts"), ("B05_Profile", "B05_Profile_UI_Structure_Pick_Session.ts"), ("B06_Section", "B06_Section_UI_Page_Structures_Panel.ts"), ): diff --git a/resources/tester/test_b05_stale_design_sync.py b/resources/tester/test_b05_stale_design_sync.py index 757cedb6..6e8532e2 100644 --- a/resources/tester/test_b05_stale_design_sync.py +++ b/resources/tester/test_b05_stale_design_sync.py @@ -23,9 +23,12 @@ B05_PANEL = (PROJECT_ROOT / "A00_Common" / "design" / "design_profile.ts").read_ encoding="utf-8" ) # 설계 재계산 자리는 700줄 제한으로 `_UI_Page_Design_Sync` 로 떨어져 나갔다(2026-09-13). -B06_PAGE = (PROJECT_ROOT / "B06_Section" / "B06_Section_UI_Page.ts").read_text(encoding="utf-8") + ( - PROJECT_ROOT / "B06_Section" / "B06_Section_UI_Page_Design_Sync.ts" -).read_text(encoding="utf-8") +# 횡단 보기 본체는 한 페이지 보기 파일로 옮겼다(계획서 24-3b). +B06_PAGE = (PROJECT_ROOT / "A00_Common" / "design" / "design_view_cross.ts").read_text( + encoding="utf-8" +) + (PROJECT_ROOT / "B06_Section" / "B06_Section_UI_Page_Design_Sync.ts").read_text( + encoding="utf-8" +) def _stale(profile_samples, sections, tol=1e-3): diff --git a/resources/tester/test_b06_detail_options.py b/resources/tester/test_b06_detail_options.py index 9bedfb3c..e6da982f 100644 --- a/resources/tester/test_b06_detail_options.py +++ b/resources/tester/test_b06_detail_options.py @@ -21,7 +21,8 @@ from B05_Profile.B05_Profile_Structures_Schema import load_structure_types # no _PANEL = PROJECT_ROOT / "B05_Profile" / "B05_Profile_UI_Structures_Panel.ts" # 옵션 칸 그리기는 700줄 제한으로 이 조각으로 옮겨 감(2026-09-29 · PLAN 21-4) _PANEL_OPTIONS = PROJECT_ROOT / "B05_Profile" / "B05_Profile_UI_Structures_Panel_Options.ts" -_B06 = PROJECT_ROOT / "B06_Section" / "B06_Section_UI_Page_Structures_Panel.ts" +# 폼은 두 보기가 한 벌을 쓴다 — 상세 칸은 한 벌이 보기에 따라 그린다(계획서 24-3b). +_SHARED = PROJECT_ROOT / "A00_Common" / "design" / "design_shared.ts" _B05 = PROJECT_ROOT / "B05_Profile" / "B05_Profile_UI_Panel.ts" @@ -30,7 +31,9 @@ def _type(type_id: str): def test_B06_은_상세_칸까지_받는다(): - assert "includeDetail: true" in _B06.read_text(encoding="utf-8") + assert 'includeDetail: () => shared.view() === "cross"' in _SHARED.read_text(encoding="utf-8") + # 그릴 때마다 지금 보기로 가린다 — 만들 때 한 번 굳히면 한 벌 폼이 보기를 못 따라간다. + assert 'typeof detail === "function" ? detail()' in _PANEL.read_text(encoding="utf-8") def test_B05_는_종전대로_유무_종류_위치만(): diff --git a/resources/tester/test_b06_save_channels.py b/resources/tester/test_b06_save_channels.py index 1ba209c4..b06e2483 100644 --- a/resources/tester/test_b06_save_channels.py +++ b/resources/tester/test_b06_save_channels.py @@ -88,11 +88,16 @@ def test_저장이_창구를_다섯_다_거친다(): def test_두_페이지가_저장_한_줄기를_탄다(): - """B05 [임시저장] · B06 [저장] · [확정]이 창구를 따로 부르지 않는다(계획서 24-2a).""" + """[저장] · [확정]은 한 페이지 한 벌이 두 보기 몫을 모아 저장 한 줄기 한 번으로 보낸다 + (계획서 24-2a · 24-3b) — 보기 몫은 창구를 따로 부르지 않는다.""" + shared = (ROOT / "A00_Common" / "design" / "design_shared.ts").read_text(encoding="utf-8") actions = (ROOT / "B05_Profile" / "B05_Profile_UI_Page_Actions.ts").read_text(encoding="utf-8") persist = (ROOT / "B06_Section" / "B06_Section_UI_Page_Persist.ts").read_text(encoding="utf-8") - for name, text in (("B05", actions), ("B06", persist)): - assert "saveDesign(" in text, f"{name} 가 저장 한 줄기를 안 탐" + assert shared.count("await saveDesign(") == 1, "저장 한 줄기를 한 자리에서만 부름" + assert "saveSharedDesign(shared, true)" in shared, "[확정]이 한 줄기를 안 탐" + assert 'await shared.ensureView("cross")' in shared, "종단 보기 [확정]이 횡단 자료를 못 얻음" + for name, text in (("종단 보기", actions), ("횡단 보기", persist), ("한 벌", shared)): for token in ("flushPendingPipes(", "flushPendingStructures(", "flushUphillOverrides("): assert token not in text, f"{name} 가 {token} 를 따로 부름 — 차례가 갈림" - assert "{ confirm: true }" in persist, "[확정]이 한 줄기를 안 탐" + assert "export function profileSaveParts(" in actions + assert "export function crossSaveParts(" in persist diff --git a/resources/tester/test_b06_save_recompute.py b/resources/tester/test_b06_save_recompute.py index c4ce42d8..a87c3222 100644 --- a/resources/tester/test_b06_save_recompute.py +++ b/resources/tester/test_b06_save_recompute.py @@ -6,10 +6,10 @@ 안 만진 측점이 옛 값 그대로 정본이 된다. (같은 입력을 주면 파이썬·TS 는 같은 값을 낸다 — 거울 문제가 아니라 **입력이 갈린 것**이다.) -⇒ `saveCurrentSections`·`confirmCurrentSections` 가 `collectSectionEdits` **앞에서** - `ctx.reconcileDesigns()` 를 부른다. 순서가 뒤집히면 옛 면적이 그대로 실린다. - (계획서 24-2a — 둘 다 저장 한 줄기 `saveDesign` 을 탄다: B06 창구 `sectionSaveContext` 의 - `prepare` 가 재계산, `cross` 가 면적 수집이고, `saveDesign` 이 `prepare` 를 먼저 부른다.) +⇒ [저장]·[확정]이 `collectSectionEdits` **앞에서** `ctx.reconcileDesigns()` 를 부른다. 순서가 + 뒤집히면 옛 면적이 그대로 실린다. (계획서 24-2a · 24-3b — 한 벌(`design_shared`)이 저장 한 줄기 + `saveDesign` 을 부르고, 횡단 보기 몫 `crossSaveParts` 의 `prepare` 가 재계산 · `cross` 가 면적 + 수집이며, `saveDesign` 이 `prepare` 를 먼저 부른다.) """ from __future__ import annotations @@ -18,7 +18,8 @@ from pathlib import Path ROOT = Path(__file__).resolve().parents[2] PERSIST = ROOT / "B06_Section" / "B06_Section_UI_Page_Persist.ts" -PAGE = ROOT / "B06_Section" / "B06_Section_UI_Page.ts" +PAGE = ROOT / "A00_Common" / "design" / "design_view_cross.ts" +SHARED = ROOT / "A00_Common" / "design" / "design_shared.ts" SAVE = ROOT / "A00_Common" / "design" / "design_save.ts" @@ -29,16 +30,11 @@ def _body(source: str, marker: str) -> str: def test_저장이_면적을_모으기_전에_다시_계산한다() -> None: text = PERSIST.read_text(encoding="utf-8") - for marker in ( - "export async function saveCurrentSections", - "export async function confirmCurrentSections", - ): - assert "saveDesign(sectionSaveContext(" in _body(text, marker)[:400], ( - f"{marker} 가 B06 창구로 저장 한 줄기를 안 탄다" - ) - body = _body(text, "function sectionSaveContext") - assert "prepare: () => ctx.reconcileDesigns()" in body, "B06 창구가 재계산을 안 넘긴다" + body = _body(text, "export function crossSaveParts") + assert "prepare: () => ctx.reconcileDesigns()" in body, "횡단 보기 몫이 재계산을 안 넘긴다" assert "collectSectionEdits(ctx)" in body[body.index("cross:") :], "면적 수집이 횡단 차례 밖" + shared = _body(SHARED.read_text(encoding="utf-8"), "export async function saveSharedDesign") + assert "prepare: crossSave?.prepare" in shared, "한 벌이 재계산을 저장 한 줄기에 안 넘긴다" save = SAVE.read_text(encoding="utf-8") run = _body(save, "export async function saveDesign") assert run.index("ctx.prepare?.()") < run.index("ctx.cross(confirm)"), ( diff --git a/resources/tester/test_design_page_single.py b/resources/tester/test_design_page_single.py index 4fdfb920..e78ba74a 100644 --- a/resources/tester/test_design_page_single.py +++ b/resources/tester/test_design_page_single.py @@ -32,24 +32,28 @@ def test_같은_페이지면_셸도_다시_안_그림(): assert "if (within) return;" in main -def test_보기_약속_두_길(): +def test_보기_약속_가운데만(): + """끝 모습(24-3b) — 보기는 가운데 위 칸만 채우고 좌측 패널 · 단추 · 종단도는 한 벌을 쓴다.""" page = _read("A00_Common/design/design_page.ts") assert "export interface DesignPageView" in page - assert "mount?: (root: HTMLElement, host: DesignPageHost)" in page - assert "mountCenter?: (" in page and "shared: DesignPageShared" in page + assert "mount?: (root" not in page # 보기가 틀을 통째로 그리던 중간 길은 걷음 + assert "mountCenter: (" in page and "shared: DesignPageShared" in page + assert 'import("./design_view_profile")).mountProfileView' in page + assert 'import("./design_view_cross")).mountCrossView' in page for hook in ("onShow:", "onHide:", "onDispose:"): assert hook in page # 보기는 처음 보일 때 한 번만 만든다 — 있으면 보이기만. - assert "if (slot) slot.show();" in page + assert "if (slot) {\n slot.show();" in page def test_페이지가_숨김_떠남을_건다(): life = _read("B05_Profile/B05_Profile_UI_Page_Life.ts") assert "deps.viewer.setActive(false)" in life and "deps.viewer.dispose()" in life - assert "bindProfileViewLife(host," in _read("B05_Profile/B05_Profile_UI_Page.ts") - b06 = _read("B06_Section/B06_Section_UI_Page.ts") - assert "host?.onHide(() => sectionView.setActive(false));" in b06 - assert "host?.onDispose(() => sectionView.dispose());" in b06 + assert "bindProfileViewLife(host," in _read("A00_Common/design/design_view_profile.ts") + cross = _read("A00_Common/design/design_view_cross.ts") + assert "sectionView.dispose();" in cross[cross.index("host.onDispose(") :] + # 종단도는 한 벌 — 보기가 숨어도 그대로 두고, 페이지를 떠날 때 한 번 정리한다. + assert "shared.profile?.dispose();" in _read("A00_Common/design/design_page.ts") def test_3D_멈춤과_문맥_놓기(): @@ -74,3 +78,31 @@ def test_창_키_듣기를_뗌(): def test_겹친_알약은_누른_것을_그대로(): profile = _read("A00_Common/design/design_profile.ts") assert "stationIdAtStructure(selectedStructureId) === stationId" in profile + + +def test_진행_단계_두_칸이_보기를_따라감(): + """종단설계 · 횡단설계 두 칸은 같은 페이지 — 누르면 주소만 바꿔 보기만 바뀐다(계획서 24-3b).""" + page = _read("A00_Common/design/design_page.ts") + assert "const VIEW_STEP: Record = { profile: 2, cross: 3 };" in page + assert "layout?.setActiveStep(VIEW_STEP[view]);" in page + assert "layout?.setTitle(" in page + click = page[page.index("onStepClick: (stepIndex) => {") :][:260] + assert "if (designViewOf(route)) navigateTo(route);" in click + layout = _read("ui_template/ui_template_workflow_layout.ts") + assert "setActiveStep: (stepIndex) =>" in layout and "setTitle: (title) =>" in layout + + +def test_초기화_계획노선_편집은_다시_세움(): + """같은 페이지 안 이동은 보기만 바꾸므로 [초기화] · 계획노선 편집은 페이지를 다시 세운다.""" + router = _read("A00_Common/router.ts") + assert "export function remountRoute(" in router + assert "remountRoute(ROUTES.B05_PROFILE)" in _read("A00_Common/design/design_reset.ts") + assert "remountRoute(ROUTES.B05_PROFILE)" in _read("A00_Common/design/design_view_profile.ts") + + +def test_종단_보기_확정은_횡단_보기를_숨긴_채_세움(): + page = _read("A00_Common/design/design_page.ts") + assert "ensureView: (view) => (slots.get(view) ?? mountView(view, false)).ready," in page + cross = _read("A00_Common/design/design_view_cross.ts") + # 숨긴 채 섰으면 한 벌 폼은 종단 보기 몫 — 보일 때 싣는다. + assert "if (host.visible()) void structuresPanel.load()" in cross diff --git a/resources/tester/test_design_profile_single.py b/resources/tester/test_design_profile_single.py index 8c36d7b7..c6eba38b 100644 --- a/resources/tester/test_design_profile_single.py +++ b/resources/tester/test_design_profile_single.py @@ -36,13 +36,16 @@ def results(): def test_두_보기가_같은_종단도와_같은_판(): + """종단도는 한 페이지 한 벌(계획서 24-3b) — 두 보기는 같은 노드에 제 알림만 건다.""" + shared = _read("A00_Common/design/design_shared.ts") + assert shared.count("createDesignProfile(") == 1 + assert "side: drainageSide" in shared and "sideTool: drainageSideTool" in shared b05 = _read("B05_Profile/B05_Profile_UI_Profile_Panel.ts") b06 = _read("B06_Section/B06_Section_UI_Section_View_Profile.ts") - for source in (b05, b06): - assert "createDesignProfile(" in source - assert "side: drainageSide" in source - assert "sideTool: drainageSideTool" in source - assert 'view: "profile"' in b05 and 'view: "cross"' in b06 + for source, view in ((b05, "profile"), (b06, "cross")): + assert "createDesignProfile(" not in source + assert "sharedProfile(shared)" in source + assert f'bindDesignView(shared, "{view}"' in source def test_옛_B06_위쪽_종단이_남지_않음(): diff --git a/resources/tester/test_design_view_parity.py b/resources/tester/test_design_view_parity.py index 0e6182a5..c56ec2f2 100644 --- a/resources/tester/test_design_view_parity.py +++ b/resources/tester/test_design_view_parity.py @@ -1,10 +1,11 @@ # -*- coding: utf-8 -*- -"""B05 · B06 한 페이지 두 보기 — **좌측 패널 같음 지킴**(계획서 24-2f). +"""B05 · B06 한 페이지 두 보기 — **좌측 패널 한 벌 지킴**(계획서 24-2f · 24-3b). -① 두 보기의 좌측 패널을 같은 견본으로 세워 공용 칸(구조물 배치)의 DOM · 아래 고정 영역이 같은지 본다 - (Node 헬퍼 `helper_design_view_parity.cjs` 가 등록부를 트랜스파일해 가짜 DOM 위에서 돌린다). -② 페이지 파일(`B05_Profile_UI_Page.ts` · `B06_Section_UI_Page.ts`)이 좌측 칸 · 아래 단추 · 저장 단추를 - 직접 만들면 빨강 — 칸과 단추는 등록부(`A00_Common/design/`)만 만든다. +① 좌측 패널을 **하나** 세우고 보기를 오가도 뿌리 · 구조물 배치 · 아래 고정 영역 · 단추 넷이 같은 노드인지, + 보기마다 제 칸만 보이는지, [보기 전환] 글자가 따라가는지 본다(Node 헬퍼 `helper_design_view_parity.cjs` + 가 등록부를 트랜스파일해 가짜 DOM 위에서 돌린다). +② 보기 파일(`design_view_profile.ts` · `design_view_cross.ts`)이 좌측 칸 · 아래 단추 · 저장 단추를 + 직접 만들면 빨강 — 칸 · 단추 · 저장은 한 벌(`design_shared`)만 만든다. """ import json @@ -18,8 +19,8 @@ import pytest HERE = os.path.dirname(os.path.abspath(__file__)) ROOT = Path(HERE).parents[1] PAGE_FILES = ( - ROOT / "B05_Profile" / "B05_Profile_UI_Page.ts", - ROOT / "B06_Section" / "B06_Section_UI_Page.ts", + ROOT / "A00_Common" / "design" / "design_view_profile.ts", + ROOT / "A00_Common" / "design" / "design_view_cross.ts", ) # 페이지 파일에 나오면 안 되는 것 — 좌측 칸 · 아래 단추 · 저장 단추를 직접 만든다는 표시. FORBIDDEN_IN_PAGES = ( @@ -48,72 +49,68 @@ def results(): ["node", os.path.join(HERE, "helper_design_view_parity.cjs")], capture_output=True, text=True, + encoding="utf-8", timeout=60, ) assert proc.returncode == 0, proc.stderr return json.loads(proc.stdout) -def test_공용_칸_구조물_배치는_두_보기가_같은_DOM(results): - profile, cross = results["views"]["profile"], results["views"]["cross"] - assert profile["structuresHtml"] == cross["structuresHtml"] - assert profile["structuresFirst"] and cross["structuresFirst"] # 맨 위 칸 +def test_보기를_오가도_좌측_패널_단추_구조물_배치는_같은_노드(results): + assert results["sameNodes"] == {"cross": True, "back": True} + # 보기마다 새로 만들지 않는다 — 칸은 처음 보일 때 한 번만 서고 뒤로는 숨김 · 보임만. + assert results["childCount"] == 1 + 5 + 2 # 아래 고정 영역 + 종단 칸 다섯 + 횡단 칸 둘 + + +def test_구조물_배치는_두_보기_모두_맨_위_같은_DOM(results): + views = results["views"] + assert views["profile"]["structuresHtml"] == views["cross"]["structuresHtml"] + assert all(views[name]["structuresFirst"] for name in ("profile", "cross", "back")) def test_구조물_목록은_두_보기_모두_아래_고정_영역_맨_위(results): - profile, cross = results["views"]["profile"], results["views"]["cross"] - assert profile["dockFirstChild"] == cross["dockFirstChild"] - assert profile["dockClass"] == cross["dockClass"] - assert profile["dockIsLast"] and cross["dockIsLast"] + views = results["views"] + assert views["profile"]["dockFirstChild"] == views["cross"]["dockFirstChild"] + assert all(views[name]["dockIsLast"] for name in ("profile", "cross", "back")) -def test_아래_단추는_네_개_두_보기_같은_차례(results): - profile, cross = results["views"]["profile"], results["views"]["cross"] - assert ( - profile["dockActions"] - == cross["dockActions"] - == [ - "reset", - "save", - "confirm", - "switchView", - ] - ) - assert profile["dockLabels"][:3] == cross["dockLabels"][:3] == ["초기화", "저장", "확정"] - - -def test_보기_전환_단추는_가려는_쪽_이름(results): - assert results["views"]["profile"]["dockLabels"][3] == "횡단 보기" - assert results["views"]["cross"]["dockLabels"][3] == "종단 보기" - assert results["switchLabelAfter"] == "종단 보기" # setView("cross") 뒤 - - -def test_꺼진_단추는_안_눌리고_켜진_단추는_동작을_부름(results): - assert results["views"]["profile"]["dockDisabled"] == [False, False, True, False] - assert results["views"]["cross"]["dockDisabled"] == [False, False, False, False] - assert results["calls"] == [ - "profile:reset", - "profile:save", - "profile:switch", - "cross:reset", - "cross:save", - "cross:confirm", - "cross:switch", +def test_보기마다_제_칸만_보인다(results): + views = results["views"] + assert views["profile"]["shownTitles"] == [ + "구조물 배치", + "페이지 설정", + "계획노선", + "경로 계산 설정", + "선택 포인트 상세 설정", ] + assert views["cross"]["shownTitles"] == ["구조물 배치", "횡단 보기 설정", "표준 횡단면 설정"] + assert views["back"]["shownTitles"] == views["profile"]["shownTitles"] + assert results["sectionIds"]["cross"] == ["structures", "cross-view", "standard-cross"] -def test_칸_차례는_보기마다_등록부_순서(results): - assert results["views"]["profile"]["sectionIds"] == [ - "structures", - "page-settings", - "planned-route", - "route-calc", - "selected-point", +def test_아래_단추는_네_개_보기_전환_글자만_따라감(results): + views = results["views"] + for name in ("profile", "cross", "back"): + assert views[name]["dockActions"] == ["reset", "save", "confirm", "switchView"] + assert views[name]["dockLabels"][:3] == ["초기화", "저장", "확정"] + # [확정]은 종단 보기에서도 켜진다(한 벌 저장이 횡단 자료를 같은 페이지에서 얻음). + assert views[name]["dockDisabled"] == [False, False, False, False] + assert views["profile"]["dockLabels"][3] == "횡단 보기" + assert views["cross"]["dockLabels"][3] == "종단 보기" + assert views["back"]["dockLabels"][3] == "횡단 보기" + + +def test_단추는_한_벌_동작을_부름(results): + names = ["reset", "save", "confirm", "switch"] + assert results["calls"] == [ + *(f"profile:{n}" for n in names), + *(f"cross:{n}" for n in names), + *(f"profile:{n}" for n in names), ] def test_횡단_보기_칸_정의와_차례(): - """B06 칸 정의는 화면 부품(입력 칸 · 표준 패널)을 물어 Node 에서 못 읽는다 — 소스로 본다.""" + """B06 칸 정의는 소스로도 본다 — 차례가 바뀌면 빨강.""" source = (ROOT / "B06_Section" / "B06_Section_UI_Page_Panel.ts").read_text(encoding="utf-8") found = re.findall( r'id: "([\w-]+)",\s*title:[^\n]*\n\s*views: \["cross"\],\s*order: (\d+)', source @@ -125,14 +122,34 @@ def test_횡단_보기_칸_정의와_차례(): @pytest.mark.parametrize("page", PAGE_FILES, ids=lambda path: path.name) -def test_페이지_파일이_좌측_칸_단추_저장을_직접_만들지_않는다(page): +def test_보기_파일이_좌측_칸_단추_저장을_직접_만들지_않는다(page): text = page.read_text(encoding="utf-8") found = [marker for marker in FORBIDDEN_IN_PAGES if marker in text] assert not found, f"{page.name} 이(가) 좌측 칸 · 단추를 직접 만든다: {found}" + assert "createDesignPanel(" not in text and "saveDesign(" not in text + # 좌측 패널은 한 벌을 쓴다 — 종단 보기는 칸 재료 모듈(`createRoutePanel`)이 대신 부른다. + assert "sharedPanel(shared)" in text or "createRoutePanel(" in text + route_panel = (ROOT / "B05_Profile" / "B05_Profile_UI_Panel.ts").read_text(encoding="utf-8") + assert "sharedPanel(shared)" in route_panel -def test_두_보기_패널은_같은_등록부로_세운다(): - b05 = (ROOT / "B05_Profile" / "B05_Profile_UI_Panel.ts").read_text(encoding="utf-8") - b06 = (ROOT / "B06_Section" / "B06_Section_UI_Page.ts").read_text(encoding="utf-8") - assert "createDesignPanel(" in b05 and '"profile"' in b05 - assert "createDesignPanel(" in b06 and '"cross"' in b06 +def test_한_벌이_패널_종단도_폼을_한_번만_만든다(): + shared = (ROOT / "A00_Common" / "design" / "design_shared.ts").read_text(encoding="utf-8") + for maker in ("createDesignPanel(", "createDesignProfile(", "createStructuresSection("): + assert shared.count(maker) == 1, maker + for guard in ( + "if (shared.panel) return shared.panel;", + "if (shared.profile) return shared.profile;", + "if (shared.structures) return shared.structures;", + ): + assert guard in shared + # 보기 쪽은 만들지 않고 한 벌을 쓴다. + for relative in ( + "B05_Profile/B05_Profile_UI_Panel.ts", + "B05_Profile/B05_Profile_UI_Profile_Panel.ts", + "B06_Section/B06_Section_UI_Section_View_Profile.ts", + "B06_Section/B06_Section_UI_Page_Structures_Panel.ts", + ): + text = (ROOT / relative).read_text(encoding="utf-8") + for maker in ("createDesignPanel(", "createDesignProfile(", "createStructuresSection("): + assert maker not in text, f"{relative} 가 {maker} 를 따로 부름" diff --git a/resources/tester/test_masshaul_single_source.py b/resources/tester/test_masshaul_single_source.py index a3390de6..ccc119e3 100644 --- a/resources/tester/test_masshaul_single_source.py +++ b/resources/tester/test_masshaul_single_source.py @@ -20,7 +20,7 @@ B06_ROUTER = _read("B06_Section/B06_Section_Router.py") REFRESH = _read("B06_Section/B06_Section_Cross_Refresh.ts") B05_PREVIEW = _read("B05_Profile/B05_Profile_UI_Profile_Preview.ts") # 설계 재계산 자리는 700줄 제한으로 `_UI_Page_Design_Sync` 로 떨어져 나갔다(2026-09-13). -B06_PAGE = _read("B06_Section/B06_Section_UI_Page.ts") + _read( +B06_PAGE = _read("A00_Common/design/design_view_cross.ts") + _read( "B06_Section/B06_Section_UI_Page_Design_Sync.ts" ) diff --git a/ui_template/ui_template_workflow_layout.ts b/ui_template/ui_template_workflow_layout.ts index 3d1853fc..08b3604c 100644 --- a/ui_template/ui_template_workflow_layout.ts +++ b/ui_template/ui_template_workflow_layout.ts @@ -34,6 +34,9 @@ export interface WorkflowLayoutHandle { root: HTMLElement; setOptionsOpen: (isOpen: boolean) => void; setProgressOpen: (isOpen: boolean) => void; + /** 한 페이지 두 보기(B05 · B06) — 보기가 바뀌면 제목 · 켜진 단계만 갈아 끼운다. */ + setTitle: (title: string) => void; + setActiveStep: (stepIndex: number) => void; } export interface StepBarOptions { @@ -188,9 +191,20 @@ export function createWorkflowLayout(options: WorkflowLayoutOptions): WorkflowLa }); root.append(body, overlays.root); + const steps = [ + ...progressContent.querySelectorAll( + ".ui-workflow-layout__step:not(.ui-workflow-layout__step--home)", + ), + ]; return { root, setOptionsOpen: overlays.setTitleOpen, setProgressOpen: overlays.setProgressOpen, + setTitle: (title) => { + const heading = overlays.root.querySelector(".ui-workflow-overlay__title"); + if (heading) heading.textContent = title; + }, + setActiveStep: (stepIndex) => + steps.forEach((step, index) => step.classList.toggle("is-active", index === stepIndex)), }; }