@opendata-ai/openchart-engine 6.5.2 → 6.7.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.
@@ -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
+ });