Two bugs in @bytelyst/data-viz@0.1.0 surfaced during a cross-repo audit:
1. Sparkline used `useMemo(() => \`bl-spark-${Math.random()...}\`)`
for its <linearGradient> id. Math.random() produces different values
during Next.js SSR vs client hydration, triggering React hydration
mismatches (and broken gradient refs on first paint). Swap for
React's `useId()` — deterministic across server + client.
Also: with a single-element series, `data.length > 0` was true but
the early-return branch left `lastX=lastY=0`, painting a stale dot
at the SVG origin. Tighten the guard to `data.length >= 2`.
2. ProgressRing's `Math.max(0, Math.min(1, value))` propagated NaN
when callers passed `NaN` or `Infinity` (e.g. division-by-zero
metrics) — producing "NaN percent" in the aria-label. Guard with
`Number.isFinite` first.
Regression tests cover all three cases — 17/17 passing.
Tests: pnpm -F @bytelyst/data-viz test → 17 passed
113 lines
3.2 KiB
TypeScript
113 lines
3.2 KiB
TypeScript
import { useId, useMemo, type CSSProperties } from 'react';
|
|
|
|
export interface SparklineProps {
|
|
/** Series values. Length 2+. */
|
|
data: number[];
|
|
/** Width in px. Default 120. */
|
|
width?: number;
|
|
/** Height in px. Default 36. */
|
|
height?: number;
|
|
/** Stroke color. Default `var(--bl-accent)`. */
|
|
stroke?: string;
|
|
/** Stroke width. Default 1.75. */
|
|
strokeWidth?: number;
|
|
/** Fill the area under the line. Default true. */
|
|
fill?: boolean;
|
|
/** Highlight the final point. Default true. */
|
|
showLastPoint?: boolean;
|
|
/** Aria label. */
|
|
ariaLabel?: string;
|
|
className?: string;
|
|
style?: CSSProperties;
|
|
}
|
|
|
|
/**
|
|
* `<Sparkline>` — tiny inline trend line. Pure SVG, no runtime layout
|
|
* cost. Auto-scales the Y axis to the data range; clamps tiny ranges
|
|
* so flat lines still render visibly.
|
|
*/
|
|
export function Sparkline({
|
|
data,
|
|
width = 120,
|
|
height = 36,
|
|
stroke = 'var(--bl-accent, #6366f1)',
|
|
strokeWidth = 1.75,
|
|
fill = true,
|
|
showLastPoint = true,
|
|
ariaLabel,
|
|
className,
|
|
style,
|
|
}: SparklineProps) {
|
|
const { path, areaPath, lastX, lastY } = useMemo(() => {
|
|
if (data.length < 2) {
|
|
return { path: '', areaPath: '', lastX: 0, lastY: 0 };
|
|
}
|
|
const min = Math.min(...data);
|
|
const max = Math.max(...data);
|
|
const range = max - min || 1;
|
|
const pad = strokeWidth + 1;
|
|
const innerW = width - pad * 2;
|
|
const innerH = height - pad * 2;
|
|
const stepX = innerW / (data.length - 1);
|
|
const pts = data.map((v, i) => {
|
|
const x = pad + i * stepX;
|
|
const y = pad + innerH - ((v - min) / range) * innerH;
|
|
return [x, y] as const;
|
|
});
|
|
const path = pts.map(([x, y], i) => (i === 0 ? `M${x},${y}` : `L${x},${y}`)).join(' ');
|
|
const areaPath =
|
|
pts.length > 1
|
|
? `${path} L${pts[pts.length - 1]![0]},${height} L${pts[0]![0]},${height} Z`
|
|
: '';
|
|
const [lx, ly] = pts[pts.length - 1]!;
|
|
return { path, areaPath, lastX: lx, lastY: ly };
|
|
}, [data, width, height, strokeWidth]);
|
|
|
|
// useId() is SSR-stable; using Math.random() here would mismatch on hydration.
|
|
const reactId = useId();
|
|
const gradId = `bl-spark-${reactId.replace(/[^a-zA-Z0-9_-]/g, '')}`;
|
|
|
|
return (
|
|
<svg
|
|
data-testid="bl-sparkline"
|
|
role="img"
|
|
aria-label={ariaLabel ?? 'Sparkline'}
|
|
width={width}
|
|
height={height}
|
|
viewBox={`0 0 ${width} ${height}`}
|
|
className={className}
|
|
style={{ overflow: 'visible', ...style }}
|
|
>
|
|
{fill && (
|
|
<>
|
|
<defs>
|
|
<linearGradient id={gradId} x1="0" y1="0" x2="0" y2="1">
|
|
<stop offset="0%" stopColor={stroke} stopOpacity={0.32} />
|
|
<stop offset="100%" stopColor={stroke} stopOpacity={0} />
|
|
</linearGradient>
|
|
</defs>
|
|
<path d={areaPath} fill={`url(#${gradId})`} stroke="none" />
|
|
</>
|
|
)}
|
|
<path
|
|
d={path}
|
|
fill="none"
|
|
stroke={stroke}
|
|
strokeWidth={strokeWidth}
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
/>
|
|
{showLastPoint && data.length >= 2 && (
|
|
<circle
|
|
cx={lastX}
|
|
cy={lastY}
|
|
r={strokeWidth + 1.25}
|
|
fill={stroke}
|
|
stroke="var(--bl-surface-card, #fff)"
|
|
strokeWidth={1.5}
|
|
/>
|
|
)}
|
|
</svg>
|
|
);
|
|
}
|