/* ============================================================================= * B06_Section_Station_Keys.ts * 세션 횡단 초안의 **측점 열쇠**(PLAN 344-3) — 노선이 바뀌어 누가거리가 밀려도 같은 측점의 고침이 남게. * * 열쇠 규칙 = `common_util_station_key`(일반 grid:k · 관 pipe: · 구조물 structure: · 시종점 end:BP/EP · * id 모름 at:<0.01 m>). 담는 칸 다섯(crossdesign · shoulderext · bermdepth · rockb · cutslope)은 측점 열쇠로 * 쓰고, 부르는 쪽에는 지금처럼 **누가거리**로 내줌(읽기 · 쓰기 창구가 그대로). * * · 지금 측점 목록 = 상세가 초안 측점을 얹을 때마다(`useStationKeys` · Section_Drafts) — 관 id 는 관 세션 * 장부 · 구조물 id 는 측점 계획(초안) · 구조물 목록(횡단 보기)에서. * · 칸마다 열쇠 → 쓴 때 누가거리 `@at` 을 함께 담음 — 구조물 id 를 아직 모를 때(첫 열기)도 그 자리 측점이면 읽음. * · 옛 초안(누가거리 열쇠 `"85.05"`) · `at:` 열쇠는 읽을 때 지금 측점 열쇠로 옮겨 다시 씀. * · 노선이 바뀜 = `carryStationDrafts` — 넘기는 칸 = 서버 `CARRY_INPUT_KEYS` 일곱(지반 유형 · 측구 종류 · * 측구 선택 · 2단 경사 · 암 경계 · 암 경사 · 소단 구간은 구간 그대로) · 단면 유형 · 측구 방향은 새 지형이 * 정함 · 사라진 측점의 고침은 버리고 「노선 밖 고침 N 건」 한 줄. * ========================================================================== */ import { readState, writeState } from "../A00_Common/b_page_state"; import { pipeIdAt } from "../A00_Common/design/design_pipe_ids"; import { showToast } from "@ui/ui_template_elements"; import { chainageKey, looseChainage, stationKey } from "@util/common_util_station_key"; import { inferStationInterval } from "@util/common_util_svg"; import type { CrossSection, SectionDetailResponse } from "./B06_Section_Api_Fetch"; /** 측점 열쇠로 담는 세션 칸. */ export type KeyedState = "crossdesign" | "shoulderext" | "bermdepth" | "rockb" | "cutslope"; export const KEYED_STATES: readonly KeyedState[] = [ "crossdesign", "shoulderext", "bermdepth", "rockb", "cutslope", ]; /** 노선이 바뀔 때 넘기는 카드 선택 — 서버 `CARRY_INPUT_KEYS` 중 선택 칸(나머지 셋 = rockb · cutslope · 소단 구간). */ const CARRY_CHOICE_KEYS = ["ground_type", "ditch_type", "ditch_choice", "two_stage_slope"] as const; /** 노선이 바뀔 때 통째로 넘기는 칸 · 버리는 칸(노견 확장 · 소단 깊이 = 그 측점 기하 손질). */ const CARRY_WHOLE: readonly KeyedState[] = ["rockb", "cutslope"]; /** 같은 측점으로 보는 거리 — 옛 초안 열쇠(0.01 m) · 서버 측점(0.001 m) 차이를 받음. */ const NEAR_M = 0.01; const HINT = "@at"; type Keyed = { key: string; chainageM: number }; type Raw = Record & { [HINT]?: Record }; const lists = new Map(); /** 프로젝트마다 구조물 측점 → id(측점 계획 · 구조물 목록). */ const owners = new Map>(); let current: { projectId: string; routeId: number } | null = null; const scopeOf = (projectId: string, routeId: number): string => `${projectId}:${routeId}`; /** 측점 계획(초안 · `planStations`)이 낸 덮는 구조물 id — 관 세션 id 는 `pipe-` 머리. */ export function useStationOwners( projectId: string, planned: ReadonlyArray<{ chainage_m: number; owners: string[] }>, ): void { owners.set( projectId, planned.flatMap((station) => station.owners.slice(0, 1).map((id) => ({ id, kind: id.startsWith("pipe-") ? ("pipe" as const) : ("structure" as const), at: station.chainage_m, })), ), ); } /** 구조물 목록(횡단 보기) — 한 자리 구조물의 id. 관 자리는 관 장부가 정함. */ export function useStructureIds( projectId: string, structures: ReadonlyArray<{ structure_id?: string | null; chainage_m?: number | null }>, ): void { const known = (owners.get(projectId) ?? []).filter((owner) => owner.kind === "pipe"); for (const item of structures) { if (item.structure_id && typeof item.chainage_m === "number") known.push({ id: item.structure_id, kind: "structure", at: item.chainage_m }); } owners.set(projectId, known); } const looksLikePipe = (section: CrossSection): boolean => Boolean(section.culvert || section.box) || /^.+ D\d+/.test(section.structure ?? ""); /** 측점 목록 → 열쇠(같은 열쇠가 둘이면 뒤엣것은 `at:`). */ export function stationKeysOf( projectId: string | null, sections: ReadonlyArray, intervalM: number | null, ): Keyed[] { const known = projectId ? (owners.get(projectId) ?? []) : []; const seen = new Set(); return sections.map((section) => { const at = section.chainage_m; let key: string; if (section.kind === "irregular") { const owner = known.find((entry) => Math.abs(entry.at - at) < NEAR_M); if (owner) key = stationKey("irregular", at, intervalM, owner.id, owner.kind); else if (looksLikePipe(section)) key = stationKey("pipe", at, intervalM, pipeIdAt(at, projectId)); else key = section.grid ? stationKey("regular", at, intervalM) : stationKey("irregular", at, intervalM); } else key = stationKey(section.kind, at, intervalM); if (seen.has(key)) key = `at:${chainageKey(at)}`; seen.add(key); return { key, chainageM: at }; }); } /** 상세가 측점을 다 얹었다 — 이 노선의 지금 측점 열쇠를 세움(읽기 · 쓰기가 이 목록으로 옮김). */ export function useStationKeys( projectId: string, routeId: number, detail: Pick, ): void { const interval = ( detail.longitudinal?.profile_alignment as { policy?: { station_interval_m?: number } } | undefined )?.policy?.station_interval_m ?? inferStationInterval(detail.cross_sections); lists.set(scopeOf(projectId, routeId), stationKeysOf(projectId, detail.cross_sections, interval)); current = { projectId, routeId }; } /** 지금 노선(마지막으로 세운 목록) — 세션 열쇠만 아는 저장소(암 경계 · 암 경사)가 씀. */ export const currentStationScope = () => current; function nearest(list: Keyed[], chainageM: number): Keyed | undefined { let best: Keyed | undefined; for (const entry of list) { const gap = Math.abs(entry.chainageM - chainageM); if (gap < NEAR_M && (!best || gap < Math.abs(best.chainageM - chainageM))) best = entry; } return best; } /** 누가거리 → 열쇠(목록이 없거나 그 자리 측점이 없으면 `at:`). */ export function keyAt(projectId: string, routeId: number, chainageM: number): string { const list = lists.get(scopeOf(projectId, routeId)); return (list && nearest(list, chainageM)?.key) ?? `at:${chainageKey(chainageM)}`; } /** * 열쇠 → 지금 누가거리(0.01 m). 목록에 있으면 그 측점 · 없으면 쓴 때 자리(`hint` · 옛 열쇠는 그 수)에 지금 * 측점이 있으면 그 측점 · 목록이 아직 없으면 쓴 때 자리 그대로 · 그 밖 = null(노선 밖). */ export function chainageOfKey( projectId: string, routeId: number, key: string, hint?: number, ): number | null { const list = lists.get(scopeOf(projectId, routeId)); const hit = list?.find((entry) => entry.key === key); if (hit) return Number(chainageKey(hit.chainageM)); const at = looseChainage(key) ?? hint; if (at === undefined) return null; if (!list) return Number(chainageKey(at)); const near = nearest(list, at); return near ? Number(chainageKey(near.chainageM)) : null; } function readRaw(name: KeyedState, projectId: string, routeId: number): Raw { const raw = readState(name, projectId, routeId); return raw && typeof raw === "object" ? raw : {}; } function entries(raw: Raw): Array<[string, V, number | undefined]> { const hints = raw[HINT] ?? {}; return Object.entries(raw) .filter(([key]) => key !== HINT) .map(([key, value]) => [key, value as V, hints[key]]); } /** * 칸 하나를 누가거리(0.01 m 글) → 값으로 읽음 — 지금 측점에 없는 고침은 뺌(지우지 않음). * 옛 열쇠(누가거리 · at:)는 지금 측점 열쇠로 옮겨 다시 씀(목록이 있을 때). */ export function readKeyed(name: KeyedState, projectId: string, routeId: number): Map { const raw = readRaw(name, projectId, routeId); const out = new Map(); let moved = false; for (const [key, value, hint] of entries(raw)) { const at = chainageOfKey(projectId, routeId, key, hint); if (at === null) continue; out.set(chainageKey(at), value); if (looseChainage(key) !== null && !keyAt(projectId, routeId, at).startsWith("at:")) moved = true; } if (moved) writeKeyed(name, projectId, routeId, out); return out; } /** * 누가거리(0.01 m 글) → 값 묶음을 측점 열쇠로 씀. 지금 측점이 아닌 옛 고침(노선 밖 · 아직 못 읽는 것)은 그대로 둠. * `only` 를 주면 그 누가거리들만 바꾸고 나머지 지금 측점 고침도 그대로. */ export function writeKeyed( name: KeyedState, projectId: string, routeId: number, byChainage: ReadonlyMap, only?: ReadonlyArray, ): void { const raw = readRaw(name, projectId, routeId); const next: Raw = { [HINT]: {} }; const replaced = new Set((only ?? [...byChainage.keys()]).map((at) => chainageKey(Number(at)))); for (const [key, value, hint] of entries(raw)) { const at = chainageOfKey(projectId, routeId, key, hint); if (at !== null && (only === undefined || replaced.has(chainageKey(at)))) continue; next[key] = value; if (hint !== undefined) next[HINT]![key] = hint; } for (const [at, value] of byChainage) { if (only !== undefined && !replaced.has(chainageKey(Number(at)))) continue; const key = keyAt(projectId, routeId, Number(at)); next[key] = value; next[HINT]![key] = Number(chainageKey(Number(at))); } writeState(name, next, projectId, routeId); } /** * 노선이 바뀜(새 측점 목록을 `useStationKeys` 로 세운 뒤) — 옛 노선 고침을 측점 열쇠로 새 노선에 넘김. * 넘김 = 카드 선택 넷(지반 유형 · 측구 종류 · 측구 선택 · 2단 경사) · 암 경계 · 암 경사 · 단면 유형 · 측구 * 방향은 새 지형이 정함 · 노견 확장 · 소단 깊이는 안 넘김. 돌려주는 값 = 노선 밖으로 나간 고침 수(측점 단위). */ export function carryStationDrafts( projectId: string, fromRouteId: number, toRouteId: number, ): number { const outside = new Set(); for (const name of KEYED_STATES) { const raw = readRaw(name, projectId, fromRouteId); const next: Raw = { [HINT]: {} }; for (const [key, value] of entries(raw)) { // 옛 열쇠(누가거리 · at:)는 옛 목록으로 측점 열쇠부터 · 측점 열쇠(id)는 그대로 const fromAt = looseChainage(key) === null ? null : chainageOfKey(projectId, fromRouteId, key); const stable = fromAt === null ? key : keyAt(projectId, fromRouteId, fromAt); const toAt = chainageOfKey(projectId, toRouteId, stable, looseChainage(stable) ?? undefined); const carried = carriedValue(name, value); if (toAt === null || stable.startsWith("at:")) { if (carried !== null) outside.add(stable); continue; } if (carried === null) continue; const toKey = keyAt(projectId, toRouteId, toAt); next[toKey] = carried; next[HINT]![toKey] = toAt; } writeState(name, next, projectId, toRouteId); } if (outside.size) showToast(`노선 밖 고침 ${outside.size} 건 — 사라진 측점이라 버렸습니다.`, "info"); return outside.size; } /** 칸마다 넘기는 값 — 넘길 것이 없으면 null. */ function carriedValue(name: KeyedState, value: unknown): unknown { if (CARRY_WHOLE.includes(name)) return value; if (name !== "crossdesign" || !value || typeof value !== "object") return null; const source = value as Record; const picked = Object.fromEntries( CARRY_CHOICE_KEYS.filter((key) => source[key] !== undefined).map((key) => [key, source[key]]), ); return Object.keys(picked).length ? picked : null; }