@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.
Files changed (44) hide show
  1. package/dist/index.d.ts +327 -0
  2. package/dist/index.js +4745 -0
  3. package/dist/index.js.map +1 -0
  4. package/dist/simulation-worker.js +1196 -0
  5. package/package.json +58 -0
  6. package/src/__test-fixtures__/dom.ts +42 -0
  7. package/src/__test-fixtures__/specs.ts +187 -0
  8. package/src/__tests__/edit-events.test.ts +747 -0
  9. package/src/__tests__/events.test.ts +336 -0
  10. package/src/__tests__/export.test.ts +150 -0
  11. package/src/__tests__/mount.test.ts +219 -0
  12. package/src/__tests__/svg-renderer.test.ts +609 -0
  13. package/src/__tests__/table-mount.test.ts +484 -0
  14. package/src/__tests__/tooltip.test.ts +201 -0
  15. package/src/export.ts +105 -0
  16. package/src/graph/__tests__/canvas-renderer.test.ts +704 -0
  17. package/src/graph/__tests__/graph-mount.test.ts +213 -0
  18. package/src/graph/__tests__/interaction.test.ts +205 -0
  19. package/src/graph/__tests__/keyboard.test.ts +653 -0
  20. package/src/graph/__tests__/search.test.ts +88 -0
  21. package/src/graph/__tests__/simulation.test.ts +233 -0
  22. package/src/graph/__tests__/spatial-index.test.ts +142 -0
  23. package/src/graph/__tests__/zoom.test.ts +195 -0
  24. package/src/graph/canvas-renderer.ts +660 -0
  25. package/src/graph/interaction.ts +359 -0
  26. package/src/graph/keyboard.ts +208 -0
  27. package/src/graph/search.ts +50 -0
  28. package/src/graph/simulation-worker-url.ts +30 -0
  29. package/src/graph/simulation-worker.ts +265 -0
  30. package/src/graph/simulation.ts +350 -0
  31. package/src/graph/spatial-index.ts +121 -0
  32. package/src/graph/types.ts +44 -0
  33. package/src/graph/worker-protocol.ts +67 -0
  34. package/src/graph/zoom.ts +104 -0
  35. package/src/graph-mount.ts +675 -0
  36. package/src/index.ts +56 -0
  37. package/src/mount.ts +1639 -0
  38. package/src/renderers/table-cells.ts +444 -0
  39. package/src/resize-observer.ts +46 -0
  40. package/src/svg-renderer.ts +914 -0
  41. package/src/table-keyboard.ts +266 -0
  42. package/src/table-mount.ts +532 -0
  43. package/src/table-renderer.ts +350 -0
  44. 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
+ });