Files
Aislo/A00_Common/design/design_shared.ts
T
eomsangdonandClaude Sonnet 5.5 45a22c5d72 fix(B05·B06): 저장 전 관 옵션 초안이 새로고침 뒤 폼 · 카드에 살아남음(PLAN 24-7a)
원인 — 횡단 캐시 상세(section-detail)는 저장분이라 새로고침 뒤 월류 폭 · 관경 같은 스펙 값이
저장값으로 돌아오고, 폼(withSpecDefaults)은 스펙을 초안 옵션보다 앞세워 초안 3.5 대신 10 을 보임.
또 횡단 보기가 먼저 선 뒤 종단 보기가 서면 관 목록을 못 받은 채 구조물 로딩이 관을 지워 초안 옵션이 사라짐.

- 횡단 보기가 서며 관 초안 옵션을 캐시 스펙에 다시 얹음(조정창 정본인 기슭막이 높이 · 형태는 제외)
- 배수유역도가 마지막으로 알린 관 목록을 한 벌 자리에 기억해 뒤에 서는 종단 보기가 먼저 실음
- 시험 test_b06_draft_pipe_replay 1건

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ly2oHAaTDYRjcniAmHR81z
2026-09-29 23:33:57 +09:00

305 lines
14 KiB
TypeScript

