learning_ai_common_plat/packages/data-viz/src/Sparkline.tsx
saravanakumardb1 acb8f02bca fix(data-viz): SSR-safe gradient id + NaN-safe ProgressRing
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
2026-05-27 14:46:27 -07:00

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>
);
}