refactor(B05·B06): 진짜 한 벌 · 가운데 보기 전환(PLAN 24-3b)
- 좌측 패널 · 아래 단추 넷 · 종단도 · 구조물 배치 폼을 페이지에 한 번만 만듦(design_shared) — 두 보기가 같은 노드 - 보기는 가운데 위 칸만: 종단 보기 = 3D(design_view_profile) · 횡단 보기 = 횡단 카드(design_view_cross · 처음 보일 때 그림) - 옛 B05_Profile_UI_Page · B06_Section_UI_Page 삭제 · 한 벌이 받은 조작은 지금 보기로, 관 목록 · 횡단 재계산 알림은 두 보기 모두로 - 진행 단계 종단설계 · 횡단설계 두 칸이 보기를 따라 켜짐 · 누르면 주소만 바꿈 · 제목도 따라감(setTitle · setActiveStep) - [저장] · [확정] 한 벌 — 두 보기 몫을 모아 saveDesign 한 번 · 종단 보기 [확정] 켬(횡단 보기를 숨긴 채 세워 횡단 자료를 얻음) - [초기화] · 계획노선 편집은 같은 페이지라도 다시 세움(remountRoute) - 덧붙임: 방향키는 초점 있는 쪽만(조정창 ↔ 종단도 계획고) · 횡단 보기 계획고 편집에도 [3D 업데이트] 대기 표시 · 종단 보기 폼 관 옵션을 횡단 캐시에도 얹음 · 종단 보기에서 횡단 조정 칸 가림 - 나눔: Cross_View 756→658(_Cross_View_Frame) · Page_Structures_Panel 700→547(_Structures_Spec) · 옛 B05 Page 786 → design_view_profile 638(_Page_Latest · _Page_Stations) - 시험: test_design_view_parity 한 벌 기준(한 패널 · 보기 오가도 같은 노드) · page_single · profile_single · save_channels · save_recompute 등 옮긴 자리로 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KmELpdXFDRZHhfbGuRhSmL
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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<DesignView, RoutePath> = {
|
||||
profile: ROUTES.B05_PROFILE,
|
||||
@@ -35,6 +45,13 @@ export function designViewOf(route: RoutePath): DesignView | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 보기마다 진행 단계 칸 · 제목. */
|
||||
const VIEW_STEP: Record<DesignView, number> = { profile: 2, cross: 3 };
|
||||
const VIEW_TITLE: Record<DesignView, keyof typeof ui_locales> = {
|
||||
profile: "B05_Route_Title",
|
||||
cross: "B06_Profile_Title",
|
||||
};
|
||||
|
||||
/** 보기가 페이지에서 받는 것 — 숨김 · 보임 · 떠남 때 할 일을 건다. */
|
||||
export interface DesignPageHost {
|
||||
readonly view: DesignView;
|
||||
@@ -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<void>;
|
||||
mountCenter?: (
|
||||
/** 가운데 위 칸만 채운다 — 좌측 패널 · 단추 · 종단도는 `shared` 한 벌. 자료가 서면 끝난다. */
|
||||
mountCenter: (
|
||||
center: HTMLElement,
|
||||
host: DesignPageHost,
|
||||
shared: DesignPageShared,
|
||||
@@ -72,17 +78,14 @@ export interface DesignPageView {
|
||||
|
||||
/** 보기 정의 — 처음 보일 때 읽는다(번들을 보기별로 나눠 둔다). */
|
||||
const VIEWS: Record<DesignView, () => Promise<DesignPageView>> = {
|
||||
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<void>;
|
||||
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<DesignView, ViewSlot>();
|
||||
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<typeof frame> => {
|
||||
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;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<void>;
|
||||
steps?: Partial<
|
||||
Record<Exclude<DesignSaveStep, "cross" | "pipes" | "alignment">, () => Promise<void>>
|
||||
>;
|
||||
cross?: (confirm: boolean) => Promise<void>;
|
||||
/** 저장 뒤 이 보기만의 캐시 비우기 · 다시 읽기(알림 · 다음 단계는 여기서 한다). */
|
||||
done?: (confirm: boolean) => Promise<void> | void;
|
||||
}
|
||||
|
||||
export interface DesignViewBinding {
|
||||
profile?: DesignProfileBinding;
|
||||
/** 「구조물 배치」 폼 알림 — 그 보기가 보일 때만 받는다. */
|
||||
structures?: Partial<StructuresCallbacks>;
|
||||
save?: DesignViewSave;
|
||||
/** [초기화] 때 이 보기만 아는 조작 캐시를 비운다. */
|
||||
onReset?: () => void;
|
||||
/** 다른 보기가 고친 관 옵션을 이 보기 캐시(횡단 스펙)에 얹는다. */
|
||||
onPipeOptions?: (chainageM: number, patch: Record<string, number | string>) => void;
|
||||
}
|
||||
|
||||
/** 한 벌 자리 — 페이지(`design_page`)가 만들어 두 보기에 같은 객체로 넘긴다. */
|
||||
export interface DesignPageShared {
|
||||
projectId: string;
|
||||
/** 좌측 패널 자리(아래 단추 넷은 좌측 패널 안). */
|
||||
left: HTMLElement;
|
||||
/** 가운데 아래 종단도 자리. */
|
||||
profileSlot: HTMLElement;
|
||||
/** 지금 보이는 보기. */
|
||||
view: () => DesignView;
|
||||
/** 좌측 칸 부품 — 두 보기가 제 몫을 넣고, 칸 정의가 이름으로 꺼낸다. */
|
||||
parts: Record<string, unknown>;
|
||||
bindings: Partial<Record<DesignView, DesignViewBinding>>;
|
||||
/** 먼저 쓰는 쪽이 만든다 — 뒤 보기는 있는 것을 쓴다. */
|
||||
panel: DesignPanel | null;
|
||||
profile: DesignProfile | null;
|
||||
structures: StructuresSection | null;
|
||||
/** 횡단 보기가 서기 전에 종단 보기가 고친 관 옵션 — 횡단 보기가 서면 캐시에 얹는다. */
|
||||
pipeOptionQueue: Array<[number, Record<string, number | string>]>;
|
||||
/** 접힌 좌측 패널을 편다(바깥에서 고른 것이 폼에 실릴 때). */
|
||||
revealLeft: () => void;
|
||||
/** 그 보기를 (숨긴 채라도) 세우고 자료가 설 때까지 기다린다. */
|
||||
ensureView: (view: DesignView) => Promise<void>;
|
||||
}
|
||||
|
||||
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<string, number | string> | 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<string, number | string>) => void,
|
||||
): void {
|
||||
bindDesignView(shared, "cross", { onPipeOptions: apply });
|
||||
shared.pipeOptionQueue.splice(0).forEach(([chainageM, patch]) => apply(chainageM, patch));
|
||||
}
|
||||
|
||||
/** 지금 보기의 종단도 알림으로 보낸다. */
|
||||
function toView<K extends keyof RouteProfilePanelCallbacks>(
|
||||
shared: DesignPageShared,
|
||||
name: K,
|
||||
): NonNullable<RouteProfilePanelCallbacks[K]> {
|
||||
return ((...args: unknown[]) =>
|
||||
(bound(shared).profile?.callbacks?.[name] as ((...a: unknown[]) => void) | undefined)?.(
|
||||
...args,
|
||||
)) as NonNullable<RouteProfilePanelCallbacks[K]>;
|
||||
}
|
||||
|
||||
/** 두 보기 모두에 보낸다 — 값이 바뀐 알림. */
|
||||
function toAll<K extends keyof RouteProfilePanelCallbacks>(
|
||||
shared: DesignPageShared,
|
||||
name: K,
|
||||
): NonNullable<RouteProfilePanelCallbacks[K]> {
|
||||
return ((...args: unknown[]) =>
|
||||
Object.values(shared.bindings).forEach((binding) =>
|
||||
(binding?.profile?.callbacks?.[name] as ((...a: unknown[]) => void) | undefined)?.(...args),
|
||||
)) as NonNullable<RouteProfilePanelCallbacks[K]>;
|
||||
}
|
||||
|
||||
/** 종단도 한 벌 — 처음 부를 때 만들어 종단도 자리에 붙인다. */
|
||||
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<StructuresCallbacks> => 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<void> {
|
||||
// [확정]은 횡단 본문(표준 횡단면 · 전 측점 면적)이 있어야 한다 — 숨긴 채라도 횡단 보기를 세운다.
|
||||
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 },
|
||||
);
|
||||
}
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
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"));
|
||||
@@ -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<string>();
|
||||
|
||||
type PipeReport = Parameters<NonNullable<RouteProfilePanelCallbacks["onPipesChanged"]>>[0];
|
||||
|
||||
export async function mountProfileView(
|
||||
center: HTMLElement,
|
||||
host: DesignPageHost,
|
||||
shared: DesignPageShared,
|
||||
): Promise<void> {
|
||||
const activeProjectId = shared.projectId;
|
||||
if (!activeProjectId) return;
|
||||
// 새로고침으로 바로 들어온 경우에도 다른 프로젝트 자료는 보관함에서 지운다.
|
||||
void purgeOtherProjects(activeProjectId);
|
||||
|
||||
const viewer = createRouteViewer();
|
||||
// 구 우클릭 메뉴의 자유 텍스트 구조물 → 레지스트리 타입(2026-08-17 이관 매핑과 동일).
|
||||
const LEGACY_TYPE_IDS: Record<string, string> = {
|
||||
기성막이: "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<string, number | string>);
|
||||
},
|
||||
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<void> {
|
||||
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<void> {
|
||||
// 화면 전체를 잠그지 않는다 — 진행 표시는 [재적용] 버튼이 맡는다(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");
|
||||
}
|
||||
})();
|
||||
}
|
||||
@@ -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<boolean> {
|
||||
const { fetchSession } = await import("../A06_Login/A06_Login_Api_Fetch");
|
||||
|
||||
@@ -52,7 +52,7 @@ export interface StructureOptionField {
|
||||
* 치수까지** 받는다(2026-08-17 사용자 확정). 부르는 쪽이 정한다. */
|
||||
export interface StructuresSectionOptions {
|
||||
/** 참이면 `phase: "detail"` 옵션(뒷길이·돌규격·형식 …)도 폼에 그린다. */
|
||||
includeDetail?: boolean;
|
||||
includeDetail?: boolean | (() => boolean);
|
||||
}
|
||||
|
||||
/** B05 배치 폼에 그릴 옵션인가 — 상세(detail)는 B06/B07 몫이라 숨긴다. */
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -18,6 +18,8 @@ export interface DrainagePanel {
|
||||
root: HTMLElement;
|
||||
/** 프로젝트가 정해지면 배경지도·도엽 레이어를 불러온다. */
|
||||
load: (projectId: string) => void;
|
||||
/** 세션 초안(관 목록)을 다시 읽어 세운다 — 다른 보기(횡단)가 관 목록을 고친 뒤(계획서 24-3b). */
|
||||
reload: () => void;
|
||||
/** 확정된 노선 평면 선형(사업지 좌표계 m)을 지도 위에 겹친다. */
|
||||
setRoute: (points: ReadonlyArray<RoutePoint>) => void;
|
||||
/** 현재 관 매설 누가거리 목록(종단 테이블의 "배관" 구조물 라인과 맞추는 데 쓴다). */
|
||||
|
||||
@@ -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<string>();
|
||||
|
||||
export async function renderB05Route(root: HTMLElement, host?: DesignPageHost): Promise<void> {
|
||||
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<string, string> = {
|
||||
기성막이: "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<RouteLatestResponse> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
// 화면 전체를 잠그지 않는다 — 진행 표시는 [재적용] 버튼이 맡는다(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");
|
||||
}
|
||||
})();
|
||||
}
|
||||
@@ -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<void> {
|
||||
}
|
||||
}
|
||||
|
||||
/** [임시저장] — 현재 편집을 영구저장소에 남기되 워크플로 단계·페이지는 그대로 둔다.
|
||||
* 종·횡 통합 확정은 B06 [확정]이 담당한다(2026-08-08 워크플로우 재정의).
|
||||
* 차례는 저장 한 줄기(`saveDesign`)가 정한다 — B06 [저장]과 같다(계획서 24-2a). */
|
||||
export async function tempSaveAction(ctx: PageActionContext): Promise<void> {
|
||||
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<void> {
|
||||
);
|
||||
},
|
||||
},
|
||||
// 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<void> {
|
||||
await resetDesign(ctx.projectId, ctx.latest()?.route?.id ?? null, () =>
|
||||
ctx.uphillOverrides.clear(),
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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<RouteLatestResponse> {
|
||||
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<void>,
|
||||
): Promise<void> {
|
||||
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<RoutePanelValues> {
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -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<void>;
|
||||
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());
|
||||
}
|
||||
|
||||
@@ -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<string, "left" | "right">;
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -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<void>;
|
||||
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,
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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<string, string> = {
|
||||
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()) {
|
||||
|
||||
@@ -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]) {
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Sample | null>((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));
|
||||
}
|
||||
}
|
||||
@@ -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<void> {
|
||||
if (!ctx.projectId) return;
|
||||
await saveDesign(sectionSaveContext(ctx, ctx.projectId));
|
||||
}
|
||||
|
||||
/** 확정 — 저장 뒤 다음 단계(상세설계)로 넘어간다. */
|
||||
export async function confirmCurrentSections(ctx: SectionPersistContext): Promise<void> {
|
||||
if (!ctx.projectId) return;
|
||||
await saveDesign(sectionSaveContext(ctx, ctx.projectId), { confirm: true });
|
||||
}
|
||||
|
||||
@@ -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<string, string | number>;
|
||||
|
||||
/** 그 종류의 등록부 기본값 `{키: 기본값}` — 스펙 값이 기본값에서 왔는지 가르는 데 씀. */
|
||||
function registryDefaults(types: StructureType[], typeId: string): Record<string, unknown> {
|
||||
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<string, unknown>,
|
||||
wings: ReadonlyArray<readonly [WingSpec, "wing_in" | "wing_out"]>,
|
||||
): 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<SectionDetailResponse["cross_sections"][number]["ford"]>,
|
||||
defaults: Record<string, unknown>,
|
||||
): 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<SectionDetailResponse["cross_sections"][number]["box"]>,
|
||||
defaults: Record<string, unknown>,
|
||||
): 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?.();
|
||||
}
|
||||
|
||||
/** 관 옮기기 — 폼의 측점 칸과 종단 끌기가 같은 길을 탄다. 재분할은 [저장]·[확정] 뒤. */
|
||||
|
||||
@@ -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<string, string | number>;
|
||||
|
||||
/** 그 종류의 등록부 기본값 `{키: 기본값}` — 스펙 값이 기본값에서 왔는지 가르는 데 씀. */
|
||||
function registryDefaults(types: StructureType[], typeId: string): Record<string, unknown> {
|
||||
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<string, unknown>,
|
||||
wings: ReadonlyArray<readonly [WingSpec, "wing_in" | "wing_out"]>,
|
||||
): 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<SectionDetailResponse["cross_sections"][number]["ford"]>,
|
||||
defaults: Record<string, unknown>,
|
||||
): 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<SectionDetailResponse["cross_sections"][number]["box"]>,
|
||||
defaults: Record<string, unknown>,
|
||||
): 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;
|
||||
}
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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<StructureInstance> = [];
|
||||
let types: ReadonlyArray<StructureType> = [];
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -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"),
|
||||
):
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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_는_종전대로_유무_종류_위치만():
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)"), (
|
||||
|
||||
@@ -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<DesignView, number> = { 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
|
||||
|
||||
@@ -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_위쪽_종단이_남지_않음():
|
||||
|
||||
@@ -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} 를 따로 부름"
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
|
||||
@@ -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<HTMLElement>(
|
||||
".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)),
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user