@opendata-ai/openchart-vanilla 2.9.1 → 2.11.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.9.1",
3
+ "version": "2.11.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.9.1",
53
- "@opendata-ai/openchart-engine": "2.9.1",
52
+ "@floating-ui/dom": "^1.7.6",
53
+ "@opendata-ai/openchart-core": "2.11.0",
54
+ "@opendata-ai/openchart-engine": "2.11.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
  });
@@ -141,7 +141,7 @@ describe('ZoomTransform', () => {
141
141
 
142
142
  describe('fitBounds', () => {
143
143
  it('returns identity for empty node array', () => {
144
- const t = ZoomTransform.fitBounds([], 800, 600);
144
+ const { transform: t } = ZoomTransform.fitBounds([], 800, 600);
145
145
  expect(t.x).toBe(0);
146
146
  expect(t.y).toBe(0);
147
147
  expect(t.k).toBe(1);
@@ -149,7 +149,7 @@ describe('ZoomTransform', () => {
149
149
 
150
150
  it('centers a single node', () => {
151
151
  const nodes = [makeNode('a', 0, 0)];
152
- const t = ZoomTransform.fitBounds(nodes, 800, 600, 40);
152
+ const { transform: t } = ZoomTransform.fitBounds(nodes, 800, 600, 40);
153
153
  // Single node at origin should be centered
154
154
  // Transform should put graph origin at screen center
155
155
  const screen = t.graphToScreen(0, 0);
@@ -159,7 +159,7 @@ describe('ZoomTransform', () => {
159
159
 
160
160
  it('fits a spread of nodes within the canvas', () => {
161
161
  const nodes = [makeNode('a', -200, -100), makeNode('b', 200, 100)];
162
- const t = ZoomTransform.fitBounds(nodes, 800, 600, 40);
162
+ const { transform: t } = ZoomTransform.fitBounds(nodes, 800, 600, 40);
163
163
 
164
164
  // Both nodes should map to within the canvas bounds (with padding)
165
165
  const sa = t.graphToScreen(-200, -100);
@@ -174,10 +174,18 @@ describe('ZoomTransform', () => {
174
174
  it('produces correct scale for known graph bounds', () => {
175
175
  // Graph spans 400x200, canvas 800x600, padding 0
176
176
  const nodes = [makeNode('a', 0, 0, 0), makeNode('b', 400, 200, 0)];
177
- const t = ZoomTransform.fitBounds(nodes, 800, 600, 0);
177
+ const { transform: t } = ZoomTransform.fitBounds(nodes, 800, 600, 0);
178
178
  // Scale should be min(800/400, 600/200) = min(2, 3) = 2
179
179
  expect(t.k).toBeCloseTo(2);
180
180
  });
181
+
182
+ it('returns contentHeight matching scaled graph bounds plus padding', () => {
183
+ const nodes = [makeNode('a', 0, 0, 0), makeNode('b', 400, 200, 0)];
184
+ const { contentHeight } = ZoomTransform.fitBounds(nodes, 800, 600, 40);
185
+ // k = min(720/400, 520/200) = min(1.8, 2.6) = 1.8
186
+ // contentHeight = 200 * 1.8 + 80 = 440
187
+ expect(contentHeight).toBeCloseTo(440);
188
+ });
181
189
  });
182
190
 
183
191
  // -------------------------------------------------------------------------
package/src/graph/zoom.ts CHANGED
@@ -52,15 +52,18 @@ export class ZoomTransform {
52
52
  /**
53
53
  * Compute a transform that fits all nodes within the given canvas
54
54
  * dimensions with the specified padding.
55
+ *
56
+ * Returns the transform and the ideal content height (in screen pixels)
57
+ * so callers can shrink the canvas to eliminate dead space.
55
58
  */
56
59
  static fitBounds(
57
60
  nodes: PositionedNode[],
58
61
  canvasW: number,
59
62
  canvasH: number,
60
63
  padding: number = 40,
61
- ): ZoomTransform {
64
+ ): { transform: ZoomTransform; contentHeight: number } {
62
65
  if (nodes.length === 0) {
63
- return ZoomTransform.identity();
66
+ return { transform: ZoomTransform.identity(), contentHeight: canvasH };
64
67
  }
65
68
 
66
69
  let minX = Infinity;
@@ -81,7 +84,10 @@ export class ZoomTransform {
81
84
 
82
85
  if (graphW === 0 && graphH === 0) {
83
86
  // All nodes at the same point; just center
84
- return new ZoomTransform(canvasW / 2 - minX, canvasH / 2 - minY, 1);
87
+ return {
88
+ transform: new ZoomTransform(canvasW / 2 - minX, canvasH / 2 - minY, 1),
89
+ contentHeight: padding * 2,
90
+ };
85
91
  }
86
92
 
87
93
  const availW = canvasW - padding * 2;
@@ -95,7 +101,13 @@ export class ZoomTransform {
95
101
  const tx = canvasW / 2 - cx * k;
96
102
  const ty = padding - minY * k;
97
103
 
98
- return new ZoomTransform(tx, ty, k);
104
+ // Content height = scaled graph extent + top and bottom padding
105
+ const contentHeight = graphH * k + padding * 2;
106
+
107
+ return {
108
+ transform: new ZoomTransform(tx, ty, k),
109
+ contentHeight,
110
+ };
99
111
  }
100
112
 
101
113
  /** Identity transform (no pan, no zoom). */