@arcfusionz/arc-analytic-ui 0.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/_virtual/_rolldown/runtime.js +13 -0
- package/dist/components/AnomalyCard/AnomalyCard.chart.d.ts +64 -0
- package/dist/components/AnomalyCard/AnomalyCard.chart.js +341 -0
- package/dist/components/AnomalyCard/AnomalyCard.d.ts +169 -0
- package/dist/components/AnomalyCard/AnomalyCard.js +1019 -0
- package/dist/components/AnomalyCard/AnomalyCard.model.d.ts +508 -0
- package/dist/components/AnomalyCard/AnomalyCard.model.js +553 -0
- package/dist/components/AnomalyCard/index.d.ts +4 -0
- package/dist/components/AnomalyCard/index.js +4 -0
- package/dist/components/ChartCard/ChartCard.chart.d.ts +72 -0
- package/dist/components/ChartCard/ChartCard.chart.js +1333 -0
- package/dist/components/ChartCard/ChartCard.d.ts +206 -0
- package/dist/components/ChartCard/ChartCard.js +718 -0
- package/dist/components/ChartCard/ChartCard.model.d.ts +452 -0
- package/dist/components/ChartCard/ChartCard.model.js +1223 -0
- package/dist/components/ChartCard/index.d.ts +4 -0
- package/dist/components/ChartCard/index.js +4 -0
- package/dist/components/ComparisonCard/ComparisonCard.chart.d.ts +80 -0
- package/dist/components/ComparisonCard/ComparisonCard.chart.js +337 -0
- package/dist/components/ComparisonCard/ComparisonCard.d.ts +200 -0
- package/dist/components/ComparisonCard/ComparisonCard.js +1325 -0
- package/dist/components/ComparisonCard/ComparisonCard.model.d.ts +309 -0
- package/dist/components/ComparisonCard/ComparisonCard.model.js +263 -0
- package/dist/components/ComparisonCard/index.d.ts +4 -0
- package/dist/components/ComparisonCard/index.js +4 -0
- package/dist/components/ForecastCard/ForecastCard.chart.d.ts +82 -0
- package/dist/components/ForecastCard/ForecastCard.chart.js +416 -0
- package/dist/components/ForecastCard/ForecastCard.d.ts +148 -0
- package/dist/components/ForecastCard/ForecastCard.js +1029 -0
- package/dist/components/ForecastCard/ForecastCard.model.d.ts +497 -0
- package/dist/components/ForecastCard/ForecastCard.model.js +554 -0
- package/dist/components/ForecastCard/index.d.ts +4 -0
- package/dist/components/ForecastCard/index.js +4 -0
- package/dist/components/HighlightCard/HighlightCard.chart.d.ts +122 -0
- package/dist/components/HighlightCard/HighlightCard.chart.js +660 -0
- package/dist/components/HighlightCard/HighlightCard.d.ts +202 -0
- package/dist/components/HighlightCard/HighlightCard.js +1286 -0
- package/dist/components/HighlightCard/HighlightCard.model.d.ts +692 -0
- package/dist/components/HighlightCard/HighlightCard.model.js +875 -0
- package/dist/components/HighlightCard/index.d.ts +4 -0
- package/dist/components/HighlightCard/index.js +4 -0
- package/dist/components/InsightCard/InsightCard.chart.d.ts +91 -0
- package/dist/components/InsightCard/InsightCard.chart.js +471 -0
- package/dist/components/InsightCard/InsightCard.d.ts +161 -0
- package/dist/components/InsightCard/InsightCard.js +961 -0
- package/dist/components/InsightCard/InsightCard.model.d.ts +405 -0
- package/dist/components/InsightCard/InsightCard.model.js +421 -0
- package/dist/components/InsightCard/index.d.ts +4 -0
- package/dist/components/InsightCard/index.js +4 -0
- package/dist/components/MetricCard/MetricCard.chart.d.ts +79 -0
- package/dist/components/MetricCard/MetricCard.chart.js +435 -0
- package/dist/components/MetricCard/MetricCard.d.ts +227 -0
- package/dist/components/MetricCard/MetricCard.js +1293 -0
- package/dist/components/MetricCard/MetricCard.model.d.ts +521 -0
- package/dist/components/MetricCard/MetricCard.model.js +627 -0
- package/dist/components/MetricCard/index.d.ts +4 -0
- package/dist/components/MetricCard/index.js +4 -0
- package/dist/components/ProfileCard/ProfileCard.chart.d.ts +64 -0
- package/dist/components/ProfileCard/ProfileCard.chart.js +427 -0
- package/dist/components/ProfileCard/ProfileCard.d.ts +140 -0
- package/dist/components/ProfileCard/ProfileCard.js +735 -0
- package/dist/components/ProfileCard/ProfileCard.model.d.ts +279 -0
- package/dist/components/ProfileCard/ProfileCard.model.js +244 -0
- package/dist/components/ProfileCard/index.d.ts +4 -0
- package/dist/components/ProfileCard/index.js +4 -0
- package/dist/components/ProgressCard/ProgressCard.chart.d.ts +73 -0
- package/dist/components/ProgressCard/ProgressCard.chart.js +373 -0
- package/dist/components/ProgressCard/ProgressCard.d.ts +137 -0
- package/dist/components/ProgressCard/ProgressCard.js +908 -0
- package/dist/components/ProgressCard/ProgressCard.model.d.ts +385 -0
- package/dist/components/ProgressCard/ProgressCard.model.js +389 -0
- package/dist/components/ProgressCard/index.d.ts +4 -0
- package/dist/components/ProgressCard/index.js +4 -0
- package/dist/components/ReportCard/ReportCard.chart.d.ts +45 -0
- package/dist/components/ReportCard/ReportCard.chart.js +231 -0
- package/dist/components/ReportCard/ReportCard.d.ts +153 -0
- package/dist/components/ReportCard/ReportCard.js +1118 -0
- package/dist/components/ReportCard/ReportCard.model.d.ts +392 -0
- package/dist/components/ReportCard/ReportCard.model.js +399 -0
- package/dist/components/ReportCard/index.d.ts +4 -0
- package/dist/components/ReportCard/index.js +4 -0
- package/dist/components/SankeyCard/SankeyCard.chart.d.ts +75 -0
- package/dist/components/SankeyCard/SankeyCard.chart.js +249 -0
- package/dist/components/SankeyCard/SankeyCard.d.ts +185 -0
- package/dist/components/SankeyCard/SankeyCard.js +788 -0
- package/dist/components/SankeyCard/SankeyCard.model.d.ts +501 -0
- package/dist/components/SankeyCard/SankeyCard.model.js +789 -0
- package/dist/components/SankeyCard/index.d.ts +4 -0
- package/dist/components/SankeyCard/index.js +4 -0
- package/dist/components/StatusCard/StatusCard.chart.d.ts +60 -0
- package/dist/components/StatusCard/StatusCard.chart.js +188 -0
- package/dist/components/StatusCard/StatusCard.d.ts +176 -0
- package/dist/components/StatusCard/StatusCard.js +989 -0
- package/dist/components/StatusCard/StatusCard.model.d.ts +555 -0
- package/dist/components/StatusCard/StatusCard.model.js +776 -0
- package/dist/components/StatusCard/index.d.ts +4 -0
- package/dist/components/StatusCard/index.js +4 -0
- package/dist/components/TableCard/TableCard.chart.d.ts +53 -0
- package/dist/components/TableCard/TableCard.chart.js +192 -0
- package/dist/components/TableCard/TableCard.d.ts +140 -0
- package/dist/components/TableCard/TableCard.js +811 -0
- package/dist/components/TableCard/TableCard.model.d.ts +381 -0
- package/dist/components/TableCard/TableCard.model.js +475 -0
- package/dist/components/TableCard/index.d.ts +4 -0
- package/dist/components/TableCard/index.js +4 -0
- package/dist/components/TargetCard/TargetCard.chart.d.ts +52 -0
- package/dist/components/TargetCard/TargetCard.chart.js +273 -0
- package/dist/components/TargetCard/TargetCard.d.ts +143 -0
- package/dist/components/TargetCard/TargetCard.js +910 -0
- package/dist/components/TargetCard/TargetCard.model.d.ts +468 -0
- package/dist/components/TargetCard/TargetCard.model.js +581 -0
- package/dist/components/TargetCard/index.d.ts +4 -0
- package/dist/components/TargetCard/index.js +4 -0
- package/dist/components/TreeCard/TreeCard.chart.d.ts +80 -0
- package/dist/components/TreeCard/TreeCard.chart.js +178 -0
- package/dist/components/TreeCard/TreeCard.d.ts +182 -0
- package/dist/components/TreeCard/TreeCard.js +1187 -0
- package/dist/components/TreeCard/TreeCard.model.d.ts +330 -0
- package/dist/components/TreeCard/TreeCard.model.js +401 -0
- package/dist/components/TreeCard/index.d.ts +4 -0
- package/dist/components/TreeCard/index.js +4 -0
- package/dist/components/TrendCard/TrendCard.chart.d.ts +75 -0
- package/dist/components/TrendCard/TrendCard.chart.js +393 -0
- package/dist/components/TrendCard/TrendCard.d.ts +224 -0
- package/dist/components/TrendCard/TrendCard.js +1053 -0
- package/dist/components/TrendCard/TrendCard.model.d.ts +545 -0
- package/dist/components/TrendCard/TrendCard.model.js +623 -0
- package/dist/components/TrendCard/index.d.ts +4 -0
- package/dist/components/TrendCard/index.js +4 -0
- package/dist/index.d.ts +76 -0
- package/dist/index.js +57 -0
- package/dist/lib/vega/ArcVegaChart.d.ts +24 -0
- package/dist/lib/vega/ArcVegaChart.js +42 -0
- package/dist/lib/vega/ArcVegaSurface.d.ts +13 -0
- package/dist/lib/vega/ArcVegaSurface.js +38 -0
- package/dist/lib/vega/axisTicks.js +156 -0
- package/dist/lib/vega/config.d.ts +29 -0
- package/dist/lib/vega/config.js +113 -0
- package/dist/lib/vega/embed.d.ts +29 -0
- package/dist/lib/vega/embed.js +29 -0
- package/dist/lib/vega/engine.d.ts +10 -0
- package/dist/lib/vega/engine.js +6 -0
- package/dist/lib/vega/formatters.d.ts +125 -0
- package/dist/lib/vega/formatters.js +277 -0
- package/dist/lib/vega/index.d.ts +11 -0
- package/dist/lib/vega/load.d.ts +13 -0
- package/dist/lib/vega/load.js +21 -0
- package/dist/lib/vega/time.d.ts +45 -0
- package/dist/lib/vega/time.js +105 -0
- package/dist/lib/vega/tokens.d.ts +73 -0
- package/dist/lib/vega/tokens.js +137 -0
- package/dist/lib/vega/tooltip.d.ts +45 -0
- package/dist/lib/vega/tooltip.js +124 -0
- package/dist/lib/vega/useArcVega.d.ts +43 -0
- package/dist/lib/vega/useArcVega.js +121 -0
- package/package.json +60 -0
|
@@ -0,0 +1,875 @@
|
|
|
1
|
+
import { formatDate, formatDateRange, formatDelta, formatNumber, parseArcNumberFormat } from "../../lib/vega/formatters.js";
|
|
2
|
+
//#region src/components/HighlightCard/HighlightCard.model.ts
|
|
3
|
+
const DEFAULT_SIGMA_THRESHOLDS = {
|
|
4
|
+
notable: 1,
|
|
5
|
+
unusual: 2,
|
|
6
|
+
extreme: 3
|
|
7
|
+
};
|
|
8
|
+
const DEFAULT_PERCENTILE_THRESHOLDS = {
|
|
9
|
+
notable: 40,
|
|
10
|
+
unusual: 45,
|
|
11
|
+
extreme: 49
|
|
12
|
+
};
|
|
13
|
+
const DEFAULT_HIGHLIGHT_LABELS = {
|
|
14
|
+
severityNames: {
|
|
15
|
+
typical: "Typical",
|
|
16
|
+
notable: "Notable",
|
|
17
|
+
unusual: "Unusual",
|
|
18
|
+
extreme: "Extreme"
|
|
19
|
+
},
|
|
20
|
+
increase: "Up",
|
|
21
|
+
decrease: "Down",
|
|
22
|
+
unchanged: "No change",
|
|
23
|
+
percentagePoints: " pp",
|
|
24
|
+
contextTitle: "In context",
|
|
25
|
+
peersTitle: "How unusual",
|
|
26
|
+
driversTitle: "What drove it",
|
|
27
|
+
noData: "No data",
|
|
28
|
+
noMatch: "No matching data",
|
|
29
|
+
clearFilters: "Clear filters",
|
|
30
|
+
error: "Couldn't load this highlight",
|
|
31
|
+
retry: "Try again",
|
|
32
|
+
unresolved: "The highlighted period has no two readings to compare",
|
|
33
|
+
insufficientPeers: "Not enough comparable periods to say how unusual this is",
|
|
34
|
+
stale: "Stale",
|
|
35
|
+
asOf: "As of",
|
|
36
|
+
tablePeriod: "Period",
|
|
37
|
+
tableValue: "Value",
|
|
38
|
+
tableStep: "Step",
|
|
39
|
+
tableChange: "Change",
|
|
40
|
+
tableShare: "Share",
|
|
41
|
+
tableDriver: "Driver",
|
|
42
|
+
tableWindow: "Window",
|
|
43
|
+
windowStart: "Start",
|
|
44
|
+
windowEnd: "End",
|
|
45
|
+
windowChange: "Change",
|
|
46
|
+
windowChangePercent: "Change %",
|
|
47
|
+
windowPeriod: "Period",
|
|
48
|
+
windowPeak: "Peak",
|
|
49
|
+
windowTrough: "Trough",
|
|
50
|
+
windowSteepest: "Steepest step",
|
|
51
|
+
highlighted: "highlighted",
|
|
52
|
+
peerMean: "Typical change",
|
|
53
|
+
peerSd: "Standard deviation",
|
|
54
|
+
peerTypical: "Typical range",
|
|
55
|
+
peerRank: "Rank",
|
|
56
|
+
peerPercentile: "Percentile",
|
|
57
|
+
peerZ: "Distance from typical",
|
|
58
|
+
peerThis: "This period",
|
|
59
|
+
peerCount: "Compared periods",
|
|
60
|
+
driverTotal: "Total change",
|
|
61
|
+
driverExplained: "Explained",
|
|
62
|
+
driverResidual: "Unexplained",
|
|
63
|
+
driverOther: "Other",
|
|
64
|
+
driverContribution: "Contribution",
|
|
65
|
+
insightMark: "Generated insight",
|
|
66
|
+
about: (name) => `About ${name}`,
|
|
67
|
+
loading: (name) => `Loading ${name}`,
|
|
68
|
+
showTable: (name) => `Show the data behind ${name}`,
|
|
69
|
+
hideTable: (name) => `Hide the data behind ${name}`,
|
|
70
|
+
windowDetails: (name) => `Details of the ${name} highlight`,
|
|
71
|
+
driversGroup: (name) => `What drove the ${name} highlight`,
|
|
72
|
+
selectDriver: (label) => `Drill into ${label}`,
|
|
73
|
+
driverSelected: ({ label, contribution, share }) => `${label}: ${contribution}${share ? `, ${share} of the change` : ""}.`,
|
|
74
|
+
tableCaption: (name) => `Data behind the ${name} highlight`,
|
|
75
|
+
peersCaption: ({ count, readings, mode }) => `Compared with ${count} ${mode === "rolling" ? "overlapping" : "non-overlapping"} ${readings}-reading period${count === 1 ? "" : "s"} in the same series.`,
|
|
76
|
+
driversCaption: ({ total, explained, residual, reconciles }) => reconciles ? `Contributions sum to ${explained} of the ${total} change.` : `Contributions sum to ${explained} of the ${total} change; ${residual} is unexplained.`,
|
|
77
|
+
foldedCaption: (folded, label) => `${folded} smaller contributors folded into ${label}.`,
|
|
78
|
+
windowLine: ({ range, label }) => label ? `${label} · ${range}` : range,
|
|
79
|
+
windowExplanation: ({ periodText, startText, endText, changeText, secondaryText, renderedMode, direction, peakText, troughText, steepestText }) => {
|
|
80
|
+
const rows = [
|
|
81
|
+
["Period", periodText],
|
|
82
|
+
["Start", startText],
|
|
83
|
+
["End", endText]
|
|
84
|
+
];
|
|
85
|
+
rows.push([direction === "up" ? "Increase" : direction === "down" ? "Decrease" : "No change", changeText]);
|
|
86
|
+
if (secondaryText) rows.push([renderedMode === "percent" ? "Change" : "Change %", secondaryText]);
|
|
87
|
+
if (peakText) rows.push(["Peak", peakText]);
|
|
88
|
+
if (troughText) rows.push(["Trough", troughText]);
|
|
89
|
+
if (steepestText) rows.push(["Steepest step", steepestText]);
|
|
90
|
+
return rows;
|
|
91
|
+
},
|
|
92
|
+
peerExplanation: (stats) => {
|
|
93
|
+
const rows = [
|
|
94
|
+
["This period", stats.changeText],
|
|
95
|
+
["Rank", stats.rankText],
|
|
96
|
+
["Typical range", stats.typicalText]
|
|
97
|
+
];
|
|
98
|
+
if (stats.baseline === "sigma") {
|
|
99
|
+
rows.push(["Typical change", stats.meanText]);
|
|
100
|
+
if (stats.z != null) rows.push(["Distance from typical", stats.zText]);
|
|
101
|
+
} else rows.push(["Percentile", stats.percentileText]);
|
|
102
|
+
rows.push(["Compared periods", String(stats.count)]);
|
|
103
|
+
return rows;
|
|
104
|
+
},
|
|
105
|
+
peerVerdict: ({ severity, rank, total, typical, change, inside }) => {
|
|
106
|
+
const place = rank === "1st" ? "the largest" : `the ${rank} largest`;
|
|
107
|
+
const scale = `${total} period${total === 1 ? "" : "s"} this length`;
|
|
108
|
+
if (severity === "typical" || inside) return `${change} is ${place} of ${scale} — inside the typical range of ${typical}.`;
|
|
109
|
+
return `${change} is ${place} of ${scale}, outside the typical range of ${typical}.`;
|
|
110
|
+
},
|
|
111
|
+
contextDescription: ({ mark, name, count, first, last, min, max, range, label, direction, change }) => `${mark} chart of ${name}: ${count} readings from ${first} to ${last}, ranging ${min} to ${max}. The highlighted period ${label ? `${label} (${range})` : range} is painted in the polarity hue and ${direction === "flat" ? "did not change" : `moved ${change}`}.`,
|
|
112
|
+
peersDescription: ({ name, count, readings, mode, change, rank, typical, severity }) => `Strip plot of the change in ${name} across ${count} ${mode === "rolling" ? "overlapping" : "non-overlapping"} ${readings}-reading periods. The highlighted period changed ${change}, the ${rank} largest, against a typical range of ${typical} — ${severity}.`,
|
|
113
|
+
driversDescription: ({ name, count, total, top, topContribution, counter, counterContribution, residual }) => `Bar chart of ${count} contributor${count === 1 ? "" : "s"} to the ${total} change in ${name}.` + (top && topContribution ? ` Largest: ${top} at ${topContribution}.` : "") + (counter && counterContribution ? ` Largest against it: ${counter} at ${counterContribution}.` : "") + (residual ? ` ${residual} is unexplained.` : ""),
|
|
114
|
+
cardDescription: (input) => {
|
|
115
|
+
const { name, state, range, label, direction, change, secondary, startText, endText, severity, rank, count, driverCount, topDriver, topContribution, asOf } = input;
|
|
116
|
+
if (state === "loading") return `Loading the ${name} highlight.`;
|
|
117
|
+
if (state === "error") return `The ${name} highlight could not be loaded.`;
|
|
118
|
+
if (state === "empty") return `No data for the ${name} highlight.`;
|
|
119
|
+
if (state === "no-match") return `No ${name} readings match the current filters.`;
|
|
120
|
+
let text = `Highlight analysis of ${name} over ${label ? `${label} (${range})` : range}: ${direction === "up" ? "rose" : direction === "down" ? "fell" : "held flat"}${change ? ` ${change}` : ""}${secondary ? ` (${secondary})` : ""}, from ${startText} to ${endText}.`;
|
|
121
|
+
if (severity && rank && count != null) text += ` That is the ${rank} of ${count + 1} comparable periods — ${severity}.`;
|
|
122
|
+
if (driverCount > 0 && topDriver && topContribution) text += ` Largest contributor: ${topDriver} at ${topContribution}.`;
|
|
123
|
+
if (asOf) text += ` As of ${asOf}.`;
|
|
124
|
+
return text;
|
|
125
|
+
},
|
|
126
|
+
insight: (input) => {
|
|
127
|
+
const { name, direction, change, secondary, range, label, severity, rank, count, topDriver, topContribution, topShare } = input;
|
|
128
|
+
const period = label ? `${label} (${range})` : range;
|
|
129
|
+
const segments = [`${name} ${direction === "up" ? "rose" : direction === "down" ? "fell" : "held flat"}`];
|
|
130
|
+
if (change) {
|
|
131
|
+
segments.push(" ");
|
|
132
|
+
segments.push({ value: change });
|
|
133
|
+
if (secondary) segments.push(` (${secondary})`);
|
|
134
|
+
}
|
|
135
|
+
segments.push(` over ${period}`);
|
|
136
|
+
if (rank && count != null) {
|
|
137
|
+
segments.push(", the ");
|
|
138
|
+
segments.push({ value: rank });
|
|
139
|
+
segments.push(` of ${count + 1} comparable periods (${severity.toLowerCase()})`);
|
|
140
|
+
}
|
|
141
|
+
if (topDriver && topContribution) {
|
|
142
|
+
segments.push(". ");
|
|
143
|
+
segments.push({ value: topDriver });
|
|
144
|
+
segments.push(" contributed ");
|
|
145
|
+
segments.push({ value: topContribution });
|
|
146
|
+
if (topShare) segments.push(` (${topShare} of the move)`);
|
|
147
|
+
}
|
|
148
|
+
segments.push(".");
|
|
149
|
+
return segments;
|
|
150
|
+
},
|
|
151
|
+
markNames: {
|
|
152
|
+
line: "Line",
|
|
153
|
+
area: "Area",
|
|
154
|
+
bar: "Bar"
|
|
155
|
+
},
|
|
156
|
+
peerModeNames: {
|
|
157
|
+
rolling: "overlapping",
|
|
158
|
+
adjacent: "non-overlapping"
|
|
159
|
+
}
|
|
160
|
+
};
|
|
161
|
+
function resolveHighlightLabels(overrides) {
|
|
162
|
+
return overrides ? {
|
|
163
|
+
...DEFAULT_HIGHLIGHT_LABELS,
|
|
164
|
+
...overrides
|
|
165
|
+
} : DEFAULT_HIGHLIGHT_LABELS;
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* The state the panel renders, from the transport's status and the data.
|
|
169
|
+
* `loading` with data already present is a refresh: the previous render stays
|
|
170
|
+
* under the veil rather than collapsing to a skeleton.
|
|
171
|
+
*/
|
|
172
|
+
function deriveHighlightState({ status, hasData, filtered }) {
|
|
173
|
+
if (status === "error") return "error";
|
|
174
|
+
if (status === "loading") return hasData ? "refreshing" : "loading";
|
|
175
|
+
if (!hasData) return filtered ? "no-match" : "empty";
|
|
176
|
+
return "ready";
|
|
177
|
+
}
|
|
178
|
+
const isFinite = (value) => typeof value === "number" && Number.isFinite(value);
|
|
179
|
+
const DAY_MS = 864e5;
|
|
180
|
+
const ISO_DATE = /^\d{4}-\d{2}(-\d{2})?([T ].*)?$/;
|
|
181
|
+
function inferXType(points) {
|
|
182
|
+
const sample = points.find((point) => point.x != null);
|
|
183
|
+
if (!sample) return "ordinal";
|
|
184
|
+
const { x } = sample;
|
|
185
|
+
if (x instanceof Date) return "temporal";
|
|
186
|
+
if (typeof x === "number") return "quantitative";
|
|
187
|
+
return ISO_DATE.test(x) ? "temporal" : "ordinal";
|
|
188
|
+
}
|
|
189
|
+
function inferGranularity(rows) {
|
|
190
|
+
const xs = rows.map((row) => row.x).filter((x) => typeof x === "number");
|
|
191
|
+
if (xs.length < 2) return "day";
|
|
192
|
+
const gaps = [];
|
|
193
|
+
for (let index = 1; index < xs.length; index += 1) gaps.push(Math.abs(xs[index] - xs[index - 1]));
|
|
194
|
+
gaps.sort((a, b) => a - b);
|
|
195
|
+
const median = gaps[Math.floor(gaps.length / 2)];
|
|
196
|
+
if (median < DAY_MS) return "time";
|
|
197
|
+
if (median >= 360 * DAY_MS) return "year";
|
|
198
|
+
if (median >= 27 * DAY_MS) return "month";
|
|
199
|
+
return "day";
|
|
200
|
+
}
|
|
201
|
+
/**
|
|
202
|
+
* Normalises `series` for Vega and the tables: temporal `x` becomes epoch
|
|
203
|
+
* milliseconds (a bare number is quantitative — pass a `Date` or an ISO string
|
|
204
|
+
* for time), invalid dates are dropped, `null` readings stay as gaps, and the
|
|
205
|
+
* range is summarised for the text equivalents. Rows are sorted along the axis
|
|
206
|
+
* for a temporal or quantitative series, so a window can be snapped and peer
|
|
207
|
+
* windows can be walked; an ordinal series keeps the consumer's order, which
|
|
208
|
+
* IS its axis. Returns `null` without a series at all.
|
|
209
|
+
*/
|
|
210
|
+
function normalizeHighlightSeries(points, granularity) {
|
|
211
|
+
if (!points) return null;
|
|
212
|
+
const xType = inferXType(points);
|
|
213
|
+
const rows = [];
|
|
214
|
+
for (const point of points) {
|
|
215
|
+
const y = isFinite(point.y) ? point.y : null;
|
|
216
|
+
if (xType === "temporal") {
|
|
217
|
+
const time = point.x instanceof Date ? point.x.getTime() : new Date(point.x).getTime();
|
|
218
|
+
if (Number.isNaN(time)) continue;
|
|
219
|
+
rows.push({
|
|
220
|
+
x: time,
|
|
221
|
+
y
|
|
222
|
+
});
|
|
223
|
+
} else if (xType === "quantitative") {
|
|
224
|
+
if (!isFinite(point.x)) continue;
|
|
225
|
+
rows.push({
|
|
226
|
+
x: point.x,
|
|
227
|
+
y
|
|
228
|
+
});
|
|
229
|
+
} else rows.push({
|
|
230
|
+
x: String(point.x),
|
|
231
|
+
y
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
if (xType !== "ordinal") rows.sort((a, b) => a.x - b.x);
|
|
235
|
+
const valid = rows.filter((row) => row.y != null);
|
|
236
|
+
const values = valid.map((row) => row.y);
|
|
237
|
+
return {
|
|
238
|
+
rows,
|
|
239
|
+
xType,
|
|
240
|
+
count: valid.length,
|
|
241
|
+
first: rows[0] ?? null,
|
|
242
|
+
last: rows[rows.length - 1] ?? null,
|
|
243
|
+
latest: valid.length > 0 ? valid[valid.length - 1] : null,
|
|
244
|
+
min: values.length > 0 ? Math.min(...values) : null,
|
|
245
|
+
max: values.length > 0 ? Math.max(...values) : null,
|
|
246
|
+
granularity: granularity ?? (xType === "temporal" ? inferGranularity(rows) : "day")
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
/** The formatters every text surface shares — headline, badges, strip, bars, tables, tooltips, descriptions. */
|
|
250
|
+
function createHighlightFormatters({ format, locale, timeZone, formatValue, xType, granularity, deltaMode, percentagePoints }) {
|
|
251
|
+
const signed = { signDisplay: "exceptZero" };
|
|
252
|
+
const spec = parseArcNumberFormat(format);
|
|
253
|
+
const formatX = (x) => {
|
|
254
|
+
if (xType === "temporal" && typeof x === "number") return formatDate(x, {
|
|
255
|
+
granularity,
|
|
256
|
+
locale,
|
|
257
|
+
timeZone
|
|
258
|
+
});
|
|
259
|
+
if (xType === "quantitative" && typeof x === "number") return formatNumber(x, "number", locale);
|
|
260
|
+
return String(x);
|
|
261
|
+
};
|
|
262
|
+
const formatAmountChange = (delta) => spec.kind === "percent" ? `${formatNumber(delta * 100, `number:${spec.digits ?? 1}`, locale, signed)}${percentagePoints}` : formatNumber(delta, format, locale, signed);
|
|
263
|
+
return {
|
|
264
|
+
formatY: (value) => isFinite(value) ? formatValue ? formatValue(value) : formatNumber(value, format, locale) : "—",
|
|
265
|
+
formatX,
|
|
266
|
+
formatXRange: (start, end) => xType === "temporal" && typeof start === "number" && typeof end === "number" ? formatDateRange(start, end, {
|
|
267
|
+
granularity,
|
|
268
|
+
locale,
|
|
269
|
+
timeZone
|
|
270
|
+
}) : `${formatX(start)} – ${formatX(end)}`,
|
|
271
|
+
formatAmountChange,
|
|
272
|
+
formatPeerChange: (change) => deltaMode === "percent" ? formatNumber(change, "percent:1", locale, signed) : formatAmountChange(change),
|
|
273
|
+
formatShare: (share) => share == null ? "—" : formatNumber(share, "percent:0", locale)
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
/** The change between two readings, with polarity resolved through `goodDirection`. */
|
|
277
|
+
function deriveHighlightDelta(value, previousValue, { deltaMode, goodDirection, format, locale, percentagePoints }) {
|
|
278
|
+
if (!isFinite(value) || !isFinite(previousValue)) return null;
|
|
279
|
+
const options = {
|
|
280
|
+
format,
|
|
281
|
+
locale,
|
|
282
|
+
pointsSuffix: percentagePoints
|
|
283
|
+
};
|
|
284
|
+
const delta = formatDelta(value, previousValue, {
|
|
285
|
+
...options,
|
|
286
|
+
mode: deltaMode
|
|
287
|
+
});
|
|
288
|
+
const other = formatDelta(value, previousValue, {
|
|
289
|
+
...options,
|
|
290
|
+
mode: delta.renderedMode === "percent" ? "absolute" : "percent"
|
|
291
|
+
});
|
|
292
|
+
let polarity = "neutral";
|
|
293
|
+
if (delta.direction !== "flat" && goodDirection !== "none") polarity = delta.direction === "up" === (goodDirection === "up") ? "positive" : "negative";
|
|
294
|
+
return {
|
|
295
|
+
direction: delta.direction,
|
|
296
|
+
polarity,
|
|
297
|
+
text: delta.text,
|
|
298
|
+
renderedMode: delta.renderedMode,
|
|
299
|
+
secondaryText: other.renderedMode === delta.renderedMode ? null : other.text
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
const stripSign = (text) => text.replace(/^[+\-−]/, "");
|
|
303
|
+
function normalizeBound(value, xType) {
|
|
304
|
+
if (xType === "temporal") {
|
|
305
|
+
const time = value instanceof Date ? value.getTime() : new Date(value).getTime();
|
|
306
|
+
return Number.isNaN(time) ? null : time;
|
|
307
|
+
}
|
|
308
|
+
if (xType === "quantitative") {
|
|
309
|
+
const numeric = typeof value === "number" ? value : Number(value);
|
|
310
|
+
return Number.isFinite(numeric) ? numeric : null;
|
|
311
|
+
}
|
|
312
|
+
return String(value);
|
|
313
|
+
}
|
|
314
|
+
/**
|
|
315
|
+
* Snaps the window onto the series and measures it. Each bound moves inward to
|
|
316
|
+
* the nearest finite reading (a temporal or quantitative bound need not hit a
|
|
317
|
+
* reading exactly; an ordinal bound is a category label), the bounds may come
|
|
318
|
+
* in either order, and the result is `null` when the window does not hold two
|
|
319
|
+
* distinct finite readings — the panel then says so and stamps
|
|
320
|
+
* `data-window="unresolved"` rather than inventing a change.
|
|
321
|
+
*
|
|
322
|
+
* Beyond the change it records the window's ANATOMY, which is what makes this
|
|
323
|
+
* a highlight-analysis panel rather than a tile: every reading inside it, the
|
|
324
|
+
* step between each consecutive pair, the peak, the trough and the steepest
|
|
325
|
+
* single step.
|
|
326
|
+
*/
|
|
327
|
+
function resolveHighlightWindow(summary, window, { deltaMode, goodDirection, format, locale, percentagePoints, formatters }) {
|
|
328
|
+
if (!summary || !window) return null;
|
|
329
|
+
const { rows, xType } = summary;
|
|
330
|
+
const lo = normalizeBound(window.start, xType);
|
|
331
|
+
const hi = normalizeBound(window.end, xType);
|
|
332
|
+
if (lo == null || hi == null) return null;
|
|
333
|
+
let startIndex = -1;
|
|
334
|
+
let endIndex = -1;
|
|
335
|
+
if (xType === "ordinal") {
|
|
336
|
+
const first = rows.findIndex((row) => row.x === lo);
|
|
337
|
+
const second = rows.findIndex((row) => row.x === hi);
|
|
338
|
+
if (first < 0 || second < 0) return null;
|
|
339
|
+
const [from, to] = first <= second ? [first, second] : [second, first];
|
|
340
|
+
for (let index = from; index <= to; index += 1) if (rows[index].y != null) {
|
|
341
|
+
startIndex = index;
|
|
342
|
+
break;
|
|
343
|
+
}
|
|
344
|
+
for (let index = to; index >= from; index -= 1) if (rows[index].y != null) {
|
|
345
|
+
endIndex = index;
|
|
346
|
+
break;
|
|
347
|
+
}
|
|
348
|
+
} else {
|
|
349
|
+
const from = Math.min(lo, hi);
|
|
350
|
+
const to = Math.max(lo, hi);
|
|
351
|
+
for (let index = 0; index < rows.length; index += 1) {
|
|
352
|
+
const row = rows[index];
|
|
353
|
+
const at = row.x;
|
|
354
|
+
if (at < from || at > to || row.y == null) continue;
|
|
355
|
+
if (startIndex < 0) startIndex = index;
|
|
356
|
+
endIndex = index;
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
if (startIndex < 0 || endIndex < 0 || startIndex === endIndex) return null;
|
|
360
|
+
const startRow = rows[startIndex];
|
|
361
|
+
const endRow = rows[endIndex];
|
|
362
|
+
const start = {
|
|
363
|
+
x: startRow.x,
|
|
364
|
+
y: startRow.y,
|
|
365
|
+
index: startIndex
|
|
366
|
+
};
|
|
367
|
+
const end = {
|
|
368
|
+
x: endRow.x,
|
|
369
|
+
y: endRow.y,
|
|
370
|
+
index: endIndex
|
|
371
|
+
};
|
|
372
|
+
const delta = deriveHighlightDelta(end.y, start.y, {
|
|
373
|
+
deltaMode,
|
|
374
|
+
goodDirection,
|
|
375
|
+
format,
|
|
376
|
+
locale,
|
|
377
|
+
percentagePoints
|
|
378
|
+
});
|
|
379
|
+
if (!delta) return null;
|
|
380
|
+
const absoluteDelta = formatDelta(end.y, start.y, {
|
|
381
|
+
mode: "absolute",
|
|
382
|
+
format,
|
|
383
|
+
locale,
|
|
384
|
+
pointsSuffix: percentagePoints
|
|
385
|
+
});
|
|
386
|
+
const percentDelta = formatDelta(end.y, start.y, {
|
|
387
|
+
mode: "percent",
|
|
388
|
+
format,
|
|
389
|
+
locale,
|
|
390
|
+
pointsSuffix: percentagePoints
|
|
391
|
+
});
|
|
392
|
+
const readings = [];
|
|
393
|
+
for (let index = startIndex; index <= endIndex; index += 1) {
|
|
394
|
+
const row = rows[index];
|
|
395
|
+
if (row.y == null) continue;
|
|
396
|
+
readings.push({
|
|
397
|
+
x: row.x,
|
|
398
|
+
y: row.y,
|
|
399
|
+
index
|
|
400
|
+
});
|
|
401
|
+
}
|
|
402
|
+
const legs = [];
|
|
403
|
+
for (let at = 1; at < readings.length; at += 1) {
|
|
404
|
+
const from = readings[at - 1];
|
|
405
|
+
const to = readings[at];
|
|
406
|
+
const step = deriveHighlightDelta(to.y, from.y, {
|
|
407
|
+
deltaMode,
|
|
408
|
+
goodDirection,
|
|
409
|
+
format,
|
|
410
|
+
locale,
|
|
411
|
+
percentagePoints
|
|
412
|
+
});
|
|
413
|
+
if (!step) continue;
|
|
414
|
+
legs.push({
|
|
415
|
+
from,
|
|
416
|
+
to,
|
|
417
|
+
change: to.y - from.y,
|
|
418
|
+
changeText: step.text,
|
|
419
|
+
rangeText: formatters.formatXRange(from.x, to.x),
|
|
420
|
+
direction: step.direction,
|
|
421
|
+
polarity: step.polarity
|
|
422
|
+
});
|
|
423
|
+
}
|
|
424
|
+
let peak = null;
|
|
425
|
+
let trough = null;
|
|
426
|
+
for (const reading of readings) {
|
|
427
|
+
if (!peak || reading.y > peak.y) peak = reading;
|
|
428
|
+
if (!trough || reading.y < trough.y) trough = reading;
|
|
429
|
+
}
|
|
430
|
+
if (peak && trough && peak.y === trough.y) {
|
|
431
|
+
peak = null;
|
|
432
|
+
trough = null;
|
|
433
|
+
}
|
|
434
|
+
if (peak && (peak.index === startIndex || peak.index === endIndex)) peak = null;
|
|
435
|
+
if (trough && (trough.index === startIndex || trough.index === endIndex)) trough = null;
|
|
436
|
+
const steepest = legs.reduce((best, leg) => best == null || Math.abs(leg.change) > Math.abs(best.change) ? leg : best, null);
|
|
437
|
+
return {
|
|
438
|
+
start,
|
|
439
|
+
end,
|
|
440
|
+
label: window.label,
|
|
441
|
+
rangeText: formatters.formatXRange(start.x, end.x),
|
|
442
|
+
delta,
|
|
443
|
+
absolute: end.y - start.y,
|
|
444
|
+
absoluteText: absoluteDelta.text,
|
|
445
|
+
percentText: percentDelta.renderedMode === "percent" ? percentDelta.text : "—",
|
|
446
|
+
readings,
|
|
447
|
+
legs,
|
|
448
|
+
length: endIndex - startIndex,
|
|
449
|
+
peak,
|
|
450
|
+
trough,
|
|
451
|
+
steepest: steepest && steepest.change !== 0 ? steepest : null
|
|
452
|
+
};
|
|
453
|
+
}
|
|
454
|
+
function quantile(sorted, fraction) {
|
|
455
|
+
if (sorted.length === 0) return 0;
|
|
456
|
+
if (sorted.length === 1) return sorted[0];
|
|
457
|
+
const position = (sorted.length - 1) * fraction;
|
|
458
|
+
const low = Math.floor(position);
|
|
459
|
+
const high = Math.ceil(position);
|
|
460
|
+
if (low === high) return sorted[low];
|
|
461
|
+
return sorted[low] + (sorted[high] - sorted[low]) * (position - low);
|
|
462
|
+
}
|
|
463
|
+
function severityFrom(distance, { notable, unusual, extreme }) {
|
|
464
|
+
if (distance > extreme) return "extreme";
|
|
465
|
+
if (distance > unusual) return "unusual";
|
|
466
|
+
if (distance > notable) return "notable";
|
|
467
|
+
return "typical";
|
|
468
|
+
}
|
|
469
|
+
/**
|
|
470
|
+
* Places the window's change among the changes of every comparable window in
|
|
471
|
+
* the same series — the reading that ESTABLISHES the highlight, and the half
|
|
472
|
+
* MetricCard's tile cannot show. Windows are the same length as the
|
|
473
|
+
* highlight's (in rows), taken at every offset (`rolling`) or tiled
|
|
474
|
+
* non-overlapping from the highlight's own start (`adjacent`), and both bounds
|
|
475
|
+
* must carry a finite reading; a window that cannot produce a change in the
|
|
476
|
+
* compared mode is skipped and counted in `skipped`.
|
|
477
|
+
*
|
|
478
|
+
* Returns `null` when fewer than `minPeers` comparable windows exist — the
|
|
479
|
+
* panel then omits the section and says why, rather than calling one window
|
|
480
|
+
* out of three "extreme".
|
|
481
|
+
*
|
|
482
|
+
* The spread is a COMPARISON, not a significance test: `rolling` windows
|
|
483
|
+
* overlap, so their changes are autocorrelated and the standard deviation is
|
|
484
|
+
* narrower than independent sampling would give. `adjacent` is the honest set.
|
|
485
|
+
* The generated text says "the 2nd largest of 48 comparable periods"; nothing
|
|
486
|
+
* here claims a p-value.
|
|
487
|
+
*/
|
|
488
|
+
function derivePeers(summary, info, { mode, baseline, deltaMode, goodDirection, format, locale, percentagePoints, formatters, minPeers = 4, thresholds }) {
|
|
489
|
+
if (!summary || !info) return null;
|
|
490
|
+
const { rows } = summary;
|
|
491
|
+
const lag = info.length;
|
|
492
|
+
if (lag < 1) return null;
|
|
493
|
+
const limits = thresholds ?? (baseline === "sigma" ? DEFAULT_SIGMA_THRESHOLDS : DEFAULT_PERCENTILE_THRESHOLDS);
|
|
494
|
+
const offsets = [];
|
|
495
|
+
if (mode === "adjacent") for (let index = info.start.index % lag; index + lag < rows.length; index += lag) offsets.push(index);
|
|
496
|
+
else for (let index = 0; index + lag < rows.length; index += 1) offsets.push(index);
|
|
497
|
+
const peers = [];
|
|
498
|
+
let skipped = 0;
|
|
499
|
+
let highlightChange = null;
|
|
500
|
+
for (const index of offsets) {
|
|
501
|
+
const from = rows[index];
|
|
502
|
+
const to = rows[index + lag];
|
|
503
|
+
if (from.y == null || to.y == null) {
|
|
504
|
+
skipped += 1;
|
|
505
|
+
continue;
|
|
506
|
+
}
|
|
507
|
+
const delta = formatDelta(to.y, from.y, {
|
|
508
|
+
mode: deltaMode,
|
|
509
|
+
format,
|
|
510
|
+
locale,
|
|
511
|
+
pointsSuffix: percentagePoints
|
|
512
|
+
});
|
|
513
|
+
if (deltaMode === "percent" && delta.renderedMode !== "percent") {
|
|
514
|
+
skipped += 1;
|
|
515
|
+
continue;
|
|
516
|
+
}
|
|
517
|
+
const change = deltaMode === "percent" ? delta.percent : delta.absolute;
|
|
518
|
+
const isHighlight = index === info.start.index;
|
|
519
|
+
if (isHighlight) highlightChange = change;
|
|
520
|
+
let polarity = "neutral";
|
|
521
|
+
if (delta.direction !== "flat" && goodDirection !== "none") polarity = delta.direction === "up" === (goodDirection === "up") ? "positive" : "negative";
|
|
522
|
+
peers.push({
|
|
523
|
+
start: from.x,
|
|
524
|
+
end: to.x,
|
|
525
|
+
change,
|
|
526
|
+
changeText: formatters.formatPeerChange(change),
|
|
527
|
+
rangeText: formatters.formatXRange(from.x, to.x),
|
|
528
|
+
isHighlight,
|
|
529
|
+
polarity
|
|
530
|
+
});
|
|
531
|
+
}
|
|
532
|
+
if (highlightChange == null) return null;
|
|
533
|
+
const others = peers.filter((peer) => !peer.isHighlight).map((peer) => peer.change);
|
|
534
|
+
if (others.length < minPeers) return null;
|
|
535
|
+
const mean = others.reduce((sum, value) => sum + value, 0) / others.length;
|
|
536
|
+
const variance = others.reduce((sum, value) => sum + (value - mean) ** 2, 0) / others.length;
|
|
537
|
+
const sd = Math.sqrt(variance);
|
|
538
|
+
const z = sd > 0 ? (highlightChange - mean) / sd : null;
|
|
539
|
+
const sorted = [...others].sort((a, b) => a - b);
|
|
540
|
+
const percentile = sorted.filter((value) => value <= highlightChange).length / sorted.length * 100;
|
|
541
|
+
const [typicalLow, typicalHigh] = baseline === "sigma" ? [mean - sd, mean + sd] : [quantile(sorted, .1), quantile(sorted, .9)];
|
|
542
|
+
const typical = highlightChange >= typicalLow && highlightChange <= typicalHigh;
|
|
543
|
+
const rank = [...peers].sort((a, b) => Math.abs(b.change) - Math.abs(a.change)).findIndex((peer) => peer.isHighlight) + 1;
|
|
544
|
+
const severity = baseline === "sigma" ? z == null ? highlightChange === mean ? "typical" : "extreme" : severityFrom(Math.abs(z), limits) : severityFrom(Math.abs(percentile - 50), limits);
|
|
545
|
+
const ordinal = (value) => {
|
|
546
|
+
const tens = value % 100;
|
|
547
|
+
if (tens >= 11 && tens <= 13) return `${value}th`;
|
|
548
|
+
switch (value % 10) {
|
|
549
|
+
case 1: return `${value}st`;
|
|
550
|
+
case 2: return `${value}nd`;
|
|
551
|
+
case 3: return `${value}rd`;
|
|
552
|
+
default: return `${value}th`;
|
|
553
|
+
}
|
|
554
|
+
};
|
|
555
|
+
return {
|
|
556
|
+
mode,
|
|
557
|
+
baseline,
|
|
558
|
+
peers,
|
|
559
|
+
count: others.length,
|
|
560
|
+
total: others.length + 1,
|
|
561
|
+
skipped,
|
|
562
|
+
length: lag,
|
|
563
|
+
change: highlightChange,
|
|
564
|
+
changeText: formatters.formatPeerChange(highlightChange),
|
|
565
|
+
mean,
|
|
566
|
+
sd,
|
|
567
|
+
meanText: formatters.formatPeerChange(mean),
|
|
568
|
+
sdText: formatters.formatPeerChange(sd),
|
|
569
|
+
z,
|
|
570
|
+
zText: z == null ? "—" : `${formatNumber(z, "number:1", locale, { signDisplay: "exceptZero" })} sd`,
|
|
571
|
+
percentile,
|
|
572
|
+
percentileText: formatNumber(percentile / 100, "percent:0", locale),
|
|
573
|
+
rank,
|
|
574
|
+
rankText: ordinal(rank),
|
|
575
|
+
typicalLow,
|
|
576
|
+
typicalHigh,
|
|
577
|
+
typicalText: `${formatters.formatPeerChange(typicalLow)} to ${formatters.formatPeerChange(typicalHigh)}`,
|
|
578
|
+
typical,
|
|
579
|
+
severity
|
|
580
|
+
};
|
|
581
|
+
}
|
|
582
|
+
/**
|
|
583
|
+
* Decomposes the window's change into its contributors — the half that
|
|
584
|
+
* EXPLAINS the highlight. Each driver's contribution is the amount it added to
|
|
585
|
+
* the total, either passed directly or derived from its own `from`/`to`
|
|
586
|
+
* readings; rows sort by absolute contribution so the biggest mover reads
|
|
587
|
+
* first, the tail folds into `Other`, and whatever the parts do not account
|
|
588
|
+
* for becomes an explicit `Unexplained` row.
|
|
589
|
+
*
|
|
590
|
+
* Totals reconcile with parts or the panel says they do not: a decomposition
|
|
591
|
+
* that silently misses a third of the move is worse than none.
|
|
592
|
+
*/
|
|
593
|
+
function normalizeDrivers(drivers, info, { goodDirection, formatters, limit, otherLabel, residualLabel, tolerance = .005 }) {
|
|
594
|
+
if (!drivers || drivers.length === 0 || !info) return null;
|
|
595
|
+
const total = info.absolute;
|
|
596
|
+
const polarityOf = (contribution) => {
|
|
597
|
+
if (contribution === 0 || goodDirection === "none") return "neutral";
|
|
598
|
+
return contribution > 0 === (goodDirection === "up") ? "positive" : "negative";
|
|
599
|
+
};
|
|
600
|
+
const seen = /* @__PURE__ */ new Map();
|
|
601
|
+
const parsed = drivers.flatMap((driver) => {
|
|
602
|
+
const from = isFinite(driver.from) ? driver.from : null;
|
|
603
|
+
const to = isFinite(driver.to) ? driver.to : null;
|
|
604
|
+
const contribution = isFinite(driver.contribution) ? driver.contribution : from != null && to != null ? to - from : null;
|
|
605
|
+
if (contribution == null) return [];
|
|
606
|
+
const base = driver.key ?? driver.label;
|
|
607
|
+
const repeats = seen.get(base) ?? 0;
|
|
608
|
+
seen.set(base, repeats + 1);
|
|
609
|
+
const share = total === 0 ? null : contribution / total;
|
|
610
|
+
return [{
|
|
611
|
+
key: repeats === 0 ? base : `${base}·${repeats + 1}`,
|
|
612
|
+
label: driver.label,
|
|
613
|
+
contribution,
|
|
614
|
+
contributionText: formatters.formatAmountChange(contribution),
|
|
615
|
+
share,
|
|
616
|
+
shareText: formatters.formatShare(share),
|
|
617
|
+
from,
|
|
618
|
+
to,
|
|
619
|
+
fromText: formatters.formatY(from),
|
|
620
|
+
toText: formatters.formatY(to),
|
|
621
|
+
direction: contribution > 0 ? "up" : contribution < 0 ? "down" : "flat",
|
|
622
|
+
polarity: polarityOf(contribution),
|
|
623
|
+
role: "driver"
|
|
624
|
+
}];
|
|
625
|
+
});
|
|
626
|
+
if (parsed.length === 0) return null;
|
|
627
|
+
const explained = parsed.reduce((sum, row) => sum + row.contribution, 0);
|
|
628
|
+
const residual = total - explained;
|
|
629
|
+
const ordered = [...parsed].sort((a, b) => Math.abs(b.contribution) - Math.abs(a.contribution));
|
|
630
|
+
const rows = [];
|
|
631
|
+
let folded = 0;
|
|
632
|
+
if (limit != null && limit > 0 && ordered.length > limit) {
|
|
633
|
+
rows.push(...ordered.slice(0, limit));
|
|
634
|
+
const tail = ordered.slice(limit);
|
|
635
|
+
folded = tail.length;
|
|
636
|
+
const contribution = tail.reduce((sum, row) => sum + row.contribution, 0);
|
|
637
|
+
const share = total === 0 ? null : contribution / total;
|
|
638
|
+
rows.push({
|
|
639
|
+
key: "__other__",
|
|
640
|
+
label: otherLabel,
|
|
641
|
+
contribution,
|
|
642
|
+
contributionText: formatters.formatAmountChange(contribution),
|
|
643
|
+
share,
|
|
644
|
+
shareText: formatters.formatShare(share),
|
|
645
|
+
from: null,
|
|
646
|
+
to: null,
|
|
647
|
+
fromText: "—",
|
|
648
|
+
toText: "—",
|
|
649
|
+
direction: contribution > 0 ? "up" : contribution < 0 ? "down" : "flat",
|
|
650
|
+
polarity: polarityOf(contribution),
|
|
651
|
+
role: "other"
|
|
652
|
+
});
|
|
653
|
+
} else rows.push(...ordered);
|
|
654
|
+
const reconciles = total === 0 ? Math.abs(residual) === 0 : Math.abs(residual / total) <= tolerance;
|
|
655
|
+
if (!reconciles) {
|
|
656
|
+
const share = total === 0 ? null : residual / total;
|
|
657
|
+
rows.push({
|
|
658
|
+
key: "__residual__",
|
|
659
|
+
label: residualLabel,
|
|
660
|
+
contribution: residual,
|
|
661
|
+
contributionText: formatters.formatAmountChange(residual),
|
|
662
|
+
share,
|
|
663
|
+
shareText: formatters.formatShare(share),
|
|
664
|
+
from: null,
|
|
665
|
+
to: null,
|
|
666
|
+
fromText: "—",
|
|
667
|
+
toText: "—",
|
|
668
|
+
direction: residual > 0 ? "up" : residual < 0 ? "down" : "flat",
|
|
669
|
+
polarity: polarityOf(residual),
|
|
670
|
+
role: "residual"
|
|
671
|
+
});
|
|
672
|
+
}
|
|
673
|
+
const withTotal = total === 0 ? 1 : Math.sign(total);
|
|
674
|
+
const contributors = rows.filter((row) => row.role !== "residual");
|
|
675
|
+
const top = contributors.filter((row) => Math.sign(row.contribution) === withTotal).reduce((best, row) => best == null || Math.abs(row.contribution) > Math.abs(best.contribution) ? row : best, null) ?? null;
|
|
676
|
+
const counter = contributors.filter((row) => Math.sign(row.contribution) === -withTotal).reduce((best, row) => best == null || Math.abs(row.contribution) > Math.abs(best.contribution) ? row : best, null) ?? null;
|
|
677
|
+
return {
|
|
678
|
+
rows,
|
|
679
|
+
total,
|
|
680
|
+
totalText: formatters.formatAmountChange(total),
|
|
681
|
+
explained,
|
|
682
|
+
explainedText: formatters.formatAmountChange(explained),
|
|
683
|
+
residual,
|
|
684
|
+
residualText: formatters.formatAmountChange(residual),
|
|
685
|
+
reconciles,
|
|
686
|
+
folded,
|
|
687
|
+
top,
|
|
688
|
+
counter
|
|
689
|
+
};
|
|
690
|
+
}
|
|
691
|
+
/**
|
|
692
|
+
* Splits the series around the window for the context plot: every row carries
|
|
693
|
+
* `hl` and the window's `tone`, the readings outside it are split into a
|
|
694
|
+
* `before` and an `after` run so the gray path never runs under the coloured
|
|
695
|
+
* one, and the window's extent rides one band row.
|
|
696
|
+
*/
|
|
697
|
+
function buildHighlightContextDatasets(summary, info) {
|
|
698
|
+
const tone = info.delta.polarity;
|
|
699
|
+
const table = [];
|
|
700
|
+
const outside = [];
|
|
701
|
+
const inside = [];
|
|
702
|
+
for (let index = 0; index < summary.rows.length; index += 1) {
|
|
703
|
+
const row = summary.rows[index];
|
|
704
|
+
const hl = index >= info.start.index && index <= info.end.index;
|
|
705
|
+
const entry = {
|
|
706
|
+
...row,
|
|
707
|
+
hl,
|
|
708
|
+
tone: hl ? tone : null
|
|
709
|
+
};
|
|
710
|
+
table.push(entry);
|
|
711
|
+
if (hl) inside.push(entry);
|
|
712
|
+
if (!hl || index === info.start.index || index === info.end.index) outside.push({
|
|
713
|
+
...row,
|
|
714
|
+
seg: index <= info.start.index ? "before" : "after"
|
|
715
|
+
});
|
|
716
|
+
}
|
|
717
|
+
return {
|
|
718
|
+
table,
|
|
719
|
+
outside,
|
|
720
|
+
inside,
|
|
721
|
+
endpoints: [{
|
|
722
|
+
x: info.start.x,
|
|
723
|
+
y: info.start.y,
|
|
724
|
+
role: "start",
|
|
725
|
+
tone
|
|
726
|
+
}, {
|
|
727
|
+
x: info.end.x,
|
|
728
|
+
y: info.end.y,
|
|
729
|
+
role: "end",
|
|
730
|
+
tone
|
|
731
|
+
}],
|
|
732
|
+
band: [{
|
|
733
|
+
start: info.start.x,
|
|
734
|
+
end: info.end.x,
|
|
735
|
+
tone
|
|
736
|
+
}]
|
|
737
|
+
};
|
|
738
|
+
}
|
|
739
|
+
/** One row `{ low, high }` — the typical-range band behind the strip. */
|
|
740
|
+
function buildTypicalRows(stats) {
|
|
741
|
+
return [{
|
|
742
|
+
low: stats.typicalLow,
|
|
743
|
+
high: stats.typicalHigh
|
|
744
|
+
}];
|
|
745
|
+
}
|
|
746
|
+
/** The driver bars, in row order, with the fields the spec encodes. */
|
|
747
|
+
function buildDriverRows(info) {
|
|
748
|
+
return info.rows.map((row) => ({
|
|
749
|
+
key: row.key,
|
|
750
|
+
label: row.label,
|
|
751
|
+
value: row.contribution,
|
|
752
|
+
share: row.share,
|
|
753
|
+
tone: row.polarity,
|
|
754
|
+
role: row.role
|
|
755
|
+
}));
|
|
756
|
+
}
|
|
757
|
+
function highlightSegmentsToText(segments) {
|
|
758
|
+
return segments.map((segment) => typeof segment === "string" ? segment : segment.value).join("");
|
|
759
|
+
}
|
|
760
|
+
/** The takeaway: what moved, how it places among its peers, which driver carried it. */
|
|
761
|
+
function buildHighlightAnalysisInsight({ name, info, peers, drivers, labels }) {
|
|
762
|
+
const { direction } = info.delta;
|
|
763
|
+
return labels.insight({
|
|
764
|
+
name,
|
|
765
|
+
direction,
|
|
766
|
+
change: direction === "flat" ? "" : stripSign(info.delta.text),
|
|
767
|
+
secondary: info.delta.secondaryText ? stripSign(info.delta.secondaryText) : null,
|
|
768
|
+
range: info.rangeText,
|
|
769
|
+
label: info.label,
|
|
770
|
+
severity: peers?.severity ?? "typical",
|
|
771
|
+
rank: peers?.rankText ?? null,
|
|
772
|
+
count: peers?.count ?? null,
|
|
773
|
+
topDriver: drivers?.top?.label ?? null,
|
|
774
|
+
topContribution: drivers?.top ? stripSign(drivers.top.contributionText) : null,
|
|
775
|
+
topShare: drivers?.top?.share != null ? drivers.top.shareText : null
|
|
776
|
+
});
|
|
777
|
+
}
|
|
778
|
+
/**
|
|
779
|
+
* The panel's text equivalent (Amy Cesal's alt-text formula: what it is, what
|
|
780
|
+
* it shows, why it is here, when it was true) — the figure's accessible
|
|
781
|
+
* description, and the summary a screen reader gets before the parts.
|
|
782
|
+
*/
|
|
783
|
+
function buildHighlightDescription({ name, state, info, peers, drivers, formatters, asOfText, labels }) {
|
|
784
|
+
if (!info || state !== "ready") return labels.cardDescription({
|
|
785
|
+
name,
|
|
786
|
+
state,
|
|
787
|
+
range: "",
|
|
788
|
+
direction: "flat",
|
|
789
|
+
change: "",
|
|
790
|
+
secondary: null,
|
|
791
|
+
startText: "",
|
|
792
|
+
endText: "",
|
|
793
|
+
severity: null,
|
|
794
|
+
rank: null,
|
|
795
|
+
count: null,
|
|
796
|
+
driverCount: 0,
|
|
797
|
+
topDriver: null,
|
|
798
|
+
topContribution: null,
|
|
799
|
+
asOf: asOfText ?? null
|
|
800
|
+
});
|
|
801
|
+
return labels.cardDescription({
|
|
802
|
+
name,
|
|
803
|
+
state,
|
|
804
|
+
range: info.rangeText,
|
|
805
|
+
label: info.label,
|
|
806
|
+
direction: info.delta.direction,
|
|
807
|
+
change: info.delta.direction === "flat" ? "" : stripSign(info.delta.text),
|
|
808
|
+
secondary: info.delta.secondaryText ? stripSign(info.delta.secondaryText) : null,
|
|
809
|
+
startText: formatters.formatY(info.start.y),
|
|
810
|
+
endText: formatters.formatY(info.end.y),
|
|
811
|
+
severity: peers ? labels.severityNames[peers.severity].toLowerCase() : null,
|
|
812
|
+
rank: peers?.rankText ?? null,
|
|
813
|
+
count: peers?.count ?? null,
|
|
814
|
+
driverCount: drivers?.rows.length ?? 0,
|
|
815
|
+
topDriver: drivers?.top?.label ?? null,
|
|
816
|
+
topContribution: drivers?.top ? stripSign(drivers.top.contributionText) : null,
|
|
817
|
+
asOf: asOfText ?? null
|
|
818
|
+
});
|
|
819
|
+
}
|
|
820
|
+
/** Accessible description of the context plot — set live through `view.description`. */
|
|
821
|
+
function buildContextDescription({ name, mark, summary, info, formatters, labels }) {
|
|
822
|
+
return labels.contextDescription({
|
|
823
|
+
mark: labels.markNames[mark],
|
|
824
|
+
name,
|
|
825
|
+
count: summary.count,
|
|
826
|
+
first: summary.first ? formatters.formatX(summary.first.x) : "—",
|
|
827
|
+
last: summary.last ? formatters.formatX(summary.last.x) : "—",
|
|
828
|
+
min: formatters.formatY(summary.min),
|
|
829
|
+
max: formatters.formatY(summary.max),
|
|
830
|
+
range: info.rangeText,
|
|
831
|
+
label: info.label,
|
|
832
|
+
direction: info.delta.direction,
|
|
833
|
+
change: stripSign(info.delta.text)
|
|
834
|
+
});
|
|
835
|
+
}
|
|
836
|
+
/** Accessible description of the peer strip. */
|
|
837
|
+
function buildPeersDescription({ name, stats, labels }) {
|
|
838
|
+
return labels.peersDescription({
|
|
839
|
+
name,
|
|
840
|
+
count: stats.count,
|
|
841
|
+
readings: stats.length + 1,
|
|
842
|
+
mode: stats.mode,
|
|
843
|
+
change: stats.changeText,
|
|
844
|
+
rank: stats.rankText,
|
|
845
|
+
typical: stats.typicalText,
|
|
846
|
+
severity: labels.severityNames[stats.severity].toLowerCase()
|
|
847
|
+
});
|
|
848
|
+
}
|
|
849
|
+
/** Accessible description of the drivers bars. */
|
|
850
|
+
function buildDriversDescription({ name, info, labels }) {
|
|
851
|
+
return labels.driversDescription({
|
|
852
|
+
name,
|
|
853
|
+
count: info.rows.filter((row) => row.role !== "residual").length,
|
|
854
|
+
total: info.totalText,
|
|
855
|
+
top: info.top?.label ?? null,
|
|
856
|
+
topContribution: info.top?.contributionText ?? null,
|
|
857
|
+
counter: info.counter?.label ?? null,
|
|
858
|
+
counterContribution: info.counter?.contributionText ?? null,
|
|
859
|
+
residual: info.reconciles ? null : info.residualText
|
|
860
|
+
});
|
|
861
|
+
}
|
|
862
|
+
/** The verdict sentence under the strip — visible text, never colour alone. */
|
|
863
|
+
function buildPeerVerdict(stats, labels) {
|
|
864
|
+
return labels.peerVerdict({
|
|
865
|
+
severity: stats.severity,
|
|
866
|
+
rank: stats.rankText,
|
|
867
|
+
total: stats.total,
|
|
868
|
+
readings: stats.length + 1,
|
|
869
|
+
typical: stats.typicalText,
|
|
870
|
+
change: stats.changeText,
|
|
871
|
+
inside: stats.typical
|
|
872
|
+
});
|
|
873
|
+
}
|
|
874
|
+
//#endregion
|
|
875
|
+
export { DEFAULT_HIGHLIGHT_LABELS, DEFAULT_PERCENTILE_THRESHOLDS, DEFAULT_SIGMA_THRESHOLDS, buildContextDescription, buildDriverRows, buildDriversDescription, buildHighlightAnalysisInsight, buildHighlightContextDatasets, buildHighlightDescription, buildPeerVerdict, buildPeersDescription, buildTypicalRows, createHighlightFormatters, deriveHighlightDelta, deriveHighlightState, derivePeers, highlightSegmentsToText, normalizeDrivers, normalizeHighlightSeries, resolveHighlightLabels, resolveHighlightWindow };
|