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>
120 lines
3.3 KiB
TypeScript
120 lines
3.3 KiB
TypeScript
import { useId, type CSSProperties } from 'react';
|
|
import { compactNumber, extent, filterFinite, linearScale, smoothPath } from './utils.js';
|
|
|
|
export interface AreaChartProps {
|
|
/** Series Y-values. */
|
|
values: number[];
|
|
/** 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;
|
|
/** Override stroke + fill base colour. */
|
|
color?: string;
|
|
/** Accessible label. */
|
|
ariaLabel?: string;
|
|
className?: string;
|
|
style?: CSSProperties;
|
|
}
|
|
|
|
/**
|
|
* `<AreaChart>` — single-series filled area chart with gradient.
|
|
*
|
|
* Wave 9.A.3.
|
|
*/
|
|
export function AreaChart({
|
|
values,
|
|
width = 480,
|
|
height = 240,
|
|
smooth = true,
|
|
color = 'var(--bl-accent, #6366f1)',
|
|
ariaLabel,
|
|
className,
|
|
style,
|
|
}: AreaChartProps) {
|
|
const titleId = useId();
|
|
const gradId = 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 finitePairs = filterFinite(values);
|
|
const finiteValues = finitePairs.map(([, v]) => v);
|
|
const [yMin, yMax] = extent(finiteValues);
|
|
const yMinDisplay = Math.min(0, yMin);
|
|
const yScale = linearScale(yMinDisplay, yMax, innerH, 0);
|
|
const xScale = (i: number) =>
|
|
values.length > 1 ? (i / (values.length - 1)) * innerW : innerW / 2;
|
|
|
|
const points: Array<[number, number]> = finitePairs.map(([i, v]) => [
|
|
xScale(i),
|
|
yScale(v),
|
|
]);
|
|
const linePath = smooth
|
|
? smoothPath(points)
|
|
: `M ${points.map(([x, y]) => `${x} ${y}`).join(' L ')}`;
|
|
const closeY = yScale(yMinDisplay);
|
|
const areaPath = points.length
|
|
? `${linePath} L ${points[points.length - 1]![0]} ${closeY} L ${points[0]![0]} ${closeY} Z`
|
|
: '';
|
|
|
|
return (
|
|
<svg
|
|
role="img"
|
|
aria-labelledby={titleId}
|
|
viewBox={`0 0 ${width} ${height}`}
|
|
width={width}
|
|
height={height}
|
|
data-testid="bl-area-chart"
|
|
className={className}
|
|
style={style}
|
|
>
|
|
<title id={titleId}>{ariaLabel ?? 'Area chart'}</title>
|
|
<defs>
|
|
<linearGradient id={gradId} x1="0" y1="0" x2="0" y2="1">
|
|
<stop offset="0%" stopColor={color} stopOpacity={0.35} />
|
|
<stop offset="100%" stopColor={color} stopOpacity={0} />
|
|
</linearGradient>
|
|
</defs>
|
|
<g transform={`translate(${padL} ${padT})`}>
|
|
<line
|
|
x1={0}
|
|
x2={innerW}
|
|
y1={closeY}
|
|
y2={closeY}
|
|
stroke="var(--bl-border-subtle, rgba(0,0,0,0.06))"
|
|
strokeWidth={1}
|
|
/>
|
|
<path d={areaPath} fill={`url(#${gradId})`} stroke="none" />
|
|
<path d={linePath} fill="none" stroke={color} strokeWidth={2} />
|
|
{/* baseline + max labels */}
|
|
<text
|
|
x={-6}
|
|
y={closeY}
|
|
textAnchor="end"
|
|
dominantBaseline="middle"
|
|
fontSize={10}
|
|
fill="var(--bl-text-tertiary, #888)"
|
|
>
|
|
{compactNumber(yMinDisplay)}
|
|
</text>
|
|
<text
|
|
x={-6}
|
|
y={yScale(yMax)}
|
|
textAnchor="end"
|
|
dominantBaseline="middle"
|
|
fontSize={10}
|
|
fill="var(--bl-text-tertiary, #888)"
|
|
>
|
|
{compactNumber(yMax)}
|
|
</text>
|
|
</g>
|
|
</svg>
|
|
);
|
|
}
|
|
|