@opendata-ai/openchart-engine 1.2.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 (85) hide show
  1. package/dist/index.d.ts +366 -0
  2. package/dist/index.js +4227 -0
  3. package/dist/index.js.map +1 -0
  4. package/package.json +62 -0
  5. package/src/__test-fixtures__/specs.ts +124 -0
  6. package/src/__tests__/axes.test.ts +114 -0
  7. package/src/__tests__/compile-chart.test.ts +337 -0
  8. package/src/__tests__/dimensions.test.ts +151 -0
  9. package/src/__tests__/legend.test.ts +113 -0
  10. package/src/__tests__/scales.test.ts +109 -0
  11. package/src/annotations/__tests__/compute.test.ts +454 -0
  12. package/src/annotations/compute.ts +603 -0
  13. package/src/charts/__tests__/registry.test.ts +110 -0
  14. package/src/charts/bar/__tests__/compute.test.ts +294 -0
  15. package/src/charts/bar/__tests__/labels.test.ts +75 -0
  16. package/src/charts/bar/compute.ts +205 -0
  17. package/src/charts/bar/index.ts +33 -0
  18. package/src/charts/bar/labels.ts +132 -0
  19. package/src/charts/column/__tests__/compute.test.ts +277 -0
  20. package/src/charts/column/compute.ts +282 -0
  21. package/src/charts/column/index.ts +33 -0
  22. package/src/charts/column/labels.ts +108 -0
  23. package/src/charts/dot/__tests__/compute.test.ts +344 -0
  24. package/src/charts/dot/compute.ts +257 -0
  25. package/src/charts/dot/index.ts +46 -0
  26. package/src/charts/dot/labels.ts +97 -0
  27. package/src/charts/line/__tests__/compute.test.ts +437 -0
  28. package/src/charts/line/__tests__/labels.test.ts +93 -0
  29. package/src/charts/line/area.ts +288 -0
  30. package/src/charts/line/compute.ts +177 -0
  31. package/src/charts/line/index.ts +68 -0
  32. package/src/charts/line/labels.ts +144 -0
  33. package/src/charts/pie/__tests__/compute.test.ts +276 -0
  34. package/src/charts/pie/compute.ts +234 -0
  35. package/src/charts/pie/index.ts +49 -0
  36. package/src/charts/pie/labels.ts +142 -0
  37. package/src/charts/registry.ts +64 -0
  38. package/src/charts/scatter/__tests__/compute.test.ts +304 -0
  39. package/src/charts/scatter/__tests__/trendline.test.ts +191 -0
  40. package/src/charts/scatter/compute.ts +124 -0
  41. package/src/charts/scatter/index.ts +41 -0
  42. package/src/charts/scatter/trendline.ts +100 -0
  43. package/src/charts/utils.ts +120 -0
  44. package/src/compile.ts +368 -0
  45. package/src/compiler/__tests__/compile.test.ts +87 -0
  46. package/src/compiler/__tests__/normalize.test.ts +210 -0
  47. package/src/compiler/__tests__/validate.test.ts +440 -0
  48. package/src/compiler/index.ts +47 -0
  49. package/src/compiler/normalize.ts +269 -0
  50. package/src/compiler/types.ts +148 -0
  51. package/src/compiler/validate.ts +581 -0
  52. package/src/graphs/__tests__/community.test.ts +228 -0
  53. package/src/graphs/__tests__/compile-graph.test.ts +315 -0
  54. package/src/graphs/__tests__/encoding.test.ts +314 -0
  55. package/src/graphs/community.ts +92 -0
  56. package/src/graphs/compile-graph.ts +291 -0
  57. package/src/graphs/encoding.ts +302 -0
  58. package/src/graphs/types.ts +98 -0
  59. package/src/index.ts +74 -0
  60. package/src/layout/axes.ts +194 -0
  61. package/src/layout/dimensions.ts +199 -0
  62. package/src/layout/gridlines.ts +84 -0
  63. package/src/layout/scales.ts +426 -0
  64. package/src/legend/compute.ts +186 -0
  65. package/src/tables/__tests__/bar-column.test.ts +147 -0
  66. package/src/tables/__tests__/category-colors.test.ts +153 -0
  67. package/src/tables/__tests__/compile-table.test.ts +208 -0
  68. package/src/tables/__tests__/format-cells.test.ts +126 -0
  69. package/src/tables/__tests__/heatmap.test.ts +124 -0
  70. package/src/tables/__tests__/pagination.test.ts +78 -0
  71. package/src/tables/__tests__/search.test.ts +94 -0
  72. package/src/tables/__tests__/sort.test.ts +107 -0
  73. package/src/tables/__tests__/sparkline.test.ts +122 -0
  74. package/src/tables/bar-column.ts +94 -0
  75. package/src/tables/category-colors.ts +67 -0
  76. package/src/tables/compile-table.ts +420 -0
  77. package/src/tables/format-cells.ts +110 -0
  78. package/src/tables/heatmap.ts +121 -0
  79. package/src/tables/pagination.ts +46 -0
  80. package/src/tables/search.ts +66 -0
  81. package/src/tables/sort.ts +69 -0
  82. package/src/tables/sparkline.ts +113 -0
  83. package/src/tables/utils.ts +16 -0
  84. package/src/tooltips/__tests__/compute.test.ts +328 -0
  85. package/src/tooltips/compute.ts +231 -0
