Merge remote-tracking branch 'origin/sub_laptop_3' into main_laptop_1
This commit is contained in:
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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) # 색 = 공통 값만
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user