@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.
Files changed (156) hide show
  1. package/dist/_virtual/_rolldown/runtime.js +13 -0
  2. package/dist/components/AnomalyCard/AnomalyCard.chart.d.ts +64 -0
  3. package/dist/components/AnomalyCard/AnomalyCard.chart.js +341 -0
  4. package/dist/components/AnomalyCard/AnomalyCard.d.ts +169 -0
  5. package/dist/components/AnomalyCard/AnomalyCard.js +1019 -0
  6. package/dist/components/AnomalyCard/AnomalyCard.model.d.ts +508 -0
  7. package/dist/components/AnomalyCard/AnomalyCard.model.js +553 -0
  8. package/dist/components/AnomalyCard/index.d.ts +4 -0
  9. package/dist/components/AnomalyCard/index.js +4 -0
  10. package/dist/components/ChartCard/ChartCard.chart.d.ts +72 -0
  11. package/dist/components/ChartCard/ChartCard.chart.js +1333 -0
  12. package/dist/components/ChartCard/ChartCard.d.ts +206 -0
  13. package/dist/components/ChartCard/ChartCard.js +718 -0
  14. package/dist/components/ChartCard/ChartCard.model.d.ts +452 -0
  15. package/dist/components/ChartCard/ChartCard.model.js +1223 -0
  16. package/dist/components/ChartCard/index.d.ts +4 -0
  17. package/dist/components/ChartCard/index.js +4 -0
  18. package/dist/components/ComparisonCard/ComparisonCard.chart.d.ts +80 -0
  19. package/dist/components/ComparisonCard/ComparisonCard.chart.js +337 -0
  20. package/dist/components/ComparisonCard/ComparisonCard.d.ts +200 -0
  21. package/dist/components/ComparisonCard/ComparisonCard.js +1325 -0
  22. package/dist/components/ComparisonCard/ComparisonCard.model.d.ts +309 -0
  23. package/dist/components/ComparisonCard/ComparisonCard.model.js +263 -0
  24. package/dist/components/ComparisonCard/index.d.ts +4 -0
  25. package/dist/components/ComparisonCard/index.js +4 -0
  26. package/dist/components/ForecastCard/ForecastCard.chart.d.ts +82 -0
  27. package/dist/components/ForecastCard/ForecastCard.chart.js +416 -0
  28. package/dist/components/ForecastCard/ForecastCard.d.ts +148 -0
  29. package/dist/components/ForecastCard/ForecastCard.js +1029 -0
  30. package/dist/components/ForecastCard/ForecastCard.model.d.ts +497 -0
  31. package/dist/components/ForecastCard/ForecastCard.model.js +554 -0
  32. package/dist/components/ForecastCard/index.d.ts +4 -0
  33. package/dist/components/ForecastCard/index.js +4 -0
  34. package/dist/components/HighlightCard/HighlightCard.chart.d.ts +122 -0
  35. package/dist/components/HighlightCard/HighlightCard.chart.js +660 -0
  36. package/dist/components/HighlightCard/HighlightCard.d.ts +202 -0
  37. package/dist/components/HighlightCard/HighlightCard.js +1286 -0
  38. package/dist/components/HighlightCard/HighlightCard.model.d.ts +692 -0
  39. package/dist/components/HighlightCard/HighlightCard.model.js +875 -0
  40. package/dist/components/HighlightCard/index.d.ts +4 -0
  41. package/dist/components/HighlightCard/index.js +4 -0
  42. package/dist/components/InsightCard/InsightCard.chart.d.ts +91 -0
  43. package/dist/components/InsightCard/InsightCard.chart.js +471 -0
  44. package/dist/components/InsightCard/InsightCard.d.ts +161 -0
  45. package/dist/components/InsightCard/InsightCard.js +961 -0
  46. package/dist/components/InsightCard/InsightCard.model.d.ts +405 -0
  47. package/dist/components/InsightCard/InsightCard.model.js +421 -0
  48. package/dist/components/InsightCard/index.d.ts +4 -0
  49. package/dist/components/InsightCard/index.js +4 -0
  50. package/dist/components/MetricCard/MetricCard.chart.d.ts +79 -0
  51. package/dist/components/MetricCard/MetricCard.chart.js +435 -0
  52. package/dist/components/MetricCard/MetricCard.d.ts +227 -0
  53. package/dist/components/MetricCard/MetricCard.js +1293 -0
  54. package/dist/components/MetricCard/MetricCard.model.d.ts +521 -0
  55. package/dist/components/MetricCard/MetricCard.model.js +627 -0
  56. package/dist/components/MetricCard/index.d.ts +4 -0
  57. package/dist/components/MetricCard/index.js +4 -0
  58. package/dist/components/ProfileCard/ProfileCard.chart.d.ts +64 -0
  59. package/dist/components/ProfileCard/ProfileCard.chart.js +427 -0
  60. package/dist/components/ProfileCard/ProfileCard.d.ts +140 -0
  61. package/dist/components/ProfileCard/ProfileCard.js +735 -0
  62. package/dist/components/ProfileCard/ProfileCard.model.d.ts +279 -0
  63. package/dist/components/ProfileCard/ProfileCard.model.js +244 -0
  64. package/dist/components/ProfileCard/index.d.ts +4 -0
  65. package/dist/components/ProfileCard/index.js +4 -0
  66. package/dist/components/ProgressCard/ProgressCard.chart.d.ts +73 -0
  67. package/dist/components/ProgressCard/ProgressCard.chart.js +373 -0
  68. package/dist/components/ProgressCard/ProgressCard.d.ts +137 -0
  69. package/dist/components/ProgressCard/ProgressCard.js +908 -0
  70. package/dist/components/ProgressCard/ProgressCard.model.d.ts +385 -0
  71. package/dist/components/ProgressCard/ProgressCard.model.js +389 -0
  72. package/dist/components/ProgressCard/index.d.ts +4 -0
  73. package/dist/components/ProgressCard/index.js +4 -0
  74. package/dist/components/ReportCard/ReportCard.chart.d.ts +45 -0
  75. package/dist/components/ReportCard/ReportCard.chart.js +231 -0
  76. package/dist/components/ReportCard/ReportCard.d.ts +153 -0
  77. package/dist/components/ReportCard/ReportCard.js +1118 -0
  78. package/dist/components/ReportCard/ReportCard.model.d.ts +392 -0
  79. package/dist/components/ReportCard/ReportCard.model.js +399 -0
  80. package/dist/components/ReportCard/index.d.ts +4 -0
  81. package/dist/components/ReportCard/index.js +4 -0
  82. package/dist/components/SankeyCard/SankeyCard.chart.d.ts +75 -0
  83. package/dist/components/SankeyCard/SankeyCard.chart.js +249 -0
  84. package/dist/components/SankeyCard/SankeyCard.d.ts +185 -0
  85. package/dist/components/SankeyCard/SankeyCard.js +788 -0
  86. package/dist/components/SankeyCard/SankeyCard.model.d.ts +501 -0
  87. package/dist/components/SankeyCard/SankeyCard.model.js +789 -0
  88. package/dist/components/SankeyCard/index.d.ts +4 -0
  89. package/dist/components/SankeyCard/index.js +4 -0
  90. package/dist/components/StatusCard/StatusCard.chart.d.ts +60 -0
  91. package/dist/components/StatusCard/StatusCard.chart.js +188 -0
  92. package/dist/components/StatusCard/StatusCard.d.ts +176 -0
  93. package/dist/components/StatusCard/StatusCard.js +989 -0
  94. package/dist/components/StatusCard/StatusCard.model.d.ts +555 -0
  95. package/dist/components/StatusCard/StatusCard.model.js +776 -0
  96. package/dist/components/StatusCard/index.d.ts +4 -0
  97. package/dist/components/StatusCard/index.js +4 -0
  98. package/dist/components/TableCard/TableCard.chart.d.ts +53 -0
  99. package/dist/components/TableCard/TableCard.chart.js +192 -0
  100. package/dist/components/TableCard/TableCard.d.ts +140 -0
  101. package/dist/components/TableCard/TableCard.js +811 -0
  102. package/dist/components/TableCard/TableCard.model.d.ts +381 -0
  103. package/dist/components/TableCard/TableCard.model.js +475 -0
  104. package/dist/components/TableCard/index.d.ts +4 -0
  105. package/dist/components/TableCard/index.js +4 -0
  106. package/dist/components/TargetCard/TargetCard.chart.d.ts +52 -0
  107. package/dist/components/TargetCard/TargetCard.chart.js +273 -0
  108. package/dist/components/TargetCard/TargetCard.d.ts +143 -0
  109. package/dist/components/TargetCard/TargetCard.js +910 -0
  110. package/dist/components/TargetCard/TargetCard.model.d.ts +468 -0
  111. package/dist/components/TargetCard/TargetCard.model.js +581 -0
  112. package/dist/components/TargetCard/index.d.ts +4 -0
  113. package/dist/components/TargetCard/index.js +4 -0
  114. package/dist/components/TreeCard/TreeCard.chart.d.ts +80 -0
  115. package/dist/components/TreeCard/TreeCard.chart.js +178 -0
  116. package/dist/components/TreeCard/TreeCard.d.ts +182 -0
  117. package/dist/components/TreeCard/TreeCard.js +1187 -0
  118. package/dist/components/TreeCard/TreeCard.model.d.ts +330 -0
  119. package/dist/components/TreeCard/TreeCard.model.js +401 -0
  120. package/dist/components/TreeCard/index.d.ts +4 -0
  121. package/dist/components/TreeCard/index.js +4 -0
  122. package/dist/components/TrendCard/TrendCard.chart.d.ts +75 -0
  123. package/dist/components/TrendCard/TrendCard.chart.js +393 -0
  124. package/dist/components/TrendCard/TrendCard.d.ts +224 -0
  125. package/dist/components/TrendCard/TrendCard.js +1053 -0
  126. package/dist/components/TrendCard/TrendCard.model.d.ts +545 -0
  127. package/dist/components/TrendCard/TrendCard.model.js +623 -0
  128. package/dist/components/TrendCard/index.d.ts +4 -0
  129. package/dist/components/TrendCard/index.js +4 -0
  130. package/dist/index.d.ts +76 -0
  131. package/dist/index.js +57 -0
  132. package/dist/lib/vega/ArcVegaChart.d.ts +24 -0
  133. package/dist/lib/vega/ArcVegaChart.js +42 -0
  134. package/dist/lib/vega/ArcVegaSurface.d.ts +13 -0
  135. package/dist/lib/vega/ArcVegaSurface.js +38 -0
  136. package/dist/lib/vega/axisTicks.js +156 -0
  137. package/dist/lib/vega/config.d.ts +29 -0
  138. package/dist/lib/vega/config.js +113 -0
  139. package/dist/lib/vega/embed.d.ts +29 -0
  140. package/dist/lib/vega/embed.js +29 -0
  141. package/dist/lib/vega/engine.d.ts +10 -0
  142. package/dist/lib/vega/engine.js +6 -0
  143. package/dist/lib/vega/formatters.d.ts +125 -0
  144. package/dist/lib/vega/formatters.js +277 -0
  145. package/dist/lib/vega/index.d.ts +11 -0
  146. package/dist/lib/vega/load.d.ts +13 -0
  147. package/dist/lib/vega/load.js +21 -0
  148. package/dist/lib/vega/time.d.ts +45 -0
  149. package/dist/lib/vega/time.js +105 -0
  150. package/dist/lib/vega/tokens.d.ts +73 -0
  151. package/dist/lib/vega/tokens.js +137 -0
  152. package/dist/lib/vega/tooltip.d.ts +45 -0
  153. package/dist/lib/vega/tooltip.js +124 -0
  154. package/dist/lib/vega/useArcVega.d.ts +43 -0
  155. package/dist/lib/vega/useArcVega.js +121 -0
  156. 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 };