- 3-1 우클릭 메뉴 일원화: 종단그래프·배수유역도 빈 자리 메뉴 = 사이드 「구조물
배치」와 같은 구조물군(A~G·기타) → 종류 2단 아코디언(공용 컨텍스트 메뉴에
하위 메뉴 지원 추가). 선택 = 즉시 추가가 아니라 폼 자동 지정(addAt, A군은
임시 배치까지). 구 [배관 추가]·평면 나열·기성막이/대피로 항목 흡수.
- 3-2 유역 리스트 문구: 세월교 검토→세월교 제안, BOX암거 후보→BOX암거 제안,
추천 Ø{d} 배관→Ø{d} 배관 제안.
- 3-3 구조물 알약 벌룬: 아래(테이블에 가림) 대신 위로 — 1행 좌상, 2행 우상,
솔로 위 중앙.
- 3-4 사이드: 스크롤 영역 상단 여유 + 페이지 설정 샘플 간격 2열(종단 먼저).
- 3-5 배수유역도 위성사진 기본 꺼짐.
- 3-6 B04 초기화 버튼 톤 다운 — 붉은 테두리·글자만, 채움 없음.
- 3-7 그래프 빈 공간 클릭 = 완전 해제 — 알약 해제 신호가 아예 없던 것 수정 +
외부 해제 시 구조물군·종류까지 리셋(선택 없을 땐 유지).
검증: tsc·vite + Playwright 헤드 실화면 V1~V8 전부 PASS (PLAN.md 3-8).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
94 lines
4.1 KiB
TypeScript
94 lines
4.1 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;
|
|
}
|
|
|
|
/** `blockClass`는 페이지 CSS 블록명(예: `b04-map`, `b05-drainage`). */
|
|
export function createMapContextMenu(blockClass: string): MapContextMenu {
|
|
const element = document.createElement("div");
|
|
element.className = `${blockClass}__context-menu`;
|
|
element.hidden = true;
|
|
|
|
function close(): void {
|
|
element.hidden = true;
|
|
// 항목까지 비운다 — 남겨 두면 다음에 열었을 때 옛 동작이 함께 걸린다.
|
|
element.replaceChildren();
|
|
}
|
|
|
|
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) {
|
|
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),
|
|
};
|
|
}
|