learning_ai_common_plat/packages/charts/src/BarChart.tsx
saravanakumardb1 da8d4ecb19 fix(charts): drop NaN/Infinity from series; DRY-up compactNumber
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>
2026-05-27 18:43:18 -07:00

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