@opendata-ai/openchart-vanilla 2.0.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 +327 -0
- package/dist/index.js +4745 -0
- package/dist/index.js.map +1 -0
- package/dist/simulation-worker.js +1196 -0
- package/package.json +58 -0
- package/src/__test-fixtures__/dom.ts +42 -0
- package/src/__test-fixtures__/specs.ts +187 -0
- package/src/__tests__/edit-events.test.ts +747 -0
- package/src/__tests__/events.test.ts +336 -0
- package/src/__tests__/export.test.ts +150 -0
- package/src/__tests__/mount.test.ts +219 -0
- package/src/__tests__/svg-renderer.test.ts +609 -0
- package/src/__tests__/table-mount.test.ts +484 -0
- package/src/__tests__/tooltip.test.ts +201 -0
- package/src/export.ts +105 -0
- package/src/graph/__tests__/canvas-renderer.test.ts +704 -0
- package/src/graph/__tests__/graph-mount.test.ts +213 -0
- package/src/graph/__tests__/interaction.test.ts +205 -0
- package/src/graph/__tests__/keyboard.test.ts +653 -0
- package/src/graph/__tests__/search.test.ts +88 -0
- package/src/graph/__tests__/simulation.test.ts +233 -0
- package/src/graph/__tests__/spatial-index.test.ts +142 -0
- package/src/graph/__tests__/zoom.test.ts +195 -0
- package/src/graph/canvas-renderer.ts +660 -0
- package/src/graph/interaction.ts +359 -0
- package/src/graph/keyboard.ts +208 -0
- package/src/graph/search.ts +50 -0
- package/src/graph/simulation-worker-url.ts +30 -0
- package/src/graph/simulation-worker.ts +265 -0
- package/src/graph/simulation.ts +350 -0
- package/src/graph/spatial-index.ts +121 -0
- package/src/graph/types.ts +44 -0
- package/src/graph/worker-protocol.ts +67 -0
- package/src/graph/zoom.ts +104 -0
- package/src/graph-mount.ts +675 -0
- package/src/index.ts +56 -0
- package/src/mount.ts +1639 -0
- package/src/renderers/table-cells.ts +444 -0
- package/src/resize-observer.ts +46 -0
- package/src/svg-renderer.ts +914 -0
- package/src/table-keyboard.ts +266 -0
- package/src/table-mount.ts +532 -0
- package/src/table-renderer.ts +350 -0
- package/src/tooltip.ts +120 -0
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { GraphSearchManager } from '../search';
|
|
3
|
+
|
|
4
|
+
// ---------------------------------------------------------------------------
|
|
5
|
+
// Test data
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
|
|
8
|
+
const nodes = [
|
|
9
|
+
{ id: 'node-1', label: 'Alice' },
|
|
10
|
+
{ id: 'node-2', label: 'Bob' },
|
|
11
|
+
{ id: 'node-3', label: 'Charlie' },
|
|
12
|
+
{ id: 'node-4', label: 'alice-bob' },
|
|
13
|
+
{ id: 'alpha-5' },
|
|
14
|
+
];
|
|
15
|
+
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
// Tests
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
|
|
20
|
+
describe('GraphSearchManager', () => {
|
|
21
|
+
it('matches case-insensitively on label', () => {
|
|
22
|
+
const search = new GraphSearchManager();
|
|
23
|
+
const matches = search.search('alice', nodes);
|
|
24
|
+
expect(matches.has('node-1')).toBe(true); // label "Alice"
|
|
25
|
+
expect(matches.has('node-4')).toBe(true); // label "alice-bob"
|
|
26
|
+
expect(matches.size).toBe(2);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('matches on node id when label does not match', () => {
|
|
30
|
+
const search = new GraphSearchManager();
|
|
31
|
+
const matches = search.search('alpha', nodes);
|
|
32
|
+
expect(matches.has('alpha-5')).toBe(true);
|
|
33
|
+
expect(matches.size).toBe(1);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('supports partial/substring matches', () => {
|
|
37
|
+
const search = new GraphSearchManager();
|
|
38
|
+
const matches = search.search('ob', nodes);
|
|
39
|
+
// "Bob" contains "ob", "alice-bob" contains "ob"
|
|
40
|
+
expect(matches.has('node-2')).toBe(true);
|
|
41
|
+
expect(matches.has('node-4')).toBe(true);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('returns empty set for no matches', () => {
|
|
45
|
+
const search = new GraphSearchManager();
|
|
46
|
+
const matches = search.search('zzz', nodes);
|
|
47
|
+
expect(matches.size).toBe(0);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('returns empty set for empty query (clears search)', () => {
|
|
51
|
+
const search = new GraphSearchManager();
|
|
52
|
+
const matches = search.search('', nodes);
|
|
53
|
+
expect(matches.size).toBe(0);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('treats whitespace-only query as empty', () => {
|
|
57
|
+
const search = new GraphSearchManager();
|
|
58
|
+
const matches = search.search(' ', nodes);
|
|
59
|
+
expect(matches.size).toBe(0);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('clearSearch returns null', () => {
|
|
63
|
+
const search = new GraphSearchManager();
|
|
64
|
+
search.search('alice', nodes);
|
|
65
|
+
const result = search.clearSearch();
|
|
66
|
+
expect(result).toBeNull();
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('getMatches returns current state', () => {
|
|
70
|
+
const search = new GraphSearchManager();
|
|
71
|
+
expect(search.getMatches()).toBeNull();
|
|
72
|
+
|
|
73
|
+
search.search('bob', nodes);
|
|
74
|
+
const matches = search.getMatches();
|
|
75
|
+
expect(matches).not.toBeNull();
|
|
76
|
+
expect(matches!.has('node-2')).toBe(true);
|
|
77
|
+
|
|
78
|
+
search.clearSearch();
|
|
79
|
+
expect(search.getMatches()).toBeNull();
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('handles nodes without labels', () => {
|
|
83
|
+
const search = new GraphSearchManager();
|
|
84
|
+
const matches = search.search('alpha', nodes);
|
|
85
|
+
// node "alpha-5" has no label but id matches
|
|
86
|
+
expect(matches.has('alpha-5')).toBe(true);
|
|
87
|
+
});
|
|
88
|
+
});
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { SimulationManager } from '../simulation';
|
|
3
|
+
import type { SimEdge, SimNode, WorkerSimulationConfig } from '../worker-protocol';
|
|
4
|
+
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
// Helpers
|
|
7
|
+
// ---------------------------------------------------------------------------
|
|
8
|
+
|
|
9
|
+
function defaultConfig(overrides?: Partial<WorkerSimulationConfig>): WorkerSimulationConfig {
|
|
10
|
+
return {
|
|
11
|
+
chargeStrength: -30,
|
|
12
|
+
linkDistance: 30,
|
|
13
|
+
clustering: null,
|
|
14
|
+
alphaDecay: 0.0228,
|
|
15
|
+
velocityDecay: 0.4,
|
|
16
|
+
collisionRadius: 10,
|
|
17
|
+
...overrides,
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function makeTriangle(): { nodes: SimNode[]; edges: SimEdge[] } {
|
|
22
|
+
return {
|
|
23
|
+
nodes: [
|
|
24
|
+
{ id: 'a', radius: 5 },
|
|
25
|
+
{ id: 'b', radius: 5 },
|
|
26
|
+
{ id: 'c', radius: 5 },
|
|
27
|
+
],
|
|
28
|
+
edges: [
|
|
29
|
+
{ source: 'a', target: 'b' },
|
|
30
|
+
{ source: 'b', target: 'c' },
|
|
31
|
+
{ source: 'c', target: 'a' },
|
|
32
|
+
],
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function makePentagon(): { nodes: SimNode[]; edges: SimEdge[] } {
|
|
37
|
+
const ids = ['a', 'b', 'c', 'd', 'e'];
|
|
38
|
+
return {
|
|
39
|
+
nodes: ids.map((id) => ({ id, radius: 5 })),
|
|
40
|
+
edges: [
|
|
41
|
+
{ source: 'a', target: 'b' },
|
|
42
|
+
{ source: 'b', target: 'c' },
|
|
43
|
+
{ source: 'c', target: 'd' },
|
|
44
|
+
{ source: 'd', target: 'e' },
|
|
45
|
+
{ source: 'e', target: 'a' },
|
|
46
|
+
{ source: 'a', target: 'c' },
|
|
47
|
+
],
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// ---------------------------------------------------------------------------
|
|
52
|
+
// Tests: Synchronous fallback (bun test has no real Web Worker)
|
|
53
|
+
// ---------------------------------------------------------------------------
|
|
54
|
+
|
|
55
|
+
describe('SimulationManager (sync fallback)', () => {
|
|
56
|
+
it('produces positions for a simple triangle graph', () => {
|
|
57
|
+
const { nodes, edges } = makeTriangle();
|
|
58
|
+
const mgr = SimulationManager.create(nodes, edges, defaultConfig());
|
|
59
|
+
|
|
60
|
+
let positions: Array<{ id: string; x: number; y: number }> | null = null;
|
|
61
|
+
mgr.onTick((pos) => {
|
|
62
|
+
positions = pos;
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
// Sync path fires immediately during create, but callbacks aren't set yet.
|
|
66
|
+
// Reheat to trigger another round with the callback attached.
|
|
67
|
+
mgr.reheat(0.3);
|
|
68
|
+
|
|
69
|
+
expect(positions).not.toBeNull();
|
|
70
|
+
expect(positions!.length).toBe(3);
|
|
71
|
+
|
|
72
|
+
// All nodes should have finite positions
|
|
73
|
+
for (const p of positions!) {
|
|
74
|
+
expect(Number.isFinite(p.x)).toBe(true);
|
|
75
|
+
expect(Number.isFinite(p.y)).toBe(true);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
mgr.destroy();
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('converges positions for a 5-node graph', () => {
|
|
82
|
+
const { nodes, edges } = makePentagon();
|
|
83
|
+
const mgr = SimulationManager.create(nodes, edges, defaultConfig());
|
|
84
|
+
|
|
85
|
+
let positions: Array<{ id: string; x: number; y: number }> | null = null;
|
|
86
|
+
let _settled = false;
|
|
87
|
+
|
|
88
|
+
mgr.onTick((pos) => {
|
|
89
|
+
positions = pos;
|
|
90
|
+
});
|
|
91
|
+
mgr.onSettled(() => {
|
|
92
|
+
_settled = true;
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
mgr.reheat(1.0);
|
|
96
|
+
|
|
97
|
+
expect(positions).not.toBeNull();
|
|
98
|
+
expect(positions!.length).toBe(5);
|
|
99
|
+
|
|
100
|
+
// Nodes should be spread out (not all at origin)
|
|
101
|
+
const uniqueX = new Set(positions!.map((p) => Math.round(p.x)));
|
|
102
|
+
expect(uniqueX.size).toBeGreaterThan(1);
|
|
103
|
+
|
|
104
|
+
mgr.destroy();
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('cluster force pulls same-community nodes closer together', () => {
|
|
108
|
+
// Two communities: A,B,C in "red" and D,E,F in "blue"
|
|
109
|
+
const nodes: SimNode[] = [
|
|
110
|
+
{ id: 'a', radius: 5, community: 'red' },
|
|
111
|
+
{ id: 'b', radius: 5, community: 'red' },
|
|
112
|
+
{ id: 'c', radius: 5, community: 'red' },
|
|
113
|
+
{ id: 'd', radius: 5, community: 'blue' },
|
|
114
|
+
{ id: 'e', radius: 5, community: 'blue' },
|
|
115
|
+
{ id: 'f', radius: 5, community: 'blue' },
|
|
116
|
+
];
|
|
117
|
+
|
|
118
|
+
const edges: SimEdge[] = [
|
|
119
|
+
{ source: 'a', target: 'b' },
|
|
120
|
+
{ source: 'b', target: 'c' },
|
|
121
|
+
{ source: 'd', target: 'e' },
|
|
122
|
+
{ source: 'e', target: 'f' },
|
|
123
|
+
// One cross-community link
|
|
124
|
+
{ source: 'c', target: 'd' },
|
|
125
|
+
];
|
|
126
|
+
|
|
127
|
+
const configWithClustering = defaultConfig({
|
|
128
|
+
clustering: { field: 'community', strength: 0.5 },
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
const mgr = SimulationManager.create(nodes, edges, configWithClustering);
|
|
132
|
+
|
|
133
|
+
let positions: Array<{ id: string; x: number; y: number }> | null = null;
|
|
134
|
+
mgr.onTick((pos) => {
|
|
135
|
+
positions = pos;
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
mgr.reheat(1.0);
|
|
139
|
+
expect(positions).not.toBeNull();
|
|
140
|
+
|
|
141
|
+
// Compute average position per community
|
|
142
|
+
const posMap = new Map(positions!.map((p) => [p.id, p]));
|
|
143
|
+
const redCentroid = {
|
|
144
|
+
x: (posMap.get('a')!.x + posMap.get('b')!.x + posMap.get('c')!.x) / 3,
|
|
145
|
+
y: (posMap.get('a')!.y + posMap.get('b')!.y + posMap.get('c')!.y) / 3,
|
|
146
|
+
};
|
|
147
|
+
const blueCentroid = {
|
|
148
|
+
x: (posMap.get('d')!.x + posMap.get('e')!.x + posMap.get('f')!.x) / 3,
|
|
149
|
+
y: (posMap.get('d')!.y + posMap.get('e')!.y + posMap.get('f')!.y) / 3,
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
// The community centroids should be further apart than members are
|
|
153
|
+
// from their own centroid (clustering is working)
|
|
154
|
+
const interClusterDist = Math.hypot(
|
|
155
|
+
redCentroid.x - blueCentroid.x,
|
|
156
|
+
redCentroid.y - blueCentroid.y,
|
|
157
|
+
);
|
|
158
|
+
|
|
159
|
+
// At minimum, communities should be separated
|
|
160
|
+
expect(interClusterDist).toBeGreaterThan(5);
|
|
161
|
+
|
|
162
|
+
mgr.destroy();
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it('pin fixes a node position', () => {
|
|
166
|
+
const { nodes, edges } = makeTriangle();
|
|
167
|
+
const mgr = SimulationManager.create(nodes, edges, defaultConfig());
|
|
168
|
+
|
|
169
|
+
let positions: Array<{ id: string; x: number; y: number }> | null = null;
|
|
170
|
+
mgr.onTick((pos) => {
|
|
171
|
+
positions = pos;
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
// Pin node 'a' at specific coordinates
|
|
175
|
+
mgr.pinNode('a', 42, 99);
|
|
176
|
+
mgr.reheat(0.3);
|
|
177
|
+
|
|
178
|
+
expect(positions).not.toBeNull();
|
|
179
|
+
const pinned = positions!.find((p) => p.id === 'a');
|
|
180
|
+
expect(pinned).toBeDefined();
|
|
181
|
+
expect(pinned!.x).toBeCloseTo(42, 0);
|
|
182
|
+
expect(pinned!.y).toBeCloseTo(99, 0);
|
|
183
|
+
|
|
184
|
+
mgr.destroy();
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it('unpin frees a pinned node', () => {
|
|
188
|
+
const { nodes, edges } = makeTriangle();
|
|
189
|
+
const mgr = SimulationManager.create(nodes, edges, defaultConfig());
|
|
190
|
+
|
|
191
|
+
let positions: Array<{ id: string; x: number; y: number }> | null = null;
|
|
192
|
+
mgr.onTick((pos) => {
|
|
193
|
+
positions = pos;
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
mgr.pinNode('a', 500, 500);
|
|
197
|
+
mgr.reheat(0.3);
|
|
198
|
+
const pinnedPos = positions!.find((p) => p.id === 'a')!;
|
|
199
|
+
expect(pinnedPos.x).toBeCloseTo(500, 0);
|
|
200
|
+
|
|
201
|
+
// Now unpin and reheat with high alpha so it moves
|
|
202
|
+
mgr.unpinNode('a');
|
|
203
|
+
mgr.reheat(1.0);
|
|
204
|
+
|
|
205
|
+
// After reheating with forces, 'a' should have moved away from 500,500
|
|
206
|
+
// because the other nodes are near the center and charge pushes things apart
|
|
207
|
+
const freedPos = positions!.find((p) => p.id === 'a')!;
|
|
208
|
+
// It should have moved at least a little
|
|
209
|
+
const dist = Math.hypot(freedPos.x - 500, freedPos.y - 500);
|
|
210
|
+
expect(dist).toBeGreaterThan(1);
|
|
211
|
+
|
|
212
|
+
mgr.destroy();
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it('destroy prevents further callbacks', async () => {
|
|
216
|
+
const { nodes, edges } = makeTriangle();
|
|
217
|
+
const mgr = SimulationManager.create(nodes, edges, defaultConfig());
|
|
218
|
+
|
|
219
|
+
let callCount = 0;
|
|
220
|
+
mgr.onTick(() => {
|
|
221
|
+
callCount++;
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
mgr.destroy();
|
|
225
|
+
mgr.reheat(0.5);
|
|
226
|
+
|
|
227
|
+
// Wait for the deferred initial tick microtask to drain
|
|
228
|
+
await Promise.resolve();
|
|
229
|
+
|
|
230
|
+
// No callbacks should fire after destroy
|
|
231
|
+
expect(callCount).toBe(0);
|
|
232
|
+
});
|
|
233
|
+
});
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { SpatialIndex } from '../spatial-index';
|
|
3
|
+
import type { PositionedNode } from '../types';
|
|
4
|
+
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
// Helpers
|
|
7
|
+
// ---------------------------------------------------------------------------
|
|
8
|
+
|
|
9
|
+
function makeNode(id: string, x: number, y: number, radius = 5): PositionedNode {
|
|
10
|
+
return {
|
|
11
|
+
id,
|
|
12
|
+
x,
|
|
13
|
+
y,
|
|
14
|
+
radius,
|
|
15
|
+
fill: '#3b82f6',
|
|
16
|
+
stroke: '#2563eb',
|
|
17
|
+
strokeWidth: 1,
|
|
18
|
+
label: id,
|
|
19
|
+
labelPriority: 0.5,
|
|
20
|
+
community: undefined,
|
|
21
|
+
data: {},
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
// Tests
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
|
|
29
|
+
describe('SpatialIndex', () => {
|
|
30
|
+
describe('findNearest', () => {
|
|
31
|
+
it('returns the correct nearest node', () => {
|
|
32
|
+
const idx = new SpatialIndex();
|
|
33
|
+
const nodes = [makeNode('a', 0, 0), makeNode('b', 100, 100), makeNode('c', 200, 200)];
|
|
34
|
+
idx.rebuild(nodes);
|
|
35
|
+
|
|
36
|
+
const result = idx.findNearest(10, 10);
|
|
37
|
+
expect(result).not.toBeNull();
|
|
38
|
+
expect(result!.id).toBe('a');
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('returns null for empty tree', () => {
|
|
42
|
+
const idx = new SpatialIndex();
|
|
43
|
+
expect(idx.findNearest(0, 0)).toBeNull();
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('respects maxDistance', () => {
|
|
47
|
+
const idx = new SpatialIndex();
|
|
48
|
+
const nodes = [makeNode('a', 100, 100, 5)];
|
|
49
|
+
idx.rebuild(nodes);
|
|
50
|
+
|
|
51
|
+
// Distance from (0,0) to node center is ~141, minus radius 5 = ~136
|
|
52
|
+
const tooFar = idx.findNearest(0, 0, 10);
|
|
53
|
+
expect(tooFar).toBeNull();
|
|
54
|
+
|
|
55
|
+
// With a large enough distance, should find it
|
|
56
|
+
const found = idx.findNearest(0, 0, 200);
|
|
57
|
+
expect(found).not.toBeNull();
|
|
58
|
+
expect(found!.id).toBe('a');
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('handles radius-based hit (clicking within a large node)', () => {
|
|
62
|
+
const idx = new SpatialIndex();
|
|
63
|
+
// Node at (100, 100) with radius 50
|
|
64
|
+
const nodes = [makeNode('big', 100, 100, 50)];
|
|
65
|
+
idx.rebuild(nodes);
|
|
66
|
+
|
|
67
|
+
// Click at (120, 120) -- within the 50px radius of the node
|
|
68
|
+
// Distance from center = ~28px, well within 50px radius
|
|
69
|
+
const hit = idx.findNearest(120, 120, 0);
|
|
70
|
+
// effectiveDist = max(0, 28.28 - 50) = 0, so it should be found with maxDistance=0
|
|
71
|
+
expect(hit).not.toBeNull();
|
|
72
|
+
expect(hit!.id).toBe('big');
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('prefers closer nodes even when a large node is present', () => {
|
|
76
|
+
const idx = new SpatialIndex();
|
|
77
|
+
const nodes = [makeNode('small-close', 10, 10, 5), makeNode('big-far', 200, 200, 50)];
|
|
78
|
+
idx.rebuild(nodes);
|
|
79
|
+
|
|
80
|
+
const result = idx.findNearest(12, 12);
|
|
81
|
+
expect(result).not.toBeNull();
|
|
82
|
+
expect(result!.id).toBe('small-close');
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
describe('findInRect', () => {
|
|
87
|
+
it('returns all nodes inside the rectangle', () => {
|
|
88
|
+
const idx = new SpatialIndex();
|
|
89
|
+
const nodes = [makeNode('a', 50, 50), makeNode('b', 150, 150), makeNode('c', 250, 250)];
|
|
90
|
+
idx.rebuild(nodes);
|
|
91
|
+
|
|
92
|
+
const result = idx.findInRect(0, 0, 200, 200);
|
|
93
|
+
const ids = result.map((n) => n.id).sort();
|
|
94
|
+
expect(ids).toEqual(['a', 'b']);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('returns empty array for empty tree', () => {
|
|
98
|
+
const idx = new SpatialIndex();
|
|
99
|
+
const result = idx.findInRect(0, 0, 100, 100);
|
|
100
|
+
expect(result).toEqual([]);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('handles inverted coordinates (x2 < x1)', () => {
|
|
104
|
+
const idx = new SpatialIndex();
|
|
105
|
+
const nodes = [makeNode('a', 50, 50), makeNode('b', 150, 150)];
|
|
106
|
+
idx.rebuild(nodes);
|
|
107
|
+
|
|
108
|
+
// Inverted rect: same area as (0,0)-(200,200)
|
|
109
|
+
const result = idx.findInRect(200, 200, 0, 0);
|
|
110
|
+
expect(result.length).toBe(2);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('returns correct subset', () => {
|
|
114
|
+
const idx = new SpatialIndex();
|
|
115
|
+
const nodes = [
|
|
116
|
+
makeNode('origin', 0, 0),
|
|
117
|
+
makeNode('top-right', 100, 0),
|
|
118
|
+
makeNode('bottom-left', 0, 100),
|
|
119
|
+
makeNode('center', 50, 50),
|
|
120
|
+
makeNode('far', 500, 500),
|
|
121
|
+
];
|
|
122
|
+
idx.rebuild(nodes);
|
|
123
|
+
|
|
124
|
+
const result = idx.findInRect(0, 0, 60, 60);
|
|
125
|
+
const ids = result.map((n) => n.id).sort();
|
|
126
|
+
expect(ids).toEqual(['center', 'origin']);
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
describe('generation tracking', () => {
|
|
131
|
+
it('increments generation on rebuild', () => {
|
|
132
|
+
const idx = new SpatialIndex();
|
|
133
|
+
expect(idx.getGeneration()).toBe(0);
|
|
134
|
+
|
|
135
|
+
idx.rebuild([makeNode('a', 0, 0)]);
|
|
136
|
+
expect(idx.getGeneration()).toBe(1);
|
|
137
|
+
|
|
138
|
+
idx.rebuild([makeNode('a', 10, 10)]);
|
|
139
|
+
expect(idx.getGeneration()).toBe(2);
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
});
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import type { PositionedNode } from '../types';
|
|
3
|
+
import { ZoomTransform } from '../zoom';
|
|
4
|
+
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
// Helpers
|
|
7
|
+
// ---------------------------------------------------------------------------
|
|
8
|
+
|
|
9
|
+
function makeNode(id: string, x: number, y: number, radius = 5): PositionedNode {
|
|
10
|
+
return {
|
|
11
|
+
id,
|
|
12
|
+
x,
|
|
13
|
+
y,
|
|
14
|
+
radius,
|
|
15
|
+
fill: '#3b82f6',
|
|
16
|
+
stroke: '#2563eb',
|
|
17
|
+
strokeWidth: 1,
|
|
18
|
+
label: undefined,
|
|
19
|
+
labelPriority: 0.5,
|
|
20
|
+
community: undefined,
|
|
21
|
+
data: {},
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
// Tests: screenToGraph / graphToScreen inverse
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
|
|
29
|
+
describe('ZoomTransform', () => {
|
|
30
|
+
describe('coordinate conversion', () => {
|
|
31
|
+
it('screenToGraph and graphToScreen are inverse at identity', () => {
|
|
32
|
+
const t = ZoomTransform.identity();
|
|
33
|
+
const screen = { x: 100, y: 200 };
|
|
34
|
+
const graph = t.screenToGraph(screen.x, screen.y);
|
|
35
|
+
const back = t.graphToScreen(graph.x, graph.y);
|
|
36
|
+
expect(back.x).toBeCloseTo(screen.x);
|
|
37
|
+
expect(back.y).toBeCloseTo(screen.y);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('screenToGraph and graphToScreen are inverse with pan', () => {
|
|
41
|
+
const t = new ZoomTransform(50, -30, 1);
|
|
42
|
+
const screen = { x: 200, y: 150 };
|
|
43
|
+
const graph = t.screenToGraph(screen.x, screen.y);
|
|
44
|
+
const back = t.graphToScreen(graph.x, graph.y);
|
|
45
|
+
expect(back.x).toBeCloseTo(screen.x);
|
|
46
|
+
expect(back.y).toBeCloseTo(screen.y);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('screenToGraph and graphToScreen are inverse with zoom', () => {
|
|
50
|
+
const t = new ZoomTransform(0, 0, 2.5);
|
|
51
|
+
const screen = { x: 300, y: 400 };
|
|
52
|
+
const graph = t.screenToGraph(screen.x, screen.y);
|
|
53
|
+
const back = t.graphToScreen(graph.x, graph.y);
|
|
54
|
+
expect(back.x).toBeCloseTo(screen.x);
|
|
55
|
+
expect(back.y).toBeCloseTo(screen.y);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('screenToGraph and graphToScreen are inverse with pan+zoom', () => {
|
|
59
|
+
const t = new ZoomTransform(100, -50, 3);
|
|
60
|
+
const screen = { x: 250, y: 175 };
|
|
61
|
+
const graph = t.screenToGraph(screen.x, screen.y);
|
|
62
|
+
const back = t.graphToScreen(graph.x, graph.y);
|
|
63
|
+
expect(back.x).toBeCloseTo(screen.x);
|
|
64
|
+
expect(back.y).toBeCloseTo(screen.y);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('graphToScreen follows gx * k + x formula', () => {
|
|
68
|
+
const t = new ZoomTransform(10, 20, 2);
|
|
69
|
+
const result = t.graphToScreen(5, 10);
|
|
70
|
+
expect(result.x).toBe(5 * 2 + 10); // 20
|
|
71
|
+
expect(result.y).toBe(10 * 2 + 20); // 40
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('screenToGraph follows (sx - x) / k formula', () => {
|
|
75
|
+
const t = new ZoomTransform(10, 20, 2);
|
|
76
|
+
const result = t.screenToGraph(20, 40);
|
|
77
|
+
expect(result.x).toBe((20 - 10) / 2); // 5
|
|
78
|
+
expect(result.y).toBe((40 - 20) / 2); // 10
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
// -------------------------------------------------------------------------
|
|
83
|
+
// zoomAt preserves pivot point
|
|
84
|
+
// -------------------------------------------------------------------------
|
|
85
|
+
|
|
86
|
+
describe('zoomAt', () => {
|
|
87
|
+
it('preserves the pivot point position', () => {
|
|
88
|
+
const t = new ZoomTransform(50, 100, 1);
|
|
89
|
+
const pivotX = 200;
|
|
90
|
+
const pivotY = 150;
|
|
91
|
+
|
|
92
|
+
// Graph point under the pivot before
|
|
93
|
+
const before = t.screenToGraph(pivotX, pivotY);
|
|
94
|
+
|
|
95
|
+
// Zoom to 2x at the pivot
|
|
96
|
+
const t2 = t.zoomAt(2, pivotX, pivotY);
|
|
97
|
+
|
|
98
|
+
// Graph point under the pivot after
|
|
99
|
+
const after = t2.screenToGraph(pivotX, pivotY);
|
|
100
|
+
|
|
101
|
+
expect(after.x).toBeCloseTo(before.x, 5);
|
|
102
|
+
expect(after.y).toBeCloseTo(before.y, 5);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('preserves pivot at high zoom', () => {
|
|
106
|
+
const t = new ZoomTransform(0, 0, 1);
|
|
107
|
+
const pivot = { x: 400, y: 300 };
|
|
108
|
+
|
|
109
|
+
const before = t.screenToGraph(pivot.x, pivot.y);
|
|
110
|
+
const t2 = t.zoomAt(10, pivot.x, pivot.y);
|
|
111
|
+
const after = t2.screenToGraph(pivot.x, pivot.y);
|
|
112
|
+
|
|
113
|
+
expect(after.x).toBeCloseTo(before.x, 5);
|
|
114
|
+
expect(after.y).toBeCloseTo(before.y, 5);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('updates the scale', () => {
|
|
118
|
+
const t = ZoomTransform.identity();
|
|
119
|
+
const t2 = t.zoomAt(3, 100, 100);
|
|
120
|
+
expect(t2.k).toBe(3);
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
// -------------------------------------------------------------------------
|
|
125
|
+
// pan
|
|
126
|
+
// -------------------------------------------------------------------------
|
|
127
|
+
|
|
128
|
+
describe('pan', () => {
|
|
129
|
+
it('adds delta to x and y', () => {
|
|
130
|
+
const t = new ZoomTransform(10, 20, 1);
|
|
131
|
+
const t2 = t.pan(5, -3);
|
|
132
|
+
expect(t2.x).toBe(15);
|
|
133
|
+
expect(t2.y).toBe(17);
|
|
134
|
+
expect(t2.k).toBe(1);
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
// -------------------------------------------------------------------------
|
|
139
|
+
// fitBounds
|
|
140
|
+
// -------------------------------------------------------------------------
|
|
141
|
+
|
|
142
|
+
describe('fitBounds', () => {
|
|
143
|
+
it('returns identity for empty node array', () => {
|
|
144
|
+
const t = ZoomTransform.fitBounds([], 800, 600);
|
|
145
|
+
expect(t.x).toBe(0);
|
|
146
|
+
expect(t.y).toBe(0);
|
|
147
|
+
expect(t.k).toBe(1);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it('centers a single node', () => {
|
|
151
|
+
const nodes = [makeNode('a', 0, 0)];
|
|
152
|
+
const t = ZoomTransform.fitBounds(nodes, 800, 600, 40);
|
|
153
|
+
// Single node at origin should be centered
|
|
154
|
+
// Transform should put graph origin at screen center
|
|
155
|
+
const screen = t.graphToScreen(0, 0);
|
|
156
|
+
expect(screen.x).toBeCloseTo(400);
|
|
157
|
+
expect(screen.y).toBeCloseTo(300);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it('fits a spread of nodes within the canvas', () => {
|
|
161
|
+
const nodes = [makeNode('a', -200, -100), makeNode('b', 200, 100)];
|
|
162
|
+
const t = ZoomTransform.fitBounds(nodes, 800, 600, 40);
|
|
163
|
+
|
|
164
|
+
// Both nodes should map to within the canvas bounds (with padding)
|
|
165
|
+
const sa = t.graphToScreen(-200, -100);
|
|
166
|
+
const sb = t.graphToScreen(200, 100);
|
|
167
|
+
|
|
168
|
+
expect(sa.x).toBeGreaterThanOrEqual(40);
|
|
169
|
+
expect(sa.y).toBeGreaterThanOrEqual(40);
|
|
170
|
+
expect(sb.x).toBeLessThanOrEqual(760);
|
|
171
|
+
expect(sb.y).toBeLessThanOrEqual(560);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it('produces correct scale for known graph bounds', () => {
|
|
175
|
+
// Graph spans 400x200, canvas 800x600, padding 0
|
|
176
|
+
const nodes = [makeNode('a', 0, 0, 0), makeNode('b', 400, 200, 0)];
|
|
177
|
+
const t = ZoomTransform.fitBounds(nodes, 800, 600, 0);
|
|
178
|
+
// Scale should be min(800/400, 600/200) = min(2, 3) = 2
|
|
179
|
+
expect(t.k).toBeCloseTo(2);
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
// -------------------------------------------------------------------------
|
|
184
|
+
// identity
|
|
185
|
+
// -------------------------------------------------------------------------
|
|
186
|
+
|
|
187
|
+
describe('identity', () => {
|
|
188
|
+
it('has x=0, y=0, k=1', () => {
|
|
189
|
+
const t = ZoomTransform.identity();
|
|
190
|
+
expect(t.x).toBe(0);
|
|
191
|
+
expect(t.y).toBe(0);
|
|
192
|
+
expect(t.k).toBe(1);
|
|
193
|
+
});
|
|
194
|
+
});
|
|
195
|
+
});
|