/** * Scatterplot-specific `RelativeData` transform. Scatter data is * `[number, number][]` — each series is a list of `[x, y]` tuples. * Relative is a values-axis concept, so this rewrites `y` to its * share of the series's total `y` (0–100) and leaves `x` raw — x is * coordinate space, not part of the cohort total. * * Pass to ``. * * The denominator is the sum of |y| across the series so mixed-sign * y values produce sane signed shares-of-magnitude. A series whose * total y-magnitude is zero (all-zero or empty input) is returned * unchanged so a stalled or empty data set doesn't show misleading 0% * values. Inputs whose shape isn't `[number, number][]` fall through * untouched. */ export const toRelativeScatterplotData = (input: unknown): unknown => { if (!Array.isArray(input)) return input return input.map((series: unknown): unknown => { if (!isXyTupleArray(series)) return series const total = series.reduce((acc, [, y]) => acc + Math.abs(y), 0) if (total <= 0) return series return series.map(([x, y]) => [x, (y / total) * 100] as [number, number]) }) } function isXyTupleArray(v: unknown): v is [number, number][] { if (!Array.isArray(v)) return false return v.every( (item) => Array.isArray(item) && item.length === 2 && typeof item[0] === 'number' && Number.isFinite(item[0]) && typeof item[1] === 'number' && Number.isFinite(item[1]), ) }