Audit pass found two latent issues:
1. **NaN/Infinity broke the SVG path.** `LineChart` mapped raw values
through `yScale()` without sanitising, so any non-finite input
emitted a literal 'NaN' in the path `d` attribute and silently
broke the visible stroke for the whole series. Same shape in
`AreaChart`.
2. **`compactNumber()` was duplicated.** Three identical copies
lived in LineChart / BarChart / AreaChart.
Fixes (all in `utils.ts`):
+ `compactNumber(v)` now exported (returns '' for non-finite)
+ `filterFinite(values)` returns `[index, value]` pairs,
keeping the original X-axis spacing for the surviving points
Behavioural changes:
- LineChart `series` containing NaN/Infinity → path skips those
points cleanly. Series of *entirely* non-finite values render
nothing (was: a fully NaN-poisoned path).
- AreaChart `values` containing NaN/Infinity → same.
- BarChart unchanged (was already safe via `extent`).
Tests: 19 \u2192 23 (4 new regression cases)
utils \u00b7 compactNumber k-suffix + non-finite handling
utils \u00b7 filterFinite preserves original indices
LineChart \u00b7 NaN/Infinity never appear in path `d`
LineChart \u00b7 all-non-finite series renders zero <g>
158 lines
4.5 KiB
TypeScript
158 lines
4.5 KiB
TypeScript
import { useId, type CSSProperties } from 'react';
|
|
import { compactNumber, extent, filterFinite, linearScale, smoothPath } from './utils.js';
|
|
|
|
export interface LineSeries {
|
|
/** Stable id — React key + accessible series label. */
|
|
id: string;
|
|
/** Y-values (X is the array index, evenly spaced). */
|
|
values: number[];
|
|
/** Optional override colour. Defaults walk the token palette. */
|
|
color?: string;
|
|
}
|
|
|
|
export interface LineChartProps {
|
|
/** One or more series, plotted in z-order. */
|
|
series: LineSeries[];
|
|
/** Chart width in px. Default 480. */
|
|
width?: number;
|
|
/** Chart height in px. Default 240. */
|
|
height?: number;
|
|
/** Smooth (catmull-rom) vs straight polyline. Default smooth. */
|
|
smooth?: boolean;
|
|
/** Show subtle Y grid lines (3 ticks). Default true. */
|
|
grid?: boolean;
|
|
/** Accessible label for the SVG. */
|
|
ariaLabel?: string;
|
|
className?: string;
|
|
style?: CSSProperties;
|
|
}
|
|
|
|
const TOKEN_PALETTE = [
|
|
'var(--bl-accent, #6366f1)',
|
|
'var(--bl-success, #10b981)',
|
|
'var(--bl-warning, #f59e0b)',
|
|
'var(--bl-info, #0ea5e9)',
|
|
'var(--bl-danger, #ef4444)',
|
|
];
|
|
|
|
/**
|
|
* `<LineChart>` — multi-series SVG line chart with optional smoothing.
|
|
*
|
|
* Pure-React, dependency-free. The series share a common Y extent;
|
|
* legend / tooltip overlays are intentionally out-of-scope — wrap a
|
|
* `<LineChart>` in a host that handles those if needed.
|
|
*
|
|
* Wave 9.A.1.
|
|
*/
|
|
export function LineChart({
|
|
series,
|
|
width = 480,
|
|
height = 240,
|
|
smooth = true,
|
|
grid = true,
|
|
ariaLabel,
|
|
className,
|
|
style,
|
|
}: LineChartProps) {
|
|
const titleId = useId();
|
|
const padL = 36;
|
|
const padR = 12;
|
|
const padT = 12;
|
|
const padB = 24;
|
|
const innerW = Math.max(0, width - padL - padR);
|
|
const innerH = Math.max(0, height - padT - padB);
|
|
|
|
const allValues = series.flatMap((s) => s.values);
|
|
const [yMin, yMax] = extent(allValues);
|
|
// Find the longest series for the X domain.
|
|
const maxLen = Math.max(1, ...series.map((s) => s.values.length));
|
|
|
|
const yScale = linearScale(yMin, yMax, innerH, 0);
|
|
const xScale = (i: number) =>
|
|
maxLen > 1 ? (i / (maxLen - 1)) * innerW : innerW / 2;
|
|
|
|
const yTicks = grid ? niceTicks(yMin, yMax, 3) : [];
|
|
|
|
return (
|
|
<svg
|
|
role="img"
|
|
aria-labelledby={titleId}
|
|
viewBox={`0 0 ${width} ${height}`}
|
|
width={width}
|
|
height={height}
|
|
data-testid="bl-line-chart"
|
|
className={className}
|
|
style={style}
|
|
>
|
|
<title id={titleId}>{ariaLabel ?? 'Line chart'}</title>
|
|
{/* Grid */}
|
|
<g transform={`translate(${padL} ${padT})`}>
|
|
{yTicks.map((t, i) => (
|
|
<g key={`t${i}`}>
|
|
<line
|
|
x1={0}
|
|
x2={innerW}
|
|
y1={yScale(t)}
|
|
y2={yScale(t)}
|
|
stroke="var(--bl-border-subtle, rgba(0,0,0,0.06))"
|
|
strokeWidth={1}
|
|
/>
|
|
<text
|
|
x={-6}
|
|
y={yScale(t)}
|
|
textAnchor="end"
|
|
dominantBaseline="middle"
|
|
fontSize={10}
|
|
fill="var(--bl-text-tertiary, #888)"
|
|
fontFamily="ui-sans-serif, system-ui, sans-serif"
|
|
>
|
|
{compactNumber(t)}
|
|
</text>
|
|
</g>
|
|
))}
|
|
{series.map((s, idx) => {
|
|
const colour = s.color ?? TOKEN_PALETTE[idx % TOKEN_PALETTE.length]!;
|
|
// Drop NaN / Infinity so the SVG path stays well-formed.
|
|
const points: Array<[number, number]> = filterFinite(s.values).map(
|
|
([i, v]) => [xScale(i), yScale(v)],
|
|
);
|
|
if (points.length === 0) return null;
|
|
const d = smooth
|
|
? smoothPath(points)
|
|
: `M ${points.map(([x, y]) => `${x} ${y}`).join(' L ')}`;
|
|
return (
|
|
<g key={s.id} data-testid="bl-line-chart-series" data-series-id={s.id}>
|
|
<path
|
|
d={d}
|
|
fill="none"
|
|
stroke={colour}
|
|
strokeWidth={2}
|
|
strokeLinejoin="round"
|
|
strokeLinecap="round"
|
|
/>
|
|
{points.map(([x, y], i) => (
|
|
<circle
|
|
key={`p-${i}`}
|
|
cx={x}
|
|
cy={y}
|
|
r={2.4}
|
|
fill={colour}
|
|
opacity={0.85}
|
|
/>
|
|
))}
|
|
</g>
|
|
);
|
|
})}
|
|
</g>
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
/** Pick ~`n` nice ticks across `[lo, hi]`. */
|
|
function niceTicks(lo: number, hi: number, n: number): number[] {
|
|
if (lo === hi) return [lo];
|
|
const step = (hi - lo) / Math.max(1, n - 1);
|
|
return Array.from({ length: n }, (_, i) => lo + step * i);
|
|
}
|
|
|