@opendata-ai/openchart-vanilla 2.10.0 → 2.12.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/styles.css CHANGED
@@ -662,6 +662,8 @@ th[aria-sort="descending"] .viz-table-sort-btn::before {
662
662
  overflow: hidden;
663
663
  background: var(--viz-bg);
664
664
  font-family: var(--viz-font-family);
665
+ width: 100%;
666
+ height: 100%;
665
667
  }
666
668
 
667
669
  .viz-graph-canvas {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opendata-ai/openchart-vanilla",
3
- "version": "2.10.0",
3
+ "version": "2.12.0",
4
4
  "description": "Vanilla JS renderer for openchart: SVG charts, HTML tables, force-directed graphs",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Riley Hilliard",
@@ -49,8 +49,9 @@
49
49
  "typecheck": "tsc --noEmit"
50
50
  },
51
51
  "dependencies": {
52
- "@opendata-ai/openchart-core": "2.10.0",
53
- "@opendata-ai/openchart-engine": "2.10.0",
52
+ "@floating-ui/dom": "^1.7.6",
53
+ "@opendata-ai/openchart-core": "2.12.0",
54
+ "@opendata-ai/openchart-engine": "2.12.0",
54
55
  "d3-force": "^3.0.0",
55
56
  "d3-quadtree": "^3.0.1"
56
57
  },
