/* ============================================================================= * 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 }; }