/* ============================================================================= * B05_Profile_UI_Page_Structures.ts * 구조물·계곡 통과 시설을 화면 세 곳(사이드 목록·종단 그래프·3D)에 맞추는 다리. * * 화면 본체(B05_Profile_UI_Page)가 700줄 한계에 닿아 분리했다. 두 정본을 섞어 * 쓰는 자리라 상태를 여기로 옮겨 왔다 — 구조물 정본(structures.json)의 목록·판번호와, * 관 지점 정본(pipe_points.json)에서 투영한 측점·알약이 그것이다. 본체는 이 객체의 * 메서드만 부르고 목록은 `irregularStations()`로 읽는다. * * 저장 규칙은 종전 그대로다 — 사이드 목록이 바뀌면 곧바로 서버 정본에 저장하고, * 판번호가 밀리면(다른 창이 먼저 저장) 최신본을 받아 화면을 맞추고 사용자에게 알린다. * ========================================================================== */ import { PIPE_DEFAULT_TYPE, pickPipeDiameter } from "@config/config_frontend"; import { showToast } from "@ui/ui_template_elements"; import { mountViewerStructureMenu } from "./B05_Profile_UI_Viewer_Menu"; import type { PipeFacility } from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; import { fetchStructures, fetchStructureTypes, saveStructures, readPendingStructures, writePendingStructures, StructureConflictError, type StructureInstance, } from "./B05_Profile_Api_Structures"; import { isPipeStation, structureLabel, type IrregularStation, } from "./B05_Profile_UI_IrregularStations"; import type { createRoutePanel } from "./B05_Profile_UI_Panel"; import type { createRouteProfilePanel } from "./B05_Profile_UI_Profile_Panel"; import { FACILITY_NAMES } from "./B05_Profile_UI_Page_Helpers"; import { followedStation } from "./B05_Profile_UI_Profile_Alignment"; import { followSnapped } from "./B05_Profile_UI_Structures_Snap"; import { plantsStation } from "./B05_Profile_UI_Profile_Structures"; import { pipeStationMarks, SAME_STATION_M, structureStationMarks, type StationMark, } from "../common_util/common_util_structure_station"; /** 관 지점 한 건 — 유효직경·시설 종류·부속 옵션까지 그대로 받는다. */ export interface PipeProjection { chainage_m: number; effective_diameter_mm: number | null; facility: PipeFacility; options?: Record; /** 독립 기슭막이 구간 — 시작 · 끝도 측점을 심는다(272 · 서버와 같은 규칙). */ start_m?: number | null; end_m?: number | null; } export interface StructuresBridgeDeps { projectId: string; /** 사이드 패널 — 생성 순서상 나중에 만들어지므로 게터로 받는다. */ panel: () => ReturnType; profilePanel: () => ReturnType; /** 3D 측점 세로선 다시 그리기 — 상세가 아직 없으면 본체가 알아서 건너뛴다. */ renderStationLines: () => void; /** 복원 중에는 서버로 저장하지 않는다(되살린 값을 그대로 되쓰지 않기 위함). */ isRestoring: () => boolean; } export function createStructuresBridge(deps: StructuresBridgeDeps) { /** 비정규 측점(그래프 세로선·3D 라벨) — 관 투영분 + A군 수동 구조물. */ let irregularStations: IrregularStation[] = []; /** 측점 자리 → 측점번호 · 잔여거리(측점간격 기준). */ function stationAt( chainage: number, ): Pick { const interval = deps.panel().values().stationInterval || 20; const station = Math.floor(chainage / interval); return { station, remainder: chainage - station * interval, chainage_m: chainage }; } /** 구간형의 시작 · 끝 측점 — 기준 측점(`id`)과 짝 · 관 · 구조물 행이 아님(옮기기 · 지우기 · 관 대조에서 * 빠짐 · `origin` 없음) · id 에 자리 이름을 붙여 옮겨도 계획고 편집이 따라감(272). */ const spanStations = (id: string, label: string, marks: StationMark[]): IrregularStation[] => marks .filter((mark) => mark.role !== "anchor") .map((mark) => ({ id: `${id}:${mark.role}`, ...stationAt(mark.chainage_m), structure: label, })); function pipesToStations(pipes: ReadonlyArray): IrregularStation[] { return pipes.flatMap((pipe) => { const label = pipe.facility === "pipe" ? structureLabel({ structureType: "배관", pipeType: PIPE_DEFAULT_TYPE, diameterMm: pickPipeDiameter(PIPE_DEFAULT_TYPE, pipe.effective_diameter_mm), }) : FACILITY_NAMES[pipe.facility]; const id = `pipe-${pipe.chainage_m.toFixed(2)}`; return [ { id, ...stationAt(pipe.chainage_m), structure: label, structureType: "배관" as const, origin: "pipe" as const, }, ...spanStations(id, label, pipeStationMarks(pipe)), ]; }); } /** [초기선 복원] 등에서 그래프의 배관 투영만 지운다 — 관 정본은 건드리지 않는다. */ function clearProjectedStations(): void { pipeStations = []; irregularStations = []; deps.renderStationLines(); deps.profilePanel().setIrregularStations([]); } /** 비정규 측점 목록 변경 → 3D·그래프·테이블에 반영(프론트 프리뷰, 백엔드 미전송). */ function applyIrregularStations(stations: IrregularStation[]): void { // 위치가 바뀌거나 삭제된 비정규 측점의 옛 chainage에 남은 계획고 편집(유령 변화점)을 옮기거나 지운다. // 옮김 = 0.006m 안 새 측점(서버 0.01m 반올림) · 같은 id · 사라진 것 하나와 새것 하나(옮긴 관 · 213-2) // — 세션 초안도 새 키로 바뀌어 옛 키가 다시 안 나감. 나머지는 지움. const nextKeys = new Set(stations.map((station) => station.chainage_m.toFixed(3))); const oldKeys = new Set(irregularStations.map((station) => station.chainage_m.toFixed(3))); const gone = irregularStations.filter( (station) => !nextKeys.has(station.chainage_m.toFixed(3)), ); const born = stations.filter((station) => !oldKeys.has(station.chainage_m.toFixed(3))); gone.forEach((station) => { // 없으면 맞춘 자리의 새 측점 — 옛 소수 자동 맞춤이 여럿을 한꺼번에 옮겨도 편집이 따라감(234-1 · 272) const same = followedStation(station, gone, born) ?? followSnapped(station, born); deps.profilePanel().resetStationEdit(station.chainage_m, same?.chainage_m); }); irregularStations = stations; deps.renderStationLines(); deps.profilePanel().setIrregularStations(stations); // 좌측 구조물 폼에서 "배관" 항목을 고쳤거나 지웠으면 배수유역도까지 따라가야 한다. // 관 목록이 그대로면 배수유역도가 아무 일도 하지 않으므로 되먹임 고리는 여기서 끊긴다. deps .profilePanel() .drainage.setPipeChainages(stations.filter(isPipeStation).map((entry) => entry.chainage_m)); } /* ── 구조물 정본(structures.json) ──────────────────────────────────── * 목록이 바뀌면 **세션 초안**에 담고(`writePending`), 정본에는 [저장]·[확정]에서만 * 쓴다(`saveStructuresIfDirty`). 판번호가 밀리면(다른 창이 먼저 저장) 최신본을 * 받아 화면을 맞추고 사용자에게 알린다. * (옛 주석은 「곧바로 서버에 저장한다」였다 — 2026-08-29 에 초안 방식으로 바뀌었고 * 주석만 남아 있었다. 2026-09-06 정정.) */ let structureRevision = 0; let structureSaving: Promise = Promise.resolve(); /* ── 횡단배수(A군) 측점 세로선 ────────────────────────────────────────── * 횡단배수 시설은 계획선을 그 지점에 물리므로 그래프에 세로 점선과 계획고 틸팅 * 버튼(▲▼)이 있어야 한다(2026-08-17 사용자 지시). 대상은 관 정본(배수관·BOX암거· * 물넘이·세월교)에 더해 수동 A군(노출형 횡단수로·개거)까지다. */ let pipeStations: IrregularStation[] = []; let structureTypeMap = new Map< string, { type_id: string; group: string; name: string; placement: string; managed_by: string | null } >(); /** 측점을 심는 수동 구조물(A군 기준 · C · D군 구간형 시작 · 기준 · 끝 — 서버 `resolve_extra_stations` * 와 같은 규칙 · 272)을 그래프 측점 목록으로 투영한다(관 정본 항목은 pipeStations 몫). */ function crossDrainStationsOf(structures: StructureInstance[]): IrregularStation[] { return structures.flatMap((structure) => { const type = structureTypeMap.get(structure.type_id); const marks = structureStationMarks(structure, type); const anchor = marks.find((mark) => mark.role === "anchor"); if (!anchor) return []; const id = structure.structure_id ?? `structure-${anchor.chainage_m.toFixed(2)}`; const label = type?.name ?? structure.type_id; return [ { id, ...stationAt(anchor.chainage_m), structure: label }, ...spanStations(id, label, marks), ]; }); } /** 관 정본 + 측점 심는 수동 구조물을 합쳐 그래프·3D 측점 목록을 맞춘다 — 같은 m 면 측점 하나(관이 * 먼저 · 서버 측점 만들기와 같음). 관 기준 측점은 다 둠(관 정본 대조 `setPipeChainages` 가 관을 * 지우지 않게) · 물넘이포장도 측점을 심음(286-1). */ function syncCrossDrainStations(): void { const kept: IrregularStation[] = []; for (const station of [...pipeStations, ...crossDrainStationsOf(ownStructures)]) { const taken = station.origin !== "pipe" && kept.some((other) => Math.abs(other.chainage_m - station.chainage_m) < SAME_STATION_M); if (!taken) kept.push(station); } applyIrregularStations(kept); } /** 알약 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 { if (!structureId?.startsWith("pipe-")) return null; const value = Number(structureId.slice("pipe-".length)); return Number.isFinite(value) ? value : null; } /** 그래프 알약 레인에 올릴 목록 — 구조물 정본 + 계곡 통과 시설(관 정본)을 합친다. * 자동 배수관도 세로 점선이 아니라 같은 알약으로 나온다(2026-08-17 사용자 지시 1). */ let ownStructures: StructureInstance[] = []; let pipeMarks: StructureInstance[] = []; function syncGraphStructures(): void { deps.profilePanel().setStructures([...ownStructures, ...pipeMarks]); } /** 관 지점을 알약 레인용 가상 구조물로 만든다(정본은 pipe_points, 저장하지 않는다). */ function pipesToMarks( pipes: Array<{ chainage_m: number; facility: PipeFacility; options?: Record; }>, ): StructureInstance[] { return pipes.map((pipe) => ({ structure_id: `pipe-${pipe.chainage_m.toFixed(2)}`, type_id: pipe.facility, placement: "point", chainage_m: pipe.chainage_m, start_m: null, end_m: null, options: pipe.options ?? {}, memo: "", placement_source: "automatic", status: "draft", revision: 0, geometry: null, })); } /* ── 미저장 조작분 세션 보관(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); syncGraphStructures(); syncCrossDrainStations(); // 복원 중 호출은 사용자 조작이 아니다 — 미저장 표시를 남기지 않는다. if (!deps.isRestoring()) { deps.panel().structures.setStructures(ownStructures); writePending(ownStructures); } } /** [저장]·[확정]에서만 부른다 — 미저장분이 있으면 정본에 한 번에 쓴다. */ async function saveStructuresIfDirty(): Promise { if (readPending() === null) return; // 저장 요청이 겹치면 판번호가 어긋나므로 앞의 저장이 끝난 뒤에 보낸다. // 보낼 것 = 세션 초안 — 횡단 보기에서 고친 조작도 이 초안에 있다(이 보기의 `ownStructures` 는 // 횡단 보기 고침을 모른다 · 안 그러면 횡단 보기에서 한 지정 · 해제가 [저장]에서 사라짐). structureSaving = structureSaving.then(() => persistStructures(readPending() ?? ownStructures)); await structureSaving; } /** 서버 정본을 다시 받아 화면(사이드 목록·그래프 마크)을 그 상태로 맞춘다. */ async function refreshStructuresFromServer(): Promise { const stored = await fetchStructures(deps.projectId).catch(() => null); if (!stored) return false; writePending(null); structureRevision = stored.revision; deps.panel().structures.setStructures(stored.structures); ownStructures = stored.structures; syncGraphStructures(); syncCrossDrainStations(); return true; } async function persistStructures(next: StructureInstance[]): Promise { 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 정정). 화면 목록도 그대로 두고 다시 [저장]하면 된다. if (error instanceof StructureConflictError) { showToast( "다른 창에서 구조물이 먼저 저장돼 이번 저장을 건너뛰었습니다. 다시 [저장]해 주세요.", "error", ); return; } showToast(error instanceof Error ? error.message : "구조물 저장에 실패했습니다.", "error"); } } /** 진입·새로고침 때 타입 레지스트리와 구조물 정본을 받아 화면에 채운다. */ async function loadStructures(): Promise { try { // 레지스트리는 **한 번만** 받고 쓰는 자리에 따라 나눈다(2026-09-07). // · 고르기 목록 — 켜진 종류만(B05 는 종전대로 여섯 군). // · 목록 이름 · 폼 · 알약 레인 · 군 판정 — **전부**. B06 에서 넣은 B군(측구·맹암거 등)은 // 레지스트리에서 꺼져 있는데, 켜진 것만 주면 이름을 못 찾아 영문 키 · 「?」 알약으로 뜬다. const [allTypes, stored] = await Promise.all([ fetchStructureTypes(true), fetchStructures(deps.projectId), ]); deps.panel().structures.setTypes(allTypes, (type) => type.enabled); deps.profilePanel().setStructureTypes(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; // 저장하지 않고 나갔던 조작분이 있으면 그것으로 화면을 세운다(2026-08-29). const pending = readPending(); ownStructures = pending ?? stored.structures; deps.panel().structures.setStructures(ownStructures); syncGraphStructures(); syncCrossDrainStations(); } 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), }); }, /** 그래프·3D가 쓰는 현재 비정규 측점 목록(물넘이포장 제외 — 알약 레인 몫). */ irregularStations: () => irregularStations, /** 구조물 종류의 군(A~G · 모르면 undefined) — 측점 고름이 덮어 열 구조물을 가름(216-1). */ typeGroup: (typeId: string) => structureTypeMap.get(typeId)?.group, /** 관 지점 목록이 바뀜 — 측점 투영과 알약 레인을 한 번에 맞춘다. */ setPipes(pipes: PipeProjection[]): void { pipeStations = pipesToStations(pipes); syncCrossDrainStations(); pipeMarks = pipesToMarks(pipes); syncGraphStructures(); }, /** [초기선 복원] — 그래프의 배관 투영만 지운다(관 정본은 배수유역이 맡는다). */ clearProjectedStations, /** 알약 식별자에서 관 누가거리를 되읽는다(관이 아니면 null). */ pipeMarkChainage, /** 측점 없는 관 알약(물넘이포장)인가 — 고름 값에 알약 id 를 남길지 가름(요구 178). */ stationlessPipeMark: (structureId: string | null): boolean => { const mark = pipeMarks.find((entry) => entry.structure_id === structureId); return !!mark && !plantsStation(mark, []); }, /** 알약 식별자의 누가거리(구조물·관 공통). B06 으로 넘길 선택값을 만들 때 쓴다. */ markChainage, /** 사이드 목록이 바뀜 — 화면을 맞추고 서버 정본에 저장한다. */ applyStructures, saveStructuresIfDirty, /** 서버 정본을 다시 받아 화면을 그 상태로 맞춘다. */ refreshFromServer: refreshStructuresFromServer, /** 진입·새로고침 — 타입 레지스트리와 구조물 정본을 받아 화면을 채운다. */ load: loadStructures, }; }