@@ -0,0 +1,361 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2
+ import { attachKeyboardNav } from '../table-keyboard';
3
+
4
+ // ---------------------------------------------------------------------------
5
+ // Helpers
6
+ // ---------------------------------------------------------------------------
7
+
8
+ function createTableDOM(rows: number, cols: number, opts?: { search?: boolean }): HTMLDivElement {
9
+ const wrapper = document.createElement('div');
10
+ const table = document.createElement('table');
11
+
12
+ // thead
13
+ const thead = document.createElement('thead');
14
+ const headerRow = document.createElement('tr');
15
+ for (let c = 0; c < cols; c++) {
16
+ const th = document.createElement('th');
17
+ th.textContent = `Col${c}`;
18
+ th.setAttribute('data-column', `col${c}`);
19
+ const sortBtn = document.createElement('button');
20
+ sortBtn.setAttribute('data-sort-column', `col${c}`);
21
+ th.appendChild(sortBtn);
22
+ headerRow.appendChild(th);
23
+ }
24
+ thead.appendChild(headerRow);
25
+ table.appendChild(thead);
26
+
27
+ // tbody
28
+ const tbody = document.createElement('tbody');
29
+ for (let r = 0; r < rows; r++) {
30
+ const tr = document.createElement('tr');
31
+ for (let c = 0; c < cols; c++) {
32
+ const td = document.createElement('td');
33
+ td.textContent = `R${r}C${c}`;
34
+ tr.appendChild(td);
35
+ }
36
+ tbody.appendChild(tr);
37
+ }
38
+ table.appendChild(tbody);
39
+ wrapper.appendChild(table);
40
+
41
+ // Search input
42
+ if (opts?.search) {
43
+ const searchDiv = document.createElement('div');
44
+ searchDiv.className = 'viz-table-search';
45
+ const input = document.createElement('input');
46
+ searchDiv.appendChild(input);
47
+ wrapper.appendChild(searchDiv);
48
+ }
49
+
50
+ return wrapper;
51
+ }
52
+
53
+ function keydown(el: HTMLElement, key: string): void {
54
+ el.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true }));
55
+ }
56
+
57
+ function focusTbody(wrapper: HTMLDivElement): HTMLElement {
58
+ const tbody = wrapper.querySelector('tbody')!;
59
+ tbody.dispatchEvent(new Event('focus'));
60
+ return tbody;
61
+ }
62
+
63
+ // ---------------------------------------------------------------------------
64
+ // Tests
65
+ // ---------------------------------------------------------------------------
66
+
67
+ describe('attachKeyboardNav', () => {
68
+ let wrapper: HTMLDivElement;
69
+ let onSort: ReturnType<typeof vi.fn>;
70
+ let onClearSearch: ReturnType<typeof vi.fn>;
71
+ let onAnnounce: ReturnType<typeof vi.fn>;
72
+ let cleanup: () => void;
73
+
74
+ function attach(w: HTMLDivElement): () => void {
75
+ return attachKeyboardNav({
76
+ wrapper: w,
77
+ onSort,
78
+ onClearSearch,
79
+ onAnnounce,
80
+ });
81
+ }
82
+
83
+ beforeEach(() => {
84
+ onSort = vi.fn();
85
+ onClearSearch = vi.fn();
86
+ onAnnounce = vi.fn();
87
+ wrapper = createTableDOM(5, 3);
88
+ document.body.appendChild(wrapper);
89
+ cleanup = attach(wrapper);
90
+ });
91
+
92
+ afterEach(() => {
93
+ cleanup();
94
+ document.body.innerHTML = '';
95
+ });
96
+
97
+ it('makes tbody focusable', () => {
98
+ const tbody = wrapper.querySelector('tbody')!;
99
+ expect(tbody.getAttribute('tabindex')).toBe('0');
100
+ });
101
+
102
+ it('makes header cells focusable', () => {
103
+ const headers = wrapper.querySelectorAll('thead th');
104
+ for (const th of headers) {
105
+ expect(th.getAttribute('tabindex')).toBe('0');
106
+ }
107
+ });
108
+
109
+ it('returns noop cleanup when no table exists', () => {
110
+ const empty = document.createElement('div');
111
+ const c = attachKeyboardNav({
112
+ wrapper: empty,
113
+ onSort,
114
+ onClearSearch,
115
+ onAnnounce,
116
+ });
117
+ expect(c).toBeTypeOf('function');
118
+ c(); // should not throw
119
+ });
120
+ });
121
+
122
+ describe('tbody arrow key navigation', () => {
123
+ let wrapper: HTMLDivElement;
124
+ let onSort: ReturnType<typeof vi.fn>;
125
+ let onClearSearch: ReturnType<typeof vi.fn>;
126
+ let onAnnounce: ReturnType<typeof vi.fn>;
127
+ let cleanup: () => void;
128
+
129
+ beforeEach(() => {
130
+ onSort = vi.fn();
131
+ onClearSearch = vi.fn();
132
+ onAnnounce = vi.fn();
133
+ wrapper = createTableDOM(5, 3);
134
+ document.body.appendChild(wrapper);
135
+ cleanup = attachKeyboardNav({
136
+ wrapper,
137
+ onSort,
138
+ onClearSearch,
139
+ onAnnounce,
140
+ });
141
+ });
142
+
143
+ afterEach(() => {
144
+ cleanup();
145
+ document.body.innerHTML = '';
146
+ });
147
+
148
+ it('focus highlights first cell and sets aria-activedescendant', () => {
149
+ const tbody = focusTbody(wrapper);
150
+ expect(tbody.getAttribute('aria-activedescendant')).toBe('viz-cell-0-0');
151
+ const cell = wrapper.querySelector('.viz-table-cell-focus');
152
+ expect(cell).not.toBeNull();
153
+ expect(cell?.textContent).toBe('R0C0');
154
+ });
155
+
156
+ it('ArrowDown moves focus down one row', () => {
157
+ const tbody = focusTbody(wrapper);
158
+ keydown(tbody, 'ArrowDown');
159
+ expect(tbody.getAttribute('aria-activedescendant')).toBe('viz-cell-1-0');
160
+ });
161
+
162
+ it('ArrowDown does not go past last row', () => {
163
+ const tbody = focusTbody(wrapper);
164
+ for (let i = 0; i < 10; i++) keydown(tbody, 'ArrowDown');
165
+ expect(tbody.getAttribute('aria-activedescendant')).toBe('viz-cell-4-0');
166
+ });
167
+
168
+ it('ArrowRight moves focus right one column', () => {
169
+ const tbody = focusTbody(wrapper);
170
+ keydown(tbody, 'ArrowRight');
171
+ expect(tbody.getAttribute('aria-activedescendant')).toBe('viz-cell-0-1');
172
+ });
173
+
174
+ it('ArrowRight does not go past last column', () => {
175
+ const tbody = focusTbody(wrapper);
176
+ for (let i = 0; i < 10; i++) keydown(tbody, 'ArrowRight');
177
+ expect(tbody.getAttribute('aria-activedescendant')).toBe('viz-cell-0-2');
178
+ });
179
+
180
+ it('ArrowLeft moves focus left', () => {
181
+ const tbody = focusTbody(wrapper);
182
+ keydown(tbody, 'ArrowRight');
183
+ keydown(tbody, 'ArrowRight');
184
+ keydown(tbody, 'ArrowLeft');
185
+ expect(tbody.getAttribute('aria-activedescendant')).toBe('viz-cell-0-1');
186
+ });
187
+
188
+ it('ArrowLeft does not go past first column', () => {
189
+ const tbody = focusTbody(wrapper);
190
+ keydown(tbody, 'ArrowLeft');
191
+ expect(tbody.getAttribute('aria-activedescendant')).toBe('viz-cell-0-0');
192
+ });
193
+
194
+ it('Home moves to first column in current row', () => {
195
+ const tbody = focusTbody(wrapper);
196
+ keydown(tbody, 'ArrowRight');
197
+ keydown(tbody, 'ArrowRight');
198
+ keydown(tbody, 'Home');
199
+ expect(tbody.getAttribute('aria-activedescendant')).toBe('viz-cell-0-0');
200
+ });
201
+
202
+ it('End moves to last column in current row', () => {
203
+ const tbody = focusTbody(wrapper);
204
+ keydown(tbody, 'End');
205
+ expect(tbody.getAttribute('aria-activedescendant')).toBe('viz-cell-0-2');
206
+ });
207
+
208
+ it('navigation clears previous focus highlight', () => {
209
+ const tbody = focusTbody(wrapper);
210
+ keydown(tbody, 'ArrowDown');
211
+ const focused = wrapper.querySelectorAll('.viz-table-cell-focus');
212
+ expect(focused.length).toBe(1);
213
+ expect(focused[0].textContent).toBe('R1C0');
214
+ });
215
+ });
216
+
217
+ describe('header keyboard navigation', () => {
218
+ let wrapper: HTMLDivElement;
219
+ let onSort: ReturnType<typeof vi.fn>;
220
+ let onClearSearch: ReturnType<typeof vi.fn>;
221
+ let onAnnounce: ReturnType<typeof vi.fn>;
222
+ let cleanup: () => void;
223
+
224
+ beforeEach(() => {
225
+ onSort = vi.fn();
226
+ onClearSearch = vi.fn();
227
+ onAnnounce = vi.fn();
228
+ wrapper = createTableDOM(3, 3);
229
+ document.body.appendChild(wrapper);
230
+ cleanup = attachKeyboardNav({
231
+ wrapper,
232
+ onSort,
233
+ onClearSearch,
234
+ onAnnounce,
235
+ });
236
+ });
237
+
238
+ afterEach(() => {
239
+ cleanup();
240
+ document.body.innerHTML = '';
241
+ });
242
+
243
+ it('ArrowUp from first body row moves focus to header', () => {
244
+ const tbody = focusTbody(wrapper);
245
+ // ArrowUp from row 0 should focus the header
246
+ keydown(tbody, 'ArrowUp');
247
+ // Focus moved to header, so tbody's activedescendant should remain unchanged
248
+ const headers = wrapper.querySelectorAll('thead th');
249
+ // Can't easily check document.activeElement in happy-dom, but header focus() was called
250
+ expect(headers[0]).toBeDefined();
251
+ });
252
+
253
+ it('Enter on header triggers sort', () => {
254
+ const headers = wrapper.querySelectorAll('thead th');
255
+ keydown(headers[1] as HTMLElement, 'Enter');
256
+ expect(onSort).toHaveBeenCalledWith('col1');
257
+ });
258
+
259
+ it('Space on header triggers sort', () => {
260
+ const headers = wrapper.querySelectorAll('thead th');
261
+ keydown(headers[0] as HTMLElement, ' ');
262
+ expect(onSort).toHaveBeenCalledWith('col0');
263
+ });
264
+
265
+ it('ArrowRight on header moves to next header', () => {
266
+ // This tests the focus logic; in happy-dom we just verify no errors
267
+ const headers = wrapper.querySelectorAll('thead th');
268
+ keydown(headers[0] as HTMLElement, 'ArrowRight');
269
+ // Should not throw
270
+ });
271
+
272
+ it('ArrowDown from header moves to body at same column', () => {
273
+ const headers = wrapper.querySelectorAll('thead th');
274
+ keydown(headers[1] as HTMLElement, 'ArrowDown');
275
+ const tbody = wrapper.querySelector('tbody')!;
276
+ // The tbody should have activedescendant set to first row, column 1
277
+ expect(tbody.getAttribute('aria-activedescendant')).toBe('viz-cell-0-1');
278
+ });
279
+ });
280
+
281
+ describe('search escape handling', () => {
282
+ let wrapper: HTMLDivElement;
283
+ let onSort: ReturnType<typeof vi.fn>;
284
+ let onClearSearch: ReturnType<typeof vi.fn>;
285
+ let onAnnounce: ReturnType<typeof vi.fn>;
286
+ let cleanup: () => void;
287
+
288
+ beforeEach(() => {
289
+ onSort = vi.fn();
290
+ onClearSearch = vi.fn();
291
+ onAnnounce = vi.fn();
292
+ wrapper = createTableDOM(3, 3, { search: true });
293
+ document.body.appendChild(wrapper);
294
+ cleanup = attachKeyboardNav({
295
+ wrapper,
296
+ onSort,
297
+ onClearSearch,
298
+ onAnnounce,
299
+ });
300
+ });
301
+
302
+ afterEach(() => {
303
+ cleanup();
304
+ document.body.innerHTML = '';
305
+ });
306
+
307
+ it('Escape in search input calls onClearSearch', () => {
308
+ const input = wrapper.querySelector('.viz-table-search input')!;
309
+ keydown(input as HTMLElement, 'Escape');
310
+ expect(onClearSearch).toHaveBeenCalledTimes(1);
311
+ });
312
+
313
+ it('Escape in search announces "Search cleared"', () => {
314
+ const input = wrapper.querySelector('.viz-table-search input')!;
315
+ keydown(input as HTMLElement, 'Escape');
316
+ expect(onAnnounce).toHaveBeenCalledWith('Search cleared');
317
+ });
318
+
319
+ it('non-Escape keys in search do not trigger clear', () => {
320
+ const input = wrapper.querySelector('.viz-table-search input')!;
321
+ keydown(input as HTMLElement, 'a');
322
+ keydown(input as HTMLElement, 'Enter');
323
+ expect(onClearSearch).not.toHaveBeenCalled();
324
+ });
325
+ });
326
+
327
+ describe('cleanup', () => {
328
+ it('removes event listeners after cleanup', () => {
329
+ const wrapper = createTableDOM(3, 3, { search: true });
330
+ document.body.appendChild(wrapper);
331
+
332
+ const onSort = vi.fn();
333
+ const onClearSearch = vi.fn();
334
+ const onAnnounce = vi.fn();
335
+
336
+ const cleanup = attachKeyboardNav({
337
+ wrapper,
338
+ onSort,
339
+ onClearSearch,
340
+ onAnnounce,
341
+ });
342
+
343
+ cleanup();
344
+
345
+ // After cleanup, keyboard events should not trigger callbacks
346
+ const tbody = wrapper.querySelector('tbody')!;
347
+ tbody.dispatchEvent(new Event('focus'));
348
+ keydown(tbody, 'ArrowDown');
349
+
350
+ const input = wrapper.querySelector('.viz-table-search input')!;
351
+ keydown(input as HTMLElement, 'Escape');
352
+
353
+ expect(onClearSearch).not.toHaveBeenCalled();
354
+
355
+ // Focus highlight should be cleared
356
+ const focused = wrapper.querySelectorAll('.viz-table-cell-focus');
357
+ expect(focused.length).toBe(0);
358
+
359
+ document.body.innerHTML = '';
360
+ });
361
+ });
@@ -5,16 +5,46 @@
5
5
  * and cleanup behavior.
