vibe-proxy/frontend/src/features/dashboard/components/Sparkline.tsx
2026-08-24 00:10:41 +02:00

87 lines
2.6 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.

import { useId, useMemo } from 'react';
import { buildSmoothLinePath } from './curve';
import styles from './Sparkline.module.scss';
const VIEW_WIDTH = 100;
const VIEW_HEIGHT = 32;
/** 顶部留白,避免峰值贴边被裁切 */
const TOP_PADDING = 3;
interface SparklineProps {
points: number[];
/** 折线/填充色,默认取主色 */
color?: string;
ariaLabel: string;
className?: string;
}
/**
* 极简迷你折线2px 线 + 同色 10% 面积。
* 单序列,因此不需要图例;数值由所在卡片的文本承载。
*/
export function Sparkline({ points, color, ariaLabel, className }: SparklineProps) {
const gradientId = useId();
const geometry = useMemo(() => {
const values = points.filter((value) => Number.isFinite(value));
if (values.length === 0) {
return null;
}
const max = Math.max(...values);
const usableHeight = VIEW_HEIGHT - TOP_PADDING;
const stepX = values.length > 1 ? VIEW_WIDTH / (values.length - 1) : 0;
const coordinates = values.map((value, index) => {
const x = values.length > 1 ? index * stepX : VIEW_WIDTH / 2;
const ratio = max > 0 ? value / max : 0;
const y = VIEW_HEIGHT - ratio * usableHeight;
return { x, y };
});
const line = buildSmoothLinePath(coordinates, TOP_PADDING, VIEW_HEIGHT);
const first = coordinates[0];
const last = coordinates[coordinates.length - 1];
const area = `${line} L${last.x.toFixed(2)} ${VIEW_HEIGHT} L${first.x.toFixed(2)} ${VIEW_HEIGHT} Z`;
return { line, area, isFlat: max <= 0 };
}, [points]);
if (!geometry) {
return (
<div className={[styles.empty, className].filter(Boolean).join(' ')} aria-hidden="true" />
);
}
const strokeColor = geometry.isFlat
? 'var(--text-quaternary)'
: (color ?? 'var(--primary-color)');
return (
<svg
className={[styles.sparkline, className].filter(Boolean).join(' ')}
viewBox={`0 0 ${VIEW_WIDTH} ${VIEW_HEIGHT}`}
preserveAspectRatio="none"
role="img"
aria-label={ariaLabel}
>
<defs>
<linearGradient id={gradientId} x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor={strokeColor} stopOpacity="0.16" />
<stop offset="100%" stopColor={strokeColor} stopOpacity="0.01" />
</linearGradient>
</defs>
{!geometry.isFlat && <path d={geometry.area} fill={`url(#${gradientId})`} stroke="none" />}
<path
d={geometry.line}
fill="none"
stroke={strokeColor}
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
vectorEffect="non-scaling-stroke"
/>
</svg>
);
}