refactor(frontend): replace recharts with uPlot for charts

Swap the Sparkline chart component (the only chart in the panel) from recharts to uPlot, keeping its public prop API identical so the three consumers (SystemHistoryModal, XrayMetricsModal, NodeHistoryPanel) are untouched. This drops recharts and 32 transitive deps (es-toolkit, victory-vendor, d3, redux, immer), shrinking the chart vendor chunk to ~51KB (22KB gzip).

The uPlot port reimplements every recharts feature on canvas: gradient area fill, spline curves, up to three series, dashed horizontal grid, formatted axes, hover tooltip and marker, reference lines, and min/max extrema dots. Because canvas cannot read CSS variables, axis and ring colors are resolved via getComputedStyle and repainted on theme changes through a MutationObserver on the body class and documentElement data-theme.

Also removes the es-toolkit/compat resolver shim from vite.config.js, which existed only for recharts, and swaps the manualChunks entry to vendor-uplot.

Note: repaint with redraw(false); a bare uPlot redraw() re-runs _setScale and nulls the index-based x-scale, which collapsed the series to a flat, partial line.
This commit is contained in:
MHSanaei
2026-07-08 02:20:30 +02:00
parent bc309ed9f8
commit 8ee79cf447
6 changed files with 487 additions and 581 deletions
+55 -1
View File
@@ -1,13 +1,67 @@
.sparkline-svg {
.sparkline-plot {
display: block;
width: 100%;
}
.sparkline-plot .uplot,
.sparkline-plot .u-wrap {
width: 100%;
}
.sparkline-plot .u-cursor-x {
border-right: 1px dashed var(--ant-color-border);
}
.sparkline-container {
position: relative;
width: 100%;
}
.sparkline-tooltip {
position: absolute;
top: 0;
left: 0;
pointer-events: none;
white-space: nowrap;
z-index: 2;
padding: 6px 10px;
border: 1px solid var(--ant-color-border-secondary);
border-radius: 6px;
background: var(--ant-color-bg-elevated);
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.12);
color: var(--ant-color-text);
font-size: 12px;
}
.sparkline-tooltip .spk-tt-label {
margin-bottom: 4px;
color: var(--ant-color-text-tertiary);
font-size: 11px;
}
.sparkline-tooltip .spk-tt-row {
display: flex;
align-items: center;
gap: 6px;
font-weight: 500;
line-height: 16px;
}
.sparkline-tooltip .spk-tt-dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex: none;
}
.sparkline-tooltip .spk-tt-name {
color: var(--ant-color-text-tertiary);
}
.sparkline-tooltip .spk-tt-val {
margin-left: auto;
}
.sparkline-extrema {
position: absolute;
top: 2px;
+420 -196
View File
@@ -1,15 +1,6 @@
import { useId, useMemo } from 'react';
import {
Area,
AreaChart,
CartesianGrid,
ReferenceDot,
ReferenceLine,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from 'recharts';
import { useEffect, useMemo, useRef } from 'react';
import uPlot from 'uplot';
import 'uplot/dist/uPlot.min.css';
import './Sparkline.css';
export interface SparklineReferenceLine {
@@ -26,8 +17,14 @@ export interface SparklineExtrema {
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[];
@@ -68,41 +65,79 @@ interface ChartPoint {
label: string;
}
export default function Sparkline({
data,
data2 = [],
data3 = [],
stroke2 = '#722ed1',
stroke3 = '#a0d911',
name1,
name2,
name3,
labels = [],
height = 80,
stroke = '#008771',
strokeWidth = 2,
maxPoints = 120,
showGrid = true,
fillOpacity = 0.22,
showMarker = true,
markerRadius = 3,
showAxes = false,
yTickStep = 25,
tickCountX = 4,
showTooltip = false,
valueMin = 0,
valueMax = 100,
yFormatter = (v: number) => `${Math.round(v)}%`,
tooltipFormatter = null,
tooltipLabelFormatter = null,
referenceLines,
extrema,
}: SparklineProps) {
const reactId = useId();
const safeId = reactId.replace(/[^a-zA-Z0-9]/g, '');
const gradId = `spkGrad-${safeId}`;
const gradId2 = `spkGrad2-${safeId}`;
const gradId3 = `spkGrad3-${safeId}`;
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(hex: string, alpha: number): string {
let h = hex.trim();
if (h.startsWith('#')) h = h.slice(1);
if (h.length === 3) h = h.split('').map((c) => c + c).join('');
if (h.length !== 6) return hex;
const int = Number.parseInt(h, 16);
if (Number.isNaN(int)) return hex;
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,
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;
@@ -135,7 +170,7 @@ export default function Sparkline({
return [valueMin, max * 1.1];
}, [points, valueMin, valueMax, hasSeries2, hasSeries3]);
const yTicks = useMemo(() => {
const yTicks = useMemo<number[] | undefined>(() => {
if (!showAxes) return undefined;
const [min, max] = yDomain;
if (valueMax === 100 && valueMin === 0 && yTickStep > 0) {
@@ -147,15 +182,13 @@ export default function Sparkline({
return Array.from({ length: n }, (_, i) => min + ((max - min) * i) / (n - 1));
}, [showAxes, yDomain, valueMin, valueMax, yTickStep]);
const xTickIndexes = useMemo(() => {
const xTickIndexes = useMemo<number[] | undefined>(() => {
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 fmtTooltip = tooltipFormatter ?? yFormatter;
const extremaPoints = useMemo(() => {
const extremaPoints = useMemo<ExtremaResult | null>(() => {
if (!extrema?.show || multiSeries || points.length < 2) return null;
let minIdx = 0;
let maxIdx = 0;
@@ -191,6 +224,340 @@ export default function Sparkline({
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);
cfgRef.current = cfg;
const viewRef = useRef<SparklineView>({ points, yDomain, yTicks, xTickIndexes, extremaPoints });
viewRef.current = { points, yDomain, yTicks, xTickIndexes, extremaPoints };
const containerRef = useRef<HTMLDivElement>(null);
const plotRef = useRef<uPlot | null>(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(() => {
plotRef.current?.redraw(false);
});
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 (
<div className="sparkline-container" role={ariaSummary ? 'img' : undefined} aria-label={ariaSummary || undefined}>
{extremaPoints && (
@@ -210,150 +577,7 @@ export default function Sparkline({
))}
</div>
)}
<ResponsiveContainer width="100%" height={height} className="sparkline-svg">
<AreaChart
data={points}
margin={{
top: showAxes ? 14 : 6,
right: showAxes ? 12 : 6,
bottom: showAxes ? 26 : 4,
left: 4,
}}
>
<defs>
<linearGradient id={gradId} x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor={stroke} stopOpacity={fillOpacity} />
<stop offset="100%" stopColor={stroke} stopOpacity={0} />
</linearGradient>
<linearGradient id={gradId2} x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor={stroke2} stopOpacity={fillOpacity} />
<stop offset="100%" stopColor={stroke2} stopOpacity={0} />
</linearGradient>
<linearGradient id={gradId3} x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor={stroke3} stopOpacity={fillOpacity} />
<stop offset="100%" stopColor={stroke3} stopOpacity={0} />
</linearGradient>
</defs>
{showGrid && (
<CartesianGrid stroke="rgba(128, 128, 140, 0.35)" strokeDasharray="3 4" vertical={false} />
)}
<XAxis
dataKey="label"
hide={!showAxes}
tick={{ fontSize: 10, fill: 'var(--ant-color-text-tertiary)' }}
axisLine={false}
tickLine={false}
tickMargin={14}
interval={0}
ticks={xTickIndexes?.map((i) => points[i]?.label).filter(Boolean) as string[] | undefined}
/>
<YAxis
domain={yDomain}
hide={!showAxes}
tick={{ fontSize: 10, fill: 'var(--ant-color-text-tertiary)', dx: -4 }}
axisLine={false}
tickLine={false}
tickMargin={8}
tickFormatter={yFormatter}
ticks={yTicks}
width={56}
/>
{showTooltip && (
<Tooltip
cursor={{ stroke: 'var(--ant-color-border)', strokeDasharray: '2 4' }}
contentStyle={{
background: 'var(--ant-color-bg-elevated)',
border: '1px solid var(--ant-color-border-secondary)',
borderRadius: 6,
fontSize: 12,
padding: '6px 10px',
boxShadow: '0 4px 14px rgba(0, 0, 0, 0.12)',
}}
labelStyle={{ color: 'var(--ant-color-text-tertiary)', marginBottom: 4, fontSize: 11 }}
itemStyle={{ color: 'var(--ant-color-text)', padding: 0, fontWeight: 500 }}
formatter={(v, name) => [fmtTooltip(Number(v) || 0), multiSeries && typeof name === 'string' ? name : '']}
labelFormatter={(label) => (tooltipLabelFormatter ? tooltipLabelFormatter(String(label)) : String(label))}
separator={multiSeries ? ': ' : ''}
/>
)}
{referenceLines?.map((rl, idx) => (
<ReferenceLine
key={`ref-${idx}-${rl.y}`}
y={rl.y}
stroke={rl.color || stroke}
strokeDasharray={rl.dash || '5 4'}
strokeWidth={1.4}
label={rl.label ? {
value: rl.label,
position: 'insideTopRight',
fill: rl.color || stroke,
fontSize: 10,
fontWeight: 600,
} : undefined}
ifOverflow="extendDomain"
/>
))}
{extremaPoints && (
<>
<ReferenceDot
x={extremaPoints.max.label}
y={extremaPoints.max.value}
r={4.5}
fill={maxColor}
stroke="var(--ant-color-bg-elevated)"
strokeWidth={2}
ifOverflow="extendDomain"
/>
<ReferenceDot
x={extremaPoints.min.label}
y={extremaPoints.min.value}
r={4.5}
fill={minColor}
stroke="var(--ant-color-bg-elevated)"
strokeWidth={2}
ifOverflow="extendDomain"
/>
</>
)}
<Area
type="monotone"
dataKey="value"
name={multiSeries ? name1 : undefined}
stroke={stroke}
strokeWidth={strokeWidth}
fill={`url(#${gradId})`}
dot={false}
activeDot={showMarker ? { r: markerRadius, fill: stroke, strokeWidth: 0 } : false}
isAnimationActive={false}
/>
{hasSeries2 && (
<Area
type="monotone"
dataKey="value2"
name={name2}
stroke={stroke2}
strokeWidth={strokeWidth}
fill={`url(#${gradId2})`}
dot={false}
activeDot={showMarker ? { r: markerRadius, fill: stroke2, strokeWidth: 0 } : false}
isAnimationActive={false}
/>
)}
{hasSeries3 && (
<Area
type="monotone"
dataKey="value3"
name={name3}
stroke={stroke3}
strokeWidth={strokeWidth}
fill={`url(#${gradId3})`}
dot={false}
activeDot={showMarker ? { r: markerRadius, fill: stroke3, strokeWidth: 0 } : false}
isAnimationActive={false}
/>
)}
</AreaChart>
</ResponsiveContainer>
<div ref={containerRef} className="sparkline-plot" style={{ height }} />
</div>
);
}