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
+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",