/* =============================================================================
* design_shared.ts
* B05 · B06 한 페이지의 **한 벌 자리**(계획서 24-3b) — 좌측 패널(칸 · 아래 단추 넷) · 종단도 ·
* 「구조물 배치」 폼을 페이지에 **한 번만** 만들고 두 보기가 같은 노드를 함께 쓴다.
*
* 한 벌이 받은 사용자 조작(종단도 측점 고름 · 구조물 폼 · 우클릭)은 **지금 보이는 보기**가
* `bindDesignView` 로 건 동작으로 간다. 값이 바뀐 알림(관 목록 · 횡단 재계산)은 숨은 보기도
* 받는다 — 다시 보일 때 같은 값을 들고 있게.
* [저장] · [확정]은 두 보기 몫을 모아 저장 한 줄기(`saveDesign`) 한 번으로 보낸다. 종단 보기의
* [확정]은 횡단 보기를 숨긴 채 세워 횡단 자료를 같은 페이지에서 얻는다(`ensureView`).
* ========================================================================== */
import { ROUTES } from "@config/config_frontend";
import { showToast } from "@ui/ui_template_elements";
import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
import { goToWorkflowStage, WORKFLOW_STEP_ROUTES } from "../b_workflow_nav";
import { navigateTo } from "../router";
import { createStructuresSection, type StructuresSection } from "../b_structures_section";
import type { StructuresCallbacks } from "../../B05_Profile/B05_Profile_UI_Structures_Panel_Types";
import type { RouteProfilePanelCallbacks } from "../../B05_Profile/B05_Profile_UI_Profile_Panel_Types";
import { drainageSide, drainageSideTool } from "../../B05_Profile/B05_Profile_UI_Profile_SideTool";
import { createDesignPanel, type DesignPanel } from "./design_panel";
import { createDesignProfile, type DesignProfile } from "./design_profile";
import { saveDesign, type DesignSaveStep } from "./design_save";
import { resetDesign } from "./design_reset";
import type { DesignView } from "./design_dock";
function L(key: keyof typeof ui_locales): string {
return ui_locales[key][currentLanguageIndex];
}
/** 종단도가 보기에게 알리는 것 — 측점 고름 · [초기선 복원] · 우클릭 · 끌기 · 판(배수유역도). */
export interface DesignProfileBinding {
onSelectStation: (stationId: string | null) => void;
onResetAll?: () => void;
callbacks?: RouteProfilePanelCallbacks;
}
/** 저장 한 줄기에 보기가 보태는 몫(계획고 · 관은 한 벌 종단도가 낸다). */
export interface DesignViewSave {
routeId: () => number | null;
prepare?: () => Promise<void>;
steps?: Partial<
Record<Exclude<DesignSaveStep, "cross" | "pipes" | "alignment">, () => Promise<void>>
>;
cross?: (confirm: boolean) => Promise<void>;
/** 저장 뒤 이 보기만의 캐시 비우기 · 다시 읽기(알림 · 다음 단계는 여기서 한다). */
done?: (confirm: boolean) => Promise<void> | void;
}
export interface DesignViewBinding {
profile?: DesignProfileBinding;
/** 「구조물 배치」 폼 알림 — 그 보기가 보일 때만 받는다. */
structures?: Partial<StructuresCallbacks>;
save?: DesignViewSave;
/** [초기화] 때 이 보기만 아는 조작 캐시를 비운다. */
onReset?: () => void;
/** 다른 보기가 고친 관 옵션을 이 보기 캐시(횡단 스펙)에 얹는다. */
onPipeOptions?: (chainageM: number, patch: Record<string, number | string>) => void;
}
/** 한 벌 자리 — 페이지(`design_page`)가 만들어 두 보기에 같은 객체로 넘긴다. */
export interface DesignPageShared {
projectId: string;
/** 좌측 패널 자리(아래 단추 넷은 좌측 패널 안). */
left: HTMLElement;
/** 가운데 아래 종단도 자리. */
profileSlot: HTMLElement;
/** 지금 보이는 보기. */
view: () => DesignView;
/** 좌측 칸 부품 — 두 보기가 제 몫을 넣고, 칸 정의가 이름으로 꺼낸다. */
parts: Record<string, unknown>;
bindings: Partial<Record<DesignView, DesignViewBinding>>;
/** 먼저 쓰는 쪽이 만든다 — 뒤 보기는 있는 것을 쓴다. */
panel: DesignPanel | null;
profile: DesignProfile | null;
structures: StructuresSection | null;
/** 배수유역도가 마지막으로 알린 관 목록 — 뒤늦게 서는 보기가 처음부터 같은 관 목록을 쥔다(24-7a). */
lastPipes?: Parameters<NonNullable<RouteProfilePanelCallbacks["onPipesChanged"]>>[0];
/** 횡단 보기가 서기 전에 종단 보기가 고친 관 옵션 — 횡단 보기가 서면 캐시에 얹는다. */
pipeOptionQueue: Array<[number, Record<string, number | string>]>;
/** 접힌 좌측 패널을 편다(바깥에서 고른 것이 폼에 실릴 때). */
revealLeft: () => void;
/** 그 보기를 (숨긴 채라도) 세우고 자료가 설 때까지 기다린다. */
ensureView: (view: DesignView) => Promise<void>;
}
const bound = (shared: DesignPageShared): DesignViewBinding => shared.bindings[shared.view()] ?? {};
/** 보기가 제 몫을 건다 — 같은 보기가 다시 걸면 덮는다. */
export function bindDesignView(
shared: DesignPageShared,
view: DesignView,
binding: DesignViewBinding,
): void {
shared.bindings[view] = { ...shared.bindings[view], ...binding };
}
/**
* 종단 보기 폼에서 고친 관 옵션을 횡단 캐시에도 얹는다 — 세월교 · BOX 폼 값은 횡단 스펙(캐시)이
* 정본이라, 안 얹으면 한 벌 폼이 횡단 보기로 가는 순간 옛 값으로 돌아간다. 횡단 보기가 아직
* 안 섰으면 담아 두었다가 설 때 얹는다.
*/
export function forwardPipeOptions(
shared: DesignPageShared,
chainageM: number,
patch: Record<string, number | string> | undefined,
): void {
if (!patch || !Object.keys(patch).length) return;
const apply = shared.bindings.cross?.onPipeOptions;
if (apply) apply(chainageM, patch);
else shared.pipeOptionQueue.push([chainageM, patch]);
}
/** 횡단 보기가 캐시 창구를 건다 — 담아 둔 옵션을 먼저 얹는다. */
export function bindPipeOptions(
shared: DesignPageShared,
apply: (chainageM: number, patch: Record<string, number | string>) => void,
): void {
bindDesignView(shared, "cross", { onPipeOptions: apply });
shared.pipeOptionQueue.splice(0).forEach(([chainageM, patch]) => apply(chainageM, patch));
}
/** 지금 보기의 종단도 알림으로 보낸다. */
function toView<K extends keyof RouteProfilePanelCallbacks>(
shared: DesignPageShared,
name: K,
): NonNullable<RouteProfilePanelCallbacks[K]> {
return ((...args: unknown[]) =>
(bound(shared).profile?.callbacks?.[name] as ((...a: unknown[]) => void) | undefined)?.(
...args,
)) as NonNullable<RouteProfilePanelCallbacks[K]>;
}
/** 두 보기 모두에 보낸다 — 값이 바뀐 알림. */
function toAll<K extends keyof RouteProfilePanelCallbacks>(
shared: DesignPageShared,
name: K,
): NonNullable<RouteProfilePanelCallbacks[K]> {
return ((...args: unknown[]) =>
Object.values(shared.bindings).forEach((binding) =>
(binding?.profile?.callbacks?.[name] as ((...a: unknown[]) => void) | undefined)?.(...args),
)) as NonNullable<RouteProfilePanelCallbacks[K]>;
}
/** 종단도 한 벌 — 처음 부를 때 만들어 종단도 자리에 붙인다. */
export function sharedProfile(shared: DesignPageShared): DesignProfile {
if (shared.profile) return shared.profile;
const profile = createDesignProfile({
projectId: shared.projectId,
view: shared.view(),
side: drainageSide,
sideTool: drainageSideTool,
onSelectStation: (stationId) => bound(shared).profile?.onSelectStation(stationId),
onResetAll: () => bound(shared).profile?.onResetAll?.(),
callbacks: {
onPipesChanged: (pipes) => {
shared.lastPipes = pipes;
toAll(shared, "onPipesChanged")(pipes);
},
onCrossDesignsUpdated: toAll(shared, "onCrossDesignsUpdated"),
onStructureMove: toView(shared, "onStructureMove"),
onStructureRemove: toView(shared, "onStructureRemove"),
onPipeAdd: toView(shared, "onPipeAdd"),
onStructureAdd: toView(shared, "onStructureAdd"),
onIrregularSelect: toView(shared, "onIrregularSelect"),
onStructureSelect: toView(shared, "onStructureSelect"),
onStructureMarkMove: toView(shared, "onStructureMarkMove"),
onStructureTypeAdd: toView(shared, "onStructureTypeAdd"),
onBasinSelected: toView(shared, "onBasinSelected"),
onPipeSelected: toView(shared, "onPipeSelected"),
},
});
shared.profileSlot.append(profile.root);
shared.profile = profile;
return profile;
}
/** 「구조물 배치」 폼 한 벌 — 알림은 지금 보기로, 상세 옵션은 횡단 보기에서만 그린다. */
export function sharedStructures(shared: DesignPageShared): StructuresSection {
if (shared.structures) return shared.structures;
const on = (): Partial<StructuresCallbacks> => bound(shared).structures ?? {};
shared.structures = createStructuresSection(
{
onChange: (next) => on().onChange?.(next),
onSelect: (structure) => on().onSelect?.(structure),
getInterval: () => on().getInterval?.() ?? 20,
onPipeAdd: (chainageM, attributes) => on().onPipeAdd?.(chainageM, attributes),
onPipeUpdate: (from, to, attributes) => on().onPipeUpdate?.(from, to, attributes),
onPipeRemove: (chainageM) => on().onPipeRemove?.(chainageM),
onPipeSelect: (chainageM) => on().onPipeSelect?.(chainageM),
onReveal: () => shared.revealLeft(),
},
{ includeDetail: () => shared.view() === "cross" },
);
shared.parts.structures = shared.structures;
return shared.structures;
}
/** 지금 두 보기가 아는 노선 — 횡단 보기가 먼저(확정 본문이 그쪽이다). */
function routeIdOf(shared: DesignPageShared): number | null {
return shared.bindings.cross?.save?.routeId() ?? shared.bindings.profile?.save?.routeId() ?? null;
}
/** 좌측 패널 한 벌 — 부품은 두 보기가 `shared.parts` 에 넣고, 칸은 보기가 처음 보일 때 선다. */
export function sharedPanel(shared: DesignPageShared): DesignPanel {
if (shared.panel) return shared.panel;
const other = (): DesignView => (shared.view() === "profile" ? "cross" : "profile");
const panel = createDesignPanel(
{
projectId: shared.projectId,
parts: shared.parts,
dock: {
reset: {
run: () =>
resetDesign(shared.projectId, routeIdOf(shared), () =>
Object.values(shared.bindings).forEach((binding) => binding?.onReset?.()),
),
},
// 자료가 서기 전에는 잠근다 — 보기가 `designReady` 로 연다.
save: { run: () => saveSharedDesign(shared, false), enabled: false },
confirm: { run: () => saveSharedDesign(shared, true), enabled: false },
// [보기 전환] = 주소만 바꾼다 — 같은 페이지라 라우터가 보기만 바꾼다(다시 안 만듦).
switchView: {
run: () => navigateTo(other() === "profile" ? ROUTES.B05_PROFILE : ROUTES.B06_SECTION),
},
},
},
shared.view(),
);
shared.left.append(panel.root);
shared.panel = panel;
return panel;
}
/** 자료가 섰다 — [저장] · [확정]을 연다(두 보기 어느 쪽이 먼저든 한 번이면 된다). */
export function designReady(shared: DesignPageShared): void {
shared.panel?.dock.setActions({
save: {
run: () => saveSharedDesign(shared, false),
enabled: true,
title: L("B06_Profile_Btn_Save_Tip"),
},
confirm: { run: () => saveSharedDesign(shared, true), enabled: true },
});
}
/**
* [저장] · [확정] 한 번 — 관(배수유역도) · 계획고는 한 벌 종단도가, 구조물 · 상단측은 종단 보기가,
* 저장 앞 재계산 · 암거 옵션 · 횡단 본문은 횡단 보기가 낸다. 차례는 `saveDesign` 한 곳.
*/
export async function saveSharedDesign(shared: DesignPageShared, confirm: boolean): Promise<void> {
// [확정]은 횡단 본문(표준 횡단면 · 전 측점 면적)이 있어야 한다 — 숨긴 채라도 횡단 보기를 세운다.
if (confirm) await shared.ensureView("cross");
const profileSave = shared.bindings.profile?.save;
const crossSave = shared.bindings.cross?.save;
if (confirm && !crossSave?.cross) {
showToast(L("B06_Profile_Confirm_Failed"), "error");
return;
}
const profile = shared.profile;
const side = profile?.side;
await saveDesign(
{
projectId: shared.projectId,
routeId: () => routeIdOf(shared),
prepare: crossSave?.prepare,
steps: {
// 세션 초안이 나간 뒤 배수유역도 목록으로 한 번 더 맞춘다(시설 되붙이기 · 응답으로 화면 갱신).
...(side
? {
pipes: async () => {
await side.savePipes();
},
}
: {}),
// 계획고 초안 — 한 벌 종단도가 저장하고 다시 그린다.
...(profile ? { alignment: () => profile.save() } : {}),
...profileSave?.steps,
...crossSave?.steps,
},
cross: async (isConfirm) => {
if (crossSave?.cross) await crossSave.cross(isConfirm);
else await profileSave?.cross?.(isConfirm);
},
done: async (isConfirm) => {
if (isConfirm) {
showToast(L("B06_Profile_Confirm_Success"), "success");
goToWorkflowStage(shared.projectId, WORKFLOW_STEP_ROUTES[4]);
return;
}
await profileSave?.done?.(false);
await crossSave?.done?.(false);
showToast(L("B06_Profile_Save_Success"), "success");
},
failed: (error, isConfirm) => {
const key = isConfirm ? "B06_Profile_Confirm_Failed" : "B06_Profile_Save_Failed";
const detail = error instanceof Error ? ` ${error.message}` : "";
showToast(`${L(key)}${detail}`, "error");
},
},
{ confirm },
);
}