@@ -0,0 +1,228 @@
1
+ import { resolveTheme } from '@opendata-ai/openchart-core';
2
+ import { describe, expect, it } from 'vitest';
3
+ import { applyCommunityColors, assignCommunities, buildCommunityColorMap } from '../community';
4
+ import type { CompiledGraphNode } from '../types';
5
+
6
+ // ---------------------------------------------------------------------------
7
+ // Shared fixtures
8
+ // ---------------------------------------------------------------------------
9
+
10
+ const theme = resolveTheme({});
11
+
12
+ function makeNodes(): CompiledGraphNode[] {
13
+ return [
14
+ {
15
+ id: 'a',
16
+ radius: 5,
17
+ fill: '#aaa',
18
+ stroke: '#888',
19
+ strokeWidth: 1,
20
+ label: 'A',
21
+ labelPriority: 0.5,
22
+ community: undefined,
23
+ data: { id: 'a', group: 'X', department: 'eng' },
24
+ },
25
+ {
26
+ id: 'b',
27
+ radius: 5,
28
+ fill: '#bbb',
29
+ stroke: '#999',
30
+ strokeWidth: 1,
31
+ label: 'B',
32
+ labelPriority: 0.3,
33
+ community: undefined,
34
+ data: { id: 'b', group: 'X', department: 'eng' },
35
+ },
36
+ {
37
+ id: 'c',
38
+ radius: 5,
39
+ fill: '#ccc',
40
+ stroke: '#aaa',
41
+ strokeWidth: 1,
42
+ label: 'C',
43
+ labelPriority: 0.8,
44
+ community: undefined,
45
+ data: { id: 'c', group: 'Y', department: 'sales' },
46
+ },
47
+ {
48
+ id: 'd',
49
+ radius: 5,
50
+ fill: '#ddd',
51
+ stroke: '#bbb',
52
+ strokeWidth: 1,
53
+ label: 'D',
54
+ labelPriority: 0.2,
55
+ community: undefined,
56
+ data: { id: 'd' }, // no group field
57
+ },
58
+ ];
59
+ }
60
+
61
+ // ---------------------------------------------------------------------------
62
+ // assignCommunities tests
63
+ // ---------------------------------------------------------------------------
64
+
65
+ describe('assignCommunities', () => {
66
+ it('assigns community labels from the specified field', () => {
67
+ const nodes = makeNodes();
68
+ assignCommunities(nodes, 'group');
69
+
70
+ expect(nodes[0].community).toBe('X');
71
+ expect(nodes[1].community).toBe('X');
72
+ expect(nodes[2].community).toBe('Y');
73
+ });
74
+
75
+ it('leaves community undefined when field is missing on a node', () => {
76
+ const nodes = makeNodes();
77
+ assignCommunities(nodes, 'group');
78
+
79
+ // Node 'd' doesn't have the 'group' field
80
+ expect(nodes[3].community).toBeUndefined();
81
+ });
82
+
83
+ it('does nothing when clusteringField is undefined', () => {
84
+ const nodes = makeNodes();
85
+ assignCommunities(nodes, undefined);
86
+
87
+ for (const node of nodes) {
88
+ expect(node.community).toBeUndefined();
89
+ }
90
+ });
91
+
92
+ it('handles non-string field values by converting to string', () => {
93
+ const nodes = makeNodes();
94
+ // Add a numeric field value
95
+ nodes[0].data.numericGroup = 42;
96
+ assignCommunities(nodes, 'numericGroup');
97
+
98
+ expect(nodes[0].community).toBe('42');
99
+ });
100
+ });
101
+
102
+ // ---------------------------------------------------------------------------
103
+ // buildCommunityColorMap tests
104
+ // ---------------------------------------------------------------------------
105
+
106
+ describe('buildCommunityColorMap', () => {
107
+ it('creates a color for each unique community', () => {
108
+ const nodes = makeNodes();
109
+ assignCommunities(nodes, 'group');
110
+
111
+ const colorMap = buildCommunityColorMap(nodes, theme);
112
+
113
+ expect(colorMap.size).toBe(2);
114
+ expect(colorMap.has('X')).toBe(true);
115
+ expect(colorMap.has('Y')).toBe(true);
116
+ });
117
+
118
+ it('uses categorical palette colors', () => {
119
+ const nodes = makeNodes();
120
+ assignCommunities(nodes, 'group');
121
+
122
+ const colorMap = buildCommunityColorMap(nodes, theme);
123
+
124
+ const colors = [...colorMap.values()];
125
+ const palette = theme.colors.categorical;
126
+
127
+ // First two communities should get first two palette colors
128
+ expect(colors[0]).toBe(palette[0]);
129
+ expect(colors[1]).toBe(palette[1]);
130
+ });
131
+
132
+ it('returns empty map when no communities assigned', () => {
133
+ const nodes = makeNodes();
134
+ // Don't assign communities
135
+
136
+ const colorMap = buildCommunityColorMap(nodes, theme);
137
+
138
+ expect(colorMap.size).toBe(0);
139
+ });
140
+
141
+ it('wraps around palette when more communities than colors', () => {
142
+ // Create enough nodes with unique communities to exceed palette size
143
+ const manyNodes: CompiledGraphNode[] = [];
144
+ const paletteSize = theme.colors.categorical.length;
145
+
146
+ for (let i = 0; i < paletteSize + 2; i++) {
147
+ manyNodes.push({
148
+ id: `n${i}`,
149
+ radius: 5,
150
+ fill: '#ccc',
151
+ stroke: '#aaa',
152
+ strokeWidth: 1,
153
+ label: `Node ${i}`,
154
+ labelPriority: 0,
155
+ community: `community-${i}`,
156
+ data: { id: `n${i}` },
157
+ });
158
+ }
159
+
160
+ const colorMap = buildCommunityColorMap(manyNodes, theme);
161
+
162
+ expect(colorMap.size).toBe(paletteSize + 2);
163
+ // The (paletteSize + 1)th community should wrap to palette[0]
164
+ const wrappedColor = [...colorMap.values()][paletteSize];
165
+ expect(wrappedColor).toBe(theme.colors.categorical[0]);
166
+ });
167
+ });
168
+
169
+ // ---------------------------------------------------------------------------
170
+ // applyCommunityColors tests
171
+ // ---------------------------------------------------------------------------
172
+
173
+ describe('applyCommunityColors', () => {
174
+ it('overrides node fill with community color', () => {
175
+ const nodes = makeNodes();
176
+ assignCommunities(nodes, 'group');
177
+
178
+ const colorMap = buildCommunityColorMap(nodes, theme);
179
+ const xColor = colorMap.get('X')!;
180
+
181
+ applyCommunityColors(nodes, colorMap);
182
+
183
+ expect(nodes[0].fill).toBe(xColor);
184
+ expect(nodes[1].fill).toBe(xColor);
185
+ });
186
+
187
+ it('overrides node stroke with darkened community color', () => {
188
+ const nodes = makeNodes();
189
+ assignCommunities(nodes, 'group');
190
+
191
+ const colorMap = buildCommunityColorMap(nodes, theme);
192
+ const originalStroke0 = nodes[0].stroke;
193
+
194
+ applyCommunityColors(nodes, colorMap);
195
+
196
+ // Stroke should have changed from original
197
+ expect(nodes[0].stroke).not.toBe(originalStroke0);
198
+ // Stroke should be different from fill (darkened)
199
+ expect(nodes[0].stroke).not.toBe(nodes[0].fill);
200
+ });
201
+
202
+ it('leaves nodes without communities unchanged', () => {
203
+ const nodes = makeNodes();
204
+ assignCommunities(nodes, 'group');
205
+
206
+ const originalFill = nodes[3].fill;
207
+ const originalStroke = nodes[3].stroke;
208
+
209
+ const colorMap = buildCommunityColorMap(nodes, theme);
210
+ applyCommunityColors(nodes, colorMap);
211
+
212
+ // Node 'd' has no group field, community is undefined
213
+ expect(nodes[3].fill).toBe(originalFill);
214
+ expect(nodes[3].stroke).toBe(originalStroke);
215
+ });
216
+
217
+ it('nodes in the same community have the same color', () => {
218
+ const nodes = makeNodes();
219
+ assignCommunities(nodes, 'group');
220
+
221
+ const colorMap = buildCommunityColorMap(nodes, theme);
222
+ applyCommunityColors(nodes, colorMap);
223
+
224
+ // Nodes a and b are both in community 'X'
225
+ expect(nodes[0].fill).toBe(nodes[1].fill);
226
+ expect(nodes[0].stroke).toBe(nodes[1].stroke);
227
+ });
228
+ });
@@ -0,0 +1,315 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { compileGraph } from '../compile-graph';
3
+
4
+ // ---------------------------------------------------------------------------
5
+ // Shared fixtures
6
+ // ---------------------------------------------------------------------------
7
+
8
+ function makeBasicGraphSpec() {
9
+ return {
10
+ type: 'graph' as const,
11
+ nodes: [
12
+ { id: 'a', group: 'X', value: 10, name: 'Alice' },
13
+ { id: 'b', group: 'X', value: 20, name: 'Bob' },
14
+ { id: 'c', group: 'Y', value: 30, name: 'Carol' },
15
+ { id: 'd', group: 'Y', value: 40, name: 'Dave' },
16
+ ],
17
+ edges: [
18
+ { source: 'a', target: 'b', weight: 1 },
19
+ { source: 'b', target: 'c', weight: 2 },
20
+ { source: 'c', target: 'd', weight: 3 },
21
+ { source: 'a', target: 'c', weight: 1 },
22
+ ],
23
+ };
24
+ }
25
+
26
+ function makeEncodedGraphSpec() {
27
+ return {
28
+ ...makeBasicGraphSpec(),
29
+ encoding: {
30
+ nodeSize: { field: 'value', type: 'quantitative' as const },
31
+ nodeColor: { field: 'group', type: 'nominal' as const },
32
+ nodeLabel: { field: 'name' },
33
+ edgeWidth: { field: 'weight', type: 'quantitative' as const },
34
+ },
35
+ };
36
+ }
37
+
38
+ function makeClusteredGraphSpec() {
39
+ return {
40
+ ...makeBasicGraphSpec(),
41
+ layout: {
42
+ type: 'force' as const,
43
+ clustering: { field: 'group' },
44
+ chargeStrength: -200,
45
+ linkDistance: 50,
46
+ },
47
+ };
48
+ }
49
+
50
+ const compileOptions = { width: 600, height: 400 };
51
+
52
+ // ---------------------------------------------------------------------------
53
+ // Full pipeline tests
54
+ // ---------------------------------------------------------------------------
55
+
56
+ describe('compileGraph', () => {
57
+ it('returns a valid GraphCompilation shape', () => {
58
+ const result = compileGraph(makeBasicGraphSpec(), compileOptions);
59
+
60
+ expect(result.nodes).toBeDefined();
61
+ expect(result.edges).toBeDefined();
62
+ expect(result.legend).toBeDefined();
63
+ expect(result.chrome).toBeDefined();
64
+ expect(result.tooltipDescriptors).toBeDefined();
65
+ expect(result.a11y).toBeDefined();
66
+ expect(result.theme).toBeDefined();
67
+ expect(result.dimensions).toBeDefined();
68
+ expect(result.simulationConfig).toBeDefined();
69
+ });
70
+
71
+ it('compiles correct number of nodes and edges', () => {
72
+ const result = compileGraph(makeBasicGraphSpec(), compileOptions);
73
+
74
+ expect(result.nodes).toHaveLength(4);
75
+ expect(result.edges).toHaveLength(4);
76
+ });
77
+
78
+ it('has correct total dimensions', () => {
79
+ const result = compileGraph(makeBasicGraphSpec(), compileOptions);
80
+
81
+ expect(result.dimensions.width).toBe(600);
82
+ expect(result.dimensions.height).toBe(400);
83
+ });
84
+
85
+ it('nodes have resolved visual properties', () => {
86
+ const result = compileGraph(makeBasicGraphSpec(), compileOptions);
87
+
88
+ for (const node of result.nodes) {
89
+ expect(node.id).toBeTruthy();
90
+ expect(node.radius).toBeGreaterThan(0);
91
+ expect(node.fill).toBeTruthy();
92
+ expect(node.stroke).toBeTruthy();
93
+ expect(node.strokeWidth).toBeGreaterThan(0);
94
+ expect(node.data).toBeDefined();
95
+ }
96
+ });
97
+
98
+ it('edges have resolved visual properties', () => {
99
+ const result = compileGraph(makeBasicGraphSpec(), compileOptions);
100
+
101
+ for (const edge of result.edges) {
102
+ expect(edge.source).toBeTruthy();
103
+ expect(edge.target).toBeTruthy();
104
+ expect(edge.stroke).toBeTruthy();
105
+ expect(edge.strokeWidth).toBeGreaterThan(0);
106
+ expect(edge.style).toBe('solid');
107
+ expect(edge.data).toBeDefined();
108
+ }
109
+ });
110
+
111
+ describe('with encoding', () => {
112
+ it('applies nodeSize encoding to produce varying radii', () => {
113
+ const result = compileGraph(makeEncodedGraphSpec(), compileOptions);
114
+
115
+ const radii = result.nodes.map((n) => n.radius);
116
+ const uniqueRadii = new Set(radii);
117
+ expect(uniqueRadii.size).toBeGreaterThan(1);
118
+ });
119
+
120
+ it('applies nodeColor encoding to produce varying fill colors', () => {
121
+ const result = compileGraph(makeEncodedGraphSpec(), compileOptions);
122
+
123
+ const groupXNode = result.nodes.find((n) => n.data.group === 'X')!;
124
+ const groupYNode = result.nodes.find((n) => n.data.group === 'Y')!;
125
+ expect(groupXNode.fill).not.toBe(groupYNode.fill);
126
+ });
127
+
128
+ it('applies nodeLabel encoding for labels', () => {
129
+ const result = compileGraph(makeEncodedGraphSpec(), compileOptions);
130
+
131
+ const alice = result.nodes.find((n) => n.id === 'a')!;
132
+ expect(alice.label).toBe('Alice');
133
+ });
134
+
135
+ it('applies edgeWidth encoding to produce varying stroke widths', () => {
136
+ const result = compileGraph(makeEncodedGraphSpec(), compileOptions);
137
+
138
+ const widths = result.edges.map((e) => e.strokeWidth);
139
+ const uniqueWidths = new Set(widths);
140
+ expect(uniqueWidths.size).toBeGreaterThan(1);
141
+ });
142
+ });
143
+
144
+ describe('with community clustering', () => {
145
+ it('assigns communities to nodes', () => {
146
+ const result = compileGraph(makeClusteredGraphSpec(), compileOptions);
147
+
148
+ const groupXNodes = result.nodes.filter((n) => n.community === 'X');
149
+ const groupYNodes = result.nodes.filter((n) => n.community === 'Y');
150
+ expect(groupXNodes).toHaveLength(2);
151
+ expect(groupYNodes).toHaveLength(2);
152
+ });
153
+
154
+ it('community colors override node colors', () => {
155
+ const result = compileGraph(makeClusteredGraphSpec(), compileOptions);
156
+
157
+ // Nodes in the same community should share a color
158
+ const xNodes = result.nodes.filter((n) => n.community === 'X');
159
+ expect(xNodes[0].fill).toBe(xNodes[1].fill);
160
+
161
+ // Different communities should have different colors
162
+ const yNode = result.nodes.find((n) => n.community === 'Y')!;
163
+ expect(xNodes[0].fill).not.toBe(yNode.fill);
164
+ });
165
+
166
+ it('legend entries match communities', () => {
167
+ const result = compileGraph(makeClusteredGraphSpec(), compileOptions);
168
+
169
+ expect(result.legend.entries).toHaveLength(2);
170
+ const labels = result.legend.entries.map((e) => e.label).sort();
171
+ expect(labels).toEqual(['X', 'Y']);
172
+ });
173
+ });
174
+
175
+ describe('tooltips', () => {
176
+ it('generates tooltip descriptors for each node', () => {
177
+ const result = compileGraph(makeBasicGraphSpec(), compileOptions);
178
+
179
+ expect(result.tooltipDescriptors.size).toBe(4);
180
+ expect(result.tooltipDescriptors.has('a')).toBe(true);
181
+ expect(result.tooltipDescriptors.has('b')).toBe(true);
182
+ expect(result.tooltipDescriptors.has('c')).toBe(true);
183
+ expect(result.tooltipDescriptors.has('d')).toBe(true);
184
+ });
185
+
186
+ it('tooltip has a title and data fields', () => {
187
+ const result = compileGraph(makeBasicGraphSpec(), compileOptions);
188
+
189
+ const tooltip = result.tooltipDescriptors.get('a')!;
190
+ expect(tooltip.title).toBeTruthy();
191
+ expect(tooltip.fields.length).toBeGreaterThan(0);
192
+ });
193
+
194
+ it('tooltip includes community when clustering is active', () => {
195
+ const result = compileGraph(makeClusteredGraphSpec(), compileOptions);
196
+
197
+ const tooltip = result.tooltipDescriptors.get('a')!;
198
+ const communityField = tooltip.fields.find((f) => f.label === 'Community');
199
+ expect(communityField).toBeDefined();
200
+ expect(communityField!.value).toBe('X');
201
+ });
202
+ });
203
+
204
+ describe('a11y', () => {
205
+ it('generates descriptive alt text', () => {
206
+ const result = compileGraph(makeBasicGraphSpec(), compileOptions);
207
+
208
+ expect(result.a11y.altText).toContain('4 nodes');
209
+ expect(result.a11y.altText).toContain('4 edges');
210
+ });
211
+
212
+ it('alt text mentions communities when clustering is active', () => {
213
+ const result = compileGraph(makeClusteredGraphSpec(), compileOptions);
214
+
215
+ expect(result.a11y.altText).toContain('communities');
216
+ });
217
+
218
+ it('has a data table fallback', () => {
219
+ const result = compileGraph(makeBasicGraphSpec(), compileOptions);
220
+
221
+ expect(result.a11y.dataTableFallback).toHaveLength(4);
222
+ });
223
+
224
+ it('is keyboard navigable when nodes exist', () => {
225
+ const result = compileGraph(makeBasicGraphSpec(), compileOptions);
226
+
227
+ expect(result.a11y.keyboardNavigable).toBe(true);
228
+ });
229
+ });
230
+
231
+ describe('simulationConfig', () => {
232
+ it('reflects default layout parameters', () => {
233
+ const result = compileGraph(makeBasicGraphSpec(), compileOptions);
234
+
235
+ expect(result.simulationConfig.chargeStrength).toBe(-300);
236
+ expect(result.simulationConfig.linkDistance).toBe(30);
237
+ expect(result.simulationConfig.alphaDecay).toBeCloseTo(0.0228);
238
+ expect(result.simulationConfig.velocityDecay).toBeCloseTo(0.4);
239
+ });
240
+
241
+ it('reflects custom layout parameters', () => {
242
+ const result = compileGraph(makeClusteredGraphSpec(), compileOptions);
243
+
244
+ expect(result.simulationConfig.chargeStrength).toBe(-200);
245
+ expect(result.simulationConfig.linkDistance).toBe(50);
246
+ });
247
+
248
+ it('includes clustering config when set', () => {
249
+ const result = compileGraph(makeClusteredGraphSpec(), compileOptions);
250
+
251
+ expect(result.simulationConfig.clustering).not.toBeNull();
252
+ expect(result.simulationConfig.clustering!.field).toBe('group');
253
+ });
254
+
255
+ it('clustering is null when not set', () => {
256
+ const result = compileGraph(makeBasicGraphSpec(), compileOptions);
257
+
258
+ expect(result.simulationConfig.clustering).toBeNull();
259
+ });
260
+
261
+ it('collision radius accounts for max node radius', () => {
262
+ const result = compileGraph(makeBasicGraphSpec(), compileOptions);
263
+
264
+ const maxRadius = Math.max(...result.nodes.map((n) => n.radius));
265
+ expect(result.simulationConfig.collisionRadius).toBe(maxRadius + 2);
266
+ });
267
+ });
268
+
269
+ describe('chrome', () => {
270
+ it('resolves chrome text elements', () => {
271
+ const spec = {
272
+ ...makeBasicGraphSpec(),
273
+ chrome: {
274
+ title: 'Network Graph',
275
+ source: 'Test Data',
276
+ },
277
+ };
278
+ const result = compileGraph(spec, compileOptions);
279
+
280
+ expect(result.chrome.title).toBeDefined();
281
+ expect(result.chrome.title!.text).toBe('Network Graph');
282
+ expect(result.chrome.source).toBeDefined();
283
+ expect(result.chrome.source!.text).toBe('Test Data');
284
+ });
285
+ });
286
+
287
+ describe('dark mode', () => {
288
+ it('applies dark mode theme when option is set', () => {
289
+ const result = compileGraph(makeBasicGraphSpec(), { ...compileOptions, darkMode: true });
290
+
291
+ expect(result.theme.isDark).toBe(true);
292
+ });
293
+ });
294
+
295
+ describe('error handling', () => {
296
+ it('throws for non-graph specs', () => {
297
+ const chartSpec = {
298
+ type: 'scatter' as const,
299
+ data: [{ x: 1, y: 2 }],
300
+ encoding: {
301
+ x: { field: 'x', type: 'quantitative' as const },
302
+ y: { field: 'y', type: 'quantitative' as const },
303
+ },
304
+ };
305
+
306
+ expect(() => compileGraph(chartSpec, compileOptions)).toThrow(
307
+ /compileGraph received a scatter spec/,
308
+ );
309
+ });
310
+
311
+ it('throws for invalid specs', () => {
312
+ expect(() => compileGraph({}, compileOptions)).toThrow();
313
+ });
314
+ });
315
+ });