- design_save: 노선 초안이 없으면 saveOneBody — 차례마다 조각(본문 · 저장 뒤 정리) · 서버가 다 저장한 뒤에만 정리(초안 비움 · 화면 맞춤) · 실패 = 차례 이름째 「아무것도 저장 안 함」 · 되돌림 실패 = 「복구 확인 필요」 · 기다림 알림 - 조각 함수: 페이지 설정 · 관 · 구조물(보기 목록) · 추가 측점 · 계획고(한 벌 종단도) · 관 옵션 · 상단측 · 횡단(횡단 보기) — 옛 flush 함수는 노선 바뀐 저장 길에 그대로(조각을 써서 같은 본문) - 보기 몫 steps · cross → parts · crossPart · 노선 바뀐 저장 길은 옛 차례 그대로(6-B) - 옛 9 차례를 단언하던 시험 10 개를 한 본문 꼴로(옛 PUT 줄 → SAVE 줄 · 조각 정리 차례) · 위키 [저장] 표 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
299 lines
16 KiB
TypeScript
299 lines
16 KiB
TypeScript
/* =============================================================================
|
|
* B05_Profile_UI_Page_Structures.ts
|
|
* 구조물·계곡 통과 시설을 화면 세 곳(사이드 목록·종단 그래프·3D)에 맞추는 다리.
|
|
*
|
|
* 화면 본체(B05_Profile_UI_Page)가 700줄 한계에 닿아 분리했다. 여기 몫 = 구조물 정본(structures.json)의
|
|
* 목록 · 종류 · 판번호 · 저장. 알약 · 비정규 측점 · 관 투영은 페이지 시설 줄 한 벌(`design_facility_lane` · 462)이
|
|
* 셈 — 이 다리는 목록을 넘기고 `irregularStations()` · `marks()` 는 시설 줄을 읽음.
|
|
*
|
|
* 저장 규칙은 종전 그대로다 — 사이드 목록이 바뀌면 곧바로 서버 정본에 저장하고,
|
|
* 판번호가 밀리면(다른 창이 먼저 저장) 최신본을 받아 화면을 맞추고 사용자에게 알린다.
|
|
* ========================================================================== */
|
|
|
|
import { showToast } from "@ui/ui_template_elements";
|
|
import { mountViewerStructureMenu } from "./B05_Profile_UI_Viewer_Menu";
|
|
import {
|
|
fetchStructures,
|
|
fetchStructureTypes,
|
|
staleDefinitionNotice,
|
|
definitionStale,
|
|
saveStructures,
|
|
readPendingStructures,
|
|
writePendingStructures,
|
|
StructureConflictError,
|
|
structuresPart,
|
|
type StructureSaveResponse,
|
|
type StructureInstance,
|
|
type StructureType,
|
|
} from "./B05_Profile_Api_Structures";
|
|
import type { createRoutePanel } from "./B05_Profile_UI_Panel";
|
|
import { plantsStation } from "./B05_Profile_UI_Profile_Structures";
|
|
import { listPick } from "../common_util/common_util_structure_catalog";
|
|
import { pipeChainageOf } from "../A00_Common/design/design_pipe_ids";
|
|
import { pageStructures, pageTypesChecked } from "../A00_Common/design/design_page_data";
|
|
import type { FacilityLane } from "../A00_Common/design/design_facility_lane";
|
|
import type { SavePart } from "../A00_Common/design/design_save_api";
|
|
|
|
export interface StructuresBridgeDeps {
|
|
projectId: string;
|
|
/** 사이드 패널 — 생성 순서상 나중에 만들어지므로 게터로 받는다. */
|
|
panel: () => ReturnType<typeof createRoutePanel>;
|
|
/** 페이지 시설 줄 한 벌(462) — 알약 · 비정규 측점은 여기서 셈. */
|
|
lane: FacilityLane;
|
|
/** 복원 중에는 서버로 저장하지 않는다(되살린 값을 그대로 되쓰지 않기 위함). */
|
|
isRestoring: () => boolean;
|
|
}
|
|
|
|
export function createStructuresBridge(deps: StructuresBridgeDeps) {
|
|
/* ── 구조물 정본(structures.json) ────────────────────────────────────
|
|
* 목록이 바뀌면 **세션 초안**에 담고(`writePending`), 정본에는 [저장]·[확정]에서만
|
|
* 쓴다(`saveStructuresIfDirty`). 판번호가 밀리면(다른 창이 먼저 저장) 최신본을
|
|
* 받아 화면을 맞추고 사용자에게 알린다.
|
|
* (옛 주석은 「곧바로 서버에 저장한다」였다 — 2026-08-29 에 초안 방식으로 바뀌었고
|
|
* 주석만 남아 있었다. 2026-09-06 정정.) */
|
|
let structureRevision = 0;
|
|
let structureSaving: Promise<void> = Promise.resolve();
|
|
|
|
let structureTypeMap = new Map<
|
|
string,
|
|
{ type_id: string; group: string; name: string; placement: string; managed_by: string | null }
|
|
>();
|
|
|
|
/** 알약 id의 누가거리 — 구조물이면 정본에서, 관이면 id 에서 되읽는다(없으면 null).
|
|
* 3D·사이드 목록과 마찬가지로 **종단 알약 선택도 B06 으로 이어져야** 해서 둔다
|
|
* (2026-09-06: 알약만 `structure-pick` 을 안 써 B06 이 못 잡았음). */
|
|
function markChainage(structureId: string | null): number | null {
|
|
if (!structureId) return null;
|
|
const pipe = pipeMarkChainage(structureId);
|
|
if (pipe !== null) return pipe;
|
|
const found = ownStructures.find((item) => item.structure_id === structureId);
|
|
if (!found) return null;
|
|
const chainage = found.chainage_m ?? found.start_m ?? null;
|
|
return typeof chainage === "number" && Number.isFinite(chainage) ? chainage : null;
|
|
}
|
|
|
|
/** 알약 id가 계곡 통과 시설(관 정본)이면 그 누가거리, 아니면 null. */
|
|
function pipeMarkChainage(structureId: string | null): number | null {
|
|
return structureId ? pipeChainageOf(structureId) : null; // 관 세션 id 장부(323-5)
|
|
}
|
|
|
|
/** 구조물 정본 목록(미저장분 우선) — 알약 레인 · 측점 계획은 시설 줄이 이것 + 관으로 셈. */
|
|
let ownStructures: StructureInstance[] = [];
|
|
/** 서버 정본 목록 — 가게 초안이 비면(노선 초안을 버림) 이것으로. */
|
|
let storedStructures: StructureInstance[] = [];
|
|
|
|
/* ── 미저장 조작분 세션 보관(2026-08-29 — CLAUDE.md 5장) ──────────────
|
|
* 조작은 화면과 세션에만 남기고 영구저장은 [저장]·[확정]에서만 한다. 세션에 두는
|
|
* 이유는 B06을 다녀오면 B05가 다시 그려져 메모리 값이 사라지기 때문이다. */
|
|
const readPending = (): StructureInstance[] | null => readPendingStructures(deps.projectId);
|
|
const writePending = (next: StructureInstance[] | null): void =>
|
|
writePendingStructures(deps.projectId, next);
|
|
|
|
/** 새 항목에 식별자를 미리 붙인다. 저장 전에도 목록에서 고르고 지울 수 있어야 하고
|
|
* (식별자가 null이면 여러 건이 서로 구분되지 않는다), 서버는 빈 값일 때만 새로
|
|
* 발급하므로 이 값이 그대로 정본 식별자가 된다(`_Structures_Repository.py:118`). */
|
|
function withLocalIds(next: StructureInstance[]): StructureInstance[] {
|
|
return next.map((item) =>
|
|
item.structure_id ? item : { ...item, structure_id: crypto.randomUUID().replace(/-/g, "") },
|
|
);
|
|
}
|
|
|
|
function applyStructures(next: StructureInstance[]): void {
|
|
ownStructures = withLocalIds(next);
|
|
deps.lane.set({ structures: ownStructures });
|
|
// 복원 중 호출은 사용자 조작이 아니다 — 미저장 표시를 남기지 않는다.
|
|
if (!deps.isRestoring()) {
|
|
deps.panel().structures.setStructures(ownStructures);
|
|
writePending(ownStructures);
|
|
}
|
|
}
|
|
|
|
/** [저장]·[확정]에서만 부른다 — 미저장분이 있으면 정본에 한 번에 쓴다. */
|
|
async function saveStructuresIfDirty(): Promise<void> {
|
|
if (readPending() === null && !definitionStale(deps.projectId)) return; // 옛 판 = 판 도장(323-9)
|
|
// 저장 요청이 겹치면 판번호가 어긋나므로 앞의 저장이 끝난 뒤에 보낸다.
|
|
// 보낼 것 = 세션 초안 — 횡단 보기에서 고친 조작도 이 초안에 있다(이 보기의 `ownStructures` 는
|
|
// 횡단 보기 고침을 모른다 · 안 그러면 횡단 보기에서 한 지정 · 해제가 [저장]에서 사라짐).
|
|
// 실패는 저장 한 줄기로 던짐(그 차례에서 멈춤 · 443) — 줄은 이어 다음 [저장]이 막히지 않게.
|
|
const run = structureSaving.then(() => persistStructures(readPending() ?? ownStructures));
|
|
structureSaving = run.catch(() => undefined);
|
|
await run;
|
|
}
|
|
|
|
/** 한 본문 [저장] 조각(472-6) — 보낼 것 = 세션 초안 ?? 이 보기 목록(`saveStructuresIfDirty` 와 같음) ·
|
|
* 저장 뒤 판번호 · 서버 목록(새 식별자)으로 화면을 맞추고 뒤 단계 표시 실패를 알림. */
|
|
async function structuresSavePart(): Promise<SavePart | null> {
|
|
if (deps.isRestoring() || (readPending() === null && !definitionStale(deps.projectId)))
|
|
return null;
|
|
const part = await structuresPart(deps.projectId, readPending() ?? ownStructures);
|
|
if (!part) return null;
|
|
return {
|
|
body: part.body,
|
|
after: async (response) => {
|
|
part.after?.(response);
|
|
const saved = response as unknown as StructureSaveResponse | undefined;
|
|
if (saved) structureRevision = saved.revision;
|
|
await refreshStructuresFromServer();
|
|
if (saved?.needs_downstream_invalidation && !saved.invalidated_downstream)
|
|
showToast(
|
|
"구조물은 저장되었지만 이후 단계(횡단·수량) 재작업 표시에 실패했습니다. " +
|
|
"B06을 다시 실행해 주세요.",
|
|
"error",
|
|
);
|
|
},
|
|
};
|
|
}
|
|
|
|
/** 서버 정본을 다시 받아 화면(사이드 목록·그래프 마크)을 그 상태로 맞춘다. */
|
|
async function refreshStructuresFromServer(): Promise<boolean> {
|
|
const stored = await fetchStructures(deps.projectId).catch(() => null);
|
|
if (!stored) return false;
|
|
writePending(null);
|
|
structureRevision = stored.revision;
|
|
deps.panel().structures.setStructures(stored.structures);
|
|
ownStructures = storedStructures = stored.structures;
|
|
deps.lane.setStored(storedStructures);
|
|
deps.lane.set({ structures: ownStructures }, false);
|
|
return true;
|
|
}
|
|
|
|
async function persistStructures(next: StructureInstance[]): Promise<void> {
|
|
if (deps.isRestoring()) return;
|
|
try {
|
|
// 판번호는 **쓰기 직전에** 서버에서 다시 받는다. 진입 때 받은 값으로 보내면,
|
|
// 화면이 떠 있는 동안 정본이 한 번이라도 바뀌었을 때(다른 창 저장·노선 재계산)
|
|
// 409 로 거절되고 아래 실패 처리가 초안을 지워 **사용자가 넣은 구조물이 통째로
|
|
// 사라졌다**(2026-09-06 실측: B06에서 구조물을 넣고 [저장]해도 정본에 안 남음).
|
|
const current = await fetchStructures(deps.projectId).catch(() => null);
|
|
const saved = await saveStructures(
|
|
deps.projectId,
|
|
current ? current.revision : structureRevision,
|
|
next,
|
|
);
|
|
structureRevision = saved.revision;
|
|
writePending(null);
|
|
// 서버가 새 항목에 식별자를 붙이므로 그 결과로 화면 목록을 맞춘다.
|
|
await refreshStructuresFromServer();
|
|
// 구조물이 바뀌면 B06 이후를 다시 돌려야 한다. 그 표시를 서버가 못 남겼다면
|
|
// 화면상 "완료"인 뒤 단계가 옛 구조물로 만든 결과라는 뜻이라 사용자가 알아야 한다.
|
|
if (saved.needs_downstream_invalidation && !saved.invalidated_downstream) {
|
|
showToast(
|
|
"구조물은 저장되었지만 이후 단계(횡단·수량) 재작업 표시에 실패했습니다. " +
|
|
"B06을 다시 실행해 주세요.",
|
|
"error",
|
|
);
|
|
}
|
|
} catch (error) {
|
|
// 실패해도 **초안은 지우지 않는다** — 지우면 사용자가 넣은 구조물이 사라진다
|
|
// (2026-09-06 정정). 화면 목록도 그대로 두고 다시 [저장]하면 된다.
|
|
// 삼키지 않고 던짐 — 저장 한 줄기가 이 차례에서 멈추고 알림(443 · 「저장했습니다」 안 뜸).
|
|
if (error instanceof StructureConflictError)
|
|
throw new Error("다른 창에서 구조물이 먼저 저장됐습니다 — 다시 [저장]해 주세요.");
|
|
throw error instanceof Error ? error : new Error("구조물 저장에 실패했습니다.");
|
|
}
|
|
}
|
|
|
|
/** 진입·새로고침 때 타입 레지스트리와 구조물 정본을 받아 화면에 채운다. */
|
|
async function loadStructures(): Promise<void> {
|
|
try {
|
|
// 레지스트리는 **한 번만** 받고 쓰는 자리에 따라 나눈다(2026-09-07).
|
|
// · 고르기 목록 — 켜진 종류만(B05 는 종전대로 여섯 군).
|
|
// · 목록 이름 · 폼 · 알약 레인 · 군 판정 — **전부**. B06 에서 넣은 B군(측구·맹암거 등)은
|
|
// 레지스트리에서 꺼져 있는데, 켜진 것만 주면 이름을 못 찾아 영문 키 · 「?」 알약으로 뜬다.
|
|
await pageTypesChecked(deps.projectId); // 정의 판 확인 · 저장분 = 페이지 자료 줄 한 번(322-0 · 386-2)
|
|
const [allTypes, stored] = await Promise.all([
|
|
fetchStructureTypes(true),
|
|
pageStructures(deps.projectId),
|
|
]);
|
|
deps.panel().structures.setTypes(allTypes, listPick("b05")); // 표 목록 both(322-1)
|
|
deps.lane.setTypes(allTypes);
|
|
// A군 판정에 쓸 타입 정보 — 횡단배수만 그래프 세로선·틸팅 대상이다.
|
|
structureTypeMap = new Map(
|
|
allTypes.map((type) => [
|
|
type.type_id,
|
|
{
|
|
type_id: type.type_id,
|
|
group: type.group,
|
|
name: type.name,
|
|
placement: type.placement,
|
|
managed_by: type.managed_by,
|
|
},
|
|
]),
|
|
);
|
|
structureRevision = stored.revision;
|
|
storedStructures = stored.structures;
|
|
deps.lane.setStored(storedStructures);
|
|
const note = staleDefinitionNotice(deps.projectId, stored.definition_version); // 322-2
|
|
if (note) showToast(note);
|
|
// 저장하지 않고 나갔던 조작분이 있으면 그것으로 화면을 세운다(2026-08-29).
|
|
const pending = readPending();
|
|
ownStructures = pending ?? stored.structures;
|
|
deps.panel().structures.setStructures(ownStructures);
|
|
deps.lane.set({ structures: ownStructures }, false);
|
|
} catch (error) {
|
|
showToast(
|
|
error instanceof Error ? error.message : "구조물 정보를 불러오지 못했습니다.",
|
|
"error",
|
|
);
|
|
}
|
|
}
|
|
|
|
return {
|
|
/** 3D 노선 우클릭 → 구조물 배치 메뉴(그래프·배수유역도와 동일, 2026-08-19).
|
|
* 목록은 사이드 패널 군 · 종류 드롭다운 그대로(129-1). */
|
|
mountViewerMenu(
|
|
viewer: {
|
|
root: HTMLElement;
|
|
modelPointAt: (clientX: number, clientY: number) => { x: number; y: number } | null;
|
|
},
|
|
routePoints: () => ReadonlyArray<{ x: number; y: number }>,
|
|
): void {
|
|
mountViewerStructureMenu({
|
|
root: viewer.root,
|
|
modelPointAt: viewer.modelPointAt,
|
|
routePoints,
|
|
structureGroups: () => deps.panel().structures.pickGroups(),
|
|
onPick: (chainage, typeId) => deps.panel().structures.addAt(chainage, typeId),
|
|
});
|
|
},
|
|
/** 측점 계획을 다시 맞춤 — 추가 측점 저장분이 늦게 옴(368-2). */
|
|
resyncStations: () => deps.lane.set({}),
|
|
/** 알약 레인 목록 · 종류(시설 줄) — 평면 구조물 마커가 같은 것을 씀(399). */
|
|
marks: (): StructureInstance[] => deps.lane.marks(),
|
|
types: (): StructureType[] => deps.lane.types(),
|
|
/** 그래프·3D가 쓰는 현재 비정규 측점 목록(시설 줄 · 물넘이포장 제외 — 알약 레인 몫). */
|
|
irregularStations: () => deps.lane.stations(),
|
|
/** 측점 계획 입력(종류 · 구조물 · 관)이 다 옴 — 그 전 목록은 「모름」(473). */
|
|
stationsReady: () => deps.lane.ready(),
|
|
/** 구조물 종류의 군(A~G · 모르면 undefined) — 측점 고름이 덮어 열 구조물을 가름(216-1). */
|
|
typeGroup: (typeId: string) => structureTypeMap.get(typeId)?.group,
|
|
/** [초기선 복원] — 그래프의 배관 투영만 지운다(관 정본은 배수유역이 맡는다). */
|
|
clearProjectedStations: () => deps.lane.clearProjected(),
|
|
/** 알약 식별자에서 관 누가거리를 되읽는다(관이 아니면 null). */
|
|
pipeMarkChainage,
|
|
/** 측점 없는 관 알약(물넘이포장)인가 — 고름 값에 알약 id 를 남길지 가름(요구 178). */
|
|
stationlessPipeMark: (structureId: string | null): boolean => {
|
|
if (pipeMarkChainage(structureId) === null) return false; // 관 알약만
|
|
const mark = deps.lane.marks().find((entry) => entry.structure_id === structureId);
|
|
return !!mark && !plantsStation(mark, []);
|
|
},
|
|
/** 알약 식별자의 누가거리(구조물·관 공통). B06 으로 넘길 선택값을 만들 때 쓴다. */
|
|
markChainage,
|
|
/** 사이드 목록이 바뀜 — 화면을 맞추고 서버 정본에 저장한다. */
|
|
applyStructures,
|
|
/** 가게 구조물 초안을 계산 묶음이 다시 씀(노선 따라 측점 다시 매김 · 되돌림 · 349-0d) — 목록만 맞춤(측점은 시설 줄). */
|
|
takeStructures(next: StructureInstance[] | null): void {
|
|
const list = next ?? storedStructures;
|
|
if (JSON.stringify(list) === JSON.stringify(ownStructures)) return;
|
|
ownStructures = list;
|
|
deps.panel().structures.setStructures(ownStructures);
|
|
},
|
|
saveStructuresIfDirty,
|
|
structuresSavePart,
|
|
/** 서버 정본을 다시 받아 화면을 그 상태로 맞춘다. */
|
|
refreshFromServer: refreshStructuresFromServer,
|
|
/** 진입·새로고침 — 타입 레지스트리와 구조물 정본을 받아 화면을 채운다. */
|
|
load: loadStructures,
|
|
};
|
|
}
|