Files
Aislo/B05_Profile/B05_Profile_UI_Viewer_Menu.ts
T
eomsangdonandClaude Fable 5 2745168888 feat(B05): C군 기본값·마커 민감도·3D 우클릭 배치·메뉴 단일화·3D 스냅·측점선 연장
- 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 <noreply@anthropic.com>
2026-08-19 18:32:55 +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)),
);
});
}