diff --git a/A00_Common/map/logic/map_slope_bands.ts b/A00_Common/map/logic/map_slope_bands.ts index 5f9b27a0d..75de7a8f8 100644 --- a/A00_Common/map/logic/map_slope_bands.ts +++ b/A00_Common/map/logic/map_slope_bands.ts @@ -4,6 +4,7 @@ * 서버 값 그림 칸 = 기울기 % 내림(0~250 · 250 = 250 % 이상 · `D03_PlanAnalysis_Layers.slope_values_png`) · * 칸 v 는 가운데(v + 0.5)가 [아래, 위) 안이면 그 범위(위 = 250 이면 250 % 이상도) · 앞 범위 먼저. * 기본 = 지식DB 15 · 35 · 40° 의 % — `임도망_편성.md:20` · `타당성평가.md:59` · `노선_배치.md:29`. + * 수평 바(631) = 경계 한 줄 ↔ 범위 · 칸 나눔 · 합침 · 경계 검사. * 부르는 곳 = `map_slope_bands.ts` · `map_legend.ts` · D03 `UI_LayerLegend`. * ========================================================================== */ @@ -96,3 +97,74 @@ export function bandRatios(counts: number[], bands: SlopeBand[]): number[] { }); return out.map((n) => n / total); } + +/** 수평 바 꼴(631 · 요구 537) — 경계 = [0, …, 250] 한 줄 · 칸 i = [경계 i, 경계 i+1) · 색 빈 글 = 칠 안 함. */ +export interface SlopeBar { + edges: number[]; + colors: string[]; +} + +/** 범위 → 바(사이 빈 곳 = 칠 안 함 칸). */ +export function bandsToBar(bands: SlopeBand[]): SlopeBar { + const edges = [0]; + const colors: string[] = []; + for (const band of [...bands].sort((a, b) => a.low - b.low)) { + if (band.low > edges.at(-1)!) { + edges.push(band.low); + colors.push(""); + } + edges.push(band.high); + colors.push(band.color); + } + if (edges.at(-1)! < SLOPE_MAX_PCT) { + edges.push(SLOPE_MAX_PCT); + colors.push(""); + } + return { edges, colors }; +} + +/** 바 → 범위(칠 안 함 칸은 뺌). */ +export const barToBands = (bar: SlopeBar): SlopeBand[] => + bar.colors.flatMap((color, i) => + color ? [{ low: bar.edges[i], high: bar.edges[i + 1], color }] : [], + ); + +/** 경계 고칠 거리 — 없으면 null(빈 값 · 0 < 값 < 250 밖 · 같은 값 · 거꾸로). */ +export function checkEdges(edges: number[]): string | null { + for (let i = 1; i < edges.length - 1; i++) { + const at = `${i}번째 경계`; + if (!Number.isFinite(edges[i])) return `${at} — 값이 비었습니다`; + if (edges[i] <= 0 || edges[i] >= SLOPE_MAX_PCT) + return `${at} — 0 보다 크고 ${SLOPE_MAX_PCT} % 보다 작게`; + if (edges[i] === edges[i - 1]) return `${at} — 옆 경계와 같은 값입니다`; + if (edges[i] < edges[i - 1]) return `${at} — 왼쪽 경계보다 커야 합니다`; + } + return null; +} + +const colored = (bar: SlopeBar): number => bar.colors.filter(Boolean).length; + +/** 칸 i 를 반으로(위 반 = 새 색) — 못 나누면 null(색 칸 SLOPE_MAX_BANDS · 너무 좁음 · 경계 틀림). */ +export function splitBar(bar: SlopeBar, i: number): SlopeBar | null { + if (colored(bar) >= SLOPE_MAX_BANDS || checkEdges(bar.edges)) return null; + const [low, high] = [bar.edges[i], bar.edges[i + 1]]; + const mid = Math.round((low + high) * 5) / 10; + if (!(mid > low && mid < high)) return null; + const color = + SLOPE_COLORS.find((c) => !bar.colors.includes(c.color))?.color ?? + SLOPE_COLORS[colored(bar) % SLOPE_COLORS.length].color; + return { + edges: [...bar.edges.slice(0, i + 1), mid, ...bar.edges.slice(i + 1)], + colors: [...bar.colors.slice(0, i + 1), color, ...bar.colors.slice(i + 1)], + }; +} + +/** 칸 i 를 이웃과 합침(왼쪽 · 첫 칸은 오른쪽 · 이웃 색 남음) — 칸 하나면 null. */ +export function mergeBar(bar: SlopeBar, i: number): SlopeBar | null { + if (bar.colors.length <= 1) return null; + const cut = i > 0 ? i : 1; + return { + edges: bar.edges.filter((_, k) => k !== cut), + colors: bar.colors.filter((_, k) => k !== i), + }; +} diff --git a/A00_Common/map/map_slope_bands.ts b/A00_Common/map/map_slope_bands.ts index 5ab73e403..ef3468648 100644 --- a/A00_Common/map/map_slope_bands.ts +++ b/A00_Common/map/map_slope_bands.ts @@ -1,17 +1,17 @@ /* ============================================================================= * map_slope_bands.ts * 공용 지도 급경사 범위 옵션(D03 · B05 · 624-3 공용으로 옮김)(PLAN 603 · 요구 466 · 467 · 468) — 범례 급경사 줄 ⚙ → 모달 - * (범위 줄 = 아래 % · 위 % · 색 · 지움 · 칸 옆 ° 환산 · [범위 추가] · [기본값] · [적용] · 빈 값 · 겹침 막음). + * (631 · 요구 537 = 수평 바 `ui_template_range_bar` — 칸 사이 경계값 한 번 · 아래 ° 환산 · 칸 눌러 색 · 칠 안 함 칸 · + * [단계 추가] = 고른 칸 반으로 · [단계 지움] = 이웃과 합침 · [기본값] · [적용] · 빈 값 · 같은 값 · 거꾸로 막음). * 기억 = 화면 취향(`aislo:pref:d03.slope-bands` · 계정에 붙음 · 프로젝트 무관). * 칠하기 = 서버 값 그림(회색 · 칸 = 기울기 % 내림) → 범위 색 그림(서버 왕복 없음 · CLAUDE.md 5장). * 범위 · 비율 셈 = `logic/map_slope_bands.ts` · 부르는 곳 = D03 `UI_Layers` · B05 `UI_RouteEdit_Layers`. * ========================================================================== */ import { createButton, createIconButton } from "@ui/ui_template_button"; -import { createInputField, createSelectField } from "@ui/ui_template_input"; -import { createLegend } from "@ui/ui_template_flow_legend"; import { openModal } from "@ui/ui_template_modal"; import { themeColor } from "@ui/ui_template_palette"; +import { createRangeBar } from "@ui/ui_template_range_bar"; import { createText } from "@ui/ui_template_text"; import { readByKey, writeByKey } from "../b_page_state"; import { loadImage } from "./map_layer_data"; @@ -21,10 +21,16 @@ import { SLOPE_MAX_BANDS, SLOPE_MAX_PCT, bandOf, + bandsToBar, + barToBands, checkBands, + checkEdges, + mergeBar, parseBands, pctToDeg, + splitBar, type SlopeBand, + type SlopeBar, } from "./logic/map_slope_bands"; const PREF_KEY = "aislo:pref:d03.slope-bands"; @@ -89,127 +95,89 @@ export const slopeGear = (onClick: () => void): HTMLButtonElement => { return button; }; -const degText = (value: string): string => { - const pct = Number(value); - return value.trim() && Number.isFinite(pct) ? `≈ ${pctToDeg(pct).toFixed(1)}°` : "—"; -}; - -/** 옵션 모달 — [적용] = 막을 것 없으면 `onApply` 후 닫음. */ +/** 옵션 모달 — 수평 바(631 · 요구 537) · [적용] = 막을 것 없으면 `onApply` 후 닫음. */ export function openSlopeBands(current: SlopeBand[], onApply: (bands: SlopeBand[]) => void): void { - let rows: Array<{ root: HTMLElement; read: () => SlopeBand }> = []; - const list = document.createElement("div"); const error = createText("", "error"); error.hidden = true; + const bar = createRangeBar({ + ...bandsToBar(current), + palette: SLOPE_COLORS.map((c) => ({ value: c.color, name: c.name, color: `var(${c.color})` })), + noneLabel: "칠 안 함", + unit: "%", + ends: ["0 %", `${SLOPE_MAX_PCT} % 이상`], + hint: (pct) => `≈ ${pctToDeg(pct).toFixed(1)}°`, + ariaLabel: "급경사 범위", + onChange: () => refresh(), + onSelect: () => refresh(), + }); + bar.root.dataset.role = "slope-bar"; + + /** 고칠 거리 — 경계 먼저 · 다음 범위(색 칸 없음 · 너무 많음). */ + function problem(): string | null { + const state = bar.get(); + return checkEdges(state.edges) ?? checkBands(barToBands(state)); + } + + function refresh(): void { + const state = bar.get(); + const why = problem(); + error.lastChild!.textContent = why ?? ""; + error.hidden = !why; + const bad = checkEdges(state.edges) ? /(\d+)번째 경계/.exec(why ?? "")?.[1] : undefined; + bar.setError(bad ? Number(bad) : undefined); + addButton.disabled = !splitBar(state, bar.selected()); + removeButton.disabled = !mergeBar(state, bar.selected()); + } + + const step = + (make: (state: SlopeBar, i: number) => SlopeBar | null, next: (i: number) => number) => () => { + const i = bar.selected(); + const got = make(bar.get(), i); + if (!got) return; + bar.set(got, next(i)); + refresh(); + }; const addButton = createButton({ - label: "범위 추가", + label: "단계 추가", variant: "ghost", size: 28, iconStart: "plus", - onClick: () => { - const last = rows.at(-1)?.read(); - const low = last && Number.isFinite(last.high) ? Math.min(last.high, SLOPE_MAX_PCT - 1) : 0; - const color = SLOPE_COLORS[rows.length % SLOPE_COLORS.length].color; - add({ low, high: SLOPE_MAX_PCT, color }); - }, + onClick: step(splitBar, (i) => i + 1), }); addButton.dataset.role = "slope-band-add"; - - function refresh(): void { - addButton.disabled = rows.length >= SLOPE_MAX_BANDS; - error.hidden = true; - } - - function add(band: SlopeBand): void { - const field = (label: string, value: number) => { - const got = createInputField({ - label, - type: "number", - unit: "%", - min: 0, - max: SLOPE_MAX_PCT, - size: 28, - value: Number.isFinite(value) ? String(value) : "", - }); - const deg = createText(degText(got.input.value), "muted"); - got.input.addEventListener("input", () => { - deg.textContent = degText(got.input.value); - refresh(); - }); - got.root.append(deg); - return got; - }; - const low = field("아래", band.low); - const high = field("위", band.high); - const color = createSelectField({ - label: "색", - size: 28, - options: SLOPE_COLORS.map((c) => ({ value: c.color, text: c.name })), - value: band.color, - }); - const chip = createLegend([{ label: "", color: `var(${band.color})` }]); - color.select.addEventListener("change", () => - chip - .querySelector(".ui-legend__mark")! - .style.setProperty("--legend-color", `var(${color.select.value})`), - ); - const root = document.createElement("div"); - root.className = "d03-plan__row"; - root.dataset.role = "slope-band"; - const entry = { - root, - read: (): SlopeBand => ({ - low: low.input.value.trim() === "" ? NaN : Number(low.input.value), - high: high.input.value.trim() === "" ? NaN : Number(high.input.value), - color: color.select.value, - }), - }; - const remove = createIconButton({ - icon: "trash", - title: "이 범위 지움", - size: 28, - onClick: () => { - rows = rows.filter((row) => row !== entry); - root.remove(); - refresh(); - }, - }); - root.append(low.root, high.root, color.root, chip, remove); - rows.push(entry); - list.append(root); - refresh(); - } - - function fill(bands: SlopeBand[]): void { - rows = []; - list.replaceChildren(); - bands.forEach(add); - } + addButton.title = "고른 칸을 반으로 나눔"; + const removeButton = createButton({ + label: "단계 지움", + variant: "ghost", + size: 28, + iconStart: "trash", + onClick: step(mergeBar, (i) => Math.max(0, i - 1)), + }); + removeButton.dataset.role = "slope-band-remove"; + removeButton.title = "고른 칸을 이웃 칸과 합침"; openModal({ title: "급경사 범위(기울기 %)", closeLabel: "닫기", - width: "40rem", + width: "960px", mount: (body, close, actions) => { - fill(current); const reset = createButton({ label: "기본값", variant: "ghost", size: 28, - onClick: () => fill(DEFAULT_SLOPE_BANDS), + onClick: () => { + bar.set(bandsToBar(DEFAULT_SLOPE_BANDS), 0); + refresh(); + }, }); reset.dataset.role = "slope-band-default"; const apply = createButton({ label: "적용", size: 28, onClick: () => { - const bands = rows.map((row) => row.read()); - const why = checkBands(bands); - if (why) { - error.lastChild!.textContent = why; - error.hidden = false; - return; - } - onApply(bands.sort((a, b) => a.low - b.low)); + refresh(); + if (problem()) return; + onApply(barToBands(bar.get())); close(); }, }); @@ -217,16 +185,17 @@ export function openSlopeBands(current: SlopeBand[], onApply: (bands: SlopeBand[ actions.append(apply); const foot = document.createElement("div"); foot.className = "d03-plan__row"; - foot.append(addButton, reset); + foot.append(addButton, removeButton, reset); body.append( createText( - `0~${SLOPE_MAX_PCT} % · 아래 포함 · 위 뺌 · 겹치면 안 됨 · 기본 = 15 · 35 · 40°(지식DB)`, + `칸 사이 = 경계값(아래 칸 끝 = 위 칸 처음 · 아래 포함) · 칸 눌러 고르고 색 · 단계 ${SLOPE_MAX_BANDS}개까지 · 기본 = 15 · 35 · 40°(지식DB)`, "hint", ), - list, + bar.root, foot, error, ); + refresh(); }, }); } diff --git a/resources/tester/test_631_slope_bar.py b/resources/tester/test_631_slope_bar.py new file mode 100644 index 000000000..b063fd7b6 --- /dev/null +++ b/resources/tester/test_631_slope_bar.py @@ -0,0 +1,160 @@ +"""급경사 범위 수평 바(PLAN 631 · 요구 537) — `A00_Common/map/logic/map_slope_bands.ts`. + +경계 ↔ 범위(기본 · 빈 곳 = 칠 안 함 칸 · 되돌리면 같음) · 칸 나눔(가운데 · 새 색 · 좁음 · 8 단계) · +합침(왼쪽 · 첫 칸은 오른쪽 · 하나면 막음) · 경계 검사(빈 값 · 0 · 250 · 같은 값 · 거꾸로) · +창 = 부품 `ui_template_range_bar`(날 input · button 없음) · 부품 모음 줄. +TS 를 tsc 로 옮겨 node 로 돌림(서버 · 화면 없이). + +./venv/Scripts/python.exe -m pytest resources/tester/test_631_slope_bar.py -q +""" + +from __future__ import annotations + +import json +import re +import subprocess +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +TS_FILE = ROOT / "A00_Common" / "map" / "logic" / "map_slope_bands.ts" +UI_FILE = ROOT / "A00_Common" / "map" / "map_slope_bands.ts" +TSC = ROOT / "config" / "node_modules" / "typescript" / "bin" / "tsc" + +_RUNNER = """ +import { readFileSync, writeFileSync } from "node:fs"; +import * as bands from "./A00_Common/map/logic/map_slope_bands.js"; +const cases = JSON.parse(readFileSync(process.argv[2], "utf8")); +const D = bands.DEFAULT_SLOPE_BANDS; +const out = { DEFAULT: D, COLORS: bands.SLOPE_COLORS.map((c) => c.color) }; +// 인자 "D" = 기본 범위 · "DBAR" = 기본 바 +const arg = (a) => (a === "D" ? D : a === "DBAR" ? bands.bandsToBar(D) : a); +for (const [key, [name, ...args]] of Object.entries(cases)) out[key] = bands[name](...args.map(arg)); +out.roundtrip = bands.barToBands(bands.bandsToBar(D)); +writeFileSync(process.argv[3], JSON.stringify(out)); +""" + +C3, C2, C1 = "--draw-map-landslide-3", "--draw-map-landslide-2", "--draw-map-landslide-1" + + +def _bar(edges: list, colors: list) -> dict: + return {"edges": edges, "colors": colors} + + +@pytest.fixture(scope="module") +def r(tmp_path_factory: pytest.TempPathFactory) -> dict: + tmp = tmp_path_factory.mktemp("slope_bar") + out = tmp / "js" + subprocess.run( # noqa: S603 — 고정 실행 파일 + [ + "node", + str(TSC), + str(TS_FILE), + "--outDir", + str(out), + "--rootDir", + str(ROOT), + "--module", + "esnext", + "--target", + "es2022", + "--moduleResolution", + "bundler", + "--ignoreConfig", + ], + cwd=str(ROOT), + check=True, + capture_output=True, + ) + (out / "runner.mjs").write_text(_RUNNER, encoding="utf-8") + eight = _bar(list(range(0, 80, 10)) + [250], [C3] * 8) + cases = { + "default_bar": ["bandsToBar", "D"], + "gap_bar": [ + "bandsToBar", + [ + {"low": 30, "high": 250, "color": C1}, + {"low": 10, "high": 20, "color": C3}, + ], + ], + "full_bar": ["bandsToBar", [{"low": 0, "high": 250, "color": C2}]], + "gap_bands": ["barToBands", _bar([0, 10, 20, 30, 250], ["", C3, "", C1])], + "split_top": ["splitBar", "DBAR", 3], + "split_gap": ["splitBar", "DBAR", 0], + "split_narrow": ["splitBar", _bar([0, 0.1, 250], [C3, C2]), 0], + "split_eight": ["splitBar", eight, 7], + "split_bad": ["splitBar", _bar([0, 30, 20, 250], [C3, C2, C1]), 0], + "merge_top": ["mergeBar", "DBAR", 3], + "merge_first": ["mergeBar", "DBAR", 0], + "merge_mid": ["mergeBar", "DBAR", 2], + "merge_one": ["mergeBar", _bar([0, 250], [C3]), 0], + "edges_ok": ["checkEdges", [0, 26.8, 70, 83.9, 250]], + "edges_blank": ["checkEdges", [0, None, 250]], + "edges_zero": ["checkEdges", [0, 0, 250]], + "edges_top": ["checkEdges", [0, 20, 250, 250]], + "edges_same": ["checkEdges", [0, 20, 20, 250]], + "edges_reverse": ["checkEdges", [0, 30, 20, 250]], + } + job, result = tmp / "job.json", tmp / "out.json" + job.write_text(json.dumps(cases), encoding="utf-8") + subprocess.run( # noqa: S603 + ["node", str(out / "runner.mjs"), str(job), str(result)], + cwd=str(ROOT), + check=True, + capture_output=True, + ) + return json.loads(result.read_text(encoding="utf-8")) + + +def test_기본_범위는_바로_가도_같음(r): + assert r["default_bar"] == _bar([0, 26.8, 70.0, 83.9, 250], ["", C3, C2, C1]) + assert r["roundtrip"] == r["DEFAULT"] # 범위 같음 = 그림 · 범례 같음 + + +def test_빈_곳은_칠_안_함_칸(r): + assert r["gap_bar"] == _bar([0, 10, 20, 30, 250], ["", C3, "", C1]) + assert r["gap_bands"] == [ + {"low": 10, "high": 20, "color": C3}, + {"low": 30, "high": 250, "color": C1}, + ] + assert r["full_bar"] == _bar([0, 250], [C2]) + + +def test_칸_나눔(r): + # 83.9 ~ 250 → 가운데 167.0 · 위 반 = 안 쓴 첫 색 + assert r["split_top"]["edges"] == [0, 26.8, 70.0, 83.9, 167.0, 250] + new = next(c for c in r["COLORS"] if c not in (C3, C2, C1)) + assert r["split_top"]["colors"] == ["", C3, C2, C1, new] + assert r["split_gap"]["edges"] == [0, 13.4, 26.8, 70.0, 83.9, 250] + assert r["split_gap"]["colors"][:2] == ["", new] + assert r["split_narrow"] is None + assert r["split_eight"] is None # 8 단계까지 + assert r["split_bad"] is None + + +def test_칸_합침(r): + assert r["merge_top"] == _bar([0, 26.8, 70.0, 250], ["", C3, C2]) + assert r["merge_first"] == _bar([0, 70.0, 83.9, 250], [C3, C2, C1]) + assert r["merge_mid"] == _bar([0, 26.8, 83.9, 250], ["", C3, C1]) + assert r["merge_one"] is None + + +def test_경계_검사(r): + assert r["edges_ok"] is None + assert "1번째 경계" in r["edges_blank"] and "비었습니다" in r["edges_blank"] + assert "0 보다 크고" in r["edges_zero"] + assert "2번째 경계" in r["edges_top"] and "250" in r["edges_top"] + assert "같은 값" in r["edges_same"] + assert "왼쪽 경계보다" in r["edges_reverse"] + + +def test_창은_부품만(): + src = UI_FILE.read_text(encoding="utf-8") + assert "createRangeBar(" in src + assert not re.search(r'createElement\("(input|button|select)"\)', src) + kit = (ROOT / "ui_template" / "ui_template_ui_kit_parts.ts").read_text(encoding="utf-8") + assert '{ key: "range-bar", name: "범위 바", group: "compound",' in kit + assert 'file: "ui_template_range_bar.ts"' in kit + css = (ROOT / "ui_template" / "ui_template_range_bar.css").read_text(encoding="utf-8") + assert not re.search(r"#[0-9a-fA-F]{3,8}\b|rgba?\(|--draw-", css) # 색 = 공통 값만 diff --git a/ui_template/ui_template_range_bar.css b/ui_template/ui_template_range_bar.css new file mode 100644 index 000000000..f135832c2 --- /dev/null +++ b/ui_template/ui_template_range_bar.css @@ -0,0 +1,111 @@ +/* 부품 「범위 바」(631) — 칸 · 경계 입력 · 색 상자 · 단 값은 ui_template_button.css */ +.ui-range-bar { + display: flex; + flex-direction: column; + gap: 8px; +} +.ui-range-bar__track { + display: flex; + align-items: flex-start; + gap: 4px; +} +.ui-range-bar__end { + flex: none; + display: inline-flex; + align-items: center; + height: var(--ui-size-h); + color: var(--color-text-secondary); + font-size: var(--text-caption); + white-space: nowrap; +} +.ui-range-bar__seg { + flex: 1 1 0; + min-width: 16px; + height: var(--ui-size-h); + padding: 0; + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + background: var(--ui-range-bar-color, transparent); + cursor: pointer; + transition: + border-color var(--transition-fast), + box-shadow var(--transition-fast); +} +.ui-range-bar__seg[data-empty], +.ui-range-bar__swatch[data-empty]::before { + background: repeating-linear-gradient( + 135deg, + var(--color-border-strong) 0 1px, + transparent 1px 5px + ); +} +.ui-range-bar__seg:is(:hover, [data-state="hover"]):not(:disabled) { + border-color: var(--color-border-strong); +} +.ui-range-bar__seg[aria-pressed="true"] { + outline: 2px solid var(--color-accent); + outline-offset: 1px; +} +.ui-range-bar__seg:is(:active, [data-state="active"]):not(:disabled) { + box-shadow: inset 0 1px 2px var(--color-scrim); +} +.ui-range-bar__seg:disabled { + opacity: 0.5; + cursor: not-allowed; +} +.ui-range-bar__edge { + flex: none; + display: flex; + flex-direction: column; + width: 4rem; + gap: 2px; +} +.ui-range-bar__edge .ui-input { + padding: 0 4px; + text-align: center; +} +.ui-range-bar__hint { + color: var(--color-text-muted); + font-size: var(--text-caption); + text-align: center; + white-space: nowrap; +} +.ui-range-bar__colors { + display: flex; + flex-wrap: wrap; + gap: 4px; +} +.ui-range-bar__swatch { + display: inline-flex; + align-items: center; + gap: 4px; + height: 24px; + padding: 0 8px; + border: 1px solid var(--color-border); + border-radius: var(--radius-pills); + background: var(--color-surface-raised); + color: var(--color-text-secondary); + font: inherit; + font-size: var(--text-caption); + cursor: pointer; +} +.ui-range-bar__swatch::before { + content: ""; + width: 12px; + height: 12px; + border-radius: 2px; + background: var(--ui-range-bar-color); +} +.ui-range-bar__swatch[data-empty]::before { + box-shadow: inset 0 0 0 1px var(--color-border-strong); +} +.ui-range-bar__swatch:is(:hover, [data-state="hover"]):not(:disabled) { + background: var(--color-hover); + color: var(--color-text); +} +.ui-range-bar__swatch[aria-pressed="true"] { + border-color: var(--color-accent); + background: var(--color-accent-soft); + color: var(--color-accent); + font-weight: var(--font-weight-bold); +} diff --git a/ui_template/ui_template_range_bar.ts b/ui_template/ui_template_range_bar.ts new file mode 100644 index 000000000..8bd883dad --- /dev/null +++ b/ui_template/ui_template_range_bar.ts @@ -0,0 +1,199 @@ +/* ============================================================================= + * ui_template_range_bar.ts + * 부품 「범위 바」(631 · 부품 모음 `#/b01-account?view=ui-kit`) — 가로 한 줄 칸(범위) · 칸 사이 경계값 하나 · 칸 눌러 고름 · 색 상자로 칸 색 + * + * 꼴 = 칸(넓이 = 경계 차 비례 · 아주 좁으면 최소 넓이) · 칸 사이 = 수 입력 · 두 끝 = 고정 글 · 아래 = 색 상자 줄(+ 칠 안 함 = 빗금) + * 크기 = data-size 24 · 28(기본 28) + * 상태 = 마우스 `:hover` · 눌림 `:active` · 고름 = aria-pressed · 막힘 `disabled` · 오류 = 경계 칸 aria-invalid(`setError`) + * 쓰기 = `createRangeBar({ edges: [0, 20, 250], colors: ["a", "b"], palette: [{ value: "a", name: "노랑", color: "var(--…)" }], onChange })` + * 나눔 · 합침 · 검사는 부르는 쪽(급경사 = `A00_Common/map/logic/map_slope_bands.ts`) + * ========================================================================== */ + +import { setUiSize, type UiSize } from "./ui_template_button"; +import { createInputField } from "./ui_template_input"; +import "./ui_template_range_bar.css"; + +export interface RangeBarColor { + value: string; + name: string; + /** CSS 색(`var(--…)`) */ + color: string; +} + +export interface RangeBarState { + /** 경계 — 처음 · 끝은 고정 · 칸 i = [경계 i, 경계 i+1) */ + edges: number[]; + /** 칸 색 값(빈 글 = 칠 안 함) */ + colors: string[]; +} + +export interface RangeBarHandle { + root: HTMLDivElement; + get: () => RangeBarState; + /** 칸을 새로 그림(onChange 는 안 부름) */ + set: (state: RangeBarState, selected?: number) => void; + selected: () => number; + /** 경계 i(1 ~ 끝 − 1)를 틀림으로 · 없으면 모두 풂 */ + setError: (edge?: number) => void; +} + +export function createRangeBar(opts: { + edges: number[]; + colors: string[]; + palette: RangeBarColor[]; + /** 있으면 색 상자 줄 끝에 칠 안 함(값 빈 글) */ + noneLabel?: string; + unit?: string; + /** 두 끝 글(없음 = 경계값 + 단위) */ + ends?: [string, string]; + /** 경계 아래 작은 글(예 ° 환산) */ + hint?: (value: number) => string; + onChange?: (state: RangeBarState) => void; + onSelect?: (index: number) => void; + size?: UiSize; + ariaLabel?: string; +}): RangeBarHandle { + const root = document.createElement("div"); + root.className = "ui-range-bar"; + setUiSize(root, opts.size ?? 28); + if (opts.ariaLabel) root.setAttribute("aria-label", opts.ariaLabel); + const track = document.createElement("div"); + track.className = "ui-range-bar__track"; + const swatchRow = document.createElement("div"); + swatchRow.className = "ui-range-bar__colors"; + root.append(track, swatchRow); + + let state: RangeBarState = { edges: [...opts.edges], colors: [...opts.colors] }; + let current = 0; + let segs: HTMLButtonElement[] = []; + let inputs: HTMLInputElement[] = []; + const unit = opts.unit ? ` ${opts.unit}` : ""; + const colorOf = (value: string): string => + opts.palette.find((p) => p.value === value)?.color ?? ""; + const nameOf = (value: string): string => + opts.palette.find((p) => p.value === value)?.name ?? opts.noneLabel ?? ""; + const changed = (): void => opts.onChange?.(get()); + const get = (): RangeBarState => ({ edges: [...state.edges], colors: [...state.colors] }); + + const swatches = [ + ...opts.palette, + ...(opts.noneLabel !== undefined ? [{ value: "", name: opts.noneLabel, color: "" }] : []), + ].map((p) => { + const b = document.createElement("button"); + b.type = "button"; + b.className = "ui-range-bar__swatch"; + b.dataset.value = p.value; + if (p.color) b.style.setProperty("--ui-range-bar-color", p.color); + else b.dataset.empty = ""; + b.append(p.name); + b.addEventListener("click", () => { + if (state.colors[current] === p.value) return; + state.colors[current] = p.value; + paint(); + changed(); + }); + swatchRow.append(b); + return b; + }); + + /** 칸 넓이 = 경계 차(경계가 틀리면 그대로 둠) */ + function layout(): void { + const e = state.edges; + if (!e.every((v, i) => Number.isFinite(v) && (i === 0 || v > e[i - 1]))) return; + segs.forEach((seg, i) => { + seg.style.flexGrow = String(e[i + 1] - e[i]); + seg.title = `${e[i]} ~ ${e[i + 1]}${unit} · ${nameOf(state.colors[i])}`; + }); + } + + function paint(): void { + segs.forEach((seg, i) => { + const color = colorOf(state.colors[i]); + if (color) { + seg.style.setProperty("--ui-range-bar-color", color); + delete seg.dataset.empty; + } else { + seg.style.removeProperty("--ui-range-bar-color"); + seg.dataset.empty = ""; + } + seg.setAttribute("aria-pressed", String(i === current)); + }); + const picked = state.colors[current]; + for (const b of swatches) b.setAttribute("aria-pressed", String(b.dataset.value === picked)); + layout(); + } + + const endText = (text: string): HTMLSpanElement => { + const span = document.createElement("span"); + span.className = "ui-range-bar__end"; + span.textContent = text; + return span; + }; + + function render(): void { + const e = state.edges; + segs = []; + inputs = []; + track.replaceChildren(endText(opts.ends?.[0] ?? `${e[0]}${unit}`)); + state.colors.forEach((_, i) => { + const seg = document.createElement("button"); + seg.type = "button"; + seg.className = "ui-range-bar__seg"; + seg.dataset.index = String(i); + seg.addEventListener("click", () => { + current = i; + paint(); + opts.onSelect?.(i); + }); + segs.push(seg); + track.append(seg); + if (i === state.colors.length - 1) return; + const field = createInputField({ + type: "number", + size: opts.size ?? 28, + value: String(e[i + 1]), + min: e[0], + max: e.at(-1), + }); + field.root.classList.add("ui-range-bar__edge"); + field.input.dataset.edge = String(i + 1); + const hint = document.createElement("span"); + hint.className = "ui-range-bar__hint"; + const showHint = (): void => { + const v = state.edges[i + 1]; + hint.textContent = opts.hint && Number.isFinite(v) ? opts.hint(v) : ""; + }; + field.input.addEventListener("input", () => { + const raw = field.input.value.trim(); + state.edges[i + 1] = raw === "" ? NaN : Number(raw); + showHint(); + layout(); + changed(); + }); + showHint(); + if (opts.hint) field.root.append(hint); + inputs.push(field.input); + track.append(field.root); + }); + track.append(endText(opts.ends?.[1] ?? `${e.at(-1)}${unit}`)); + paint(); + } + + const set = (next: RangeBarState, selected = current): void => { + state = { edges: [...next.edges], colors: [...next.colors] }; + current = Math.max(0, Math.min(selected, state.colors.length - 1)); + render(); + }; + + const setError = (edge?: number): void => { + inputs.forEach((input, k) => { + const bad = k + 1 === edge; + input.classList.toggle("ui-input--error", bad); + if (bad) input.setAttribute("aria-invalid", "true"); + else input.removeAttribute("aria-invalid"); + }); + }; + + render(); + return { root, get, set, selected: () => current, setError }; +} diff --git a/ui_template/ui_template_ui_kit_parts.ts b/ui_template/ui_template_ui_kit_parts.ts index 4477ed974..f1c28dd1d 100644 --- a/ui_template/ui_template_ui_kit_parts.ts +++ b/ui_template/ui_template_ui_kit_parts.ts @@ -46,6 +46,7 @@ import { createEmpty } from "./ui_template_empty"; import { createLegend } from "./ui_template_flow_legend"; import { createKv } from "./ui_template_kv"; import { createProgressBar, createProgressCircle } from "./ui_template_progress"; +import { createRangeBar } from "./ui_template_range_bar"; import { createStat, statGrid } from "./ui_template_stat"; import { toastNode } from "./ui_template_toast"; import { FRAME_DEMOS } from "./ui_template_ui_kit_frames"; @@ -347,6 +348,9 @@ export const KIT_PARTS: KitPart[] = [ demo: (s) => (s === "default" ? col(createBarChart([{ label: "산림설계", value: 12 }, { label: "녹색기술", value: 7 }, { label: "그 밖 3 곳", value: 4 }]), createDonut([{ label: "임도", value: 5 }, { label: "예정노선", value: 3 }, { label: "안정성", value: 2 }]), createLineChart({ series: [{ name: "CPU", values: [20, 35, 30, 50, 42, 60], colorIndex: 0 }], height: 120 })) : null) }, { key: "legend", name: "범례", group: "compound", kinds: "색 칩 · 선 · 점선 · 점 · 꺼짐 · 세로", file: "ui_template_flow_legend.ts", demo: (s) => (s === "default" ? col(createLegend([{ label: "임도", color: "var(--color-chart-0)" }, { label: "설계선", color: "var(--color-chart-2)", kind: "line" }, { label: "원지반선", color: "var(--color-chart-1)", kind: "dash" }, { label: "관", color: "var(--color-chart-3)", kind: "dot" }, { label: "꺼진 층", color: "var(--color-chart-0)", off: true }], { onToggle: noop }), createLegend([{ label: "절토", color: "var(--color-danger)" }, { label: "성토", color: "var(--color-info)" }], { vertical: true })) : null) }, + { key: "range-bar", name: "범위 바", group: "compound", kinds: "칸(넓이 = 경계 비례) · 칸 사이 경계값 하나 · 칸 눌러 고름 · 색 상자 · 칠 안 함 빗금 · 경계 오류 · 24 · 28", file: "ui_template_range_bar.ts", + demo: (s) => { if (s !== "default" && s !== "error") return null; const bar = createRangeBar({ edges: [0, 26.8, 70, 83.9, 250], colors: ["", "c0", "c1", "c2"], palette: [0, 1, 2, 3].map((n) => ({ value: `c${n}`, name: `색 ${n + 1}`, color: `var(--color-chart-${n})` })), noneLabel: "칠 안 함", unit: "%", ends: ["0 %", "250 % 이상"], hint: (v) => `${Math.round(v)}`, onChange: noop }); + if (s === "error") bar.setError(2); return bar.root; } }, { key: "setting-row", name: "설정 줄", group: "compound", kinds: "제목 · 설명 왼쪽 · 오른쪽 값 · 조작 · 줄 52 · 좁은 꼴(줄 28)", file: "ui_template_setting_row.ts", demo: (s) => (s === "default" ? col(settingCard([settingRow("회사 이름", "보고서 머리에 들어감", valueText("산림설계")), settingRow("단위", "", valueText(null)), settingRow("기본 노폭", "새 노선에 먼저 들어감", createInputField({ placeholder: "3.0 m" }).root), settingRow("글꼴 파일", "", createButton({ label: "바꾸기", variant: "ghost", size: 28, onClick: noop }))]), settingCard([settingRow("측면 한계각(°)", "", createInputField({ placeholder: "30", size: 24 }).root, { compact: true }), settingRow("균열 모드", "", valueText("없음"), { compact: true })])) : null) }, { key: "drop", name: "파일 놓기 칸", group: "compound", kinds: "끌어 놓기 · 고르기 단추 · 받는 꼴 안내 · 진행 · 오류", file: "ui_template_drop.ts",