From 2265ac4f7c1f54d8327d64ed1dcdde0e540ac48e Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 1 Nov 2026 11:26:52 +0900 Subject: [PATCH] =?UTF-8?q?feat(=EB=B6=80=ED=92=88):=20634-1=20=EC=97=AC?= =?UTF-8?q?=EB=9F=BF=20=EA=B3=A0=EB=A5=B4=EA=B8=B0=20=EB=A9=94=EB=89=B4=20?= =?UTF-8?q?=C2=B7=20=ED=95=9C=20=ED=96=89=20=EB=91=90=20=EC=98=81=EC=97=AD?= =?UTF-8?q?=20=C2=B7=20=EB=A7=89=ED=9E=98=20=EA=B8=80=20=C2=B7=20=EC=83=88?= =?UTF-8?q?=20=EC=95=84=EC=9D=B4=EC=BD=98(=EC=9A=94=EA=B5=AC=20546)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - createPickMenu multi — menuitemcheckbox · 눌러도 안 닫힘 · values() · 줄 icon · disabled · title · setItem · 단추 ▾ + 켠 수 배지(0 = 없음) · 하나라도 켜면 눌림 · 밀기 · 창 크기 바뀌면 닫힘 · offLabel 없으면 끔 줄 · 눌림 없음 - createMapToolRow — 그룹1 왼쪽(행 반까지) · 그룹2 오른쪽 · 영역마다 min-width 0 + 자기 가로 밀기 · [?] 판은 행 아래 - mapToolTip — 평소 제목만 · 막힘 때만 「제목 — 까닭」 · MAP_TOOLS 글 = 표 호버제목 - 아이콘 — 묶음 넷(streams · signpost · parcelHouse · ban) · 겹친 셋 대신(contours · cutFill · lineament) · 스냅 간격 spacing - 배지 count · accent 덧 · 부품 모음 견본 「고르기 메뉴(하나 · 여럿)」 · 지도 도구줄 한 행 견본 - 시험 568_1 고침 — 예정노선 둘 줄이 한 줄 꼴이 되어 목록에 잡힘 · 새 지킴 634_1 Co-Authored-By: Claude Opus 5.5 --- resources/tester/test_568_1_map_tools.py | 4 + resources/tester/test_634_1_pick_menu_row.py | 68 +++++++++ ui_template/ui_template_badge.css | 6 + ui_template/ui_template_context_menu.css | 26 ++++ ui_template/ui_template_context_menu.ts | 145 +++++++++++++++---- ui_template/ui_template_icons.ts | 11 ++ ui_template/ui_template_map_tools.css | 30 ++++ ui_template/ui_template_map_tools.ts | 61 +++++--- ui_template/ui_template_ui_kit_frames.ts | 28 +++- ui_template/ui_template_ui_kit_parts.ts | 17 ++- 10 files changed, 339 insertions(+), 57 deletions(-) create mode 100644 resources/tester/test_634_1_pick_menu_row.py diff --git a/resources/tester/test_568_1_map_tools.py b/resources/tester/test_568_1_map_tools.py index 9cfd9653f..2fbc11742 100644 --- a/resources/tester/test_568_1_map_tools.py +++ b/resources/tester/test_568_1_map_tools.py @@ -55,6 +55,8 @@ def test_단추_목록_아이콘_차례(): "footprint", "measure", "station-snap", + "expected-draw", + "expected-original", ] names = _icon_names() assert all(icon in names for _, icon, _ in rows) @@ -66,6 +68,8 @@ def test_단추_목록_아이콘_차례(): "footprint", "measure", "station-snap", + "expected-draw", + "expected-original", } diff --git a/resources/tester/test_634_1_pick_menu_row.py b/resources/tester/test_634_1_pick_menu_row.py new file mode 100644 index 000000000..c16fb31db --- /dev/null +++ b/resources/tester/test_634_1_pick_menu_row.py @@ -0,0 +1,68 @@ +"""634-1 부품(요구 546) — 여럿 고르기 메뉴(안 닫힘 · 켠 수 배지 · 줄 아이콘 · 막힘) · 한 행 두 영역 · 견본 · 아이콘.""" + +import re +from pathlib import Path + +UI = Path(__file__).resolve().parents[2] / "ui_template" +MENU = (UI / "ui_template_context_menu.ts").read_text(encoding="utf-8") +MENU_CSS = (UI / "ui_template_context_menu.css").read_text(encoding="utf-8") +TOOLS = (UI / "ui_template_map_tools.ts").read_text(encoding="utf-8") +TOOLS_CSS = (UI / "ui_template_map_tools.css").read_text(encoding="utf-8") +ICONS = (UI / "ui_template_icons.ts").read_text(encoding="utf-8") + + +def test_여럿_고르기_안_닫힘_배지(): + pick = MENU[MENU.index("export function createPickMenu(") :] + assert '"menuitemcheckbox"' in pick and "values," in pick and "setItem(value, patch)" in pick + click = pick[pick.index('b.addEventListener("click"') :] + click = click[: click.index("pop.append(b);")] + # 여럿 = 켬끔 뒤 바로 돌아감(setOpen(false) 앞) + assert click.index("if (multi)") < click.index("setOpen(false)") + assert ( + 'createBadge("0", "accent", { kind: "count"' in pick + and "badge.hidden = count === 0;" in pick + ) + assert 'root.setAttribute("aria-pressed", String(count > 0));' in pick + assert 'window.addEventListener("resize", onShift);' in pick # 창 크기 = 닫힘 + assert 'document.addEventListener("scroll", onShift, true);' in pick # 밀기 = 닫힘 + assert ".ui-pick-menu--multi" in MENU_CSS and ".ui-row-menu__item:disabled" in MENU_CSS + + +def test_한_행_두_영역(): + assert "export function createMapToolRow(left: HTMLElement, right: HTMLElement)" in TOOLS + assert ":scope > .ui-map-tools__hint" in TOOLS # [?] 판은 행 아래 + first = TOOLS_CSS[TOOLS_CSS.index(".ui-map-row__line > .ui-map-tools:first-child {") :] + first = first[: first.index("}")] + assert "flex-shrink: 0;" in first and "max-width: 50%;" in first # 모자라면 그룹2 먼저 + area = TOOLS_CSS[TOOLS_CSS.index(".ui-map-row__line > .ui-map-tools {") :] + assert "min-width: 0;" in area[: area.index("}")] + + +def test_마우스_글_막힘만_까닭(): + tip = TOOLS[TOOLS.index("export function mapToolTip(") :] + assert "why ? `${el.dataset.tip} — ${why}` : el.dataset.tip" in tip[: tip.index("\n}")] + assert 'title: "도움말"' in TOOLS + + +def test_아이콘_겹침_없음(): + table = TOOLS[TOOLS.index("export const MAP_TOOLS") : TOOLS.index("satisfies")] + icons = re.findall(r'icon: "(\w+)"', table) + assert len(icons) == len(set(icons)) + for name in ( + "contours", + "cutFill", + "lineament", + "spacing", + "streams", + "signpost", + "parcelHouse", + "ban", + ): + assert re.search(rf"^ {name}:", ICONS, re.M), name + + +def test_견본(): + parts = (UI / "ui_template_ui_kit_parts.ts").read_text(encoding="utf-8") + frames = (UI / "ui_template_ui_kit_frames.ts").read_text(encoding="utf-8") + assert 'name: "고르기 메뉴(하나 · 여럿)"' in parts and "demo: pickDemo" in parts + assert "createMapToolRow(layers.root, tools.root)" in frames diff --git a/ui_template/ui_template_badge.css b/ui_template/ui_template_badge.css index 2ffde90d0..f3d6c8166 100644 --- a/ui_template/ui_template_badge.css +++ b/ui_template/ui_template_badge.css @@ -84,3 +84,9 @@ .ui-badge[hidden] { display: none !important; } + +/* 수 · 진행색(634-1 · 지도 겹 묶음 켠 수) */ +.ui-badge--count.ui-badge--accent { + background: var(--color-primary); + color: var(--color-primary-text); +} diff --git a/ui_template/ui_template_context_menu.css b/ui_template/ui_template_context_menu.css index 8565c0dc4..bab8d247e 100644 --- a/ui_template/ui_template_context_menu.css +++ b/ui_template/ui_template_context_menu.css @@ -128,6 +128,32 @@ visibility: visible; } +/* 줄 아이콘(634-1) — ✓ · 아이콘 · 글 한 줄 */ +.ui-pick-menu__pop .ui-row-menu__item { + display: flex; + align-items: center; + gap: var(--spacing-8); +} +.ui-pick-menu__pop .ui-row-menu__item:disabled { + opacity: 0.55; +} + +/* 여럿 고르기 단추(634-1) — 아이콘 + 켠 수 배지 + ▾ */ +.ui-icon-btn.ui-pick-menu--multi { + width: auto; + min-width: var(--ui-size-h); + grid-auto-flow: column; + gap: 2px; + padding-inline: var(--spacing-4); +} +.ui-pick-menu__caret { + font-size: var(--text-caption); + line-height: 1; +} +.ui-pick-menu--multi > .ui-badge[hidden] { + display: none; +} + /* 숨김 — display 가 [hidden] 을 덮지 않게(공용 기본 꼴이 안 실린 곳 · 페이지 꾸밈 위에서도) */ :is(.ui-row-menu, .ui-row-menu__btn, .ui-row-menu__item)[hidden] { display: none !important; diff --git a/ui_template/ui_template_context_menu.ts b/ui_template/ui_template_context_menu.ts index a6fc3719a..db8dbb558 100644 --- a/ui_template/ui_template_context_menu.ts +++ b/ui_template/ui_template_context_menu.ts @@ -12,7 +12,8 @@ * ========================================================================== */ import { createIconButton } from "./ui_template_button"; -import type { IconName } from "./ui_template_icons"; +import { createBadge } from "./ui_template_badge"; +import { icon, type IconName } from "./ui_template_icons"; import "./ui_template_context_menu.css"; import { showConfirmDialog } from "./ui_template_modal"; @@ -299,58 +300,125 @@ export function createRowMenu(items: RowMenuItem[]): HTMLElement { } /* ============================================================================= - * 부품 「아이콘 고르기 메뉴」(PLAN 626-1 · 요구 530 · 지도 도구줄 단추 하나 + 아래 판) - * 꼴 = 아이콘 단추(32 · 고른 것 있으면 눌림) → 아래 판 = 항목 하나만 고름(✓) + 맨 끝 「끔」 - * 자리 = 판은 화면 맨 위(body · fixed) — 도구줄 가로 밀기 칸에 잘리지 않게 · 단추 바로 아래 - * 상태 = 고름 `aria-checked` · 단추 `aria-pressed` · 막힘(단추 disabled) + * 부품 「고르기 메뉴」(PLAN 626-1 · 634-1 · 요구 530 · 546 · 지도 도구줄 단추 하나 + 아래 판) + * 꼴 = 하나(기본) — 아이콘 단추(32) → 아래 판 = 항목 하나만 고름(✓) · 누르면 닫힘 · `offLabel` 이면 맨 끝 「끔」 줄 + 고르면 눌림 + * 여럿(`multi`) — 아이콘 + 켠 수 배지(0 = 없음) + ▾ · 판 = 켬끔 줄(✓) · 눌러도 안 닫힘 · 하나라도 켜면 눌림 + * 줄 = `icon` · `title`(마우스 글) · `disabled`(흐림) — 바꿈은 `setItem(value, { checked, disabled, title })` + * 자리 = 판은 화면 맨 위(body · fixed) — 도구줄 가로 밀기 칸에 잘리지 않게 · 단추 바로 아래 · 밀기 · 창 크기 바뀌면 닫힘 + * 상태 = 고름 `aria-checked` · 단추 `aria-pressed` · 막힘(단추 · 줄 disabled) * 쓰는 법 = `createPickMenu({ icon: "compass", title, items: [{ value, label }], offLabel: "끔", onPick })` - * → { root, set(value | null), value() } · `onPick` 은 누를 때만(set 은 안 부름) + * → { root, set(value | null), value(), values(), setItem() } · `onPick` 은 누를 때만(set · setItem 은 안 부름) + * 여럿 = `createPickMenu({ multi: true, icon, title, items: [{ value, label, icon }], onPick: (value) => 켬끔 })` * 한 번에 하나 · 밖 누름 · Esc 로 닫힘 = 위 장부(enterMenu · leaveMenu) * ========================================================================== */ +export interface PickMenuItem { + value: string; + label: string; + title?: string; + icon?: IconName; + disabled?: boolean; +} + export interface PickMenu { root: HTMLButtonElement; + /** 하나 — 고름 바꿈 · 여럿 — 그 하나만 켬(null = 모두 끔). */ set: (value: string | null) => void; value: () => string | null; + /** 켠(고른) 줄 값들. */ + values: () => string[]; + /** 줄 하나 고침 — 켬 · 막힘 · 마우스 글. */ + setItem: ( + value: string, + patch: { checked?: boolean; disabled?: boolean; title?: string }, + ) => void; } export function createPickMenu(opts: { icon: IconName; title: string; - items: Array<{ value: string; label: string; title?: string }>; - offLabel: string; + items: PickMenuItem[]; + offLabel?: string; + multi?: boolean; onPick: (value: string | null) => void; }): PickMenu { - let current: string | null = null; - const root = createIconButton({ icon: opts.icon, title: opts.title, size: 32, pressed: false }); + const multi = !!opts.multi; + const root = createIconButton({ + icon: opts.icon, + title: opts.title, + size: 32, + pressed: multi || opts.offLabel ? false : undefined, + }); root.setAttribute("aria-haspopup", "menu"); root.setAttribute("aria-expanded", "false"); + const badge = createBadge("0", "accent", { kind: "count", size: 18 }); + if (multi) { + root.classList.add("ui-pick-menu--multi"); + badge.hidden = true; + const caret = Object.assign(document.createElement("span"), { textContent: "▾" }); + caret.className = "ui-pick-menu__caret"; + root.append(badge, caret); + } 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 all: PickMenuItem[] = [ + ...opts.items, + ...(!multi && opts.offLabel ? [{ value: "", label: opts.offLabel }] : []), + ]; + const rows = all.map((item) => { + const b = document.createElement("button"); + b.type = "button"; + b.className = "ui-row-menu__item"; + if (item.icon) b.append(icon(item.icon, 18)); + b.append(Object.assign(document.createElement("span"), { textContent: item.label })); + if (item.title) b.title = item.title; + b.disabled = !!item.disabled; + b.dataset.value = item.value; + b.setAttribute("role", multi ? "menuitemcheckbox" : "menuitemradio"); + b.addEventListener("click", () => { + if (multi) { + mark(b, b.getAttribute("aria-checked") !== "true"); + return opts.onPick(item.value); + } + setOpen(false); + set(item.value || null); + opts.onPick(current); + }); + pop.append(b); + return b; + }); + const rowOf = (value: string): HTMLButtonElement | undefined => + rows.find((row) => row.dataset.value === value); + const values = (): string[] => + rows + .filter((row) => row.getAttribute("aria-checked") === "true") + .map((row) => row.dataset.value!); + let current: string | null = null; + /** 단추 눌림 · 배지 — 줄 켬에 맞춤. */ + function paint(): void { + if (!multi) { + if (opts.offLabel) root.setAttribute("aria-pressed", String(current !== null)); + return; + } + const count = values().length; + root.setAttribute("aria-pressed", String(count > 0)); + badge.textContent = String(count); + badge.hidden = count === 0; + } + function mark(row: HTMLButtonElement, checked: boolean): void { + row.setAttribute("aria-checked", String(checked)); + paint(); + } const entry: MenuEntry = { close: () => setOpen(false), contains: (target) => target instanceof Node && (root.contains(target) || pop.contains(target)), }; + /** 밀기(도구줄 · 쪽) · 창 크기 = 판이 단추에서 떨어짐 → 닫음(판 안 밀기는 그대로). */ + const onShift = (event: Event): void => { + if (!(event.target instanceof Node && pop.contains(event.target))) setOpen(false); + }; function setOpen(open: boolean): void { if (open) { document.body.append(pop); @@ -361,6 +429,11 @@ export function createPickMenu(opts: { left: `${at.left}px`, right: "auto", }); + window.addEventListener("resize", onShift); + document.addEventListener("scroll", onShift, true); + } else { + window.removeEventListener("resize", onShift); + document.removeEventListener("scroll", onShift, true); } pop.hidden = !open; root.setAttribute("aria-expanded", String(open)); @@ -370,14 +443,26 @@ export function createPickMenu(opts: { } 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)); + paint(); } set(null); root.addEventListener("click", (e) => { e.stopPropagation(); setOpen(pop.hidden === true); }); - return { root, set, value: () => current }; + return { + root, + set, + value: () => current, + values, + setItem(value, patch) { + const row = rowOf(value); + if (!row) return; + if (patch.disabled !== undefined) row.disabled = patch.disabled; + if (patch.title !== undefined) row.title = patch.title; + if (patch.checked !== undefined) mark(row, patch.checked); + }, + }; } diff --git a/ui_template/ui_template_icons.ts b/ui_template/ui_template_icons.ts index 1648672f5..58d8a91ab 100644 --- a/ui_template/ui_template_icons.ts +++ b/ui_template/ui_template_icons.ts @@ -158,6 +158,17 @@ const PATHS = { "M16 5l3 3l-2 1l4 4l-3 1l4 4h-9|M15 21l0 -3|M8 13l-2 -2|M8 12l2 -2|M8 21v-13|M5.824 16a3 3 0 0 1 -2.743 -3.69a3 3 0 0 1 .304 -4.833a3 3 0 0 1 4.615 -3.707a3 3 0 0 1 4.614 3.707a3 3 0 0 1 .305 4.833a3 3 0 0 1 -2.919 3.695h-4z", plant: "M7 15h10v4a2 2 0 0 1 -2 2h-6a2 2 0 0 1 -2 -2z|M12 9a6 6 0 0 0 -6 -6h-3v2a6 6 0 0 0 6 6h3|M12 11a6 6 0 0 1 6 -6h3v1a6 6 0 0 1 -6 6h-3|M12 15l0 -6", + // 634-1 — 지도 도구 · 겹(겹친 셋 대신 · 1m 등고선 · 절성토선 · 지질구조선 · 스냅 간격) + contours: + "M10 12a2 1.6 0 1 0 4 0a2 1.6 0 1 0 -4 0|M6.5 12a5.5 4.2 0 1 0 11 0a5.5 4.2 0 1 0 -11 0|M3 12a9 7.5 0 1 0 18 0a9 7.5 0 1 0 -18 0", + cutFill: "M10 4l-1.5 16|M14 4l1.5 16|M6 4l-3.5 16|M18 4l3.5 16", + lineament: "M3 18l3 -4|M8 11.5l2 -3|M12 7l2 3|M16 12.5l2 -3|M20 6.5l1 -1.5", + spacing: "M4 5v14|M20 5v14|M8 12h8|M8 12l2 -2|M8 12l2 2|M16 12l-2 -2|M16 12l-2 2", + // 634-1 — 지도 겹 묶음(수계망도 · 도로 · 토지·건물 · 제한·재해) + streams: "M12 21v-7|M12 14l-5 -7|M12 14l6 -9|M8.8 9.5l-4.8 -1.5|M15.6 8.6l4.4 -0.6", + signpost: "M12 21v-11|M12 5v-2|M5 5h11l3 2.5l-3 2.5h-11z|M9 21h6", + parcelHouse: "M3 21h18|M5 21v-9l7 -6l7 6v9|M10 21v-5h4v5", + ban: "M3 12a9 9 0 1 0 18 0a9 9 0 1 0 -18 0|M5.7 5.7l12.6 12.6", } as const; export type IconName = keyof typeof PATHS; diff --git a/ui_template/ui_template_map_tools.css b/ui_template/ui_template_map_tools.css index debd97581..7825dbdaf 100644 --- a/ui_template/ui_template_map_tools.css +++ b/ui_template/ui_template_map_tools.css @@ -62,8 +62,38 @@ display: grid; } +/* 한 행 두 영역(634-1) — 왼쪽 겹 줄 · 오른쪽 도구줄 · 영역마다 자기 가로 밀기(min-width 0) · + 왼쪽은 행 반까지 제 폭 · 모자라면 오른쪽이 먼저 밀림 · [?] 판은 행 아래 · 빈 자리는 지도 조작 통과 */ +.ui-map-row { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: var(--spacing-8); + min-width: 0; + pointer-events: none; +} + +.ui-map-row__line { + display: flex; + align-items: flex-start; + gap: var(--spacing-8); + width: 100%; + min-width: 0; +} + +.ui-map-row__line > .ui-map-tools { + flex: 0 1 auto; + min-width: 0; +} + +.ui-map-row__line > .ui-map-tools:first-child { + flex-shrink: 0; + max-width: 50%; +} + /* 숨김 — display 가 [hidden] 을 덮지 않게 */ .ui-map-tools[hidden], +.ui-map-row[hidden], .ui-map-tools__hint[hidden] { display: none !important; } diff --git a/ui_template/ui_template_map_tools.ts b/ui_template/ui_template_map_tools.ts index a4c24aa70..4fa983ccf 100644 --- a/ui_template/ui_template_map_tools.ts +++ b/ui_template/ui_template_map_tools.ts @@ -10,6 +10,7 @@ * hint: { lines: ["휠 = 확대"] } }) → { root, bar, hint, button(act) } * 페이지 문구가 다르면 `mapToolButton("measure", { title })` 를 묶음에 그대로 끼움 · 고르기 · 칩도 요소로 끼움 * 누름 동작은 페이지가 붙임(`onClick` 또는 `[data-act]` 로 찾아 붙임) · 자리는 페이지 CSS(root) + * 한 행 두 영역 = `createMapToolRow(겹 줄 root, 도구줄 root)`(634-1) · 막힘 글 = `mapToolTip(단추, 까닭)` * 대신하는 옛 변종 = B05 `b05-route__view-controls` 글자 유리 단추 줄 · `b05-routeedit__hint` 안내 판 · D03 `tool()` 지역 함수 * ========================================================================== */ @@ -20,29 +21,19 @@ import "./ui_template_map_tools.css"; /** 단추 목록 — 차례 = 도구줄 차례 · toggle = 켬끔 단추(aria-pressed) */ export const MAP_TOOLS = { - undo: { icon: "undo", title: "되돌리기 (Ctrl+Z)" }, - redo: { icon: "redo", title: "다시하기 (Ctrl+Y)" }, - view3d: { icon: "cube", title: "3D 보기" }, - "rotate-ccw": { icon: "rotateCcw", title: "반시계로 돌리기" }, - "rotate-cw": { icon: "rotateCw", title: "시계로 돌리기" }, - "contour-minor": { icon: "ripple", title: "1m 등고선", toggle: true }, + undo: { icon: "undo", title: "되돌리기" }, + redo: { icon: "redo", title: "다시하기" }, + view3d: { icon: "cube", title: "3D" }, + "rotate-ccw": { icon: "rotateCcw", title: "좌 회전" }, + "rotate-cw": { icon: "rotateCw", title: "우 회전" }, + "contour-minor": { icon: "contours", title: "등고선 1m", toggle: true }, stations: { icon: "tallymarks", title: "측점", toggle: true }, - slopes: { icon: "percentage", title: "기울기", toggle: true }, - footprint: { icon: "road", title: "절/성토선", toggle: true }, - measure: { icon: "ruler", title: "거리 재기", toggle: true }, - "station-snap": { icon: "magnet", title: "측점 스냅", toggle: true }, - "expected-draw": { - icon: "pencil", - title: - "예정노선 변경 — 켜면 예정노선이 맨 위 · 예정노선 페이지와 같은 긋기 · 고르기 · 고치기 → 평면선을 예정노선 따라 통째 다시 추적 (켠 동안 계획선 · 구조물은 안 집힘)", - toggle: true, - }, - "expected-original": { - icon: "lineDashed", - title: - "예정노선(원본) — 파일로 올린 예정노선을 옅은 점선으로 보이기 · 숨기기 (고친 예정노선과 다를 때만 보임)", - toggle: true, - }, + slopes: { icon: "percentage", title: "길이·기울기", toggle: true }, + footprint: { icon: "cutFill", title: "절성토선", toggle: true }, + measure: { icon: "ruler", title: "길이측정", toggle: true }, + "station-snap": { icon: "magnet", title: "스냅", toggle: true }, + "expected-draw": { icon: "pencil", title: "예정노선 변경", toggle: true }, + "expected-original": { icon: "lineDashed", title: "예정노선 원본", toggle: true }, } satisfies Record; export type MapToolAct = keyof typeof MAP_TOOLS; @@ -68,9 +59,18 @@ export function mapToolButton(act: MapToolAct, opts: MapToolButtonOptions = {}): onClick: opts.onClick, }); button.dataset.act = act; + button.dataset.tip = opts.title ?? tool.title; return button; } +/** 마우스 글(요구 546) — 평소 제목만 · 막힘이면 「제목 — 까닭」 · 제목 = `data-tip`(없으면 지금 글). */ +export function mapToolTip(el: HTMLElement, why?: string | null): void { + el.dataset.tip ??= el.title; + const text = why ? `${el.dataset.tip} — ${why}` : el.dataset.tip; + el.title = text; + el.setAttribute("aria-label", text); +} + export interface MapToolsOptions { /** 묶음들 — 사이에 나눔선 · act 이름 또는 페이지가 만든 요소 */ groups: (MapToolAct | HTMLElement)[][]; @@ -119,7 +119,7 @@ export function createMapTools(opts: MapToolsOptions): MapTools { let hint: HTMLElement | null = null; if (opts.hint) { const { lines, onToggle } = opts.hint; - const toggle = createIconButton({ icon: "help", title: "조작 도움말 펼치기 · 접기", size: 32 }); + const toggle = createIconButton({ icon: "help", title: "도움말", size: 32 }); toggle.dataset.act = "hint-toggle"; const panel = document.createElement("div"); panel.className = "ui-map-tools__hint"; @@ -154,3 +154,18 @@ export function createMapTools(opts: MapToolsOptions): MapTools { button: (act) => root.querySelector(`[data-act="${act}"]`), }; } + +/** 한 행 두 영역(634-1 · 요구 546) — 왼쪽(지도 겹) · 오른쪽(도구) · 영역마다 자기 가로 밀기 · 모자라면 오른쪽 먼저 밀림 · + * [?] 안내 판은 행 아래로 옮김 · 자리(위 · 왼쪽 · 오른쪽)는 페이지 CSS(root). */ +export function createMapToolRow(left: HTMLElement, right: HTMLElement): HTMLElement { + const root = document.createElement("div"); + root.className = "ui-map-row"; + const line = document.createElement("div"); + line.className = "ui-map-row__line"; + line.append(left, right); + const hints = [left, right].flatMap((area) => + Array.from(area.querySelectorAll(":scope > .ui-map-tools__hint")), + ); + root.append(line, ...hints); + return root; +} diff --git a/ui_template/ui_template_ui_kit_frames.ts b/ui_template/ui_template_ui_kit_frames.ts index b1e9558a3..f86d00964 100644 --- a/ui_template/ui_template_ui_kit_frames.ts +++ b/ui_template/ui_template_ui_kit_frames.ts @@ -10,7 +10,8 @@ import { createFloatPanel } from "./ui_template_float_panel"; import { createFormGrid, createPageFrame } from "./ui_template_general_layout"; import { createInputField } from "./ui_template_input"; import { createListRow } from "./ui_template_list_row"; -import { createMapTools, mapToolButton } from "./ui_template_map_tools"; +import { createPickMenu } from "./ui_template_context_menu"; +import { createMapToolRow, createMapTools, mapToolButton } from "./ui_template_map_tools"; import { createWorkflowPanelHandle } from "./ui_template_overlay"; import { createPanelHead } from "./ui_template_panel_head"; import { createPublicFrame } from "./ui_template_public_layout"; @@ -237,7 +238,30 @@ function mapTools(): HTMLElement { ], }); tools.button("contour-minor")!.disabled = true; - return tools.root; + // 634-1 한 행 두 영역 — 왼쪽 겹 줄(단독 + 묶음 메뉴) · 오른쪽 도구줄 + const water = createPickMenu({ + multi: true, + icon: "streams", + title: "수계망도", + onPick: () => {}, + items: [ + { value: "stream", label: "세류", icon: "droplet" }, + { value: "water", label: "하천", icon: "ripple" }, + ], + }); + water.setItem("stream", { checked: true }); + const layers = createMapTools({ + label: "지도 겹", + groups: [ + [ + createIconButton({ icon: "satellite", title: "위성사진", size: 32, pressed: true }), + water.root, + ], + ], + }); + const row = createMapToolRow(layers.root, tools.root); + row.style.width = "100%"; + return row; } /** 열쇠 → 견본(상태별 · null = 그 상태 없음) */ diff --git a/ui_template/ui_template_ui_kit_parts.ts b/ui_template/ui_template_ui_kit_parts.ts index f1c28dd1d..f5743ffbc 100644 --- a/ui_template/ui_template_ui_kit_parts.ts +++ b/ui_template/ui_template_ui_kit_parts.ts @@ -31,7 +31,7 @@ import { createText } from "./ui_template_text"; import { createToggle } from "./ui_template_toggle"; // 552-3 묶음 부품 import { createBanner } from "./ui_template_banner"; -import { createRowMenu, type RowMenuItem } from "./ui_template_context_menu"; +import { createPickMenu, createRowMenu, type RowMenuItem } from "./ui_template_context_menu"; import { createDrop } from "./ui_template_drop"; import { buildModal, confirmPanel } from "./ui_template_modal"; import { settingCard, settingRow, valueText } from "./ui_template_setting_row"; @@ -134,6 +134,18 @@ const gridRows = (): Cell[][] => [ [{ content: "암", head: true }, "3.10", "m³"], [{ content: "쌓기", colSpan: 2, head: true }, "8.05", "m³"], ]; +/** 634-1 견본 — 고르기 메뉴 하나(추천 노선 꼴) · 여럿(지도 겹 묶음 · 켠 수 배지 · 막힌 줄) */ +const pickDemo = (s: KitState): HTMLElement | null => { + if (s !== "default" && s !== "disabled") return null; + const one = createPickMenu({ icon: "compass", title: "추천노선", items: [{ value: "a", label: "노선 가" }, { value: "b", label: "노선 나" }], offLabel: "끔", onPick: noop }); + const many = createPickMenu({ multi: true, icon: "streams", title: "수계망도", onPick: noop, + items: [{ value: "stream", label: "세류", icon: "droplet" }, { value: "water", label: "하천", icon: "ripple" }, { value: "bands", label: "계류100m", icon: "borderOuter", disabled: true, title: "계류100m — 지형 받기 뒤" }] }); + one.set("a"); + many.setItem("stream", { checked: true }); + many.setItem("water", { checked: true }); + if (s === "disabled") [one.root, many.root].forEach((b) => (b.disabled = true)); + return row(one.root, many.root); +}; const openMenu = (s: KitState, items = KIT_MENU): HTMLElement => { const menu = createRowMenu(items); menu.classList.add("is-open"); @@ -322,6 +334,7 @@ export const KIT_PARTS: KitPart[] = [ { key: "row-menu", name: "⋯ · 오른쪽 메뉴", group: "compound", kinds: "줄 끝 ⋯(28) · 펼친 판 · 위험 항목(빨강 + 확인창) · 막힌 항목", file: "ui_template_context_menu.ts", demo: (s) => { if (s === "error") return null; if (s === "default") return createRowMenu(KIT_MENU); if (s === "hover") { const m = createRowMenu(KIT_MENU); markState(m.querySelector(".ui-row-menu__btn")!, s); return m; } return openMenu(s, s === "disabled" ? [{ label: "내보내기", onClick: noop, disabled: true }, ...KIT_MENU] : KIT_MENU); } }, + { key: "pick-menu", name: "고르기 메뉴(하나 · 여럿)", group: "compound", kinds: "아이콘 단추 32 → 아래 판 · 하나(✓ · 누르면 닫힘 · 끔 줄) · 여럿(켬끔 ✓ · 안 닫힘 · 켠 수 배지 · ▾) · 줄 아이콘 · 막힌 줄 흐림", file: "ui_template_context_menu.ts", demo: pickDemo }, { key: "empty", name: "빈 상태", group: "compound", kinds: "선 아이콘 · 한 줄 · 주 단추 · 사용법 · 작은 꼴", file: "ui_template_empty.ts", demo: (s) => (s === "default" ? col(createEmpty({ icon: "folder", text: "아직 프로젝트가 없음", hint: "새 프로젝트를 만들어 시작", action: createButton({ label: "새 프로젝트", onClick: noop }), onGuide: noop }), createEmpty({ text: "고른 측점 없음", small: true })) : null) }, { key: "modal", name: "모달 · 확인창", group: "compound", kinds: "보통(720) · 넓음 · 폭 단 24rem · 40rem · 960 · 넓은 중간 1100 × 680 · 긴 제목 = 한 줄 말줄임 · 확인 · 위험 확인 · 글 묻기(prompt 대신)", file: "ui_template_modal.ts", @@ -370,7 +383,7 @@ export const KIT_PARTS: KitPart[] = [ demo: FRAME_DEMOS["drawer"] }, { key: "toolbar", name: "도구줄", group: "frame", kinds: "단추 묶음 · 나눔선 · 밀기 · 유리 · 24 · 28 · 32", file: "ui_template_toolbar.ts", demo: FRAME_DEMOS["toolbar"] }, - { key: "map-tools", name: "지도 도구줄", group: "frame", kinds: "아이콘 단추 한 벌 · 묶음 나눔선 · 켬끔(aria-pressed) · [?] 안내 판 · 페이지가 쓸 단추만", file: "ui_template_map_tools.ts", + { key: "map-tools", name: "지도 도구줄", group: "frame", kinds: "아이콘 단추 한 벌 · 묶음 나눔선 · 켬끔(aria-pressed) · [?] 안내 판 · 페이지가 쓸 단추만 · 한 행 두 영역(겹 · 도구 · 영역마다 밀기)", file: "ui_template_map_tools.ts", demo: FRAME_DEMOS["map-tools"] }, { key: "steps", name: "단계 줄", group: "frame", kinds: "가로 막대 · compact(28) · 세로 목록(40 · ☰ 서랍)", file: "ui_template_workflow_layout.ts", demo: FRAME_DEMOS.steps },