Files
Aislo/A00_Common/design/design_shared.ts
T

429 lines
21 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 { plantedBase } from "./design_pipeline_base";
import { isUserPick } from "./design_user_pick";
import { SECTION_STATION_INTERVAL_M } from "../../B05_Profile/logic/B05_Profile_Sections_Stations";
import {
applyStructureAreaRows,
structureAreaRows,
} from "../../B06_Section/B06_Section_Structure_Layouts";
import { withDraftAdjusts } from "../../B06_Section/B06_Section_Section_Adjusts";
import {
holdSectionDetail,
invalidateSectionDetail,
restoreSectionDetail,
swapSectionStations,
} from "../../B06_Section/B06_Section_Section_Store";
import {
refreshCrossDesigns,
type CrossRefreshInput,
} from "../../B06_Section/B06_Section_Cross_Refresh";
import { bermSpansFromStructures, bermSpecAt } from "../../B06_Section/B06_Section_UI_Page_Persist";
import { detailCross } from "../../B05_Profile/logic/B05_Profile_Pipeline_Detail";
import type { CrossOutput, PipelineStation } from "./logic/design_pipeline_types";
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]>;
}
/** 횡단 설계가 다시 셈됨 — 두 보기에 알리고 좌측 시설 폼 자동 칸(관 길이 · 흙막이 수)을 공용 캐시 새 값으로 다시
* 그림(416 · 관을 옮긴 뒤 측점 횡단이 늦게 서도 B05 좌측 = B06 카드). */
function crossDesignsUpdated(shared: DesignPageShared, updated: ReadonlyArray<number>): void {
toAll(shared, "onCrossDesignsUpdated")(updated);
shared.structures?.facility.recount();
}
/** 종단도 한 벌 — 처음 부를 때 만들어 종단도 자리에 붙인다. */
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: (updated) => crossDesignsUpdated(shared, updated),
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-5 · 저장 노선은 다시 셈 창구가 세션 사본으로)
bermAt: (structures) => {
const spans = bermSpansFromStructures(Array.isArray(structures) ? structures : []);
return (chainageM) => bermSpecAt(spans, chainageM);
},
// 저장 노선 횡단 = 다시 셈 창구 한 벌(365-4) — 카드 · 유토곡선 · [저장] 패치가 보는 상세 설계를 늘 고치고 그
// 값을 묶음 출력으로(발자국 · 평면 횡단 판) · 측점이 상세와 다르면(갈아끼움 전) 묶음 엔진(439)
savedCross: (detail, routeId, stations) =>
refreshSavedCross(
{ projectId, routeId, detail, edits: profile.pipelineSource().edits },
stations,
(updated) => {
if (updated.length) crossDesignsUpdated(shared, updated);
profile.redraw();
},
),
plantedBase, // 저장 노선 종단 기준 = 입력이 심는 측점(386-3 · 보기 심기 무관)
structureAreas: (detail) => {
// 초안 상세에도 저장 안 한 조절(옹벽 · 세월 · BOX · 집수 · 417 M3) — 면적 얹기 앞
const routeId = routeIdOf(shared);
if (routeId !== null) withDraftAdjusts(detail, projectId, routeId);
const rows = detail.cross_sections;
applyStructureAreaRows(rows, structureAreaRows(rows));
},
// 초안 시작 — 초안 전 상태를 떠 둠 · 버리면 그대로(측점이 달라도 · 349-0o) · 상세가 없으면 묶음 측점으로 갈아끼움
holdDraft: (routeId, saved, seed) => {
const hold = saved && holdSectionDetail(projectId, routeId, saved);
if (hold) seed?.(hold.facilities); // 초안 전 관 · 구조물(440-4)
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";
};
},
});
}
/**
* 저장 노선 cross 차례(387-4 · 439) — 카드 자료(상세 설계)는 **늘** 다시 셈 · 묶음에 돌려줄 값만 측점 대조
* (상세 측점 ≠ 묶음 측점이면 null = 묶음 엔진). 대조가 앞서면 1 mm 어긋남에 카드 단추가 안 먹었다(요구 266).
*/
export async function refreshSavedCross(
input: CrossRefreshInput,
stations: ReadonlyArray<PipelineStation>,
onUpdated: (updated: number[]) => void,
): Promise<CrossOutput | null> {
onUpdated(await refreshCrossDesigns(input));
return detailCross(input.detail, stations);
}
/** 「구조물 배치」 폼 한 벌 — 알림은 지금 보기로, 상세 옵션은 횡단 보기에서만 그린다. */
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: () => isUserPick(shared.projectId) && shared.revealLeft(), // 사용자 고름만(398-1)
},
{ 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;
// [저장] · [확정] 본문 = 횡단 보기 한 벌(표준 횡단면 · 전 측점 면적 · 카드 고침) — 들어온 화면 무관 · 숨긴 채라도
// 세움(B05 만 연 캐시 패치 길 지움 · 386-6)
await shared.ensureView("cross");
const profileSave = shared.bindings.profile?.save;
const crossSave = shared.bindings.cross?.save;
const crossBody = crossSave?.cross;
if (!crossBody) {
showToast(L(confirm ? "B06_Profile_Confirm_Failed" : "B06_Profile_Save_Failed"), "error");
return;
}
const profile = shared.profile;
const side = profile?.side;
await saveDesign(
{
projectId: shared.projectId,
routeId: () => routeIdOf(shared),
prepare: crossSave?.prepare,
steps: {
// 관 정본 PUT 은 세션 초안 한 번(기본 창구) — 배수유역도는 저장분을 다시 받아 화면만 맞춤(433-7 · 판
// 메모리를 다시 보내면 B06 고침 직후 · 판이 안 선 [저장]에서 관을 잃음).
...(side ? { pipes: async () => side.reload(confirm) } : {}),
// 계획고 초안 — 한 벌 종단도가 저장하고 다시 그린다.
...(profile ? { alignment: () => profile.save() } : {}),
...profileSave?.steps,
...crossSave?.steps,
},
cross: crossBody,
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;
}
// 서버가 정본(계획선 · 횡단 · 면적)을 다시 썼다 — 공유 캐시를 한 번 비우고 두 보기가 새 상세 한 객체를
// 받음(416 · 보기마다 비우면 앞 보기가 받은 새 상세를 뒤 보기가 또 버림)
invalidateSectionDetail(shared.projectId);
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 },
);
}