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);
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user