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>
86 lines
2.5 KiB
TypeScript
86 lines
2.5 KiB
TypeScript
/**
|
|
* Shared utilities for chart primitives. Pure functions — no React.
|
|
*/
|
|
|
|
/** Linear scale: maps `[d0, d1]` → `[r0, r1]`. */
|
|
export function linearScale(
|
|
d0: number,
|
|
d1: number,
|
|
r0: number,
|
|
r1: number,
|
|
): (v: number) => number {
|
|
const dr = d1 - d0;
|
|
if (dr === 0) return () => (r0 + r1) / 2;
|
|
const mr = r1 - r0;
|
|
return (v) => r0 + ((v - d0) / dr) * mr;
|
|
}
|
|
|
|
/** Min/max of a numeric array, ignoring NaN/non-finite. */
|
|
export function extent(values: readonly number[]): [number, number] {
|
|
let min = Number.POSITIVE_INFINITY;
|
|
let max = Number.NEGATIVE_INFINITY;
|
|
for (const v of values) {
|
|
if (!Number.isFinite(v)) continue;
|
|
if (v < min) min = v;
|
|
if (v > max) max = v;
|
|
}
|
|
if (min === Number.POSITIVE_INFINITY) return [0, 1];
|
|
if (min === max) return [min - 1, max + 1];
|
|
return [min, max];
|
|
}
|
|
|
|
/** Format a number with `Intl.NumberFormat`, falling back to plain `.toString()`. */
|
|
export function formatNumber(
|
|
value: number,
|
|
opts: Intl.NumberFormatOptions = {},
|
|
): string {
|
|
try {
|
|
return new Intl.NumberFormat(undefined, opts).format(value);
|
|
} catch {
|
|
return String(value);
|
|
}
|
|
}
|
|
|
|
/** 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 '';
|
|
if (pts.length === 1) {
|
|
const [x, y] = pts[0]!;
|
|
return `M ${x} ${y}`;
|
|
}
|
|
let d = `M ${pts[0]![0]} ${pts[0]![1]}`;
|
|
for (let i = 1; i < pts.length; i++) {
|
|
const [x0, y0] = pts[Math.max(0, i - 2)]!;
|
|
const [x1, y1] = pts[i - 1]!;
|
|
const [x2, y2] = pts[i]!;
|
|
const [x3, y3] = pts[Math.min(pts.length - 1, i + 1)]!;
|
|
const cp1x = x1 + (x2 - x0) / 6;
|
|
const cp1y = y1 + (y2 - y0) / 6;
|
|
const cp2x = x2 - (x3 - x1) / 6;
|
|
const cp2y = y2 - (y3 - y1) / 6;
|
|
d += ` C ${cp1x} ${cp1y}, ${cp2x} ${cp2y}, ${x2} ${y2}`;
|
|
}
|
|
return d;
|
|
}
|