@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.
- package/dist/index.d.ts +366 -0
- package/dist/index.js +4227 -0
- package/dist/index.js.map +1 -0
- package/package.json +62 -0
- package/src/__test-fixtures__/specs.ts +124 -0
- package/src/__tests__/axes.test.ts +114 -0
- package/src/__tests__/compile-chart.test.ts +337 -0
- package/src/__tests__/dimensions.test.ts +151 -0
- package/src/__tests__/legend.test.ts +113 -0
- package/src/__tests__/scales.test.ts +109 -0
- package/src/annotations/__tests__/compute.test.ts +454 -0
- package/src/annotations/compute.ts +603 -0
- package/src/charts/__tests__/registry.test.ts +110 -0
- package/src/charts/bar/__tests__/compute.test.ts +294 -0
- package/src/charts/bar/__tests__/labels.test.ts +75 -0
- package/src/charts/bar/compute.ts +205 -0
- package/src/charts/bar/index.ts +33 -0
- package/src/charts/bar/labels.ts +132 -0
- package/src/charts/column/__tests__/compute.test.ts +277 -0
- package/src/charts/column/compute.ts +282 -0
- package/src/charts/column/index.ts +33 -0
- package/src/charts/column/labels.ts +108 -0
- package/src/charts/dot/__tests__/compute.test.ts +344 -0
- package/src/charts/dot/compute.ts +257 -0
- package/src/charts/dot/index.ts +46 -0
- package/src/charts/dot/labels.ts +97 -0
- package/src/charts/line/__tests__/compute.test.ts +437 -0
- package/src/charts/line/__tests__/labels.test.ts +93 -0
- package/src/charts/line/area.ts +288 -0
- package/src/charts/line/compute.ts +177 -0
- package/src/charts/line/index.ts +68 -0
- package/src/charts/line/labels.ts +144 -0
- package/src/charts/pie/__tests__/compute.test.ts +276 -0
- package/src/charts/pie/compute.ts +234 -0
- package/src/charts/pie/index.ts +49 -0
- package/src/charts/pie/labels.ts +142 -0
- package/src/charts/registry.ts +64 -0
- package/src/charts/scatter/__tests__/compute.test.ts +304 -0
- package/src/charts/scatter/__tests__/trendline.test.ts +191 -0
- package/src/charts/scatter/compute.ts +124 -0
- package/src/charts/scatter/index.ts +41 -0
- package/src/charts/scatter/trendline.ts +100 -0
- package/src/charts/utils.ts +120 -0
- package/src/compile.ts +368 -0
- package/src/compiler/__tests__/compile.test.ts +87 -0
- package/src/compiler/__tests__/normalize.test.ts +210 -0
- package/src/compiler/__tests__/validate.test.ts +440 -0
- package/src/compiler/index.ts +47 -0
- package/src/compiler/normalize.ts +269 -0
- package/src/compiler/types.ts +148 -0
- package/src/compiler/validate.ts +581 -0
- package/src/graphs/__tests__/community.test.ts +228 -0
- package/src/graphs/__tests__/compile-graph.test.ts +315 -0
- package/src/graphs/__tests__/encoding.test.ts +314 -0
- package/src/graphs/community.ts +92 -0
- package/src/graphs/compile-graph.ts +291 -0
- package/src/graphs/encoding.ts +302 -0
- package/src/graphs/types.ts +98 -0
- package/src/index.ts +74 -0
- package/src/layout/axes.ts +194 -0
- package/src/layout/dimensions.ts +199 -0
- package/src/layout/gridlines.ts +84 -0
- package/src/layout/scales.ts +426 -0
- package/src/legend/compute.ts +186 -0
- package/src/tables/__tests__/bar-column.test.ts +147 -0
- package/src/tables/__tests__/category-colors.test.ts +153 -0
- package/src/tables/__tests__/compile-table.test.ts +208 -0
- package/src/tables/__tests__/format-cells.test.ts +126 -0
- package/src/tables/__tests__/heatmap.test.ts +124 -0
- package/src/tables/__tests__/pagination.test.ts +78 -0
- package/src/tables/__tests__/search.test.ts +94 -0
- package/src/tables/__tests__/sort.test.ts +107 -0
- package/src/tables/__tests__/sparkline.test.ts +122 -0
- package/src/tables/bar-column.ts +94 -0
- package/src/tables/category-colors.ts +67 -0
- package/src/tables/compile-table.ts +420 -0
- package/src/tables/format-cells.ts +110 -0
- package/src/tables/heatmap.ts +121 -0
- package/src/tables/pagination.ts +46 -0
- package/src/tables/search.ts +66 -0
- package/src/tables/sort.ts +69 -0
- package/src/tables/sparkline.ts +113 -0
- package/src/tables/utils.ts +16 -0
- package/src/tooltips/__tests__/compute.test.ts +328 -0
- 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
|
+
});
|