import { useEffect, useMemo, useRef } from 'react'; import uPlot from 'uplot'; import 'uplot/dist/uPlot.min.css'; import './Sparkline.css'; export interface SparklineReferenceLine { y: number; label?: string; color?: string; dash?: string; } export interface SparklineExtrema { show?: boolean; formatter?: (v: number) => string; minColor?: string; maxColor?: string; } const DEFAULT_STROKE = '#008771'; const DEFAULT_STROKE2 = '#722ed1'; const DEFAULT_STROKE3 = '#a0d911'; const DEFAULT_MIN_COLOR = '#52c41a'; const DEFAULT_MAX_COLOR = '#fa541c'; const GRID_COLOR = 'rgba(128, 128, 140, 0.35)'; const AXIS_FONT = '10px system-ui, -apple-system, "Segoe UI", Roboto, sans-serif'; const LABEL_FONT = 'system-ui, -apple-system, "Segoe UI", Roboto, sans-serif'; interface SparklineProps { data: number[]; data2?: number[]; data3?: number[]; stroke2?: string; stroke3?: string; name1?: string; name2?: string; name3?: string; labels?: (string | number)[]; height?: number; stroke?: string; strokeWidth?: number; maxPoints?: number; showGrid?: boolean; fillOpacity?: number; showMarker?: boolean; markerRadius?: number; showAxes?: boolean; yTickStep?: number; tickCountX?: number; showTooltip?: boolean; showLegend?: boolean; valueMin?: number; valueMax?: number | null; yFormatter?: (v: number) => string; tooltipFormatter?: ((v: number) => string) | null; tooltipLabelFormatter?: ((label: string) => string) | null; referenceLines?: SparklineReferenceLine[]; extrema?: SparklineExtrema; } interface ChartPoint { index: number; value: number; value2: number; value3: number; label: string; } interface ExtremaResult { min: ChartPoint; max: ChartPoint; minIdx: number; maxIdx: number; } interface SparklineView { points: ChartPoint[]; yDomain: [number, number]; yTicks: number[] | undefined; xTickIndexes: number[] | undefined; extremaPoints: ExtremaResult | null; } function hexToRgba(color: string, alpha: number): string { const trimmed = color.trim(); const fn = trimmed.match(/^rgba?\(([^)]+)\)$/i); if (fn) { const parts = fn[1] .split(/[,/]\s*|\s+/) .filter(Boolean) .map(Number); if (parts.length >= 3 && parts.slice(0, 3).every((n) => Number.isFinite(n))) { const baseAlpha = parts.length > 3 && Number.isFinite(parts[3]) ? parts[3] : 1; return `rgba(${parts[0]}, ${parts[1]}, ${parts[2]}, ${baseAlpha * alpha})`; } return trimmed; } let h = trimmed; if (h.startsWith('#')) h = h.slice(1); if (h.length === 3) h = h .split('') .map((c) => c + c) .join(''); if (h.length !== 6) return trimmed; const int = Number.parseInt(h, 16); if (Number.isNaN(int)) return trimmed; const r = (int >> 16) & 255; const g = (int >> 8) & 255; const b = int & 255; return `rgba(${r}, ${g}, ${b}, ${alpha})`; } function cssVar(el: HTMLElement, name: string, fallback: string): string { const v = getComputedStyle(el).getPropertyValue(name).trim(); return v || fallback; } function parseDash(dash: string, dpr: number): number[] { return dash .trim() .split(/\s+/) .map((n) => (Number(n) || 0) * dpr); } function dprOf(u: uPlot): number { return u.width > 0 ? u.ctx.canvas.width / u.width : uPlot.pxRatio || 1; } export default function Sparkline(props: SparklineProps) { const { data, data2 = [], data3 = [], stroke = DEFAULT_STROKE, stroke2 = DEFAULT_STROKE2, stroke3 = DEFAULT_STROKE3, name1, name2, name3, labels = [], height = 80, strokeWidth = 2, maxPoints = 120, showGrid = true, fillOpacity = 0.22, showMarker = true, markerRadius = 3, showAxes = false, yTickStep = 25, tickCountX = 4, showTooltip = false, showLegend = true, valueMin = 0, valueMax = 100, yFormatter = (v: number) => `${Math.round(v)}%`, tooltipFormatter = null, tooltipLabelFormatter = null, referenceLines, extrema, } = props; const hasSeries2 = data2.length > 0; const hasSeries3 = data3.length > 0; const multiSeries = hasSeries2 || hasSeries3; const points = useMemo(() => { const n = Math.min(data.length, maxPoints); if (n === 0) return []; const sliceStart = data.length - n; const labelStart = Math.max(0, labels.length - n); const slice2Start = data2.length - n; const slice3Start = data3.length - n; return data.slice(sliceStart).map((value, i) => ({ index: i, value: Number(value) || 0, value2: data2.length ? Number(data2[slice2Start + i]) || 0 : 0, value3: data3.length ? Number(data3[slice3Start + i]) || 0 : 0, label: String(labels[labelStart + i] ?? i + 1), })); }, [data, data2, data3, labels, maxPoints]); const yDomain = useMemo<[number, number]>(() => { if (valueMax != null) return [valueMin, valueMax]; let max = valueMin; for (const p of points) { if (Number.isFinite(p.value) && p.value > max) max = p.value; if (hasSeries2 && Number.isFinite(p.value2) && p.value2 > max) max = p.value2; if (hasSeries3 && Number.isFinite(p.value3) && p.value3 > max) max = p.value3; } if (max <= valueMin) max = valueMin + 1; return [valueMin, max * 1.1]; }, [points, valueMin, valueMax, hasSeries2, hasSeries3]); const yTicks = useMemo(() => { if (!showAxes) return undefined; const [min, max] = yDomain; if (valueMax === 100 && valueMin === 0 && yTickStep > 0) { const out: number[] = []; for (let v = min; v <= max; v += yTickStep) out.push(v); return out; } const n = 5; return Array.from({ length: n }, (_, i) => min + ((max - min) * i) / (n - 1)); }, [showAxes, yDomain, valueMin, valueMax, yTickStep]); const xTickIndexes = useMemo(() => { if (!showAxes || points.length === 0) return undefined; const m = Math.max(2, tickCountX); return Array.from({ length: m }, (_, i) => Math.round((i * (points.length - 1)) / (m - 1))); }, [showAxes, tickCountX, points.length]); const extremaPoints = useMemo(() => { if (!extrema?.show || multiSeries || points.length < 2) return null; let minIdx = 0; let maxIdx = 0; for (let i = 1; i < points.length; i++) { if (points[i].value < points[minIdx].value) minIdx = i; if (points[i].value > points[maxIdx].value) maxIdx = i; } if (minIdx === maxIdx) return null; return { min: points[minIdx], max: points[maxIdx], minIdx, maxIdx }; }, [points, extrema?.show, multiSeries]); const legendItems = useMemo( () => [ { name: name1, color: stroke }, { name: name2, color: stroke2 }, { name: name3, color: stroke3 }, ].filter((s, i) => s.name && (i === 0 ? multiSeries : i === 1 ? hasSeries2 : hasSeries3)), [name1, name2, name3, stroke, stroke2, stroke3, multiSeries, hasSeries2, hasSeries3], ); const fmtExtrema = extrema?.formatter ?? yFormatter; const minColor = extrema?.minColor ?? DEFAULT_MIN_COLOR; const maxColor = extrema?.maxColor ?? DEFAULT_MAX_COLOR; const ariaSummary = useMemo(() => { if (points.length === 0) return name1 ?? ''; const last = points[points.length - 1]; const parts: string[] = []; parts.push(name1 ? `${name1}: ${yFormatter(last.value)}` : yFormatter(last.value)); if (hasSeries2 && name2) parts.push(`${name2}: ${yFormatter(last.value2)}`); if (hasSeries3 && name3) parts.push(`${name3}: ${yFormatter(last.value3)}`); return parts.join(', '); }, [points, name1, name2, name3, hasSeries2, hasSeries3, yFormatter]); const cfg = { stroke, stroke2, stroke3, strokeWidth, fillOpacity, markerRadius, showGrid, showMarker, showAxes, showTooltip, height, name1, name2, name3, yFormatter, tooltipFormatter, tooltipLabelFormatter, referenceLines, extrema, }; const cfgRef = useRef(cfg); const viewRef = useRef({ points, yDomain, yTicks, xTickIndexes, extremaPoints }); useEffect(() => { cfgRef.current = cfg; viewRef.current = { points, yDomain, yTicks, xTickIndexes, extremaPoints }; }); const containerRef = useRef(null); const plotRef = useRef(null); useEffect(() => { const container = containerRef.current; if (!container) return; let tooltipEl: HTMLDivElement | null = null; const seriesColor = (idx: number): string => { const p = cfgRef.current; if (idx <= 1) return p.stroke ?? DEFAULT_STROKE; if (idx === 2) return p.stroke2 ?? DEFAULT_STROKE2; return p.stroke3 ?? DEFAULT_STROKE3; }; const gridTicks = (): number[] => { const yt = viewRef.current.yTicks; if (yt && yt.length) return yt; const [mn, mx] = viewRef.current.yDomain; const n = 4; return Array.from({ length: n + 1 }, (_, i) => mn + ((mx - mn) * i) / n); }; const buildData = (): uPlot.AlignedData => { const v = viewRef.current; const xs = v.points.map((_, i) => i); const series: number[][] = [v.points.map((p) => p.value)]; if (hasSeries2) series.push(v.points.map((p) => p.value2)); if (hasSeries3) series.push(v.points.map((p) => p.value3)); return [xs, ...series]; }; const makeSeries = (): uPlot.Series => ({ stroke: (_u, sidx) => seriesColor(sidx), width: cfgRef.current.strokeWidth ?? 2, fill: (u, sidx) => { const { ctx, bbox } = u; const color = seriesColor(sidx); const grad = ctx.createLinearGradient(0, bbox.top, 0, bbox.top + bbox.height); grad.addColorStop(0, hexToRgba(color, cfgRef.current.fillOpacity ?? 0.22)); grad.addColorStop(1, hexToRgba(color, 0)); return grad; }, paths: uPlot.paths.spline?.(), points: { show: false }, spanGaps: true, }); const series: uPlot.Series[] = [{}, makeSeries()]; if (hasSeries2) series.push(makeSeries()); if (hasSeries3) series.push(makeSeries()); const axisStroke = (u: uPlot) => cssVar(u.root, '--ant-color-text-tertiary', '#8c8c8c'); const axes: uPlot.Axis[] = [ { show: showAxes, stroke: axisStroke, grid: { show: false }, ticks: { show: false }, font: AXIS_FONT, gap: 6, size: 28, splits: () => viewRef.current.xTickIndexes ?? [], values: (_u, splits) => splits.map((i) => viewRef.current.points[i]?.label ?? ''), }, { show: showAxes, scale: 'y', side: 3, stroke: axisStroke, grid: { show: false }, ticks: { show: false }, font: AXIS_FONT, gap: 4, size: 56, splits: () => viewRef.current.yTicks ?? [], values: (_u, splits) => splits.map((v) => (cfgRef.current.yFormatter ? cfgRef.current.yFormatter(v) : String(v))), }, ]; const drawGrid = (u: uPlot) => { if (cfgRef.current.showGrid === false) return; const { ctx, bbox } = u; const dpr = dprOf(u); ctx.save(); ctx.strokeStyle = GRID_COLOR; ctx.lineWidth = dpr; ctx.setLineDash([3 * dpr, 4 * dpr]); ctx.beginPath(); for (const ty of gridTicks()) { const py = Math.round(u.valToPos(ty, 'y', true)) + 0.5; ctx.moveTo(bbox.left, py); ctx.lineTo(bbox.left + bbox.width, py); } ctx.stroke(); ctx.restore(); }; const drawOverlay = (u: uPlot) => { const p = cfgRef.current; const v = viewRef.current; const { ctx, bbox } = u; const dpr = dprOf(u); const right = bbox.left + bbox.width; if (p.referenceLines?.length) { for (const rl of p.referenceLines) { const color = rl.color || p.stroke || DEFAULT_STROKE; const py = Math.round(u.valToPos(rl.y, 'y', true)) + 0.5; ctx.save(); ctx.strokeStyle = color; ctx.lineWidth = 1.4 * dpr; ctx.setLineDash(parseDash(rl.dash ?? '5 4', dpr)); ctx.beginPath(); ctx.moveTo(bbox.left, py); ctx.lineTo(right, py); ctx.stroke(); ctx.restore(); if (rl.label) { ctx.save(); ctx.fillStyle = color; ctx.font = `600 ${10 * dpr}px ${LABEL_FONT}`; ctx.textAlign = 'right'; ctx.textBaseline = 'bottom'; ctx.fillText(rl.label, right - 4 * dpr, py - 3 * dpr); ctx.restore(); } } } const ex = v.extremaPoints; if (p.extrema?.show && ex) { const ringColor = cssVar(u.root, '--ant-color-bg-elevated', '#ffffff'); const dot = (value: number, idx: number, color: string) => { const px = u.valToPos(idx, 'x', true); const py = u.valToPos(value, 'y', true); ctx.save(); ctx.beginPath(); ctx.arc(px, py, 4.5 * dpr, 0, Math.PI * 2); ctx.fillStyle = color; ctx.fill(); ctx.lineWidth = 2 * dpr; ctx.strokeStyle = ringColor; ctx.stroke(); ctx.restore(); }; dot(ex.max.value, ex.maxIdx, p.extrema.maxColor ?? DEFAULT_MAX_COLOR); dot(ex.min.value, ex.minIdx, p.extrema.minColor ?? DEFAULT_MIN_COLOR); } }; const updateTooltip = (u: uPlot) => { if (!tooltipEl) return; const idx = u.cursor.idx; const v = viewRef.current; const p = cfgRef.current; if (idx == null || idx < 0 || idx >= v.points.length) { tooltipEl.style.display = 'none'; return; } const pt = v.points[idx]; const fmt = p.tooltipFormatter ?? p.yFormatter ?? ((x: number) => String(x)); const label = p.tooltipLabelFormatter ? p.tooltipLabelFormatter(String(pt.label)) : String(pt.label); const multi = hasSeries2 || hasSeries3; tooltipEl.textContent = ''; const labelDiv = document.createElement('div'); labelDiv.className = 'spk-tt-label'; labelDiv.textContent = label; tooltipEl.appendChild(labelDiv); const rows = [ { name: p.name1, color: p.stroke ?? DEFAULT_STROKE, val: pt.value, on: true }, { name: p.name2, color: p.stroke2 ?? DEFAULT_STROKE2, val: pt.value2, on: hasSeries2 }, { name: p.name3, color: p.stroke3 ?? DEFAULT_STROKE3, val: pt.value3, on: hasSeries3 }, ]; for (const r of rows) { if (!r.on) continue; const row = document.createElement('div'); row.className = 'spk-tt-row'; if (multi) { const marker = document.createElement('span'); marker.className = 'spk-tt-dot'; marker.style.background = r.color; row.appendChild(marker); const nm = document.createElement('span'); nm.className = 'spk-tt-name'; nm.textContent = r.name ?? ''; row.appendChild(nm); } const val = document.createElement('span'); val.className = 'spk-tt-val'; val.textContent = fmt(r.val); row.appendChild(val); tooltipEl.appendChild(row); } tooltipEl.style.display = ''; const overW = u.over.clientWidth; const overH = u.over.clientHeight; const cx = u.cursor.left ?? 0; const cy = u.cursor.top ?? 0; const w = tooltipEl.offsetWidth; const h = tooltipEl.offsetHeight; let x = cx + 12; if (x + w + 8 > overW) x = cx - w - 12; if (x < 0) x = 4; let y = cy - h - 12; if (y < 0) y = Math.min(cy + 12, overH - h - 4); tooltipEl.style.transform = `translate(${Math.round(x)}px, ${Math.round(y)}px)`; }; const opts: uPlot.Options = { width: container.clientWidth || 600, height, padding: [8, 8, showAxes ? 0 : 2, showAxes ? 0 : 2], legend: { show: false }, cursor: { show: showTooltip, x: showTooltip, y: false, drag: { x: false, y: false, setScale: false }, points: { show: showMarker, size: () => (cfgRef.current.markerRadius ?? 3) * 2, width: 0, stroke: (_u, sidx) => seriesColor(sidx), fill: (_u, sidx) => seriesColor(sidx), }, }, scales: { x: { time: false, range: (_u, dmin, dmax) => (dmin === dmax ? [dmin - 0.5, dmax + 0.5] : [dmin, dmax]), }, y: { range: () => { const [mn, mx] = viewRef.current.yDomain; return [mn, mx]; }, }, }, series, axes, hooks: { init: [ (u) => { if (!cfgRef.current.showTooltip) return; tooltipEl = document.createElement('div'); tooltipEl.className = 'sparkline-tooltip'; tooltipEl.style.display = 'none'; u.over.appendChild(tooltipEl); }, ], drawClear: [drawGrid], draw: [drawOverlay], setCursor: [updateTooltip], }, }; const u = new uPlot(opts, buildData(), container); plotRef.current = u; const ro = new ResizeObserver(() => { const w = container.clientWidth; if (w > 0) u.setSize({ width: w, height }); }); ro.observe(container); return () => { ro.disconnect(); u.destroy(); plotRef.current = null; tooltipEl = null; }; }, [hasSeries2, hasSeries3, showAxes, showTooltip, showMarker, height]); useEffect(() => { plotRef.current?.setData( (() => { const xs = points.map((_, i) => i); const s: number[][] = [points.map((p) => p.value)]; if (hasSeries2) s.push(points.map((p) => p.value2)); if (hasSeries3) s.push(points.map((p) => p.value3)); return [xs, ...s] as uPlot.AlignedData; })(), ); }, [points, hasSeries2, hasSeries3, valueMin, valueMax]); useEffect(() => { const redraw = () => plotRef.current?.redraw(false); const moBody = new MutationObserver(redraw); moBody.observe(document.body, { attributes: true, attributeFilter: ['class'] }); const moRoot = new MutationObserver(redraw); moRoot.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] }); return () => { moBody.disconnect(); moRoot.disconnect(); }; }, []); return (
{extremaPoints && ( )} {showLegend && legendItems.length > 0 && ( )}
); }