From 2745168888bb38e21d17d74c5a4f9cdb8d27a80d Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 19 Aug 2026 18:32:55 +0900 Subject: [PATCH] =?UTF-8?q?feat(B05):=20C=EA=B5=B0=20=EA=B8=B0=EB=B3=B8?= =?UTF-8?q?=EA=B0=92=C2=B7=EB=A7=88=EC=BB=A4=20=EB=AF=BC=EA=B0=90=EB=8F=84?= =?UTF-8?q?=C2=B73D=20=EC=9A=B0=ED=81=B4=EB=A6=AD=20=EB=B0=B0=EC=B9=98?= =?UTF-8?q?=C2=B7=EB=A9=94=EB=89=B4=20=EB=8B=A8=EC=9D=BC=ED=99=94=C2=B73D?= =?UTF-8?q?=20=EC=8A=A4=EB=83=85=C2=B7=EC=B8=A1=EC=A0=90=EC=84=A0=20?= =?UTF-8?q?=EC=97=B0=EC=9E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - C군 기본값 확정(2026-08-19 사용자): 높이 2.5·길이 10·전길이 5 — 기본값이 생겨 required 해제. 기준 측점만 넣으면 범위가 바로 계산된다. - 드래그 민감도 완화: 종단 알약(누적→변위 14px, 임계 전 시각 이동 금지)·배수유역도 관 마커(이동 시작 8px 임계) — 고르려다 옮겨지는 문제. - 3D 노선 우클릭 → 구조물 배치 메뉴(신규 B05_Profile_UI_Viewer_Menu) — 지형 픽을 노선 폴리라인에 투영(15m 이내), 그래프·배수유역도와 같은 2단 메뉴·addAt 경로. - 우클릭 메뉴 전역 단일화(ui_template_context_menu): 새 메뉴가 열리면 다른 영역 메뉴는 닫히고, 좌클릭·중간버튼은 어디서든 닫는다(캡처 단계). - 3D 비정규 측점(배관 등) 좌표를 노선 폴리라인 chainage 투영으로 정확화 — 규칙 측점 직선보간의 곡선 모서리 잘림(노선 이탈) 해소. - 3D 측점선(노란 띠) 도로 반폭 1.5배 연장 — 측구 방향 램프는 끝단에 따라붙음. - 검증: pytest 112 통과(정책 테스트 기본값 반영), tsc, 헤드 브라우저 저장 왕복 (기준 3+0 → 55~65·anchor 60·before 5, 목록 '배수관 1/2' 번호 표기). Co-Authored-By: Claude Fable 5 --- B05_Profile/B05_Profile_Structure_Types.json | 50 +++++------ B05_Profile/B05_Profile_UI_Drainage_Pipes.ts | 11 +++ B05_Profile/B05_Profile_UI_Page.ts | 4 + B05_Profile/B05_Profile_UI_Page_Helpers.ts | 55 +++++++++--- B05_Profile/B05_Profile_UI_Page_Structures.ts | 19 ++++ .../B05_Profile_UI_Structures_Marks.ts | 22 +++-- .../B05_Profile_UI_Structures_Panel.ts | 6 +- B05_Profile/B05_Profile_UI_Viewer.ts | 19 +++- B05_Profile/B05_Profile_UI_Viewer_Menu.ts | 89 +++++++++++++++++++ ui_template/ui_template_context_menu.ts | 33 +++++++ 10 files changed, 260 insertions(+), 48 deletions(-) create mode 100644 B05_Profile/B05_Profile_UI_Viewer_Menu.ts diff --git a/B05_Profile/B05_Profile_Structure_Types.json b/B05_Profile/B05_Profile_Structure_Types.json index e46623ca..397d6642 100644 --- a/B05_Profile/B05_Profile_Structure_Types.json +++ b/B05_Profile/B05_Profile_Structure_Types.json @@ -517,8 +517,8 @@ "label": "높이", "input": "number", "unit": "m", - "default": null, - "required": true, + "default": 2.5, + "required": false, "phase": "b05" }, { @@ -526,8 +526,8 @@ "label": "길이", "input": "number", "unit": "m", - "default": null, - "required": true, + "default": 10, + "required": false, "phase": "b05" }, { @@ -535,7 +535,7 @@ "label": "전길이", "input": "number", "unit": "m", - "default": 0, + "default": 5, "required": false, "phase": "b05" }, @@ -566,8 +566,8 @@ "label": "높이(≤3.0)", "input": "number", "unit": "m", - "default": null, - "required": true, + "default": 2.5, + "required": false, "phase": "b05" }, { @@ -575,8 +575,8 @@ "label": "길이", "input": "number", "unit": "m", - "default": null, - "required": true, + "default": 10, + "required": false, "phase": "b05" }, { @@ -584,7 +584,7 @@ "label": "전길이", "input": "number", "unit": "m", - "default": 0, + "default": 5, "required": false, "phase": "b05" }, @@ -615,8 +615,8 @@ "label": "높이(≤2.0)", "input": "number", "unit": "m", - "default": null, - "required": true, + "default": 2.5, + "required": false, "phase": "b05" }, { @@ -624,8 +624,8 @@ "label": "길이", "input": "number", "unit": "m", - "default": null, - "required": true, + "default": 10, + "required": false, "phase": "b05" }, { @@ -633,7 +633,7 @@ "label": "전길이", "input": "number", "unit": "m", - "default": 0, + "default": 5, "required": false, "phase": "b05" }, @@ -664,8 +664,8 @@ "label": "높이", "input": "number", "unit": "m", - "default": null, - "required": true, + "default": 2.5, + "required": false, "phase": "b05" }, { @@ -673,8 +673,8 @@ "label": "길이", "input": "number", "unit": "m", - "default": null, - "required": true, + "default": 10, + "required": false, "phase": "b05" }, { @@ -682,7 +682,7 @@ "label": "전길이", "input": "number", "unit": "m", - "default": 0, + "default": 5, "required": false, "phase": "b05" }, @@ -713,8 +713,8 @@ "label": "높이", "input": "number", "unit": "m", - "default": null, - "required": true, + "default": 2.5, + "required": false, "phase": "b05" }, { @@ -722,8 +722,8 @@ "label": "길이", "input": "number", "unit": "m", - "default": null, - "required": true, + "default": 10, + "required": false, "phase": "b05" }, { @@ -731,7 +731,7 @@ "label": "전길이", "input": "number", "unit": "m", - "default": 0, + "default": 5, "required": false, "phase": "b05" }, diff --git a/B05_Profile/B05_Profile_UI_Drainage_Pipes.ts b/B05_Profile/B05_Profile_UI_Drainage_Pipes.ts index fdc07159..a91a0036 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Pipes.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Pipes.ts @@ -20,6 +20,8 @@ interface RoutePointLike { /** 마커 히트 판정 반경(px)과 계획선 추가 클릭 허용 거리(px). */ const HIT_RADIUS_PX = 12; +/** 마커 이동이 시작되는 최소 끌기 거리(px) — 이 안에서는 클릭(선택)으로 본다. */ +const DRAG_START_PX = 8; const ADD_SNAP_PX = 14; export interface PipeEditor { @@ -66,6 +68,9 @@ export function createPipeEditor( let selectedIndex: number | null = null; let draggingIndex: number | null = null; let dragMoved = false; + /** 마커를 잡은 화면 좌표 — 이 자리에서 DRAG_START_PX를 벗어나야 이동이 시작된다 + * (2026-08-19 사용자 보고: 고르려다 관이 옮겨지는 민감도 문제). */ + let dragDownAt: { x: number; y: number } | null = null; /** 화면 → 사업지 좌표계 m (MapRender affine의 역변환). */ function screenToMetric( @@ -245,6 +250,7 @@ export function createPipeEditor( if (Math.hypot(screenX - screen.x, screenY - screen.y) <= HIT_RADIUS_PX) { selectedIndex = i; draggingIndex = i; + dragDownAt = { x: screenX, y: screenY }; onChange(); return true; } @@ -253,6 +259,10 @@ export function createPipeEditor( }, handleMove(view, screenX, screenY) { if (draggingIndex === null) return false; + // 잡은 자리에서 충분히 끌기 전에는 이동하지 않는다 — 클릭 판정 여유(2026-08-19). + if (dragDownAt && Math.hypot(screenX - dragDownAt.x, screenY - dragDownAt.y) < DRAG_START_PX) + return true; + dragDownAt = null; const metric = screenToMetric(view, screenX, screenY); if (!metric) return true; const nearest = nearestChainage(metric.x, metric.y); @@ -267,6 +277,7 @@ export function createPipeEditor( handleUp() { if (draggingIndex === null) return false; draggingIndex = null; + dragDownAt = null; if (dragMoved) { sortPipes(); onChange(); diff --git a/B05_Profile/B05_Profile_UI_Page.ts b/B05_Profile/B05_Profile_UI_Page.ts index bccc6121..0ad1ca1f 100644 --- a/B05_Profile/B05_Profile_UI_Page.ts +++ b/B05_Profile/B05_Profile_UI_Page.ts @@ -351,6 +351,8 @@ export async function renderB05Route(root: HTMLElement): Promise { regular, bridge.irregularStations(), detail.longitudinal.length_m, + // 중심 좌표는 노선 폴리라인 기준 — 3D 마커가 노선 위에 정확히 얹힌다(2026-08-19). + latest?.route_points ?? [], ); // 측점 바 양 끝 램프용 상단측: 사용자 변경분 → solve 자동 판정 순으로 적용. const withUphill = [...regular, ...injected].map((station) => ({ @@ -371,6 +373,8 @@ export async function renderB05Route(root: HTMLElement): Promise { }, isRestoring: () => restoring, }); + // 3D 노선 우클릭 → 구조물 배치 메뉴(그래프·배수유역도와 동일, 2026-08-19). + bridge.mountViewerMenu(viewer, () => latest?.route_points ?? []); function renderSections(detail: SectionDetailResponse, routeId?: number): void { currentSectionDetail = detail; diff --git a/B05_Profile/B05_Profile_UI_Page_Helpers.ts b/B05_Profile/B05_Profile_UI_Page_Helpers.ts index c1132925..038954f4 100644 --- a/B05_Profile/B05_Profile_UI_Page_Helpers.ts +++ b/B05_Profile/B05_Profile_UI_Page_Helpers.ts @@ -86,15 +86,43 @@ export function circlePoint(point: PlacedRoutePoint): CirclePoint { return { ...routePoint(point), radius_m: point.radius_m ?? 25 }; } +/** 노선 폴리라인 위 chainage 지점의 (x, y) — 세그먼트 누적 길이로 정확히 되짚는다. + * 규칙 측점(20m 간격) 사이 직선보간은 곡선 구간에서 모서리를 잘라 3D 마커가 노선을 + * 벗어난다(2026-08-19 사용자 보고 — 도엽 등고선 분석 좌표와 라이다 노선의 미세 차). + * 폴리라인은 노선 정본이라 이 좌표가 3D 노선 선 위에 정확히 얹힌다. */ +export function chainageToPolylineXY( + polyline: ReadonlyArray<{ x: number; y: number }>, + chainage: number, +): { x: number; y: number } | null { + if (polyline.length < 2) return null; + let travelled = 0; + for (let index = 1; index < polyline.length; index += 1) { + const from = polyline[index - 1]; + const to = polyline[index]; + const span = Math.hypot(to.x - from.x, to.y - from.y); + if (span < 1e-9) continue; + if (travelled + span >= chainage - 1e-6) { + const t = Math.min(Math.max((chainage - travelled) / span, 0), 1); + return { x: from.x + (to.x - from.x) * t, y: from.y + (to.y - from.y) * t }; + } + travelled += span; + } + const last = polyline[polyline.length - 1]; + return { x: last.x, y: last.y }; +} + /** * 비정규 측점을 규칙 측점 좌표 사이 chainage로 선형보간해 `SectionStation`(월드 좌표·프레임 포함)으로 * 만든다. 백엔드가 아직 이 측점의 횡단을 생성하지 않으므로, 3D 표시에 필요한 위치만 근사한다. - * 노선 범위를 벗어난 chainage는 제외한다. + * 노선 범위를 벗어난 chainage는 제외한다. `routePolyline`을 주면 중심 (x, y)는 노선 + * 폴리라인에서 정확히 되짚는다(곡선 모서리 잘림 방지, 2026-08-19) — 표고·프레임은 + * 여전히 규칙 측점 보간값이다. */ export function interpolateIrregularStations( base: SectionStation[], list: IrregularStation[], maxChainage: number, + routePolyline: ReadonlyArray<{ x: number; y: number }> = [], ): SectionStation[] { const sorted = [...base].sort((a, b) => a.chainage_m - b.chainage_m); if (!sorted.length) return []; @@ -133,15 +161,22 @@ export function interpolateIrregularStations( }; return list .filter((entry) => entry.chainage_m >= 0 && entry.chainage_m <= maxChainage + 1e-6) - .map((entry) => ({ - ...anchorAt(entry.chainage_m), - station_id: irregularStationId(entry.id), - chainage_m: entry.chainage_m, - label: irregularLabel(entry), - kind: "irregular" as const, - // 3D 측점 라벨이 `측점번호 구조물명`으로 표기할 수 있게 구조물 이름을 실어 보낸다. - structure: entry.structure, - })); + .map((entry) => { + const anchor = anchorAt(entry.chainage_m); + // 중심 좌표는 노선 폴리라인에서 정확히 — 곡선에서 규칙 측점 직선보간이 만드는 + // 모서리 잘림(3D 마커가 노선 옆으로 새는 현상)을 없앤다(2026-08-19). + const onRoute = chainageToPolylineXY(routePolyline, entry.chainage_m); + return { + ...anchor, + ...(onRoute ? { center_x: onRoute.x, center_y: onRoute.y } : {}), + station_id: irregularStationId(entry.id), + chainage_m: entry.chainage_m, + label: irregularLabel(entry), + kind: "irregular" as const, + // 3D 측점 라벨이 `측점번호 구조물명`으로 표기할 수 있게 구조물 이름을 실어 보낸다. + structure: entry.structure, + }; + }); } /** 시설 종류별 표시 이름 — 그래프·3D 라벨용(배관은 관종·관경까지 따로 붙인다). */ diff --git a/B05_Profile/B05_Profile_UI_Page_Structures.ts b/B05_Profile/B05_Profile_UI_Page_Structures.ts index 5c7358f8..00af15b7 100644 --- a/B05_Profile/B05_Profile_UI_Page_Structures.ts +++ b/B05_Profile/B05_Profile_UI_Page_Structures.ts @@ -13,6 +13,7 @@ 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, @@ -262,6 +263,24 @@ export function createStructuresBridge(deps: StructuresBridgeDeps) { } return { + /** 3D 노선 우클릭 → 구조물 배치 메뉴(그래프·배수유역도와 동일, 2026-08-19). + * 타입 목록은 레지스트리 로드 후 채워지는 structureTypeMap을 그대로 쓴다. */ + 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, + structureTypes: () => + [...structureTypeMap].map(([type_id, info]) => ({ type_id, ...info })), + onPick: (chainage, typeId) => deps.panel().structures.addAt(chainage, typeId), + }); + }, /** 그래프·3D가 쓰는 현재 비정규 측점 목록. */ irregularStations: () => irregularStations, /** 관 지점 목록이 바뀜 — 측점 투영과 알약 레인을 한 번에 맞춘다. */ diff --git a/B05_Profile/B05_Profile_UI_Structures_Marks.ts b/B05_Profile/B05_Profile_UI_Structures_Marks.ts index b2e29e9f..40cd7296 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Marks.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Marks.ts @@ -21,8 +21,10 @@ import { } from "./B05_Profile_Api_Structures"; import { formatStation } from "./B05_Profile_Util_Station"; -/** 마크를 잡았다고 볼 여유(px). 손떨림을 이동으로 오해하지 않게 한다. */ -const GRAB_SLACK_PX = 10; +/** 마크를 잡았다고 볼 여유(px) — 시작점에서 이만큼 **벗어나기 전에는** 이동으로 + * 보지 않고 시각적으로도 움직이지 않는다. 예전 10px 누적 방식은 손떨림이 쌓여 + * 고르려다 옮겨지는 일이 잦았다(2026-08-19 사용자 보고 — 민감도 완화). */ +const GRAB_SLACK_PX = 14; /** 겹쳐 두 줄로 갈릴 때의 알약 높이(px) — 레인 높이를 정하는 값이다. */ const PILL_HEIGHT_PX = 24; /** 혼자 있는(가운데 한 줄) 알약 높이(px) — 자리가 남으니 30%만 키운다 @@ -237,19 +239,25 @@ function attachMarkDrag( options: StructureMarksOptions, ): void { let dragging = false; - let movedPx = 0; + let startX = 0; + /** 시작점에서 가장 멀리 벗어난 거리(px) — 누적이 아니라 변위라 손떨림이 안 쌓인다. */ + let maxOffsetPx = 0; mark.addEventListener("pointerdown", (event) => { if (event.button !== 0) return; dragging = true; - movedPx = 0; + startX = event.clientX; + maxOffsetPx = 0; mark.setPointerCapture(event.pointerId); event.stopPropagation(); }); mark.addEventListener("pointermove", (event) => { if (!dragging) return; - movedPx += Math.abs(event.movementX); + maxOffsetPx = Math.max(maxOffsetPx, Math.abs(event.clientX - startX)); + // 여유를 벗어나기 전에는 시각적으로도 움직이지 않는다 — 클릭 중 마크가 흔들리면 + // 이동으로 오해하기 쉽다(2026-08-19 민감도 완화). + if (maxOffsetPx <= GRAB_SLACK_PX) return; const lane = mark.parentElement; if (!lane) return; const localX = event.clientX - lane.getBoundingClientRect().left; @@ -260,8 +268,8 @@ function attachMarkDrag( if (!dragging) return; dragging = false; mark.releasePointerCapture(event.pointerId); - // 손이 떨린 정도(수 px)는 이동이 아니라 클릭으로 본다 — 고르려다 옮겨지면 곤란하다. - if (movedPx <= GRAB_SLACK_PX) return; + // 여유 안에서 뗐으면 이동이 아니라 클릭이다 — 고르려다 옮겨지면 곤란하다. + if (maxOffsetPx <= GRAB_SLACK_PX) return; const lane = mark.parentElement; if (!lane) return; const localX = event.clientX - lane.getBoundingClientRect().left; diff --git a/B05_Profile/B05_Profile_UI_Structures_Panel.ts b/B05_Profile/B05_Profile_UI_Structures_Panel.ts index 0d1e1115..fb00e1c9 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Panel.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Panel.ts @@ -531,8 +531,10 @@ export function createStructuresSection(callbacks: StructuresCallbacks): Structu } const beforeEntry = optionInputs.find((entry) => entry.key === "before_m"); if (beforeEntry && target.options.before_m === undefined) { - const before = structureAnchorM(target) - target.start_m; - if (before > 0.005) beforeEntry.input.value = before.toFixed(1); + // 옵션이 없던 시절 항목은 기본값(5)이 아니라 저장된 기준−시작을 그대로 쓴다 + // — 0이어도 명시해야 기본값이 범위를 옆으로 밀지 않는다. + const before = Math.max(structureAnchorM(target) - target.start_m, 0); + beforeEntry.input.value = before.toFixed(1); } } syncFacilityForm(target.options); diff --git a/B05_Profile/B05_Profile_UI_Viewer.ts b/B05_Profile/B05_Profile_UI_Viewer.ts index 821aecd3..91caa0d5 100644 --- a/B05_Profile/B05_Profile_UI_Viewer.ts +++ b/B05_Profile/B05_Profile_UI_Viewer.ts @@ -55,6 +55,8 @@ export interface RouteViewer { renderStationLines: (stations: SectionStationMarker[], halfWidth: number) => void; setView: (view: "iso" | "top" | "front" | "side") => void; beginMoveSelected: () => void; + /** 화면(client) 좌표 아래 지형의 모델 좌표 — 3D 우클릭 구조물 배치용(2026-08-19). */ + modelPointAt: (clientX: number, clientY: number) => { x: number; y: number; z: number } | null; dispose: () => void; } @@ -219,14 +221,16 @@ export function createRouteViewer(): RouteViewer { }); } - function terrainPoint( - event: PointerEvent | DragEvent, + /** 화면(client) 좌표 아래 지형의 모델 좌표 — 우클릭 구조물 배치 등 외부 픽에도 쓴다. */ + function terrainPointAt( + clientX: number, + clientY: number, ): { x: number; y: number; z: number } | null { if (!terrain || !bounds) return null; const rect = canvas.getBoundingClientRect(); const pointer = new THREE.Vector2( - ((event.clientX - rect.left) / rect.width) * 2 - 1, - -((event.clientY - rect.top) / rect.height) * 2 + 1, + ((clientX - rect.left) / rect.width) * 2 - 1, + -((clientY - rect.top) / rect.height) * 2 + 1, ); const raycaster = new THREE.Raycaster(); raycaster.setFromCamera(pointer, camera); @@ -234,6 +238,12 @@ export function createRouteViewer(): RouteViewer { return hit ? sceneToModel(hit.point, bounds) : null; } + function terrainPoint( + event: PointerEvent | DragEvent, + ): { x: number; y: number; z: number } | null { + return terrainPointAt(event.clientX, event.clientY); + } + canvas.addEventListener("dragover", (event) => event.preventDefault()); canvas.addEventListener("drop", (event) => { event.preventDefault(); @@ -426,6 +436,7 @@ export function createRouteViewer(): RouteViewer { movingSelected = true; status.textContent = "선택한 포인트를 이동할 지형 위치를 클릭하세요."; }, + modelPointAt: terrainPointAt, dispose() { cancelAnimationFrame(frame); resizeObserver.disconnect(); diff --git a/B05_Profile/B05_Profile_UI_Viewer_Menu.ts b/B05_Profile/B05_Profile_UI_Viewer_Menu.ts new file mode 100644 index 00000000..69a2c728 --- /dev/null +++ b/B05_Profile/B05_Profile_UI_Viewer_Menu.ts @@ -0,0 +1,89 @@ +/* ============================================================================= + * 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)), + ); + }); +} diff --git a/ui_template/ui_template_context_menu.ts b/ui_template/ui_template_context_menu.ts index 3d4d42e3..3998986c 100644 --- a/ui_template/ui_template_context_menu.ts +++ b/ui_template/ui_template_context_menu.ts @@ -26,16 +26,44 @@ export interface MapContextMenu { contains: (target: EventTarget | null) => boolean; } +/* ── 전역 단일 메뉴 규칙 (2026-08-19 사용자 지시) ──────────────────────────── + * 종단그래프·배수유역도·3D 어디서 열리든 우클릭 메뉴는 **한 번에 하나**다 — 새로 + * 열리면 다른 영역의 메뉴는 닫힌다. 좌클릭(0)·중간버튼(1)은 어디서 눌러도 열린 + * 메뉴를 닫는다. 캡처 단계로 들어 지도 캔버스가 이벤트를 삼켜도 닫힌다. */ +const openMenus = new Set>(); +let globalCloseBound = false; + +function bindGlobalClose(): void { + if (globalCloseBound) return; + globalCloseBound = true; + document.addEventListener( + "pointerdown", + (event) => { + openMenus.forEach((menu) => { + if (!menu.contains(event.target)) menu.close(); + }); + }, + true, + ); +} + /** `blockClass`는 페이지 CSS 블록명(예: `b04-map`, `b05-drainage`). */ export function createMapContextMenu(blockClass: string): MapContextMenu { + bindGlobalClose(); const element = document.createElement("div"); element.className = `${blockClass}__context-menu`; element.hidden = true; + const registryEntry = { + close, + contains: (target: EventTarget | null) => target instanceof Node && element.contains(target), + }; + function close(): void { element.hidden = true; // 항목까지 비운다 — 남겨 두면 다음에 열었을 때 옛 동작이 함께 걸린다. element.replaceChildren(); + openMenus.delete(registryEntry); } function buildLeaf(label: string, action: () => void, sub = false): HTMLButtonElement { @@ -54,6 +82,11 @@ export function createMapContextMenu(blockClass: string): MapContextMenu { return { element, open(x, y, items) { + // 다른 영역에 열려 있던 메뉴는 닫는다 — 전역에 하나만(2026-08-19 사용자 지시). + openMenus.forEach((menu) => { + if (menu !== registryEntry) menu.close(); + }); + openMenus.add(registryEntry); element.replaceChildren(); items.forEach((item) => { if (Array.isArray(item)) {