@opendata-ai/openchart-vanilla 2.10.0 → 2.12.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/src/mount.ts CHANGED
@@ -1367,6 +1367,7 @@ export function createChart(
1367
1367
  let destroyed = false;
1368
1368
  let isDragging = false;
1369
1369
  let pendingRender = false;
1370
+ let resizeTimer: ReturnType<typeof setTimeout> | null = null;
1370
1371
 
1371
1372
  const measureText = createMeasureText();
1372
1373
 
@@ -1590,6 +1591,10 @@ export function createChart(
1590
1591
  if (destroyed) return;
1591
1592
  destroyed = true;
1592
1593
 
1594
+ if (resizeTimer !== null) {
1595
+ clearTimeout(resizeTimer);
1596
+ resizeTimer = null;
1597
+ }
1593
1598
  if (cleanupTooltipEvents) {
1594
1599
  cleanupTooltipEvents();
1595
1600
  cleanupTooltipEvents = null;
@@ -1637,10 +1642,14 @@ export function createChart(
1637
1642
  // Initial render
1638
1643
  render();
1639
1644
 
1640
- // Set up responsive resize
1645
+ // Set up responsive resize with debounce to avoid full SVG rebuild on every frame
1641
1646
  if (options?.responsive !== false) {
1642
1647
  disconnectResize = observeResize(container, () => {
1643
- resize();
1648
+ if (resizeTimer !== null) clearTimeout(resizeTimer);
1649
+ resizeTimer = setTimeout(() => {
1650
+ resizeTimer = null;
1651
+ resize();
1652
+ }, 100);
1644
1653
  });
1645
1654
  }
1646
1655
 
@@ -868,6 +868,20 @@ 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');
@@ -956,11 +970,6 @@ function renderLegend(parent: SVGElement, legend: LegendLayout): void {
956
970
  );
957
971
  const entryWidth = legend.swatchSize + legend.swatchGap + labelWidth + legend.entryGap;
958
972
  offsetX += entryWidth;
959
- // Wrap to next line if exceeding bounds
960
- if (offsetX > legend.bounds.x + legend.bounds.width && i < legend.entries.length - 1) {
961
- offsetX = legend.bounds.x;
962
- offsetY += legend.swatchSize + 6;
963
- }
964
973
  } else {
965
974
  offsetY += legend.swatchSize + legend.entryGap;
966
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
  }