/* ============================================================================= * B05_Profile_UI_Viewer_Menu.ts * 3D 뷰포트 노선 위 우클릭 → 구조물 배치 메뉴 (2026-08-19 사용자 지시). * * 종단그래프·배수유역도와 **같은 메뉴**(구조물군 → 종류 2단, 선택 = 사이드 폼 * addAt 경로)를 3D에도 단다. 지형 우클릭 지점을 노선 폴리라인에 투영해 누가거리를 * 얻고, 노선에서 먼 자리는 메뉴를 띄우지 않는다(회전 등 기본 조작 방해 금지). * 전역 단일 메뉴 규칙(다른 영역에 열리면 이쪽은 닫힘, 좌·중클릭 닫힘)은 공용 * createMapContextMenu가 처리한다. * ========================================================================== */ import { createMapContextMenu } from "@ui/ui_template_context_menu"; import { structureGroupMenuItems, type StructureMenuType, } from "./B05_Profile_UI_Profile_Structures"; /** 노선 위로 본다 판정 거리(m) — 이보다 멀면 노선 우클릭이 아니다. */ const ROUTE_PICK_DISTANCE_M = 15; export interface ViewerMenuDeps { /** 3D 뷰포트 루트(position: relative) — 메뉴를 이 안에 절대 위치로 띄운다. */ root: HTMLElement; /** 화면 좌표 아래 지형의 모델 좌표(없으면 null). */ modelPointAt: (clientX: number, clientY: number) => { x: number; y: number } | null; /** 노선 폴리라인(모델 좌표, 시점→종점 순). 비어 있으면 메뉴를 띄우지 않는다. */ routePoints: () => ReadonlyArray<{ x: number; y: number }>; /** 레지스트리 타입 목록 — 사이드 「구조물 배치」 종류와 같은 원천. */ structureTypes: () => ReadonlyArray; /** 종류 선택 — 사이드 폼 자동 지정(addAt 경로, A군은 임시 배치까지). */ onPick: (chainageM: number, typeId: string) => void; } /** 모델 좌표를 노선 폴리라인에 투영 — 최근접 지점의 누가거리와 거리(m). */ function projectToRoute( polyline: ReadonlyArray<{ x: number; y: number }>, x: number, y: number, ): { chainage: number; distance: number } | null { if (polyline.length < 2) return null; let best: { chainage: number; distance: number } | null = null; let travelled = 0; for (let index = 1; index < polyline.length; index += 1) { const from = polyline[index - 1]; const to = polyline[index]; const dx = to.x - from.x; const dy = to.y - from.y; const span2 = dx * dx + dy * dy; if (span2 < 1e-12) continue; const span = Math.sqrt(span2); const t = Math.min(Math.max(((x - from.x) * dx + (y - from.y) * dy) / span2, 0), 1); const px = from.x + dx * t; const py = from.y + dy * t; const distance = Math.hypot(x - px, y - py); if (!best || distance < best.distance) { best = { chainage: travelled + span * t, distance }; } travelled += span; } return best; } /** 3D 뷰포트에 노선 우클릭 구조물 배치 메뉴를 붙인다(뷰어 수명과 같다). */ export function mountViewerStructureMenu(deps: ViewerMenuDeps): void { const menu = createMapContextMenu("b05-profile-chart"); deps.root.append(menu.element); deps.root.addEventListener("contextmenu", (event) => { if (menu.contains(event.target)) { event.preventDefault(); return; } const types = deps.structureTypes(); if (!types.length) return; const model = deps.modelPointAt(event.clientX, event.clientY); if (!model) return; const hit = projectToRoute(deps.routePoints(), model.x, model.y); // 노선에서 먼 자리는 브라우저 기본 메뉴를 그대로 둔다 — 배치 대상이 아니다. if (!hit || hit.distance > ROUTE_PICK_DISTANCE_M) return; event.preventDefault(); const chainage = Number(hit.chainage.toFixed(2)); const rect = deps.root.getBoundingClientRect(); menu.open( event.clientX - rect.left, event.clientY - rect.top, structureGroupMenuItems(types, (typeId) => deps.onPick(chainage, typeId)), ); }); }