@opendata-ai/openchart-vanilla 2.10.0 → 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 +98 -64
- 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-mount.ts +30 -38
- package/src/mount.ts +11 -2
- package/src/svg-renderer.ts +14 -5
- package/src/tooltip.ts +70 -44
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
|
-
|
|
1648
|
+
if (resizeTimer !== null) clearTimeout(resizeTimer);
|
|
1649
|
+
resizeTimer = setTimeout(() => {
|
|
1650
|
+
resizeTimer = null;
|
|
1651
|
+
resize();
|
|
1652
|
+
}, 100);
|
|
1644
1653
|
});
|
|
1645
1654
|
}
|
|
1646
1655
|
|
package/src/svg-renderer.ts
CHANGED
|
@@ -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,
|
|
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
|
}
|