6
6
  */
7
7
 
8
- import { afterEach, describe, expect, it } from 'vitest';
8
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
9
9
  import { createContainer } from '../__test-fixtures__/dom';
10
- import { createTooltipManager } from '../tooltip';
10
+
11
+ const mockComputePosition = vi.fn();
12
+
13
+ vi.mock('@floating-ui/dom', async (importOriginal) => {
14
+ const actual = await importOriginal<typeof import('@floating-ui/dom')>();
15
+ return {
16
+ ...actual,
17
+ computePosition: (...args: unknown[]) => mockComputePosition(...args),
18
+ };
19
+ });
20
+
21
+ // Import after mock so the module picks up the mock
22
+ const { createTooltipManager } = await import('../tooltip');
23
+
24
+ /**
25
+ * Flush the microtask queue so computePosition's .then() resolves.
26
+ */
27
+ const flushPositioning = () => vi.waitFor(() => Promise.resolve());
11
28
 
12
29
  // ---------------------------------------------------------------------------
13
30
  // Cleanup
14
31
  // ---------------------------------------------------------------------------
15
32
 
33
+ const defaultPositionResult = {
34
+ x: 0,
35
+ y: 0,
36
+ placement: 'bottom-start' as const,
37
+ strategy: 'absolute' as const,
38
+ middlewareData: {},
39
+ };
40
+
41
+ beforeEach(() => {
42
+ mockComputePosition.mockResolvedValue(defaultPositionResult);
43
+ });
44
+
16
45
  afterEach(() => {
17
46
  document.body.innerHTML = '';
47
+ mockComputePosition.mockReset();
18
48
  });
