/* ============================================================================= * 지도 우클릭 메뉴 (B04 2D 지도 · B05 배수유역도 공용) * * 지도 위 객체를 우클릭했을 때 뜨는 작은 메뉴. 뷰포트의 자식으로 넣고 뷰포트 기준 절대 * 위치로 띄운다. * * **주의**: 메뉴는 뷰포트 안에 있으므로 메뉴 위에서 난 pointer 이벤트가 지도 조작으로 * 새어 들어간다. 걸러 내지 않으면 메뉴를 닫는 처리가 항목의 click보다 먼저 돌아 아무 일도 * 일어나지 않는다(2026-08-01 B04에서 실제로 발생). 그래서 `contains()`를 함께 제공한다 — * 지도 쪽 pointerdown/pointerup/contextmenu는 이 판정으로 먼저 걸러야 한다. * ========================================================================== */ /** 평면 항목 = [문구, 동작]. 하위 메뉴 항목 = {label, children} — 누르면 바로 아래로 * 펼쳐지는 아코디언(구조물군 → 종류, 2026-08-18 B05 우클릭 메뉴 일원화). */ export type MapContextMenuItem = | [label: string, action: () => void] | { label: string; children: Array<[label: string, action: () => void]> }; export interface MapContextMenu { /** 뷰포트에 append할 요소. */ element: HTMLElement; /** 위치는 뷰포트 기준 px. */ open: (x: number, y: number, items: MapContextMenuItem[]) => void; close: () => void; /** 이벤트가 메뉴 안에서 났는지. 지도 조작에서 걸러 내는 데 쓴다. */ 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 { const entry = document.createElement("button"); entry.type = "button"; entry.className = `${blockClass}__context-menu-item`; if (sub) entry.classList.add("is-sub"); entry.textContent = label; entry.addEventListener("click", () => { close(); action(); }); return entry; } 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)) { element.append(buildLeaf(item[0], item[1])); return; } // 하위 메뉴(아코디언) — 머리글을 누르면 종류 목록이 바로 아래로 펼쳐진다. // 한 번에 한 군만 연다(다른 군을 열면 앞의 군은 접힘). const head = document.createElement("button"); head.type = "button"; head.className = `${blockClass}__context-menu-item is-group`; head.textContent = `${item.label} ▸`; const body = document.createElement("div"); body.className = `${blockClass}__context-menu-sub`; body.hidden = true; item.children.forEach(([label, action]) => body.append(buildLeaf(label, action, true))); head.addEventListener("click", () => { const willOpen = body.hidden; element .querySelectorAll(`.${blockClass}__context-menu-sub`) .forEach((other) => (other.hidden = true)); element .querySelectorAll(`.${blockClass}__context-menu-item.is-group`) .forEach((other) => (other.textContent = other.textContent!.replace("▾", "▸"))); body.hidden = !willOpen; head.textContent = `${item.label} ${willOpen ? "▾" : "▸"}`; }); element.append(head, body); }); element.style.left = `${x}px`; element.style.top = `${y}px`; element.hidden = false; }, close, contains: (target) => target instanceof Node && element.contains(target), }; }