'use client'; import { useId } from 'react'; import { calculateNiceAxisMax, formatCompactNumber } from '@/lib/utils/metrics'; type TrendSeries = { label: string; color: string; values: number[]; fillColor?: string; variant?: 'line' | 'area'; valueFormatter?: (value: number) => string; }; function clamp(value: number, min: number, max: number) { return Math.min(Math.max(value, min), max); } function buildPath(points: Array<[number, number]>) { if (points.length === 0) { return ''; } return points .map(([x, y], index) => `${index === 0 ? 'M' : 'L'} ${x} ${y}`) .join(' '); } export function TrendChart({ labels, series, height = 240, yAxisValueFormatter = formatCompactNumber, }: { labels: string[]; series: TrendSeries[]; height?: number; yAxisValueFormatter?: (value: number) => string; }) { const chartId = useId(); const width = 720; const paddingTop = 18; const paddingRight = 18; const paddingBottom = 42; const paddingLeft = 54; const innerWidth = width - paddingLeft - paddingRight; const innerHeight = height - paddingTop - paddingBottom; const normalizedSeries = series.filter((entry) => entry.values.some((value) => Number.isFinite(value)), ); const pointCount = Math.max( labels.length, ...normalizedSeries.map((entry) => entry.values.length), 0, ); const allValues = normalizedSeries.flatMap((entry) => entry.values.filter((value) => Number.isFinite(value)), ); const axisMax = calculateNiceAxisMax(allValues); const yTicks = 4; if (normalizedSeries.length === 0 || pointCount === 0) { return (
暂无趋势数据
); } const xStep = pointCount > 1 ? innerWidth / (pointCount - 1) : innerWidth / 2; return (
{Array.from({ length: yTicks + 1 }, (_, index) => { const value = axisMax - (axisMax / yTicks) * index; const y = paddingTop + (innerHeight / yTicks) * index; return ( {yAxisValueFormatter(value)} ); })} {labels.map((label, index) => { const x = pointCount > 1 ? paddingLeft + xStep * index : paddingLeft + innerWidth / 2; return ( {label} ); })} {normalizedSeries.map((entry, seriesIndex) => { const points = entry.values.map((value, index) => { const x = pointCount > 1 ? paddingLeft + xStep * index : paddingLeft + innerWidth / 2; const safeValue = Number.isFinite(value) ? value : 0; const y = paddingTop + innerHeight - (clamp(safeValue, 0, axisMax) / axisMax) * innerHeight; return [x, y] as [number, number]; }); const linePath = buildPath(points); const areaPath = points.length > 0 ? `${linePath} L ${points[points.length - 1][0]} ${paddingTop + innerHeight} L ${points[0][0]} ${paddingTop + innerHeight} Z` : ''; const gradientId = `${chartId}-gradient-${seriesIndex}`; return ( {entry.variant === 'area' && entry.fillColor ? ( <> ) : null} {points.map(([x, y], pointIndex) => { const value = entry.values[pointIndex] ?? 0; const label = labels[pointIndex] ?? `点 ${pointIndex + 1}`; const formatter = entry.valueFormatter ?? yAxisValueFormatter; return ( {`${entry.label} · ${label} · ${formatter(value)}`} ); })} ); })}
{normalizedSeries.map((entry) => (
{entry.label}
))}
); }