|
| 1 | +import type { ChartConfiguration } from "chart.js"; |
| 2 | + |
| 3 | +export interface ExtremeUpperTail { |
| 4 | + /** Lowest value classified as an outlier. */ |
| 5 | + firstOutlier: number; |
| 6 | + /** Number of values in the extreme upper tail. */ |
| 7 | + outlierCount: number; |
| 8 | +} |
| 9 | + |
| 10 | +function quantile(sorted: number[], percentile: number): number { |
| 11 | + const position = (sorted.length - 1) * percentile; |
| 12 | + const lower = Math.floor(position); |
| 13 | + const upper = Math.ceil(position); |
| 14 | + const weight = position - lower; |
| 15 | + return sorted[lower] + (sorted[upper] - sorted[lower]) * weight; |
| 16 | +} |
| 17 | + |
| 18 | +/** |
| 19 | + * Finds a small, clearly separated upper tail using two robust estimators: |
| 20 | + * Tukey's far-outlier fence and the median absolute deviation (MAD). The |
| 21 | + * stricter usable fence wins. A ratio guard prevents ordinary high values from |
| 22 | + * changing an otherwise readable chart to a logarithmic scale. |
| 23 | + */ |
| 24 | +export function detectExtremeUpperTail( |
| 25 | + input: readonly number[], |
| 26 | +): ExtremeUpperTail | null { |
| 27 | + const values = input |
| 28 | + .filter((value) => Number.isFinite(value) && value > 0) |
| 29 | + .sort((a, b) => a - b); |
| 30 | + |
| 31 | + // Six values is enough for the smallest generated comparison chart while |
| 32 | + // still making quartiles meaningful. |
| 33 | + if (values.length < 6) return null; |
| 34 | + |
| 35 | + const q1 = quantile(values, 0.25); |
| 36 | + const median = quantile(values, 0.5); |
| 37 | + const q3 = quantile(values, 0.75); |
| 38 | + const iqr = q3 - q1; |
| 39 | + |
| 40 | + const deviations = values |
| 41 | + .map((value) => Math.abs(value - median)) |
| 42 | + .sort((a, b) => a - b); |
| 43 | + const mad = quantile(deviations, 0.5); |
| 44 | + |
| 45 | + const fences: number[] = []; |
| 46 | + if (iqr > 0) fences.push(q3 + 3 * iqr); |
| 47 | + if (mad > 0) fences.push(median + 6 * mad); |
| 48 | + |
| 49 | + // When most values are identical both robust spreads can be zero. A lone |
| 50 | + // value still has to exceed the common value by 4x to qualify. |
| 51 | + if (fences.length === 0 && median > 0) fences.push(median * 4); |
| 52 | + if (fences.length === 0) return null; |
| 53 | + |
| 54 | + const fence = Math.min(...fences); |
| 55 | + const firstOutlierIndex = values.findIndex((value) => value > fence); |
| 56 | + if (firstOutlierIndex <= 0) return null; |
| 57 | + |
| 58 | + const outlierCount = values.length - firstOutlierIndex; |
| 59 | + // More than one fifth of the chart is a population, not a sparse upper tail. |
| 60 | + if (outlierCount / values.length > 0.2) return null; |
| 61 | + |
| 62 | + const normalMax = values[firstOutlierIndex - 1]; |
| 63 | + const firstOutlier = values[firstOutlierIndex]; |
| 64 | + if (firstOutlier / normalMax < 1.35) return null; |
| 65 | + |
| 66 | + return { firstOutlier, outlierCount }; |
| 67 | +} |
| 68 | + |
| 69 | +interface ChartDataset { |
| 70 | + data?: unknown[]; |
| 71 | +} |
| 72 | + |
| 73 | +function objectRecord(value: unknown): Record<string, unknown> { |
| 74 | + return value && typeof value === "object" |
| 75 | + ? (value as Record<string, unknown>) |
| 76 | + : {}; |
| 77 | +} |
| 78 | + |
| 79 | +function measuredValue(point: unknown, horizontal: boolean): number | null { |
| 80 | + if (typeof point === "number" && Number.isFinite(point)) return point; |
| 81 | + if (!point || typeof point !== "object") return null; |
| 82 | + const key = horizontal ? "x" : "y"; |
| 83 | + const value = (point as Record<string, unknown>)[key]; |
| 84 | + return typeof value === "number" && Number.isFinite(value) ? value : null; |
| 85 | +} |
| 86 | + |
| 87 | +function axisTitleWithLogScale(title: unknown): unknown { |
| 88 | + const record = objectRecord(title); |
| 89 | + if (!record.text) return title; |
| 90 | + const text = String(record.text); |
| 91 | + return text.includes("log10 scale") |
| 92 | + ? title |
| 93 | + : { ...record, text: `${text} · log10 scale` }; |
| 94 | +} |
| 95 | + |
| 96 | +function logTickLabel(value: unknown): string { |
| 97 | + const numeric = typeof value === "number" ? value : Number(value); |
| 98 | + if (!Number.isFinite(numeric) || numeric <= 0) return ""; |
| 99 | + const magnitude = 10 ** Math.floor(Math.log10(numeric)); |
| 100 | + const mantissa = numeric / magnitude; |
| 101 | + const major = [1, 2, 5].some( |
| 102 | + (candidate) => Math.abs(mantissa - candidate) < 1e-8, |
| 103 | + ); |
| 104 | + return major ? numeric.toLocaleString("en-US") : ""; |
| 105 | +} |
| 106 | + |
| 107 | +/** |
| 108 | + * Switches a chart's measured axis to Chart.js's logarithmic scale. By default |
| 109 | + * the switch is adaptive; callers can force it for a known mixed population. |
| 110 | + * Source values and datalabels are never modified. |
| 111 | + */ |
| 112 | +export function withAdaptiveLogScale( |
| 113 | + config: ChartConfiguration, |
| 114 | + force = false, |
| 115 | +): ChartConfiguration { |
| 116 | + const source = config as unknown as Record<string, unknown>; |
| 117 | + const type = source.type; |
| 118 | + if (type !== "bar" && type !== "line") return config; |
| 119 | + |
| 120 | + const data = objectRecord(source.data); |
| 121 | + const datasets = Array.isArray(data.datasets) |
| 122 | + ? (data.datasets as ChartDataset[]) |
| 123 | + : []; |
| 124 | + const options = objectRecord(source.options); |
| 125 | + const horizontal = type === "bar" && options.indexAxis === "y"; |
| 126 | + const values = datasets.flatMap((dataset) => |
| 127 | + (dataset.data ?? []) |
| 128 | + .map((point) => measuredValue(point, horizontal)) |
| 129 | + .filter((value): value is number => value !== null && value > 0), |
| 130 | + ); |
| 131 | + if (values.length === 0) return config; |
| 132 | + if (!force && !detectExtremeUpperTail(values)) return config; |
| 133 | + |
| 134 | + const scales = objectRecord(options.scales); |
| 135 | + const axisKey = horizontal ? "x" : "y"; |
| 136 | + const axis = objectRecord(scales[axisKey]); |
| 137 | + const ticks = objectRecord(axis.ticks); |
| 138 | + const maxValue = Math.max(...values); |
| 139 | + const logarithmicAxis: Record<string, unknown> = { |
| 140 | + ...axis, |
| 141 | + type: "logarithmic", |
| 142 | + beginAtZero: false, |
| 143 | + grace: "10%", |
| 144 | + suggestedMax: maxValue * (horizontal ? 2 : 1.25), |
| 145 | + title: axisTitleWithLogScale(axis.title), |
| 146 | + ticks: { ...ticks, callback: logTickLabel }, |
| 147 | + }; |
| 148 | + |
| 149 | + // Linear-chart bounds and steps are not meaningful on a logarithmic axis. |
| 150 | + delete logarithmicAxis.max; |
| 151 | + delete (logarithmicAxis.ticks as Record<string, unknown>).stepSize; |
| 152 | + |
| 153 | + return { |
| 154 | + ...source, |
| 155 | + options: { |
| 156 | + ...options, |
| 157 | + scales: { ...scales, [axisKey]: logarithmicAxis }, |
| 158 | + }, |
| 159 | + } as unknown as ChartConfiguration; |
| 160 | +} |
0 commit comments