@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.
- package/README.md +293 -0
- package/dist/types/component/index.d.ts +95 -0
- package/dist/types/core/axis.d.ts +77 -0
- package/dist/types/core/cartesian.d.ts +127 -0
- package/dist/types/core/chart.d.ts +58 -0
- package/dist/types/core/domain.d.ts +96 -0
- package/dist/types/core/marks.d.ts +86 -0
- package/dist/types/core/palette.d.ts +72 -0
- package/dist/types/core/scale.d.ts +59 -0
- package/dist/types/core/session.d.ts +85 -0
- package/dist/types/core/stream-adapter.d.ts +187 -0
- package/dist/types/index.d.ts +35 -0
- package/dist/types/transforms/benchmark-adapter.d.ts +169 -0
- package/dist/types/transforms/mermaid-adapter.d.ts +30 -0
- package/dist/types/types/bar.d.ts +213 -0
- package/dist/types/types/boxplot.d.ts +116 -0
- package/dist/types/types/candlestick.d.ts +218 -0
- package/dist/types/types/gauge.d.ts +68 -0
- package/dist/types/types/heatmap.d.ts +104 -0
- package/dist/types/types/line.d.ts +272 -0
- package/dist/types/types/map.d.ts +137 -0
- package/dist/types/types/pie.d.ts +146 -0
- package/dist/types/types/radar.d.ts +89 -0
- package/dist/types/types/sankey.d.ts +100 -0
- package/dist/types/types/scatter.d.ts +80 -0
- package/dist/types/types/streamgraph.d.ts +75 -0
- package/dist/types/types/treemap.d.ts +118 -0
- package/package.json +76 -0
- package/schemas/chart-definition.schema.json +448 -0
- package/src/component/index.js +125 -0
- package/src/core/axis.js +221 -0
- package/src/core/cartesian.js +192 -0
- package/src/core/chart.js +101 -0
- package/src/core/domain.js +123 -0
- package/src/core/marks.js +110 -0
- package/src/core/palette.js +126 -0
- package/src/core/scale.js +106 -0
- package/src/core/session.js +0 -0
- package/src/core/stream-adapter.js +613 -0
- package/src/index.js +40 -0
- package/src/transforms/benchmark-adapter.js +298 -0
- package/src/transforms/mermaid-adapter.js +19 -0
- package/src/types/bar.js +276 -0
- package/src/types/boxplot.js +216 -0
- package/src/types/candlestick.js +274 -0
- package/src/types/gauge.js +140 -0
- package/src/types/heatmap.js +176 -0
- package/src/types/line.js +349 -0
- package/src/types/map.js +378 -0
- package/src/types/pie.js +163 -0
- package/src/types/radar.js +224 -0
- package/src/types/sankey.js +391 -0
- package/src/types/scatter.js +148 -0
- package/src/types/streamgraph.js +158 -0
- package/src/types/treemap.js +322 -0
- 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
|
+
}
|