Merge remote-tracking branch 'origin/dev' into sub_laptop_6
This commit is contained in:
@@ -110,6 +110,8 @@ export const STATE_REGISTRY = {
|
||||
scope: "project",
|
||||
legacy: (p) => `aislo:structure-pick:${p}`,
|
||||
},
|
||||
/** 설계 가게(`design/design_store`)의 고른 측점 · 구조물 — `{chainageM, structureId}`. */
|
||||
selection: { bucket: "draft", scope: "project" },
|
||||
/** 배수관·암거 조정창에서 예약한 옵션 값. */
|
||||
culvertopt: { bucket: "draft", scope: "route", legacy: (p, r) => `b06:culvertopt:${p}:${r}` },
|
||||
/** 배수관 이동(측점 옮김) 예약. */
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
/* =============================================================================
|
||||
* design_dock.ts
|
||||
* B05 · B06 한 페이지 두 보기의 **좌측 패널 맨 아래 고정 영역**(계획서 24-1 · 24-2b).
|
||||
*
|
||||
* 구성 = [위에 놓을 것(구조물 목록)][구분선][단추 행]. 단추 넷은 두 보기가 **같다**:
|
||||
* [초기화] [저장] [확정] [보기 전환](종단 보기에서는 「횡단 보기」 · 횡단 보기에서는
|
||||
* 「종단 보기」). 누르면 무엇을 하는지는 페이지가 `DesignDockActions` 로 건넨다 — 이 파일은
|
||||
* 단추를 만들고 켜고 끄기만 한다(저장 흐름은 `design_save.ts` 몫).
|
||||
* ========================================================================== */
|
||||
|
||||
import { createButton } from "@ui/ui_template_elements";
|
||||
import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
|
||||
|
||||
/** 보기 이름 — 종단 보기(3D + 종단도) · 횡단 보기(종단도 + 횡단 카드). */
|
||||
export type DesignView = "profile" | "cross";
|
||||
|
||||
/** 단추 하나의 동작 — `enabled` 가 거짓이면 눌리지 않고 `title` 이 까닭을 알린다. */
|
||||
export interface DesignDockAction {
|
||||
run: () => void | Promise<void>;
|
||||
enabled?: boolean;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export interface DesignDockActions {
|
||||
reset: DesignDockAction;
|
||||
save: DesignDockAction;
|
||||
confirm: DesignDockAction;
|
||||
/** [보기 전환] — 지금은 주소 이동이다(2단계에서 같은 페이지 안 전환으로 바뀜). */
|
||||
switchView: DesignDockAction;
|
||||
}
|
||||
|
||||
export interface DesignDock {
|
||||
root: HTMLElement;
|
||||
/** 단추 넷의 켬·끔·설명을 다시 준다(자료가 늦게 오는 페이지가 부름). */
|
||||
setActions: (actions: Partial<DesignDockActions>) => void;
|
||||
/** 보기가 바뀌면 [보기 전환] 글자가 따라 바뀐다. */
|
||||
setView: (view: DesignView) => void;
|
||||
}
|
||||
|
||||
function label(key: keyof typeof ui_locales): string {
|
||||
return ui_locales[key][currentLanguageIndex];
|
||||
}
|
||||
|
||||
/** 보기 전환 단추 글자 — 가려는 **쪽** 이름이다. */
|
||||
export function switchViewLabel(view: DesignView): string {
|
||||
return label(view === "profile" ? "Design_Btn_ToCross" : "Design_Btn_ToProfile");
|
||||
}
|
||||
|
||||
/**
|
||||
* 아래 고정 영역을 만든다. `above` 는 단추 행 위에 놓을 것(구조물 목록) — 목록을 「구조물 배치」
|
||||
* 본문에 두면 고른 구조물의 폼 길이에 밀려 화면 밖으로 나갔다(2026-08-18 사용자 지시).
|
||||
*/
|
||||
export function createDesignDock(
|
||||
view: DesignView,
|
||||
above: ReadonlyArray<HTMLElement>,
|
||||
initial: DesignDockActions,
|
||||
): DesignDock {
|
||||
let current = view;
|
||||
let actions = { ...initial };
|
||||
|
||||
const root = document.createElement("div");
|
||||
root.className = "b05-route__dock ui-sidebar-actions";
|
||||
const divider = document.createElement("hr");
|
||||
divider.className = "b05-structure__divider";
|
||||
const row = document.createElement("div");
|
||||
row.className = view === "profile" ? "b05-route__actions" : "b06-profile__actions";
|
||||
|
||||
const press = (name: keyof DesignDockActions) => (): void => {
|
||||
void Promise.resolve(actions[name].run());
|
||||
};
|
||||
const resetButton = createButton({
|
||||
label: label("Common_Btn_Reset"),
|
||||
variant: "danger",
|
||||
onClick: press("reset"),
|
||||
});
|
||||
const saveButton = createButton({
|
||||
label: label("B06_Profile_Btn_Save"),
|
||||
variant: "ghost",
|
||||
onClick: press("save"),
|
||||
});
|
||||
const confirmButton = createButton({
|
||||
label: label("B06_Profile_Btn_Confirm"),
|
||||
variant: "filled",
|
||||
onClick: press("confirm"),
|
||||
});
|
||||
const switchButton = createButton({
|
||||
label: switchViewLabel(view),
|
||||
variant: "ghost",
|
||||
onClick: press("switchView"),
|
||||
});
|
||||
resetButton.dataset.dockAction = "reset";
|
||||
saveButton.dataset.dockAction = "save";
|
||||
confirmButton.dataset.dockAction = "confirm";
|
||||
switchButton.dataset.dockAction = "switchView";
|
||||
const buttons = {
|
||||
reset: resetButton,
|
||||
save: saveButton,
|
||||
confirm: confirmButton,
|
||||
switchView: switchButton,
|
||||
};
|
||||
|
||||
const sync = (): void => {
|
||||
(Object.keys(buttons) as Array<keyof DesignDockActions>).forEach((name) => {
|
||||
const action = actions[name];
|
||||
buttons[name].disabled = action.enabled === false;
|
||||
buttons[name].title = action.title ?? "";
|
||||
});
|
||||
switchButton.textContent = switchViewLabel(current);
|
||||
row.className = current === "profile" ? "b05-route__actions" : "b06-profile__actions";
|
||||
};
|
||||
|
||||
row.append(resetButton, saveButton, confirmButton, switchButton);
|
||||
root.append(...above, divider, row);
|
||||
sync();
|
||||
|
||||
return {
|
||||
root,
|
||||
setActions: (next) => {
|
||||
actions = { ...actions, ...next };
|
||||
sync();
|
||||
},
|
||||
setView: (next) => {
|
||||
current = next;
|
||||
sync();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
/* =============================================================================
|
||||
* design_panel.ts
|
||||
* B05 · B06 한 페이지 두 보기의 **좌측 패널 칸 등록부**(계획서 24-1 · 24-2b).
|
||||
*
|
||||
* 칸(구조물 배치 · 페이지 설정 · 표준 횡단면 설정 …)은 **한 번만 정의**하고 보일 보기만 적는다.
|
||||
* 페이지 파일은 칸도 단추도 직접 만들지 않고 `createDesignPanel(ctx, view)` 한 줄만 부른다 —
|
||||
* 시험(`test_design_view_parity.py`)이 이를 지킨다.
|
||||
*
|
||||
* registerPanelSection({ id, title, views, order, build }) ← 칸 정의(모듈이 읽힐 때 한 번)
|
||||
* createDesignPanel(ctx, view) → { root, setView } ← 페이지가 부름
|
||||
*
|
||||
* 칸이 쓰는 페이지 쪽 부품(구조물 패널 · 입력 칸 묶음 …)은 `ctx.parts` 에서 이름으로 꺼낸다 —
|
||||
* 칸 정의가 B05 · B06 의 구체 타입을 몰라도 되게 하려는 것이다.
|
||||
* ========================================================================== */
|
||||
|
||||
import { attachCollapsible } from "@ui/ui_template_collapsible";
|
||||
import {
|
||||
createDesignDock,
|
||||
type DesignDock,
|
||||
type DesignDockActions,
|
||||
type DesignView,
|
||||
} from "./design_dock";
|
||||
|
||||
export type { DesignView } from "./design_dock";
|
||||
|
||||
/** 좌측 패널 하나가 페이지에서 받는 것 — 값은 바뀌므로 부품은 이름으로, 단추 동작은 함수로. */
|
||||
export interface DesignPanelContext {
|
||||
projectId: string | null;
|
||||
/** 이름 → 페이지가 만든 부품. 칸 정의가 `part()` 로 꺼낸다. */
|
||||
parts: Record<string, unknown>;
|
||||
/** 아래 단추 넷의 동작 — 페이지가 채운다(저장 흐름은 `design_save`). */
|
||||
dock: DesignDockActions;
|
||||
}
|
||||
|
||||
/** 칸 정의가 돌려주는 것 — 칸 뿌리 하나, 그리고 (있으면) 아래 고정 영역 위에 얹을 것. */
|
||||
export type PanelSectionBuild = HTMLElement | { root: HTMLElement; dock?: HTMLElement };
|
||||
|
||||
export interface PanelSectionDef {
|
||||
id: string;
|
||||
title: string;
|
||||
/** 이 칸이 보일 보기 — 둘 다 적으면 한 정의로 두 보기(구조물 배치). */
|
||||
views: ReadonlyArray<DesignView>;
|
||||
/** 칸 차례 — 작을수록 위. */
|
||||
order: number;
|
||||
build: (ctx: DesignPanelContext, view: DesignView) => PanelSectionBuild;
|
||||
}
|
||||
|
||||
const registry = new Map<string, PanelSectionDef>();
|
||||
|
||||
/** 칸 정의를 등록한다. 같은 id 는 덮어쓴다(같은 모듈이 다시 읽혀도 한 벌). */
|
||||
export function registerPanelSection(def: PanelSectionDef): void {
|
||||
registry.set(def.id, def);
|
||||
}
|
||||
|
||||
/** 그 보기에 보일 칸 정의 — 차례대로. 시험이 「두 보기가 같은 칸을 쓰는지」 볼 때도 쓴다. */
|
||||
export function panelSectionsFor(view: DesignView): PanelSectionDef[] {
|
||||
return [...registry.values()]
|
||||
.filter((def) => def.views.includes(view))
|
||||
.sort((a, b) => a.order - b.order);
|
||||
}
|
||||
|
||||
/** 칸이 부품을 이름으로 꺼낸다 — 페이지가 안 건넸으면 바로 알리도록 던진다. */
|
||||
export function part<T>(ctx: DesignPanelContext, name: string): T {
|
||||
const found = ctx.parts[name];
|
||||
if (found === undefined)
|
||||
throw new Error(`좌측 패널 부품 「${name}」 을 페이지가 건네지 않았습니다.`);
|
||||
return found as T;
|
||||
}
|
||||
|
||||
/**
|
||||
* 칸 틀 — 접는 제목 + 본문. 두 보기가 **다른 클래스**를 쓰던 것(`b05-route__panel-section` ·
|
||||
* `b06-profile__group`)을 그대로 살린다(2단계에서 한 벌로 합침). 종단 보기 본문은 별도 div,
|
||||
* 횡단 보기는 칸 뿌리에 바로 붙인다.
|
||||
*/
|
||||
export function createSectionFrame(
|
||||
view: DesignView,
|
||||
title: string,
|
||||
collapsed = false,
|
||||
): { root: HTMLElement; body: HTMLElement } {
|
||||
const root = document.createElement("section");
|
||||
const heading = document.createElement("h3");
|
||||
heading.textContent = title;
|
||||
if (view === "profile") {
|
||||
// ui-sidebar-section: 사이드 컨테이너 공통 외곽선(진하게, 2026-08-05 사용자 지시).
|
||||
// 접힘 초기값만 여기서 준다 — 펼치기·접기 토글은 ui_template_collapsible 전역 몫.
|
||||
root.className = `b05-route__panel-section ui-collapsible ui-sidebar-section${collapsed ? " is-collapsed" : ""}`;
|
||||
heading.className = "ui-collapsible__title";
|
||||
const body = document.createElement("div");
|
||||
body.className = "b05-route__panel-body";
|
||||
root.append(heading, body);
|
||||
return { root, body };
|
||||
}
|
||||
root.className = `b06-profile__group ui-collapsible ui-sidebar-section${collapsed ? " is-collapsed" : ""}`;
|
||||
heading.className = "b06-profile__group-legend ui-collapsible__title";
|
||||
root.append(heading);
|
||||
return { root, body: root };
|
||||
}
|
||||
|
||||
export interface DesignPanel {
|
||||
root: HTMLElement;
|
||||
/** 아래 고정 영역 — 단추 켬·끔을 페이지가 다시 줄 때 쓴다. */
|
||||
dock: DesignDock;
|
||||
/** 보기가 바뀌면 그 보기에 없는 칸은 숨기고 있는 칸은 (처음이면 만들어) 보인다. */
|
||||
setView: (view: DesignView) => void;
|
||||
}
|
||||
|
||||
/** 좌측 패널 — 그 보기의 칸을 차례대로 세우고 아래 고정 영역을 붙인다. */
|
||||
export function createDesignPanel(ctx: DesignPanelContext, view: DesignView): DesignPanel {
|
||||
const root = document.createElement("div");
|
||||
root.className = view === "profile" ? "b05-route__panel" : "b06-profile__form";
|
||||
const built = new Map<string, HTMLElement>();
|
||||
const dockParts: HTMLElement[] = [];
|
||||
const dock = createDesignDock(view, dockParts, ctx.dock);
|
||||
|
||||
const show = (target: DesignView): void => {
|
||||
const wanted = new Set(panelSectionsFor(target).map((def) => def.id));
|
||||
for (const def of panelSectionsFor(target)) {
|
||||
if (built.has(def.id)) continue;
|
||||
const made = def.build(ctx, target);
|
||||
const sectionRoot = "root" in made ? made.root : made;
|
||||
if ("root" in made && made.dock) dock.root.prepend(made.dock);
|
||||
built.set(def.id, sectionRoot);
|
||||
root.insertBefore(sectionRoot, dock.root);
|
||||
}
|
||||
// 차례를 정의 순서로 다시 맞춘다 — 늦게 만들어진 칸이 맨 아래에 남지 않게.
|
||||
for (const def of panelSectionsFor(target)) {
|
||||
const element = built.get(def.id);
|
||||
if (element) root.insertBefore(element, dock.root);
|
||||
}
|
||||
built.forEach((element, id) => {
|
||||
// 칸이 스스로 숨긴 것(선택 포인트)은 건드리지 않고, 보기 밖 칸만 가리고 되살린다.
|
||||
element.style.display = wanted.has(id) ? "" : "none";
|
||||
});
|
||||
};
|
||||
|
||||
root.append(dock.root);
|
||||
show(view);
|
||||
// 컨테이너 제목 행 전체 클릭 시 본문을 접거나 편다(공용 collapsible). 내부 details 등 별도
|
||||
// 접힘 항목은 손대지 않는다.
|
||||
attachCollapsible(root);
|
||||
|
||||
return {
|
||||
root,
|
||||
dock,
|
||||
setView: (next) => {
|
||||
dock.setView(next);
|
||||
root.className = next === "profile" ? "b05-route__panel" : "b06-profile__form";
|
||||
show(next);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
/* =============================================================================
|
||||
* A00_Common/design/design_save.ts
|
||||
* B05 · B06 **저장 한 줄기**(계획서 24-1) — [저장] · [확정]이 어느 보기에서 눌려도 같은 차례.
|
||||
*
|
||||
* 관 → 구조물 → 계획고 → 암거 옵션 → 오르막(상단측) → 횡단
|
||||
*
|
||||
* 차례는 여기 한 곳에만 둔다. 보기마다 다른 것은 **화면이 쥔 값으로 한 번 더 맞추는 일**
|
||||
* (B05 배수유역도 목록 · 구조물 목록 새로 받기 · 종단 패널 다시 그리기)과 횡단 저장 본문뿐이라,
|
||||
* 그 몫만 `steps` · `cross` 로 받는다. 서버 라우트는 그대로다.
|
||||
*
|
||||
* ⚠ 차례를 바꾸면 결과가 갈린다 — 관 목록은 스냅샷이라 구간값(암거 옵션)보다 먼저, 상단측은
|
||||
* 횡단 patch 보다 먼저 나가야 사용자 수정이 맨 위에 얹힌다(2026-09-06 · 09-12).
|
||||
* 영구저장은 여기서만 한다(CLAUDE.md 5장 — 자동저장 없음).
|
||||
* ========================================================================== */
|
||||
|
||||
import { hideLoadingOverlay, showLoadingOverlay, showToast } from "@ui/ui_template_elements";
|
||||
import { flushPendingPipes } from "../../B05_Profile/B05_Profile_Api_Pipes_Draft";
|
||||
import { flushPendingStructures } from "../../B05_Profile/B05_Profile_Api_Structures";
|
||||
import {
|
||||
flushUphillOverrides,
|
||||
saveProfileAlignment,
|
||||
} from "../../B05_Profile/B05_Profile_Api_Fetch";
|
||||
import {
|
||||
markAlignmentSaved,
|
||||
pendingAlignment,
|
||||
} from "../../B05_Profile/B05_Profile_UI_Profile_Edit";
|
||||
import { flushCulvertOptions } from "../../B06_Section/B06_Section_Api_Culvert_Options";
|
||||
import { stateKey } from "../b_page_state";
|
||||
|
||||
/** 저장 차례 — 이 순서 그대로 돈다. */
|
||||
export const DESIGN_SAVE_ORDER = [
|
||||
"pipes",
|
||||
"structures",
|
||||
"alignment",
|
||||
"culvertOptions",
|
||||
"uphill",
|
||||
"cross",
|
||||
] as const;
|
||||
|
||||
export type DesignSaveStep = (typeof DESIGN_SAVE_ORDER)[number];
|
||||
|
||||
export interface DesignSaveContext {
|
||||
projectId: string;
|
||||
routeId: () => number | null;
|
||||
/** 저장 앞 준비 — 면적을 모으기 전 전 측점 재계산(B06). */
|
||||
prepare?: () => Promise<void>;
|
||||
/**
|
||||
* 차례마다 **기본 창구 대신** 도는 일. 없으면 기본 창구(세션 초안 → 정본)가 돈다.
|
||||
* `pipes` 만 예외 — 기본(세션 초안)이 먼저 나가고 그 뒤에 돈다(화면 목록으로 한 번 더 맞춤).
|
||||
*/
|
||||
steps?: Partial<Record<Exclude<DesignSaveStep, "cross">, () => Promise<void>>>;
|
||||
/** 횡단 — 보기마다 본문이 다르다. `confirm` 이면 단계를 닫는 길로. */
|
||||
cross: (confirm: boolean) => Promise<void>;
|
||||
/** 모두 나간 뒤 — 캐시 비우기 · 화면 다시 읽기 · 알림 · 다음 단계. */
|
||||
done?: (confirm: boolean) => Promise<void> | void;
|
||||
/** 멈춘 저장을 알린다. */
|
||||
failed: (error: unknown, confirm: boolean) => void;
|
||||
}
|
||||
|
||||
/** 실패해도 저장을 멈추지 않는 창구 — 알리고 넘어간다(초안은 세션에 남아 다시 나갈 수 있다). */
|
||||
function reportOnly(label: string): (error: unknown) => void {
|
||||
return (error) => {
|
||||
const detail = error instanceof Error ? ` ${error.message}` : "";
|
||||
showToast(`${label} 저장에 실패했습니다.${detail}`, "error");
|
||||
};
|
||||
}
|
||||
|
||||
/** 계획고 초안(가게 하나)을 종단 정본에 쓴다. 없으면 아무 일도 하지 않는다. */
|
||||
async function flushAlignmentDraft(projectId: string, routeId: number): Promise<void> {
|
||||
const draft = pendingAlignment(routeId);
|
||||
if (!draft) return;
|
||||
await saveProfileAlignment(projectId, routeId, draft);
|
||||
markAlignmentSaved(routeId);
|
||||
}
|
||||
|
||||
/** 실패해도 다음 차례로 넘어가는 창구와 그 이름(보기가 준 일에도 같이 씌운다). */
|
||||
const TOLERANT: Partial<Record<DesignSaveStep, string>> = {
|
||||
pipes: "배수관",
|
||||
structures: "구조물",
|
||||
alignment: "계획선",
|
||||
};
|
||||
|
||||
/** 차례마다 기본 창구. 암거 옵션 · 상단측은 조용히 넘어간다(다음 [저장]에서 다시 나감). */
|
||||
function defaultStep(
|
||||
step: Exclude<DesignSaveStep, "cross">,
|
||||
projectId: string,
|
||||
routeId: number,
|
||||
): () => Promise<void> {
|
||||
switch (step) {
|
||||
case "pipes":
|
||||
return () => flushPendingPipes(projectId);
|
||||
case "structures":
|
||||
return () => flushPendingStructures(projectId);
|
||||
case "alignment":
|
||||
return () => flushAlignmentDraft(projectId, routeId);
|
||||
case "culvertOptions":
|
||||
return () =>
|
||||
flushCulvertOptions(projectId, stateKey("culvertopt", projectId, routeId)).catch(
|
||||
() => undefined,
|
||||
);
|
||||
case "uphill":
|
||||
return () => flushUphillOverrides(projectId).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* [저장] · [확정] — 세션에 쌓인 조작을 정해진 차례로 정본에 보낸다.
|
||||
* 노선이 없으면 아무 일도 하지 않고 false. 끝까지 나가면 true.
|
||||
*/
|
||||
export async function saveDesign(
|
||||
ctx: DesignSaveContext,
|
||||
options: { confirm?: boolean } = {},
|
||||
): Promise<boolean> {
|
||||
const confirm = options.confirm ?? false;
|
||||
const routeId = ctx.routeId();
|
||||
if (routeId === null) return false;
|
||||
showLoadingOverlay();
|
||||
try {
|
||||
await ctx.prepare?.();
|
||||
for (const step of DESIGN_SAVE_ORDER) {
|
||||
if (step === "cross") {
|
||||
await ctx.cross(confirm);
|
||||
continue;
|
||||
}
|
||||
const own = ctx.steps?.[step];
|
||||
const base = defaultStep(step, ctx.projectId, routeId);
|
||||
const run =
|
||||
step === "pipes"
|
||||
? async () => {
|
||||
await base();
|
||||
await own?.();
|
||||
}
|
||||
: (own ?? base);
|
||||
const label = TOLERANT[step];
|
||||
await (label ? run().catch(reportOnly(label)) : run());
|
||||
}
|
||||
await ctx.done?.(confirm);
|
||||
return true;
|
||||
} catch (error) {
|
||||
ctx.failed(error, confirm);
|
||||
return false;
|
||||
} finally {
|
||||
hideLoadingOverlay();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
/* =============================================================================
|
||||
* A00_Common/design/design_selection.ts
|
||||
* 측점 · 구조물 고름 **한 곳** (PLAN 24-2d).
|
||||
*
|
||||
* 24-1 약속의 `design_store` 「selection({chainageM, structureId})」 키 자리다. 그 모듈이
|
||||
* 서면 이 파일의 `readSelection` · `selectDesign` · `subscribeSelection` 세 함수를 그쪽
|
||||
* `get` · `set` · `subscribe("selection")` 으로 잇는다 — 부르는 쪽 코드는 바뀌지 않는다.
|
||||
*
|
||||
* 값은 등록표 키 `structure-pick`(② 설계 초안 · 페이지를 오가도 남음)에 실린다.
|
||||
* 어느 화면(3D · 종단 알약 · 목록 · 횡단 카드)에서 골라도 같은 값이 적히고, 다른 쪽은
|
||||
* 이 값을 읽어 따라간다. 관 · 구조물을 찾는 거리 `PIPE_MATCH_M` 도 여기 한 값뿐이다.
|
||||
* ========================================================================== */
|
||||
|
||||
import { readState, writeState } from "../b_page_state";
|
||||
|
||||
/** 관 · 구조물 찾는 거리(m) — 관은 계획선에 스냅돼 측점과 살짝 어긋난다. 이 값이 유일하다. */
|
||||
export const PIPE_MATCH_M = 0.51;
|
||||
|
||||
export interface DesignSelection {
|
||||
chainageM: number | null;
|
||||
structureId: string | null;
|
||||
}
|
||||
|
||||
export const NO_SELECTION: DesignSelection = { chainageM: null, structureId: null };
|
||||
|
||||
/** 두 누가거리가 같은 자리인가. */
|
||||
export function isNear(a: number, b: number): boolean {
|
||||
return Math.abs(a - b) < PIPE_MATCH_M;
|
||||
}
|
||||
|
||||
/** 선택을 풀 때 필요한 구조물의 최소 모양 — B05 `StructureInstance` 가 그대로 맞는다. */
|
||||
export interface SelectableStructure {
|
||||
structure_id: string;
|
||||
chainage_m?: number | null;
|
||||
start_m?: number | null;
|
||||
end_m?: number | null;
|
||||
}
|
||||
|
||||
/** 풀린 선택 — 관이면 그 관의 누가거리, 아니면 구조물 id. 둘 다 없으면 고를 항목이 없다. */
|
||||
export interface ResolvedSelection {
|
||||
pipeChainageM: number | null;
|
||||
structureId: string | null;
|
||||
}
|
||||
|
||||
/** 이 구조물이 그 측점을 덮는가 — 점형은 기준점 근처, 구간형은 시작~종료 안(양끝 여유 포함). */
|
||||
export function coversChainage(structure: SelectableStructure, chainageM: number): boolean {
|
||||
const anchor = structure.chainage_m ?? structure.start_m;
|
||||
const start = structure.start_m ?? anchor;
|
||||
const end = structure.end_m ?? anchor;
|
||||
if (start == null || end == null) return false;
|
||||
return (
|
||||
chainageM >= Math.min(start, end) - PIPE_MATCH_M &&
|
||||
chainageM <= Math.max(start, end) + PIPE_MATCH_M
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 선택 값을 폼이 열 항목으로 푼다 — 어느 화면에서 골랐든 **같은 순서 · 같은 거리**다.
|
||||
* ① 구조물 id 가 있고 목록에 있으면 그 구조물 ② 측점 근처 관(계곡 통과 시설이 먼저 —
|
||||
* 같은 측점에 구조물이 겹쳐도 횡단도에 그려지는 것은 관이다) ③ 측점을 덮는 구조물.
|
||||
*/
|
||||
export function resolveSelection(
|
||||
selection: DesignSelection,
|
||||
pipes: ReadonlyArray<{ chainage_m: number }>,
|
||||
structures: ReadonlyArray<SelectableStructure>,
|
||||
): ResolvedSelection {
|
||||
if (selection.structureId) {
|
||||
const byId = structures.find((entry) => entry.structure_id === selection.structureId);
|
||||
if (byId) return { pipeChainageM: null, structureId: byId.structure_id };
|
||||
}
|
||||
const chainageM = selection.chainageM;
|
||||
if (chainageM === null) return { pipeChainageM: null, structureId: null };
|
||||
const pipe = pipes.find((entry) => isNear(entry.chainage_m, chainageM));
|
||||
if (pipe) return { pipeChainageM: pipe.chainage_m, structureId: null };
|
||||
const hit = structures.find((entry) => coversChainage(entry, chainageM));
|
||||
return { pipeChainageM: null, structureId: hit?.structure_id ?? null };
|
||||
}
|
||||
|
||||
/* ── 세션 값 + 알림 ─────────────────────────────────────────────────────── */
|
||||
|
||||
interface Stored {
|
||||
at?: unknown;
|
||||
key?: unknown;
|
||||
sid?: unknown;
|
||||
}
|
||||
|
||||
/** 세션에 남은 고름 — 없으면 null. `key` 는 횡단도가 부재를 부르는 이름(조정창 여는 데 씀). */
|
||||
export function readSelection(
|
||||
projectId: string | null,
|
||||
): (DesignSelection & { partKey?: string }) | null {
|
||||
if (!projectId) return null;
|
||||
try {
|
||||
const value = readState<Stored>("structure-pick", projectId);
|
||||
if (!value || typeof value.at !== "number" || !Number.isFinite(value.at)) return null;
|
||||
return {
|
||||
chainageM: value.at,
|
||||
structureId: typeof value.sid === "string" ? value.sid : null,
|
||||
partKey: typeof value.key === "string" ? value.key : undefined,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export type SelectionListener = (selection: DesignSelection, from: string | undefined) => void;
|
||||
|
||||
const listeners = new Map<string, Set<SelectionListener>>();
|
||||
let dispatching = false;
|
||||
|
||||
/** 고름이 바뀔 때 부른다. 돌려주는 함수로 구독을 끊는다. */
|
||||
export function subscribeSelection(projectId: string | null, fn: SelectionListener): () => void {
|
||||
if (!projectId) return () => undefined;
|
||||
let set = listeners.get(projectId);
|
||||
if (!set) listeners.set(projectId, (set = new Set()));
|
||||
set.add(fn);
|
||||
return () => set.delete(fn);
|
||||
}
|
||||
|
||||
/**
|
||||
* 고름을 적고 구독자에게 알린다. `partKey` 를 안 주면 이미 적힌 부재키를 지킨다 — 3D 로
|
||||
* 부재를 집은 직후 선택 동기화가 측점만 다시 적어도 조정창이 그대로 열려야 한다.
|
||||
* `from` 은 낸 쪽 이름 — 구독자가 제 것을 되받아 다시 그리지 않게 거른다.
|
||||
* 값이 그대로면 알리지 않고, 알림 도중 다시 낸 값은 적기만 한다(되울림 방지).
|
||||
*/
|
||||
export function selectDesign(
|
||||
projectId: string | null,
|
||||
selection: DesignSelection,
|
||||
options: { partKey?: string; from?: string } = {},
|
||||
): void {
|
||||
if (!projectId) return;
|
||||
if (selection.chainageM === null) {
|
||||
writeState("structure-pick", null, projectId);
|
||||
} else {
|
||||
const before = readSelection(projectId);
|
||||
const same =
|
||||
before !== null &&
|
||||
before.chainageM === selection.chainageM &&
|
||||
before.structureId === selection.structureId &&
|
||||
(options.partKey === undefined || options.partKey === before.partKey);
|
||||
if (same) return;
|
||||
const key = options.partKey ?? before?.partKey;
|
||||
writeState(
|
||||
"structure-pick",
|
||||
{ at: selection.chainageM, key, sid: selection.structureId ?? undefined },
|
||||
projectId,
|
||||
);
|
||||
}
|
||||
if (dispatching) return;
|
||||
dispatching = true;
|
||||
try {
|
||||
for (const fn of [...(listeners.get(projectId) ?? [])]) fn(selection, options.from);
|
||||
} finally {
|
||||
dispatching = false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
/* =============================================================================
|
||||
* A00_Common/design/design_store.ts
|
||||
* B05 · B06 설계 상태 **한 곳**(계획서 24-1) — 두 보기가 같은 값을 같은 창구로 읽고 쓴다.
|
||||
*
|
||||
* const store = useDesignStore(projectId, routeId);
|
||||
* store.get("pipes") · store.set("pipes", next) · store.subscribe("pipes", fn)
|
||||
*
|
||||
* 밑은 지금 캐시 그대로다 — 값마다 `b_page_state` 등록 키 하나(새 키는 등록부에 먼저).
|
||||
* 가게는 값을 따로 들고 있지 않는다: 읽을 때마다 캐시를 읽고, 쓸 때 캐시에 쓴 뒤 듣는 쪽을
|
||||
* 부른다. 그래서 가게를 거치지 않고 캐시를 고친 옛 자리가 남아 있어도 읽는 값은 갈리지 않는다
|
||||
* (다만 그 자리는 듣는 쪽을 못 부른다 — 쓰는 길은 가게로 옮길 것).
|
||||
*
|
||||
* ⚠ `alignmentDraft` 만 등록부 밖 옛 키(`b05-profile-alignment-draft:{노선}`)를 감싼다 —
|
||||
* B05 되돌리기(`_Profile_History`)가 `b05-` 접두어 키만 사진 찍기 때문이다. 등록부 이관은
|
||||
* 24-2c 가 되돌리기와 함께 한다(브레인 판정 9-29).
|
||||
* ========================================================================== */
|
||||
|
||||
import { readByKey, readState, writeByKey, writeState } from "../b_page_state";
|
||||
import type { StructureInstance } from "../../B05_Profile/B05_Profile_Api_Structures";
|
||||
import type { DetailPipeInput } from "../../B04_PreProcess/B04_PreProcess_Api_Fetch";
|
||||
import type { AlignmentEdits } from "../../B05_Profile/B05_Profile_UI_Profile_Alignment";
|
||||
import type { SectionDetailResponse } from "../../B06_Section/B06_Section_Api_Fetch";
|
||||
|
||||
/** 한 페이지의 두 보기(계획서 24-1). */
|
||||
export type DesignView = "profile" | "cross";
|
||||
|
||||
/** 고른 자리 — 측점 · 구조물. 둘 다 비면 고른 것이 없다. */
|
||||
export interface DesignSelection {
|
||||
chainageM: number | null;
|
||||
structureId: string | null;
|
||||
}
|
||||
|
||||
/** 가게가 드는 값 — 없으면 null(= 만진 적 없음 · 저장분을 쓸 것). */
|
||||
export interface DesignStoreValues {
|
||||
/** 미저장 구조물 목록(정본 `structures.json`). */
|
||||
structures: StructureInstance[] | null;
|
||||
/** 미저장 관 목록(정본 `pipe_points.json`) — 주인은 이 가게 하나다. */
|
||||
pipes: DetailPipeInput[] | null;
|
||||
/** 미저장 계획고 편집(▲▼ · 곡선반경). 노선이 있어야 한다. */
|
||||
alignmentDraft: AlignmentEdits | null;
|
||||
/** 종횡단 상세 — 세션 사본. 화면이 함께 쥐는 객체는 `B06_Section_Section_Store`. */
|
||||
sectionDetail: SectionDetailResponse | null;
|
||||
/** 고른 측점 · 구조물. */
|
||||
selection: DesignSelection | null;
|
||||
}
|
||||
|
||||
export type DesignStoreKey = keyof DesignStoreValues;
|
||||
|
||||
export interface DesignStore {
|
||||
get<K extends DesignStoreKey>(key: K): DesignStoreValues[K];
|
||||
set<K extends DesignStoreKey>(key: K, value: DesignStoreValues[K]): void;
|
||||
/** 가게로 쓸 때마다 부른다. 돌려받은 함수로 끊는다. */
|
||||
subscribe<K extends DesignStoreKey>(
|
||||
key: K,
|
||||
fn: (value: DesignStoreValues[K]) => void,
|
||||
): () => void;
|
||||
}
|
||||
|
||||
/** 옛 계획고 초안 키 — 위 머리 주석 ⚠ 참조. */
|
||||
export const ALIGNMENT_DRAFT_KEY_PREFIX = "b05-profile-alignment-draft";
|
||||
|
||||
interface Slot<T> {
|
||||
/** 듣는 쪽을 묶는 이름 — 같은 캐시 칸이면 같은 이름. 칸을 못 정하면 null. */
|
||||
id: string | null;
|
||||
read(): T | null;
|
||||
write(value: T | null): void;
|
||||
}
|
||||
|
||||
function slotOf<K extends DesignStoreKey>(
|
||||
key: K,
|
||||
projectId: string | null,
|
||||
routeId: number | null,
|
||||
): Slot<DesignStoreValues[K]> {
|
||||
type V = DesignStoreValues[K];
|
||||
if (key === "alignmentDraft") {
|
||||
const raw = routeId === null ? null : `${ALIGNMENT_DRAFT_KEY_PREFIX}:${routeId}`;
|
||||
return {
|
||||
id: raw,
|
||||
read: () => {
|
||||
const text = raw ? readByKey(raw) : null;
|
||||
if (!text) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(text) as Partial<AlignmentEdits>;
|
||||
return {
|
||||
station_offsets: parsed.station_offsets ?? {},
|
||||
curve_radii: parsed.curve_radii ?? {},
|
||||
} as V;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
write: (value) => {
|
||||
if (raw) writeByKey(raw, value === null ? null : JSON.stringify(value));
|
||||
},
|
||||
};
|
||||
}
|
||||
// 등록부 이름 — 가게 이름과 캐시 이름이 다른 것만 적는다.
|
||||
const name = (
|
||||
{
|
||||
structures: "structures",
|
||||
pipes: "pipes",
|
||||
sectionDetail: "section-detail",
|
||||
selection: "selection",
|
||||
} as const
|
||||
)[key as Exclude<DesignStoreKey, "alignmentDraft">];
|
||||
const routeScoped = name === "section-detail";
|
||||
if (!projectId || (routeScoped && routeId === null)) {
|
||||
return { id: null, read: () => null, write: () => undefined };
|
||||
}
|
||||
const scopeRoute = routeScoped ? routeId : undefined;
|
||||
return {
|
||||
id: `${name}:${projectId}${routeScoped ? `:${routeId}` : ""}`,
|
||||
read: () => readState<V>(name, projectId, scopeRoute),
|
||||
write: (value) => writeState(name, value, projectId, scopeRoute),
|
||||
};
|
||||
}
|
||||
|
||||
const listeners = new Map<string, Set<(value: unknown) => void>>();
|
||||
|
||||
/**
|
||||
* 그 프로젝트 · 노선의 가게. 부를 때마다 새로 만들어도 된다 — 값도 듣는 쪽도 가게 밖
|
||||
* (캐시 · 모듈 표)에 있어서 같은 칸을 가리키면 같은 가게다.
|
||||
*
|
||||
* 노선을 모르면 `routeId = null` — 노선 칸(`alignmentDraft` · `sectionDetail`)은 비어 보이고
|
||||
* 쓰기는 버려진다. 프로젝트를 모르면 프로젝트 칸이 그렇다.
|
||||
*/
|
||||
export function useDesignStore(projectId: string | null, routeId: number | null): DesignStore {
|
||||
return {
|
||||
get(key) {
|
||||
return slotOf(key, projectId, routeId).read() as DesignStoreValues[typeof key];
|
||||
},
|
||||
set(key, value) {
|
||||
const slot = slotOf(key, projectId, routeId);
|
||||
slot.write(value);
|
||||
if (slot.id) listeners.get(`${key}|${slot.id}`)?.forEach((fn) => fn(value));
|
||||
},
|
||||
subscribe(key, fn) {
|
||||
const id = slotOf(key, projectId, routeId).id;
|
||||
if (!id) return () => undefined;
|
||||
const bucket = `${key}|${id}`;
|
||||
const set = listeners.get(bucket) ?? new Set();
|
||||
listeners.set(bucket, set);
|
||||
const listener = fn as (value: unknown) => void;
|
||||
set.add(listener);
|
||||
return () => set.delete(listener);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -7,9 +7,12 @@
|
||||
* 하나뿐이었다). 다른 조작값과 같은 규칙으로 세션에 쌓고 저장 앞단에서 함께 내보낸다.
|
||||
*
|
||||
* 자동저장은 하지 않는다(CLAUDE.md 5장).
|
||||
*
|
||||
* 관 목록의 주인은 설계 가게(`design_store` 의 `pipes`) 하나다(계획서 24-2a) — B05 배수유역도 ·
|
||||
* B06 구조물 목록 모두 이 창구로 읽고 쓴다.
|
||||
* ========================================================================== */
|
||||
|
||||
import { readState, writeState } from "../A00_Common/b_page_state";
|
||||
import { useDesignStore } from "../A00_Common/design/design_store";
|
||||
import { saveDetailPipePoints } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
|
||||
import type { DetailPipeInput } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
|
||||
|
||||
@@ -17,11 +20,11 @@ import type { DetailPipeInput } from "../B04_PreProcess/B04_PreProcess_Api_Fetch
|
||||
export type PendingPipes = DetailPipeInput[];
|
||||
|
||||
export function readPendingPipes(projectId: string): PendingPipes | null {
|
||||
return readState<PendingPipes>("pipes", projectId);
|
||||
return useDesignStore(projectId, null).get("pipes");
|
||||
}
|
||||
|
||||
export function writePendingPipes(projectId: string, next: PendingPipes | null): void {
|
||||
writeState("pipes", next, projectId);
|
||||
useDesignStore(projectId, null).set("pipes", next);
|
||||
}
|
||||
|
||||
/** 초안이 있으면 정본에 쓰고 비운다. 없으면 아무 일도 하지 않는다. */
|
||||
|
||||
@@ -8,8 +8,9 @@
|
||||
제약 탐색은 하지 않고, 넘은 곳은 `metrics.violations` 에 뜬다.
|
||||
· earthwork — 후보 중 절 · 성토 합(`cut_m3 + fill_m3`) 최소.
|
||||
· gentle — 후보 중 최대 종단기울기 최소(같으면 평균 최소).
|
||||
· weighted — 후보 중 예상노선 이격 · 절성토 · 기울기(최대 + 평균) 세 값을 후보 범위로
|
||||
0~1 정규화해 비중(합 100)으로 더한 값 최소.
|
||||
· weighted — 후보 중 예상노선 이격 · 절성토 · 기울기(최대, 같으면 평균) 세 값을 후보
|
||||
범위로 0~1 정규화해 비중(합 100)으로 더한 값 최소. 끝값은 그 갈래와 같은 선 —
|
||||
토공 100 = 토공 최소 · 기울기 100 = 기울기 순한 · 예상 100 = 추적 선 그대로(23-3e).
|
||||
|
||||
후보 = 목표 비중(토공 · 기울기 · 이격 · 기본 비중)을 바꿔 가며 격자 탐색(`..._Search`)한 노선들.
|
||||
갈래는 **비교값(화면에 보이는 값)으로 고른다** — 탐색 목표(4 m 링크 기울기 · 옆경사)는 비교값
|
||||
@@ -230,12 +231,16 @@ def generate_initial_route(
|
||||
follow=follow,
|
||||
)
|
||||
|
||||
bound = None
|
||||
if mode == "follow":
|
||||
def follow_line() -> tuple[Any, dict[str, Any]]:
|
||||
# 예상노선 추적 = 전처리 초기값(`planned_route_initial.csv`)과 같은 규칙 · 같은 입력 →
|
||||
# 같은 선(`_write_planned_polyline` 기본값). 제약은 따지지 않고 넘은 곳만 목록에 뜸.
|
||||
outline = build_planned_polyline(expected, min_radius_m=min_radius_m)
|
||||
metrics = measure(outline, None, follow=True)
|
||||
return outline, measure(outline, None, follow=True)
|
||||
|
||||
bound = None
|
||||
# 비중 예상 100 = 예상노선 추적 그 선(PLAN 23-3e) — 사이 비중은 제약 지킨 후보에서만 고름
|
||||
if mode == "follow" or (mode == "weighted" and used["earthwork"] == used["grade"] == 0):
|
||||
outline, metrics = follow_line()
|
||||
else:
|
||||
key = _pool_key(
|
||||
expected, terrain, streams, max_grade, min_radius_m, stream_offset, grade_smooth
|
||||
@@ -354,7 +359,8 @@ def _pick(
|
||||
return metrics["cut_m3"] + metrics["fill_m3"]
|
||||
|
||||
def grade(metrics: dict[str, Any]) -> float:
|
||||
return metrics["max_grade_pct"] + metrics["avg_grade_pct"]
|
||||
# 최대 먼저 · 평균은 같을 때만(기울기 순한과 같은 순서 — 값은 0.01 단위라 평균 몫 < 0.01)
|
||||
return metrics["max_grade_pct"] + 1e-4 * metrics["avg_grade_pct"]
|
||||
|
||||
def offset(metrics: dict[str, Any]) -> float:
|
||||
return metrics["offset_avg_m"]
|
||||
|
||||
@@ -32,6 +32,7 @@ import { createPipeEditor } from "./B05_Profile_UI_Drainage_Pipes";
|
||||
import { createFacilityStore } from "./B05_Profile_UI_Drainage_Facility";
|
||||
import { mergeFacilityOptions } from "./B05_Profile_UI_Drainage_Facility_Merge";
|
||||
import { readPendingPipes, writePendingPipes } from "./B05_Profile_Api_Pipes_Draft";
|
||||
import { buildPipeReports } from "./B05_Profile_UI_Drainage_Report";
|
||||
import { createDrainageChrome } from "./B05_Profile_UI_Drainage_Chrome";
|
||||
import { bindDrainageInteractions } from "./B05_Profile_UI_Drainage_Interact";
|
||||
import type { RouteSpanBand } from "./B05_Profile_UI_Drainage_Spans";
|
||||
@@ -355,31 +356,7 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra
|
||||
summaryText(summary, pipeEditor.pipes().length, basins.length, zSource);
|
||||
// 목록 통지가 선택 통지보다 **먼저** 가야 한다 — 사이드 폼은 이 목록에서 관을
|
||||
// 찾아 편집 화면을 채우므로, 순서가 뒤집히면 새로 넣은 관의 폼이 열리지 않는다.
|
||||
callbacks.onPipesChanged?.(
|
||||
// 관마다 담당 유역의 배수 유효직경을 붙인다(±0.5m 매칭). 유역 없는 관은 null.
|
||||
// 세월교 검토 유역(bridge_required)도 null — 관 규격을 최대치로 올려 봐야 무의미하고,
|
||||
// 그 지점은 세월교·물넘이로 별도 설계한다(임도설치규정 제12조).
|
||||
// 시설 종류·구간·부속 옵션·출처(source)도 함께 — 통합 목록·사이드 폼이
|
||||
// 이 목록만으로 편집 화면을 채운다(2026-08-17 단일 폼 통합).
|
||||
pipeEditor.pipes().map((pipe) => {
|
||||
const chainage = Math.round(pipe.chainage_m * 100) / 100;
|
||||
const basin = basins.find((entry) => Math.abs(entry.chainage_m - chainage) < 0.51);
|
||||
const attributes = facilityStore.get(chainage);
|
||||
return {
|
||||
chainage_m: chainage,
|
||||
effective_diameter_mm:
|
||||
basin && !basin.bridge_required ? (basin.pipe_diameter_mm ?? null) : null,
|
||||
// 설계유량은 세월교 검토 유역에도 붙인다 — 물넘이·세월교 단면이 이 값에서 나온다.
|
||||
design_flow_m3s: basin?.design_flow_m3s ?? null,
|
||||
facility: attributes?.facility ?? "pipe",
|
||||
start_m: attributes?.start_m,
|
||||
end_m: attributes?.end_m,
|
||||
source: (pipe.reason || "user") as PipeSource,
|
||||
options: attributes?.options,
|
||||
summary_item: attributes?.summary_item,
|
||||
};
|
||||
}),
|
||||
);
|
||||
callbacks.onPipesChanged?.(buildPipeReports(pipeEditor.pipes(), basins, facilityStore));
|
||||
syncPipeSelection();
|
||||
}
|
||||
|
||||
@@ -405,18 +382,19 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra
|
||||
}
|
||||
}
|
||||
|
||||
/** 지금 관 목록 + 시설 정보 — 저장 · 재계산 · 세션 초안 요청 몸통이 모두 이것이다. */
|
||||
const pipeRequest = () =>
|
||||
facilityStore.attach(
|
||||
pipeEditor.pipes().map((pipe) => ({
|
||||
chainage_m: pipe.chainage_m,
|
||||
source: (pipe.reason || "user") as PipeSource,
|
||||
})),
|
||||
);
|
||||
|
||||
/** 지금 화면의 관 목록을 세션 초안에 담는다 — 정본 쓰기는 [저장]·[확정] 몫이다. */
|
||||
function rememberPipes(): void {
|
||||
if (!projectId) return;
|
||||
writePendingPipes(
|
||||
projectId,
|
||||
facilityStore.attach(
|
||||
pipeEditor.pipes().map((pipe) => ({
|
||||
chainage_m: pipe.chainage_m,
|
||||
source: (pipe.reason || "user") as PipeSource,
|
||||
})),
|
||||
),
|
||||
);
|
||||
writePendingPipes(projectId, pipeRequest());
|
||||
}
|
||||
|
||||
/** 저장된 관 지점(없으면 자동 배치)을 불러온다. 화면에 들어올 때 1회. 저장 전 조작분(세션 초안)이
|
||||
@@ -436,17 +414,7 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra
|
||||
/** 세부유역을 다시 나눈다. 격자 해석은 하지 않으므로 즉시 끝난다.
|
||||
* 시설 종류·구간·옵션은 계산기에서 사라지므로 요청에 되붙인다. */
|
||||
const analyze = (): Promise<void> =>
|
||||
run(() =>
|
||||
computeDetailBasins(
|
||||
projectId as string,
|
||||
facilityStore.attach(
|
||||
pipeEditor.pipes().map((pipe) => ({
|
||||
chainage_m: pipe.chainage_m,
|
||||
source: (pipe.reason || "user") as PipeSource,
|
||||
})),
|
||||
),
|
||||
),
|
||||
);
|
||||
run(() => computeDetailBasins(projectId as string, pipeRequest()));
|
||||
|
||||
analyzeButton.addEventListener("click", () => void analyze());
|
||||
deleteButton.addEventListener("click", () => void pipeEditor.deleteSelected());
|
||||
@@ -664,16 +632,8 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra
|
||||
},
|
||||
async savePipes() {
|
||||
if (!projectId) return 0;
|
||||
const response = await saveDetailPipePoints(
|
||||
projectId,
|
||||
// 시설 종류·구간·옵션까지 정본에 남긴다(계산기 왕복에서 사라지므로 되붙임).
|
||||
facilityStore.attach(
|
||||
pipeEditor.pipes().map((pipe) => ({
|
||||
chainage_m: pipe.chainage_m,
|
||||
source: (pipe.reason || "user") as PipeSource,
|
||||
})),
|
||||
),
|
||||
);
|
||||
// 시설 종류·구간·옵션까지 정본에 남긴다(계산기 왕복에서 사라지므로 되붙임).
|
||||
const response = await saveDetailPipePoints(projectId, pipeRequest());
|
||||
apply(response);
|
||||
scheduleDraw();
|
||||
return response.pipe_count;
|
||||
|
||||
@@ -52,24 +52,25 @@ export interface DrainagePanel {
|
||||
dispose: () => void;
|
||||
}
|
||||
|
||||
/** 관 하나의 보고 — 누가거리 + 담당 유역의 배수 유효직경(mm) + 시설 종류 · 구간 · 옵션 · 집계 항목. */
|
||||
export interface DrainagePipeReport {
|
||||
chainage_m: number;
|
||||
effective_diameter_mm: number | null;
|
||||
/** 담당 유역의 설계유량(㎥/s, 100년빈도·2.0배). 물넘이·세월교 개략 단면의 입력이다. */
|
||||
design_flow_m3s?: number | null;
|
||||
facility: PipeFacility;
|
||||
start_m?: number;
|
||||
end_m?: number;
|
||||
source?: PipeSource;
|
||||
options?: Record<string, string | number>;
|
||||
/** 이 관이 뜻하는 구조물 집계표 항목(21-1) — 없으면 미지정. */
|
||||
summary_item?: SummaryItem | null;
|
||||
}
|
||||
|
||||
export interface DrainagePanelCallbacks {
|
||||
/** 관 목록이 바뀔 때마다 누가거리 + 담당 유역의 배수 유효직경(mm) + 시설 종류를
|
||||
* 넘긴다 — 종단 구조물 라인·통합 목록 동기화 및 관경 자동 지정(D800 기본)용. */
|
||||
onPipesChanged?: (
|
||||
pipes: Array<{
|
||||
chainage_m: number;
|
||||
effective_diameter_mm: number | null;
|
||||
/** 담당 유역의 설계유량(㎥/s, 100년빈도·2.0배). 물넘이·세월교 개략 단면의 입력이다. */
|
||||
design_flow_m3s?: number | null;
|
||||
facility: PipeFacility;
|
||||
start_m?: number;
|
||||
end_m?: number;
|
||||
source?: PipeSource;
|
||||
options?: Record<string, string | number>;
|
||||
/** 이 관이 뜻하는 구조물 집계표 항목(21-1) — 없으면 미지정. */
|
||||
summary_item?: SummaryItem | null;
|
||||
}>,
|
||||
) => void;
|
||||
onPipesChanged?: (pipes: DrainagePipeReport[]) => void;
|
||||
/** 유역을 고르거나 풀 때 그 관의 누가거리(없으면 null)를 넘긴다 — 그래프·사이드 패널 동기화용. */
|
||||
onBasinSelected?: (chainageM: number | null) => void;
|
||||
/** 관 마커 선택이 바뀔 때(클릭·해제) 누가거리를 알린다 — 유역이 없는 관도 전 화면
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
/* =============================================================================
|
||||
* B05_Profile_UI_Drainage_Report.ts
|
||||
* 배수유역 패널이 관 목록이 바뀔 때 밖에 알리는 **관 보고**(`DrainagePipeReport`)를 만든다.
|
||||
* `B05_Profile_UI_Drainage_Panel` 에서 떼어냄(700줄 제한) — 셈은 그대로, 상태를 안 든다.
|
||||
* ========================================================================== */
|
||||
|
||||
import type { DetailBasin, PipeSource } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
|
||||
import type { createFacilityStore } from "./B05_Profile_UI_Drainage_Facility";
|
||||
import type { DrainagePipeReport } from "./B05_Profile_UI_Drainage_Panel_Types";
|
||||
|
||||
/** 관마다 담당 유역의 배수 유효직경을 붙인다(±0.5m 매칭). 유역 없는 관은 null.
|
||||
* 세월교 검토 유역(bridge_required)도 null — 관 규격을 최대치로 올려 봐야 무의미하고,
|
||||
* 그 지점은 세월교·물넘이로 별도 설계한다(임도설치규정 제12조).
|
||||
* 시설 종류·구간·부속 옵션·출처(source)도 함께 — 통합 목록·사이드 폼이
|
||||
* 이 목록만으로 편집 화면을 채운다(2026-08-17 단일 폼 통합). */
|
||||
export function buildPipeReports(
|
||||
pipes: ReadonlyArray<{ chainage_m: number; reason?: string }>,
|
||||
basins: ReadonlyArray<DetailBasin>,
|
||||
facilityStore: ReturnType<typeof createFacilityStore>,
|
||||
): DrainagePipeReport[] {
|
||||
return pipes.map((pipe) => {
|
||||
const chainage = Math.round(pipe.chainage_m * 100) / 100;
|
||||
const basin = basins.find((entry) => Math.abs(entry.chainage_m - chainage) < 0.51);
|
||||
const attributes = facilityStore.get(chainage);
|
||||
return {
|
||||
chainage_m: chainage,
|
||||
effective_diameter_mm:
|
||||
basin && !basin.bridge_required ? (basin.pipe_diameter_mm ?? null) : null,
|
||||
// 설계유량은 세월교 검토 유역에도 붙인다 — 물넘이·세월교 단면이 이 값에서 나온다.
|
||||
design_flow_m3s: basin?.design_flow_m3s ?? null,
|
||||
facility: attributes?.facility ?? "pipe",
|
||||
start_m: attributes?.start_m,
|
||||
end_m: attributes?.end_m,
|
||||
source: (pipe.reason || "user") as PipeSource,
|
||||
options: attributes?.options,
|
||||
summary_item: attributes?.summary_item,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -1,11 +1,93 @@
|
||||
/* =============================================================================
|
||||
* B05_Profile_UI_Drainage_Side.ts
|
||||
* 배수유역도를 종단도 오른쪽 판(`side`)으로 끼우는 함수(계획서 24-2c · 24-2e).
|
||||
* 판 자리 약속은 `A00_Common/design/design_profile.ts` 의 `DesignSideFactory` 다.
|
||||
* 지금은 옛 종단 패널이 직접 만들던 것을 그대로 옮긴 첫 판이다.
|
||||
* 배수유역도를 종단도 오른쪽 판(`side`)으로 끼우는 홀로 선 함수(계획서 24-2c · 24-2e).
|
||||
* 판 자리 약속은 `A00_Common/design/design_profile.ts` 의 `DesignSideFactory` 다 —
|
||||
* `createDrainageSide` 가 그 모양 그대로다(`(host) => 판`).
|
||||
*
|
||||
* 판(`createDrainagePanel`)은 종단 패널을 모른다 — 종단쪽 일은 전부 `host`
|
||||
* (`DrainagePanelCallbacks` + projectId · view, `_Panel_Types` 약속)로 받고, 종단쪽에서 들어오는 일은
|
||||
* 판의 창구(`setRoute` · `setIntervalSpans` · `pipeChainages` · `setPipeChainages` ·
|
||||
* `selectPipeAtChainage` …)로만 한다.
|
||||
* · 관 목록이 바뀜(최소 흙덮기 경고 · 구조물 라인 · 통합 목록) = `host.onPipesChanged`
|
||||
* · 관 자리 읽기 · 쓰기(되돌리기) = `pipeChainages()` · `setPipeChainages()`
|
||||
* · 우클릭 메뉴의 구조물 종류 = `host.structureMenuItems`
|
||||
* · 구간 띠 · 노선 = `setIntervalSpans()` · `setRoute()`
|
||||
* 두 보기(종단 · 횡단)에서 같은 판을 끼우고 **켜고 끈다** — 끄면 자리를 비우되 관 · 유역
|
||||
* 상태는 그대로 살아 있다(다시 켜면 이어서 보임). 켬 · 끔을 어디에 적을지는 끼우는 쪽 몫이다.
|
||||
* ========================================================================== */
|
||||
|
||||
import type { DesignSideFactory } from "../A00_Common/design/design_profile";
|
||||
import "./B05_Profile_UI_Style_Drainage.css"; // 홀로 서려면 판이 자기 모양을 들고 온다.
|
||||
import type { DesignSideBoard, DesignSideHost } from "../A00_Common/design/design_profile";
|
||||
import { createDrainagePanel } from "./B05_Profile_UI_Drainage_Panel";
|
||||
|
||||
export const createDrainageSide: DesignSideFactory = (host) => createDrainagePanel(host);
|
||||
/** 종단쪽이 넘기는 이음 — 판 바깥 약속(`DrainagePanelCallbacks`)에 projectId · view 를 더한 것. */
|
||||
export type DrainageSideHost = DesignSideHost;
|
||||
|
||||
/** 종단도 side 판 = 배수유역 판 창구 그대로 + 켜고 끔. */
|
||||
export type DrainageSideBoard = DesignSideBoard & {
|
||||
/** 켜고 끈다. 켜면 숨겼던 사이 바뀐 크기 · 그림을 판이 스스로 다시 맞춘다. */
|
||||
setVisible: (visible: boolean) => void;
|
||||
isVisible: () => boolean;
|
||||
/** 켬 · 끔이 바뀔 때마다 부른다(단추 표시 · 저장). 해제 함수를 돌려준다. */
|
||||
onVisibleChange: (listener: (visible: boolean) => void) => () => void;
|
||||
};
|
||||
|
||||
export interface DrainageSideOptions {
|
||||
/** 처음 켤지(기본 켬). */
|
||||
visible?: boolean;
|
||||
}
|
||||
|
||||
/** `DesignSideFactory` — 종단도를 만들 때 한 번 부른다. */
|
||||
export function createDrainageSide(
|
||||
host: DrainageSideHost,
|
||||
options: DrainageSideOptions = {},
|
||||
): DrainageSideBoard {
|
||||
const panel = createDrainagePanel(host);
|
||||
const listeners = new Set<(visible: boolean) => void>();
|
||||
let visible = true;
|
||||
|
||||
function setVisible(next: boolean): void {
|
||||
if (next === visible) return;
|
||||
visible = next;
|
||||
panel.root.hidden = !visible;
|
||||
listeners.forEach((listener) => listener(visible));
|
||||
}
|
||||
// 처음 상태는 알림 없이 맞춘다.
|
||||
if (options.visible === false) {
|
||||
visible = false;
|
||||
panel.root.hidden = true;
|
||||
}
|
||||
|
||||
return {
|
||||
...panel,
|
||||
// 보기가 바뀌어도 판은 다시 안 만든다 — 켬 · 끔은 끼우는 쪽이 `setVisible` 로 정한다.
|
||||
setView: () => {},
|
||||
setVisible,
|
||||
isVisible: () => visible,
|
||||
onVisibleChange(listener) {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
},
|
||||
dispose() {
|
||||
listeners.clear();
|
||||
panel.dispose();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** 켜고 끄는 단추 — 어느 보기에서든 같은 모양 · 같은 글. 누르면 판을 뒤집는다. */
|
||||
export function createDrainageToggleButton(side: DrainageSideBoard): HTMLButtonElement {
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.className = "ui-btn ui-btn--ghost b05-drainage__toggle";
|
||||
button.textContent = "배수유역도";
|
||||
button.title = "배수유역도 판을 켜고 끕니다";
|
||||
const sync = (visible: boolean): void => {
|
||||
button.setAttribute("aria-pressed", String(visible));
|
||||
button.classList.toggle("is-active", visible);
|
||||
};
|
||||
sync(side.isVisible());
|
||||
side.onVisibleChange(sync);
|
||||
button.addEventListener("click", () => side.setVisible(!side.isVisible()));
|
||||
return button;
|
||||
}
|
||||
|
||||
@@ -23,9 +23,7 @@ import {
|
||||
solveRoute,
|
||||
type RouteLatestResponse,
|
||||
} from "./B05_Profile_Api_Fetch";
|
||||
import { flushCulvertOptions } from "../B06_Section/B06_Section_Api_Culvert_Options";
|
||||
import { flushPendingPipes } from "./B05_Profile_Api_Pipes_Draft";
|
||||
import { stateKey } from "../A00_Common/b_page_state";
|
||||
import { saveDesign } from "../A00_Common/design/design_save";
|
||||
import {
|
||||
invalidateSectionDetail,
|
||||
saveCachedCrossPatches,
|
||||
@@ -127,74 +125,68 @@ export async function solveRouteAction(ctx: PageActionContext): Promise<void> {
|
||||
}
|
||||
|
||||
/** [임시저장] — 현재 편집을 영구저장소에 남기되 워크플로 단계·페이지는 그대로 둔다.
|
||||
* 종·횡 통합 확정은 B06 [확정]이 담당한다(2026-08-08 워크플로우 재정의). */
|
||||
* 종·횡 통합 확정은 B06 [확정]이 담당한다(2026-08-08 워크플로우 재정의).
|
||||
* 차례는 저장 한 줄기(`saveDesign`)가 정한다 — B06 [저장]과 같다(계획서 24-2a). */
|
||||
export async function tempSaveAction(ctx: PageActionContext): Promise<void> {
|
||||
if (!ctx.routeReady()) return;
|
||||
const latest = ctx.latest();
|
||||
const projectId = ctx.projectId;
|
||||
// 관 매설 지점을 B04와 같은 저장소에 남긴다 — 저장 실패가 임시저장을 막지는 않는다.
|
||||
// 세션 초안(B06 저장도 같은 창구를 쓴다)을 먼저 내보내고, 화면 목록으로 한 번 더 맞춘다.
|
||||
await flushPendingPipes(projectId).catch(() => undefined);
|
||||
await ctx
|
||||
.profilePanel()
|
||||
.drainage.savePipes()
|
||||
.catch(() => 0);
|
||||
showLoadingOverlay();
|
||||
try {
|
||||
// 종단 계획선 편집은 화면에서만 계산해 두었으므로 저장 시점에 영속화한다.
|
||||
await ctx.profilePanel().save();
|
||||
// 구조물도 조작분이 세션에만 있다(2026-08-29 — CLAUDE.md 5장). 실패는 자체 토스트로
|
||||
// 알리고 세션에 남겨 두므로, 여기서 저장 전체를 멈추지 않는다.
|
||||
await ctx.bridge().saveStructuresIfDirty();
|
||||
// 비정규 측점·상단측 변경분까지 데이터로는 확정 저장하되, 단계 완료 전이는 하지 않는다.
|
||||
await confirmRoute(
|
||||
projectId,
|
||||
{
|
||||
filter_key: latest?.surface_params.source_filter,
|
||||
method: latest?.surface_params.method,
|
||||
smooth: latest?.surface_params.smooth,
|
||||
surface_model_id: ctx.confirmedSurface()?.model_id ?? undefined,
|
||||
irregular_stations: ctx
|
||||
.bridge()
|
||||
.irregularStations()
|
||||
.map((station) => ({
|
||||
chainage_m: station.chainage_m,
|
||||
structure: station.structure,
|
||||
})),
|
||||
// 상단측(측구 방향) 사용자 변경분 — 종단 정본에 병합되어 B06이 그대로 소비한다.
|
||||
uphill_overrides: [...ctx.uphillOverrides.entries()].map(([chainage, side]) => ({
|
||||
chainage_m: Number(chainage),
|
||||
side,
|
||||
})),
|
||||
await saveDesign({
|
||||
projectId,
|
||||
routeId: () => latest?.route?.id ?? null,
|
||||
steps: {
|
||||
// 세션 초안이 나간 뒤 화면 목록으로 한 번 더 맞춘다(시설 보관소 되붙이기 · 응답으로 화면 갱신).
|
||||
pipes: async () => {
|
||||
await ctx.profilePanel().drainage.savePipes();
|
||||
},
|
||||
false,
|
||||
);
|
||||
// B06 조정창에서 만진 배수관 구간값도 세션에만 있다 — 함께 내보낸다
|
||||
// (CLAUDE.md 5장: 영구저장은 [저장]·[확정]에서만). 실패해도 저장은 진행한다.
|
||||
// 관 위치는 두 페이지가 같은 스냅샷(`pipes`)으로만 옮김 — 옛 이동 예약은 안 보냄.
|
||||
if (latest?.route?.id != null) {
|
||||
await flushCulvertOptions(
|
||||
projectId,
|
||||
stateKey("culvertopt", projectId, latest.route.id),
|
||||
).catch(() => undefined);
|
||||
}
|
||||
// 구조물 — 저장 뒤 서버 목록(새 식별자)으로 화면을 맞춘다. 실패는 자체 토스트로 알리고
|
||||
// 세션에 남겨 두므로 저장 전체를 멈추지 않는다(2026-08-29 — CLAUDE.md 5장).
|
||||
structures: () => ctx.bridge().saveStructuresIfDirty(),
|
||||
// 종단 계획선 편집은 화면에서만 계산해 두었으므로 저장 시점에 영속화한다(패널도 다시 그림).
|
||||
alignment: () => ctx.profilePanel().save(),
|
||||
// 상단측 + 비정규 측점 — 데이터로는 확정 저장하되, 단계 완료 전이는 하지 않는다.
|
||||
uphill: async () => {
|
||||
await confirmRoute(
|
||||
projectId,
|
||||
{
|
||||
filter_key: latest?.surface_params.source_filter,
|
||||
method: latest?.surface_params.method,
|
||||
smooth: latest?.surface_params.smooth,
|
||||
surface_model_id: ctx.confirmedSurface()?.model_id ?? undefined,
|
||||
irregular_stations: ctx
|
||||
.bridge()
|
||||
.irregularStations()
|
||||
.map((station) => ({
|
||||
chainage_m: station.chainage_m,
|
||||
structure: station.structure,
|
||||
})),
|
||||
// 상단측(측구 방향) 사용자 변경분 — 종단 정본에 병합되어 B06이 그대로 소비한다.
|
||||
uphill_overrides: [...ctx.uphillOverrides.entries()].map(([chainage, side]) => ({
|
||||
chainage_m: Number(chainage),
|
||||
side,
|
||||
})),
|
||||
},
|
||||
false,
|
||||
);
|
||||
},
|
||||
},
|
||||
// B06에서 만져 **캐시에 얹힌** 횡단 수정분을 함께 남긴다 — 안 보내면 바로 아래
|
||||
// 캐시 비우기에서 사라진다. 계획선 저장 **뒤에** 보내야 사용자 수정 1세트가
|
||||
// 최종본으로 얹힌다(2026-08-24 사용자 지적).
|
||||
if (latest?.route?.id != null) {
|
||||
await saveCachedCrossPatches(projectId, latest.route.id);
|
||||
}
|
||||
// 서버가 종단 정본의 계획선을 다시 썼다 — 공유 캐시를 비워 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");
|
||||
} catch (error) {
|
||||
showToast(error instanceof Error ? error.message : L("B05_Route_TempSave_Failed"), "error");
|
||||
} finally {
|
||||
hideLoadingOverlay();
|
||||
}
|
||||
cross: async () => {
|
||||
if (latest?.route?.id != null) await saveCachedCrossPatches(projectId, latest.route.id);
|
||||
},
|
||||
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");
|
||||
},
|
||||
failed: (error) =>
|
||||
showToast(error instanceof Error ? error.message : L("B05_Route_TempSave_Failed"), "error"),
|
||||
});
|
||||
}
|
||||
|
||||
/** [초기화] — 사용자 편집 전부 폐기, 계획노선 CSV 기본값으로 B05·B06 재계산 후 재진입.
|
||||
|
||||
@@ -17,8 +17,8 @@
|
||||
|
||||
import type { AlignmentEdits, ProfileAlignment } from "./B05_Profile_UI_Profile_Alignment";
|
||||
import { chainageKey, emptyEdits, hasEdits } from "./B05_Profile_UI_Profile_Alignment";
|
||||
import { ALIGNMENT_DRAFT_KEY_PREFIX, useDesignStore } from "../A00_Common/design/design_store";
|
||||
|
||||
const DRAFT_KEY_PREFIX = "b05-profile-alignment-draft";
|
||||
/** 버튼 폭(21px)에 여유를 더한 값 — 이보다 가까우면 겹친 것으로 본다. */
|
||||
const BUTTON_CLEARANCE_PX = 23;
|
||||
const BUTTON_HALF_PX = 10;
|
||||
@@ -79,6 +79,9 @@ export interface ProfileEditStore {
|
||||
edited(): boolean;
|
||||
}
|
||||
|
||||
/** 초안 읽기 · 쓰기는 설계 가게(`design_store` 의 `alignmentDraft`) 한 창구로만. */
|
||||
const draftOf = (routeId: number) => useDesignStore(null, routeId);
|
||||
|
||||
/**
|
||||
* 노선의 미저장 편집 초안을 읽는다(B06 공용).
|
||||
*
|
||||
@@ -87,7 +90,7 @@ export interface ProfileEditStore {
|
||||
* 쓰면 초안 편집분이 B06에서 되돌아간 것처럼 보인다.
|
||||
*/
|
||||
export function readAlignmentDraft(routeId: number | null): AlignmentEdits | null {
|
||||
return routeId === null ? null : readDraft(`${DRAFT_KEY_PREFIX}:${routeId}`);
|
||||
return routeId === null ? null : draftOf(routeId).get("alignmentDraft");
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -96,13 +99,15 @@ export function readAlignmentDraft(routeId: number | null): AlignmentEdits | nul
|
||||
* 초기화는 서버 값을 초기 스냅샷으로 되돌리는데, 이 초안이 남아 있으면 화면이 그 위에
|
||||
* 옛 편집 델타를 다시 얹어 계획선이 측점에서 원지반선과 만나지 않는다(2026-09-04 실측:
|
||||
* 초기화 직후에는 멀쩡하다가 새로고침 한 번에 최대 6.0m 어긋남).
|
||||
* 노선 번호를 모르는 옛 초안까지 쓸어야 해 여기서만 키를 훑는다.
|
||||
*/
|
||||
export function clearAlignmentDrafts(): void {
|
||||
shared.clear();
|
||||
try {
|
||||
const keys: string[] = [];
|
||||
for (let index = 0; index < sessionStorage.length; index += 1) {
|
||||
const key = sessionStorage.key(index);
|
||||
if (key?.startsWith(`${DRAFT_KEY_PREFIX}:`)) keys.push(key);
|
||||
if (key?.startsWith(`${ALIGNMENT_DRAFT_KEY_PREFIX}:`)) keys.push(key);
|
||||
}
|
||||
keys.forEach((key) => sessionStorage.removeItem(key));
|
||||
} catch {
|
||||
@@ -110,18 +115,47 @@ export function clearAlignmentDrafts(): void {
|
||||
}
|
||||
}
|
||||
|
||||
function readDraft(storageKey: string): AlignmentEdits | null {
|
||||
try {
|
||||
const raw = sessionStorage.getItem(storageKey);
|
||||
if (!raw) return null;
|
||||
const parsed = JSON.parse(raw) as Partial<AlignmentEdits>;
|
||||
return {
|
||||
station_offsets: parsed.station_offsets ?? {},
|
||||
curve_radii: parsed.curve_radii ?? {},
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
/**
|
||||
* 노선마다 **한 벌**인 편집 상태 — B05 종단 패널이 주인이고 B06 계획선 편집은 같은 벌을
|
||||
* 쓴다(계획서 24-2a). 예전에는 두 화면이 가게를 따로 만들어 같은 초안 키를 각자 고쳤다 —
|
||||
* 한쪽이 메모리에 쥔 옛 값으로 다시 쓰면 다른 쪽 편집이 덮였다.
|
||||
*/
|
||||
interface EditState {
|
||||
current: AlignmentEdits;
|
||||
unsaved: boolean;
|
||||
/**
|
||||
* [초기화]의 기준점 — **마지막 [저장] 시점의 편집분**이다(2026-09-03 사용자 지시).
|
||||
* 처음에는 서버 저장분이고, [저장]을 누를 때마다 그때 값으로 옮겨 간다. 초기 자동
|
||||
* 선형으로 되돌리는 것(`resetAll`)과는 다르다 — 저장한 작업까지 지우지 않는다.
|
||||
*/
|
||||
savedBaseline: AlignmentEdits;
|
||||
/** 서버 저장분 — 측점 원복 · 전체 원복이 자동 곡선반경을 되살리는 기준. */
|
||||
saved: AlignmentEdits;
|
||||
}
|
||||
|
||||
const shared = new Map<number, EditState>();
|
||||
|
||||
/** 서버 저장분과 남은 초안으로 상태를 세운다(초안이 있으면 그것이 이기고 미저장). */
|
||||
function freshState(routeId: number | null, saved: AlignmentEdits): EditState {
|
||||
const draft = readAlignmentDraft(routeId);
|
||||
return { current: draft ?? saved, unsaved: draft !== null, savedBaseline: saved, saved };
|
||||
}
|
||||
|
||||
/** [저장]이 보낼 계획고 초안 — 미저장 편집이 없으면 null. */
|
||||
export function pendingAlignment(routeId: number): AlignmentEdits | null {
|
||||
const state = shared.get(routeId);
|
||||
if (state) return state.unsaved ? state.current : null;
|
||||
return readAlignmentDraft(routeId);
|
||||
}
|
||||
|
||||
/** 계획고가 정본에 나갔다 — 그 노선 상태를 저장됨으로 두고 초안을 지운다. */
|
||||
export function markAlignmentSaved(routeId: number): void {
|
||||
const state = shared.get(routeId);
|
||||
if (state) {
|
||||
state.unsaved = false;
|
||||
state.savedBaseline = state.current;
|
||||
}
|
||||
draftOf(routeId).set("alignmentDraft", null);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -131,6 +165,9 @@ function readDraft(storageKey: string): AlignmentEdits | null {
|
||||
* 새로고침한 경우) 초안을 우선 채택하고 미저장 상태로 표시한다. routeId가 바뀌면
|
||||
* 초안 키도 바뀌어 이전 노선의 편집이 새 노선에 잘못 얹히지 않는다.
|
||||
*
|
||||
* 부를 때마다 그 노선의 한 벌을 **초안 · 저장분으로 다시 세운다**(되돌리기가 초안을 바꾼 뒤
|
||||
* 다시 읽는 길). 이미 선 한 벌에 붙기만 하려면 `useProfileEditStore`.
|
||||
*
|
||||
* routeId가 아직 없으면 **초안을 두지 않는다**(2026-09-04). 노선 없는 키(`:none`)에
|
||||
* 쌓인 초안은 어느 노선에나 되붙어 초기화로도 떨어지지 않았다.
|
||||
*/
|
||||
@@ -139,56 +176,62 @@ export function createProfileEditStore(
|
||||
saved: AlignmentEdits,
|
||||
onChange: () => void,
|
||||
): ProfileEditStore {
|
||||
const storageKey = routeId === null ? null : `${DRAFT_KEY_PREFIX}:${routeId}`;
|
||||
const draft = storageKey ? readDraft(storageKey) : null;
|
||||
let current = draft ?? saved;
|
||||
let unsaved = draft !== null;
|
||||
/**
|
||||
* [초기화]의 기준점 — **마지막 [저장] 시점의 편집분**이다(2026-09-03 사용자 지시).
|
||||
* 처음에는 서버 저장분이고, [저장]을 누를 때마다 그때 값으로 옮겨 간다. 초기 자동
|
||||
* 선형으로 되돌리는 것(`resetAll`)과는 다르다 — 저장한 작업까지 지우지 않는다.
|
||||
*/
|
||||
let savedBaseline = saved;
|
||||
const fresh = freshState(routeId, saved);
|
||||
// 이미 선 한 벌은 **그 자리에서** 다시 세운다 — 바꿔 끼우면 붙어 있던 쪽이 옛 벌을 쥔다.
|
||||
const state = routeId === null ? fresh : Object.assign(shared.get(routeId) ?? fresh, fresh);
|
||||
if (routeId !== null) shared.set(routeId, state);
|
||||
return handleOf(routeId, state, onChange);
|
||||
}
|
||||
|
||||
/** 그 노선에 이미 선 한 벌에 붙는다 — 없으면 `createProfileEditStore` 와 같다(B06 계획선 편집). */
|
||||
export function useProfileEditStore(
|
||||
routeId: number,
|
||||
saved: AlignmentEdits,
|
||||
onChange: () => void,
|
||||
): ProfileEditStore {
|
||||
const state = shared.get(routeId);
|
||||
return state
|
||||
? handleOf(routeId, state, onChange)
|
||||
: createProfileEditStore(routeId, saved, onChange);
|
||||
}
|
||||
|
||||
function handleOf(
|
||||
routeId: number | null,
|
||||
state: EditState,
|
||||
onChange: () => void,
|
||||
): ProfileEditStore {
|
||||
const draft = routeId === null ? null : draftOf(routeId);
|
||||
|
||||
function dropDraft(): void {
|
||||
if (!storageKey) return;
|
||||
try {
|
||||
sessionStorage.removeItem(storageKey);
|
||||
} catch {
|
||||
// 세션 저장소를 못 쓰는 환경에서도 편집 자체는 계속 동작해야 한다.
|
||||
}
|
||||
draft?.set("alignmentDraft", null);
|
||||
}
|
||||
|
||||
function commit(next: AlignmentEdits): void {
|
||||
current = next;
|
||||
unsaved = true;
|
||||
try {
|
||||
if (storageKey) sessionStorage.setItem(storageKey, JSON.stringify(current));
|
||||
} catch {
|
||||
// 초안 보관 실패는 편집을 막지 않는다.
|
||||
}
|
||||
state.current = next;
|
||||
state.unsaved = true;
|
||||
draft?.set("alignmentDraft", state.current);
|
||||
onChange();
|
||||
}
|
||||
|
||||
return {
|
||||
edits: () => current,
|
||||
edits: () => state.current,
|
||||
replace: commit,
|
||||
markSaved() {
|
||||
unsaved = false;
|
||||
savedBaseline = current;
|
||||
state.unsaved = false;
|
||||
state.savedBaseline = state.current;
|
||||
dropDraft();
|
||||
},
|
||||
restoreSaved() {
|
||||
current = savedBaseline;
|
||||
unsaved = false;
|
||||
state.current = state.savedBaseline;
|
||||
state.unsaved = false;
|
||||
dropDraft();
|
||||
onChange();
|
||||
},
|
||||
canRestoreSaved: () => JSON.stringify(current) !== JSON.stringify(savedBaseline),
|
||||
canRestoreSaved: () => JSON.stringify(state.current) !== JSON.stringify(state.savedBaseline),
|
||||
resetStation(chainageM, moveTo) {
|
||||
const key = chainageKey(chainageM);
|
||||
const stationOffsets = { ...current.station_offsets };
|
||||
const curveRadii = { ...current.curve_radii };
|
||||
const stationOffsets = { ...state.current.station_offsets };
|
||||
const curveRadii = { ...state.current.curve_radii };
|
||||
if (moveTo !== undefined) {
|
||||
const to = chainageKey(moveTo);
|
||||
if (stationOffsets[key] !== undefined) stationOffsets[to] = stationOffsets[key];
|
||||
@@ -200,7 +243,7 @@ export function createProfileEditStore(
|
||||
delete stationOffsets[key];
|
||||
// 그 측점의 곡선반경은 저장분(자동 계산이 역산해 둔 값)으로 되돌린다 — 지우면
|
||||
// 프론트가 기본 R로 다시 그려 계획고가 서버 정본과 어긋난다(2026-08-23).
|
||||
const savedRadius = saved.curve_radii?.[key];
|
||||
const savedRadius = state.saved.curve_radii?.[key];
|
||||
if (savedRadius === undefined) delete curveRadii[key];
|
||||
else curveRadii[key] = savedRadius;
|
||||
commit({ station_offsets: stationOffsets, curve_radii: curveRadii });
|
||||
@@ -209,10 +252,10 @@ export function createProfileEditStore(
|
||||
// 지우는 것은 **사용자 편집 측점**이다. 자동 계획선이 역산해 저장한 곡선반경
|
||||
// (`curve_radii`)까지 비우면 호가 달라져 화면 계획고가 정본과 달라진다 —
|
||||
// 세월교 지점에서 0.22m 낮게 나와 최소고 경고가 잘못 떴다(2026-08-23 진단).
|
||||
commit({ ...emptyEdits(), curve_radii: { ...(saved.curve_radii ?? {}) } });
|
||||
commit({ ...emptyEdits(), curve_radii: { ...(state.saved.curve_radii ?? {}) } });
|
||||
},
|
||||
dirty: () => unsaved,
|
||||
edited: () => hasEdits(current),
|
||||
dirty: () => state.unsaved,
|
||||
edited: () => hasEdits(state.current),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
|
||||
/** 회전 단추 아이콘 — **반만 도는 화살표**(2026-09-12 사용자 지시 ㉙). 한 바퀴를 다 그린
|
||||
* 기호(`↺`·`↻`)는 「한 바퀴 돈다」로 읽혀 한 칸씩 도는 동작과 안 맞았다. */
|
||||
const HALF_TURN_ICON = {
|
||||
export const HALF_TURN_ICON = {
|
||||
ccw: `<svg viewBox="0 0 16 16" width="16" height="16" aria-hidden="true" fill="none"
|
||||
stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M13 8a5 5 0 0 0-10 0" /><path d="M3 8 1.2 5.6" /><path d="M3 8 5.4 6.6" /></svg>`,
|
||||
@@ -42,9 +42,9 @@ export function createRouteEditChrome(): RouteEditChrome {
|
||||
<strong>계획노선 편집</strong>
|
||||
<span class="b05-routeedit__rec-now" data-rec-now></span>
|
||||
<span class="b05-routeedit__actions">
|
||||
<i class="b05-routeedit__divider" aria-hidden="true"></i>
|
||||
<button type="button" class="ui-btn ui-btn--ghost" data-act="recommend" disabled
|
||||
title="예상노선 추적 · 토공 최소 · 기울기 순한 · 비중 계산 중 하나로 노선을 추천받아 미리 봅니다">노선 추천</button>
|
||||
<i class="b05-routeedit__divider" aria-hidden="true"></i>
|
||||
<button type="button" class="ui-btn ui-btn--ghost" data-act="contour-minor"
|
||||
aria-pressed="false" disabled
|
||||
title="1m 간격 등고선을 얇고 옅게 겹칩니다 (확대하면 보임)">1m 등고선</button>
|
||||
|
||||
@@ -34,6 +34,8 @@ export interface RouteEditNavigationParams {
|
||||
/** 돌린 지도 보정 — 화면에서 민 만큼(dx, dy)을 **그림 좌표의 만큼**으로 바꾼다.
|
||||
* 안 주면 안 돌린 것으로 본다(배수유역도는 회전이 없다). */
|
||||
unrotateDelta?: (dx: number, dy: number) => [number, number];
|
||||
/** 마우스 왼쪽 버튼으로도 팬 — 집을 노드가 없는 미리 보기 지도용. 편집 창은 가운데 버튼만. */
|
||||
leftPan?: boolean;
|
||||
draw: () => void;
|
||||
}
|
||||
|
||||
@@ -78,7 +80,12 @@ export function bindRouteEditNavigation(params: RouteEditNavigationParams): void
|
||||
// 가운데 버튼 기본 동작(페이지 자동 스크롤)이 팬과 겹치지 않게 막는다.
|
||||
if (event.button === 1) event.preventDefault();
|
||||
// 마우스는 가운데 버튼만 팬이다 — 왼쪽 버튼은 노드·손잡이 조작 몫.
|
||||
if (event.pointerType === "mouse" && event.button !== 1) return;
|
||||
if (
|
||||
event.pointerType === "mouse" &&
|
||||
event.button !== 1 &&
|
||||
!(params.leftPan && event.button === 0)
|
||||
)
|
||||
return;
|
||||
const view = getView();
|
||||
dragStart = {
|
||||
x: event.clientX,
|
||||
|
||||
@@ -23,7 +23,6 @@ import {
|
||||
criteriaLine,
|
||||
CRITERIA_KEYS,
|
||||
LOGIC_LABEL,
|
||||
mapPaths,
|
||||
metricsLine,
|
||||
rebalanceWeights,
|
||||
relaxedLine,
|
||||
@@ -36,6 +35,7 @@ import {
|
||||
type MapLayer,
|
||||
type WeightKey,
|
||||
} from "./B05_Profile_UI_RouteEdit_Initial_Model";
|
||||
import { createRecommendMap, RECOMMEND_MAP_HTML } from "./B05_Profile_UI_RouteEdit_Recommend_Map";
|
||||
import "./B05_Profile_UI_Style_RouteRecommend.css";
|
||||
|
||||
type Vertex = [number, number];
|
||||
@@ -61,34 +61,31 @@ const CRITERIA_FIELD: Record<
|
||||
{ label: string; unit: string; title: string; of: (c: InitialCriteria) => number | null }
|
||||
> = {
|
||||
max_grade_pct: {
|
||||
label: "기울기",
|
||||
label: "종단기울기 상한",
|
||||
unit: "%",
|
||||
title: "종단기울기 상한(%) — 비우면 프로젝트 기본값(설계속도 · 지형 표값)",
|
||||
of: (c) => c.maxGradePct,
|
||||
},
|
||||
min_radius_m: {
|
||||
label: "최소R",
|
||||
label: "최소 반지름",
|
||||
unit: "m",
|
||||
title: "최소 곡선반지름(m) — 비우면 프로젝트 기본값",
|
||||
of: (c) => c.minRadiusM,
|
||||
},
|
||||
stream_offset_m: {
|
||||
label: "계류",
|
||||
label: "계류 이격",
|
||||
unit: "m",
|
||||
title: "계류 이격(m) — 비우면 기본값 100m",
|
||||
of: (c) => c.streamOffsetM,
|
||||
},
|
||||
grade_smooth_m: {
|
||||
label: "지반",
|
||||
label: "지반 고름 폭",
|
||||
unit: "m",
|
||||
title: "기울기 잴 지반 고름 폭(m) — 비우면 기본값 5m",
|
||||
of: (c) => c.gradeSmoothM,
|
||||
},
|
||||
};
|
||||
|
||||
const MAP_W = 460;
|
||||
const MAP_H = 340;
|
||||
|
||||
export interface RecommendParams {
|
||||
/** 칸에 처음 채울 제약 겹침(직접 넣은 값만). */
|
||||
criteria: RouteInitialCriteria | null;
|
||||
@@ -120,13 +117,17 @@ export function openRecommendModal(params: RecommendParams): void {
|
||||
let seq = 0;
|
||||
|
||||
let bodyEl: HTMLElement | null = null;
|
||||
let map: ReturnType<typeof createRecommendMap> | null = null;
|
||||
openModal({
|
||||
title: "노선 추천",
|
||||
closeLabel: "✕",
|
||||
dialogClass: "b05-routerec",
|
||||
// 곡선 라벨 패널(z-index 1001)이 떠 있어도 이 모달과 뒤 막이 그 위에 오게 한다.
|
||||
backdropClass: "b05-routerec-backdrop",
|
||||
// ✕ · Esc · 바깥 누르기 모두 [닫기]와 같다 — 미리 보기를 버린다.
|
||||
beforeClose: () => {
|
||||
closed = true;
|
||||
map?.dispose();
|
||||
return true;
|
||||
},
|
||||
mount: (body, close) => {
|
||||
@@ -141,21 +142,28 @@ export function openRecommendModal(params: RecommendParams): void {
|
||||
</div>
|
||||
<div class="b05-routerec__main">
|
||||
<div class="b05-routerec__mapbox">
|
||||
<svg class="b05-routerec__map" viewBox="0 0 ${MAP_W} ${MAP_H}" role="img"
|
||||
aria-label="추천 노선 미리 보기"></svg>
|
||||
<div class="b05-routerec__mapwrap" role="img" aria-label="추천 노선 미리 보기">
|
||||
${RECOMMEND_MAP_HTML}
|
||||
</div>
|
||||
<div class="b05-routerec__legend">
|
||||
<span><i class="is-expected"></i>예상노선(원본)</span>
|
||||
<span><i class="is-current"></i>창을 연 노선</span>
|
||||
<span><i class="is-preview"></i>미리 보기</span>
|
||||
</div>
|
||||
<div class="b05-routerec__maphint">휠 = 확대 · 축소 끌기 = 이동 ↺ ↻ = 회전</div>
|
||||
</div>
|
||||
<div class="b05-routerec__side">
|
||||
<div class="b05-routerec__weights" hidden>
|
||||
${WEIGHT_KEYS.map(
|
||||
(key) => `<label><span>${WEIGHT_NAME[key]}</span>
|
||||
<input type="range" min="0" max="100" step="1" data-weight="${key}" />
|
||||
<output data-weight-out="${key}"></output></label>`,
|
||||
).join("")}
|
||||
<div class="b05-routerec__bars">
|
||||
${WEIGHT_KEYS.map(
|
||||
(
|
||||
key,
|
||||
) => `<label class="b05-routerec__bar"><output data-weight-out="${key}"></output>
|
||||
<input type="range" min="0" max="100" step="1" data-weight="${key}"
|
||||
aria-label="${WEIGHT_NAME[key]} 비중" />
|
||||
<span>${WEIGHT_NAME[key]}</span></label>`,
|
||||
).join("")}
|
||||
</div>
|
||||
<span class="b05-routerec__sum" data-weight-sum></span>
|
||||
</div>
|
||||
<div class="b05-routerec__criteria">
|
||||
@@ -181,7 +189,7 @@ export function openRecommendModal(params: RecommendParams): void {
|
||||
</div>`;
|
||||
|
||||
const q = <T extends HTMLElement>(sel: string): T => body.querySelector<T>(sel)!;
|
||||
const svg = body.querySelector<SVGSVGElement>(".b05-routerec__map")!;
|
||||
map = createRecommendMap(q<HTMLElement>(".b05-routerec__mapwrap"));
|
||||
const pills = [...body.querySelectorAll<HTMLButtonElement>("[data-logic]")];
|
||||
const weightsBox = q<HTMLElement>(".b05-routerec__weights");
|
||||
const rerun = q<HTMLButtonElement>('[data-act="rerun"]');
|
||||
@@ -213,9 +221,7 @@ export function openRecommendModal(params: RecommendParams): void {
|
||||
{ kind: "current", points: params.current },
|
||||
];
|
||||
if (preview) layers.push({ kind: "preview", points: preview.snapshot.planned });
|
||||
svg.innerHTML = mapPaths(layers, MAP_W, MAP_H)
|
||||
.map((path) => `<path class="is-${path.kind}" d="${path.d}" />`)
|
||||
.join("");
|
||||
map?.setLayers(layers);
|
||||
}
|
||||
|
||||
function render(): void {
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
/* =============================================================================
|
||||
* B05_Profile_UI_RouteEdit_Recommend_Map.ts
|
||||
* [노선 추천] 모달의 미리 보기 지도 — 캔버스 하나에 선 세 겹(예상노선 · 창을 연 노선 · 미리 보기).
|
||||
*
|
||||
* 화면 셈 · 조작은 편집 창 것을 그대로 가져다 쓴다(베끼지 않음) — 좌표 옮기기 `metricToScreen` ·
|
||||
* 처음 맞춤 `computeRouteView` · 캔버스 크기 `fitCanvas`(`_View`) · 휠 확대 · 끌기
|
||||
* `bindRouteEditNavigation`(`_Input`) · 회전 `createMapRotation`(`_Rotate`). 배경 지도(VWorld)는
|
||||
* 없어서, 선들의 범위를 감싼 가짜 도엽(`VWorldMeta`)을 만들어 같은 셈에 넣는다.
|
||||
* ========================================================================== */
|
||||
|
||||
import { themeColor } from "@ui/ui_template_palette";
|
||||
import {
|
||||
computeMapRect,
|
||||
computeRouteView,
|
||||
metricToScreen,
|
||||
ROUTE_VIEW_MARGIN_M,
|
||||
routeLineColor,
|
||||
type ViewState,
|
||||
} from "../B04_PreProcess/B04_PreProcess_UI_MapRender";
|
||||
import type { VWorldMeta } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
|
||||
import { HALF_TURN_ICON } from "./B05_Profile_UI_RouteEdit_Chrome";
|
||||
import { bindRouteEditNavigation } from "./B05_Profile_UI_RouteEdit_Input";
|
||||
import type { MapLayer } from "./B05_Profile_UI_RouteEdit_Initial_Model";
|
||||
import { createMapRotation } from "./B05_Profile_UI_RouteEdit_Rotate";
|
||||
import { fitCanvas } from "./B05_Profile_UI_RouteEdit_View";
|
||||
|
||||
/** 처음 맞출 때 선 범위 둘레로 두는 여유(m) — 편집 창(200 m)보다 좁게, 선이 지도를 채우게. */
|
||||
const FIT_MARGIN_M = 30;
|
||||
|
||||
/** 지도 칸 마크업 — 캔버스 + 회전 단추 둘(편집 창과 같은 모양 · 같은 `data-act`). */
|
||||
export const RECOMMEND_MAP_HTML = `
|
||||
<canvas class="b05-routerec__map"></canvas>
|
||||
<div class="b05-routeedit__spin">
|
||||
<button type="button" class="ui-btn ui-btn--glass" data-act="rotate-ccw"
|
||||
title="반시계로 돌리기" aria-label="반시계로 돌리기">${HALF_TURN_ICON.ccw}</button>
|
||||
<button type="button" class="ui-btn ui-btn--glass" data-act="rotate-cw"
|
||||
title="시계로 돌리기" aria-label="시계로 돌리기">${HALF_TURN_ICON.cw}</button>
|
||||
</div>`;
|
||||
|
||||
export interface RecommendMap {
|
||||
/** 겹을 바꿔 다시 그린다. 처음 점이 있는 겹이 들어올 때 한 번만 화면을 맞춘다. */
|
||||
setLayers: (layers: MapLayer[]) => void;
|
||||
dispose: () => void;
|
||||
}
|
||||
|
||||
/** `box` = `RECOMMEND_MAP_HTML` 을 넣은 칸(높이가 정해져 있어야 함). */
|
||||
export function createRecommendMap(box: HTMLElement): RecommendMap {
|
||||
const canvas = box.querySelector<HTMLCanvasElement>("canvas")!;
|
||||
const context = canvas.getContext("2d")!;
|
||||
let layers: MapLayer[] = [];
|
||||
let meta: VWorldMeta | null = null;
|
||||
let fitted = false;
|
||||
let view: ViewState = {
|
||||
width: 0,
|
||||
height: 0,
|
||||
scale: 1,
|
||||
offsetX: 0,
|
||||
offsetY: 0,
|
||||
mapRect: computeMapRect(null, 0, 0),
|
||||
};
|
||||
const rotation = createMapRotation({ overlay: box, size: () => view, onChange: () => draw() });
|
||||
|
||||
function bounds(): { x_min: number; x_max: number; y_min: number; y_max: number } | null {
|
||||
const all = layers.flatMap((layer) => layer.points);
|
||||
if (!all.length) return null;
|
||||
const xs = all.map(([x]) => x);
|
||||
const ys = all.map(([, y]) => y);
|
||||
return {
|
||||
x_min: Math.min(...xs),
|
||||
x_max: Math.max(...xs),
|
||||
y_min: Math.min(...ys),
|
||||
y_max: Math.max(...ys),
|
||||
};
|
||||
}
|
||||
|
||||
/** 선 범위 + 여유를 도엽으로 본다 — 이후 확대 상한 · 맞춤이 편집 창과 같은 공식을 탄다. */
|
||||
function makeMeta(range: NonNullable<ReturnType<typeof bounds>>): VWorldMeta {
|
||||
const x_min = range.x_min - ROUTE_VIEW_MARGIN_M;
|
||||
const y_min = range.y_min - ROUTE_VIEW_MARGIN_M;
|
||||
const x_max = range.x_max + ROUTE_VIEW_MARGIN_M;
|
||||
const y_max = range.y_max + ROUTE_VIEW_MARGIN_M;
|
||||
return {
|
||||
x_min,
|
||||
x_max,
|
||||
y_min,
|
||||
y_max,
|
||||
width_meters: x_max - x_min,
|
||||
height_meters: y_max - y_min,
|
||||
center_x: (x_min + x_max) / 2,
|
||||
center_y: (y_min + y_max) / 2,
|
||||
lon_min: 0,
|
||||
lon_max: 1,
|
||||
lat_min: 0,
|
||||
lat_max: 1,
|
||||
};
|
||||
}
|
||||
|
||||
function refit(): void {
|
||||
const b = bounds();
|
||||
if (!b || !view.width) return;
|
||||
meta = makeMeta(b);
|
||||
view = {
|
||||
...view,
|
||||
mapRect: computeMapRect(meta, view.width, view.height),
|
||||
...computeRouteView(meta, b, view.width, view.height, FIT_MARGIN_M),
|
||||
};
|
||||
fitted = true;
|
||||
}
|
||||
|
||||
function draw(): void {
|
||||
context.clearRect(0, 0, view.width, view.height);
|
||||
if (!meta) return;
|
||||
context.save();
|
||||
context.translate(view.width / 2, view.height / 2);
|
||||
context.rotate(rotation.radians());
|
||||
context.translate(-view.width / 2, -view.height / 2);
|
||||
context.lineCap = "round";
|
||||
context.lineJoin = "round";
|
||||
const styles: Record<MapLayer["kind"], () => void> = {
|
||||
expected: () => {
|
||||
context.strokeStyle = themeColor("--color-text-secondary", "#9ca3af");
|
||||
context.lineWidth = 1.5;
|
||||
context.setLineDash([5, 4]);
|
||||
},
|
||||
current: () => {
|
||||
context.strokeStyle = routeLineColor();
|
||||
context.globalAlpha = 0.55;
|
||||
context.lineWidth = 2;
|
||||
context.setLineDash([]);
|
||||
},
|
||||
preview: () => {
|
||||
context.strokeStyle = themeColor("--color-primary", "#6d28d9");
|
||||
context.lineWidth = 3;
|
||||
context.setLineDash([]);
|
||||
},
|
||||
};
|
||||
for (const layer of layers) {
|
||||
if (layer.points.length < 2) continue;
|
||||
context.save();
|
||||
styles[layer.kind]();
|
||||
context.beginPath();
|
||||
layer.points.forEach(([x, y], index) => {
|
||||
const [sx, sy] = metricToScreen(meta!, view, x, y);
|
||||
if (index) context.lineTo(sx, sy);
|
||||
else context.moveTo(sx, sy);
|
||||
});
|
||||
context.stroke();
|
||||
context.restore();
|
||||
}
|
||||
context.restore();
|
||||
}
|
||||
|
||||
function resize(): void {
|
||||
const { width, height } = fitCanvas(canvas, context);
|
||||
if (!width || !height) return;
|
||||
view = { ...view, width, height, mapRect: computeMapRect(meta, width, height) };
|
||||
// 처음 한 번만 맞춘다 — 그 뒤 창 크기가 바뀌어도 사용자가 만진 배율 · 자리를 지킨다.
|
||||
if (!fitted) refit();
|
||||
draw();
|
||||
}
|
||||
|
||||
bindRouteEditNavigation({
|
||||
canvas,
|
||||
getView: () => view,
|
||||
setView: (next) => (view = next),
|
||||
getMeta: () => meta,
|
||||
unrotateDelta: rotation.unrotateDelta,
|
||||
leftPan: true,
|
||||
draw,
|
||||
});
|
||||
// 모달이 붙기 전에는 칸 크기가 0 이다 — 붙은 뒤 크기가 잡히면 맞추고 그린다.
|
||||
const observer = new ResizeObserver(() => resize());
|
||||
observer.observe(canvas.parentElement!);
|
||||
|
||||
return {
|
||||
setLayers(next) {
|
||||
layers = next;
|
||||
if (!fitted) refit();
|
||||
draw();
|
||||
},
|
||||
dispose: () => observer.disconnect(),
|
||||
};
|
||||
}
|
||||
@@ -288,3 +288,8 @@
|
||||
}
|
||||
|
||||
/* 구조물(배관) 배치로 갈라진 구배 블록 — 평소엔 값 없이 윤곽만, 선택하면 강조와 함께 값 표시. */
|
||||
|
||||
/* 종단도 판 자리에서 끈 배수유역도(`_Drainage_Side` · 계획서 24-2e) — 자리를 비운다. */
|
||||
.b05-drainage[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@@ -9,13 +9,20 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 곡선 라벨 패널(`.b05-routeedit__label` = 모달 + 1) 위로 — 모달과 뒤 막이 함께 올라간다. */
|
||||
.ui-modal__backdrop.b05-routerec-backdrop {
|
||||
z-index: calc(var(--z-modal, 1000) + 2);
|
||||
}
|
||||
|
||||
.ui-modal.b05-routerec {
|
||||
width: min(900px, 94vw);
|
||||
width: min(960px, 94vw);
|
||||
max-height: 92vh;
|
||||
font-size: var(--text-body-sm);
|
||||
}
|
||||
|
||||
.b05-routerec__body {
|
||||
gap: var(--spacing-12);
|
||||
font-size: var(--text-body-sm);
|
||||
}
|
||||
|
||||
/* 알약 — 하나만 고른 상태(라디오 묶음). */
|
||||
@@ -26,12 +33,12 @@
|
||||
}
|
||||
|
||||
.b05-routerec__pill {
|
||||
padding: 6px var(--spacing-16);
|
||||
padding: 8px var(--spacing-16);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 999px;
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text-body);
|
||||
font-size: var(--text-body-sm);
|
||||
font-size: var(--text-body);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@@ -53,7 +60,7 @@
|
||||
|
||||
.b05-routerec__main {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.5fr) minmax(0, 1fr);
|
||||
grid-template-columns: minmax(0, 1.4fr) minmax(0, 1fr);
|
||||
gap: var(--spacing-12);
|
||||
align-items: start;
|
||||
}
|
||||
@@ -64,36 +71,27 @@
|
||||
gap: var(--spacing-4, 4px);
|
||||
}
|
||||
|
||||
.b05-routerec__map {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
/* 미리 보기 지도 — 캔버스 칸(높이 고정). 회전 단추는 편집 창 것(`.b05-routeedit__spin`)과 같은 모양. */
|
||||
.b05-routerec__mapwrap {
|
||||
position: relative;
|
||||
height: 380px;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-8, 6px);
|
||||
background: var(--color-surface);
|
||||
}
|
||||
|
||||
.b05-routerec__map path {
|
||||
fill: none;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
.b05-routerec__map {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
touch-action: none;
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.b05-routerec__map path.is-expected {
|
||||
stroke: var(--color-text-secondary, #9ca3af);
|
||||
stroke-dasharray: 5 4;
|
||||
stroke-width: 1.5;
|
||||
}
|
||||
|
||||
.b05-routerec__map path.is-current {
|
||||
stroke: var(--map-route, #f97316);
|
||||
stroke-width: 2;
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.b05-routerec__map path.is-preview {
|
||||
stroke: var(--color-primary);
|
||||
stroke-width: 2.5;
|
||||
.b05-routerec__maphint {
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--text-body-sm);
|
||||
}
|
||||
|
||||
.b05-routerec__legend {
|
||||
@@ -101,7 +99,7 @@
|
||||
flex-wrap: wrap;
|
||||
gap: var(--spacing-12);
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--text-caption);
|
||||
font-size: var(--text-body-sm);
|
||||
}
|
||||
|
||||
.b05-routerec__legend i {
|
||||
@@ -128,53 +126,81 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-12);
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--text-caption);
|
||||
color: var(--color-text-body);
|
||||
font-size: var(--text-body-sm);
|
||||
}
|
||||
|
||||
/* 비중 — 세로 막대 셋이 나란히(값 위 · 이름 아래). 슬라이더가 세로로 선다. */
|
||||
.b05-routerec__weights {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4, 4px);
|
||||
}
|
||||
|
||||
.b05-routerec__weights label {
|
||||
display: grid;
|
||||
grid-template-columns: 3.5em 1fr 2.5em;
|
||||
align-items: center;
|
||||
.b05-routerec__weights[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.b05-routerec__bars {
|
||||
display: flex;
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
|
||||
.b05-routerec__weights output {
|
||||
.b05-routerec__bar {
|
||||
display: flex;
|
||||
flex: 1 1 0;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--spacing-4, 4px);
|
||||
}
|
||||
|
||||
.b05-routerec__bar input[type="range"] {
|
||||
width: 28px;
|
||||
height: 130px;
|
||||
margin: 0;
|
||||
writing-mode: vertical-lr;
|
||||
direction: rtl; /* 위로 올릴수록 큰 값 */
|
||||
accent-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.b05-routerec__bar output {
|
||||
color: var(--color-text-body);
|
||||
text-align: right;
|
||||
font-size: var(--text-body);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.b05-routerec__bar span {
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.b05-routerec__sum {
|
||||
text-align: right;
|
||||
text-align: center;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* 공통 제약 네 칸 — 비우면 placeholder 가 기본값. */
|
||||
/* 공통 제약 네 칸 — 한 줄에 하나(이름 · 입력 · 단위). 비우면 placeholder 가 기본값. */
|
||||
.b05-routerec__criteria {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--spacing-8) var(--spacing-12);
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
|
||||
.b05-routerec__criteria label {
|
||||
display: inline-flex;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 5.5em 1.5em;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
white-space: nowrap;
|
||||
gap: var(--spacing-8);
|
||||
color: var(--color-text-body);
|
||||
}
|
||||
|
||||
.b05-routerec__criteria input {
|
||||
width: 4em;
|
||||
padding: 2px 4px;
|
||||
width: 100%;
|
||||
padding: 4px 8px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-4, 4px);
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text-body);
|
||||
font-size: var(--text-caption);
|
||||
font-size: var(--text-body);
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
@@ -191,8 +217,8 @@
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--text-caption);
|
||||
line-height: 1.45;
|
||||
font-size: var(--text-body-sm);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* 상한을 풀었다는 안내 — 눈에 띄게(계획서 20). */
|
||||
@@ -212,7 +238,7 @@
|
||||
.b05-routerec__state {
|
||||
flex: 1 1 12em;
|
||||
color: var(--color-text-body);
|
||||
font-size: var(--text-caption);
|
||||
font-size: var(--text-body-sm);
|
||||
}
|
||||
|
||||
/* 좁은 폭 — 지도 아래로 조작 칸. */
|
||||
@@ -220,4 +246,8 @@
|
||||
.b05-routerec__main {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.b05-routerec__mapwrap {
|
||||
height: 300px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
* ========================================================================== */
|
||||
|
||||
import {
|
||||
createProfileEditStore,
|
||||
useProfileEditStore,
|
||||
type ProfileEditStore,
|
||||
} from "../B05_Profile/B05_Profile_UI_Profile_Edit";
|
||||
import { readAlignment, toDesignProfile } from "../B05_Profile/B05_Profile_UI_Profile_Data";
|
||||
@@ -28,8 +28,8 @@ export interface GradeEditContext {
|
||||
}
|
||||
|
||||
/**
|
||||
* 계획선 편집(▲/▼) — B05 와 **같은 세션 초안**에 쌓는다(2026-09-12 사용자: B06 에만
|
||||
* 버튼이 없었다). 누르면 그 자리에서 계획선·전 측점 횡단을 다시 풀고(`reconcile`),
|
||||
* 계획선 편집(▲/▼) — B05 종단 패널의 **같은 가게**(노선마다 한 벌)를 쓴다(계획서 24-2a ·
|
||||
* 2026-09-12 사용자: B06 에만 버튼이 없었다). 누르면 그 자리에서 계획선·전 측점 횡단을 다시 풀고(`reconcile`),
|
||||
* 영구저장은 [저장]·[확정]에서만 한다(CLAUDE.md 5장).
|
||||
*/
|
||||
export function createGradeEdit(ctx: GradeEditContext): GradeEditProvider {
|
||||
@@ -53,7 +53,7 @@ export function createGradeEdit(ctx: GradeEditContext): GradeEditProvider {
|
||||
if (!stored) return null; // 선형 저장분이 없는 옛 노선 — 편집할 기준선이 없다.
|
||||
if (!gradeStore || gradeRouteId !== currentRouteId) {
|
||||
gradeRouteId = currentRouteId;
|
||||
gradeStore = createProfileEditStore(currentRouteId, stored.edits, () => undefined);
|
||||
gradeStore = useProfileEditStore(currentRouteId, stored.edits, () => undefined);
|
||||
}
|
||||
const store = gradeStore;
|
||||
const base = toAlignmentBase(stored);
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
* (CLAUDE.md 5장 데이터 3층: 영구저장은 [저장]·[확정]에서만).
|
||||
* ========================================================================== */
|
||||
|
||||
import { hideLoadingOverlay, showLoadingOverlay, showToast } from "@ui/ui_template_elements";
|
||||
import { showToast } from "@ui/ui_template_elements";
|
||||
import { goToWorkflowStage, WORKFLOW_STEP_ROUTES } from "../A00_Common/b_workflow_nav";
|
||||
import {
|
||||
confirmSections,
|
||||
@@ -17,14 +17,7 @@ import {
|
||||
type SectionDetailResponse,
|
||||
} from "./B06_Section_Api_Fetch";
|
||||
import { invalidateSectionDetail } from "./B06_Section_Section_Store";
|
||||
import { flushPendingPipes } from "../B05_Profile/B05_Profile_Api_Pipes_Draft";
|
||||
import { saveProfileAlignment } from "../B05_Profile/B05_Profile_Api_Fetch";
|
||||
import {
|
||||
clearAlignmentDrafts,
|
||||
readAlignmentDraft,
|
||||
} from "../B05_Profile/B05_Profile_UI_Profile_Edit";
|
||||
import { flushPendingStructures } from "../B05_Profile/B05_Profile_Api_Structures";
|
||||
import { flushUphillOverrides } from "../B05_Profile/B05_Profile_Api_Fetch";
|
||||
import { saveDesign, type DesignSaveContext } from "../A00_Common/design/design_save";
|
||||
import { buildCrossPatches, type CrossPatchSources } from "./B06_Section_UI_Page_Patches";
|
||||
import { readByKey, readState, writeByKey, writeState } from "../A00_Common/b_page_state";
|
||||
import {
|
||||
@@ -423,102 +416,64 @@ export function collectSectionEdits(ctx: SectionPersistContext): {
|
||||
};
|
||||
}
|
||||
|
||||
/** 계획선 편집 초안이 있으면 종단 정본에 쓰고 초안을 지운다. 없으면 아무 일도 하지 않는다. */
|
||||
async function flushAlignmentDraft(projectId: string, routeId: number | null): Promise<void> {
|
||||
if (routeId === null) return;
|
||||
const draft = readAlignmentDraft(routeId);
|
||||
if (!draft) return;
|
||||
await saveProfileAlignment(projectId, routeId, draft);
|
||||
clearAlignmentDrafts();
|
||||
}
|
||||
|
||||
/** 세션에 쌓인 조정창·구조물 조작을 정본으로 내보낸다 — [저장]·[확정] 공통 앞단. */
|
||||
async function flushPendingEdits(ctx: SectionPersistContext, projectId: string): Promise<void> {
|
||||
// ⚠ 순서는 **B05 [임시저장]과 같아야 한다**(2026-09-12 사용자: 어느 페이지에서 저장해도
|
||||
// 결과가 같아야 한다). 관 목록은 B05 가 **전체 스냅샷**으로, B06 이 **바뀐 것만**(추가·
|
||||
// 삭제·이동·구간값) 담으므로, 스냅샷을 먼저 얹고 그 위에 델타를 적용해야 한다. 반대로
|
||||
// 하면 스냅샷이 B06 편집을 통째로 덮는다.
|
||||
await flushPendingPipes(projectId).catch((error) => {
|
||||
const detail = error instanceof Error ? ` ${error.message}` : "";
|
||||
showToast(`배수관 저장에 실패했습니다.${detail}`, "error");
|
||||
});
|
||||
// 조정창 구간값·추가·삭제·이동은 세션에만 있다 — 정본 payload를 모으기 전에 내보낸다
|
||||
// (CLAUDE.md 5장: 영구저장은 [저장]·[확정]에서만).
|
||||
await ctx.flushCulvertOptions();
|
||||
// B05 3D에서 바꾼 상단측(측구 방향)도 여기서 내보낸다 — 예전에는 B05 [임시저장]에만
|
||||
// 실려, B06에서 저장·확정하면 세션에만 남아 옛 방향이 정본에 그대로 있었다
|
||||
// (2026-09-06). 서버가 종단 정본과 저장된 횡단 설계를 함께 갱신하므로 아래
|
||||
// 횡단 patch 저장보다 **먼저** 나가야 사용자 수정이 위에 얹힌다.
|
||||
await flushUphillOverrides(projectId).catch(() => undefined);
|
||||
// B05에서 만지고 넘어온 구조물 조작분도 여기서 정본에 남긴다. 실패해도 횡단
|
||||
// 저장까지 막지는 않는다 — 미저장분은 세션에 남으므로 다시 시도할 수 있다
|
||||
// (2026-08-29 실측: 타입이 거절되자 sections/save가 아예 나가지 않았다).
|
||||
// 계획선 편집(▲/▼)은 세션 초안에만 있다 — B06 에서 고쳤든 B05 에서 고쳤든 여기서
|
||||
// 종단 정본으로 내보낸다(2026-09-12). 종전에는 B06 이 초안을 **읽기만** 해서, B06 에서
|
||||
// 저장하면 계획선 편집이 다음 진입 때 사라졌다.
|
||||
await flushAlignmentDraft(projectId, ctx.routeId()).catch((error) => {
|
||||
const detail = error instanceof Error ? ` ${error.message}` : "";
|
||||
showToast(`계획선 저장에 실패했습니다.${detail}`, "error");
|
||||
});
|
||||
await flushPendingStructures(projectId).catch((error) => {
|
||||
const detail = error instanceof Error ? ` ${error.message}` : "";
|
||||
showToast(`구조물 저장에 실패했습니다.${detail}`, "error");
|
||||
});
|
||||
/**
|
||||
* B06 의 저장 한 줄기 창구(계획서 24-2a) — 차례는 `saveDesign` 이 정하고(B05 [임시저장]과 같음),
|
||||
* 여기서는 B06 몫만 준다: 저장 앞 전 측점 재계산 · 조정창 구간값 · 횡단 본문 · 뒤처리.
|
||||
*/
|
||||
function sectionSaveContext(ctx: SectionPersistContext, projectId: string): DesignSaveContext {
|
||||
return {
|
||||
projectId,
|
||||
routeId: ctx.routeId,
|
||||
// ⚠ 면적을 모으기 **전에** 전 측점을 다시 계산한다 — 안 그러면 표준단면을 고친 뒤
|
||||
// 안 만진 측점이 옛 면적으로 실려 나간다(2026-09-09 실측).
|
||||
prepare: () => ctx.reconcileDesigns(),
|
||||
// 조정창 구간값은 조정창 저장기가 낸다 — 실패하면 알리고 저장을 멈춘다(예전 그대로).
|
||||
steps: { culvertOptions: () => ctx.flushCulvertOptions() },
|
||||
cross: async (confirm) => {
|
||||
const routeId = ctx.routeId();
|
||||
if (routeId === null) return;
|
||||
const edits = collectSectionEdits(ctx);
|
||||
await (confirm ? confirmSections : saveSections)(
|
||||
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;
|
||||
}
|
||||
// 저장으로 구조물 초안이 정본이 됐다 — 화면이 들고 있던 상세는 초안 기준으로 벽을
|
||||
// 얹은 사본이라, 그대로 두면 새로 저장된 구조물의 벽이 다음 조회까지 안 붙는다
|
||||
// (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");
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** 임시 저장 — 저장만 하고 페이지는 그대로 둔다. */
|
||||
export async function saveCurrentSections(ctx: SectionPersistContext): Promise<void> {
|
||||
const routeId = ctx.routeId();
|
||||
if (!ctx.projectId || routeId === null) return;
|
||||
showLoadingOverlay();
|
||||
try {
|
||||
// ⚠ 면적을 모으기 **전에** 전 측점을 다시 계산한다 — 안 그러면 표준단면을 고친 뒤
|
||||
// 안 만진 측점이 옛 면적으로 실려 나간다(2026-09-09 실측).
|
||||
await ctx.reconcileDesigns();
|
||||
await flushPendingEdits(ctx, ctx.projectId);
|
||||
const edits = collectSectionEdits(ctx);
|
||||
await saveSections(
|
||||
ctx.projectId,
|
||||
routeId,
|
||||
ctx.standardValues(),
|
||||
edits.crossPatches.length ? edits.crossPatches : undefined,
|
||||
edits.massHaul,
|
||||
);
|
||||
// 저장으로 구조물 초안이 정본이 됐다 — 화면이 들고 있던 상세는 초안 기준으로 벽을
|
||||
// 얹은 사본이라, 그대로 두면 새로 저장된 구조물의 벽이 다음 조회까지 안 붙는다
|
||||
// (2026-09-06 실측: 8+0.0 흙막이가 저장 뒤 화면 사본에만 빠져 있었음).
|
||||
invalidateSectionDetail(ctx.projectId, routeId);
|
||||
showToast(L("B06_Profile_Save_Success"), "success");
|
||||
} catch (error) {
|
||||
const detail = error instanceof Error ? ` ${error.message}` : "";
|
||||
showToast(`${L("B06_Profile_Save_Failed")}${detail}`, "error");
|
||||
} finally {
|
||||
hideLoadingOverlay();
|
||||
}
|
||||
if (!ctx.projectId) return;
|
||||
await saveDesign(sectionSaveContext(ctx, ctx.projectId));
|
||||
}
|
||||
|
||||
/** 확정 — 저장 뒤 다음 단계(상세설계)로 넘어간다. */
|
||||
export async function confirmCurrentSections(ctx: SectionPersistContext): Promise<void> {
|
||||
const routeId = ctx.routeId();
|
||||
if (!ctx.projectId || routeId === null) return;
|
||||
showLoadingOverlay();
|
||||
try {
|
||||
await ctx.reconcileDesigns(); // 저장과 같은 이유 — 옛 면적이 정본으로 굳는 것을 막는다
|
||||
await flushPendingEdits(ctx, ctx.projectId);
|
||||
const edits = collectSectionEdits(ctx);
|
||||
await confirmSections(
|
||||
ctx.projectId,
|
||||
routeId,
|
||||
ctx.standardValues(),
|
||||
edits.crossPatches.length ? edits.crossPatches : undefined,
|
||||
edits.massHaul,
|
||||
);
|
||||
showToast(L("B06_Profile_Confirm_Success"), "success");
|
||||
goToWorkflowStage(ctx.projectId, WORKFLOW_STEP_ROUTES[4]);
|
||||
} catch (error) {
|
||||
const detail = error instanceof Error ? error.message : L("B06_Profile_Confirm_Failed");
|
||||
showToast(`${L("B06_Profile_Confirm_Failed")} ${detail}`, "error");
|
||||
} finally {
|
||||
hideLoadingOverlay();
|
||||
}
|
||||
if (!ctx.projectId) return;
|
||||
await saveDesign(sectionSaveContext(ctx, ctx.projectId), { confirm: true });
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
/* 배수유역 관 보고(`B05_Profile_UI_Drainage_Report.ts`) 셈 단위검증 헬퍼 — TS 를 트랜스파일해 Node 로 돈다. */
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
|
||||
|
||||
const file = path.join(__dirname, "..", "..", "B05_Profile", "B05_Profile_UI_Drainage_Report.ts");
|
||||
const js = ts.transpileModule(fs.readFileSync(file, "utf8"), {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
|
||||
}).outputText;
|
||||
const box = { exports: {} };
|
||||
new Function("exports", "module", "require", js)(box.exports, box, require);
|
||||
const { buildPipeReports } = box.exports;
|
||||
|
||||
const store = {
|
||||
get: (chainage) =>
|
||||
chainage === 100.5 ? { facility: "box_culvert", start_m: 95, end_m: 106, options: { h: 2 } } : null,
|
||||
};
|
||||
const basins = [
|
||||
{ chainage_m: 40, pipe_diameter_mm: 800, design_flow_m3s: 1.2, bridge_required: false },
|
||||
{ chainage_m: 100.5, pipe_diameter_mm: 1000, design_flow_m3s: 3.4, bridge_required: true },
|
||||
];
|
||||
const out = buildPipeReports(
|
||||
[
|
||||
{ chainage_m: 40.004, reason: "auto" },
|
||||
{ chainage_m: 100.499 },
|
||||
{ chainage_m: 200 },
|
||||
],
|
||||
basins,
|
||||
store,
|
||||
);
|
||||
process.stdout.write(JSON.stringify(out));
|
||||
@@ -0,0 +1,180 @@
|
||||
/* 설계 가게 · 저장 한 줄기(계획서 24-2a) 단위검증 헬퍼 —
|
||||
* `A00_Common/design/design_store.ts` · `design_save.ts` · `B05_Profile_UI_Profile_Edit.ts` 를
|
||||
* 그 자리에서 트랜스파일해 Node 로 돌린다. 서버 창구는 가짜로 두고 부른 차례만 적는다.
|
||||
*/
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
|
||||
|
||||
const ROOT = path.join(__dirname, "..", "..");
|
||||
|
||||
function memoryStorage() {
|
||||
const map = new Map();
|
||||
return {
|
||||
getItem: (key) => (map.has(key) ? map.get(key) : null),
|
||||
setItem: (key, value) => map.set(key, String(value)),
|
||||
removeItem: (key) => map.delete(key),
|
||||
key: (index) => [...map.keys()][index] ?? null,
|
||||
get length() {
|
||||
return map.size;
|
||||
},
|
||||
};
|
||||
}
|
||||
const session = memoryStorage();
|
||||
globalThis.window = { sessionStorage: session, localStorage: memoryStorage() };
|
||||
globalThis.sessionStorage = session;
|
||||
|
||||
const calls = [];
|
||||
const record = (name, fail) => async () => {
|
||||
calls.push(name);
|
||||
if (fail) throw new Error(`${name} 실패`);
|
||||
};
|
||||
let failStructures = false;
|
||||
const STUBS = {
|
||||
"@ui/ui_template_elements": {
|
||||
showToast: (text, kind) => calls.push(`toast:${kind}`),
|
||||
showLoadingOverlay: () => calls.push("overlay:on"),
|
||||
hideLoadingOverlay: () => calls.push("overlay:off"),
|
||||
},
|
||||
"../../B05_Profile/B05_Profile_Api_Pipes_Draft": {
|
||||
flushPendingPipes: () => record("pipes")(),
|
||||
},
|
||||
"../../B05_Profile/B05_Profile_Api_Structures": {
|
||||
flushPendingStructures: () => record("structures", failStructures)(),
|
||||
},
|
||||
"../../B05_Profile/B05_Profile_Api_Fetch": {
|
||||
flushUphillOverrides: () => record("uphill")(),
|
||||
saveProfileAlignment: async (_p, routeId, edits) =>
|
||||
calls.push(`alignment:${routeId}:${JSON.stringify(edits.station_offsets)}`),
|
||||
},
|
||||
"../../B06_Section/B06_Section_Api_Culvert_Options": {
|
||||
flushCulvertOptions: async (_p, key) => calls.push(`culvert:${key}`),
|
||||
},
|
||||
};
|
||||
|
||||
const cache = new Map();
|
||||
function loadTs(file) {
|
||||
const full = path.resolve(file);
|
||||
if (cache.has(full)) return cache.get(full);
|
||||
const js = ts.transpileModule(fs.readFileSync(full, "utf8"), {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
|
||||
}).outputText;
|
||||
const box = { exports: {} };
|
||||
cache.set(full, box.exports);
|
||||
const localRequire = (request) => {
|
||||
if (STUBS[request]) return STUBS[request];
|
||||
if (request.startsWith(".")) return loadTs(path.join(path.dirname(full), `${request}.ts`));
|
||||
return require(request);
|
||||
};
|
||||
new Function("exports", "module", "require", js)(box.exports, box, localRequire);
|
||||
cache.set(full, box.exports);
|
||||
return box.exports;
|
||||
}
|
||||
|
||||
const { useDesignStore } = loadTs(path.join(ROOT, "A00_Common", "design", "design_store.ts"));
|
||||
const edit = loadTs(path.join(ROOT, "B05_Profile", "B05_Profile_UI_Profile_Edit.ts"));
|
||||
const { saveDesign, DESIGN_SAVE_ORDER } = loadTs(
|
||||
path.join(ROOT, "A00_Common", "design", "design_save.ts"),
|
||||
);
|
||||
|
||||
async function run() {
|
||||
const out = {};
|
||||
|
||||
// ① 가게 — 등록 키 밑 · 같은 칸이면 두 가게가 같은 값 · 듣는 쪽
|
||||
const a = useDesignStore("p1", 7);
|
||||
const b = useDesignStore("p1", 99); // 프로젝트 칸은 노선과 무관
|
||||
const heard = [];
|
||||
const off = b.subscribe("pipes", (value) => heard.push(value.length));
|
||||
a.set("pipes", [{ chainage_m: 10, source: "user" }]);
|
||||
out.pipesShared = b.get("pipes");
|
||||
out.pipesKey = [...Array(session.length).keys()].map((i) => session.key(i));
|
||||
off();
|
||||
a.set("pipes", [{ chainage_m: 10 }, { chainage_m: 20 }]);
|
||||
out.heard = heard;
|
||||
out.otherProject = useDesignStore("p2", 7).get("pipes");
|
||||
out.noRoute = useDesignStore("p1", null).get("alignmentDraft");
|
||||
a.set("selection", { chainageM: 12.5, structureId: "s-1" });
|
||||
out.selection = b.get("selection");
|
||||
a.set("pipes", null);
|
||||
a.set("selection", null);
|
||||
|
||||
// ② 계획고 가게 한 벌 — B05 패널 · B06 계획선 편집이 같은 벌
|
||||
const saved = { station_offsets: {}, curve_radii: { "40.000": 30 } };
|
||||
const b05 = edit.createProfileEditStore(7, saved, () => undefined);
|
||||
const b06 = edit.useProfileEditStore(7, saved, () => undefined);
|
||||
b06.replace({ station_offsets: { "20.000": 0.5 }, curve_radii: saved.curve_radii });
|
||||
out.b05SeesB06 = b05.edits().station_offsets;
|
||||
out.b05Dirty = b05.dirty();
|
||||
// B05 가 자기 손에 쥔 값으로 이어 고쳐도 B06 편집이 안 사라짐(옛 두 가게 흠)
|
||||
b05.replace({
|
||||
station_offsets: { ...b05.edits().station_offsets, "60.000": -0.2 },
|
||||
curve_radii: b05.edits().curve_radii,
|
||||
});
|
||||
out.afterBoth = b06.edits().station_offsets;
|
||||
out.draft = useDesignStore(null, 7).get("alignmentDraft").station_offsets;
|
||||
out.pending = edit.pendingAlignment(7).station_offsets;
|
||||
// 되돌리기가 초안을 바꾼 뒤 B05 가 다시 세움 — B06 쪽 손잡이도 같은 벌을 봄
|
||||
useDesignStore(null, 7).set("alignmentDraft", {
|
||||
station_offsets: { "20.000": 0.1 },
|
||||
curve_radii: {},
|
||||
});
|
||||
edit.createProfileEditStore(7, saved, () => undefined);
|
||||
out.rebuiltSeenByB06 = b06.edits().station_offsets;
|
||||
|
||||
// ③ 저장 한 줄기 — 차례 · 기본 창구 · 보기 몫 · 계획고 저장됨 표시
|
||||
calls.length = 0;
|
||||
const ok = await saveDesign({
|
||||
projectId: "p1",
|
||||
routeId: () => 7,
|
||||
prepare: record("prepare"),
|
||||
steps: { pipes: record("pipes:screen") },
|
||||
cross: async (confirm) => calls.push(`cross:${confirm}`),
|
||||
done: async (confirm) => calls.push(`done:${confirm}`),
|
||||
failed: () => calls.push("failed"),
|
||||
});
|
||||
out.saveOk = ok;
|
||||
out.saveCalls = [...calls];
|
||||
out.afterSave = { dirty: b05.dirty(), draft: useDesignStore(null, 7).get("alignmentDraft") };
|
||||
out.order = DESIGN_SAVE_ORDER;
|
||||
|
||||
// ④ 구조물이 실패해도 다음 차례로(알림) · 확정 · 노선 없으면 안 나감
|
||||
calls.length = 0;
|
||||
failStructures = true;
|
||||
await saveDesign(
|
||||
{
|
||||
projectId: "p1",
|
||||
routeId: () => 7,
|
||||
steps: { uphill: record("uphill:screen") },
|
||||
cross: async (confirm) => calls.push(`cross:${confirm}`),
|
||||
failed: () => calls.push("failed"),
|
||||
},
|
||||
{ confirm: true },
|
||||
);
|
||||
failStructures = false;
|
||||
out.tolerant = [...calls];
|
||||
calls.length = 0;
|
||||
out.noRouteSave = await saveDesign({
|
||||
projectId: "p1",
|
||||
routeId: () => null,
|
||||
cross: async () => calls.push("cross"),
|
||||
failed: () => calls.push("failed"),
|
||||
});
|
||||
out.noRouteCalls = [...calls];
|
||||
|
||||
// ⑤ [초기화] — 초안 · 한 벌 모두 버림
|
||||
b05.replace({ station_offsets: { "80.000": 1 }, curve_radii: {} });
|
||||
edit.clearAlignmentDrafts();
|
||||
out.cleared = {
|
||||
draft: useDesignStore(null, 7).get("alignmentDraft"),
|
||||
pending: edit.pendingAlignment(7),
|
||||
};
|
||||
return out;
|
||||
}
|
||||
|
||||
run().then(
|
||||
(out) => console.log(JSON.stringify(out)),
|
||||
(error) => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
},
|
||||
);
|
||||
@@ -474,23 +474,25 @@ def _resave_unchanged(flow: Flow) -> None:
|
||||
flow.put_structures(flow.api.get(f"{flow.base}/route/structures").json()["structures"])
|
||||
|
||||
|
||||
# 두 페이지 모두 저장 한 줄기(`A00_Common/design/design_save.ts` `DESIGN_SAVE_ORDER`)를 탄다 —
|
||||
# 관 → 구조물 → 계획고 → 암거 옵션(구간값) → 오르막(상단측 확정) → 횡단(계획서 24-2a).
|
||||
def _b05_save(flow: Flow) -> None:
|
||||
"""B05 [임시저장] 차례 — 관 → 계획선 → 구조물 → 확정(관 측점 · 상단측) → 구간값 → 횡단."""
|
||||
"""B05 [임시저장] — 오르막 차례에 관 측점(비정규 측점)도 함께 확정."""
|
||||
flow.put_pipes(flow.b05_pipe_payload())
|
||||
flow.put_alignment()
|
||||
flow.put_structures(flow.screen_structures(reread=True))
|
||||
flow.confirm(with_irregular=True)
|
||||
flow.put_alignment()
|
||||
flow.flush_culvert()
|
||||
flow.confirm(with_irregular=True)
|
||||
flow.save_sections()
|
||||
|
||||
|
||||
def _b06_save(flow: Flow) -> None:
|
||||
"""B06 [저장] 차례 — 관 → 구간값 → 상단측만 확정 → 계획선 → 구조물 → 횡단."""
|
||||
"""B06 [저장] — 오르막 차례는 상단측만 확정."""
|
||||
flow.put_pipes(flow.b06_pipe_payload())
|
||||
flow.put_structures(flow.screen_structures(reread=False))
|
||||
flow.put_alignment()
|
||||
flow.flush_culvert()
|
||||
flow.confirm(with_irregular=False)
|
||||
flow.put_alignment()
|
||||
flow.put_structures(flow.screen_structures(reread=False))
|
||||
flow.save_sections()
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""배수유역도 판이 종단 패널을 모르고 홀로 선다(계획서 24-2e).
|
||||
|
||||
- 배수유역 파일(`B05_Profile_UI_Drainage_*`)은 종단 패널(`_Profile_*`)이나 B06 을 가져오지 않는다 —
|
||||
종단쪽 일은 `_Panel_Types` 약속(콜백 · 창구)으로만 오간다.
|
||||
- 파일마다 700줄 이하.
|
||||
- 관 보고 만들기(`_Report`)는 상태 없이 같은 결과: ±0.5m 유역 매칭 · 세월교 유역은 관경 null ·
|
||||
시설 정보 · 출처 기본값 user.
|
||||
- `createDrainageSide` 가 켜고 끄는 창구를 낸다(두 보기에서 같은 판).
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
B05 = ROOT / "B05_Profile"
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
|
||||
|
||||
def _drainage_files() -> list[Path]:
|
||||
return sorted(B05.glob("B05_Profile_UI_Drainage_*.ts"))
|
||||
|
||||
|
||||
def test_배수유역_파일은_종단_패널과_B06을_안_가져옴():
|
||||
files = _drainage_files()
|
||||
assert len(files) >= 10
|
||||
for path in files:
|
||||
source = path.read_text(encoding="utf-8")
|
||||
for line in source.splitlines():
|
||||
if not line.lstrip().startswith(("import ", "} from ", "export {", "export type {")):
|
||||
continue
|
||||
assert "UI_Profile_" not in line, (path.name, line)
|
||||
assert "B06_Section" not in line, (path.name, line)
|
||||
|
||||
|
||||
def test_배수유역_파일은_700줄_이하():
|
||||
for path in _drainage_files():
|
||||
lines = len(path.read_text(encoding="utf-8").splitlines())
|
||||
assert lines <= 700, (path.name, lines)
|
||||
|
||||
|
||||
def test_켜고_끄는_창구():
|
||||
side = (B05 / "B05_Profile_UI_Drainage_Side.ts").read_text(encoding="utf-8")
|
||||
for name in (
|
||||
"createDrainageSide",
|
||||
"createDrainageToggleButton",
|
||||
"setVisible",
|
||||
"onVisibleChange",
|
||||
):
|
||||
assert name in side
|
||||
assert "root.hidden" in side or "panel.root.hidden" in side
|
||||
# 종단도 판 자리 약속(`DesignSideFactory` · design_profile.ts) 모양 그대로
|
||||
assert "DesignSideBoard" in side and "DesignSideHost" in side
|
||||
assert "export function createDrainageSide(" in side
|
||||
types = (B05 / "B05_Profile_UI_Drainage_Panel_Types.ts").read_text(encoding="utf-8")
|
||||
assert "export interface DrainagePipeReport" in types
|
||||
assert "onPipesChanged?: (pipes: DrainagePipeReport[]) => void;" in types
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def reports():
|
||||
proc = subprocess.run(
|
||||
["node", os.path.join(HERE, "helper_b05_drainage_report.cjs")],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
timeout=60,
|
||||
)
|
||||
assert proc.returncode == 0, proc.stderr
|
||||
return json.loads(proc.stdout)
|
||||
|
||||
|
||||
def test_관_보고_유역_매칭과_기본값(reports):
|
||||
first, box, plain = reports
|
||||
# 누가거리는 소수 둘째 자리로 · 담당 유역의 관경 · 설계유량이 붙는다.
|
||||
assert first["chainage_m"] == 40 and first["effective_diameter_mm"] == 800
|
||||
assert first["design_flow_m3s"] == 1.2 and first["facility"] == "pipe"
|
||||
assert first["source"] == "auto"
|
||||
# 세월교 검토 유역은 관경만 null · 설계유량은 남김 · 시설 정보를 데려옴.
|
||||
assert box["chainage_m"] == 100.5 and box["effective_diameter_mm"] is None
|
||||
assert box["design_flow_m3s"] == 3.4
|
||||
assert box["facility"] == "box_culvert" and (box["start_m"], box["end_m"]) == (95, 106)
|
||||
assert box["options"] == {"h": 2} and box["source"] == "user"
|
||||
# 유역 없는 관 — 모두 null · 맨 배관.
|
||||
assert plain["effective_diameter_mm"] is None and plain["design_flow_m3s"] is None
|
||||
assert plain["facility"] == "pipe"
|
||||
@@ -192,3 +192,24 @@ def test_되돌리기_다시하기가_현재_추천을_함께_되살림(results)
|
||||
{"logic": "earthwork"},
|
||||
None,
|
||||
]
|
||||
|
||||
|
||||
def test_노선_추천_다듬기_23_3():
|
||||
"""구분선은 [노선 추천] 오른쪽 · 모달 지도는 편집 창 함수를 가져다 씀 · 모달이 라벨 패널 위."""
|
||||
page = os.path.join(HERE, "..", "..", "B05_Profile")
|
||||
|
||||
def read(name):
|
||||
return open(os.path.join(page, name), encoding="utf-8").read()
|
||||
|
||||
chrome = read("B05_Profile_UI_RouteEdit_Chrome.ts")
|
||||
rec = chrome.index('data-act="recommend"')
|
||||
divider = chrome.index("b05-routeedit__divider", rec)
|
||||
assert rec < divider < chrome.index('data-act="contour-minor"')
|
||||
assert chrome.count("b05-routeedit__divider") == 2
|
||||
shared = read("B05_Profile_UI_RouteEdit_Recommend_Map.ts")
|
||||
for name in ("bindRouteEditNavigation", "createMapRotation", "fitCanvas", "metricToScreen"):
|
||||
assert f"{name}(" in shared
|
||||
css = read("B05_Profile_UI_Style_RouteRecommend.css")
|
||||
assert "calc(var(--z-modal, 1000) + 2)" in css # 라벨 패널(+1) 위
|
||||
assert "writing-mode: vertical-lr" in css
|
||||
assert 'backdropClass: "b05-routerec-backdrop"' in read("B05_Profile_UI_RouteEdit_Recommend.ts")
|
||||
|
||||
@@ -63,14 +63,36 @@ def test_창구_넷은_초안이_비면_요청을_안_낸다():
|
||||
|
||||
|
||||
def test_저장이_창구를_다섯_다_거친다():
|
||||
"""순서·개수가 바뀌면 위 성질의 뜻도 바뀐다 — 자리를 못박는다."""
|
||||
persist = (ROOT / "B06_Section" / "B06_Section_UI_Page_Persist.ts").read_text(encoding="utf-8")
|
||||
"""순서·개수가 바뀌면 위 성질의 뜻도 바뀐다 — 자리를 못박는다.
|
||||
|
||||
차례는 저장 한 줄기(`A00_Common/design/design_save.ts`) 한 곳에 있다(계획서 24-2a) —
|
||||
B05 [임시저장] · B06 [저장] · [확정]이 모두 그것을 탄다."""
|
||||
save = (ROOT / "A00_Common" / "design" / "design_save.ts").read_text(encoding="utf-8")
|
||||
for token in (
|
||||
"flushCulvertOptions()",
|
||||
"flushCulvertOptions(",
|
||||
"flushUphillOverrides(",
|
||||
"flushPendingPipes(",
|
||||
"flushPendingStructures(",
|
||||
):
|
||||
assert token in persist, f"{token} 가 저장 앞단에서 사라짐"
|
||||
# 하나가 실패해도 나머지가 나가야 한다 — 관·구조물은 `catch` 로 감싸 둔다.
|
||||
assert persist.count(".catch(") >= 3, "실패 격리(catch)가 줄었음"
|
||||
assert token in save, f"{token} 가 저장 한 줄기에서 사라짐"
|
||||
persist = (ROOT / "B06_Section" / "B06_Section_UI_Page_Persist.ts").read_text(encoding="utf-8")
|
||||
assert "ctx.flushCulvertOptions()" in persist, "B06 조정창 구간값이 저장에서 빠짐"
|
||||
# 하나가 실패해도 나머지가 나가야 한다 — 관·구조물·계획선은 `catch` 로 감싸 둔다.
|
||||
assert save.count(".catch(") >= 3, "실패 격리(catch)가 줄었음"
|
||||
order = save[save.index("DESIGN_SAVE_ORDER = [") :]
|
||||
order = order[: order.index("]")]
|
||||
names = ["pipes", "structures", "alignment", "culvertOptions", "uphill", "cross"]
|
||||
assert [order.index(f'"{n}"') for n in names] == sorted(order.index(f'"{n}"') for n in names), (
|
||||
"저장 차례가 바뀜 — 관 → 구조물 → 계획고 → 암거 옵션 → 오르막 → 횡단"
|
||||
)
|
||||
|
||||
|
||||
def test_두_페이지가_저장_한_줄기를_탄다():
|
||||
"""B05 [임시저장] · B06 [저장] · [확정]이 창구를 따로 부르지 않는다(계획서 24-2a)."""
|
||||
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} 가 저장 한 줄기를 안 탐"
|
||||
for token in ("flushPendingPipes(", "flushPendingStructures(", "flushUphillOverrides("):
|
||||
assert token not in text, f"{name} 가 {token} 를 따로 부름 — 차례가 갈림"
|
||||
assert "{ confirm: true }" in persist, "[확정]이 한 줄기를 안 탐"
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
|
||||
⇒ `saveCurrentSections`·`confirmCurrentSections` 가 `collectSectionEdits` **앞에서**
|
||||
`ctx.reconcileDesigns()` 를 부른다. 순서가 뒤집히면 옛 면적이 그대로 실린다.
|
||||
(계획서 24-2a — 둘 다 저장 한 줄기 `saveDesign` 을 탄다: B06 창구 `sectionSaveContext` 의
|
||||
`prepare` 가 재계산, `cross` 가 면적 수집이고, `saveDesign` 이 `prepare` 를 먼저 부른다.)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -17,6 +19,7 @@ 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"
|
||||
SAVE = ROOT / "A00_Common" / "design" / "design_save.ts"
|
||||
|
||||
|
||||
def _body(source: str, marker: str) -> str:
|
||||
@@ -30,11 +33,17 @@ def test_저장이_면적을_모으기_전에_다시_계산한다() -> None:
|
||||
"export async function saveCurrentSections",
|
||||
"export async function confirmCurrentSections",
|
||||
):
|
||||
body = _body(text, marker)
|
||||
assert "ctx.reconcileDesigns()" in body, f"{marker} 가 재계산을 안 부른다"
|
||||
assert body.index("ctx.reconcileDesigns()") < body.index("collectSectionEdits(ctx)"), (
|
||||
f"{marker}: 재계산이 면적 수집보다 뒤에 있다 — 옛 면적이 실린다"
|
||||
assert "saveDesign(sectionSaveContext(" in _body(text, marker)[:400], (
|
||||
f"{marker} 가 B06 창구로 저장 한 줄기를 안 탄다"
|
||||
)
|
||||
body = _body(text, "function sectionSaveContext")
|
||||
assert "prepare: () => ctx.reconcileDesigns()" in body, "B06 창구가 재계산을 안 넘긴다"
|
||||
assert "collectSectionEdits(ctx)" in body[body.index("cross:") :], "면적 수집이 횡단 차례 밖"
|
||||
save = SAVE.read_text(encoding="utf-8")
|
||||
run = _body(save, "export async function saveDesign")
|
||||
assert run.index("ctx.prepare?.()") < run.index("ctx.cross(confirm)"), (
|
||||
"저장 한 줄기가 재계산보다 면적 수집을 먼저 부른다 — 옛 면적이 실린다"
|
||||
)
|
||||
|
||||
|
||||
def test_페이지가_재계산_통로를_이어_준다() -> None:
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""설계 가게 한 곳 · 저장 한 줄기(계획서 24-2a) 회귀검증.
|
||||
|
||||
- 가게(`design_store`) — 밑은 등록 키 · 같은 칸이면 어느 가게든 같은 값 · 듣는 쪽.
|
||||
- 계획고 가게 한 벌 — B05 종단 패널 · B06 계획선 편집이 같은 벌을 씀(옛 두 가게 덮어쓰기 흠).
|
||||
- 저장 한 줄기(`saveDesign`) — 관 → 구조물 → 계획고 → 암거 옵션 → 오르막 → 횡단 · 관 · 구조물 ·
|
||||
계획고는 실패해도 다음 차례로.
|
||||
Node 헬퍼(`helper_design_store_save.cjs`)가 TS 를 트랜스파일해 돌린다.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
import pytest
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def out():
|
||||
proc = subprocess.run(
|
||||
["node", os.path.join(HERE, "helper_design_store_save.cjs")],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
timeout=60,
|
||||
)
|
||||
assert proc.returncode == 0, proc.stderr
|
||||
return json.loads(proc.stdout)
|
||||
|
||||
|
||||
def test_가게는_등록_키_밑_같은_칸은_같은_값(out):
|
||||
assert out["pipesShared"] == [{"chainage_m": 10, "source": "user"}]
|
||||
assert out["pipesKey"] == ["aislo:draft:pipes:p1"]
|
||||
assert out["heard"] == [1], "끊은 뒤에는 안 불려야 함"
|
||||
assert out["otherProject"] is None
|
||||
assert out["noRoute"] is None
|
||||
assert out["selection"] == {"chainageM": 12.5, "structureId": "s-1"}
|
||||
|
||||
|
||||
def test_계획고_가게_한_벌(out):
|
||||
assert out["b05SeesB06"] == {"20.000": 0.5}
|
||||
assert out["b05Dirty"] is True
|
||||
assert out["afterBoth"] == {"20.000": 0.5, "60.000": -0.2}
|
||||
assert out["draft"] == out["afterBoth"] == out["pending"]
|
||||
assert out["rebuiltSeenByB06"] == {"20.000": 0.1}
|
||||
|
||||
|
||||
def test_저장_차례(out):
|
||||
assert out["order"] == ["pipes", "structures", "alignment", "culvertOptions", "uphill", "cross"]
|
||||
assert out["saveOk"] is True
|
||||
assert out["saveCalls"] == [
|
||||
"overlay:on",
|
||||
"prepare",
|
||||
"pipes",
|
||||
"pipes:screen",
|
||||
"structures",
|
||||
'alignment:7:{"20.000":0.1}',
|
||||
"culvert:aislo:draft:culvertopt:p1:7",
|
||||
"uphill",
|
||||
"cross:false",
|
||||
"done:false",
|
||||
"overlay:off",
|
||||
]
|
||||
assert out["afterSave"] == {"dirty": False, "draft": None}
|
||||
|
||||
|
||||
def test_구조물_실패는_알리고_넘어감_확정_노선없음(out):
|
||||
calls = out["tolerant"]
|
||||
assert "toast:error" in calls
|
||||
assert calls.index("structures") < calls.index("toast:error") < calls.index("uphill:screen")
|
||||
assert "uphill" not in calls, "보기 몫이 기본 창구를 갈음"
|
||||
assert calls[-2:] == ["cross:true", "overlay:off"]
|
||||
assert "failed" not in calls
|
||||
assert out["noRouteSave"] is False and out["noRouteCalls"] == []
|
||||
|
||||
|
||||
def test_초기화는_초안과_한_벌을_버림(out):
|
||||
assert out["cleared"] == {"draft": None, "pending": None}
|
||||
@@ -113,6 +113,60 @@ def test_each_branch_best_at_own_goal(hill: Terrain) -> None:
|
||||
assert all(top <= r["metrics"]["max_grade_pct"] + 1e-6 for r in runs.values())
|
||||
|
||||
|
||||
# 비중 끝값 · 단조(PLAN 23-3e) — 예상 · 토공 · 기울기 한쪽 100 이면 그 갈래와 같은 선
|
||||
ENDS = {
|
||||
"follow": {"expected": 100, "earthwork": 0, "grade": 0},
|
||||
"earthwork": {"expected": 0, "earthwork": 100, "grade": 0},
|
||||
"gentle": {"expected": 0, "earthwork": 0, "grade": 100},
|
||||
}
|
||||
# 예상 100 → 0(나머지 반반) · 기울기 100 → 토공 100
|
||||
TOWARD_OTHERS = [
|
||||
(100, 0, 0),
|
||||
(90, 5, 5),
|
||||
(75, 12.5, 12.5),
|
||||
(50, 25, 25),
|
||||
(25, 37.5, 37.5),
|
||||
(0, 50, 50),
|
||||
]
|
||||
TOWARD_EARTH = [(0, 0, 100), (0, 25, 75), (0, 50, 50), (0, 75, 25), (0, 100, 0)]
|
||||
|
||||
|
||||
def _gap(first: dict, second: dict) -> float:
|
||||
return LineString(first["planned"]).hausdorff_distance(LineString(second["planned"]))
|
||||
|
||||
|
||||
def _check_weight_ends(run) -> None:
|
||||
for mode, weights in ENDS.items():
|
||||
end, own = run("weighted", weights), run(mode)
|
||||
assert _gap(end, own) < 0.01, (mode, _gap(end, own))
|
||||
assert end["nodes"] == own["nodes"]
|
||||
assert end["metrics"]["length_m"] == own["metrics"]["length_m"]
|
||||
|
||||
|
||||
def _check_weight_monotone(run) -> None:
|
||||
"""비중을 한쪽으로 옮기면 그 목표 값은 나빠지지 않음(후보가 같으니 가중합 최소의 성질)."""
|
||||
|
||||
def metrics(steps):
|
||||
keys = ("expected", "earthwork", "grade")
|
||||
return [run("weighted", dict(zip(keys, step)))["metrics"] for step in steps]
|
||||
|
||||
offsets = [m["offset_avg_m"] for m in metrics(TOWARD_OTHERS)]
|
||||
assert offsets == sorted(offsets), offsets
|
||||
earth = metrics(TOWARD_EARTH)
|
||||
cuts = [m["cut_m3"] + m["fill_m3"] for m in earth]
|
||||
tops = [m["max_grade_pct"] for m in earth]
|
||||
assert cuts == sorted(cuts, reverse=True), cuts
|
||||
assert tops == sorted(tops), tops
|
||||
|
||||
|
||||
def test_weight_ends_match_branches(hill: Terrain) -> None:
|
||||
_check_weight_ends(lambda mode, weights=None: _run(hill, mode, weights=weights))
|
||||
|
||||
|
||||
def test_weight_moves_monotone(hill: Terrain) -> None:
|
||||
_check_weight_monotone(lambda mode, weights=None: _run(hill, mode, weights=weights))
|
||||
|
||||
|
||||
def test_follow_stays_on_expected_where_allowed() -> None:
|
||||
"""고른 경사(2 %) — 추종은 예상노선을 그대로(격자 칸 반 안쪽)."""
|
||||
ramp = _terrain(lambda xx, yy: 0.02 * xx)
|
||||
@@ -301,18 +355,19 @@ def project_runs() -> dict:
|
||||
streams = stream_lines(_reproject_features(features, to_metric))
|
||||
route = read_planned_route_csv(expected_route_csv_path(PROJECT))
|
||||
expected = [(float(v.x), float(v.y)) for v in route.vertices]
|
||||
return {
|
||||
mode: generate_initial_route(
|
||||
|
||||
def run(mode: str, weights: dict | None = None) -> dict:
|
||||
return generate_initial_route(
|
||||
expected,
|
||||
terrain,
|
||||
streams,
|
||||
mode=mode,
|
||||
weights=None,
|
||||
weights=weights,
|
||||
max_grade=GRADE,
|
||||
min_radius_m=RADIUS,
|
||||
)
|
||||
for mode in MODES
|
||||
}
|
||||
|
||||
return {**{mode: run(mode) for mode in MODES}, "run": run}
|
||||
|
||||
|
||||
def test_project_follow_is_preprocess_initial(project_runs: dict) -> None:
|
||||
@@ -331,7 +386,7 @@ def test_project_follow_is_preprocess_initial(project_runs: dict) -> None:
|
||||
|
||||
def test_project_branches_differ_by_goal(project_runs: dict) -> None:
|
||||
"""갈래끼리 선이 수 m 이상 갈리고 · 각 갈래가 제 목표 비교값에서 가장 좋음."""
|
||||
searched = {mode: project_runs[mode] for mode in SEARCHED}
|
||||
searched = {mode: project_runs[mode] for mode in SEARCHED} # 비중은 50/25/25
|
||||
for first in SEARCHED:
|
||||
for second in SEARCHED:
|
||||
if first < second:
|
||||
@@ -344,3 +399,13 @@ def test_project_branches_differ_by_goal(project_runs: dict) -> None:
|
||||
assert all(top <= r["metrics"]["max_grade_pct"] for r in searched.values())
|
||||
for result in searched.values():
|
||||
assert result["metrics"]["over_limit"]["total"] == len(result["metrics"]["violations"])
|
||||
|
||||
|
||||
def test_project_weight_ends_match_branches(project_runs: dict) -> None:
|
||||
"""랩탑_보조 — 비중 100/0/0 = 추적(1078.01 m) · 0/100/0 = 토공 최소 · 0/0/100 = 기울기 순한."""
|
||||
_check_weight_ends(project_runs["run"])
|
||||
assert project_runs["run"]("weighted", ENDS["follow"])["metrics"]["length_m"] == 1078.01
|
||||
|
||||
|
||||
def test_project_weight_moves_monotone(project_runs: dict) -> None:
|
||||
_check_weight_monotone(project_runs["run"])
|
||||
|
||||
@@ -144,6 +144,9 @@ export const ui_locales_b2 = {
|
||||
이름이 혼선을 줬다 — [저장]으로 바꾼다(2026-08-28 사용자 지시). */
|
||||
B05_Route_Btn_TempSave: ["저장", "Save"],
|
||||
B05_Route_Btn_GoCross: ["횡단 이동", "Go to Cross"],
|
||||
/* 좌측 패널 아래 단추 [보기 전환] — 가려는 쪽 이름(계획서 24-2b). */
|
||||
Design_Btn_ToCross: ["횡단 보기", "Cross view"],
|
||||
Design_Btn_ToProfile: ["종단 보기", "Profile view"],
|
||||
B05_Route_TempSave_Success: ["변경 내용을 저장했습니다.", "Changes saved."],
|
||||
B05_Route_TempSave_Failed: ["저장에 실패했습니다.", "Failed to save."],
|
||||
B05_Route_Reset_Confirm: [
|
||||
|
||||
Reference in New Issue
Block a user