@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.
@@ -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
- const node = compilation.nodes.find((n) => n.id === nodeId);
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
- const [source, target] = edgeId.split('->');
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
- resize();
1648
+ if (resizeTimer !== null) clearTimeout(resizeTimer);
1649
+ resizeTimer = setTimeout(() => {
1650
+ resizeTimer = null;
1651
+ resize();
1652
+ }, 100);
1641
1653
  });
1642
1654
  }
1643
1655
 
@@ -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
- entryG.setAttribute(
877
- 'aria-label',
878
- `${entry.label}: ${entry.active !== false ? 'visible' : 'hidden'}`,
879
- );
880
- entryG.setAttribute('style', 'cursor: pointer');
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
- // Apply dimming for inactive entries
883
- if (entry.active === false) {
884
- entryG.setAttribute('opacity', '0.3');
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, tap-outside-to-hide.
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
- let html = '';
49
-
50
- // Title row: optional color dot + title text
51
- if (content.title) {
52
- const titleColor = content.fields.find((f) => f.color)?.color;
53
- html += '<div class="viz-tooltip-header">';
54
- if (titleColor) {
55
- html += `<span class="viz-tooltip-dot" style="background:${esc(titleColor)}"></span>`;
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
- // Field rows
62
- if (content.fields.length > 0) {
63
- html += '<div class="viz-tooltip-body">';
64
- for (const field of content.fields) {
65
- html += '<div class="viz-tooltip-row">';
66
- html += `<span class="viz-tooltip-label">${esc(field.label)}</span>`;
67
- html += `<span class="viz-tooltip-value">${esc(field.value)}</span>`;
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
- // Flip horizontal if overflowing right
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
- // Clamp to container bounds
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
- tooltip.style.left = `${left}px`;
97
- tooltip.style.top = `${top}px`;
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, '&amp;')
117
142
  .replace(/</g, '&lt;')
118
143
  .replace(/>/g, '&gt;')
119
- .replace(/"/g, '&quot;');
144
+ .replace(/"/g, '&quot;')
145
+ .replace(/'/g, '&#x27;');
120
146
  }