- 624-3 도구줄 = 공용 표 「b05」 · 아래 줄 = 공용 겹 줄(띠 · 올린 임도망도 숨김 · 처음 = 등고선 · 하천) · 범례 = 공용 legendRows(상태 글판 위) - 노선 띠 300 m 자르기 = B05 자기 칸(등고선 · 하천 · 1 m)만 · 그 밖 겹 = 안 자르는 둘째 GL 칸(맨 밑 · 켠 겹 있을 때만 · GL 만) · 등고선 끔 = 등고 글도 숨김 - 626-1 추천 보기 = 도구줄 아이콘 + 아래 판(영선 · 토공 최소 · 기울기 최소 · 비중 계산 · 끔) — route_recommend 저장본(지금 설계 지형) 선만 · 다시 셈 · 쓰기 없음 - 지형 칩 → 확인창 → 새 POST /route/design-terrain(설계 지형만 바꿈 · 노선 저장 체인 다시 · 노선 · 초기값 · 추천 저장본 그대로 · 잠금 · 실패 되돌림) - 걷음 — 추천 모달(_Recommend · _Help · _Map · CSS) · 좌측 「노선 추천」 칸 · saveRecommendedRoute · UI_RouteReset(서버 as_initial 길 · 시험은 남김) - 부품 상자 — createPickMenu(아이콘 고르기 메뉴 · 판 = body fixed · ✓) - 낡은 시험 고침 · 지움 — 모달 · 좌측 칸 · [적용] 화면 배선을 보던 시험(283 · 346_3 · 349 · 364_4 · 383_1 · 390_5 · 418 · 421 · 421_2 · 476_2 · 509_2 · 509_5 · 551_2 · 600_3 · b05_routeedit_initial · design_view_parity · modal_top · 133_1 · 133_2) · test_508_2 · helper_283 지움(모달 전용) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
384 lines
16 KiB
TypeScript
384 lines
16 KiB
TypeScript
/* =============================================================================
|
|
* 지도 우클릭 메뉴 (B04 2D 지도 · B05 배수유역도 공용)
|
|
*
|
|
* 지도 위 객체를 우클릭했을 때 뜨는 작은 메뉴. 뷰포트의 자식으로 넣고 뷰포트 기준 절대
|
|
* 위치로 띄운다.
|
|
*
|
|
* **주의**: 메뉴는 뷰포트 안에 있으므로 메뉴 위에서 난 pointer 이벤트가 지도 조작으로
|
|
* 새어 들어간다. 걸러 내지 않으면 메뉴를 닫는 처리가 항목의 click보다 먼저 돌아 아무 일도
|
|
* 일어나지 않는다(2026-08-01 B04에서 실제로 발생). 그래서 `contains()`를 함께 제공한다 —
|
|
* 지도 쪽 pointerdown/pointerup/contextmenu는 이 판정으로 먼저 걸러야 한다.
|
|
* 모양은 공용 CSS 한 벌(`ui_template_context_menu.css` · `.ui-ctx-menu`) — 나무 메뉴와 같음(계획서 39-9b).
|
|
* ========================================================================== */
|
|
|
|
import { createIconButton } from "./ui_template_button";
|
|
import type { IconName } from "./ui_template_icons";
|
|
import "./ui_template_context_menu.css";
|
|
import { showConfirmDialog } from "./ui_template_modal";
|
|
|
|
/** 평면 항목 = [문구, 동작]. 하위 메뉴 항목 = {label, children} — 「글 ▸」 한 줄 · 올리거나 누르면
|
|
* 오른쪽 옆 새 판(하위 메뉴 · 화면 오른쪽 끝이면 왼쪽) · → 열기 · ← 닫기(PLAN 246-2).
|
|
* 구분선 = `"-"` — 맨 앞 · 맨 뒤 · 겹친 것은 안 그림. */
|
|
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)은 어디서 눌러도 열린
|
|
* 메뉴를 닫는다. 캡처 단계로 들어 지도 캔버스가 이벤트를 삼켜도 닫힌다. */
|
|
type MenuEntry = Pick<MapContextMenu, "close" | "contains">;
|
|
const openMenus = new Set<MenuEntry>();
|
|
let globalCloseBound = false;
|
|
|
|
/** 다른 메뉴를 닫고 장부에 올림 — 나무 메뉴(`ui_template_tree.ts`)도 같은 장부(한 번에 하나) */
|
|
export function enterMenu(entry: MenuEntry): void {
|
|
bindGlobalClose();
|
|
openMenus.forEach((menu) => {
|
|
if (menu !== entry) menu.close();
|
|
});
|
|
openMenus.add(entry);
|
|
}
|
|
|
|
/** 닫힌 메뉴를 장부에서 뺌 */
|
|
export function leaveMenu(entry: MenuEntry): void {
|
|
openMenus.delete(entry);
|
|
}
|
|
|
|
function bindGlobalClose(): void {
|
|
if (globalCloseBound) return;
|
|
globalCloseBound = true;
|
|
document.addEventListener(
|
|
"pointerdown",
|
|
(event) => {
|
|
openMenus.forEach((menu) => {
|
|
if (!menu.contains(event.target)) menu.close();
|
|
});
|
|
},
|
|
true,
|
|
);
|
|
// Esc 도 열린 메뉴를 닫음 — 메뉴가 열렸을 때만 키를 먹음(편집 취소 등 아래로 안 샘)
|
|
document.addEventListener(
|
|
"keydown",
|
|
(event) => {
|
|
if (event.key !== "Escape" || !openMenus.size) return;
|
|
openMenus.forEach((menu) => menu.close());
|
|
event.stopPropagation();
|
|
event.preventDefault();
|
|
},
|
|
true,
|
|
);
|
|
}
|
|
|
|
/** `blockClass`는 페이지 CSS 블록명(예: `b04-map`, `b05-drainage`). */
|
|
export function createMapContextMenu(blockClass: string): MapContextMenu {
|
|
bindGlobalClose();
|
|
const element = document.createElement("div");
|
|
element.className = `${blockClass}__context-menu ui-ctx-menu`;
|
|
element.hidden = true;
|
|
// 메뉴 안 click 은 메뉴에서 끝냄 — 그릇(종단 그래프 등)의 「빈 곳 클릭 = 선택 해제」로 새면
|
|
// 폼이 리셋되고 다시 그려져 메뉴가 사라짐(54-49).
|
|
element.addEventListener("click", (event) => event.stopPropagation());
|
|
|
|
const registryEntry = {
|
|
close,
|
|
contains: (target: EventTarget | null) => target instanceof Node && element.contains(target),
|
|
};
|
|
|
|
function close(): void {
|
|
element.hidden = true;
|
|
// 항목까지 비운다 — 남겨 두면 다음에 열었을 때 옛 동작이 함께 걸린다.
|
|
element.replaceChildren();
|
|
leaveMenu(registryEntry);
|
|
}
|
|
|
|
function buildLeaf(label: string, action: () => void, sub = false): HTMLButtonElement {
|
|
const entry = document.createElement("button");
|
|
entry.type = "button";
|
|
entry.className = `${blockClass}__context-menu-item ui-ctx-menu__item`;
|
|
if (!sub) entry.addEventListener("pointerenter", closeSide); // 다른 줄로 옮기면 옆 판 닫힘
|
|
entry.textContent = label;
|
|
entry.addEventListener("click", () => {
|
|
close();
|
|
action();
|
|
});
|
|
return entry;
|
|
}
|
|
|
|
/** 옆 판(하위 메뉴) 닫음 — 머리 줄 표시도 풂 */
|
|
function closeSide(): void {
|
|
element.querySelector(".ui-ctx-menu__side")?.remove();
|
|
element
|
|
.querySelectorAll<HTMLElement>(".ui-ctx-menu__item.is-open")
|
|
.forEach((head) => head.classList.remove("is-open"));
|
|
}
|
|
|
|
/** 옆 판 자리 — 머리 줄 오른쪽(넘치면 메뉴 왼쪽) · 아래 끝은 올림 · fixed(메뉴 overflow 에 안 잘림) ·
|
|
* 변환된 그릇 안이면 실제 자리와 어긋난 만큼 바로잡음 */
|
|
function placeSide(side: HTMLElement, head: HTMLElement): void {
|
|
const view = element.ownerDocument.defaultView ?? window;
|
|
const menu = element.getBoundingClientRect();
|
|
let x = menu.right;
|
|
let y = head.getBoundingClientRect().top;
|
|
side.style.left = `${x}px`;
|
|
side.style.top = `${y}px`;
|
|
const box = side.getBoundingClientRect();
|
|
const [ox, oy] = [box.left - x, box.top - y];
|
|
y -= (side.firstElementChild?.getBoundingClientRect().top ?? box.top) - box.top; // 첫 줄 = 머리 줄 높이
|
|
if (x + box.width > view.innerWidth - 4) x = Math.max(4, menu.left - box.width);
|
|
if (y + box.height > view.innerHeight - 4) y = Math.max(4, view.innerHeight - 4 - box.height);
|
|
side.style.left = `${x - ox}px`;
|
|
side.style.top = `${y - oy}px`;
|
|
}
|
|
|
|
/** 하위 메뉴 머리 줄 — 올림 · 누름 · → = 옆 판 · 옆 판 ← = 닫고 머리로 */
|
|
function buildGroup(label: string, children: Array<[string, () => void]>): HTMLButtonElement {
|
|
const head = document.createElement("button");
|
|
head.type = "button";
|
|
head.className = `${blockClass}__context-menu-item ui-ctx-menu__item is-group`;
|
|
head.setAttribute("aria-haspopup", "menu");
|
|
head.textContent = `${label} ▸`;
|
|
const show = (focus: boolean): void => {
|
|
if (!head.classList.contains("is-open")) {
|
|
closeSide();
|
|
const side = document.createElement("div");
|
|
side.className = `${blockClass}__context-menu-sub ui-ctx-menu ui-ctx-menu__side`;
|
|
side.style.position = "fixed";
|
|
side.setAttribute("role", "menu");
|
|
children.forEach(([text, action]) => side.append(buildLeaf(text, action, true)));
|
|
side.addEventListener("keydown", (event) => {
|
|
if (event.key !== "ArrowLeft") return;
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
closeSide();
|
|
head.focus();
|
|
});
|
|
element.append(side);
|
|
head.classList.add("is-open");
|
|
placeSide(side, head);
|
|
}
|
|
if (focus) element.querySelector<HTMLElement>(".ui-ctx-menu__side button")?.focus();
|
|
};
|
|
head.addEventListener("pointerenter", () => show(false));
|
|
head.addEventListener("click", () => show(true));
|
|
head.addEventListener("keydown", (event) => {
|
|
if (event.key !== "ArrowRight") return;
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
show(true);
|
|
});
|
|
return head;
|
|
}
|
|
|
|
/** 화면 밖으로 나가면 안쪽으로 당김(오른쪽 · 아래 끝) — 열 때 */
|
|
function fit(): void {
|
|
const x = parseFloat(element.style.left);
|
|
const y = parseFloat(element.style.top);
|
|
const box = element.getBoundingClientRect();
|
|
const view = element.ownerDocument.defaultView ?? window; // 새 창에 옮겨진 패널(54-35)
|
|
const dx = Math.max(0, box.right - view.innerWidth + 4);
|
|
const dy = Math.max(0, box.bottom - view.innerHeight + 4);
|
|
if (dx) element.style.left = `${Math.max(x - dx, x - box.left)}px`;
|
|
if (dy) element.style.top = `${Math.max(y - dy, y - box.top)}px`;
|
|
}
|
|
|
|
return {
|
|
element,
|
|
open(x, y, items) {
|
|
// 다른 영역에 열려 있던 메뉴는 닫는다 — 전역에 하나만(2026-08-19 사용자 지시).
|
|
enterMenu(registryEntry);
|
|
element.replaceChildren();
|
|
items.forEach((item) => {
|
|
if (item === "-") {
|
|
const last = element.lastElementChild;
|
|
if (last && !last.classList.contains("ui-ctx-menu__sep"))
|
|
element.append(
|
|
Object.assign(document.createElement("div"), { className: "ui-ctx-menu__sep" }),
|
|
);
|
|
return;
|
|
}
|
|
if (Array.isArray(item)) {
|
|
element.append(buildLeaf(item[0], item[1]));
|
|
return;
|
|
}
|
|
// 하위 메뉴 = 머리 줄 하나 · 옆 판(한 번에 하나)
|
|
element.append(buildGroup(item.label, item.children));
|
|
});
|
|
if (element.lastElementChild?.classList.contains("ui-ctx-menu__sep"))
|
|
element.lastElementChild.remove();
|
|
element.style.left = `${x}px`;
|
|
element.style.top = `${y}px`;
|
|
element.hidden = false;
|
|
fit();
|
|
},
|
|
close,
|
|
contains: (target) => target instanceof Node && element.contains(target),
|
|
};
|
|
}
|
|
|
|
/* =============================================================================
|
|
* 부품 「⋯ · 오른쪽 메뉴」(552-3 · 목록 20)
|
|
* 꼴 = 줄 끝 ⋯(`createRowMenu` · 아이콘 단추 28 → 아래 판) · 오른쪽 누름 메뉴(`createMapContextMenu` · 위 · 하위판 ▸ · 구분선)
|
|
* · 위험 항목(`danger` → 빨강 + 확인창 · `ownConfirm` 이면 확인창 없이)
|
|
* 크기 = ⋯ 단추 28(아이콘 18) · 항목 줄 32 · 판 최소 폭 132(⋯) / 160(오른쪽 누름)
|
|
* 상태 = 기본 · 마우스(항목 바탕) · 눌림(.is-open · 단추 바탕) · 막힘(항목 disabled)
|
|
* 쓰는 법 = `createRowMenu([{ label: "열기", onClick: open }, { label: "삭제", danger: true, onClick: drop }])`
|
|
* 대신하는 옛 변종 = B01 `rowMenu`(`.b01-pg-menu`) · `.b01-proj-menu__context-menu` · 시트 `.ss-toolbar__popover` · B06 `b06-design__more` · CAD `.cad-ribbon-overflow`
|
|
* 한 번에 하나 · 밖 누름 · Esc 로 닫힘 = 위 장부(enterMenu · leaveMenu)를 같이 씀
|
|
* ========================================================================== */
|
|
|
|
export interface RowMenuItem {
|
|
label: string;
|
|
onClick: () => void;
|
|
/** 삭제 · 빼기 = 빨강 + 확인창 */
|
|
danger?: boolean;
|
|
/** 확인창 글(danger 일 때) */
|
|
confirm?: string;
|
|
/** 누르면 열리는 모달이 확인을 맡음 — 빨강만 · 확인창 없음 */
|
|
ownConfirm?: boolean;
|
|
disabled?: boolean;
|
|
}
|
|
|
|
/** 줄 끝 ⋯ — 열면 항목 · 밖을 누르면 닫힘 · danger 는 확인 뒤 실행 */
|
|
export function createRowMenu(items: RowMenuItem[]): HTMLElement {
|
|
const wrap = document.createElement("div");
|
|
wrap.className = "ui-row-menu";
|
|
if (!items.length) return wrap;
|
|
const trigger = createIconButton({ icon: "dots", title: "더 보기", size: 28 });
|
|
trigger.classList.add("ui-row-menu__btn");
|
|
trigger.setAttribute("aria-haspopup", "menu");
|
|
trigger.setAttribute("aria-expanded", "false");
|
|
const pop = document.createElement("div");
|
|
pop.className = "ui-row-menu__pop";
|
|
pop.setAttribute("role", "menu");
|
|
pop.hidden = true;
|
|
const entry: MenuEntry = {
|
|
close: () => setOpen(false),
|
|
contains: (target) => target instanceof Node && wrap.contains(target),
|
|
};
|
|
function setOpen(open: boolean): void {
|
|
pop.hidden = !open;
|
|
trigger.setAttribute("aria-expanded", String(open));
|
|
wrap.classList.toggle("is-open", open);
|
|
if (open) enterMenu(entry);
|
|
else leaveMenu(entry);
|
|
}
|
|
for (const item of items) {
|
|
const b = document.createElement("button");
|
|
b.type = "button";
|
|
b.className = `ui-row-menu__item${item.danger ? " is-danger" : ""}`;
|
|
b.textContent = item.label;
|
|
b.disabled = !!item.disabled;
|
|
b.setAttribute("role", "menuitem");
|
|
b.addEventListener("click", () => {
|
|
setOpen(false);
|
|
if (!item.danger || item.ownConfirm) return item.onClick();
|
|
void showConfirmDialog(item.confirm ?? `${item.label} 하시겠습니까?`, item.label).then(
|
|
(ok) => ok && item.onClick(),
|
|
);
|
|
});
|
|
pop.append(b);
|
|
}
|
|
trigger.addEventListener("click", (e) => {
|
|
e.stopPropagation();
|
|
setOpen(pop.hidden === true);
|
|
});
|
|
wrap.append(trigger, pop);
|
|
return wrap;
|
|
}
|
|
|
|
/* =============================================================================
|
|
* 부품 「아이콘 고르기 메뉴」(PLAN 626-1 · 요구 530 · 지도 도구줄 단추 하나 + 아래 판)
|
|
* 꼴 = 아이콘 단추(32 · 고른 것 있으면 눌림) → 아래 판 = 항목 하나만 고름(✓) + 맨 끝 「끔」
|
|
* 자리 = 판은 화면 맨 위(body · fixed) — 도구줄 가로 밀기 칸에 잘리지 않게 · 단추 바로 아래
|
|
* 상태 = 고름 `aria-checked` · 단추 `aria-pressed` · 막힘(단추 disabled)
|
|
* 쓰는 법 = `createPickMenu({ icon: "compass", title, items: [{ value, label }], offLabel: "끔", onPick })`
|
|
* → { root, set(value | null), value() } · `onPick` 은 누를 때만(set 은 안 부름)
|
|
* 한 번에 하나 · 밖 누름 · Esc 로 닫힘 = 위 장부(enterMenu · leaveMenu)
|
|
* ========================================================================== */
|
|
|
|
export interface PickMenu {
|
|
root: HTMLButtonElement;
|
|
set: (value: string | null) => void;
|
|
value: () => string | null;
|
|
}
|
|
|
|
export function createPickMenu(opts: {
|
|
icon: IconName;
|
|
title: string;
|
|
items: Array<{ value: string; label: string; title?: string }>;
|
|
offLabel: string;
|
|
onPick: (value: string | null) => void;
|
|
}): PickMenu {
|
|
let current: string | null = null;
|
|
const root = createIconButton({ icon: opts.icon, title: opts.title, size: 32, pressed: false });
|
|
root.setAttribute("aria-haspopup", "menu");
|
|
root.setAttribute("aria-expanded", "false");
|
|
const pop = document.createElement("div");
|
|
pop.className = "ui-row-menu__pop ui-pick-menu__pop";
|
|
pop.setAttribute("role", "menu");
|
|
pop.hidden = true;
|
|
const rows = [...opts.items, { value: "", label: opts.offLabel, title: undefined }].map(
|
|
(item) => {
|
|
const b = document.createElement("button");
|
|
b.type = "button";
|
|
b.className = "ui-row-menu__item";
|
|
b.textContent = item.label;
|
|
if (item.title) b.title = item.title;
|
|
b.dataset.value = item.value;
|
|
b.setAttribute("role", "menuitemradio");
|
|
b.addEventListener("click", () => {
|
|
setOpen(false);
|
|
set(item.value || null);
|
|
opts.onPick(current);
|
|
});
|
|
pop.append(b);
|
|
return b;
|
|
},
|
|
);
|
|
const entry: MenuEntry = {
|
|
close: () => setOpen(false),
|
|
contains: (target) => target instanceof Node && (root.contains(target) || pop.contains(target)),
|
|
};
|
|
function setOpen(open: boolean): void {
|
|
if (open) {
|
|
document.body.append(pop);
|
|
const at = root.getBoundingClientRect();
|
|
Object.assign(pop.style, {
|
|
position: "fixed",
|
|
top: `${at.bottom + 4}px`,
|
|
left: `${at.left}px`,
|
|
right: "auto",
|
|
});
|
|
}
|
|
pop.hidden = !open;
|
|
root.setAttribute("aria-expanded", String(open));
|
|
if (open) return enterMenu(entry);
|
|
leaveMenu(entry);
|
|
pop.remove();
|
|
}
|
|
function set(value: string | null): void {
|
|
current = value;
|
|
root.setAttribute("aria-pressed", String(value !== null));
|
|
for (const row of rows)
|
|
row.setAttribute("aria-checked", String((row.dataset.value || null) === value));
|
|
}
|
|
set(null);
|
|
root.addEventListener("click", (e) => {
|
|
e.stopPropagation();
|
|
setOpen(pop.hidden === true);
|
|
});
|
|
return { root, set, value: () => current };
|
|
}
|