This commit is contained in:
2026-07-08 19:32:16 +09:00
parent 9c40dec070
commit 8ffa9b097a
14 changed files with 238 additions and 50 deletions
+37 -11
View File
@@ -311,12 +311,16 @@ export interface LineChartOptions {
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_COUNT = 5; // x축에 표기할 라벨 개수 (양끝 포함)
const X_TICK_STEP = 3; // x축 라벨 표기 간격 (3개마다 1개 표시)
/** 유효 점들을 Catmull-Rom → 3차 베지어로 변환한 스플라인 path 데이터를 만든다. */
function splinePath(points: { x: number; y: number }[]): string {
@@ -343,6 +347,8 @@ function splinePath(points: { x: number; y: number }[]): string {
/** 시계열 스플라인 차트를 반환. 데이터가 없으면 안내 문구를 담은 빈 상태를 반환. */
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" });
@@ -354,16 +360,18 @@ export function createLineChart(opts: LineChartOptions): HTMLDivElement {
return wrap;
}
const plotW = CHART_W - CHART_PAD.left - CHART_PAD.right;
const plotH = CHART_H - CHART_PAD.top - CHART_PAD.bottom;
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 ${CHART_W} ${CHART_H}`);
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);
@@ -374,7 +382,7 @@ export function createLineChart(opts: LineChartOptions): HTMLDivElement {
const line = document.createElementNS(svgNs, "line");
line.setAttribute("class", "ui-chart__grid");
line.setAttribute("x1", String(CHART_PAD.left));
line.setAttribute("x2", String(CHART_W - CHART_PAD.right));
line.setAttribute("x2", String(w - CHART_PAD.right));
line.setAttribute("y1", String(y));
line.setAttribute("y2", String(y));
svg.append(line);
@@ -387,20 +395,18 @@ export function createLineChart(opts: LineChartOptions): HTMLDivElement {
svg.append(tick);
}
// x축 라벨 (양끝 포함 X_TICK_COUNT개를 균등 선택)
// x축 라벨 (데이터 포인트 개수만큼, X_TICK_STEP 간격으로 표기)
if (opts.xLabels && opts.xLabels.length > 0) {
const labels = opts.xLabels;
const baseY = CHART_PAD.top + plotH;
for (let t = 0; t < X_TICK_COUNT; t += 1) {
const idx = Math.round(((pointCount - 1) * t) / (X_TICK_COUNT - 1));
for (let idx = 0; idx < pointCount; idx += X_TICK_STEP) {
const label = labels[idx];
if (label === undefined) continue;
const anchor = t === 0 ? "start" : t === X_TICK_COUNT - 1 ? "end" : "middle";
if (label === undefined || label === "") continue;
const tick = document.createElementNS(svgNs, "text");
tick.setAttribute("class", "ui-chart__tick ui-chart__tick--x");
tick.setAttribute("x", String(xAt(idx)));
tick.setAttribute("y", String(baseY + 16));
tick.setAttribute("text-anchor", anchor);
tick.setAttribute("text-anchor", "middle");
tick.textContent = label;
svg.append(tick);
}
@@ -426,6 +432,17 @@ export function createLineChart(opts: LineChartOptions): HTMLDivElement {
path.setAttribute("class", `ui-chart__line ui-chart__line--c${s.colorIndex ?? 0}`);
path.setAttribute("d", d.trim());
svg.append(path);
// 각 데이터 포인트에 점(circle) 그리기
s.values.forEach((v, i) => {
if (v === null || v === undefined) return;
const circle = document.createElementNS(svgNs, "circle");
circle.setAttribute("class", `ui-chart__point ui-chart__point--c${s.colorIndex ?? 0}`);
circle.setAttribute("cx", String(xAt(i)));
circle.setAttribute("cy", String(yAt(v)));
circle.setAttribute("r", "3");
svg.append(circle);
});
}
// 범례 (그래프 영역 우상단 오버레이)
@@ -698,6 +715,15 @@ const BASE_CSS = `
.ui-chart__line--c1 { stroke: var(--color-chart-1); }
.ui-chart__line--c2 { stroke: var(--color-chart-2); }
.ui-chart__line--c3 { stroke: var(--color-chart-3); }
.ui-chart__point {
stroke-width: 1.5;
stroke: var(--color-surface);
}
.ui-chart__point--c0 { fill: var(--color-chart-0); }
.ui-chart__point--c1 { fill: var(--color-chart-1); }
.ui-chart__point--c2 { fill: var(--color-chart-2); }
.ui-chart__point--c3 { fill: var(--color-chart-3); }
.ui-chart__legend {
position: absolute;
top: var(--spacing-4);