@opendata-ai/openchart-vanilla 2.9.1 → 2.10.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opendata-ai/openchart-vanilla",
3
- "version": "2.9.1",
3
+ "version": "2.10.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,8 @@
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
+ "@opendata-ai/openchart-core": "2.10.0",
53
+ "@opendata-ai/openchart-engine": "2.10.0",
54
54
  "d3-force": "^3.0.0",
55
55
  "d3-quadtree": "^3.0.1"
56
56
  },
@@ -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). */
@@ -121,6 +121,7 @@ export function createGraph(
121
121
  let needsRender = false;
122
122
  let isGesturing = false;
123
123
  let gestureTimeout: ReturnType<typeof setTimeout> | null = null;
124
+ let selfResizing = false;
124
125
 
125
126
  // ---------------------------------------------------------------------------
126
127
  // Helpers
@@ -410,10 +411,38 @@ export function createGraph(
410
411
 
411
412
  simulation.onSettled(() => {
412
413
  // One final fit after simulation settles
413
- if (canvas && positionedNodes.length > 0 && interactionManager) {
414
+ if (canvas && positionedNodes.length > 0 && interactionManager && renderer) {
414
415
  const { width: cw, height: ch } = getCanvasDimensions();
415
- const fitTransform = ZoomTransform.fitBounds(positionedNodes, cw, ch);
416
+ const { transform: fitTransform, contentHeight } = ZoomTransform.fitBounds(
417
+ positionedNodes,
418
+ cw,
419
+ ch,
420
+ );
416
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
+
417
446
  needsRender = true;
418
447
  scheduleRender();
419
448
  }
@@ -635,7 +664,7 @@ export function createGraph(
635
664
  function zoomToFit(): void {
636
665
  if (destroyed || !interactionManager || positionedNodes.length === 0) return;
637
666
  const { width: cw, height: ch } = getCanvasDimensions();
638
- const fitTransform = ZoomTransform.fitBounds(positionedNodes, cw, ch);
667
+ const { transform: fitTransform } = ZoomTransform.fitBounds(positionedNodes, cw, ch);
639
668
  interactionManager.setTransform(fitTransform);
640
669
  needsRender = true;
641
670
  scheduleRender();
@@ -670,7 +699,9 @@ export function createGraph(
670
699
  }
671
700
 
672
701
  function doResize(): void {
673
- if (destroyed || !canvas || !renderer || !wrapper) return;
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 = '';
674
705
  const { width, height } = getContainerDimensions();
675
706
  const chromeHeight = chromeEl?.getBoundingClientRect().height || 0;
676
707
  const canvasHeight = Math.max(height - chromeHeight, 200);
package/src/mount.ts CHANGED
@@ -1115,6 +1115,9 @@ function wireLegendInteraction(
1115
1115
  const hiddenSeries = new Set<string>();
1116
1116
 
1117
1117
  for (const entry of legendEntries) {
1118
+ // Skip overflow indicator entries ("+N more")
1119
+ if (entry.getAttribute('data-legend-overflow') === 'true') continue;
1120
+
1118
1121
  const handleClick = () => {
1119
1122
  const label = entry.getAttribute('data-legend-label');
1120
1123
  if (!label) return;
@@ -873,15 +873,21 @@ function renderLegend(parent: SVGElement, legend: LegendLayout): void {
873
873
  entryG.setAttribute('role', 'listitem');
874
874
  entryG.setAttribute('data-legend-index', String(i));
875
875
  entryG.setAttribute('data-legend-label', entry.label);
876
- entryG.setAttribute(
877
- 'aria-label',
878
- `${entry.label}: ${entry.active !== false ? 'visible' : 'hidden'}`,
879
- );
880
- entryG.setAttribute('style', 'cursor: pointer');
876
+ if (entry.overflow) {
877
+ entryG.setAttribute('data-legend-overflow', 'true');
878
+ entryG.setAttribute('aria-label', entry.label);
879
+ entryG.setAttribute('opacity', '0.5');
880
+ } else {
881
+ entryG.setAttribute(
882
+ 'aria-label',
883
+ `${entry.label}: ${entry.active !== false ? 'visible' : 'hidden'}`,
884
+ );
885
+ entryG.setAttribute('style', 'cursor: pointer');
881
886
 
882
- // Apply dimming for inactive entries
883
- if (entry.active === false) {
884
- entryG.setAttribute('opacity', '0.3');
887
+ // Apply dimming for inactive entries
888
+ if (entry.active === false) {
889
+ entryG.setAttribute('opacity', '0.3');
890
+ }
885
891
  }
886
892
 
887
893
  // Swatch