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

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