Files
Aislo/B05_Profile/B05_Profile_UI_Viewer_Menu.ts
T
eomsangdonandClaude Opus 5.5 0f8efaa424 feat(common): B · M 페이지 브라우저 기본 오른쪽 클릭 메뉴 막기 — 공용 진입 창 캡처 한 곳 · CAD iframe 도 · 글 입력 칸은 살림
- 새 ui_template_native_menu.ts — 주소 /^[bm]\d/ · 글 입력 칸(input · textarea · contenteditable · 읽기 전용 포함) 제외 · preventDefault 만(우리 메뉴 그대로)
- main.ts 공용 진입 한 줄 · cad_host.ts iframe load 때 같은 함수
- 「브라우저 메뉴 그대로 둔다」 주석 셋 바로잡음(B04 지도 · B05 배수 · 3D)
- 시험 test_common_native_menu.py (PLAN 39-9a)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UoSr2sqvYtBf1dtQdus88s
2026-10-04 17:12:28 +09:00

90 lines
4.0 KiB
TypeScript

/* =============================================================================
* 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<StructureMenuType>;
/** 종류 선택 — 사이드 폼 자동 지정(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)),
);
});
}