260707_2
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -439,8 +439,8 @@ export const ui_locales = {
|
||||
B01_Dashboard_Reject: ["거절", "Reject"],
|
||||
B01_Dashboard_Resources: ["리소스 현황", "Resources"],
|
||||
B01_Dashboard_Resources_ChartCaption: [
|
||||
"최근 30일 리소스 사용률 (2분 간격)",
|
||||
"Resource usage over the last 30 days (2-min interval)",
|
||||
"최근 7일 리소스 사용률 (2분 간격)",
|
||||
"Resource usage over the last 7 days (2-min interval)",
|
||||
],
|
||||
B01_Dashboard_Resources_ChartAria: [
|
||||
"CPU, 메모리, 디스크 사용률 시계열 그래프",
|
||||
|
||||
Reference in New Issue
Block a user