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

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