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>
This commit is contained in:
saravanakumardb1 2026-05-27 18:43:18 -07:00
parent d57ed9b878
commit da8d4ecb19
5 changed files with 68 additions and 22 deletions

View File

@ -1,5 +1,5 @@
import { useId, type CSSProperties } from 'react';
import { extent, linearScale, smoothPath } from './utils.js';
import { compactNumber, extent, filterFinite, linearScale, smoothPath } from './utils.js';
export interface AreaChartProps {
/** Series Y-values. */
@ -42,13 +42,15 @@ export function AreaChart({
const innerW = Math.max(0, width - padL - padR);
const innerH = Math.max(0, height - padT - padB);
const [yMin, yMax] = extent(values);
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]> = values.map((v, i) => [
const points: Array<[number, number]> = finitePairs.map(([i, v]) => [
xScale(i),
yScale(v),
]);
@ -115,7 +117,3 @@ export function AreaChart({
);
}
function compactNumber(v: number): string {
if (Math.abs(v) >= 1000) return `${(v / 1000).toFixed(1)}k`;
return v.toFixed(Math.abs(v) < 1 ? 2 : 0);
}

View File

@ -1,5 +1,5 @@
import { useId, type CSSProperties } from 'react';
import { extent, linearScale } from './utils.js';
import { compactNumber, extent, linearScale } from './utils.js';
export interface BarDatum {
/** Stable id — React key + accessible label. */
@ -139,7 +139,3 @@ export function BarChart({
);
}
function compactNumber(v: number): string {
if (Math.abs(v) >= 1000) return `${(v / 1000).toFixed(1)}k`;
return v.toFixed(Math.abs(v) < 1 ? 2 : 0);
}

View File

@ -1,5 +1,5 @@
import { useId, type CSSProperties } from 'react';
import { extent, linearScale, smoothPath } from './utils.js';
import { compactNumber, extent, filterFinite, linearScale, smoothPath } from './utils.js';
export interface LineSeries {
/** Stable id — React key + accessible series label. */
@ -112,10 +112,11 @@ export function LineChart({
))}
{series.map((s, idx) => {
const colour = s.color ?? TOKEN_PALETTE[idx % TOKEN_PALETTE.length]!;
const points: Array<[number, number]> = s.values.map((v, i) => [
xScale(i),
yScale(v),
]);
// 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 ')}`;
@ -154,7 +155,3 @@ function niceTicks(lo: number, hi: number, n: number): number[] {
return Array.from({ length: n }, (_, i) => lo + step * i);
}
function compactNumber(v: number): string {
if (Math.abs(v) >= 1000) return `${(v / 1000).toFixed(1)}k`;
return v.toFixed(Math.abs(v) < 1 ? 2 : 0);
}

View File

@ -6,7 +6,7 @@ import { BarChart } from '../BarChart.js';
import { AreaChart } from '../AreaChart.js';
import { Donut } from '../Donut.js';
import { Gauge } from '../Gauge.js';
import { extent, linearScale, smoothPath } from '../utils.js';
import { compactNumber, extent, filterFinite, linearScale, smoothPath } from '../utils.js';
beforeEach(() => cleanup());
@ -31,6 +31,22 @@ describe('utils', () => {
expect(smoothPath([[1, 2]])).toBe('M 1 2');
expect(smoothPath([[0, 0], [10, 10]])).toMatch(/^M 0 0 C/);
});
it('compactNumber renders k-suffix, fractions, and empty for non-finite', () => {
expect(compactNumber(2400)).toBe('2.4k');
expect(compactNumber(0.5)).toBe('0.50');
expect(compactNumber(7)).toBe('7');
expect(compactNumber(NaN)).toBe('');
expect(compactNumber(Infinity)).toBe('');
});
it('filterFinite drops NaN/Infinity but preserves original indices', () => {
expect(filterFinite([1, NaN, 3, Infinity, 5])).toEqual([
[0, 1],
[2, 3],
[4, 5],
]);
});
});
describe('LineChart', () => {
@ -57,6 +73,24 @@ describe('LineChart', () => {
render(<LineChart series={[{ id: 'a', values: [1] }]} ariaLabel="My label" />);
expect(screen.getByTestId('bl-line-chart').querySelector('title')?.textContent).toBe('My label');
});
it('drops NaN / Infinity from a series rather than emitting NaN in the SVG path', () => {
render(
<LineChart
series={[{ id: 'gappy', values: [1, NaN, 3, Infinity, 5] }]}
/>,
);
const path = screen.getByTestId('bl-line-chart-series').querySelector('path');
const d = path?.getAttribute('d') ?? '';
expect(d).not.toMatch(/NaN/);
expect(d).not.toMatch(/Infinity/);
expect(d.length).toBeGreaterThan(0);
});
it('renders nothing for a series of entirely non-finite values', () => {
render(<LineChart series={[{ id: 'broken', values: [NaN, NaN] }]} />);
expect(screen.queryByTestId('bl-line-chart-series')).toBeNull();
});
});
describe('BarChart', () => {

View File

@ -41,6 +41,27 @@ export function formatNumber(
}
}
/** Compact numeric formatter — `1234` → `1.2k`, `0.5` → `0.50`. */
export function compactNumber(v: number): string {
if (!Number.isFinite(v)) return '';
if (Math.abs(v) >= 1000) return `${(v / 1000).toFixed(1)}k`;
return v.toFixed(Math.abs(v) < 1 ? 2 : 0);
}
/**
* Drop non-finite (NaN / Infinity) values from a numeric series,
* preserving the original indices. Returns the `[index, value]` pairs
* so callers can keep their X-axis spacing intact.
*/
export function filterFinite(values: readonly number[]): Array<[number, number]> {
const out: Array<[number, number]> = [];
for (let i = 0; i < values.length; i++) {
const v = values[i]!;
if (Number.isFinite(v)) out.push([i, v]);
}
return out;
}
/** Build a smooth catmull-rom path through `pts`. */
export function smoothPath(pts: ReadonlyArray<[number, number]>): string {
if (pts.length === 0) return '';