@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/dist/index.js +51 -14
- package/dist/index.js.map +1 -1
- package/package.json +3 -3
- package/src/graph/__tests__/zoom.test.ts +12 -4
- package/src/graph/zoom.ts +16 -4
- package/src/graph-mount.ts +35 -4
- package/src/mount.ts +3 -0
- package/src/svg-renderer.ts +14 -8
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@opendata-ai/openchart-vanilla",
|
|
3
|
-
"version": "2.
|
|
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.
|
|
53
|
-
"@opendata-ai/openchart-engine": "2.
|
|
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
|
|
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
|
-
|
|
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). */
|
package/src/graph-mount.ts
CHANGED
|
@@ -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(
|
|
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;
|
package/src/svg-renderer.ts
CHANGED
|
@@ -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
|
-
|
|
877
|
-
'
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
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
|
-
|
|
883
|
-
|
|
884
|
-
|
|
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
|