@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,789 @@
|
|
|
1
|
+
import { formatNumber } from "../../lib/vega/formatters.js";
|
|
2
|
+
//#region src/components/SankeyCard/SankeyCard.model.ts
|
|
3
|
+
/**
|
|
4
|
+
* The theme ships three categorical identity slots (`chart-1…3`), because
|
|
5
|
+
* three of the brand's six hues are reserved for status. An identity encoding
|
|
6
|
+
* past that folds its tail to the context gray instead of repeating a slot.
|
|
7
|
+
*/
|
|
8
|
+
const SANKEY_MAX_IDENTITY = 3;
|
|
9
|
+
/** Below two columns there is no flow to draw — one column is a bar chart, and the card says so instead of drawing a sankey of nothing. */
|
|
10
|
+
const SANKEY_MIN_STAGES = 2;
|
|
11
|
+
/**
|
|
12
|
+
* Below two flows there is nothing to compare. A diagram of ONE ribbon is a
|
|
13
|
+
* picture of a single number: it has no sibling to weigh it against, no split
|
|
14
|
+
* to read a share at, and no chain — every reason this card exists is
|
|
15
|
+
* missing. The reader is better served by a number, so the card declines and
|
|
16
|
+
* says so rather than drawing a decoration.
|
|
17
|
+
*/
|
|
18
|
+
const SANKEY_MIN_FLOWS = 2;
|
|
19
|
+
/** Points sampled along each ribbon edge. Fixed, so a Chromatic baseline never drifts on it. */
|
|
20
|
+
const RIBBON_SAMPLES = 20;
|
|
21
|
+
/** Below this many pixels a ribbon is not a shape a reader can see, whatever it means. */
|
|
22
|
+
const HAIRLINE_PX = 1.5;
|
|
23
|
+
const DEFAULT_SANKEY_LABELS = {
|
|
24
|
+
noData: "No data",
|
|
25
|
+
noMatch: "No matching data",
|
|
26
|
+
clearFilters: "Clear filters",
|
|
27
|
+
error: "Couldn't load this flow",
|
|
28
|
+
retry: "Try again",
|
|
29
|
+
stale: "Stale",
|
|
30
|
+
asOf: "As of",
|
|
31
|
+
insufficient: "Not enough flows to compare",
|
|
32
|
+
tableFrom: "From",
|
|
33
|
+
tableTo: "To",
|
|
34
|
+
tableValue: "Volume",
|
|
35
|
+
tableShare: "Share",
|
|
36
|
+
tableNode: "Node",
|
|
37
|
+
tableStage: "Stage",
|
|
38
|
+
tableIn: "In",
|
|
39
|
+
tableOut: "Out",
|
|
40
|
+
tableUnaccounted: "Unaccounted",
|
|
41
|
+
other: "Other",
|
|
42
|
+
otherMembers: (list) => `Includes ${list}`,
|
|
43
|
+
flowTitle: "Volume",
|
|
44
|
+
shareTitle: "Share of source",
|
|
45
|
+
selectedNode: (label) => `${label} selected. Its paths are highlighted.`,
|
|
46
|
+
clearSelection: "Clear selection",
|
|
47
|
+
selectNode: (label) => `Highlight the paths through ${label}`,
|
|
48
|
+
showTable: (name) => `Show the data behind ${name}`,
|
|
49
|
+
hideTable: (name) => `Hide the data behind ${name}`,
|
|
50
|
+
about: (name) => `About ${name}`,
|
|
51
|
+
loading: (name) => `Loading ${name}`,
|
|
52
|
+
tableCaption: (name) => `Data behind the ${name} flow`,
|
|
53
|
+
flowsCaption: (name) => `Every flow in ${name}`,
|
|
54
|
+
nodesCaption: (name) => `Every node in ${name}`,
|
|
55
|
+
cyclesNote: (count) => `${count} ${count === 1 ? "flow was" : "flows were"} left out to keep the diagram readable: a sankey cannot show a loop back to an earlier stage.`,
|
|
56
|
+
imbalanceNote: (count, amount) => `${amount} arrived at ${count} ${count === 1 ? "node" : "nodes"} without leaving through any listed flow.`,
|
|
57
|
+
foldedNote: (label) => `The smallest nodes in a stage are grouped as ${label}.`,
|
|
58
|
+
hairlineNote: (count) => `${count} ${count === 1 ? "flow is" : "flows are"} too thin to see at this scale — the proportions are exact, so read those values in the table.`,
|
|
59
|
+
overIdentityNote: (max) => `Only the ${max} largest get their own colour; the rest are gray.`,
|
|
60
|
+
rejectedNote: (count) => `${count} ${count === 1 ? "flow" : "flows"} could not be shown.`,
|
|
61
|
+
flowSentence: ({ value, total, share, from, to }) => `${value} of ${total} left ${from} for ${to} (${share}).`,
|
|
62
|
+
lossSentence: ({ value, share, to }) => `The largest loss was ${value} to ${to} (${share}).`,
|
|
63
|
+
stageSummary: (count, list) => `${count} ${count === 1 ? "stage" : "stages"}: ${list}.`,
|
|
64
|
+
chartDescription: ({ name, stages, nodes, flows, total, stageList }) => `Sankey flow diagram of ${name}: ${total} across ${stages} ${stages === 1 ? "stage" : "stages"}, ${nodes} ${nodes === 1 ? "node" : "nodes"} and ${flows} ${flows === 1 ? "flow" : "flows"} — ${stageList}.`,
|
|
65
|
+
insightMark: "Insight",
|
|
66
|
+
insight: ({ total, from, to, value, share, lossTo, lossValue, lossShare }) => {
|
|
67
|
+
const segments = [
|
|
68
|
+
"Of ",
|
|
69
|
+
{ value: total },
|
|
70
|
+
` leaving ${from}, `,
|
|
71
|
+
{ value },
|
|
72
|
+
` (`,
|
|
73
|
+
{ value: share },
|
|
74
|
+
`) went to ${to}`
|
|
75
|
+
];
|
|
76
|
+
if (lossTo && lossValue && lossShare) segments.push(`, and the largest loss was `, { value: lossValue }, ` (`, { value: lossShare }, `) to ${lossTo}`);
|
|
77
|
+
segments.push(".");
|
|
78
|
+
return segments;
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
function resolveSankeyLabels(overrides) {
|
|
82
|
+
return overrides ? {
|
|
83
|
+
...DEFAULT_SANKEY_LABELS,
|
|
84
|
+
...overrides
|
|
85
|
+
} : DEFAULT_SANKEY_LABELS;
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* The state machine. Error wins; loading over existing data is a refresh (the
|
|
89
|
+
* previous render stays under the veil); no flow at all is `no-match` when the
|
|
90
|
+
* consumer's filter caused it and `empty` otherwise.
|
|
91
|
+
*
|
|
92
|
+
* `insufficient` is the honest refusal: a single flow is a picture of one
|
|
93
|
+
* number, with no sibling to compare it against and no chain to follow, so
|
|
94
|
+
* the card declines to draw a sankey of it. The column floor is checked
|
|
95
|
+
* alongside it — a valid flow always spans two columns, so that half is a
|
|
96
|
+
* guard rather than a case, and it keeps the rule true if the depth
|
|
97
|
+
* assignment ever changes.
|
|
98
|
+
*/
|
|
99
|
+
function deriveSankeyState({ status, hasFlow, hasData, filtered, stages, count }) {
|
|
100
|
+
if (status === "error") return "error";
|
|
101
|
+
if (status === "loading") return hasData ? "refreshing" : "loading";
|
|
102
|
+
if (!hasFlow) return filtered ? "no-match" : "empty";
|
|
103
|
+
if (stages < 2 || count < 2) return "insufficient";
|
|
104
|
+
return "ready";
|
|
105
|
+
}
|
|
106
|
+
const isFiniteNumber = (value) => typeof value === "number" && Number.isFinite(value);
|
|
107
|
+
/** Suffix a repeated key so two nodes never share a column slot or a colour. */
|
|
108
|
+
function uniqueKeys(entries, read) {
|
|
109
|
+
const seen = /* @__PURE__ */ new Map();
|
|
110
|
+
return entries.map((entry) => {
|
|
111
|
+
const base = read(entry);
|
|
112
|
+
const repeats = seen.get(base) ?? 0;
|
|
113
|
+
seen.set(base, repeats + 1);
|
|
114
|
+
return repeats === 0 ? base : `${base}·${repeats + 1}`;
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Drops the back edges that make the graph cyclic, in declared order so the
|
|
119
|
+
* result is deterministic. A sankey is a drawing of a DAG: a flow back to an
|
|
120
|
+
* earlier stage has no left-to-right geometry, and the honest response is to
|
|
121
|
+
* leave it out and SAY so — never to draw a ribbon that doubles back, and
|
|
122
|
+
* never to reorder the stages until the cycle disappears.
|
|
123
|
+
*
|
|
124
|
+
* Iterative depth-first search, so a long chain cannot overflow the stack.
|
|
125
|
+
*/
|
|
126
|
+
function breakCycles(nodeKeys, edges) {
|
|
127
|
+
const outgoing = /* @__PURE__ */ new Map();
|
|
128
|
+
edges.forEach((edge, index) => {
|
|
129
|
+
const list = outgoing.get(edge.source);
|
|
130
|
+
if (list) list.push(index);
|
|
131
|
+
else outgoing.set(edge.source, [index]);
|
|
132
|
+
});
|
|
133
|
+
const state = /* @__PURE__ */ new Map();
|
|
134
|
+
const dropped = /* @__PURE__ */ new Set();
|
|
135
|
+
for (const start of nodeKeys) {
|
|
136
|
+
if (state.get(start)) continue;
|
|
137
|
+
state.set(start, 1);
|
|
138
|
+
const stack = [{
|
|
139
|
+
key: start,
|
|
140
|
+
cursor: 0
|
|
141
|
+
}];
|
|
142
|
+
while (stack.length > 0) {
|
|
143
|
+
const frame = stack[stack.length - 1];
|
|
144
|
+
const out = outgoing.get(frame.key) ?? [];
|
|
145
|
+
if (frame.cursor >= out.length) {
|
|
146
|
+
state.set(frame.key, 2);
|
|
147
|
+
stack.pop();
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
const edgeIndex = out[frame.cursor];
|
|
151
|
+
frame.cursor += 1;
|
|
152
|
+
if (dropped.has(edgeIndex)) continue;
|
|
153
|
+
const next = edges[edgeIndex].target;
|
|
154
|
+
const seen = state.get(next) ?? 0;
|
|
155
|
+
if (seen === 1) {
|
|
156
|
+
dropped.add(edgeIndex);
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
if (seen === 2) continue;
|
|
160
|
+
state.set(next, 1);
|
|
161
|
+
stack.push({
|
|
162
|
+
key: next,
|
|
163
|
+
cursor: 0
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
return dropped;
|
|
168
|
+
}
|
|
169
|
+
/** Longest path from a source to each node — the `left` alignment's depth. */
|
|
170
|
+
function longestFromSources(nodeKeys, edges) {
|
|
171
|
+
const indegree = new Map(nodeKeys.map((key) => [key, 0]));
|
|
172
|
+
const outgoing = /* @__PURE__ */ new Map();
|
|
173
|
+
for (const edge of edges) {
|
|
174
|
+
indegree.set(edge.target, (indegree.get(edge.target) ?? 0) + 1);
|
|
175
|
+
const list = outgoing.get(edge.source);
|
|
176
|
+
if (list) list.push(edge);
|
|
177
|
+
else outgoing.set(edge.source, [edge]);
|
|
178
|
+
}
|
|
179
|
+
const depth = new Map(nodeKeys.map((key) => [key, 0]));
|
|
180
|
+
const queue = nodeKeys.filter((key) => (indegree.get(key) ?? 0) === 0);
|
|
181
|
+
for (let cursor = 0; cursor < queue.length; cursor += 1) {
|
|
182
|
+
const key = queue[cursor];
|
|
183
|
+
for (const edge of outgoing.get(key) ?? []) {
|
|
184
|
+
depth.set(edge.target, Math.max(depth.get(edge.target) ?? 0, (depth.get(key) ?? 0) + 1));
|
|
185
|
+
const remaining = (indegree.get(edge.target) ?? 0) - 1;
|
|
186
|
+
indegree.set(edge.target, remaining);
|
|
187
|
+
if (remaining === 0) queue.push(edge.target);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
return depth;
|
|
191
|
+
}
|
|
192
|
+
/** Longest path from each node to a sink — the `right` alignment's height. */
|
|
193
|
+
function longestToSinks(nodeKeys, edges) {
|
|
194
|
+
const outdegree = new Map(nodeKeys.map((key) => [key, 0]));
|
|
195
|
+
const incoming = /* @__PURE__ */ new Map();
|
|
196
|
+
for (const edge of edges) {
|
|
197
|
+
outdegree.set(edge.source, (outdegree.get(edge.source) ?? 0) + 1);
|
|
198
|
+
const list = incoming.get(edge.target);
|
|
199
|
+
if (list) list.push(edge);
|
|
200
|
+
else incoming.set(edge.target, [edge]);
|
|
201
|
+
}
|
|
202
|
+
const height = new Map(nodeKeys.map((key) => [key, 0]));
|
|
203
|
+
const queue = nodeKeys.filter((key) => (outdegree.get(key) ?? 0) === 0);
|
|
204
|
+
for (let cursor = 0; cursor < queue.length; cursor += 1) {
|
|
205
|
+
const key = queue[cursor];
|
|
206
|
+
for (const edge of incoming.get(key) ?? []) {
|
|
207
|
+
height.set(edge.source, Math.max(height.get(edge.source) ?? 0, (height.get(key) ?? 0) + 1));
|
|
208
|
+
const remaining = (outdegree.get(edge.source) ?? 0) - 1;
|
|
209
|
+
outdegree.set(edge.source, remaining);
|
|
210
|
+
if (remaining === 0) queue.push(edge.source);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
return height;
|
|
214
|
+
}
|
|
215
|
+
/**
|
|
216
|
+
* Normalises `nodes` and `links` into a drawable DAG plus everything the text
|
|
217
|
+
* surfaces read.
|
|
218
|
+
*
|
|
219
|
+
* The order of operations matters and each step is a decision:
|
|
220
|
+
* 1. Keys resolve (a repeat is suffixed), and a link naming a node that does
|
|
221
|
+
* not exist is rejected rather than inventing one.
|
|
222
|
+
* 2. Flows aggregate — two `A→B` rows are one ribbon — and a negative or
|
|
223
|
+
* self-looping flow is rejected. Each rejection is COUNTED and reported.
|
|
224
|
+
* 3. Cycles break, deterministically, and are reported.
|
|
225
|
+
* 4. Depths resolve through `align`.
|
|
226
|
+
* 5. A column past `maxNodesPerStage` folds its smallest into `Other`, links
|
|
227
|
+
* re-route onto the fold and aggregate again. The table still names every
|
|
228
|
+
* member, so the fold is honest rather than a disappearance.
|
|
229
|
+
* 6. Node values are `max(in, out)`, so an imbalance shows as unfilled node
|
|
230
|
+
* rather than being balanced away.
|
|
231
|
+
* 7. Colours are assigned by the node's declared POSITION, never its rank, so
|
|
232
|
+
* filtering cannot repaint the survivors.
|
|
233
|
+
*
|
|
234
|
+
* Returns `null` when there is nothing to speak of.
|
|
235
|
+
*/
|
|
236
|
+
function normalizeSankey(nodes, links, { align = "justify", nodeOrder, color = "none", maxNodesPerStage = 8, palette, emphasis, context, positive, negative, otherLabel }) {
|
|
237
|
+
if (!links) return null;
|
|
238
|
+
const rejected = [];
|
|
239
|
+
const declared = nodes ?? [];
|
|
240
|
+
const declaredKeys = uniqueKeys(declared, (node) => node.key ?? node.label);
|
|
241
|
+
const labelOf = /* @__PURE__ */ new Map();
|
|
242
|
+
const orderIndex = /* @__PURE__ */ new Map();
|
|
243
|
+
declaredKeys.forEach((key, index) => {
|
|
244
|
+
labelOf.set(key, declared[index].label);
|
|
245
|
+
orderIndex.set(key, index);
|
|
246
|
+
});
|
|
247
|
+
const knownDeclared = nodes != null;
|
|
248
|
+
const keys = [...declaredKeys];
|
|
249
|
+
const admit = (key) => {
|
|
250
|
+
if (labelOf.has(key)) return true;
|
|
251
|
+
if (knownDeclared) return false;
|
|
252
|
+
labelOf.set(key, key);
|
|
253
|
+
orderIndex.set(key, keys.length);
|
|
254
|
+
keys.push(key);
|
|
255
|
+
return true;
|
|
256
|
+
};
|
|
257
|
+
const merged = /* @__PURE__ */ new Map();
|
|
258
|
+
for (const link of links) {
|
|
259
|
+
const source = String(link.source);
|
|
260
|
+
const target = String(link.target);
|
|
261
|
+
if (!isFiniteNumber(link.value) || link.value < 0) {
|
|
262
|
+
rejected.push({
|
|
263
|
+
source,
|
|
264
|
+
target,
|
|
265
|
+
value: link.value,
|
|
266
|
+
reason: "negative"
|
|
267
|
+
});
|
|
268
|
+
continue;
|
|
269
|
+
}
|
|
270
|
+
if (source === target) {
|
|
271
|
+
rejected.push({
|
|
272
|
+
source,
|
|
273
|
+
target,
|
|
274
|
+
value: link.value,
|
|
275
|
+
reason: "self-loop"
|
|
276
|
+
});
|
|
277
|
+
continue;
|
|
278
|
+
}
|
|
279
|
+
if (!admit(source) || !admit(target)) {
|
|
280
|
+
rejected.push({
|
|
281
|
+
source,
|
|
282
|
+
target,
|
|
283
|
+
value: link.value,
|
|
284
|
+
reason: "unknown-node"
|
|
285
|
+
});
|
|
286
|
+
continue;
|
|
287
|
+
}
|
|
288
|
+
const id = `${source}→${target}`;
|
|
289
|
+
const existing = merged.get(id);
|
|
290
|
+
if (existing) {
|
|
291
|
+
existing.value += link.value;
|
|
292
|
+
existing.label ??= link.label;
|
|
293
|
+
} else merged.set(id, {
|
|
294
|
+
source,
|
|
295
|
+
target,
|
|
296
|
+
value: link.value,
|
|
297
|
+
label: link.label,
|
|
298
|
+
tone: link.tone ?? "neutral"
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
let edges = [...merged.values()];
|
|
302
|
+
if (keys.length === 0) return null;
|
|
303
|
+
const dropped = breakCycles(keys, edges);
|
|
304
|
+
let cycles = 0;
|
|
305
|
+
if (dropped.size > 0) {
|
|
306
|
+
cycles = dropped.size;
|
|
307
|
+
edges.forEach((edge, index) => {
|
|
308
|
+
if (dropped.has(index)) rejected.push({
|
|
309
|
+
source: edge.source,
|
|
310
|
+
target: edge.target,
|
|
311
|
+
value: edge.value,
|
|
312
|
+
reason: "cycle"
|
|
313
|
+
});
|
|
314
|
+
});
|
|
315
|
+
edges = edges.filter((_, index) => !dropped.has(index));
|
|
316
|
+
}
|
|
317
|
+
const fromSources = longestFromSources(keys, edges);
|
|
318
|
+
const hasOutgoing = new Set(edges.map((edge) => edge.source));
|
|
319
|
+
const maxFromSources = Math.max(0, ...keys.map((key) => fromSources.get(key) ?? 0));
|
|
320
|
+
let depthOf;
|
|
321
|
+
if (align === "right") {
|
|
322
|
+
const toSinks = longestToSinks(keys, edges);
|
|
323
|
+
const maxHeight = Math.max(0, ...keys.map((key) => toSinks.get(key) ?? 0));
|
|
324
|
+
depthOf = new Map(keys.map((key) => [key, maxHeight - (toSinks.get(key) ?? 0)]));
|
|
325
|
+
} else if (align === "justify") depthOf = new Map(keys.map((key) => [key, hasOutgoing.has(key) ? fromSources.get(key) ?? 0 : maxFromSources]));
|
|
326
|
+
else depthOf = new Map(keys.map((key) => [key, fromSources.get(key) ?? 0]));
|
|
327
|
+
const stages = Math.max(0, ...keys.map((key) => depthOf.get(key) ?? 0)) + 1;
|
|
328
|
+
const cap = Math.max(2, maxNodesPerStage);
|
|
329
|
+
let folded = false;
|
|
330
|
+
const foldMembers = /* @__PURE__ */ new Map();
|
|
331
|
+
const foldTarget = /* @__PURE__ */ new Map();
|
|
332
|
+
if (keys.length > cap) {
|
|
333
|
+
const provisional = /* @__PURE__ */ new Map();
|
|
334
|
+
for (const key of keys) provisional.set(key, 0);
|
|
335
|
+
for (const edge of edges) {
|
|
336
|
+
provisional.set(edge.source, (provisional.get(edge.source) ?? 0) + edge.value);
|
|
337
|
+
provisional.set(edge.target, (provisional.get(edge.target) ?? 0) + edge.value);
|
|
338
|
+
}
|
|
339
|
+
for (let depth = 0; depth < stages; depth += 1) {
|
|
340
|
+
const column = keys.filter((key) => (depthOf.get(key) ?? 0) === depth);
|
|
341
|
+
if (column.length <= cap) continue;
|
|
342
|
+
const fold = [...column].sort((a, b) => {
|
|
343
|
+
const gap = (provisional.get(b) ?? 0) - (provisional.get(a) ?? 0);
|
|
344
|
+
return gap !== 0 ? gap : (orderIndex.get(a) ?? 0) - (orderIndex.get(b) ?? 0);
|
|
345
|
+
}).slice(cap - 1);
|
|
346
|
+
if (fold.length < 2) continue;
|
|
347
|
+
folded = true;
|
|
348
|
+
const otherKey = `__other-${depth}__`;
|
|
349
|
+
labelOf.set(otherKey, otherLabel);
|
|
350
|
+
depthOf.set(otherKey, depth);
|
|
351
|
+
orderIndex.set(otherKey, Number.MAX_SAFE_INTEGER - (stages - depth));
|
|
352
|
+
foldMembers.set(otherKey, fold.map((key) => labelOf.get(key) ?? key));
|
|
353
|
+
for (const key of fold) foldTarget.set(key, otherKey);
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
if (foldTarget.size > 0) {
|
|
357
|
+
const rerouted = /* @__PURE__ */ new Map();
|
|
358
|
+
for (const edge of edges) {
|
|
359
|
+
const source = foldTarget.get(edge.source) ?? edge.source;
|
|
360
|
+
const target = foldTarget.get(edge.target) ?? edge.target;
|
|
361
|
+
if (source === target) continue;
|
|
362
|
+
const id = `${source}→${target}`;
|
|
363
|
+
const existing = rerouted.get(id);
|
|
364
|
+
if (existing) existing.value += edge.value;
|
|
365
|
+
else rerouted.set(id, {
|
|
366
|
+
...edge,
|
|
367
|
+
source,
|
|
368
|
+
target,
|
|
369
|
+
label: void 0
|
|
370
|
+
});
|
|
371
|
+
}
|
|
372
|
+
edges = [...rerouted.values()];
|
|
373
|
+
for (const key of foldTarget.keys()) {
|
|
374
|
+
labelOf.delete(key);
|
|
375
|
+
orderIndex.delete(key);
|
|
376
|
+
depthOf.delete(key);
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
const liveKeys = [...labelOf.keys()].filter((key) => depthOf.has(key));
|
|
380
|
+
const inValue = new Map(liveKeys.map((key) => [key, 0]));
|
|
381
|
+
const outValue = new Map(liveKeys.map((key) => [key, 0]));
|
|
382
|
+
for (const edge of edges) {
|
|
383
|
+
outValue.set(edge.source, (outValue.get(edge.source) ?? 0) + edge.value);
|
|
384
|
+
inValue.set(edge.target, (inValue.get(edge.target) ?? 0) + edge.value);
|
|
385
|
+
}
|
|
386
|
+
let overIdentity = false;
|
|
387
|
+
const identityColor = /* @__PURE__ */ new Map();
|
|
388
|
+
if (color === "terminal" || color === "node") {
|
|
389
|
+
const ranked = [...liveKeys.filter((key) => color === "terminal" ? (outValue.get(key) ?? 0) === 0 : (inValue.get(key) ?? 0) === 0)].sort((a, b) => {
|
|
390
|
+
const gap = Math.max(inValue.get(b) ?? 0, outValue.get(b) ?? 0) - Math.max(inValue.get(a) ?? 0, outValue.get(a) ?? 0);
|
|
391
|
+
return gap !== 0 ? gap : (orderIndex.get(a) ?? 0) - (orderIndex.get(b) ?? 0);
|
|
392
|
+
});
|
|
393
|
+
const slots = Math.min(palette.length, 3);
|
|
394
|
+
ranked.forEach((key, rank) => {
|
|
395
|
+
if (rank < slots) identityColor.set(key, palette[rank] ?? emphasis);
|
|
396
|
+
else overIdentity = true;
|
|
397
|
+
});
|
|
398
|
+
}
|
|
399
|
+
const toneColor = (tone) => tone === "positive" ? positive : tone === "negative" ? negative : emphasis;
|
|
400
|
+
const resolvedOrder = nodeOrder ?? (knownDeclared ? "declared" : "value");
|
|
401
|
+
const infos = /* @__PURE__ */ new Map();
|
|
402
|
+
for (const key of liveKeys) {
|
|
403
|
+
const inTotal = inValue.get(key) ?? 0;
|
|
404
|
+
const outTotal = outValue.get(key) ?? 0;
|
|
405
|
+
infos.set(key, {
|
|
406
|
+
key,
|
|
407
|
+
label: labelOf.get(key) ?? key,
|
|
408
|
+
depth: depthOf.get(key) ?? 0,
|
|
409
|
+
index: orderIndex.get(key) ?? 0,
|
|
410
|
+
order: 0,
|
|
411
|
+
value: Math.max(inTotal, outTotal),
|
|
412
|
+
inValue: inTotal,
|
|
413
|
+
outValue: outTotal,
|
|
414
|
+
imbalance: inTotal - outTotal,
|
|
415
|
+
isSource: inTotal === 0,
|
|
416
|
+
isSink: outTotal === 0,
|
|
417
|
+
folded: foldMembers.has(key),
|
|
418
|
+
members: foldMembers.get(key) ?? [],
|
|
419
|
+
color: context
|
|
420
|
+
});
|
|
421
|
+
}
|
|
422
|
+
const columns = Array.from({ length: stages }, () => []);
|
|
423
|
+
for (const info of infos.values()) columns[info.depth]?.push(info);
|
|
424
|
+
for (const column of columns) {
|
|
425
|
+
column.sort((a, b) => {
|
|
426
|
+
if (a.folded !== b.folded) return a.folded ? 1 : -1;
|
|
427
|
+
if (resolvedOrder === "value") {
|
|
428
|
+
const gap = b.value - a.value;
|
|
429
|
+
if (gap !== 0) return gap;
|
|
430
|
+
}
|
|
431
|
+
return a.index - b.index;
|
|
432
|
+
});
|
|
433
|
+
column.forEach((info, position) => {
|
|
434
|
+
info.order = position;
|
|
435
|
+
});
|
|
436
|
+
}
|
|
437
|
+
const linkInfos = edges.map((edge) => {
|
|
438
|
+
const source = infos.get(edge.source);
|
|
439
|
+
const target = infos.get(edge.target);
|
|
440
|
+
const share = source.outValue > 0 ? edge.value / source.outValue : null;
|
|
441
|
+
const paint = color === "polarity" ? toneColor(edge.tone) : color === "terminal" ? identityColor.get(edge.target) ?? reachableColor(edge.target, edges, identityColor, "forward") ?? context : color === "node" ? identityColor.get(edge.source) ?? reachableColor(edge.source, edges, identityColor, "back") ?? context : emphasis;
|
|
442
|
+
return {
|
|
443
|
+
id: `${edge.source}→${edge.target}`,
|
|
444
|
+
source: edge.source,
|
|
445
|
+
target: edge.target,
|
|
446
|
+
sourceLabel: source.label,
|
|
447
|
+
targetLabel: target.label,
|
|
448
|
+
label: edge.label,
|
|
449
|
+
value: edge.value,
|
|
450
|
+
share,
|
|
451
|
+
tone: edge.tone,
|
|
452
|
+
color: paint,
|
|
453
|
+
depth: source.depth,
|
|
454
|
+
targetDepth: target.depth
|
|
455
|
+
};
|
|
456
|
+
}).sort((a, b) => a.depth - b.depth || b.value - a.value);
|
|
457
|
+
const orderedNodes = columns.flat();
|
|
458
|
+
const imbalanced = orderedNodes.filter((node) => Math.abs(node.imbalance) > 0 && !node.isSink && !node.isSource);
|
|
459
|
+
const total = columns[0]?.reduce((sum, node) => sum + node.value, 0) ?? 0;
|
|
460
|
+
for (const node of orderedNodes) node.color = color === "terminal" || color === "node" ? identityColor.get(node.key) ?? context : context;
|
|
461
|
+
return {
|
|
462
|
+
nodes: orderedNodes,
|
|
463
|
+
links: linkInfos,
|
|
464
|
+
columns,
|
|
465
|
+
stages,
|
|
466
|
+
total,
|
|
467
|
+
count: linkInfos.filter((link) => link.value > 0).length,
|
|
468
|
+
rejected,
|
|
469
|
+
cycles,
|
|
470
|
+
imbalanced,
|
|
471
|
+
folded,
|
|
472
|
+
overIdentity
|
|
473
|
+
};
|
|
474
|
+
/**
|
|
475
|
+
* The hue for a flow whose coloured endpoint is several hops away: walk the
|
|
476
|
+
* graph and collect EVERY assigned colour reachable that way.
|
|
477
|
+
*
|
|
478
|
+
* One colour reachable means the flow really does end in that outcome, so
|
|
479
|
+
* it may wear it. Several means it does not — an upstream ribbon that later
|
|
480
|
+
* splits between Paid and Lapsed belongs to neither, and painting it the
|
|
481
|
+
* larger one's hue would claim a destination the data does not give it. It
|
|
482
|
+
* takes the context gray instead, which is what gray is for.
|
|
483
|
+
*
|
|
484
|
+
* Memo-free: these graphs are small, and a cache would have to be
|
|
485
|
+
* invalidated by the fold.
|
|
486
|
+
*/
|
|
487
|
+
function reachableColor(key, all, assigned, direction) {
|
|
488
|
+
const seen = /* @__PURE__ */ new Set([key]);
|
|
489
|
+
const found = /* @__PURE__ */ new Set();
|
|
490
|
+
let frontier = [key];
|
|
491
|
+
while (frontier.length > 0) {
|
|
492
|
+
const next = [];
|
|
493
|
+
for (const current of frontier) {
|
|
494
|
+
const hit = assigned.get(current);
|
|
495
|
+
if (hit) {
|
|
496
|
+
found.add(hit);
|
|
497
|
+
continue;
|
|
498
|
+
}
|
|
499
|
+
for (const edge of all) {
|
|
500
|
+
const from = direction === "forward" ? edge.source : edge.target;
|
|
501
|
+
const to = direction === "forward" ? edge.target : edge.source;
|
|
502
|
+
if (from === current && !seen.has(to)) {
|
|
503
|
+
seen.add(to);
|
|
504
|
+
next.push(to);
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
frontier = next;
|
|
509
|
+
}
|
|
510
|
+
return found.size === 1 ? [...found][0] : void 0;
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
/**
|
|
514
|
+
* Turns a summary into normalised geometry.
|
|
515
|
+
*
|
|
516
|
+
* Vertical: one value→pixel scale shared by every column (d3-sankey's `ky`),
|
|
517
|
+
* so a thickness means the same quantity wherever it appears — the whole
|
|
518
|
+
* point of the diagram. Nodes stack from the top with `nodeGap` between them,
|
|
519
|
+
* then a few relaxation passes slide each node toward the weighted centre of
|
|
520
|
+
* what it connects to and push overlaps apart again; that is what stops the
|
|
521
|
+
* ribbons knotting, and it is capped and deterministic so a baseline never
|
|
522
|
+
* drifts. Each node's flows are then stacked in the order of the partner's
|
|
523
|
+
* position, so ribbons run roughly parallel instead of crossing inside a node.
|
|
524
|
+
*
|
|
525
|
+
* Horizontal: a column is a normalised centre LINE. The node rect straddles
|
|
526
|
+
* it with a pixel width and the ribbons run line to line, passing under the
|
|
527
|
+
* opaque rects — so the geometry carries no pixel width at all and the same
|
|
528
|
+
* rows are correct at every panel width.
|
|
529
|
+
*/
|
|
530
|
+
function layoutSankey(summary, { plotHeight, nodeGap, iterations = 6, curvature = .5, labels = "all", minLabelHeight = 10, formatValue, formatShare, missing }) {
|
|
531
|
+
const empty = {
|
|
532
|
+
nodes: [],
|
|
533
|
+
ribbons: [],
|
|
534
|
+
columnCenters: [],
|
|
535
|
+
hairlines: 0
|
|
536
|
+
};
|
|
537
|
+
if (!summary || summary.stages < 1 || plotHeight <= 0) return empty;
|
|
538
|
+
const { columns, links, stages } = summary;
|
|
539
|
+
let scale = Infinity;
|
|
540
|
+
for (const column of columns) {
|
|
541
|
+
if (column.length === 0) continue;
|
|
542
|
+
const gaps = (column.length - 1) * nodeGap;
|
|
543
|
+
const sum = column.reduce((total, node) => total + node.value, 0);
|
|
544
|
+
const available = Math.max(1, plotHeight - gaps);
|
|
545
|
+
scale = Math.min(scale, sum > 0 ? available / sum : Infinity);
|
|
546
|
+
}
|
|
547
|
+
if (!Number.isFinite(scale)) scale = 0;
|
|
548
|
+
const top = /* @__PURE__ */ new Map();
|
|
549
|
+
const height = /* @__PURE__ */ new Map();
|
|
550
|
+
for (const column of columns) {
|
|
551
|
+
let y = 0;
|
|
552
|
+
for (const node of column) {
|
|
553
|
+
const h = node.value * scale;
|
|
554
|
+
top.set(node.key, y);
|
|
555
|
+
height.set(node.key, h);
|
|
556
|
+
y += h + nodeGap;
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
const incoming = /* @__PURE__ */ new Map();
|
|
560
|
+
const outgoing = /* @__PURE__ */ new Map();
|
|
561
|
+
for (const link of links) {
|
|
562
|
+
const outList = outgoing.get(link.source);
|
|
563
|
+
if (outList) outList.push(link);
|
|
564
|
+
else outgoing.set(link.source, [link]);
|
|
565
|
+
const inList = incoming.get(link.target);
|
|
566
|
+
if (inList) inList.push(link);
|
|
567
|
+
else incoming.set(link.target, [link]);
|
|
568
|
+
}
|
|
569
|
+
const center = (key) => (top.get(key) ?? 0) + (height.get(key) ?? 0) / 2;
|
|
570
|
+
const pull = (node, side) => {
|
|
571
|
+
const list = (side === "in" ? incoming.get(node.key) : outgoing.get(node.key)) ?? [];
|
|
572
|
+
let weight = 0;
|
|
573
|
+
let sum = 0;
|
|
574
|
+
for (const link of list) {
|
|
575
|
+
if (link.value <= 0) continue;
|
|
576
|
+
sum += center(side === "in" ? link.source : link.target) * link.value;
|
|
577
|
+
weight += link.value;
|
|
578
|
+
}
|
|
579
|
+
return weight > 0 ? sum / weight : null;
|
|
580
|
+
};
|
|
581
|
+
const resolveCollisions = () => {
|
|
582
|
+
for (const column of columns) {
|
|
583
|
+
if (column.length === 0) continue;
|
|
584
|
+
let limit = 0;
|
|
585
|
+
for (const node of column) {
|
|
586
|
+
const y = Math.max(top.get(node.key) ?? 0, limit);
|
|
587
|
+
top.set(node.key, y);
|
|
588
|
+
limit = y + (height.get(node.key) ?? 0) + nodeGap;
|
|
589
|
+
}
|
|
590
|
+
let floor = plotHeight;
|
|
591
|
+
for (let index = column.length - 1; index >= 0; index -= 1) {
|
|
592
|
+
const node = column[index];
|
|
593
|
+
const h = height.get(node.key) ?? 0;
|
|
594
|
+
const y = Math.min(top.get(node.key) ?? 0, floor - h);
|
|
595
|
+
top.set(node.key, Math.max(0, y));
|
|
596
|
+
floor = (top.get(node.key) ?? 0) - nodeGap;
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
};
|
|
600
|
+
resolveCollisions();
|
|
601
|
+
const passes = Math.max(0, Math.min(24, iterations));
|
|
602
|
+
let alpha = 1;
|
|
603
|
+
for (let pass = 0; pass < passes; pass += 1) {
|
|
604
|
+
alpha *= .99;
|
|
605
|
+
for (let depth = stages - 2; depth >= 0; depth -= 1) for (const node of columns[depth] ?? []) {
|
|
606
|
+
const target = pull(node, "out");
|
|
607
|
+
if (target == null) continue;
|
|
608
|
+
const shift = (target - center(node.key)) * alpha;
|
|
609
|
+
top.set(node.key, (top.get(node.key) ?? 0) + shift);
|
|
610
|
+
}
|
|
611
|
+
resolveCollisions();
|
|
612
|
+
for (let depth = 1; depth < stages; depth += 1) for (const node of columns[depth] ?? []) {
|
|
613
|
+
const target = pull(node, "in");
|
|
614
|
+
if (target == null) continue;
|
|
615
|
+
const shift = (target - center(node.key)) * alpha;
|
|
616
|
+
top.set(node.key, (top.get(node.key) ?? 0) + shift);
|
|
617
|
+
}
|
|
618
|
+
resolveCollisions();
|
|
619
|
+
}
|
|
620
|
+
const byPartner = (list, side) => [...list].sort((a, b) => {
|
|
621
|
+
const gap = center(side === "source" ? a.source : a.target) - center(side === "source" ? b.source : b.target);
|
|
622
|
+
return gap !== 0 ? gap : a.value === b.value ? a.id.localeCompare(b.id) : b.value - a.value;
|
|
623
|
+
});
|
|
624
|
+
const sourceTop = /* @__PURE__ */ new Map();
|
|
625
|
+
const targetTop = /* @__PURE__ */ new Map();
|
|
626
|
+
for (const node of summary.nodes) {
|
|
627
|
+
let offset = top.get(node.key) ?? 0;
|
|
628
|
+
for (const link of byPartner(outgoing.get(node.key) ?? [], "target")) {
|
|
629
|
+
sourceTop.set(link.id, offset);
|
|
630
|
+
offset += link.value * scale;
|
|
631
|
+
}
|
|
632
|
+
let inset = top.get(node.key) ?? 0;
|
|
633
|
+
for (const link of byPartner(incoming.get(node.key) ?? [], "source")) {
|
|
634
|
+
targetTop.set(link.id, inset);
|
|
635
|
+
inset += link.value * scale;
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
const columnX = (depth) => stages > 1 ? depth / (stages - 1) : .5;
|
|
639
|
+
const norm = (pixels) => pixels / plotHeight;
|
|
640
|
+
const ease = Math.max(0, Math.min(1, curvature));
|
|
641
|
+
const nodeRows = summary.nodes.map((node) => {
|
|
642
|
+
const y = top.get(node.key) ?? 0;
|
|
643
|
+
const h = height.get(node.key) ?? 0;
|
|
644
|
+
const first = node.depth === 0;
|
|
645
|
+
const last = node.depth === stages - 1;
|
|
646
|
+
return {
|
|
647
|
+
k: node.key,
|
|
648
|
+
x: columnX(node.depth),
|
|
649
|
+
y: norm(y),
|
|
650
|
+
y2: norm(y + h),
|
|
651
|
+
mid: norm(y + h / 2),
|
|
652
|
+
h,
|
|
653
|
+
label: node.label,
|
|
654
|
+
vt: formatValue(node.value),
|
|
655
|
+
c: node.color,
|
|
656
|
+
first,
|
|
657
|
+
last,
|
|
658
|
+
lab: labels === "none" ? false : labels === "ends" ? (first || last) && h >= minLabelHeight : h >= minLabelHeight
|
|
659
|
+
};
|
|
660
|
+
});
|
|
661
|
+
const ribbonRows = [];
|
|
662
|
+
let hairlines = 0;
|
|
663
|
+
for (const link of links) {
|
|
664
|
+
if (link.value <= 0) continue;
|
|
665
|
+
const thickness = link.value * scale;
|
|
666
|
+
if (thickness < 1.5) hairlines += 1;
|
|
667
|
+
const y0 = sourceTop.get(link.id) ?? 0;
|
|
668
|
+
const y1 = targetTop.get(link.id) ?? 0;
|
|
669
|
+
const x0 = columnX(link.depth);
|
|
670
|
+
const x1 = columnX(link.targetDepth);
|
|
671
|
+
const shareText = link.share == null ? missing : formatShare(link.share);
|
|
672
|
+
for (let sample = 0; sample < 20; sample += 1) {
|
|
673
|
+
const t = sample / 19;
|
|
674
|
+
const w = ease * (t * t * (3 - 2 * t)) + (1 - ease) * t;
|
|
675
|
+
const topEdge = y0 + (y1 - y0) * w;
|
|
676
|
+
ribbonRows.push({
|
|
677
|
+
id: link.id,
|
|
678
|
+
x: x0 + (x1 - x0) * t,
|
|
679
|
+
y: norm(topEdge),
|
|
680
|
+
y2: norm(topEdge + thickness),
|
|
681
|
+
c: link.color,
|
|
682
|
+
sl: link.sourceLabel,
|
|
683
|
+
tl: link.targetLabel,
|
|
684
|
+
v: link.value,
|
|
685
|
+
sh: shareText,
|
|
686
|
+
s: link.source,
|
|
687
|
+
t: link.target
|
|
688
|
+
});
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
return {
|
|
692
|
+
nodes: nodeRows,
|
|
693
|
+
ribbons: ribbonRows,
|
|
694
|
+
columnCenters: Array.from({ length: stages }, (_, depth) => columnX(depth)),
|
|
695
|
+
hairlines
|
|
696
|
+
};
|
|
697
|
+
}
|
|
698
|
+
function createSankeyFormatters({ format, shareFormat, locale, formatValue }) {
|
|
699
|
+
return {
|
|
700
|
+
formatValue: (value) => isFiniteNumber(value) ? formatValue ? formatValue(value) : formatNumber(value, format, locale) : "—",
|
|
701
|
+
formatShare: (share) => isFiniteNumber(share) ? formatNumber(share, shareFormat, locale) : "—"
|
|
702
|
+
};
|
|
703
|
+
}
|
|
704
|
+
/**
|
|
705
|
+
* What the diagram is about: the largest flow leaving the first stage, and
|
|
706
|
+
* the largest declared loss anywhere. Only a link the consumer TONED as
|
|
707
|
+
* negative counts as a loss — the card never decides on its own that an
|
|
708
|
+
* outcome is bad.
|
|
709
|
+
*/
|
|
710
|
+
function deriveSankeyHighlights(summary) {
|
|
711
|
+
if (!summary || summary.count === 0) return null;
|
|
712
|
+
const drawable = summary.links.filter((link) => link.value > 0);
|
|
713
|
+
const firstStage = drawable.filter((link) => link.depth === 0);
|
|
714
|
+
const main = (firstStage.length > 0 ? firstStage : drawable).reduce((best, link) => link.value > best.value ? link : best);
|
|
715
|
+
const losses = drawable.filter((link) => link.tone === "negative");
|
|
716
|
+
const loss = losses.length > 0 ? losses.reduce((worst, link) => link.value > worst.value ? link : worst) : null;
|
|
717
|
+
return {
|
|
718
|
+
main,
|
|
719
|
+
loss: loss && loss.id !== main.id ? loss : null
|
|
720
|
+
};
|
|
721
|
+
}
|
|
722
|
+
/** The figure's generated description: the volume, the stages, the main flow, the largest loss, the caveats and the as-of. */
|
|
723
|
+
function buildSankeyDescription({ name, state, summary, formatters, asOfText, labels }) {
|
|
724
|
+
if (state === "loading") return labels.loading(name);
|
|
725
|
+
if (state === "error") return `${name}: ${labels.error}`;
|
|
726
|
+
if (state === "empty") return `${name}: ${labels.noData}`;
|
|
727
|
+
if (state === "no-match") return `${name}: ${labels.noMatch}`;
|
|
728
|
+
if (state === "insufficient") return `${name}: ${labels.insufficient}`;
|
|
729
|
+
if (!summary) return name;
|
|
730
|
+
const parts = [`${name}.`];
|
|
731
|
+
const highlights = deriveSankeyHighlights(summary);
|
|
732
|
+
if (highlights) {
|
|
733
|
+
parts.push(labels.flowSentence({
|
|
734
|
+
value: formatters.formatValue(highlights.main.value),
|
|
735
|
+
total: formatters.formatValue(summary.total),
|
|
736
|
+
share: formatters.formatShare(highlights.main.share),
|
|
737
|
+
from: highlights.main.sourceLabel,
|
|
738
|
+
to: highlights.main.targetLabel
|
|
739
|
+
}));
|
|
740
|
+
if (highlights.loss) parts.push(labels.lossSentence({
|
|
741
|
+
value: formatters.formatValue(highlights.loss.value),
|
|
742
|
+
share: formatters.formatShare(highlights.loss.share),
|
|
743
|
+
to: highlights.loss.targetLabel
|
|
744
|
+
}));
|
|
745
|
+
}
|
|
746
|
+
parts.push(labels.stageSummary(summary.stages, summary.columns.map((column) => column.map((node) => node.label).join(" / ")).filter(Boolean).join(" → ") || "—"));
|
|
747
|
+
if (summary.cycles > 0) parts.push(labels.cyclesNote(summary.cycles));
|
|
748
|
+
if (summary.imbalanced.length > 0) {
|
|
749
|
+
const amount = summary.imbalanced.reduce((sum, node) => sum + Math.abs(node.imbalance), 0);
|
|
750
|
+
parts.push(labels.imbalanceNote(summary.imbalanced.length, formatters.formatValue(amount)));
|
|
751
|
+
}
|
|
752
|
+
if (summary.folded) parts.push(labels.foldedNote(labels.other));
|
|
753
|
+
if (summary.overIdentity) parts.push(labels.overIdentityNote(3));
|
|
754
|
+
if (asOfText) parts.push(`${labels.asOf} ${asOfText}.`);
|
|
755
|
+
return parts.join(" ");
|
|
756
|
+
}
|
|
757
|
+
/** The plot's own `aria-label` — the form, the volume, the stages. */
|
|
758
|
+
function buildSankeyChartDescription({ name, summary, formatters, labels }) {
|
|
759
|
+
return labels.chartDescription({
|
|
760
|
+
name,
|
|
761
|
+
stages: summary.stages,
|
|
762
|
+
nodes: summary.nodes.length,
|
|
763
|
+
flows: summary.count,
|
|
764
|
+
total: formatters.formatValue(summary.total),
|
|
765
|
+
stageList: summary.columns.map((column) => column.map((node) => node.label).join(" / ")).filter(Boolean).join(" → ") || "—"
|
|
766
|
+
});
|
|
767
|
+
}
|
|
768
|
+
/** The generated insight, as segments so the values render bold and the words stay plain. */
|
|
769
|
+
function buildSankeyInsight({ name, summary, formatters, labels }) {
|
|
770
|
+
const highlights = deriveSankeyHighlights(summary);
|
|
771
|
+
if (!summary || !highlights) return null;
|
|
772
|
+
return labels.insight({
|
|
773
|
+
name,
|
|
774
|
+
total: formatters.formatValue(summary.total),
|
|
775
|
+
from: highlights.main.sourceLabel,
|
|
776
|
+
to: highlights.main.targetLabel,
|
|
777
|
+
value: formatters.formatValue(highlights.main.value),
|
|
778
|
+
share: formatters.formatShare(highlights.main.share),
|
|
779
|
+
lossTo: highlights.loss?.targetLabel ?? null,
|
|
780
|
+
lossValue: highlights.loss ? formatters.formatValue(highlights.loss.value) : null,
|
|
781
|
+
lossShare: highlights.loss ? formatters.formatShare(highlights.loss.share) : null
|
|
782
|
+
});
|
|
783
|
+
}
|
|
784
|
+
/** The plain-text reading of insight segments — for descriptions and tests. */
|
|
785
|
+
function sankeyInsightToText(segments) {
|
|
786
|
+
return segments.map((segment) => typeof segment === "string" ? segment : segment.value).join("");
|
|
787
|
+
}
|
|
788
|
+
//#endregion
|
|
789
|
+
export { DEFAULT_SANKEY_LABELS, HAIRLINE_PX, RIBBON_SAMPLES, SANKEY_MAX_IDENTITY, SANKEY_MIN_FLOWS, SANKEY_MIN_STAGES, buildSankeyChartDescription, buildSankeyDescription, buildSankeyInsight, createSankeyFormatters, deriveSankeyHighlights, deriveSankeyState, layoutSankey, normalizeSankey, resolveSankeyLabels, sankeyInsightToText };
|