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>
142 lines
4.0 KiB
TypeScript
142 lines
4.0 KiB
TypeScript
import { useId, type CSSProperties } from 'react';
|
|
import { compactNumber, extent, linearScale } from './utils.js';
|
|
|
|
export interface BarDatum {
|
|
/** Stable id — React key + accessible label. */
|
|
id: string;
|
|
/** Y-value. Negative values are supported (render below the baseline). */
|
|
value: number;
|
|
/** Optional X-axis tick label (default: `id`). */
|
|
label?: string;
|
|
/** Optional override colour. */
|
|
color?: string;
|
|
}
|
|
|
|
export interface BarChartProps {
|
|
data: BarDatum[];
|
|
/** Chart width in px. Default 480. */
|
|
width?: number;
|
|
/** Chart height in px. Default 240. */
|
|
height?: number;
|
|
/** Bar corner radius (px). Default 4. */
|
|
cornerRadius?: number;
|
|
/** Y baseline value (default 0). Use a custom baseline for diverging
|
|
* charts (e.g. ±deltas around a target). */
|
|
baseline?: number;
|
|
/** Show subtle Y grid lines (3 ticks). Default true. */
|
|
grid?: boolean;
|
|
/** Accessible label. */
|
|
ariaLabel?: string;
|
|
className?: string;
|
|
style?: CSSProperties;
|
|
}
|
|
|
|
/**
|
|
* `<BarChart>` — vertical bar chart with diverging-baseline support.
|
|
*
|
|
* Wave 9.A.2. Token-tinted via `--bl-accent` with per-bar override.
|
|
*/
|
|
export function BarChart({
|
|
data,
|
|
width = 480,
|
|
height = 240,
|
|
cornerRadius = 4,
|
|
baseline = 0,
|
|
grid = true,
|
|
ariaLabel,
|
|
className,
|
|
style,
|
|
}: BarChartProps) {
|
|
const titleId = useId();
|
|
const padL = 36;
|
|
const padR = 12;
|
|
const padT = 12;
|
|
const padB = 28;
|
|
const innerW = Math.max(0, width - padL - padR);
|
|
const innerH = Math.max(0, height - padT - padB);
|
|
|
|
const values = data.map((d) => d.value);
|
|
let [yMin, yMax] = extent(values);
|
|
yMin = Math.min(yMin, baseline);
|
|
yMax = Math.max(yMax, baseline);
|
|
const yScale = linearScale(yMin, yMax, innerH, 0);
|
|
const yBase = yScale(baseline);
|
|
|
|
const n = data.length;
|
|
const slot = n > 0 ? innerW / n : 0;
|
|
const barW = Math.max(2, slot * 0.7);
|
|
|
|
return (
|
|
<svg
|
|
role="img"
|
|
aria-labelledby={titleId}
|
|
viewBox={`0 0 ${width} ${height}`}
|
|
width={width}
|
|
height={height}
|
|
data-testid="bl-bar-chart"
|
|
className={className}
|
|
style={style}
|
|
>
|
|
<title id={titleId}>{ariaLabel ?? 'Bar chart'}</title>
|
|
<g transform={`translate(${padL} ${padT})`}>
|
|
{grid &&
|
|
[yMin, baseline, yMax].map((t, i) => (
|
|
<g key={`g${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>
|
|
))}
|
|
{data.map((d, i) => {
|
|
const cx = slot * i + slot / 2;
|
|
const x = cx - barW / 2;
|
|
const y = Math.min(yBase, yScale(d.value));
|
|
const h = Math.abs(yBase - yScale(d.value));
|
|
const colour = d.color ?? 'var(--bl-accent, #6366f1)';
|
|
return (
|
|
<g key={d.id} data-testid="bl-bar-chart-bar" data-bar-id={d.id}>
|
|
<rect
|
|
x={x}
|
|
y={y}
|
|
width={barW}
|
|
height={Math.max(0.5, h)}
|
|
rx={cornerRadius}
|
|
ry={cornerRadius}
|
|
fill={colour}
|
|
opacity={d.value === baseline ? 0.4 : 0.9}
|
|
/>
|
|
<text
|
|
x={cx}
|
|
y={innerH + 14}
|
|
textAnchor="middle"
|
|
fontSize={10}
|
|
fill="var(--bl-text-tertiary, #888)"
|
|
fontFamily="ui-sans-serif, system-ui, sans-serif"
|
|
>
|
|
{d.label ?? d.id}
|
|
</text>
|
|
</g>
|
|
);
|
|
})}
|
|
</g>
|
|
</svg>
|
|
);
|
|
}
|
|
|