@jarenjs/charts 0.34.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 (56) hide show
  1. package/README.md +293 -0
  2. package/dist/types/component/index.d.ts +95 -0
  3. package/dist/types/core/axis.d.ts +77 -0
  4. package/dist/types/core/cartesian.d.ts +127 -0
  5. package/dist/types/core/chart.d.ts +58 -0
  6. package/dist/types/core/domain.d.ts +96 -0
  7. package/dist/types/core/marks.d.ts +86 -0
  8. package/dist/types/core/palette.d.ts +72 -0
  9. package/dist/types/core/scale.d.ts +59 -0
  10. package/dist/types/core/session.d.ts +85 -0
  11. package/dist/types/core/stream-adapter.d.ts +187 -0
  12. package/dist/types/index.d.ts +35 -0
  13. package/dist/types/transforms/benchmark-adapter.d.ts +169 -0
  14. package/dist/types/transforms/mermaid-adapter.d.ts +30 -0
  15. package/dist/types/types/bar.d.ts +213 -0
  16. package/dist/types/types/boxplot.d.ts +116 -0
  17. package/dist/types/types/candlestick.d.ts +218 -0
  18. package/dist/types/types/gauge.d.ts +68 -0
  19. package/dist/types/types/heatmap.d.ts +104 -0
  20. package/dist/types/types/line.d.ts +272 -0
  21. package/dist/types/types/map.d.ts +137 -0
  22. package/dist/types/types/pie.d.ts +146 -0
  23. package/dist/types/types/radar.d.ts +89 -0
  24. package/dist/types/types/sankey.d.ts +100 -0
  25. package/dist/types/types/scatter.d.ts +80 -0
  26. package/dist/types/types/streamgraph.d.ts +75 -0
  27. package/dist/types/types/treemap.d.ts +118 -0
  28. package/package.json +76 -0
  29. package/schemas/chart-definition.schema.json +448 -0
  30. package/src/component/index.js +125 -0
  31. package/src/core/axis.js +221 -0
  32. package/src/core/cartesian.js +192 -0
  33. package/src/core/chart.js +101 -0
  34. package/src/core/domain.js +123 -0
  35. package/src/core/marks.js +110 -0
  36. package/src/core/palette.js +126 -0
  37. package/src/core/scale.js +106 -0
  38. package/src/core/session.js +0 -0
  39. package/src/core/stream-adapter.js +613 -0
  40. package/src/index.js +40 -0
  41. package/src/transforms/benchmark-adapter.js +298 -0
  42. package/src/transforms/mermaid-adapter.js +19 -0
  43. package/src/types/bar.js +276 -0
  44. package/src/types/boxplot.js +216 -0
  45. package/src/types/candlestick.js +274 -0
  46. package/src/types/gauge.js +140 -0
  47. package/src/types/heatmap.js +176 -0
  48. package/src/types/line.js +349 -0
  49. package/src/types/map.js +378 -0
  50. package/src/types/pie.js +163 -0
  51. package/src/types/radar.js +224 -0
  52. package/src/types/sankey.js +391 -0
  53. package/src/types/scatter.js +148 -0
  54. package/src/types/streamgraph.js +158 -0
  55. package/src/types/treemap.js +322 -0
  56. package/styles/charts.css +83 -0
