/* ============================================================================= * 종단 테이블 구조물 배치 라인 (B05) * * 종단 **그래프** 위 우클릭 메뉴. 구조물 조작은 전부 그래프에서 한다 — 측점선을 끌어 옮기고, * 우클릭으로 배관을 넣거나 지운다(2026-08-01 사용자 지시). 테이블은 값만 보여 준다. * * 가까운 구조물이 있으면 삭제, 없으면 그 자리에 배관 추가를 띄운다. 배관은 배수유역도의 관 지점이 * 투영된 것이라(`origin: "pipe"`), 지우면 관 지점 정본(`pipe_points.json`)이 바뀌고 세부유역도 * 함께 다시 나뉜다 — 값을 두 곳에 따로 쌓지 않기 위해서다. * ========================================================================== */ import { createMapContextMenu, type MapContextMenuItem } from "@ui/ui_template_context_menu"; import { irregularStationId, isPipeStation, type IrregularStation, } from "./B05_Profile_UI_IrregularStations"; import { structureAnchorM, type StructureInstance } from "./B05_Profile_Api_Structures"; import { GROUP_LABELS } from "./B05_Profile_UI_Structures_Panel"; /** 우클릭 메뉴용 최소 타입 정보 — 사이드 「구조물 배치」 종류 목록과 같은 원천. */ export interface StructureMenuType { type_id: string; group: string; name: string; } /** 사이드 「구조물 배치」와 같은 구조물군 → 종류 2단 메뉴 항목을 만든다 * (2026-08-18 우클릭 메뉴 일원화 — 종단그래프·배수유역도 공용). 항목 선택 = * 즉시 추가가 아니라 폼 자동 지정(addAt 경로). */ export function structureGroupMenuItems( types: ReadonlyArray, onPick: (typeId: string) => void, ): MapContextMenuItem[] { const groups = [...new Set(types.map((type) => type.group))]; return groups.map((group) => ({ label: GROUP_LABELS[group] ?? group, children: types .filter((type) => type.group === group) .map((type): [string, () => void] => [type.name, () => onPick(type.type_id)]), })); } /** 선을 잡았다고 볼 좌우 여유(px). 선 자체는 얇아 그대로는 집기 어렵다. */ const GRAB_SLACK_PX = 6; export interface StructureLineOptions { /** 테이블에 얹을 구조물 측점 목록. */ stations: ReadonlyArray; /** chainage → x(px). 테이블·그래프와 같은 매핑을 쓴다. */ x: (chainageM: number) => number; /** x(px) → chainage. 선을 끌 때 역변환에 쓴다. */ chainageAt: (px: number) => number; /** 노선 총 연장(m). 이 범위를 벗어난 자리로는 옮기지 않는다. */ maxChainageM: number; /** 선을 지울 때. */ onRemove: (station: IrregularStation) => void; /** 빈 자리에서 우클릭해 배관을 넣을 때. */ onAddPipe: (chainageM: number) => void; /** 빈 자리에서 우클릭해 배관 외 구조물(기성막이/대피로/기타)을 넣을 때. */ onAddStructure?: (chainageM: number, structureType: "기성막이" | "대피로" | "기타") => void; /** 레지스트리 타입 목록(구조물군·이름). 우클릭 메뉴의 "구조물 추가" 항목이 된다. */ structureTypes?: ReadonlyArray<{ type_id: string; group: string; name: string }>; /** 레지스트리 타입을 골라 넣을 때. */ onAddStructureType?: (chainageM: number, typeId: string) => void; } /** * 그래프 칸에 우클릭 메뉴를 붙인다. 그래프는 매 그리기마다 새로 만들어지므로 이 함수도 * 그때마다 다시 부른다 — 상태를 밖에 남기지 않는다. */ export function mountStructureMenu(host: HTMLElement, options: StructureLineOptions): void { const menu = createMapContextMenu("b05-profile-chart"); function clamp(chainageM: number): number { return Math.min(Math.max(chainageM, 0), options.maxChainageM); } // 우클릭 — 가까운 구조물이 있으면 삭제, 없으면 그 자리에 배관을 넣는다. host.addEventListener("contextmenu", (event) => { if (menu.contains(event.target)) { event.preventDefault(); return; } const rect = host.getBoundingClientRect(); const localX = event.clientX - rect.left; const chainage = clamp(options.chainageAt(localX)); // 선을 그리지 않으므로 화면 거리로 가장 가까운 구조물을 찾는다. const near = options.stations .map((station) => ({ station, distance: Math.abs(options.x(station.chainage_m) - localX) })) .filter((entry) => entry.distance <= GRAB_SLACK_PX) .sort((left, right) => left.distance - right.distance)[0]; event.preventDefault(); const at = Number(chainage.toFixed(2)); // 빈 자리 — 사이드 「구조물 배치」와 같은 구조물군 → 종류 2단 메뉴(2026-08-18 // 일원화). 선택 = 폼 자동 지정(addAt 경로, A군은 임시 배치까지). 레지스트리를 // 아직 못 받았으면 구 항목(배관·기성막이 등)으로 대신한다. const types = options.structureTypes ?? []; const addItems: MapContextMenuItem[] = types.length ? structureGroupMenuItems(types, (typeId) => options.onAddStructureType?.(at, typeId)) : [ ["배관 추가", () => options.onAddPipe(at)], ...(["기성막이", "대피로", "기타"] as const).map((type): [string, () => void] => [ `${type === "기타" ? "기타 구조물" : type} 추가`, () => options.onAddStructure?.(at, type), ]), ]; menu.open(localX, event.clientY - rect.top, [ ...(near ? [ [ isPipeStation(near.station) ? "배관 삭제" : "구조물 삭제", () => options.onRemove(near.station), ] as [string, () => void], ] : addItems), ]); }); host.addEventListener("pointerdown", (event) => { if (!menu.contains(event.target)) menu.close(); }); host.append(menu.element); } /** 같은 자리로 볼 여유(m) — 측점선과 알약은 같은 누가거리를 쓰지만 소수점이 갈린다. */ export const SAME_CHAINAGE_M = 0.51; /** * 측점선 id ↔ 알약(구조물) id 짝짓기. * * 둘은 한 구조물의 두 표시라 선택이 함께 움직여야 한다(2026-08-17 사용자 지시). * 패널 본체가 700줄 한계라 이 짝짓기만 여기로 옮겼다(2026-09-02). */ export function structureIdAtStation( stationId: string | null, stations: IrregularStation[], structures: StructureInstance[], ): string | null { if (stationId === null) return null; const prefix = irregularStationId(""); if (!stationId.startsWith(prefix)) return null; const station = stations.find((entry) => irregularStationId(entry.id) === stationId); if (!station) return null; const hit = structures.find( (item) => Math.abs(structureAnchorM(item) - station.chainage_m) < SAME_CHAINAGE_M, ); return hit?.structure_id ?? null; } /** 알약(구조물) id → 측점선 id. 세로선이 없는 구조물(A군 외)이면 null. */ export function stationIdAtStructure( structureId: string | null, stations: IrregularStation[], structures: StructureInstance[], ): string | null { if (structureId === null) return null; const structure = structures.find((item) => item.structure_id === structureId); if (!structure) return null; const anchor = structureAnchorM(structure); const station = stations.find((entry) => Math.abs(entry.chainage_m - anchor) < SAME_CHAINAGE_M); return station ? irregularStationId(station.id) : null; }