Files
Aislo/A00_Common/design/design_shared.ts
T
eomsangdonandClaude Opus 5.5 ec0bb044c2 feat(365-4): 한 바퀴 정리 — 저장 노선 계획고 = 횡단 한 벌 · B06 다시 그림 한 번
- 저장 노선 cross = 다시 셈 창구 한 벌(refreshCrossDesigns · 카드 · 유토곡선 · [저장] 패치가 보는 상세 설계) — 묶음 cross 가 부르고 그 설계를 냄(detailCross) · 종단도 계획고 편집 뒤 따로 예약 안 함(pipelineOwnsCross)
- B06 다시 그림 — 묶음 차례는 cross 뒤에만 · 겹친 신호(갈아끼움 · 묶음 · 초안)는 refreshDetailForStructures 한 번(최신 차례만)
- 중심점 좌우 = 노선 차례가 cross 전에 밀림 → cross 한 번(계획고 맞춤 차례 · 시험으로 못박음)
- stationDiff → logic/design_pipeline_stations(700줄)
- 시험 test_365(저장 ▲▼ 다시 셈 1 · 상세 설계 그대로 · 중심점 좌우 cross 1) · 346-2 pills · 230-2 고침

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TteK8mfYZetk5P3bEZkyVZ
2026-10-17 12:28:14 +09:00