@@ -0,0 +1,224 @@
1
+ //@ts-check
2
+ /**
3
+ * @file The radar chart type: N named axes as spokes from a shared
4
+ * center, one polygon per series over a common 0..top value domain.
5
+ * Data shape:
6
+ *
7
+ * data = { axes: string[], series: [{ name, values: number[] }] }
8
+ * config = { type:'radar', title?, max? }
9
+ *
10
+ * The AST stays polar and geometry-free: axis angles in radians
11
+ * (12 o'clock = -π/2, clockwise), ring and vertex radii as fractions of
12
+ * the (unknown) outer radius. Non-finite or negative samples become
13
+ * `null` vertices — the polygon simply skips them, mirroring how the
14
+ * line type breaks its path on unplottable samples.
15
+ */
16
+
17
+ import {
18
+ svgRoot, rect, line as svgLine, textAt, textWidth, num, coord, polarPoint, anchorForAngle,
19
+ } from '@jarenjs/view/helpers';
20
+ import { clamp01 } from '@jarenjs/core/math';
21
+ import { axisTicksLinear, niceStep, formatTickValue } from '../core/axis.js';
22
+ import { FS_TICK, FS_LABEL, annotateChart, chartTitle } from '../core/cartesian.js';
23
+ import { CATEGORICAL, seriesColor } from '../core/palette.js';
24
+ import { normalizeTooltip, valueMark } from '../core/marks.js';
25
+
26
+ /**
27
+ * @typedef {object} RadarAST
28
+ * @property {'radar'} type
29
+ * @property {string|null} title
30
+ * @property {number} top value at the outer ring
31
+ * @property {{label: string, angle: number, labeled: boolean}[]} axes
32
+ * @property {number} labelEvery spoke-label stride (1 = label every axis)
33
+ * @property {{r: number, label: string}[]} rings tick fractions (0..1]
34
+ * @property {{name: string, points: ({angle: number, r: number}|null)[]}[]} series
35
+ * @property {{name: string, swatch: number}[]|null} legend
36
+ */
37
+
38
+ /**
39
+ * Build the geometry-free radar AST.
40
+ * `config.labelEvery` overrides the spoke-label stride; by default it
41
+ * is derived from the axis count (see {@link radarLabelEvery}).
42
+ * @param {any} data
43
+ * @param {any} [config]
44
+ * @returns {RadarAST}
45
+ */
46
+ export function buildRadarAST(data, config = {}) {
47
+ const axes = (data?.axes ?? []).map((a) => String(a));
48
+ const input = (data?.series ?? []).filter((s) => Array.isArray(s.values));
49
+ const n = axes.length;
50
+
51
+ let maxVal = 0;
52
+ for (const s of input) {
53
+ for (let i = 0; i < n; i++) {
54
+ const v = s.values[i];
55
+ if (typeof v === 'number' && Number.isFinite(v) && v > maxVal) maxVal = v;
56
+ }
57
+ }
58
+ let top;
59
+ if (typeof config.max === 'number' && Number.isFinite(config.max) && config.max > 0) {
60
+ top = config.max;
61
+ }
62
+ else {
63
+ if (maxVal === 0) maxVal = 1;
64
+ const step = niceStep(maxVal, 4);
65
+ top = step * Math.ceil(maxVal / step);
66
+ }
67
+
68
+ const angleOf = (i) => -Math.PI / 2 + (i / Math.max(1, n)) * Math.PI * 2;
69
+ const rings = axisTicksLinear(0, top, 4)
70
+ .filter((v) => v > 0)
71
+ .map((v) => ({ r: clamp01(v / top), label: formatTickValue(v) }));
72
+
73
+ const series = input.map((s) => ({
74
+ name: String(s.name ?? ''),
75
+ points: axes.map((_, i) => {
76
+ const v = s.values[i];
77
+ if (typeof v !== 'number' || !Number.isFinite(v) || v < 0) return null;
78
+ return { angle: angleOf(i), r: clamp01(v / top) };
79
+ }),
80
+ }));
81
+
82
+ const labelEvery = Number.isInteger(config.labelEvery) && config.labelEvery > 0
83
+ ? config.labelEvery
84
+ : radarLabelEvery(n);
85
+
86
+ return {
87
+ type: 'radar',
88
+ title: config.title ?? null,
89
+ top,
90
+ axes: axes.map((label, i) => ({ label, angle: angleOf(i), labeled: i % labelEvery === 0 })),
91
+ labelEvery,
92
+ rings,
93
+ series,
94
+ legend: series.length > 1 ? series.map((s, i) => ({ name: s.name, swatch: i })) : null,
95
+ };
96
+ }
97
+
98
+ /**
99
+ * How many axes apart the spoke labels stand. A radar's labels sit on
100
+ * a circle, so their room shrinks as the axis count grows while the
101
+ * radius stays fixed — past a dozen axes the text collides. The stride
102
+ * keeps at most {@link LABEL_BUDGET} labels drawn, whatever the axis
103
+ * count; every spoke is still drawn, and every axis is still named by
104
+ * the `<title>` on its spoke, so thinning costs no information.
105
+ * @param {number} axisCount
106
+ * @returns {number} a stride ≥ 1
107
+ */
108
+ function radarLabelEvery(axisCount) {
109
+ return axisCount <= LABEL_BUDGET ? 1 : Math.ceil(axisCount / LABEL_BUDGET);
110
+ }
111
+
112
+ /** The most spoke labels a fixed-radius radar reads cleanly with. */
113
+ const LABEL_BUDGET = 12;
114
+
115
+ /**
116
+ * Render a radar AST to a pure-vnode SVG: ring polygons and spokes for
117
+ * the scale, one translucent polygon with vertex dots per series, a
118
+ * swatch legend on the right.
119
+ * @param {RadarAST} ast
120
+ * @param {{tokens: Record<string,string>, cssVars: Record<string,string>}} theme
121
+ * @param {string} hash
122
+ * @param {{rootClass?: string, keyPrefix?: string, palette?: readonly string[],
123
+ * tooltip?: import('../core/marks.js').ChartTooltipSpec}} [options]
124
+ * @returns {any}
125
+ */
126
+ export function renderRadarAST(ast, theme, hash, options = {}) {
127
+ const t = theme.tokens;
128
+ const palette = options.palette ?? CATEGORICAL;
129
+ const tooltip = normalizeTooltip(options.tooltip);
130
+ const R = 120;
131
+ // Only drawn labels claim margin; a thinned radar is not padded for
132
+ // text it does not render.
133
+ const labelPad = Math.ceil(Math.max(40,
134
+ ...ast.axes.filter((a) => a.labeled).map((a) => textWidth(a.label, FS_LABEL) + 12)));
135
+ const cx = labelPad + R;
136
+ const top = (ast.title ? 40 : 20) + FS_LABEL;
137
+ const cy = top + R;
138
+ const children = [];
139
+
140
+ if (ast.title) {
141
+ children.push(chartTitle(cx, 24, ast.title, FS_LABEL + 3, t));
142
+ }
143
+
144
+ const px = (angle, r) => coord(polarPoint(cx, cy, R * r, angle).x);
145
+ const py = (angle, r) => coord(polarPoint(cx, cy, R * r, angle).y);
146
+ const ringPoints = (r) => ast.axes.map((a) => `${px(a.angle, r)},${py(a.angle, r)}`).join(' ');
147
+
148
+ // Scale chrome: concentric ring polygons, one spoke per axis.
149
+ for (const ring of ast.rings) {
150
+ if (ast.axes.length >= 3) {
151
+ children.push(['polygon', {
152
+ points: ringPoints(ring.r),
153
+ fill: 'none', stroke: t.grid, 'stroke-width': 1, class: 'chart-grid',
154
+ }]);
155
+ }
156
+ else {
157
+ children.push(['circle', {
158
+ cx: num(cx), cy: num(cy), r: coord(R * ring.r),
159
+ fill: 'none', stroke: t.grid, 'stroke-width': 1, class: 'chart-grid',
160
+ }]);
161
+ }
162
+ children.push(textAt(cx + 4, py(-Math.PI / 2, ring.r) + FS_TICK, ring.label, FS_TICK,
163
+ { fill: t.muted, class: 'chart-tick' }));
164
+ }
165
+ for (const axis of ast.axes) {
166
+ const spoke = svgLine(cx, cy, px(axis.angle, 1), py(axis.angle, 1),
167
+ { stroke: t.axis, 'stroke-width': 1, class: 'chart-axis' });
168
+ // A spoke whose label was thinned away carries the name as hover
169
+ // text instead, so the thinning hides text, never information.
170
+ children.push(axis.labeled ? spoke : [...spoke, ['title', {}, axis.label]]);
171
+ if (!axis.labeled) continue;
172
+ const anchor = anchorForAngle(axis.angle);
173
+ const c = Math.cos(axis.angle);
174
+ const s = Math.sin(axis.angle);
175
+ const dy = s > 0.3 ? FS_LABEL : s < -0.3 ? -4 : 4;
176
+ children.push(textAt(
177
+ coord(cx + (R + 8) * c), coord(cy + (R + 8) * s + dy),
178
+ axis.label, FS_LABEL,
179
+ { 'text-anchor': anchor, fill: t.muted, class: 'chart-axis-label' }));
180
+ }
181
+
182
+ // One polygon per series (unplottable vertices skipped), vertex dots.
183
+ for (let si = 0; si < ast.series.length; si++) {
184
+ const s = ast.series[si];
185
+ const color = seriesColor(si, palette);
186
+ const pts = [];
187
+ for (const p of s.points) {
188
+ if (p !== null) pts.push(`${px(p.angle, p.r)},${py(p.angle, p.r)}`);
189
+ }
190
+ if (pts.length === 0) continue;
191
+ children.push(valueMark('polygon', {
192
+ points: pts.join(' '),
193
+ fill: color, 'fill-opacity': 0.15,
194
+ stroke: color, 'stroke-width': 2, class: 'chart-radar-series',
195
+ }, tooltip, s.name, { type: 'radar', series: s.name }));
196
+ for (const p of s.points) {
197
+ if (p !== null) {
198
+ children.push(['circle', {
199
+ cx: px(p.angle, p.r), cy: py(p.angle, p.r), r: 2.5,
200
+ fill: color, class: 'chart-dot',
201
+ }]);
202
+ }
203
+ }
204
+ }
205
+
206
+ // Legend column on the right, the pie legend pattern.
207
+ const legendX = cx + R + labelPad + 10;
208
+ let legendW = 0;
209
+ if (ast.legend !== null) {
210
+ for (let i = 0; i < ast.legend.length; i++) {
211
+ const entry = ast.legend[i];
212
+ const y = top + i * 24;
213
+ legendW = Math.max(legendW, Math.ceil(textWidth(entry.name, FS_LABEL)) + 30);
214
+ children.push(rect(legendX, y, 14, 14, { fill: seriesColor(entry.swatch, palette), class: 'chart-swatch' }));
215
+ children.push(textAt(legendX + 20, y + 12, entry.name, FS_LABEL, { fill: t.text, class: 'chart-tick' }));
216
+ }
217
+ }
218
+
219
+ const width = legendX + legendW + (ast.legend !== null ? 20 : 0);
220
+ const height = cy + R + FS_LABEL + 20;
221
+ const svg = svgRoot(options.rootClass ?? 'chart chart-svg chart-radar-chart',
222
+ width, height, theme, children, (options.keyPrefix ?? 'radar-') + hash);
223
+ return annotateChart(svg, ast.title);
224
+ }
@@ -0,0 +1,391 @@
1
+ //@ts-check
2
+ /**
3
+ * @file The sankey chart type: named nodes in flow layers joined by
4
+ * value-proportional ribbons. Data shape:
5
+ *
6
+ * data = { nodes?: (string | { name })[],
7
+ * links: [{ source, target, value }] } // by name or node index
8
+ * config = { type:'sankey', title? }
9
+ *
10
+ * Names referenced only by links are added as nodes automatically. A
11
+ * link that would close a cycle is dropped (a sankey is a DAG); so are
12
+ * self-links and non-positive values. Layout: a node's layer is its
13
+ * longest path from the sources; within a layer nodes stack in input
14
+ * order, sized by throughput (max of in-flow and out-flow) on one
15
+ * global scale, centered vertically. The AST is unit-square with `y`
16
+ * growing downward (no axes — reading order wins, as in the treemap).
17
+ */
18
+
19
+ import { svgRoot, textAt, coord } from '@jarenjs/view/helpers';
20
+ import { FS_LABEL, annotateChart, chartTitle } from '../core/cartesian.js';
21
+ import { CATEGORICAL, seriesColor } from '../core/palette.js';
22
+ import { normalizeTooltip, valueMark } from '../core/marks.js';
23
+
24
+ /**
25
+ * @typedef {object} SankeyNodeAST
26
+ * @property {string} name
27
+ * @property {number} layer
28
+ * @property {number} x0 @property {number} x1
29
+ * @property {number} y0 @property {number} y1
30
+ */
31
+ /**
32
+ * @typedef {object} SankeyLinkAST
33
+ * @property {number} source node index @property {number} target node index
34
+ * @property {number} value
35
+ * @property {number} sy0 @property {number} sy1 ribbon extent at the source
36
+ * @property {number} ty0 @property {number} ty1 ribbon extent at the target
37
+ */
38
+ /**
39
+ * @typedef {object} SankeyAST
40
+ * @property {'sankey'} type
41
+ * @property {string|null} title
42
+ * @property {SankeyNodeAST[]} nodes
43
+ * @property {SankeyLinkAST[]} links
44
+ */
45
+
46
+ /**
47
+ * Build the geometry-free sankey AST.
48
+ * @param {any} data
49
+ * @param {any} [config]
50
+ * @returns {SankeyAST}
51
+ */
52
+ export function buildSankeyAST(data, config = {}) {
53
+ const names = [];
54
+ const indexOf = new Map();
55
+ const intern = (name) => {
56
+ let i = indexOf.get(name);
57
+ if (i === undefined) {
58
+ i = names.length;
59
+ names.push(name);
60
+ indexOf.set(name, i);
61
+ }
62
+ return i;
63
+ };
64
+ for (const node of data?.nodes ?? [])
65
+ intern(String(typeof node === 'object' && node !== null ? node.name : node));
66
+
67
+ /** Resolve a link endpoint: node index or name (names may be new). */
68
+ const resolve = (ref) => {
69
+ if (typeof ref === 'number')
70
+ return Number.isInteger(ref) && ref >= 0 && ref < names.length ? ref : null;
71
+ if (typeof ref === 'string') return intern(ref);
72
+ return null;
73
+ };
74
+
75
+ // Accept links in order; drop malformed ones and cycle-closers.
76
+ /** @type {number[][]} adjacency: out-neighbors per node */
77
+ const out = [];
78
+ const reaches = (from, to) => {
79
+ if (from === to) return true;
80
+ const stack = [from];
81
+ const seen = new Set([from]);
82
+ while (stack.length !== 0) {
83
+ for (const next of out[stack.pop()] ?? []) {
84
+ if (next === to) return true;
85
+ if (!seen.has(next)) { seen.add(next); stack.push(next); }
86
+ }
87
+ }
88
+ return false;
89
+ };
90
+ const links = [];
91
+ for (const link of data?.links ?? []) {
92
+ const source = resolve(link?.source);
93
+ const target = resolve(link?.target);
94
+ const value = link?.value;
95
+ if (source === null || target === null || source === target) continue;
96
+ if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) continue;
97
+ if (reaches(target, source)) continue;
98
+ (out[source] ??= []).push(target);
99
+ links.push({ source, target, value });
100
+ }
101
+
102
+ // Longest path from the sources, computed to a fixed point (the
103
+ // graph is acyclic by construction, so this terminates).
104
+ const layer = new Array(names.length).fill(0);
105
+ let changed = true;
106
+ while (changed) {
107
+ changed = false;
108
+ for (const link of links) {
109
+ if (layer[link.target] < layer[link.source] + 1) {
110
+ layer[link.target] = layer[link.source] + 1;
111
+ changed = true;
112
+ }
113
+ }
114
+ }
115
+ const maxLayer = Math.max(0, ...layer.filter((_, i) => hasFlow(i)));
116
+
117
+ function hasFlow(i) {
118
+ return links.some((l) => l.source === i || l.target === i);
119
+ }
120
+
121
+ // Throughput per node; one global value→height scale.
122
+ const inFlow = new Array(names.length).fill(0);
123
+ const outFlow = new Array(names.length).fill(0);
124
+ for (const link of links) {
125
+ outFlow[link.source] += link.value;
126
+ inFlow[link.target] += link.value;
127
+ }
128
+ const size = names.map((_, i) => Math.max(inFlow[i], outFlow[i]));
129
+
130
+ const gap = 0.04;
131
+ const byLayer = new Map();
132
+ for (let i = 0; i < names.length; i++) {
133
+ if (!hasFlow(i)) continue;
134
+ let list = byLayer.get(layer[i]);
135
+ if (list === undefined) byLayer.set(layer[i], list = []);
136
+ list.push(i);
137
+ }
138
+ reduceCrossings(byLayer, links);
139
+ /** @type {Map<number, number>} node → its position within its layer */
140
+ const rank = new Map();
141
+ for (const list of byLayer.values())
142
+ list.forEach((node, at) => rank.set(node, at));
143
+ let scale = Infinity;
144
+ for (const list of byLayer.values()) {
145
+ const total = list.reduce((s, i) => s + size[i], 0);
146
+ const room = 1 - gap * (list.length - 1);
147
+ if (total > 0 && room / total < scale) scale = room / total;
148
+ }
149
+ if (!Number.isFinite(scale)) scale = 0;
150
+
151
+ const nodeW = 0.035;
152
+ const nodes = [];
153
+ const nodeAt = new Array(names.length).fill(-1);
154
+ for (const [l, list] of byLayer) {
155
+ const totalH = list.reduce((s, i) => s + size[i] * scale, 0) + gap * (list.length - 1);
156
+ let y = (1 - totalH) / 2;
157
+ for (const i of list) {
158
+ const h = size[i] * scale;
159
+ nodeAt[i] = nodes.length;
160
+ nodes.push({
161
+ name: names[i],
162
+ layer: l,
163
+ x0: maxLayer === 0 ? 0 : (l / maxLayer) * (1 - nodeW),
164
+ x1: maxLayer === 0 ? nodeW : (l / maxLayer) * (1 - nodeW) + nodeW,
165
+ y0: y,
166
+ y1: y + h,
167
+ });
168
+ y += h + gap;
169
+ }
170
+ }
171
+
172
+ // Ribbon slots: links stack down each node face ordered by where
173
+ // their far end sits, not by input order — the local half of crossing
174
+ // reduction. Two ribbons leaving one node cross each other whenever
175
+ // their slots and their targets disagree, and that is decided here.
176
+ const outAt = names.map((_, i) => nodeAt[i] === -1 ? 0 : nodes[nodeAt[i]].y0);
177
+ const inAt = names.map((_, i) => nodeAt[i] === -1 ? 0 : nodes[nodeAt[i]].y0);
178
+ const sy = new Array(links.length);
179
+ const ty = new Array(links.length);
180
+ for (const at of orderedFaces(links, (l) => l.source, (l) => rank.get(l.target) ?? 0)) {
181
+ sy[at] = outAt[links[at].source];
182
+ outAt[links[at].source] += links[at].value * scale;
183
+ }
184
+ for (const at of orderedFaces(links, (l) => l.target, (l) => rank.get(l.source) ?? 0)) {
185
+ ty[at] = inAt[links[at].target];
186
+ inAt[links[at].target] += links[at].value * scale;
187
+ }
188
+ const linkAsts = links.map((link, at) => ({
189
+ source: nodeAt[link.source],
190
+ target: nodeAt[link.target],
191
+ value: link.value,
192
+ sy0: sy[at], sy1: sy[at] + link.value * scale,
193
+ ty0: ty[at], ty1: ty[at] + link.value * scale,
194
+ }));
195
+
196
+ return {
197
+ type: 'sankey',
198
+ title: config.title ?? null,
199
+ nodes,
200
+ links: linkAsts,
201
+ };
202
+ }
203
+
204
+ /**
205
+ * Link indexes grouped by one endpoint and ordered by where the other
206
+ * endpoint sits, groups in first-appearance order. The result is a flat
207
+ * index list: walking it assigns every face's slots top-down.
208
+ * @param {{source:number,target:number,value:number}[]} links
209
+ * @param {(l: any) => number} faceOf the node whose face the slot is on
210
+ * @param {(l: any) => number} keyOf the far end's position
211
+ * @returns {number[]} link indexes
212
+ */
213
+ function orderedFaces(links, faceOf, keyOf) {
214
+ /** @type {Map<number, number[]>} */
215
+ const faces = new Map();
216
+ for (let at = 0; at < links.length; at++) {
217
+ const face = faceOf(links[at]);
218
+ let list = faces.get(face);
219
+ if (list === undefined) faces.set(face, list = []);
220
+ list.push(at);
221
+ }
222
+ const out = [];
223
+ for (const list of faces.values()) {
224
+ // stable: equal far ends keep input order
225
+ list.sort((a, b) => keyOf(links[a]) - keyOf(links[b]));
226
+ out.push(...list);
227
+ }
228
+ return out;
229
+ }
230
+
231
+ /**
232
+ * Order the nodes inside each layer to reduce ribbon crossings, in
233
+ * place. The heuristic is the classic barycenter sweep: repeatedly
234
+ * place each node at the average position of its neighbors — value-
235
+ * weighted, because a thick ribbon crossing reads worse than a thin one
236
+ * — alternating down the layers and back up, and keeping whichever
237
+ * arrangement counted the fewest crossings. Input order is the starting
238
+ * arrangement and wins every tie, so a graph the sweeps cannot improve
239
+ * (and every graph with one node per layer) lays out exactly as it did
240
+ * before crossing reduction existed.
241
+ *
242
+ * Positions are compared as fractions of a layer's height, so a link
243
+ * that skips a layer is measured against the same scale as its
244
+ * neighbors.
245
+ * @param {Map<number, number[]>} byLayer layer number → node indexes
246
+ * @param {{source:number,target:number,value:number}[]} links
247
+ * @returns {void}
248
+ */
249
+ function reduceCrossings(byLayer, links) {
250
+ const layers = [...byLayer.keys()].sort((a, b) => a - b);
251
+ if (layers.length < 2 || links.length < 2) return;
252
+
253
+ /** @type {Map<number, {other:number, value:number}[]>} */
254
+ const inbound = new Map();
255
+ /** @type {Map<number, {other:number, value:number}[]>} */
256
+ const outbound = new Map();
257
+ for (const link of links) {
258
+ (inbound.get(link.target) ?? setDefault(inbound, link.target)).push({ other: link.source, value: link.value });
259
+ (outbound.get(link.source) ?? setDefault(outbound, link.source)).push({ other: link.target, value: link.value });
260
+ }
261
+
262
+ /** Normalized position of every node in the current arrangement. */
263
+ const positions = () => {
264
+ const pos = new Map();
265
+ for (const l of layers) {
266
+ const list = byLayer.get(l);
267
+ for (let i = 0; i < list.length; i++)
268
+ pos.set(list[i], list.length === 1 ? 0.5 : i / (list.length - 1));
269
+ }
270
+ return pos;
271
+ };
272
+
273
+ const crossings = () => {
274
+ const pos = positions();
275
+ let count = 0;
276
+ for (let a = 0; a < links.length; a++) {
277
+ for (let b = a + 1; b < links.length; b++) {
278
+ const ds = pos.get(links[a].source) - pos.get(links[b].source);
279
+ const dt = pos.get(links[a].target) - pos.get(links[b].target);
280
+ if (ds * dt < 0) count++;
281
+ }
282
+ }
283
+ return count;
284
+ };
285
+
286
+ const sweep = (side) => {
287
+ const pos = positions();
288
+ for (const l of layers) {
289
+ const list = byLayer.get(l);
290
+ const key = new Map();
291
+ for (let i = 0; i < list.length; i++) {
292
+ const edges = side.get(list[i]) ?? [];
293
+ let weight = 0;
294
+ let sum = 0;
295
+ for (const e of edges) {
296
+ weight += e.value;
297
+ sum += e.value * pos.get(e.other);
298
+ }
299
+ // no neighbors on this side: stay where you are
300
+ key.set(list[i], weight === 0 ? pos.get(list[i]) : sum / weight);
301
+ }
302
+ list.sort((a, b) => key.get(a) - key.get(b));
303
+ }
304
+ };
305
+
306
+ let best = layers.map((l) => byLayer.get(l).slice());
307
+ let bestCount = crossings();
308
+ for (let pass = 0; pass < 4 && bestCount !== 0; pass++) {
309
+ for (const side of [inbound, outbound]) {
310
+ sweep(side);
311
+ const count = crossings();
312
+ if (count < bestCount) {
313
+ bestCount = count;
314
+ best = layers.map((l) => byLayer.get(l).slice());
315
+ }
316
+ }
317
+ }
318
+ layers.forEach((l, i) => byLayer.set(l, best[i]));
319
+ }
320
+
321
+ /** Seed and return an empty adjacency list. */
322
+ function setDefault(map, key) {
323
+ const list = [];
324
+ map.set(key, list);
325
+ return list;
326
+ }
327
+
328
+ /**
329
+ * Render a sankey AST to a pure-vnode SVG: categorical node bars,
330
+ * translucent muted ribbons with flow `<title>`s, node labels beside
331
+ * the bar on its open side.
332
+ * @param {SankeyAST} ast
333
+ * @param {{tokens: Record<string,string>, cssVars: Record<string,string>}} theme
334
+ * @param {string} hash
335
+ * @param {{rootClass?: string, keyPrefix?: string, palette?: readonly string[], width?: number,
336
+ * tooltip?: import('../core/marks.js').ChartTooltipSpec}} [options]
337
+ * @returns {any}
338
+ */
339
+ export function renderSankeyAST(ast, theme, hash, options = {}) {
340
+ const t = theme.tokens;
341
+ const palette = options.palette ?? CATEGORICAL;
342
+ const tooltip = normalizeTooltip(options.tooltip);
343
+ const width = options.width ?? 560;
344
+ const top = ast.title ? 34 : 8;
345
+ const pad = 8;
346
+ const plotW = width - 2 * pad;
347
+ const plotH = 280;
348
+ const children = [];
349
+
350
+ if (ast.title) {
351
+ children.push(chartTitle(width / 2, 22, ast.title, 15, t));
352
+ }
353
+
354
+ const X = (x) => coord(pad + x * plotW);
355
+ const Y = (y) => coord(top + y * plotH);
356
+
357
+ for (const link of ast.links) {
358
+ const s = ast.nodes[link.source];
359
+ const target = ast.nodes[link.target];
360
+ const x0 = X(s.x1);
361
+ const x1 = X(target.x0);
362
+ const mx = coord((x0 + x1) / 2);
363
+ children.push(valueMark('path', {
364
+ d: `M${x0},${Y(link.sy0)} C${mx},${Y(link.sy0)} ${mx},${Y(link.ty0)} ${x1},${Y(link.ty0)} `
365
+ + `L${x1},${Y(link.ty1)} C${mx},${Y(link.ty1)} ${mx},${Y(link.sy1)} ${x0},${Y(link.sy1)} Z`,
366
+ fill: t.muted, 'fill-opacity': 0.3, class: 'chart-sankey-link',
367
+ }, tooltip, `${s.name} → ${target.name}: ${link.value}`,
368
+ { type: 'sankey', source: s.name, target: target.name, value: link.value }));
369
+ }
370
+
371
+ for (let i = 0; i < ast.nodes.length; i++) {
372
+ const node = ast.nodes[i];
373
+ children.push(valueMark('rect', {
374
+ x: X(node.x0), y: Y(node.y0),
375
+ width: coord((node.x1 - node.x0) * plotW),
376
+ height: coord(Math.max(1, (node.y1 - node.y0) * plotH)),
377
+ fill: seriesColor(i, palette), class: 'chart-sankey-node',
378
+ }, tooltip, node.name, { type: 'sankey', node: node.name }));
379
+ const onLeftHalf = (node.x0 + node.x1) / 2 < 0.5;
380
+ children.push(textAt(
381
+ onLeftHalf ? X(node.x1) + 5 : X(node.x0) - 5,
382
+ Y((node.y0 + node.y1) / 2) + 4,
383
+ node.name, FS_LABEL,
384
+ { 'text-anchor': onLeftHalf ? 'start' : 'end', fill: t.text, class: 'chart-sankey-label' }));
385
+ }
386
+
387
+ const height = top + plotH + 8;
388
+ const svg = svgRoot(options.rootClass ?? 'chart chart-svg chart-sankey-chart',
389
+ width, height, theme, children, (options.keyPrefix ?? 'sankey-') + hash);
390
+ return annotateChart(svg, ast.title);
391
+ }