Files
Aislo/ui_template/ui_template_elements_chart.ts
T
eomsangdonandClaude Opus 5.5 7e584c1e7b feat(ui-kit): 묶음 부품 열 — 카드 · 접이 카드 · 빈 상태 · 알림 · 띠 알림 · 숫자 카드 · 값 목록 · 진행 막대 · 작은 그래프 · 범례 (PLAN 552-3)
- 새 부품 파일: ui_template_card · empty · banner · stat · kv · toast · flow_legend(.ts) · 진행 막대 · 막대 · 원 그래프 덧붙임
- createCard · showToast · 로딩 가림막은 부품 파일로 옮기고 본체(ui_template_elements)가 다시 내보냄 — 부름 꼴 그대로
- 대시보드 emptyState = 빈 상태 부품을 씀(한 벌) · 선 카드 그림자 0 · 가림막 옛 보라 → --color-scrim
- 부품 모음 화면 견본(밝음 · 어두움 · 상태) · 지킴 시험 test_552_3_* 열
- 전후 촬영: B01 여덟 화면 자리 · 화소 같음(실시간 CPU 값만 다름) · A01 · B08 카드 그림자만 다름

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 21:28:29 +09:00

291 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* =============================================================================
* ui_template_elements_chart.ts
* 부품 「작은 그래프」(552-3 · 목록 31) — 막대 · 원(도넛) · 선.
* 꼴 = 가로 막대(`createBarChart` · 이름 · 막대 · 값) · 원(`createDonut` · 가운데 합 · 옆 범례) · 선(`createLineChart` · 아래)
* 크기 = 막대 줄 22 · 원 지름 96 · 선 640 × 200 가상 폭(폭 100 %)
* 상태 = 기본 · 빈 값이면 「자료 없음」(`.ui-chart__empty`)
* 색 = 갈래 0~3 = `--color-chart-N` · 그 밖 색은 부르는 쪽이 CSS 값 글(`var(--…)`)로 줌
* 쓰는 법 = `createBarChart([{ label: "산림설계", value: 12 }])` · `createDonut([{ label: "임도", value: 5, color: "var(--color-chart-1)" }])`
* 대신하는 옛 변종 = B01 `.b01-home__bars` · `__donut` · `.b01-sys-chart` · `.b01-home__thumb`
*
* 공통 라인 차트 — 대시보드 리소스 그래프에 쓰는 SVG 꺾은선.
*
* `ui_template_elements.ts` 가 700줄을 넘어 떼어냈다(2026-09-04). 본체가 그대로
* 다시 내보내므로 호출부의 import 경로는 바뀌지 않는다.
* ========================================================================== */
import { el } from "./ui_template_elements_base";
import { createLegend } from "./ui_template_flow_legend";
import "./ui_template_elements_chart.css";
export interface LineChartSeries {
/** 범례에 표시할 이름 (i18n 결과 문자열) */
name: string;
/** y 값 배열 (x는 인덱스 순서, null은 결측으로 선 끊김) */
values: (number | null)[];
/** 선 색상 클래스 접미사: 0~3 (theme.css의 --color-chart-N 참조) */
colorIndex?: 0 | 1 | 2 | 3;
}
export interface LineChartOptions {
series: LineChartSeries[];
/** x축 라벨 (values와 같은 길이 권장, 일부만 자동 선택 표기) */
xLabels?: string[];
/** y축 최대값 (기본: 100 = 퍼센트) */
yMax?: number;
/** y축 단위 접미사 (기본: "%") */
yUnit?: string;
/** 접근성 설명 */
ariaLabel?: string;
/** 커스텀 가상 가로폭 (기본: CHART_W = 640) */
width?: number;
/** 커스텀 가상 세로폭 (기본: CHART_H = 200) */
height?: number;
}
const CHART_W = 640;
const CHART_H = 200;
const CHART_PAD = { top: 12, right: 12, bottom: 26, left: 36 };
const X_TICK_STEP = 3; // x축 라벨 표기 간격 (3개마다 1개 표시)
/** 유효 점들을 Catmull-Rom → 3차 베지어로 변환한 스플라인 path 데이터를 만든다. */
function splinePath(points: { x: number; y: number }[]): string {
if (points.length === 0) return "";
if (points.length === 1) return `M${points[0].x},${points[0].y}`;
let d = `M${points[0].x.toFixed(1)},${points[0].y.toFixed(1)}`;
for (let i = 0; i < points.length - 1; i += 1) {
const p0 = points[i - 1] ?? points[i];
const p1 = points[i];
const p2 = points[i + 1];
const p3 = points[i + 2] ?? p2;
// Catmull-Rom (tension 1/6) → cubic Bézier 제어점
const c1x = p1.x + (p2.x - p0.x) / 6;
const c1y = p1.y + (p2.y - p0.y) / 6;
const c2x = p2.x - (p3.x - p1.x) / 6;
const c2y = p2.y - (p3.y - p1.y) / 6;
d +=
` C${c1x.toFixed(1)},${c1y.toFixed(1)} ` +
`${c2x.toFixed(1)},${c2y.toFixed(1)} ` +
`${p2.x.toFixed(1)},${p2.y.toFixed(1)}`;
}
return d;
}
/** 시계열 스플라인 차트를 반환. 데이터가 없으면 안내 문구를 담은 빈 상태를 반환. */
export function createLineChart(opts: LineChartOptions): HTMLDivElement {
const w = opts.width ?? CHART_W;
const h = opts.height ?? CHART_H;
const yMax = opts.yMax ?? 100;
const yUnit = opts.yUnit ?? "%";
const wrap = el("div", { className: "ui-chart" });
const pointCount = Math.max(0, ...opts.series.map((s) => s.values.length));
if (pointCount < 2) {
wrap.append(el("div", { className: "ui-chart__empty", text: "—" }));
wrap.setAttribute("data-empty", "true");
return wrap;
}
const plotW = w - CHART_PAD.left - CHART_PAD.right;
const plotH = h - CHART_PAD.top - CHART_PAD.bottom;
const xAt = (i: number) => CHART_PAD.left + (plotW * i) / (pointCount - 1);
const yAt = (v: number) => CHART_PAD.top + plotH * (1 - Math.min(v, yMax) / yMax);
const svgNs = "http://www.w3.org/2000/svg";
const svg = document.createElementNS(svgNs, "svg");
svg.setAttribute("class", "ui-chart__svg");
svg.setAttribute("viewBox", `0 0 ${w} ${h}`);
svg.setAttribute("role", "img");
svg.setAttribute("width", "100%");
svg.setAttribute("height", "100%");
svg.setAttribute("preserveAspectRatio", "none");
if (opts.ariaLabel) svg.setAttribute("aria-label", opts.ariaLabel);
// y축 그리드 + 라벨 (0, 25, 50, 75, 100%)
for (let g = 0; g <= 4; g += 1) {
const v = (yMax / 4) * g;
const y = yAt(v);
const line = document.createElementNS(svgNs, "line");
line.setAttribute("class", "ui-chart__grid");
line.setAttribute("x1", String(CHART_PAD.left));
line.setAttribute("x2", String(w - CHART_PAD.right));
line.setAttribute("y1", String(y));
line.setAttribute("y2", String(y));
svg.append(line);
const tick = document.createElementNS(svgNs, "text");
tick.setAttribute("class", "ui-chart__tick");
tick.setAttribute("x", String(CHART_PAD.left - 6));
tick.setAttribute("y", String(y + 4));
tick.setAttribute("text-anchor", "end");
tick.textContent = `${Math.round(v)}${yUnit}`;
svg.append(tick);
}
// x축 라벨 (데이터 포인트 개수만큼, X_TICK_STEP 간격으로 표기) + 수직 점선 그리드
if (opts.xLabels && opts.xLabels.length > 0) {
const labels = opts.xLabels;
const baseY = CHART_PAD.top + plotH;
for (let idx = 0; idx < pointCount; idx += X_TICK_STEP) {
const label = labels[idx];
const x = xAt(idx);
if (label !== undefined && label !== "") {
// 수직 점선 그리드
const vline = document.createElementNS(svgNs, "line");
vline.setAttribute("class", "ui-chart__grid ui-chart__grid--vertical");
vline.setAttribute("x1", String(x));
vline.setAttribute("x2", String(x));
vline.setAttribute("y1", String(CHART_PAD.top));
vline.setAttribute("y2", String(baseY));
svg.append(vline);
// x축 라벨
const tick = document.createElementNS(svgNs, "text");
tick.setAttribute("class", "ui-chart__tick ui-chart__tick--x");
tick.setAttribute("x", String(x));
tick.setAttribute("y", String(baseY + 16));
tick.setAttribute("text-anchor", "middle");
tick.textContent = label;
svg.append(tick);
}
}
}
// 시리즈별 스플라인 (null 구간은 연속 세그먼트로 나눠 각각 곡선 처리)
for (const s of opts.series) {
let segment: { x: number; y: number }[] = [];
let d = "";
const flush = () => {
if (segment.length > 0) d += `${splinePath(segment)} `;
segment = [];
};
s.values.forEach((v, i) => {
if (v === null || v === undefined) {
flush();
return;
}
segment.push({ x: xAt(i), y: yAt(v) });
});
flush();
const path = document.createElementNS(svgNs, "path");
path.setAttribute("class", `ui-chart__line ui-chart__line--c${s.colorIndex ?? 0}`);
path.setAttribute("d", d.trim());
svg.append(path);
}
// 범례 (그래프 영역 우상단 오버레이)
const legend = el("div", { className: "ui-chart__legend" });
opts.series.forEach((s) => {
legend.append(
el("span", {
className: `ui-chart__legend-item ui-chart__legend-item--c${s.colorIndex ?? 0}`,
text: s.name,
}),
);
});
const plot = el("div", { className: "ui-chart__plot" });
plot.append(svg, legend);
wrap.append(plot);
return wrap;
}
/* =============================================================================
* 8. 기본 컴포넌트 스타일 주입 (injectBaseStyles)
* theme.css 변수만 참조. 앱 진입 시 1회 호출.
* ========================================================================== */
const SVG_NS_CHART = "http://www.w3.org/2000/svg";
function svgNode<K extends keyof SVGElementTagNameMap>(
tag: K,
attrs: Record<string, string | number>,
): SVGElementTagNameMap[K] {
const n = document.createElementNS(SVG_NS_CHART, tag);
for (const [k, v] of Object.entries(attrs)) n.setAttribute(k, String(v));
return n;
}
export interface ChartItem {
label: string;
value: number;
/** CSS 색 값 글(`var(--color-chart-2)`) — 없으면 갈래 차례(--color-chart-N) */
color?: string;
}
const chartColor = (item: ChartItem, i: number): string =>
item.color ?? `var(--color-chart-${i % 4})`;
const emptyChart = (): HTMLDivElement =>
el("div", {
className: "ui-chart ui-chart--mini",
children: [el("div", { className: "ui-chart__empty", text: "자료 없음" })],
});
/** 가로 막대 — 이름 · 막대 · 값 · 줄 22 */
export function createBarChart(items: ChartItem[], color?: string): HTMLDivElement {
if (!items.length) return emptyChart();
const max = Math.max(1, ...items.map((r) => r.value));
const rowH = 22;
const svg = svgNode("svg", {
class: "ui-mini-bars",
viewBox: `0 0 320 ${items.length * rowH}`,
width: "100%",
preserveAspectRatio: "xMinYMin meet",
role: "img",
});
items.forEach((r, i) => {
const y = i * rowH;
const width = (r.value / max) * 180;
const name = svgNode("text", { x: 0, y: y + 14 });
name.textContent = r.label.length > 9 ? `${r.label.slice(0, 9)}…` : r.label;
const count = svgNode("text", { x: 96 + width, y: y + 14 });
count.textContent = String(r.value);
const bar = svgNode("rect", { x: 90, y: y + 4, width, height: 12, rx: 3 });
bar.style.fill = r.color ?? color ?? "var(--color-accent)";
svg.append(name, bar, count);
});
return el("div", {
className: "ui-chart ui-chart--mini",
children: [svg as unknown as HTMLElement],
});
}
/** 원(도넛) — 가운데 = 합 · 옆 = 범례 */
export function createDonut(items: ChartItem[]): HTMLDivElement {
const total = items.reduce((a, r) => a + Math.max(0, r.value), 0);
if (!total) return emptyChart();
const svg = svgNode("svg", {
class: "ui-mini-donut",
viewBox: "0 0 42 42",
width: 96,
height: 96,
});
svg.append(svgNode("circle", { cx: 21, cy: 21, r: 15.9155, class: "ui-mini-donut__track" }));
let start = 0;
items.forEach((r, i) => {
const pct = (Math.max(0, r.value) / total) * 100;
const arc = svgNode("circle", {
cx: 21,
cy: 21,
r: 15.9155,
"stroke-dasharray": `${pct} ${100 - pct}`,
"stroke-dashoffset": 25 - start,
});
arc.style.stroke = chartColor(r, i);
svg.append(arc);
start += pct;
});
const sum = svgNode("text", { x: 21, y: 24, "text-anchor": "middle" });
sum.textContent = String(total);
svg.append(sum);
const legend = createLegend(
items.map((r, i) => ({ label: `${r.label} ${r.value}`, color: chartColor(r, i) })),
{
vertical: true,
},
);
return el("div", {
className: "ui-chart ui-chart--mini ui-mini-donut-row",
children: [svg as unknown as HTMLElement, legend],
});
}