@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/index.d.ts +2 -1
- package/dist/index.js +126 -55
- package/dist/index.js.map +1 -1
- package/dist/styles.css +2 -0
- package/package.json +4 -3
- package/src/__tests__/table-keyboard.test.ts +361 -0
- package/src/__tests__/tooltip.test.ts +119 -9
- package/src/graph/__tests__/zoom.test.ts +12 -4
- package/src/graph/zoom.ts +16 -4
- package/src/graph-mount.ts +31 -8
- package/src/mount.ts +14 -2
- package/src/svg-renderer.ts +28 -13
- package/src/tooltip.ts +70 -44
package/src/graph-mount.ts
CHANGED
|
@@ -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,6 +123,7 @@ export function createGraph(
|
|
|
121
123
|
let needsRender = false;
|
|
122
124
|
let isGesturing = false;
|
|
123
125
|
let gestureTimeout: ReturnType<typeof setTimeout> | null = null;
|
|
126
|
+
let lastEdgeHitTime = 0;
|
|
124
127
|
|
|
125
128
|
// ---------------------------------------------------------------------------
|
|
126
129
|
// Helpers
|
|
@@ -159,6 +162,11 @@ export function createGraph(
|
|
|
159
162
|
return compileGraph(currentSpec, compileOpts);
|
|
160
163
|
}
|
|
161
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
|
+
|
|
162
170
|
function buildAdjacencyMap(edges: CompiledGraphEdge[]): Map<string, Set<string>> {
|
|
163
171
|
const map = new Map<string, Set<string>>();
|
|
164
172
|
for (const edge of edges) {
|
|
@@ -190,8 +198,7 @@ export function createGraph(
|
|
|
190
198
|
* Falls back to an empty object if not found.
|
|
191
199
|
*/
|
|
192
200
|
function nodeDataById(nodeId: string): Record<string, unknown> {
|
|
193
|
-
|
|
194
|
-
return node?.data ?? {};
|
|
201
|
+
return nodeDataMap.get(nodeId) ?? {};
|
|
195
202
|
}
|
|
196
203
|
|
|
197
204
|
/**
|
|
@@ -244,9 +251,7 @@ export function createGraph(
|
|
|
244
251
|
* Look up edge data by edge id ("source->target").
|
|
245
252
|
*/
|
|
246
253
|
function edgeDataById(edgeId: string): Record<string, unknown> | null {
|
|
247
|
-
|
|
248
|
-
const edge = compilation.edges.find((e) => e.source === source && e.target === target);
|
|
249
|
-
return edge?.data ?? null;
|
|
254
|
+
return edgeDataMap.get(edgeId) ?? null;
|
|
250
255
|
}
|
|
251
256
|
|
|
252
257
|
// ---------------------------------------------------------------------------
|
|
@@ -410,9 +415,9 @@ export function createGraph(
|
|
|
410
415
|
|
|
411
416
|
simulation.onSettled(() => {
|
|
412
417
|
// One final fit after simulation settles
|
|
413
|
-
if (canvas && positionedNodes.length > 0 && interactionManager) {
|
|
418
|
+
if (canvas && positionedNodes.length > 0 && interactionManager && renderer) {
|
|
414
419
|
const { width: cw, height: ch } = getCanvasDimensions();
|
|
415
|
-
const fitTransform = ZoomTransform.fitBounds(positionedNodes, cw, ch);
|
|
420
|
+
const { transform: fitTransform } = ZoomTransform.fitBounds(positionedNodes, cw, ch);
|
|
416
421
|
interactionManager.setTransform(fitTransform);
|
|
417
422
|
needsRender = true;
|
|
418
423
|
scheduleRender();
|
|
@@ -510,6 +515,21 @@ export function createGraph(
|
|
|
510
515
|
}
|
|
511
516
|
},
|
|
512
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
|
+
|
|
513
533
|
// Edge hit testing: check proximity to edge line segments
|
|
514
534
|
const transform = interactionManager?.getTransform();
|
|
515
535
|
const threshold = 5 / (transform?.k ?? 1); // 5px in screen space
|
|
@@ -635,7 +655,7 @@ export function createGraph(
|
|
|
635
655
|
function zoomToFit(): void {
|
|
636
656
|
if (destroyed || !interactionManager || positionedNodes.length === 0) return;
|
|
637
657
|
const { width: cw, height: ch } = getCanvasDimensions();
|
|
638
|
-
const fitTransform = ZoomTransform.fitBounds(positionedNodes, cw, ch);
|
|
658
|
+
const { transform: fitTransform } = ZoomTransform.fitBounds(positionedNodes, cw, ch);
|
|
639
659
|
interactionManager.setTransform(fitTransform);
|
|
640
660
|
needsRender = true;
|
|
641
661
|
scheduleRender();
|
|
@@ -689,6 +709,7 @@ export function createGraph(
|
|
|
689
709
|
// Recompile
|
|
690
710
|
compilation = compile();
|
|
691
711
|
adjacencyMap = buildAdjacencyMap(compilation.edges);
|
|
712
|
+
buildDataMaps();
|
|
692
713
|
|
|
693
714
|
// Update DOM chrome/legend
|
|
694
715
|
renderChrome();
|
|
@@ -718,6 +739,7 @@ export function createGraph(
|
|
|
718
739
|
// Recompile with new spec (encoding, chrome, nodeOverrides, etc.)
|
|
719
740
|
compilation = compile();
|
|
720
741
|
adjacencyMap = buildAdjacencyMap(compilation.edges);
|
|
742
|
+
buildDataMaps();
|
|
721
743
|
|
|
722
744
|
// Transfer positions to new compiled nodes
|
|
723
745
|
positionedNodes = compilation.nodes.map((node) => {
|
|
@@ -802,6 +824,7 @@ export function createGraph(
|
|
|
802
824
|
try {
|
|
803
825
|
compilation = compile();
|
|
804
826
|
adjacencyMap = buildAdjacencyMap(compilation.edges);
|
|
827
|
+
buildDataMaps();
|
|
805
828
|
createDOM();
|
|
806
829
|
initSimulation();
|
|
807
830
|
initInteraction();
|
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;
|
|
@@ -1364,6 +1367,7 @@ export function createChart(
|
|
|
1364
1367
|
let destroyed = false;
|
|
1365
1368
|
let isDragging = false;
|
|
1366
1369
|
let pendingRender = false;
|
|
1370
|
+
let resizeTimer: ReturnType<typeof setTimeout> | null = null;
|
|
1367
1371
|
|
|
1368
1372
|
const measureText = createMeasureText();
|
|
1369
1373
|
|
|
@@ -1587,6 +1591,10 @@ export function createChart(
|
|
|
1587
1591
|
if (destroyed) return;
|
|
1588
1592
|
destroyed = true;
|
|
1589
1593
|
|
|
1594
|
+
if (resizeTimer !== null) {
|
|
1595
|
+
clearTimeout(resizeTimer);
|
|
1596
|
+
resizeTimer = null;
|
|
1597
|
+
}
|
|
1590
1598
|
if (cleanupTooltipEvents) {
|
|
1591
1599
|
cleanupTooltipEvents();
|
|
1592
1600
|
cleanupTooltipEvents = null;
|
|
@@ -1634,10 +1642,14 @@ export function createChart(
|
|
|
1634
1642
|
// Initial render
|
|
1635
1643
|
render();
|
|
1636
1644
|
|
|
1637
|
-
// Set up responsive resize
|
|
1645
|
+
// Set up responsive resize with debounce to avoid full SVG rebuild on every frame
|
|
1638
1646
|
if (options?.responsive !== false) {
|
|
1639
1647
|
disconnectResize = observeResize(container, () => {
|
|
1640
|
-
|
|
1648
|
+
if (resizeTimer !== null) clearTimeout(resizeTimer);
|
|
1649
|
+
resizeTimer = setTimeout(() => {
|
|
1650
|
+
resizeTimer = null;
|
|
1651
|
+
resize();
|
|
1652
|
+
}, 100);
|
|
1641
1653
|
});
|
|
1642
1654
|
}
|
|
1643
1655
|
|
package/src/svg-renderer.ts
CHANGED
|
@@ -868,20 +868,40 @@ function renderLegend(parent: SVGElement, legend: LegendLayout): void {
|
|
|
868
868
|
|
|
869
869
|
for (let i = 0; i < legend.entries.length; i++) {
|
|
870
870
|
const entry = legend.entries[i];
|
|
871
|
+
|
|
872
|
+
// Pre-check: wrap to next line if this entry would overflow bounds
|
|
873
|
+
if (isHorizontal && i > 0) {
|
|
874
|
+
const labelWidth = estimateTextWidth(
|
|
875
|
+
entry.label,
|
|
876
|
+
legend.labelStyle.fontSize,
|
|
877
|
+
legend.labelStyle.fontWeight,
|
|
878
|
+
);
|
|
879
|
+
const entryWidth = legend.swatchSize + legend.swatchGap + labelWidth + legend.entryGap;
|
|
880
|
+
if (offsetX + entryWidth > legend.bounds.x + legend.bounds.width) {
|
|
881
|
+
offsetX = legend.bounds.x;
|
|
882
|
+
offsetY += legend.swatchSize + 6;
|
|
883
|
+
}
|
|
884
|
+
}
|
|
871
885
|
const entryG = createSVGElement('g');
|
|
872
886
|
entryG.setAttribute('class', 'viz-legend-entry');
|
|
873
887
|
entryG.setAttribute('role', 'listitem');
|
|
874
888
|
entryG.setAttribute('data-legend-index', String(i));
|
|
875
889
|
entryG.setAttribute('data-legend-label', entry.label);
|
|
876
|
-
|
|
877
|
-
'
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
890
|
+
if (entry.overflow) {
|
|
891
|
+
entryG.setAttribute('data-legend-overflow', 'true');
|
|
892
|
+
entryG.setAttribute('aria-label', entry.label);
|
|
893
|
+
entryG.setAttribute('opacity', '0.5');
|
|
894
|
+
} else {
|
|
895
|
+
entryG.setAttribute(
|
|
896
|
+
'aria-label',
|
|
897
|
+
`${entry.label}: ${entry.active !== false ? 'visible' : 'hidden'}`,
|
|
898
|
+
);
|
|
899
|
+
entryG.setAttribute('style', 'cursor: pointer');
|
|
881
900
|
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
901
|
+
// Apply dimming for inactive entries
|
|
902
|
+
if (entry.active === false) {
|
|
903
|
+
entryG.setAttribute('opacity', '0.3');
|
|
904
|
+
}
|
|
885
905
|
}
|
|
886
906
|
|
|
887
907
|
// Swatch
|
|
@@ -950,11 +970,6 @@ function renderLegend(parent: SVGElement, legend: LegendLayout): void {
|
|
|
950
970
|
);
|
|
951
971
|
const entryWidth = legend.swatchSize + legend.swatchGap + labelWidth + legend.entryGap;
|
|
952
972
|
offsetX += entryWidth;
|
|
953
|
-
// Wrap to next line if exceeding bounds
|
|
954
|
-
if (offsetX > legend.bounds.x + legend.bounds.width && i < legend.entries.length - 1) {
|
|
955
|
-
offsetX = legend.bounds.x;
|
|
956
|
-
offsetY += legend.swatchSize + 6;
|
|
957
|
-
}
|
|
958
973
|
} else {
|
|
959
974
|
offsetY += legend.swatchSize + legend.entryGap;
|
|
960
975
|
}
|
package/src/tooltip.ts
CHANGED
|
@@ -2,9 +2,11 @@
|
|
|
2
2
|
* Tooltip manager: creates and positions a floating tooltip element.
|
|
3
3
|
*
|
|
4
4
|
* Shows tooltip content near the mouse/touch position with viewport
|
|
5
|
-
* edge avoidance. Touch support via tap-to-show,
|
|
5
|
+
* edge avoidance via @floating-ui/dom. Touch support via tap-to-show,
|
|
6
|
+
* tap-outside-to-hide.
|
|
6
7
|
*/
|
|
7
8
|
|
|
9
|
+
import { computePosition, flip, offset, shift } from '@floating-ui/dom';
|
|
8
10
|
import type { TooltipContent } from '@opendata-ai/openchart-core';
|
|
9
11
|
|
|
10
12
|
export interface TooltipManager {
|
|
@@ -36,6 +38,11 @@ export function createTooltipManager(container: HTMLElement): TooltipManager {
|
|
|
36
38
|
container.style.position = container.style.position || 'relative';
|
|
37
39
|
container.appendChild(tooltip);
|
|
38
40
|
|
|
41
|
+
// Track last content to skip innerHTML when only position changes
|
|
42
|
+
let lastContentKey = '';
|
|
43
|
+
// Generation counter to discard stale async position callbacks
|
|
44
|
+
let currentPositionId = 0;
|
|
45
|
+
|
|
39
46
|
// Hide on tap-outside for touch devices
|
|
40
47
|
const handleDocumentTouch = (e: Event): void => {
|
|
41
48
|
if (!container.contains(e.target as Node)) {
|
|
@@ -45,60 +52,78 @@ export function createTooltipManager(container: HTMLElement): TooltipManager {
|
|
|
45
52
|
document.addEventListener('touchstart', handleDocumentTouch);
|
|
46
53
|
|
|
47
54
|
function show(content: TooltipContent, x: number, y: number): void {
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
if (
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
55
|
+
// Fast content identity check: title + field count + first/last field values
|
|
56
|
+
const contentKey = `${content.title}|${content.fields.length}|${content.fields[0]?.value}|${content.fields[content.fields.length - 1]?.value}`;
|
|
57
|
+
|
|
58
|
+
if (contentKey !== lastContentKey) {
|
|
59
|
+
lastContentKey = contentKey;
|
|
60
|
+
|
|
61
|
+
let html = '';
|
|
62
|
+
|
|
63
|
+
// Title row: optional color dot + title text
|
|
64
|
+
if (content.title) {
|
|
65
|
+
const titleColor = content.fields.find((f) => f.color)?.color;
|
|
66
|
+
html += '<div class="viz-tooltip-header">';
|
|
67
|
+
if (titleColor) {
|
|
68
|
+
html += `<span class="viz-tooltip-dot" style="background:${esc(titleColor)}"></span>`;
|
|
69
|
+
}
|
|
70
|
+
html += `<span class="viz-tooltip-title">${esc(content.title)}</span>`;
|
|
71
|
+
html += '</div>';
|
|
56
72
|
}
|
|
57
|
-
html += `<span class="viz-tooltip-title">${esc(content.title)}</span>`;
|
|
58
|
-
html += '</div>';
|
|
59
|
-
}
|
|
60
73
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
74
|
+
// Field rows
|
|
75
|
+
if (content.fields.length > 0) {
|
|
76
|
+
html += '<div class="viz-tooltip-body">';
|
|
77
|
+
for (const field of content.fields) {
|
|
78
|
+
html += '<div class="viz-tooltip-row">';
|
|
79
|
+
html += `<span class="viz-tooltip-label">${esc(field.label)}</span>`;
|
|
80
|
+
html += `<span class="viz-tooltip-value">${esc(field.value)}</span>`;
|
|
81
|
+
html += '</div>';
|
|
82
|
+
}
|
|
68
83
|
html += '</div>';
|
|
69
84
|
}
|
|
70
|
-
html += '</div>';
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
tooltip.innerHTML = html;
|
|
74
|
-
tooltip.style.display = 'block';
|
|
75
|
-
|
|
76
|
-
// Position with viewport edge avoidance
|
|
77
|
-
const containerRect = container.getBoundingClientRect();
|
|
78
|
-
const tooltipRect = tooltip.getBoundingClientRect();
|
|
79
|
-
|
|
80
|
-
let left = x + TOOLTIP_OFFSET;
|
|
81
|
-
let top = y + TOOLTIP_OFFSET;
|
|
82
85
|
|
|
83
|
-
|
|
84
|
-
if (left + tooltipRect.width > containerRect.width) {
|
|
85
|
-
left = x - tooltipRect.width - TOOLTIP_OFFSET;
|
|
86
|
-
}
|
|
87
|
-
// Flip vertical if overflowing bottom
|
|
88
|
-
if (top + tooltipRect.height > containerRect.height) {
|
|
89
|
-
top = y - tooltipRect.height - TOOLTIP_OFFSET;
|
|
86
|
+
tooltip.innerHTML = html;
|
|
90
87
|
}
|
|
91
88
|
|
|
92
|
-
|
|
93
|
-
left = Math.max(0, Math.min(left, containerRect.width - tooltipRect.width));
|
|
94
|
-
top = Math.max(0, Math.min(top, containerRect.height - tooltipRect.height));
|
|
89
|
+
tooltip.style.display = 'block';
|
|
95
90
|
|
|
96
|
-
|
|
97
|
-
|
|
91
|
+
// Position with viewport-aware edge avoidance via @floating-ui/dom.
|
|
92
|
+
// Uses a virtual element since the reference point is a coordinate,
|
|
93
|
+
// not a DOM element.
|
|
94
|
+
const positionId = ++currentPositionId;
|
|
95
|
+
const virtualRef = {
|
|
96
|
+
getBoundingClientRect() {
|
|
97
|
+
const rect = container.getBoundingClientRect();
|
|
98
|
+
return {
|
|
99
|
+
x: rect.left + x,
|
|
100
|
+
y: rect.top + y,
|
|
101
|
+
width: 0,
|
|
102
|
+
height: 0,
|
|
103
|
+
top: rect.top + y,
|
|
104
|
+
left: rect.left + x,
|
|
105
|
+
right: rect.left + x,
|
|
106
|
+
bottom: rect.top + y,
|
|
107
|
+
};
|
|
108
|
+
},
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
computePosition(virtualRef, tooltip, {
|
|
112
|
+
placement: 'bottom-start',
|
|
113
|
+
middleware: [offset(TOOLTIP_OFFSET), flip(), shift({ padding: 5 })],
|
|
114
|
+
}).then(({ x: fx, y: fy }) => {
|
|
115
|
+
// Discard stale callbacks from earlier show() calls
|
|
116
|
+
if (positionId !== currentPositionId) return;
|
|
117
|
+
// computePosition returns coordinates relative to the tooltip's offset
|
|
118
|
+
// parent (the container with position: relative), so apply directly.
|
|
119
|
+
tooltip.style.left = `${fx}px`;
|
|
120
|
+
tooltip.style.top = `${fy}px`;
|
|
121
|
+
});
|
|
98
122
|
}
|
|
99
123
|
|
|
100
124
|
function hide(): void {
|
|
101
125
|
tooltip.style.display = 'none';
|
|
126
|
+
lastContentKey = '';
|
|
102
127
|
}
|
|
103
128
|
|
|
104
129
|
function destroy(): void {
|
|
@@ -116,5 +141,6 @@ function esc(str: string): string {
|
|
|
116
141
|
.replace(/&/g, '&')
|
|
117
142
|
.replace(/</g, '<')
|
|
118
143
|
.replace(/>/g, '>')
|
|
119
|
-
.replace(/"/g, '"')
|
|
144
|
+
.replace(/"/g, '"')
|
|
145
|
+
.replace(/'/g, ''');
|
|
120
146
|
}
|