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>
This commit is contained in:
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -351,6 +351,8 @@ export async function renderB05Route(root: HTMLElement): Promise<void> {
|
||||
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<void> {
|
||||
},
|
||||
isRestoring: () => restoring,
|
||||
});
|
||||
// 3D 노선 우클릭 → 구조물 배치 메뉴(그래프·배수유역도와 동일, 2026-08-19).
|
||||
bridge.mountViewerMenu(viewer, () => latest?.route_points ?? []);
|
||||
|
||||
function renderSections(detail: SectionDetailResponse, routeId?: number): void {
|
||||
currentSectionDetail = detail;
|
||||
|
||||
@@ -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 라벨용(배관은 관종·관경까지 따로 붙인다). */
|
||||
|
||||
@@ -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,
|
||||
/** 관 지점 목록이 바뀜 — 측점 투영과 알약 레인을 한 번에 맞춘다. */
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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<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)),
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -26,16 +26,44 @@ export interface MapContextMenu {
|
||||
contains: (target: EventTarget | null) => boolean;
|
||||
}
|
||||
|
||||
/* ── 전역 단일 메뉴 규칙 (2026-08-19 사용자 지시) ────────────────────────────
|
||||
* 종단그래프·배수유역도·3D 어디서 열리든 우클릭 메뉴는 **한 번에 하나**다 — 새로
|
||||
* 열리면 다른 영역의 메뉴는 닫힌다. 좌클릭(0)·중간버튼(1)은 어디서 눌러도 열린
|
||||
* 메뉴를 닫는다. 캡처 단계로 들어 지도 캔버스가 이벤트를 삼켜도 닫힌다. */
|
||||
const openMenus = new Set<Pick<MapContextMenu, "close" | "contains">>();
|
||||
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)) {
|
||||
|
||||
Reference in New Issue
Block a user