19
49
 
20
50
  // ---------------------------------------------------------------------------
@@ -131,19 +161,99 @@ describe('createTooltipManager lifecycle', () => {
131
161
  // ---------------------------------------------------------------------------
132
162
 
133
163
  describe('tooltip positioning', () => {
134
- it('positions tooltip at offset from given coordinates', () => {
164
+ it('positions tooltip via computePosition with flip and shift', async () => {
165
+ mockComputePosition.mockResolvedValueOnce({
166
+ x: 112,
167
+ y: 212,
168
+ placement: 'bottom-start',
169
+ strategy: 'absolute',
170
+ middlewareData: {},
171
+ });
172
+
135
173
  const container = createContainer();
136
174
  const manager = createTooltipManager(container);
137
175
 
138
176
  manager.show({ title: 'Test', fields: [{ label: 'V', value: '1' }] }, 100, 200);
177
+ await flushPositioning();
178
+
179
+ // computePosition should have been called with the tooltip element
180
+ expect(mockComputePosition).toHaveBeenCalledOnce();
181
+ const [ref, tooltipEl, options] = mockComputePosition.mock.calls[0];
182
+ expect(tooltipEl).toBeInstanceOf(HTMLElement);
183
+ expect(options?.placement).toBe('bottom-start');
184
+ // Should include offset, flip, and shift middleware
185
+ expect(options?.middleware).toHaveLength(3);
186
+
187
+ // Virtual reference should return a rect at the mouse position
188
+ const rect = (ref as { getBoundingClientRect: () => DOMRect }).getBoundingClientRect();
189
+ expect(rect.width).toBe(0);
190
+ expect(rect.height).toBe(0);
191
+
192
+ // Position should be applied from computePosition result
193
+ const tooltip = container.querySelector('.viz-tooltip') as HTMLElement;
194
+ expect(tooltip.style.left).toContain('px');
195
+ expect(tooltip.style.top).toContain('px');
196
+
197
+ manager.destroy();
198
+ });
199
+
200
+ it('discards stale position callbacks on rapid show() calls', async () => {
201
+ type PosResult = {
202
+ x: number;
203
+ y: number;
204
+ placement: string;
205
+ strategy: string;
206
+ middlewareData: Record<string, never>;
207
+ };
208
+ let resolveFirst!: (val: PosResult) => void;
209
+ let resolveSecond!: (val: PosResult) => void;
210
+
211
+ mockComputePosition
212
+ .mockImplementationOnce(
213
+ () =>
214
+ new Promise((r) => {
215
+ resolveFirst = r;
216
+ }),
217
+ )
218
+ .mockImplementationOnce(
219
+ () =>
220
+ new Promise((r) => {
221
+ resolveSecond = r;
222
+ }),
223
+ );
224
+
225
+ const container = createContainer();
226
+ const manager = createTooltipManager(container);
227
+
228
+ // Two rapid show() calls
229
+ manager.show({ title: 'A', fields: [{ label: 'V', value: '1' }] }, 10, 10);
230
+ manager.show({ title: 'B', fields: [{ label: 'V', value: '2' }] }, 200, 200);
231
+
232
+ // Resolve the second (latest) first
233
+ resolveSecond({
234
+ x: 212,
235
+ y: 212,
236
+ placement: 'bottom-start',
237
+ strategy: 'absolute',
238
+ middlewareData: {},
239
+ });
240
+ await flushPositioning();
139
241
 
140
242
  const tooltip = container.querySelector('.viz-tooltip') as HTMLElement;
141
- // Tooltip should be positioned (the exact offset is TOOLTIP_OFFSET = 12)
142
- const left = parseInt(tooltip.style.left, 10);
143
- const top = parseInt(tooltip.style.top, 10);
144
- // Should be near the given coordinates (offset by 12)
145
- expect(left).toBeGreaterThanOrEqual(0);
146
- expect(top).toBeGreaterThanOrEqual(0);
243
+ const posAfterSecond = tooltip.style.left;
244
+
245
+ // Now resolve the first (stale) - should be discarded
246
+ resolveFirst({
247
+ x: 22,
248
+ y: 22,
249
+ placement: 'bottom-start',
250
+ strategy: 'absolute',
251
+ middlewareData: {},
252
+ });
253
+ await flushPositioning();
254
+
255
+ // Position should not have changed (stale callback was discarded)
256
+ expect(tooltip.style.left).toBe(posAfterSecond);
147
257
 
148
258
  manager.destroy();
149
259
  });
@@ -114,6 +114,8 @@ export function createGraph(
114
114
  let positionedNodes: PositionedNode[] = [];
115
115
  let positionedEdges: PositionedEdge[] = [];
116
116
  let adjacencyMap = new Map<string, Set<string>>();
117
+ let nodeDataMap = new Map<string, Record<string, unknown>>();
118
+ let edgeDataMap = new Map<string, Record<string, unknown>>();
117
119
  let hoveredNodeId: string | null = null;
118
120
  let hoveredEdgeId: string | null = null;
119
121
  let selectedNodeIds = new Set<string>();
@@ -121,7 +123,7 @@ export function createGraph(
121
123
  let needsRender = false;
122
124
  let isGesturing = false;
123
125
  let gestureTimeout: ReturnType<typeof setTimeout> | null = null;
124
- let selfResizing = false;
126
+ let lastEdgeHitTime = 0;
125
127
 
126
128
  // ---------------------------------------------------------------------------
127
129
  // Helpers
@@ -160,6 +162,11 @@ export function createGraph(
160
162
  return compileGraph(currentSpec, compileOpts);
161
163
  }
162
164
 
165
+ function buildDataMaps(): void {
166
+ nodeDataMap = new Map(compilation.nodes.map((n) => [n.id, n.data ?? {}]));
167
+ edgeDataMap = new Map(compilation.edges.map((e) => [`${e.source}->${e.target}`, e.data ?? {}]));
168
+ }
169
+
163
170
  function buildAdjacencyMap(edges: CompiledGraphEdge[]): Map<string, Set<string>> {
164
171
  const map = new Map<string, Set<string>>();
165
172
  for (const edge of edges) {
@@ -191,8 +198,7 @@ export function createGraph(
191
198
  * Falls back to an empty object if not found.
192
199
  */
193
200
  function nodeDataById(nodeId: string): Record<string, unknown> {
194
- const node = compilation.nodes.find((n) => n.id === nodeId);
195
- return node?.data ?? {};
201
+ return nodeDataMap.get(nodeId) ?? {};
196
202
  }
197
203
 
198
204
  /**
@@ -245,9 +251,7 @@ export function createGraph(
245
251
  * Look up edge data by edge id ("source->target").
246
252
  */
247
253
  function edgeDataById(edgeId: string): Record<string, unknown> | null {
248
- const [source, target] = edgeId.split('->');
249
- const edge = compilation.edges.find((e) => e.source === source && e.target === target);
250
- return edge?.data ?? null;
254
+ return edgeDataMap.get(edgeId) ?? null;
251
255
  }
252
256
 
253
257
  // ---------------------------------------------------------------------------
@@ -413,36 +417,8 @@ export function createGraph(
413
417
  // One final fit after simulation settles
414
418
  if (canvas && positionedNodes.length > 0 && interactionManager && renderer) {
415
419
  const { width: cw, height: ch } = getCanvasDimensions();
416
- const { transform: fitTransform, contentHeight } = ZoomTransform.fitBounds(
417
- positionedNodes,
418
- cw,
419
- ch,
420
- );
420
+ const { transform: fitTransform } = ZoomTransform.fitBounds(positionedNodes, cw, ch);
421
421
  interactionManager.setTransform(fitTransform);
422
-
423
- // Shrink canvas + container to actual content height to eliminate dead space.
424
- // The chrome (title/subtitle) sits above the canvas, so total height includes both.
425
- const chromeH = chromeEl?.getBoundingClientRect().height || 0;
426
- const totalContentHeight = Math.ceil(contentHeight) + chromeH;
427
- const containerH = container.getBoundingClientRect().height;
428
-
429
- if (totalContentHeight < containerH) {
430
- selfResizing = true;
431
- const targetCanvasH = Math.ceil(contentHeight);
432
- renderer.resize(cw, targetCanvasH);
433
- // Re-fit with the new canvas height
434
- const refit = ZoomTransform.fitBounds(positionedNodes, cw, targetCanvasH);
435
- interactionManager.setTransform(refit.transform);
436
- // Collapse the container to content height instead of filling the parent.
437
- // This eliminates dead space below compact graphs in tall containers.
438
- container.style.height = 'fit-content';
439
- // Hold selfResizing long enough for the ResizeObserver (debounced ~16ms)
440
- // to see it and skip the doResize that would clear our height override.
441
- setTimeout(() => {
442
- selfResizing = false;
443
- }, 100);
444
- }
445
-
446
422
  needsRender = true;
447
423
  scheduleRender();
448
424
  }
@@ -539,6 +515,21 @@ export function createGraph(
539
515
  }
540
516
  },
541
517
  onBackgroundHover(graphX, graphY, screenX, screenY) {
518
+ // Throttle edge hit testing to avoid O(n) scan on every mousemove
519
+ const now = performance.now();
520
+ if (now - lastEdgeHitTime < 32) {
521
+ // When throttled, clear edge hover so hover-off transitions stay snappy
522
+ if (hoveredEdgeId) {
523
+ hoveredEdgeId = null;
524
+ needsRender = true;
525
+ scheduleRender();
526
+ options?.onEdgeHover?.(null);
527
+ tooltipManager?.hide();
528
+ }
529
+ return;
530
+ }
531
+ lastEdgeHitTime = now;
532
+
542
533
  // Edge hit testing: check proximity to edge line segments
543
534
  const transform = interactionManager?.getTransform();
544
535
  const threshold = 5 / (transform?.k ?? 1); // 5px in screen space
@@ -699,9 +690,7 @@ export function createGraph(
699
690
  }
700
691
 
701
692
  function doResize(): void {
702
- if (destroyed || !canvas || !renderer || !wrapper || selfResizing) return;
703
- // Clear any content-fit height override so we read the parent's actual size
704
- container.style.height = '';
693
+ if (destroyed || !canvas || !renderer || !wrapper) return;
705
694
  const { width, height } = getContainerDimensions();
706
695
  const chromeHeight = chromeEl?.getBoundingClientRect().height || 0;
707
696
  const canvasHeight = Math.max(height - chromeHeight, 200);
@@ -720,6 +709,7 @@ export function createGraph(
720
709
  // Recompile
721
710
  compilation = compile();
722
711
  adjacencyMap = buildAdjacencyMap(compilation.edges);
712
+ buildDataMaps();
723
713
 
724
714
  // Update DOM chrome/legend
725
715
  renderChrome();
@@ -749,6 +739,7 @@ export function createGraph(
749
739
  // Recompile with new spec (encoding, chrome, nodeOverrides, etc.)
750
740
  compilation = compile();
751
741
  adjacencyMap = buildAdjacencyMap(compilation.edges);
742
+ buildDataMaps();
752
743
 
753
744
  // Transfer positions to new compiled nodes
754
745
  positionedNodes = compilation.nodes.map((node) => {
@@ -833,6 +824,7 @@ export function createGraph(
833
824
  try {
834
825
  compilation = compile();
835
826
  adjacencyMap = buildAdjacencyMap(compilation.edges);
827
+ buildDataMaps();
836
828
  createDOM();
837
829
  initSimulation();
838
830
  initInteraction();