Merge remote-tracking branch 'origin/sub_laptop_3' into main_laptop_1

This commit is contained in:
2026-10-31 22:46:44 +09:00
6 changed files with 618 additions and 103 deletions
+72
View File
@@ -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),
};
}
+72 -103
View File
@@ -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<HTMLElement>(".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();
},
});
}
+160
View File
@@ -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) # 색 = 공통 값만
+111
View File
@@ -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);
}
+199
View File
@@ -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 };
}
+4
View File
@@ -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",