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:
2026-08-19 18:32:55 +09:00
co-authored by Claude Fable 5
parent 19c0b085ab
commit 2745168888
10 changed files with 260 additions and 48 deletions
+25 -25
View File
@@ -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();
+4
View File
@@ -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;
+45 -10
View File
@@ -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,
/** 관 지점 목록이 바뀜 — 측점 투영과 알약 레인을 한 번에 맞춘다. */
+15 -7
View File
@@ -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);
+15 -4
View File
@@ -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();
+89
View File
@@ -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)),
);
});
}
+33
View File
@@ -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)) {