/** * Default data-side transform for `RelativeData`: rewrite each * `{ name, value }` datum's `value` to its share of the series total * expressed as 0–100 (matches v1's range). The companion percent * formatter divides by 100 so it can call `Intl.NumberFormat` with * `style: 'percent'`. * * Used by widgets whose series are arrays of `{ name, value }` * objects — bar, pie, timeseries, category. Widgets with a different * data shape pass their own transform via * ``; see `widgets-v2/histogram` and * `widgets-v2/scatterplot` for in-tree examples. * * The denominator is the sum of |value| across the series so mixed-sign * series (e.g. cashflow with inflows + outflows) produce sane signed * shares-of-magnitude. A series whose total magnitude is zero (all-zero * or empty input) is returned unchanged so a stalled or empty data set * doesn't show misleading 0% values. Series whose shape doesn't match * the named-value pattern fall through untouched. */ interface NamedValue { name: unknown value: number [k: string]: unknown } function isNamedValueArray(v: unknown): v is NamedValue[] { if (!Array.isArray(v)) return false return v.every( (item) => item != null && typeof item === 'object' && 'value' in item && typeof (item as { value: unknown }).value === 'number', ) } export const toRelativeData = (input: unknown): unknown => { if (!Array.isArray(input)) return input return input.map((series: unknown): unknown => { if (!isNamedValueArray(series)) return series const total = series.reduce((acc, d) => acc + Math.abs(d.value), 0) if (total <= 0) return series return series.map((d) => ({ ...d, value: (d.value / total) * 100 })) }) } /** * Build a percent formatter using `Intl.NumberFormat`. The returned function * expects values in the 0–100 range (output of {@link toRelativeData}) and * divides by 100 internally before calling `style: 'percent'`. * * Locale: passed straight to `Intl.NumberFormat`. Falls back to the runtime * default when omitted. */ export function createPercentFormatter( locale?: string, ): (value: number) => string { const fmt = new Intl.NumberFormat(locale, { style: 'percent', minimumFractionDigits: 0, maximumFractionDigits: 2, }) return (value: number) => { if (typeof value !== 'number' || !Number.isFinite(value)) return String(value) return fmt.format(value / 100) } }