390 lines
19 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 { splitSidebarActions } from "@ui/ui_template_overlay";
import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
import { goToWorkflowStage, WORKFLOW_STEP_ROUTES } from "../b_workflow_nav";
import { currentRoute, navigateTo, remountRoute } from "../router";
import { clearRouteLatestCache } from "../../B05_Profile/B05_Profile_Api_Fetch";
import { fetchCurrentRouteId } from "../../B06_Section/B06_Section_Api_Fetch";
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 } from "../../B05_Profile/B05_Profile_UI_Profile_SideTool";
import { createDesignPanel, type DesignPanel } from "./design_panel";
import { createDesignProfile, type DesignProfile } from "./design_profile";
import { attachProfilePopout, attachSubPanelPopouts } from "./design_profile_popout";
import { saveDesign, type DesignSaveStep } from "./design_save";
import { markDesignClean } from "./design_leave";
import { resetDesign } from "./design_reset";
import type { DesignView } from "./design_dock";
import { routeDraftBridge, type PagePipelineOptions } from "./design_pipeline_page";
import { SECTION_STATION_INTERVAL_M } from "../../B05_Profile/logic/B05_Profile_Sections_Stations";
import {
applyStructureAreaRows,
structureAreaRows,
} from "../../B06_Section/B06_Section_Structure_Layouts";
import {
holdSectionDetail,
restoreSectionDetail,
swapSectionStations,
} from "../../B06_Section/B06_Section_Section_Store";
import { refreshCrossDesigns } from "../../B06_Section/B06_Section_Cross_Refresh";
import { detailCross } from "../../B05_Profile/logic/B05_Profile_Pipeline_Detail";
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;
/** 계산 묶음 알림 — 묶음은 페이지가 세움(365-1) · 보기는 알림만 받음. */
pipeline?: Pick<PagePipelineOptions, "onCorridorStale" | "onFacilitiesFollowed">;
}
/** 한 벌 자리 — 페이지(`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 popoutButton = document.createElement("button");
const profile = createDesignProfile({
projectId: shared.projectId,
view: shared.view(),
side: drainageSide,
toolLead: popoutButton,
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"),
structureGroups: () => sharedStructures(shared).pickGroups(),
onBasinSelected: toView(shared, "onBasinSelected"),
onPipeSelected: toView(shared, "onPipeSelected"),
},
});
shared.profileSlot.append(profile.root);
attachProfilePopout(profile, shared.profileSlot, popoutButton);
attachSubPanelPopouts(profile);
shared.profile = profile;
if (shared.projectId) startPagePipeline(shared, profile);
return profile;
}
/** 계산 묶음 한 벌(365-1) — 종단도와 함께 페이지가 세움 · B05 · B06 어느 쪽으로 들어와도(새로고침도) 노선
* 초안을 셈. 편집기는 `routeDraftBridge` 로 이 다리를 받아 씀 · 보기 몫 알림은 `pipeline` 바인딩으로. */
function startPagePipeline(shared: DesignPageShared, profile: DesignProfile): void {
const projectId = shared.projectId;
const each = (fn: (hooks: NonNullable<DesignViewBinding["pipeline"]>) => void): void =>
Object.values(shared.bindings).forEach((binding) => binding?.pipeline && fn(binding.pipeline));
routeDraftBridge(projectId, profile, {
stationIntervalM: SECTION_STATION_INTERVAL_M,
onCorridorStale: () => each((hooks) => hooks.onCorridorStale?.()),
onFacilitiesFollowed: (far) => each((hooks) => hooks.onFacilitiesFollowed?.(far)),
onUnstable: () => showToast(L("Design_Pipeline_Unstable"), "warning"),
// 저장 노선 횡단 = 다시 셈 창구 한 벌(365-4) — 카드 · 유토곡선 · [저장] 패치가 보는 상세 설계를 고치고 그 값을
// 묶음 출력으로(발자국 · 평면 횡단 판) · 측점이 상세와 다르면(갈아끼움 전) 묶음 엔진
savedCross: async (detail, routeId, stations) => {
if (!detailCross(detail, stations)) return null;
const edits = profile.pipelineSource().edits;
const updated = await refreshCrossDesigns({ projectId, routeId, detail, edits });
if (updated.length) toAll(shared, "onCrossDesignsUpdated")(updated);
profile.redraw();
return detailCross(detail, stations);
},
structureAreas: ({ cross_sections: rows }) =>
applyStructureAreaRows(rows, structureAreaRows(rows)),
// 초안 시작 — 초안 전 상태를 떠 둠 · 버리면 그대로(측점이 달라도 · 349-0o) · 상세가 없으면 묶음 측점으로 갈아끼움
holdDraft: (routeId, saved) => {
const hold = saved && holdSectionDetail(projectId, routeId, saved);
return async (stations) => {
if (!hold) {
await swapSectionStations(projectId, routeId, stations);
return "no-saved";
}
const back = await restoreSectionDetail(projectId, routeId, hold, stations);
return !back ? "no-cache" : back.same ? "saved" : "saved-diff";
};
},
});
}
/** 「구조물 배치」 폼 한 벌 — 알림은 지금 보기로, 상세 옵션은 횡단 보기에서만 그린다. */
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),
onListRelease: () => on().onListRelease?.(),
onReveal: () => shared.revealLeft(),
},
{ includeDetail: () => shared.view() === "cross", projectId: shared.projectId },
);
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);
// 틀이 먼저 섰으면 좌측 자리가 빈 채로 지나갔다 — 붙인 지금 [스크롤 칸][아래 고정 단추]로 쪼갠다.
const body = shared.left.closest<HTMLElement>(".ui-workflow-overlay__body");
if (body) splitSidebarActions(body);
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 },
});
}
/**
* 화면이 쥔 노선이 서버의 지금 노선과 다른가 — 다르면 아무것도 보내지 않고 페이지를 새로 세운다.
* 노선 [확인] · 다른 탭으로 노선이 갈렸는데 옛 노선 id 로 저장이 나가 옛 노선을 다시 계산했다
* (PLAN 76-1). 묻기가 실패하면 그대로 저장한다 — 서버도 옛 노선 저장은 409 로 거절한다.
*/
async function routeChanged(shared: DesignPageShared): Promise<boolean> {
const shown = routeIdOf(shared);
if (shown === null) return false;
const current = await fetchCurrentRouteId(shared.projectId).catch(() => shown);
if (current === shown) return false;
showToast(L("B06_Profile_Route_Changed"), "error");
clearRouteLatestCache(shared.projectId);
remountRoute(currentRoute());
return true;
}
/**
* [저장] · [확정] 한 번 — 관(배수유역도) · 계획고는 한 벌 종단도가, 구조물 · 상단측은 종단 보기가,
* 저장 앞 재계산 · 암거 옵션 · 횡단 본문은 횡단 보기가 낸다. 차례는 `saveDesign` 한 곳.
*/
export async function saveSharedDesign(shared: DesignPageShared, confirm: boolean): Promise<void> {
if (await routeChanged(shared)) return;
// [확정]은 횡단 본문(표준 횡단면 · 전 측점 면적)이 있어야 한다 — 숨긴 채라도 횡단 보기를 세운다.
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(confirm);
},
}
: {}),
// 계획고 초안 — 한 벌 종단도가 저장하고 다시 그린다.
...(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) => {
markDesignClean(shared.projectId); // 떠남 확인 — 지금 초안이 저장분(346-4)
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 },
);
}