/* ============================================================================= * A00_Common/design/design_selection.ts * 측점 · 구조물 고름 **한 곳** (PLAN 24-2d). * * 24-1 약속의 `design_store` 「selection({chainageM, structureId})」 키 자리다. 그 모듈이 * 서면 이 파일의 `readSelection` · `selectDesign` · `subscribeSelection` 세 함수를 그쪽 * `get` · `set` · `subscribe("selection")` 으로 잇는다 — 부르는 쪽 코드는 바뀌지 않는다. * * 값은 등록표 키 `structure-pick`(② 설계 초안 · 페이지를 오가도 남음)에 실린다. * 어느 화면(3D · 종단 알약 · 목록 · 횡단 카드)에서 골라도 같은 값이 적히고, 다른 쪽은 * 이 값을 읽어 따라간다. 관 · 구조물을 찾는 거리 `PIPE_MATCH_M` 도 여기 한 값뿐이다. * ========================================================================== */ import { readState, writeState } from "../b_page_state"; /** 관 · 구조물 찾는 거리(m) — 관은 계획선에 스냅돼 측점과 살짝 어긋난다. 이 값이 유일하다. */ export const PIPE_MATCH_M = 0.51; export interface DesignSelection { chainageM: number | null; structureId: string | null; } export const NO_SELECTION: DesignSelection = { chainageM: null, structureId: null }; /** 두 누가거리가 같은 자리인가. */ export function isNear(a: number, b: number): boolean { return Math.abs(a - b) < PIPE_MATCH_M; } /** 그 누가거리에서 찾는 거리 안에 있는 항목 중 **가장 가까운 것** — 이웃한 항목이 둘 걸려도 * 자리가 맞는 쪽을 고른다. */ export function nearestWithin( items: ReadonlyArray, chainageM: number, chainageOf: (item: T) => number, ): T | undefined { let best: T | undefined; let bestGap = PIPE_MATCH_M; for (const item of items) { const gap = Math.abs(chainageOf(item) - chainageM); if (gap < bestGap) { best = item; bestGap = gap; } } return best; } /** 선택을 풀 때 필요한 구조물의 최소 모양 — B05 `StructureInstance` 가 그대로 맞는다. */ export interface SelectableStructure { structure_id?: string | null; chainage_m?: number | null; start_m?: number | null; end_m?: number | null; } /** 풀린 선택 — 관이면 그 관의 누가거리, 아니면 구조물 id. 둘 다 없으면 고를 항목이 없다. */ export interface ResolvedSelection { pipeChainageM: number | null; structureId: string | null; } /** 이 구조물이 그 측점을 덮는가 — 점형은 기준점 근처, 구간형은 시작~종료 안(양끝 여유 포함). */ export function coversChainage(structure: SelectableStructure, chainageM: number): boolean { const anchor = structure.chainage_m ?? structure.start_m; const start = structure.start_m ?? anchor; const end = structure.end_m ?? anchor; if (start == null || end == null) return false; return ( chainageM >= Math.min(start, end) - PIPE_MATCH_M && chainageM <= Math.max(start, end) + PIPE_MATCH_M ); } /** * 선택 값을 폼이 열 항목으로 푼다 — 어느 화면에서 골랐든 **같은 순서 · 같은 거리**다. * ① 구조물 id 가 있고 목록에 있으면 그 구조물 ② 측점 근처 관(계곡 통과 시설이 먼저 — * 같은 측점에 구조물이 겹쳐도 횡단도에 그려지는 것은 관이다) ③ 측점을 덮는 구조물. */ export function resolveSelection( selection: DesignSelection, pipes: ReadonlyArray<{ chainage_m: number }>, structures: ReadonlyArray, ): ResolvedSelection { if (selection.structureId) { const byId = structures.find((entry) => entry.structure_id === selection.structureId); if (byId) return { pipeChainageM: null, structureId: byId.structure_id ?? null }; } const chainageM = selection.chainageM; if (chainageM === null) return { pipeChainageM: null, structureId: null }; const pipe = nearestWithin(pipes, chainageM, (entry) => entry.chainage_m); if (pipe) return { pipeChainageM: pipe.chainage_m, structureId: null }; const hit = structures.find((entry) => coversChainage(entry, chainageM)); return { pipeChainageM: null, structureId: hit?.structure_id ?? null }; } /* ── 세션 값 + 알림 ─────────────────────────────────────────────────────── */ interface Stored { at?: unknown; key?: unknown; sid?: unknown; } /** 세션에 남은 고름 — 없으면 null. `key` 는 횡단도가 부재를 부르는 이름(조정창 여는 데 씀). */ export function readSelection( projectId: string | null, ): (DesignSelection & { partKey?: string }) | null { if (!projectId) return null; try { const value = readState("structure-pick", projectId); if (!value || typeof value.at !== "number" || !Number.isFinite(value.at)) return null; return { chainageM: value.at, structureId: typeof value.sid === "string" ? value.sid : null, partKey: typeof value.key === "string" ? value.key : undefined, }; } catch { return null; } } export type SelectionListener = (selection: DesignSelection, from: string | undefined) => void; const listeners = new Map>(); let dispatching = false; /** 고름이 바뀔 때 부른다. 돌려주는 함수로 구독을 끊는다. */ export function subscribeSelection(projectId: string | null, fn: SelectionListener): () => void { if (!projectId) return () => undefined; let set = listeners.get(projectId); if (!set) listeners.set(projectId, (set = new Set())); set.add(fn); return () => set.delete(fn); } /** * 고름을 적고 구독자에게 알린다. `partKey` 를 안 주면 이미 적힌 부재키를 지킨다 — 3D 로 * 부재를 집은 직후 선택 동기화가 측점만 다시 적어도 조정창이 그대로 열려야 한다. * `from` 은 낸 쪽 이름 — 구독자가 제 것을 되받아 다시 그리지 않게 거른다. * 값이 그대로면 알리지 않고, 알림 도중 다시 낸 값은 적기만 한다(되울림 방지). */ export function selectDesign( projectId: string | null, selection: DesignSelection, options: { partKey?: string; from?: string } = {}, ): void { if (!projectId) return; if (selection.chainageM === null) { writeState("structure-pick", null, projectId); } else { const before = readSelection(projectId); const same = before !== null && before.chainageM === selection.chainageM && before.structureId === selection.structureId && (options.partKey === undefined || options.partKey === before.partKey); if (same) return; const key = options.partKey ?? before?.partKey; writeState( "structure-pick", { at: selection.chainageM, key, sid: selection.structureId ?? undefined }, projectId, ); } if (dispatching) return; dispatching = true; try { for (const fn of [...(listeners.get(projectId) ?? [])]) fn(selection, options.from); } finally { dispatching = false; } }