@opendata-ai/openchart-vanilla 6.28.6 → 7.0.2
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 +13 -8
- package/dist/index.js +2800 -2349
- package/dist/index.js.map +1 -1
- package/dist/styles.css +1 -1
- package/package.json +3 -3
- package/src/__tests__/crosshair.test.ts +11 -2
- package/src/__tests__/events.test.ts +55 -10
- package/src/barlist-mount.ts +15 -1
- package/src/graph/__tests__/canvas-renderer.test.ts +1 -0
- package/src/interactions/chart-events.ts +139 -0
- package/src/interactions/crosshair.ts +233 -0
- package/src/interactions/drag-handler.ts +175 -0
- package/src/interactions/editing-drags.ts +512 -0
- package/src/interactions/index.ts +25 -0
- package/src/interactions/keyboard-nav.ts +111 -0
- package/src/interactions/legend-interaction.ts +38 -0
- package/src/interactions/selection.ts +271 -0
- package/src/interactions/tooltip-events.ts +72 -0
- package/src/mount.ts +182 -1761
- package/src/renderers/annotations.ts +82 -2
- package/src/renderers/axes.ts +18 -1
- package/src/renderers/brand.ts +7 -1
- package/src/renderers/chrome.ts +50 -3
- package/src/renderers/endpoint-labels.ts +164 -0
- package/src/renderers/legend.ts +32 -27
- package/src/renderers/marks.ts +65 -17
- package/src/renderers/metrics.ts +50 -0
- package/src/svg-renderer.ts +80 -20
- package/src/tilemap-mount.ts +6 -6
- package/src/tilemap-renderer.ts +0 -2
- package/src/tooltip.ts +27 -7
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import type { ChartLayout, TooltipContent } from '@opendata-ai/openchart-core';
|
|
2
|
+
import type { TooltipManager } from '../tooltip';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Wire keyboard navigation on the SVG element.
|
|
6
|
+
* Arrow keys move focus between mark elements. Enter/Space shows tooltip.
|
|
7
|
+
* Escape hides tooltip. Returns a cleanup function.
|
|
8
|
+
*/
|
|
9
|
+
export function wireKeyboardNav(
|
|
10
|
+
svg: SVGElement,
|
|
11
|
+
container: HTMLElement,
|
|
12
|
+
tooltipDescriptors: Map<string, TooltipContent>,
|
|
13
|
+
tooltipManager: TooltipManager,
|
|
14
|
+
layout: ChartLayout,
|
|
15
|
+
): () => void {
|
|
16
|
+
container.setAttribute('tabindex', '0');
|
|
17
|
+
container.setAttribute('aria-roledescription', 'chart');
|
|
18
|
+
container.setAttribute('aria-label', layout.a11y.altText);
|
|
19
|
+
|
|
20
|
+
const markElements: SVGElement[] = [];
|
|
21
|
+
const allMarkEls = svg.querySelectorAll('[data-mark-id]');
|
|
22
|
+
for (const el of allMarkEls) {
|
|
23
|
+
const markId = el.getAttribute('data-mark-id');
|
|
24
|
+
if (markId && tooltipDescriptors.has(markId)) {
|
|
25
|
+
markElements.push(el as SVGElement);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
let focusIndex = -1;
|
|
30
|
+
|
|
31
|
+
function highlightMark(index: number): void {
|
|
32
|
+
if (focusIndex >= 0 && focusIndex < markElements.length) {
|
|
33
|
+
markElements[focusIndex].classList.remove('oc-mark-focused');
|
|
34
|
+
markElements[focusIndex].removeAttribute('aria-selected');
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
focusIndex = index;
|
|
38
|
+
|
|
39
|
+
if (focusIndex >= 0 && focusIndex < markElements.length) {
|
|
40
|
+
const el = markElements[focusIndex];
|
|
41
|
+
el.classList.add('oc-mark-focused');
|
|
42
|
+
el.setAttribute('aria-selected', 'true');
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function showTooltipForFocused(): void {
|
|
47
|
+
if (focusIndex < 0 || focusIndex >= markElements.length) return;
|
|
48
|
+
|
|
49
|
+
const el = markElements[focusIndex];
|
|
50
|
+
const markId = el.getAttribute('data-mark-id');
|
|
51
|
+
if (!markId) return;
|
|
52
|
+
|
|
53
|
+
const content = tooltipDescriptors.get(markId);
|
|
54
|
+
if (!content) return;
|
|
55
|
+
|
|
56
|
+
const bbox = el.getBoundingClientRect();
|
|
57
|
+
const containerRect = container.getBoundingClientRect();
|
|
58
|
+
const x = bbox.left + bbox.width / 2 - containerRect.left;
|
|
59
|
+
const y = bbox.top - containerRect.top;
|
|
60
|
+
tooltipManager.show(content, x, y);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const handleKeyDown = (e: KeyboardEvent) => {
|
|
64
|
+
if (markElements.length === 0) return;
|
|
65
|
+
|
|
66
|
+
switch (e.key) {
|
|
67
|
+
case 'ArrowRight':
|
|
68
|
+
case 'ArrowDown': {
|
|
69
|
+
e.preventDefault();
|
|
70
|
+
const next = focusIndex < markElements.length - 1 ? focusIndex + 1 : 0;
|
|
71
|
+
highlightMark(next);
|
|
72
|
+
showTooltipForFocused();
|
|
73
|
+
break;
|
|
74
|
+
}
|
|
75
|
+
case 'ArrowLeft':
|
|
76
|
+
case 'ArrowUp': {
|
|
77
|
+
e.preventDefault();
|
|
78
|
+
const prev = focusIndex > 0 ? focusIndex - 1 : markElements.length - 1;
|
|
79
|
+
highlightMark(prev);
|
|
80
|
+
showTooltipForFocused();
|
|
81
|
+
break;
|
|
82
|
+
}
|
|
83
|
+
case 'Enter':
|
|
84
|
+
case ' ': {
|
|
85
|
+
e.preventDefault();
|
|
86
|
+
if (focusIndex >= 0) {
|
|
87
|
+
showTooltipForFocused();
|
|
88
|
+
} else if (markElements.length > 0) {
|
|
89
|
+
highlightMark(0);
|
|
90
|
+
showTooltipForFocused();
|
|
91
|
+
}
|
|
92
|
+
break;
|
|
93
|
+
}
|
|
94
|
+
case 'Escape': {
|
|
95
|
+
e.preventDefault();
|
|
96
|
+
tooltipManager.hide();
|
|
97
|
+
highlightMark(-1);
|
|
98
|
+
break;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
container.addEventListener('keydown', handleKeyDown);
|
|
104
|
+
|
|
105
|
+
return () => {
|
|
106
|
+
container.removeEventListener('keydown', handleKeyDown);
|
|
107
|
+
container.removeAttribute('tabindex');
|
|
108
|
+
container.removeAttribute('aria-roledescription');
|
|
109
|
+
container.removeAttribute('aria-label');
|
|
110
|
+
};
|
|
111
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { ChartLayout, ElementEdit } from '@opendata-ai/openchart-core';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Wire click handlers on legend entries to toggle series visibility.
|
|
5
|
+
* Returns a cleanup function.
|
|
6
|
+
*/
|
|
7
|
+
export function wireLegendInteraction(
|
|
8
|
+
svg: SVGElement,
|
|
9
|
+
_layout: ChartLayout,
|
|
10
|
+
toggleSeries: (series: string) => boolean,
|
|
11
|
+
onLegendToggle?: (series: string, visible: boolean) => void,
|
|
12
|
+
onEdit?: (edit: ElementEdit) => void,
|
|
13
|
+
): () => void {
|
|
14
|
+
const legendEntries = svg.querySelectorAll('[data-legend-index]');
|
|
15
|
+
const cleanups: Array<() => void> = [];
|
|
16
|
+
|
|
17
|
+
for (const entry of legendEntries) {
|
|
18
|
+
if (entry.getAttribute('data-legend-overflow') === 'true') continue;
|
|
19
|
+
|
|
20
|
+
const handleClick = () => {
|
|
21
|
+
const label = entry.getAttribute('data-legend-label');
|
|
22
|
+
if (!label) return;
|
|
23
|
+
|
|
24
|
+
const nowHidden = toggleSeries(label);
|
|
25
|
+
onLegendToggle?.(label, !nowHidden);
|
|
26
|
+
onEdit?.({ type: 'legend-toggle', series: label, hidden: nowHidden });
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
entry.addEventListener('click', handleClick);
|
|
30
|
+
cleanups.push(() => entry.removeEventListener('click', handleClick));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return () => {
|
|
34
|
+
for (const cleanup of cleanups) {
|
|
35
|
+
cleanup();
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
}
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
Annotation,
|
|
3
|
+
ChartLayout,
|
|
4
|
+
ChartSpec,
|
|
5
|
+
ChromeKey,
|
|
6
|
+
ElementRef,
|
|
7
|
+
GraphSpec,
|
|
8
|
+
LayerSpec,
|
|
9
|
+
TextAnnotation,
|
|
10
|
+
} from '@opendata-ai/openchart-core';
|
|
11
|
+
import { elementRef } from '@opendata-ai/openchart-core';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Find a DOM element inside the SVG that matches the given ElementRef.
|
|
15
|
+
*/
|
|
16
|
+
export function findElementByRef(svg: SVGElement, ref: ElementRef): SVGElement | null {
|
|
17
|
+
switch (ref.type) {
|
|
18
|
+
case 'annotation': {
|
|
19
|
+
if (ref.id) {
|
|
20
|
+
const byId = svg.querySelector(`[data-annotation-id="${ref.id}"]`);
|
|
21
|
+
if (byId) return byId as SVGElement;
|
|
22
|
+
}
|
|
23
|
+
return svg.querySelector(`[data-annotation-index="${ref.index}"]`) as SVGElement | null;
|
|
24
|
+
}
|
|
25
|
+
case 'chrome':
|
|
26
|
+
return svg.querySelector(`[data-chrome-key="${ref.key}"]`) as SVGElement | null;
|
|
27
|
+
case 'series-label':
|
|
28
|
+
return svg.querySelector(`.oc-mark-label[data-series="${ref.series}"]`) as SVGElement | null;
|
|
29
|
+
case 'legend':
|
|
30
|
+
return svg.querySelector('.oc-legend') as SVGElement | null;
|
|
31
|
+
case 'legend-entry':
|
|
32
|
+
return svg.querySelector(`[data-legend-index="${ref.index}"]`) as SVGElement | null;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Build an ElementRef from a DOM element's data attributes.
|
|
38
|
+
*/
|
|
39
|
+
export function buildElementRef(
|
|
40
|
+
element: Element,
|
|
41
|
+
_specAnnotations: Annotation[],
|
|
42
|
+
): ElementRef | null {
|
|
43
|
+
const annotationEl = element.closest('[data-annotation-index]');
|
|
44
|
+
if (annotationEl) {
|
|
45
|
+
const index = Number(annotationEl.getAttribute('data-annotation-index'));
|
|
46
|
+
const id = annotationEl.getAttribute('data-annotation-id') ?? undefined;
|
|
47
|
+
return elementRef.annotation(index, id);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const chromeEl = element.closest('[data-chrome-key]');
|
|
51
|
+
if (chromeEl) {
|
|
52
|
+
const key = chromeEl.getAttribute('data-chrome-key') as ChromeKey;
|
|
53
|
+
if (key) return elementRef.chrome(key);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const seriesLabelEl = element.closest('.oc-mark-label[data-series]');
|
|
57
|
+
if (seriesLabelEl) {
|
|
58
|
+
const series = seriesLabelEl.getAttribute('data-series');
|
|
59
|
+
if (series) return elementRef.seriesLabel(series);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const legendEntryEl = element.closest('[data-legend-index]');
|
|
63
|
+
if (legendEntryEl) {
|
|
64
|
+
const index = Number(legendEntryEl.getAttribute('data-legend-index'));
|
|
65
|
+
const series = legendEntryEl.getAttribute('data-legend-label') ?? '';
|
|
66
|
+
return elementRef.legendEntry(series, index);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const legendEl = element.closest('.oc-legend');
|
|
70
|
+
if (legendEl) return elementRef.legend();
|
|
71
|
+
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Get an ordered list of all editable ElementRefs from the current spec and layout.
|
|
77
|
+
*/
|
|
78
|
+
export function getEditableElements(
|
|
79
|
+
spec: ChartSpec | LayerSpec | GraphSpec,
|
|
80
|
+
layout: ChartLayout,
|
|
81
|
+
): ElementRef[] {
|
|
82
|
+
const refs: ElementRef[] = [];
|
|
83
|
+
|
|
84
|
+
const chromeKeys: ChromeKey[] = ['title', 'subtitle', 'source', 'byline', 'footer'];
|
|
85
|
+
for (const key of chromeKeys) {
|
|
86
|
+
if (layout.chrome[key]) {
|
|
87
|
+
refs.push(elementRef.chrome(key));
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const annotations: Annotation[] =
|
|
92
|
+
'annotations' in spec && Array.isArray(spec.annotations) ? spec.annotations : [];
|
|
93
|
+
for (let i = 0; i < annotations.length; i++) {
|
|
94
|
+
refs.push(elementRef.annotation(i, annotations[i].id));
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const seriesLabels: string[] = [];
|
|
98
|
+
for (const mark of layout.marks) {
|
|
99
|
+
if (mark.type === 'line' && mark.label?.visible && mark.seriesKey) {
|
|
100
|
+
seriesLabels.push(mark.seriesKey);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
seriesLabels.sort();
|
|
104
|
+
for (const series of seriesLabels) {
|
|
105
|
+
refs.push(elementRef.seriesLabel(series));
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if ('entries' in layout.legend && layout.legend.entries.length > 0) {
|
|
109
|
+
refs.push(elementRef.legend());
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return refs;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Check if an ElementRef points to a text-editable element.
|
|
117
|
+
*/
|
|
118
|
+
export function isTextEditable(ref: ElementRef, specAnnotations: Annotation[]): boolean {
|
|
119
|
+
if (ref.type === 'chrome') return true;
|
|
120
|
+
if (ref.type === 'annotation') {
|
|
121
|
+
const annotation = specAnnotations[ref.index];
|
|
122
|
+
return annotation?.type === 'text';
|
|
123
|
+
}
|
|
124
|
+
return false;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Get the current text content for an element ref.
|
|
129
|
+
*/
|
|
130
|
+
export function getElementText(
|
|
131
|
+
ref: ElementRef,
|
|
132
|
+
spec: ChartSpec | LayerSpec | GraphSpec,
|
|
133
|
+
): string | null {
|
|
134
|
+
if (ref.type === 'chrome') {
|
|
135
|
+
const chromeConfig = 'chrome' in spec ? spec.chrome : undefined;
|
|
136
|
+
if (!chromeConfig) return null;
|
|
137
|
+
const entry = chromeConfig[ref.key];
|
|
138
|
+
if (typeof entry === 'string') return entry;
|
|
139
|
+
if (typeof entry === 'object' && entry !== null && 'text' in entry) {
|
|
140
|
+
return (entry as { text: string }).text;
|
|
141
|
+
}
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
if (ref.type === 'annotation') {
|
|
145
|
+
const annotations: Annotation[] =
|
|
146
|
+
'annotations' in spec && Array.isArray(spec.annotations) ? spec.annotations : [];
|
|
147
|
+
const annotation = annotations[ref.index];
|
|
148
|
+
if (annotation?.type === 'text') return (annotation as TextAnnotation).text ?? null;
|
|
149
|
+
if (annotation?.label) return annotation.label;
|
|
150
|
+
return null;
|
|
151
|
+
}
|
|
152
|
+
return null;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Compare two ElementRefs for equality.
|
|
157
|
+
*/
|
|
158
|
+
export function refsEqual(a: ElementRef | null, b: ElementRef | null): boolean {
|
|
159
|
+
if (a === null || b === null) return a === b;
|
|
160
|
+
if (a.type !== b.type) return false;
|
|
161
|
+
switch (a.type) {
|
|
162
|
+
case 'annotation': {
|
|
163
|
+
const bAnno = b as typeof a;
|
|
164
|
+
if (a.id && bAnno.id) return a.id === bAnno.id;
|
|
165
|
+
return a.index === bAnno.index;
|
|
166
|
+
}
|
|
167
|
+
case 'chrome':
|
|
168
|
+
return a.key === (b as typeof a).key;
|
|
169
|
+
case 'series-label':
|
|
170
|
+
return a.series === (b as typeof a).series;
|
|
171
|
+
case 'legend':
|
|
172
|
+
return true;
|
|
173
|
+
case 'legend-entry': {
|
|
174
|
+
const bEntry = b as typeof a;
|
|
175
|
+
return a.index === bEntry.index && a.series === bEntry.series;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Render a selection overlay rectangle around a target element.
|
|
182
|
+
*/
|
|
183
|
+
export function renderSelectionOverlay(
|
|
184
|
+
svg: SVGElement,
|
|
185
|
+
ref: ElementRef,
|
|
186
|
+
layout: ChartLayout,
|
|
187
|
+
): SVGGElement | null {
|
|
188
|
+
const target = findElementByRef(svg, ref);
|
|
189
|
+
if (!target) return null;
|
|
190
|
+
|
|
191
|
+
const bbox = (target as SVGGraphicsElement).getBBox();
|
|
192
|
+
const padding = 4;
|
|
193
|
+
|
|
194
|
+
const accentColor = layout.theme.colors.categorical?.[0] ?? '#4f46e5';
|
|
195
|
+
|
|
196
|
+
const g = document.createElementNS('http://www.w3.org/2000/svg', 'g');
|
|
197
|
+
g.setAttribute('class', 'oc-selection-overlay');
|
|
198
|
+
|
|
199
|
+
const rect = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
|
|
200
|
+
rect.setAttribute('x', String(bbox.x - padding));
|
|
201
|
+
rect.setAttribute('y', String(bbox.y - padding));
|
|
202
|
+
rect.setAttribute('width', String(bbox.width + padding * 2));
|
|
203
|
+
rect.setAttribute('height', String(bbox.height + padding * 2));
|
|
204
|
+
rect.setAttribute('rx', '3');
|
|
205
|
+
rect.setAttribute('fill', 'transparent');
|
|
206
|
+
rect.setAttribute('stroke', accentColor);
|
|
207
|
+
rect.setAttribute('stroke-width', '1.5');
|
|
208
|
+
rect.setAttribute('pointer-events', 'none');
|
|
209
|
+
|
|
210
|
+
g.appendChild(rect);
|
|
211
|
+
svg.appendChild(g);
|
|
212
|
+
|
|
213
|
+
return g;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Create a visually-hidden data table from the chart's a11y fallback data.
|
|
218
|
+
*/
|
|
219
|
+
export function createScreenReaderTable(
|
|
220
|
+
layout: ChartLayout,
|
|
221
|
+
container: HTMLElement,
|
|
222
|
+
): HTMLTableElement | null {
|
|
223
|
+
const data = layout.a11y.dataTableFallback;
|
|
224
|
+
if (!data || data.length === 0) return null;
|
|
225
|
+
|
|
226
|
+
const table = document.createElement('table');
|
|
227
|
+
table.className = 'oc-sr-only';
|
|
228
|
+
table.style.position = 'absolute';
|
|
229
|
+
table.style.width = '1px';
|
|
230
|
+
table.style.height = '1px';
|
|
231
|
+
table.style.padding = '0';
|
|
232
|
+
table.style.margin = '-1px';
|
|
233
|
+
table.style.overflow = 'hidden';
|
|
234
|
+
table.style.clipPath = 'inset(50%)';
|
|
235
|
+
table.style.whiteSpace = 'nowrap';
|
|
236
|
+
table.style.borderWidth = '0';
|
|
237
|
+
table.setAttribute('role', 'table');
|
|
238
|
+
table.setAttribute('aria-label', `Data table: ${layout.a11y.altText}`);
|
|
239
|
+
|
|
240
|
+
if (data.length > 0) {
|
|
241
|
+
const thead = document.createElement('thead');
|
|
242
|
+
const headerRow = document.createElement('tr');
|
|
243
|
+
const headers = data[0] as unknown[];
|
|
244
|
+
for (const header of headers) {
|
|
245
|
+
const th = document.createElement('th');
|
|
246
|
+
th.textContent = String(header ?? '');
|
|
247
|
+
th.setAttribute('scope', 'col');
|
|
248
|
+
headerRow.appendChild(th);
|
|
249
|
+
}
|
|
250
|
+
thead.appendChild(headerRow);
|
|
251
|
+
table.appendChild(thead);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
if (data.length > 1) {
|
|
255
|
+
const tbody = document.createElement('tbody');
|
|
256
|
+
for (let i = 1; i < data.length; i++) {
|
|
257
|
+
const tr = document.createElement('tr');
|
|
258
|
+
const cells = data[i] as unknown[];
|
|
259
|
+
for (const cell of cells) {
|
|
260
|
+
const td = document.createElement('td');
|
|
261
|
+
td.textContent = String(cell ?? '');
|
|
262
|
+
tr.appendChild(td);
|
|
263
|
+
}
|
|
264
|
+
tbody.appendChild(tr);
|
|
265
|
+
}
|
|
266
|
+
table.appendChild(tbody);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
container.appendChild(table);
|
|
270
|
+
return table;
|
|
271
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import type { TooltipContent } from '@opendata-ai/openchart-core';
|
|
2
|
+
import type { TooltipManager } from '../tooltip';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Wire tooltip events on mark elements inside an SVG.
|
|
6
|
+
* Returns a cleanup function to remove all listeners.
|
|
7
|
+
*/
|
|
8
|
+
export function wireTooltipEvents(
|
|
9
|
+
svg: SVGElement,
|
|
10
|
+
tooltipDescriptors: Map<string, TooltipContent>,
|
|
11
|
+
tooltipManager: TooltipManager,
|
|
12
|
+
): () => void {
|
|
13
|
+
const markElements = svg.querySelectorAll('[data-mark-id]');
|
|
14
|
+
const cleanups: Array<() => void> = [];
|
|
15
|
+
|
|
16
|
+
for (const el of markElements) {
|
|
17
|
+
const markId = el.getAttribute('data-mark-id');
|
|
18
|
+
if (!markId) continue;
|
|
19
|
+
|
|
20
|
+
const content = tooltipDescriptors.get(markId);
|
|
21
|
+
if (!content) continue;
|
|
22
|
+
|
|
23
|
+
const handleMouseEnter = (e: Event) => {
|
|
24
|
+
const mouseEvent = e as MouseEvent;
|
|
25
|
+
const svgRect = svg.getBoundingClientRect();
|
|
26
|
+
const x = mouseEvent.clientX - svgRect.left;
|
|
27
|
+
const y = mouseEvent.clientY - svgRect.top;
|
|
28
|
+
tooltipManager.show(content, x, y);
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const handleMouseMove = (e: Event) => {
|
|
32
|
+
const mouseEvent = e as MouseEvent;
|
|
33
|
+
const svgRect = svg.getBoundingClientRect();
|
|
34
|
+
const x = mouseEvent.clientX - svgRect.left;
|
|
35
|
+
const y = mouseEvent.clientY - svgRect.top;
|
|
36
|
+
tooltipManager.show(content, x, y);
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const handleMouseLeave = () => {
|
|
40
|
+
tooltipManager.hide();
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const handleTouchStart = (e: Event) => {
|
|
44
|
+
const touchEvent = e as TouchEvent;
|
|
45
|
+
if (touchEvent.touches.length > 0) {
|
|
46
|
+
const touch = touchEvent.touches[0];
|
|
47
|
+
const svgRect = svg.getBoundingClientRect();
|
|
48
|
+
const x = touch.clientX - svgRect.left;
|
|
49
|
+
const y = touch.clientY - svgRect.top;
|
|
50
|
+
tooltipManager.show(content, x, y);
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
el.addEventListener('mouseenter', handleMouseEnter);
|
|
55
|
+
el.addEventListener('mousemove', handleMouseMove);
|
|
56
|
+
el.addEventListener('mouseleave', handleMouseLeave);
|
|
57
|
+
el.addEventListener('touchstart', handleTouchStart);
|
|
58
|
+
|
|
59
|
+
cleanups.push(() => {
|
|
60
|
+
el.removeEventListener('mouseenter', handleMouseEnter);
|
|
61
|
+
el.removeEventListener('mousemove', handleMouseMove);
|
|
62
|
+
el.removeEventListener('mouseleave', handleMouseLeave);
|
|
63
|
+
el.removeEventListener('touchstart', handleTouchStart);
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return () => {
|
|
68
|
+
for (const cleanup of cleanups) {
|
|
69
|
+
cleanup();
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
}
|