learning_ai_common_plat/packages/charts/src/LineChart.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

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