@opendata-ai/openchart-engine 6.5.1 → 6.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +42 -17
- package/dist/index.js +1331 -363
- package/dist/index.js.map +1 -1
- package/package.json +4 -2
- package/src/__tests__/dimensions.test.ts +47 -1
- package/src/annotations/__tests__/compute.test.ts +28 -0
- package/src/annotations/compute.ts +0 -8
- package/src/compile.ts +30 -0
- package/src/compiler/normalize.ts +25 -2
- package/src/compiler/types.ts +6 -1
- package/src/compiler/validate.ts +109 -5
- package/src/index.ts +9 -1
- package/src/layout/dimensions.ts +6 -1
- package/src/sankey/__tests__/compile-sankey.test.ts +353 -0
- package/src/sankey/__tests__/layout.test.ts +165 -0
- package/src/sankey/compile-sankey.ts +593 -0
- package/src/sankey/layout.ts +170 -0
- package/src/sankey/types.ts +36 -0
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { compileSankey } from '../../compile';
|
|
3
|
+
|
|
4
|
+
// ---------------------------------------------------------------------------
|
|
5
|
+
// Shared fixtures
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
|
|
8
|
+
const basicSpec = {
|
|
9
|
+
type: 'sankey' as const,
|
|
10
|
+
data: [
|
|
11
|
+
{ from: 'A', to: 'C', amount: 10 },
|
|
12
|
+
{ from: 'B', to: 'C', amount: 20 },
|
|
13
|
+
{ from: 'C', to: 'D', amount: 15 },
|
|
14
|
+
{ from: 'C', to: 'E', amount: 15 },
|
|
15
|
+
],
|
|
16
|
+
encoding: {
|
|
17
|
+
source: { field: 'from', type: 'nominal' as const },
|
|
18
|
+
target: { field: 'to', type: 'nominal' as const },
|
|
19
|
+
value: { field: 'amount', type: 'quantitative' as const },
|
|
20
|
+
},
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const defaultOptions = { width: 600, height: 400 };
|
|
24
|
+
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
// Tests
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
|
|
29
|
+
describe('compileSankey', () => {
|
|
30
|
+
it('compiles a basic sankey and returns correct node/link counts', () => {
|
|
31
|
+
const result = compileSankey(basicSpec, defaultOptions);
|
|
32
|
+
|
|
33
|
+
// A, B, C, D, E = 5 nodes
|
|
34
|
+
expect(result.nodes).toHaveLength(5);
|
|
35
|
+
// A->C, B->C, C->D, C->E = 4 links
|
|
36
|
+
expect(result.links).toHaveLength(4);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('infers nodes from unique source/target values in data', () => {
|
|
40
|
+
const result = compileSankey(basicSpec, defaultOptions);
|
|
41
|
+
|
|
42
|
+
const nodeIds = result.nodes.map((n) => n.nodeId).sort();
|
|
43
|
+
expect(nodeIds).toEqual(['A', 'B', 'C', 'D', 'E']);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('node positions are valid (x >= 0, y >= 0, width > 0, height > 0)', () => {
|
|
47
|
+
const result = compileSankey(basicSpec, defaultOptions);
|
|
48
|
+
|
|
49
|
+
for (const node of result.nodes) {
|
|
50
|
+
expect(node.x).toBeGreaterThanOrEqual(0);
|
|
51
|
+
expect(node.y).toBeGreaterThanOrEqual(0);
|
|
52
|
+
expect(node.width).toBeGreaterThan(0);
|
|
53
|
+
expect(node.height).toBeGreaterThan(0);
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('link paths are valid SVG path strings starting with M', () => {
|
|
58
|
+
const result = compileSankey(basicSpec, defaultOptions);
|
|
59
|
+
|
|
60
|
+
for (const link of result.links) {
|
|
61
|
+
expect(link.path).toBeTruthy();
|
|
62
|
+
expect(link.path[0]).toBe('M');
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('nodes get default colors from theme categorical palette', () => {
|
|
67
|
+
const result = compileSankey(basicSpec, defaultOptions);
|
|
68
|
+
|
|
69
|
+
for (const node of result.nodes) {
|
|
70
|
+
expect(node.fill).toBeTruthy();
|
|
71
|
+
// Should be a color string (hex, rgb, etc.)
|
|
72
|
+
expect(typeof node.fill).toBe('string');
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// With 5 nodes and no color encoding, each node gets a different palette slot
|
|
76
|
+
const fills = new Set(result.nodes.map((n) => n.fill));
|
|
77
|
+
expect(fills.size).toBe(5);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('color encoding groups nodes by category value', () => {
|
|
81
|
+
const spec = {
|
|
82
|
+
...basicSpec,
|
|
83
|
+
encoding: {
|
|
84
|
+
...basicSpec.encoding,
|
|
85
|
+
color: { field: 'from', type: 'nominal' as const },
|
|
86
|
+
},
|
|
87
|
+
};
|
|
88
|
+
const result = compileSankey(spec, defaultOptions);
|
|
89
|
+
|
|
90
|
+
// Nodes that share the same color field value should share the same color.
|
|
91
|
+
// "from" field: A has "A", B has "B", C is a target (gets the color of
|
|
92
|
+
// the first row where it appears as source or target).
|
|
93
|
+
const nodeA = result.nodes.find((n) => n.nodeId === 'A')!;
|
|
94
|
+
const nodeB = result.nodes.find((n) => n.nodeId === 'B')!;
|
|
95
|
+
// A and B have different "from" categories, so different colors
|
|
96
|
+
expect(nodeA.fill).not.toBe(nodeB.fill);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
describe('linkStyle', () => {
|
|
100
|
+
it('gradient: sourceColor and targetColor differ (match connected nodes)', () => {
|
|
101
|
+
const spec = { ...basicSpec, linkStyle: 'gradient' as const };
|
|
102
|
+
const result = compileSankey(spec, defaultOptions);
|
|
103
|
+
|
|
104
|
+
// Link from A->C: source and target have different colors
|
|
105
|
+
const acLink = result.links.find((l) => l.sourceId === 'A' && l.targetId === 'C')!;
|
|
106
|
+
const nodeA = result.nodes.find((n) => n.nodeId === 'A')!;
|
|
107
|
+
const nodeC = result.nodes.find((n) => n.nodeId === 'C')!;
|
|
108
|
+
expect(acLink.sourceColor).toBe(nodeA.fill);
|
|
109
|
+
expect(acLink.targetColor).toBe(nodeC.fill);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('source: both colors equal source node fill', () => {
|
|
113
|
+
const spec = { ...basicSpec, linkStyle: 'source' as const };
|
|
114
|
+
const result = compileSankey(spec, defaultOptions);
|
|
115
|
+
|
|
116
|
+
const acLink = result.links.find((l) => l.sourceId === 'A' && l.targetId === 'C')!;
|
|
117
|
+
const nodeA = result.nodes.find((n) => n.nodeId === 'A')!;
|
|
118
|
+
expect(acLink.sourceColor).toBe(nodeA.fill);
|
|
119
|
+
expect(acLink.targetColor).toBe(nodeA.fill);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('target: both colors equal target node fill', () => {
|
|
123
|
+
const spec = { ...basicSpec, linkStyle: 'target' as const };
|
|
124
|
+
const result = compileSankey(spec, defaultOptions);
|
|
125
|
+
|
|
126
|
+
const acLink = result.links.find((l) => l.sourceId === 'A' && l.targetId === 'C')!;
|
|
127
|
+
const nodeC = result.nodes.find((n) => n.nodeId === 'C')!;
|
|
128
|
+
expect(acLink.sourceColor).toBe(nodeC.fill);
|
|
129
|
+
expect(acLink.targetColor).toBe(nodeC.fill);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it('neutral: both colors are the same muted value', () => {
|
|
133
|
+
const spec = { ...basicSpec, linkStyle: 'neutral' as const };
|
|
134
|
+
const result = compileSankey(spec, defaultOptions);
|
|
135
|
+
|
|
136
|
+
for (const link of result.links) {
|
|
137
|
+
expect(link.sourceColor).toBe(link.targetColor);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// All neutral links share the same color
|
|
141
|
+
const colors = new Set(result.links.map((l) => l.sourceColor));
|
|
142
|
+
expect(colors.size).toBe(1);
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
describe('chrome', () => {
|
|
147
|
+
it('resolves title and subtitle in output', () => {
|
|
148
|
+
const spec = {
|
|
149
|
+
...basicSpec,
|
|
150
|
+
chrome: {
|
|
151
|
+
title: 'Energy Flow',
|
|
152
|
+
subtitle: 'US energy sources to end uses',
|
|
153
|
+
},
|
|
154
|
+
};
|
|
155
|
+
const result = compileSankey(spec, defaultOptions);
|
|
156
|
+
|
|
157
|
+
expect(result.chrome.title).toBeDefined();
|
|
158
|
+
expect(result.chrome.title!.text).toBe('Energy Flow');
|
|
159
|
+
expect(result.chrome.subtitle).toBeDefined();
|
|
160
|
+
expect(result.chrome.subtitle!.text).toBe('US energy sources to end uses');
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
describe('legend', () => {
|
|
165
|
+
it('entries match unique node colors when color encoding is set', () => {
|
|
166
|
+
const spec = {
|
|
167
|
+
...basicSpec,
|
|
168
|
+
encoding: {
|
|
169
|
+
...basicSpec.encoding,
|
|
170
|
+
color: { field: 'from', type: 'nominal' as const },
|
|
171
|
+
},
|
|
172
|
+
};
|
|
173
|
+
const result = compileSankey(spec, defaultOptions);
|
|
174
|
+
|
|
175
|
+
// Legend should have entries for the unique color categories
|
|
176
|
+
expect(result.legend.entries.length).toBeGreaterThan(0);
|
|
177
|
+
for (const entry of result.legend.entries) {
|
|
178
|
+
expect(entry.label).toBeTruthy();
|
|
179
|
+
expect(entry.color).toBeTruthy();
|
|
180
|
+
}
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it('has no legend entries when no color encoding is set', () => {
|
|
184
|
+
const result = compileSankey(basicSpec, defaultOptions);
|
|
185
|
+
|
|
186
|
+
// Without color encoding, no legend needed
|
|
187
|
+
expect(result.legend.entries).toHaveLength(0);
|
|
188
|
+
});
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
describe('tooltip descriptors', () => {
|
|
192
|
+
it('contains entries for nodes keyed as node-{id}', () => {
|
|
193
|
+
const result = compileSankey(basicSpec, defaultOptions);
|
|
194
|
+
|
|
195
|
+
expect(result.tooltipDescriptors.has('node-A')).toBe(true);
|
|
196
|
+
expect(result.tooltipDescriptors.has('node-B')).toBe(true);
|
|
197
|
+
expect(result.tooltipDescriptors.has('node-C')).toBe(true);
|
|
198
|
+
expect(result.tooltipDescriptors.has('node-D')).toBe(true);
|
|
199
|
+
expect(result.tooltipDescriptors.has('node-E')).toBe(true);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it('contains entries for links keyed as link-{source}-{target}', () => {
|
|
203
|
+
const result = compileSankey(basicSpec, defaultOptions);
|
|
204
|
+
|
|
205
|
+
expect(result.tooltipDescriptors.has('link-A-C')).toBe(true);
|
|
206
|
+
expect(result.tooltipDescriptors.has('link-B-C')).toBe(true);
|
|
207
|
+
expect(result.tooltipDescriptors.has('link-C-D')).toBe(true);
|
|
208
|
+
expect(result.tooltipDescriptors.has('link-C-E')).toBe(true);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it('node tooltip has title and flow field', () => {
|
|
212
|
+
const result = compileSankey(basicSpec, defaultOptions);
|
|
213
|
+
|
|
214
|
+
const tooltip = result.tooltipDescriptors.get('node-A')!;
|
|
215
|
+
expect(tooltip.title).toBeTruthy();
|
|
216
|
+
expect(tooltip.fields.length).toBeGreaterThan(0);
|
|
217
|
+
expect(tooltip.fields.some((f) => f.label === 'Total flow')).toBe(true);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it('link tooltip has title and flow field', () => {
|
|
221
|
+
const result = compileSankey(basicSpec, defaultOptions);
|
|
222
|
+
|
|
223
|
+
const tooltip = result.tooltipDescriptors.get('link-A-C')!;
|
|
224
|
+
expect(tooltip.title).toContain('A');
|
|
225
|
+
expect(tooltip.title).toContain('C');
|
|
226
|
+
expect(tooltip.fields.some((f) => f.label === 'Flow')).toBe(true);
|
|
227
|
+
});
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
describe('animation', () => {
|
|
231
|
+
it('node animation indices increase left-to-right by column depth', () => {
|
|
232
|
+
const result = compileSankey(basicSpec, defaultOptions);
|
|
233
|
+
|
|
234
|
+
// Group nodes by depth
|
|
235
|
+
const byDepth = new Map<number, number[]>();
|
|
236
|
+
for (const node of result.nodes) {
|
|
237
|
+
const indices = byDepth.get(node.depth) ?? [];
|
|
238
|
+
indices.push(node.animationIndex);
|
|
239
|
+
byDepth.set(node.depth, indices);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// All indices in a shallower column should be less than all indices
|
|
243
|
+
// in a deeper column
|
|
244
|
+
const depths = [...byDepth.keys()].sort((a, b) => a - b);
|
|
245
|
+
for (let i = 0; i < depths.length - 1; i++) {
|
|
246
|
+
const currentMax = Math.max(...byDepth.get(depths[i])!);
|
|
247
|
+
const nextMin = Math.min(...byDepth.get(depths[i + 1])!);
|
|
248
|
+
expect(currentMax).toBeLessThan(nextMin);
|
|
249
|
+
}
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
it('link animation indices come after all node indices', () => {
|
|
253
|
+
const spec = { ...basicSpec, animation: true };
|
|
254
|
+
const result = compileSankey(spec, defaultOptions);
|
|
255
|
+
|
|
256
|
+
const maxNodeIndex = Math.max(...result.nodes.map((n) => n.animationIndex));
|
|
257
|
+
const minLinkIndex = Math.min(...result.links.map((l) => l.animationIndex));
|
|
258
|
+
expect(minLinkIndex).toBeGreaterThan(maxNodeIndex);
|
|
259
|
+
});
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
describe('a11y', () => {
|
|
263
|
+
it('generates descriptive alt text', () => {
|
|
264
|
+
const result = compileSankey(basicSpec, defaultOptions);
|
|
265
|
+
|
|
266
|
+
expect(result.a11y.altText).toContain('5 nodes');
|
|
267
|
+
expect(result.a11y.altText).toContain('4 links');
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
it('has a data table fallback', () => {
|
|
271
|
+
const result = compileSankey(basicSpec, defaultOptions);
|
|
272
|
+
|
|
273
|
+
expect(result.a11y.dataTableFallback.length).toBeGreaterThan(0);
|
|
274
|
+
});
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
describe('dimensions', () => {
|
|
278
|
+
it('reflects the compile options', () => {
|
|
279
|
+
const result = compileSankey(basicSpec, defaultOptions);
|
|
280
|
+
|
|
281
|
+
expect(result.dimensions.width).toBe(600);
|
|
282
|
+
expect(result.dimensions.height).toBe(400);
|
|
283
|
+
});
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
describe('dark mode', () => {
|
|
287
|
+
it('applies dark mode theme when option is set', () => {
|
|
288
|
+
const result = compileSankey(basicSpec, { ...defaultOptions, darkMode: true });
|
|
289
|
+
|
|
290
|
+
expect(result.theme.isDark).toBe(true);
|
|
291
|
+
});
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
describe('validation', () => {
|
|
295
|
+
it('throws when data is empty', () => {
|
|
296
|
+
const spec = {
|
|
297
|
+
...basicSpec,
|
|
298
|
+
data: [],
|
|
299
|
+
};
|
|
300
|
+
|
|
301
|
+
expect(() => compileSankey(spec, defaultOptions)).toThrow();
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
it('throws when source encoding is missing', () => {
|
|
305
|
+
const spec = {
|
|
306
|
+
...basicSpec,
|
|
307
|
+
encoding: {
|
|
308
|
+
target: { field: 'to', type: 'nominal' as const },
|
|
309
|
+
value: { field: 'amount', type: 'quantitative' as const },
|
|
310
|
+
},
|
|
311
|
+
};
|
|
312
|
+
|
|
313
|
+
expect(() => compileSankey(spec, defaultOptions)).toThrow();
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
it('throws when target encoding is missing', () => {
|
|
317
|
+
const spec = {
|
|
318
|
+
...basicSpec,
|
|
319
|
+
encoding: {
|
|
320
|
+
source: { field: 'from', type: 'nominal' as const },
|
|
321
|
+
value: { field: 'amount', type: 'quantitative' as const },
|
|
322
|
+
},
|
|
323
|
+
};
|
|
324
|
+
|
|
325
|
+
expect(() => compileSankey(spec, defaultOptions)).toThrow();
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
it('throws when value encoding is missing', () => {
|
|
329
|
+
const spec = {
|
|
330
|
+
...basicSpec,
|
|
331
|
+
encoding: {
|
|
332
|
+
source: { field: 'from', type: 'nominal' as const },
|
|
333
|
+
target: { field: 'to', type: 'nominal' as const },
|
|
334
|
+
},
|
|
335
|
+
};
|
|
336
|
+
|
|
337
|
+
expect(() => compileSankey(spec, defaultOptions)).toThrow();
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
it('throws for non-sankey specs', () => {
|
|
341
|
+
const chartSpec = {
|
|
342
|
+
mark: 'bar' as const,
|
|
343
|
+
data: [{ x: 1, y: 2 }],
|
|
344
|
+
encoding: {
|
|
345
|
+
x: { field: 'x', type: 'quantitative' as const },
|
|
346
|
+
y: { field: 'y', type: 'quantitative' as const },
|
|
347
|
+
},
|
|
348
|
+
};
|
|
349
|
+
|
|
350
|
+
expect(() => compileSankey(chartSpec, defaultOptions)).toThrow(/non-sankey spec/);
|
|
351
|
+
});
|
|
352
|
+
});
|
|
353
|
+
});
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { computeSankeyLayout } from '../layout';
|
|
3
|
+
|
|
4
|
+
// ---------------------------------------------------------------------------
|
|
5
|
+
// Shared fixtures
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
|
|
8
|
+
/** Linear chain: A -> B -> C (3 columns) */
|
|
9
|
+
const linearData = [
|
|
10
|
+
{ source: 'A', target: 'B', value: 10 },
|
|
11
|
+
{ source: 'B', target: 'C', value: 10 },
|
|
12
|
+
];
|
|
13
|
+
|
|
14
|
+
/** Branching: A -> B, A -> C */
|
|
15
|
+
const branchData = [
|
|
16
|
+
{ source: 'A', target: 'B', value: 10 },
|
|
17
|
+
{ source: 'A', target: 'C', value: 20 },
|
|
18
|
+
];
|
|
19
|
+
|
|
20
|
+
const area = { x: 0, y: 0, width: 600, height: 400 };
|
|
21
|
+
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
// Tests
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
|
|
26
|
+
describe('computeSankeyLayout', () => {
|
|
27
|
+
it('linear chain produces correct column count (depths 0, 1, 2)', () => {
|
|
28
|
+
const { nodes } = computeSankeyLayout(
|
|
29
|
+
linearData,
|
|
30
|
+
'source',
|
|
31
|
+
'target',
|
|
32
|
+
'value',
|
|
33
|
+
area,
|
|
34
|
+
12,
|
|
35
|
+
16,
|
|
36
|
+
'justify',
|
|
37
|
+
6,
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
const depths = new Set(nodes.map((n) => n.depth));
|
|
41
|
+
expect(depths.size).toBe(3);
|
|
42
|
+
expect(depths).toContain(0);
|
|
43
|
+
expect(depths).toContain(1);
|
|
44
|
+
expect(depths).toContain(2);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('nodeWidth is respected', () => {
|
|
48
|
+
const nodeWidth = 20;
|
|
49
|
+
const { nodes } = computeSankeyLayout(
|
|
50
|
+
linearData,
|
|
51
|
+
'source',
|
|
52
|
+
'target',
|
|
53
|
+
'value',
|
|
54
|
+
area,
|
|
55
|
+
nodeWidth,
|
|
56
|
+
16,
|
|
57
|
+
'justify',
|
|
58
|
+
6,
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
for (const node of nodes) {
|
|
62
|
+
const computedWidth = (node.x1 ?? 0) - (node.x0 ?? 0);
|
|
63
|
+
expect(computedWidth).toBeCloseTo(nodeWidth, 0);
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('nodePadding ensures vertical gap between nodes in the same column', () => {
|
|
68
|
+
const padding = 24;
|
|
69
|
+
// Use left alignment for a deterministic test (justify may spread nodes across columns).
|
|
70
|
+
const { nodes: leftNodes } = computeSankeyLayout(
|
|
71
|
+
branchData,
|
|
72
|
+
'source',
|
|
73
|
+
'target',
|
|
74
|
+
'value',
|
|
75
|
+
area,
|
|
76
|
+
12,
|
|
77
|
+
padding,
|
|
78
|
+
'left',
|
|
79
|
+
6,
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
const col1 = leftNodes.filter((n) => n.depth === 1).sort((a, b) => (a.y0 ?? 0) - (b.y0 ?? 0));
|
|
83
|
+
|
|
84
|
+
if (col1.length >= 2) {
|
|
85
|
+
for (let i = 0; i < col1.length - 1; i++) {
|
|
86
|
+
const gap = (col1[i + 1].y0 ?? 0) - (col1[i].y1 ?? 0);
|
|
87
|
+
expect(gap).toBeGreaterThanOrEqual(padding - 1); // Allow 1px rounding
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('different nodeAlign values produce different layouts', () => {
|
|
93
|
+
const justifyResult = computeSankeyLayout(
|
|
94
|
+
linearData,
|
|
95
|
+
'source',
|
|
96
|
+
'target',
|
|
97
|
+
'value',
|
|
98
|
+
area,
|
|
99
|
+
12,
|
|
100
|
+
16,
|
|
101
|
+
'justify',
|
|
102
|
+
6,
|
|
103
|
+
);
|
|
104
|
+
|
|
105
|
+
const leftResult = computeSankeyLayout(
|
|
106
|
+
linearData,
|
|
107
|
+
'source',
|
|
108
|
+
'target',
|
|
109
|
+
'value',
|
|
110
|
+
area,
|
|
111
|
+
12,
|
|
112
|
+
16,
|
|
113
|
+
'left',
|
|
114
|
+
6,
|
|
115
|
+
);
|
|
116
|
+
|
|
117
|
+
// For a linear chain with justify vs left, node positions may differ.
|
|
118
|
+
// At minimum, both should produce valid layouts with the right node count.
|
|
119
|
+
expect(justifyResult.nodes).toHaveLength(3);
|
|
120
|
+
expect(leftResult.nodes).toHaveLength(3);
|
|
121
|
+
|
|
122
|
+
// The x positions should be the same for a linear chain (both align
|
|
123
|
+
// left-to-right), but verify the layouts are structurally sound.
|
|
124
|
+
for (const node of justifyResult.nodes) {
|
|
125
|
+
expect(node.x0).toBeDefined();
|
|
126
|
+
expect(node.x1).toBeDefined();
|
|
127
|
+
expect(node.y0).toBeDefined();
|
|
128
|
+
expect(node.y1).toBeDefined();
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it('returns correct link count', () => {
|
|
133
|
+
const { links } = computeSankeyLayout(
|
|
134
|
+
linearData,
|
|
135
|
+
'source',
|
|
136
|
+
'target',
|
|
137
|
+
'value',
|
|
138
|
+
area,
|
|
139
|
+
12,
|
|
140
|
+
16,
|
|
141
|
+
'justify',
|
|
142
|
+
6,
|
|
143
|
+
);
|
|
144
|
+
|
|
145
|
+
expect(links).toHaveLength(2);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it('link values match input data', () => {
|
|
149
|
+
const { links } = computeSankeyLayout(
|
|
150
|
+
linearData,
|
|
151
|
+
'source',
|
|
152
|
+
'target',
|
|
153
|
+
'value',
|
|
154
|
+
area,
|
|
155
|
+
12,
|
|
156
|
+
16,
|
|
157
|
+
'justify',
|
|
158
|
+
6,
|
|
159
|
+
);
|
|
160
|
+
|
|
161
|
+
for (const link of links) {
|
|
162
|
+
expect(link.value).toBe(10);
|
|
163
|
+
}
|
|
164
|
+
});
|
|
165
|
+
});
|