Files
Aislo/ui_template/ui_template_context_menu.ts
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

127 lines
5.3 KiB
TypeScript

/* =============================================================================
* 지도 우클릭 메뉴 (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<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 {
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<HTMLElement>(`.${blockClass}__context-menu-sub`)
.forEach((other) => (other.hidden = true));
element
.querySelectorAll<HTMLElement>(`.${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),
};
}