@internetstiftelsen/charts 0.15.0 → 0.17.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/README.md +5 -1
- package/dist/theme.js +6 -0
- package/dist/tooltip/dom.d.ts +60 -0
- package/dist/tooltip/dom.js +457 -0
- package/dist/tooltip/geometry.d.ts +19 -0
- package/dist/tooltip/geometry.js +857 -0
- package/dist/tooltip/types.d.ts +79 -0
- package/dist/tooltip/types.js +24 -0
- package/dist/tooltip/xy-interaction.d.ts +34 -0
- package/dist/tooltip/xy-interaction.js +791 -0
- package/dist/tooltip.d.ts +6 -75
- package/dist/tooltip.js +51 -1445
- package/dist/types.d.ts +7 -3
- package/dist/x-axis.d.ts +11 -1
- package/dist/x-axis.js +150 -10
- package/dist/xy-chart.d.ts +1 -0
- package/dist/xy-chart.js +10 -4
- package/docs/components.md +29 -22
- package/docs/xy-chart.md +3 -10
- package/package.json +1 -1
|
@@ -0,0 +1,791 @@
|
|
|
1
|
+
import { pointer, select } from 'd3';
|
|
2
|
+
import { getSeriesColor } from '../types.js';
|
|
3
|
+
import { getContrastTextColor, sanitizeForCSS } from '../utils.js';
|
|
4
|
+
import { clipTooltipAnchorToBounds, getAnchoredTooltipPosition, getSplitTooltipViewportBounds, resolveSharedTooltipTarget, resolveSplitTooltipPositions, resolveSplitTooltipTarget, resolveTooltipArrowEdge, } from './geometry.js';
|
|
5
|
+
export function attachXYTooltipArea(config) {
|
|
6
|
+
const { svg, data, series, xKey, x, y, theme, plotArea, parseValue, isHorizontal, categoryScaleType, mode, position, barAnchorPosition, colorMode, formatter, labelFormatter, customFormatter, dom, } = config;
|
|
7
|
+
const tooltip = dom.getRootTooltip();
|
|
8
|
+
if (!tooltip || data.length === 0) {
|
|
9
|
+
return null;
|
|
10
|
+
}
|
|
11
|
+
const resolvedPosition = position === 'auto' ? (isHorizontal ? 'vertical' : 'side') : position;
|
|
12
|
+
const resolvedBarAnchorPosition = isHorizontal ? 'auto' : barAnchorPosition;
|
|
13
|
+
const resolveSeriesValue = config.resolveSeriesValue ??
|
|
14
|
+
((targetSeries, dataPoint) => {
|
|
15
|
+
const rawValue = dataPoint[targetSeries.dataKey];
|
|
16
|
+
if (rawValue === null || rawValue === undefined) {
|
|
17
|
+
return NaN;
|
|
18
|
+
}
|
|
19
|
+
return parseValue(rawValue);
|
|
20
|
+
});
|
|
21
|
+
const getXPosition = (dataPoint) => {
|
|
22
|
+
const scaled = x(getCategoryScaleValue(dataPoint[xKey], categoryScaleType));
|
|
23
|
+
return (scaled || 0) + (x.bandwidth ? x.bandwidth() / 2 : 0);
|
|
24
|
+
};
|
|
25
|
+
const getYPosition = (dataPoint) => {
|
|
26
|
+
const scaled = y(getCategoryScaleValue(dataPoint[xKey], categoryScaleType));
|
|
27
|
+
return (scaled || 0) + (y.bandwidth ? y.bandwidth() / 2 : 0);
|
|
28
|
+
};
|
|
29
|
+
const getTooltipValueSign = (value) => {
|
|
30
|
+
if (value < 0) {
|
|
31
|
+
return -1;
|
|
32
|
+
}
|
|
33
|
+
if (value > 0) {
|
|
34
|
+
return 1;
|
|
35
|
+
}
|
|
36
|
+
return 0;
|
|
37
|
+
};
|
|
38
|
+
const buildTooltipLabel = (dataPoint) => {
|
|
39
|
+
const labelValue = dataPoint[xKey];
|
|
40
|
+
return labelFormatter
|
|
41
|
+
? labelFormatter(String(labelValue), dataPoint)
|
|
42
|
+
: String(labelValue);
|
|
43
|
+
};
|
|
44
|
+
const buildTooltipRow = (dataPoint, currentSeries) => {
|
|
45
|
+
const value = dataPoint[currentSeries.dataKey];
|
|
46
|
+
if (formatter) {
|
|
47
|
+
return formatter(currentSeries.dataKey, normalizeFormatterValue(value), dataPoint);
|
|
48
|
+
}
|
|
49
|
+
return `${currentSeries.dataKey}: ${value}`;
|
|
50
|
+
};
|
|
51
|
+
const getVisibleTooltipSeries = (dataPoint) => {
|
|
52
|
+
return series.filter((currentSeries) => {
|
|
53
|
+
const value = dataPoint[currentSeries.dataKey];
|
|
54
|
+
return value !== null && value !== undefined;
|
|
55
|
+
});
|
|
56
|
+
};
|
|
57
|
+
const getSeriesTooltipStyle = (currentSeries, dataPoint, index) => {
|
|
58
|
+
if (colorMode !== 'series') {
|
|
59
|
+
return undefined;
|
|
60
|
+
}
|
|
61
|
+
const seriesColor = currentSeries.type === 'bar' && currentSeries.colorAdapter
|
|
62
|
+
? currentSeries.colorAdapter(dataPoint, index)
|
|
63
|
+
: getSeriesColor(currentSeries);
|
|
64
|
+
return {
|
|
65
|
+
background: seriesColor,
|
|
66
|
+
border: seriesColor,
|
|
67
|
+
color: getContrastTextColor(seriesColor),
|
|
68
|
+
};
|
|
69
|
+
};
|
|
70
|
+
const getSharedTooltipStyle = (visibleSeries, dataPoint, index) => {
|
|
71
|
+
if (visibleSeries.length !== 1) {
|
|
72
|
+
return undefined;
|
|
73
|
+
}
|
|
74
|
+
return getSeriesTooltipStyle(visibleSeries[0], dataPoint, index);
|
|
75
|
+
};
|
|
76
|
+
const buildSharedTooltipContent = (dataPoint, visibleSeries) => {
|
|
77
|
+
if (visibleSeries.length === 0) {
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
if (customFormatter) {
|
|
81
|
+
return customFormatter(dataPoint, visibleSeries);
|
|
82
|
+
}
|
|
83
|
+
const label = buildTooltipLabel(dataPoint);
|
|
84
|
+
const rows = visibleSeries.map((currentSeries) => buildTooltipRow(dataPoint, currentSeries));
|
|
85
|
+
return `<strong>${label}</strong><br/>${rows.join('<br/>')}`;
|
|
86
|
+
};
|
|
87
|
+
const buildSplitTooltipContent = (dataPoint, currentSeries) => {
|
|
88
|
+
if (customFormatter) {
|
|
89
|
+
return customFormatter(dataPoint, [currentSeries]);
|
|
90
|
+
}
|
|
91
|
+
const label = buildTooltipLabel(dataPoint);
|
|
92
|
+
return `<strong>${label}</strong><br/>${buildTooltipRow(dataPoint, currentSeries)}`;
|
|
93
|
+
};
|
|
94
|
+
const buildAccessibleLabel = (dataPoint) => {
|
|
95
|
+
const content = buildSharedTooltipContent(dataPoint, getVisibleTooltipSeries(dataPoint));
|
|
96
|
+
return content ? stripHtml(content) : buildTooltipLabel(dataPoint);
|
|
97
|
+
};
|
|
98
|
+
const dataPointPositions = data.map((dataPoint) => isHorizontal ? getYPosition(dataPoint) : getXPosition(dataPoint));
|
|
99
|
+
const focusCircleSeries = series.filter((currentSeries) => {
|
|
100
|
+
if (currentSeries.type === 'line' &&
|
|
101
|
+
currentSeries.points.show === 'never') {
|
|
102
|
+
return false;
|
|
103
|
+
}
|
|
104
|
+
return (currentSeries.type === 'line' ||
|
|
105
|
+
currentSeries.type === 'area' ||
|
|
106
|
+
currentSeries.type === 'scatter');
|
|
107
|
+
});
|
|
108
|
+
const barSeries = series.filter((currentSeries) => {
|
|
109
|
+
return currentSeries.type === 'bar';
|
|
110
|
+
});
|
|
111
|
+
const hasBarSeries = barSeries.length > 0;
|
|
112
|
+
const overlay = svg
|
|
113
|
+
.append('rect')
|
|
114
|
+
.attr('class', 'tooltip-overlay')
|
|
115
|
+
.attr('x', plotArea.left)
|
|
116
|
+
.attr('y', plotArea.top)
|
|
117
|
+
.attr('width', plotArea.width)
|
|
118
|
+
.attr('height', plotArea.height)
|
|
119
|
+
.attr('aria-hidden', 'true')
|
|
120
|
+
.style('fill', 'none')
|
|
121
|
+
.style('pointer-events', 'all');
|
|
122
|
+
const focusCircles = focusCircleSeries.map((currentSeries) => {
|
|
123
|
+
const seriesColor = getSeriesColor(currentSeries);
|
|
124
|
+
return svg
|
|
125
|
+
.append('circle')
|
|
126
|
+
.attr('class', `focus-circle-${sanitizeForCSS(currentSeries.dataKey)}`)
|
|
127
|
+
.attr('r', theme.line.point.size + 1)
|
|
128
|
+
.attr('fill', theme.line.point.color || seriesColor)
|
|
129
|
+
.attr('stroke', theme.line.point.strokeColor || seriesColor)
|
|
130
|
+
.attr('stroke-width', theme.line.point.strokeWidth)
|
|
131
|
+
.attr('aria-hidden', 'true')
|
|
132
|
+
.style('opacity', 0)
|
|
133
|
+
.style('pointer-events', 'none');
|
|
134
|
+
});
|
|
135
|
+
const clearVisualState = () => {
|
|
136
|
+
focusCircles.forEach((circle) => circle.style('opacity', 0));
|
|
137
|
+
if (!hasBarSeries) {
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
barSeries.forEach((currentSeries) => {
|
|
141
|
+
svg.selectAll(`.bar-${sanitizeForCSS(currentSeries.dataKey)}`).style('opacity', 1);
|
|
142
|
+
});
|
|
143
|
+
};
|
|
144
|
+
const updateVisualStateAtIndex = (closestIndex) => {
|
|
145
|
+
const dataPoint = data[closestIndex];
|
|
146
|
+
const dataPointPosition = dataPointPositions[closestIndex];
|
|
147
|
+
focusCircleSeries.forEach((currentSeries, seriesIndex) => {
|
|
148
|
+
const value = resolveSeriesValue(currentSeries, dataPoint, closestIndex);
|
|
149
|
+
if (!Number.isFinite(value)) {
|
|
150
|
+
focusCircles[seriesIndex].style('opacity', 0);
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
if (isHorizontal) {
|
|
154
|
+
focusCircles[seriesIndex]
|
|
155
|
+
.attr('cx', x(value) ?? 0)
|
|
156
|
+
.attr('cy', dataPointPosition)
|
|
157
|
+
.style('opacity', 1);
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
focusCircles[seriesIndex]
|
|
161
|
+
.attr('cx', dataPointPosition)
|
|
162
|
+
.attr('cy', y(value) ?? 0)
|
|
163
|
+
.style('opacity', 1);
|
|
164
|
+
});
|
|
165
|
+
if (!hasBarSeries) {
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
barSeries.forEach((currentSeries) => {
|
|
169
|
+
svg.selectAll(`.bar-${sanitizeForCSS(currentSeries.dataKey)}`).style('opacity', (_, index) => index === closestIndex ? 1 : 0.5);
|
|
170
|
+
});
|
|
171
|
+
};
|
|
172
|
+
const showSharedTooltipAtIndex = (closestIndex) => {
|
|
173
|
+
const dataPoint = data[closestIndex];
|
|
174
|
+
const dataPointPosition = dataPointPositions[closestIndex];
|
|
175
|
+
const visibleSeries = getVisibleTooltipSeries(dataPoint);
|
|
176
|
+
updateVisualStateAtIndex(closestIndex);
|
|
177
|
+
dom.hideSplitTooltips();
|
|
178
|
+
dom.applyRootTooltipStyles(theme, getSharedTooltipStyle(visibleSeries, dataPoint, closestIndex));
|
|
179
|
+
const content = buildSharedTooltipContent(dataPoint, visibleSeries);
|
|
180
|
+
if (!content) {
|
|
181
|
+
dom.hideTooltipSelection(tooltip);
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
const measuredTooltip = dom.measureTooltip(tooltip, content);
|
|
185
|
+
if (!measuredTooltip) {
|
|
186
|
+
dom.hideTooltipSelection(tooltip);
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
const svgNode = svg.node();
|
|
190
|
+
if (!svgNode) {
|
|
191
|
+
dom.hideTooltipSelection(tooltip);
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
const sharedAnchor = resolveSharedTooltipAnchor({
|
|
195
|
+
svgNode,
|
|
196
|
+
dataPoint,
|
|
197
|
+
dataPointPosition,
|
|
198
|
+
series,
|
|
199
|
+
x,
|
|
200
|
+
y,
|
|
201
|
+
isHorizontal,
|
|
202
|
+
resolveSeriesValue,
|
|
203
|
+
closestIndex,
|
|
204
|
+
});
|
|
205
|
+
const placementBounds = getTooltipPlacementBounds(svgNode);
|
|
206
|
+
const visibleAnchor = clipTooltipAnchorToBounds(sharedAnchor, placementBounds);
|
|
207
|
+
const target = resolveSharedTooltipTarget(visibleAnchor);
|
|
208
|
+
const arrowEdge = resolveTooltipArrowEdge(resolvedPosition, visibleAnchor, target, measuredTooltip.width, measuredTooltip.height, placementBounds);
|
|
209
|
+
const tooltipPosition = getAnchoredTooltipPosition(visibleAnchor, target, measuredTooltip.width, measuredTooltip.height, arrowEdge, placementBounds);
|
|
210
|
+
if (!tooltipPosition) {
|
|
211
|
+
dom.hideTooltipSelection(tooltip);
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
dom.renderTooltipWithoutConnector(tooltip, tooltipPosition.left, tooltipPosition.top);
|
|
215
|
+
};
|
|
216
|
+
const getSeriesTooltipAnchor = (currentSeries, index, value) => {
|
|
217
|
+
if (!Number.isFinite(value)) {
|
|
218
|
+
return null;
|
|
219
|
+
}
|
|
220
|
+
const svgNode = svg.node();
|
|
221
|
+
if (!svgNode) {
|
|
222
|
+
return null;
|
|
223
|
+
}
|
|
224
|
+
if (currentSeries.type === 'bar') {
|
|
225
|
+
return resolveBarTooltipAnchor(svgNode, currentSeries.dataKey, index);
|
|
226
|
+
}
|
|
227
|
+
const categoryPosition = dataPointPositions[index];
|
|
228
|
+
const valuePosition = isHorizontal ? (x(value) ?? 0) : (y(value) ?? 0);
|
|
229
|
+
return resolvePointTooltipAnchor(svgNode, isHorizontal, categoryPosition, valuePosition);
|
|
230
|
+
};
|
|
231
|
+
const showSplitTooltipAtIndex = (closestIndex) => {
|
|
232
|
+
const dataPoint = data[closestIndex];
|
|
233
|
+
const svgNode = svg.node();
|
|
234
|
+
if (!svgNode) {
|
|
235
|
+
dom.hideSplitTooltips();
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
const placementBounds = getTooltipPlacementBounds(svgNode);
|
|
239
|
+
updateVisualStateAtIndex(closestIndex);
|
|
240
|
+
dom.hideTooltipSelection(tooltip);
|
|
241
|
+
const layouts = [];
|
|
242
|
+
series.forEach((currentSeries, seriesIndex) => {
|
|
243
|
+
const rawValue = dataPoint[currentSeries.dataKey];
|
|
244
|
+
if (rawValue === null || rawValue === undefined) {
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
const value = resolveSeriesValue(currentSeries, dataPoint, closestIndex);
|
|
248
|
+
const anchor = getSeriesTooltipAnchor(currentSeries, closestIndex, value);
|
|
249
|
+
if (!anchor) {
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
const visibleAnchor = clipTooltipAnchorToBounds(anchor, placementBounds);
|
|
253
|
+
const splitTooltip = dom.getSplitTooltip(seriesIndex, theme, getSeriesTooltipStyle(currentSeries, dataPoint, closestIndex));
|
|
254
|
+
const content = buildSplitTooltipContent(dataPoint, currentSeries);
|
|
255
|
+
const measuredTooltip = dom.measureTooltip(splitTooltip, content);
|
|
256
|
+
if (!measuredTooltip) {
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
const target = resolveSplitTooltipTarget(currentSeries, visibleAnchor, resolvedBarAnchorPosition);
|
|
260
|
+
const targetMode = currentSeries.type === 'bar' &&
|
|
261
|
+
resolvedBarAnchorPosition === 'auto'
|
|
262
|
+
? 'auto'
|
|
263
|
+
: 'fixed';
|
|
264
|
+
layouts.push({
|
|
265
|
+
div: splitTooltip,
|
|
266
|
+
anchor: visibleAnchor,
|
|
267
|
+
width: measuredTooltip.width,
|
|
268
|
+
height: measuredTooltip.height,
|
|
269
|
+
left: 0,
|
|
270
|
+
top: 0,
|
|
271
|
+
arrowEdge: 'left',
|
|
272
|
+
targetX: target.x,
|
|
273
|
+
targetY: target.y,
|
|
274
|
+
order: seriesIndex,
|
|
275
|
+
targetMode,
|
|
276
|
+
valueSign: getTooltipValueSign(value),
|
|
277
|
+
});
|
|
278
|
+
});
|
|
279
|
+
if (layouts.length === 0) {
|
|
280
|
+
dom.hideSplitTooltips();
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
resolveSplitTooltipPositions(layouts, resolvedPosition, placementBounds, isHorizontal);
|
|
284
|
+
dom.hideUnusedSplitTooltips(layouts.map((layout) => layout.div));
|
|
285
|
+
layouts.forEach((layout) => {
|
|
286
|
+
dom.renderTooltipWithConnector(layout.div, layout.arrowEdge, layout.left, layout.top, layout.width, layout.height, layout.targetX, layout.targetY, layout.anchor);
|
|
287
|
+
});
|
|
288
|
+
};
|
|
289
|
+
const showSingleTooltip = (request) => {
|
|
290
|
+
const dataPoint = data[request.index];
|
|
291
|
+
const svgNode = svg.node();
|
|
292
|
+
if (!svgNode) {
|
|
293
|
+
dom.hideTooltipSelection(tooltip);
|
|
294
|
+
return null;
|
|
295
|
+
}
|
|
296
|
+
const placementBounds = getTooltipPlacementBounds(svgNode);
|
|
297
|
+
const candidates = [];
|
|
298
|
+
series.forEach((currentSeries, seriesIndex) => {
|
|
299
|
+
const rawValue = dataPoint[currentSeries.dataKey];
|
|
300
|
+
if (rawValue === null || rawValue === undefined) {
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
const value = resolveSeriesValue(currentSeries, dataPoint, request.index);
|
|
304
|
+
const anchor = getSeriesTooltipAnchor(currentSeries, request.index, value);
|
|
305
|
+
if (!anchor) {
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
const visibleAnchor = clipTooltipAnchorToBounds(anchor, placementBounds);
|
|
309
|
+
const target = resolveSplitTooltipTarget(currentSeries, visibleAnchor, resolvedBarAnchorPosition);
|
|
310
|
+
candidates.push({
|
|
311
|
+
series: currentSeries,
|
|
312
|
+
seriesIndex,
|
|
313
|
+
anchor: visibleAnchor,
|
|
314
|
+
target,
|
|
315
|
+
});
|
|
316
|
+
});
|
|
317
|
+
const selectedCandidate = candidates.find((candidate) => candidate.seriesIndex === request.seriesIndex) ?? getClosestSingleTooltipCandidate(candidates, request);
|
|
318
|
+
updateVisualStateAtIndex(request.index);
|
|
319
|
+
dom.hideSplitTooltips();
|
|
320
|
+
if (!selectedCandidate) {
|
|
321
|
+
dom.hideTooltipSelection(tooltip);
|
|
322
|
+
return null;
|
|
323
|
+
}
|
|
324
|
+
dom.applyRootTooltipStyles(theme, getSeriesTooltipStyle(selectedCandidate.series, dataPoint, request.index));
|
|
325
|
+
const content = buildSplitTooltipContent(dataPoint, selectedCandidate.series);
|
|
326
|
+
const measuredTooltip = dom.measureTooltip(tooltip, content);
|
|
327
|
+
if (!measuredTooltip) {
|
|
328
|
+
dom.hideTooltipSelection(tooltip);
|
|
329
|
+
return null;
|
|
330
|
+
}
|
|
331
|
+
const arrowEdge = resolveTooltipArrowEdge(resolvedPosition, selectedCandidate.anchor, selectedCandidate.target, measuredTooltip.width, measuredTooltip.height, placementBounds);
|
|
332
|
+
const tooltipPosition = getAnchoredTooltipPosition(selectedCandidate.anchor, selectedCandidate.target, measuredTooltip.width, measuredTooltip.height, arrowEdge, placementBounds);
|
|
333
|
+
if (!tooltipPosition) {
|
|
334
|
+
dom.hideTooltipSelection(tooltip);
|
|
335
|
+
return null;
|
|
336
|
+
}
|
|
337
|
+
dom.renderTooltipWithConnector(tooltip, arrowEdge, tooltipPosition.left, tooltipPosition.top, measuredTooltip.width, measuredTooltip.height, selectedCandidate.target.x, selectedCandidate.target.y, selectedCandidate.anchor);
|
|
338
|
+
return {
|
|
339
|
+
index: request.index,
|
|
340
|
+
seriesIndex: selectedCandidate.seriesIndex,
|
|
341
|
+
};
|
|
342
|
+
};
|
|
343
|
+
let activeTooltipRequest = null;
|
|
344
|
+
const hideTooltip = () => {
|
|
345
|
+
activeTooltipRequest = null;
|
|
346
|
+
dom.hideTooltipSelection(tooltip);
|
|
347
|
+
dom.hideSplitTooltips();
|
|
348
|
+
clearVisualState();
|
|
349
|
+
};
|
|
350
|
+
const tooltipSvgNode = svg.node();
|
|
351
|
+
const queuedRender = createQueuedTooltipRender(tooltipSvgNode, (request) => {
|
|
352
|
+
if (mode === 'single') {
|
|
353
|
+
activeTooltipRequest = showSingleTooltip(request);
|
|
354
|
+
return;
|
|
355
|
+
}
|
|
356
|
+
activeTooltipRequest = { index: request.index };
|
|
357
|
+
if (mode === 'split') {
|
|
358
|
+
showSplitTooltipAtIndex(request.index);
|
|
359
|
+
return;
|
|
360
|
+
}
|
|
361
|
+
showSharedTooltipAtIndex(request.index);
|
|
362
|
+
});
|
|
363
|
+
overlay
|
|
364
|
+
.on('mousemove', (event) => {
|
|
365
|
+
const [mouseX, mouseY] = pointer(event, svg.node());
|
|
366
|
+
const closestIndex = getClosestIndexFromPointer(mouseX, mouseY, dataPointPositions, isHorizontal);
|
|
367
|
+
const pointerPosition = getDocumentPointerPosition(event);
|
|
368
|
+
queuedRender.request({
|
|
369
|
+
index: closestIndex,
|
|
370
|
+
pointerX: pointerPosition.x,
|
|
371
|
+
pointerY: pointerPosition.y,
|
|
372
|
+
});
|
|
373
|
+
})
|
|
374
|
+
.on('mouseout', () => {
|
|
375
|
+
if (isTooltipFocusTarget(document.activeElement)) {
|
|
376
|
+
return;
|
|
377
|
+
}
|
|
378
|
+
queuedRender.cancel();
|
|
379
|
+
hideTooltip();
|
|
380
|
+
});
|
|
381
|
+
const focusTargets = svg
|
|
382
|
+
.append('g')
|
|
383
|
+
.attr('class', 'tooltip-focus-targets')
|
|
384
|
+
.selectAll('rect')
|
|
385
|
+
.data(data)
|
|
386
|
+
.join('rect')
|
|
387
|
+
.attr('class', 'tooltip-focus-target')
|
|
388
|
+
.attr('data-index', (_, i) => i)
|
|
389
|
+
.attr('x', (_, i) => getFocusTargetBounds(i, dataPointPositions, plotArea, x, y, isHorizontal).x)
|
|
390
|
+
.attr('y', (_, i) => getFocusTargetBounds(i, dataPointPositions, plotArea, x, y, isHorizontal).y)
|
|
391
|
+
.attr('width', (_, i) => getFocusTargetBounds(i, dataPointPositions, plotArea, x, y, isHorizontal).width)
|
|
392
|
+
.attr('height', (_, i) => getFocusTargetBounds(i, dataPointPositions, plotArea, x, y, isHorizontal).height)
|
|
393
|
+
.attr('tabindex', 0)
|
|
394
|
+
.attr('fill', 'transparent')
|
|
395
|
+
.attr('stroke', 'none')
|
|
396
|
+
.attr('stroke-width', 0)
|
|
397
|
+
.attr('aria-label', (dataPoint) => buildAccessibleLabel(dataPoint))
|
|
398
|
+
.style('pointer-events', 'none');
|
|
399
|
+
const focusTargetNodes = focusTargets.nodes();
|
|
400
|
+
focusTargets
|
|
401
|
+
.on('focus', function () {
|
|
402
|
+
const currentIndex = focusTargetNodes.indexOf(this);
|
|
403
|
+
if (currentIndex === -1) {
|
|
404
|
+
return;
|
|
405
|
+
}
|
|
406
|
+
queuedRender.cancel();
|
|
407
|
+
select(this).attr('stroke', '#111827').attr('stroke-width', 2);
|
|
408
|
+
if (mode === 'single') {
|
|
409
|
+
activeTooltipRequest = showSingleTooltip({
|
|
410
|
+
index: currentIndex,
|
|
411
|
+
});
|
|
412
|
+
return;
|
|
413
|
+
}
|
|
414
|
+
activeTooltipRequest = { index: currentIndex };
|
|
415
|
+
if (mode === 'split') {
|
|
416
|
+
showSplitTooltipAtIndex(currentIndex);
|
|
417
|
+
return;
|
|
418
|
+
}
|
|
419
|
+
showSharedTooltipAtIndex(currentIndex);
|
|
420
|
+
})
|
|
421
|
+
.on('blur', function (event) {
|
|
422
|
+
select(this).attr('stroke', 'none').attr('stroke-width', 0);
|
|
423
|
+
if (isTooltipFocusTarget(event.relatedTarget)) {
|
|
424
|
+
return;
|
|
425
|
+
}
|
|
426
|
+
queuedRender.cancel();
|
|
427
|
+
hideTooltip();
|
|
428
|
+
})
|
|
429
|
+
.on('keydown', function (event) {
|
|
430
|
+
const currentIndex = focusTargetNodes.indexOf(this);
|
|
431
|
+
if (currentIndex === -1) {
|
|
432
|
+
return;
|
|
433
|
+
}
|
|
434
|
+
const nextIndex = getNextFocusTargetIndex(currentIndex, event.key, focusTargetNodes.length);
|
|
435
|
+
if (nextIndex < 0 || nextIndex >= focusTargetNodes.length) {
|
|
436
|
+
return;
|
|
437
|
+
}
|
|
438
|
+
event.preventDefault();
|
|
439
|
+
focusTargetNodes[nextIndex].focus();
|
|
440
|
+
});
|
|
441
|
+
return attachTooltipScrollListeners(tooltipSvgNode, () => {
|
|
442
|
+
if (activeTooltipRequest === null) {
|
|
443
|
+
return;
|
|
444
|
+
}
|
|
445
|
+
queuedRender.request(activeTooltipRequest);
|
|
446
|
+
});
|
|
447
|
+
}
|
|
448
|
+
function normalizeFormatterValue(value) {
|
|
449
|
+
if (value === null ||
|
|
450
|
+
value === undefined ||
|
|
451
|
+
typeof value === 'string' ||
|
|
452
|
+
typeof value === 'number' ||
|
|
453
|
+
typeof value === 'boolean' ||
|
|
454
|
+
value instanceof Date) {
|
|
455
|
+
return value;
|
|
456
|
+
}
|
|
457
|
+
return String(value);
|
|
458
|
+
}
|
|
459
|
+
function getCategoryScaleValue(value, scaleType) {
|
|
460
|
+
switch (scaleType) {
|
|
461
|
+
case 'band':
|
|
462
|
+
return String(value);
|
|
463
|
+
case 'time':
|
|
464
|
+
return value instanceof Date ? value : new Date(String(value));
|
|
465
|
+
case 'linear':
|
|
466
|
+
case 'log':
|
|
467
|
+
return typeof value === 'number' ? value : Number(value);
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
function stripHtml(content) {
|
|
471
|
+
return content
|
|
472
|
+
.replace(/<br\s*\/?>/gi, '. ')
|
|
473
|
+
.replace(/<[^>]+>/g, ' ')
|
|
474
|
+
.replace(/\s+/g, ' ')
|
|
475
|
+
.trim();
|
|
476
|
+
}
|
|
477
|
+
function isTooltipFocusTarget(element) {
|
|
478
|
+
return (element instanceof SVGElement &&
|
|
479
|
+
element.classList.contains('tooltip-focus-target'));
|
|
480
|
+
}
|
|
481
|
+
function getClosestIndexFromPointer(mouseX, mouseY, dataPointPositions, isHorizontal) {
|
|
482
|
+
const pointerPosition = isHorizontal ? mouseY : mouseX;
|
|
483
|
+
let closestIndex = 0;
|
|
484
|
+
let minDistance = Math.abs(pointerPosition - dataPointPositions[0]);
|
|
485
|
+
for (let i = 1; i < dataPointPositions.length; i++) {
|
|
486
|
+
const distance = Math.abs(pointerPosition - dataPointPositions[i]);
|
|
487
|
+
if (distance < minDistance) {
|
|
488
|
+
minDistance = distance;
|
|
489
|
+
closestIndex = i;
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
return closestIndex;
|
|
493
|
+
}
|
|
494
|
+
function getDocumentPointerPosition(event) {
|
|
495
|
+
return {
|
|
496
|
+
x: event.clientX + window.scrollX,
|
|
497
|
+
y: event.clientY + window.scrollY,
|
|
498
|
+
};
|
|
499
|
+
}
|
|
500
|
+
function getClosestSingleTooltipCandidate(candidates, request) {
|
|
501
|
+
if (candidates.length === 0) {
|
|
502
|
+
return null;
|
|
503
|
+
}
|
|
504
|
+
if (request.pointerX === undefined ||
|
|
505
|
+
request.pointerY === undefined ||
|
|
506
|
+
!Number.isFinite(request.pointerX) ||
|
|
507
|
+
!Number.isFinite(request.pointerY)) {
|
|
508
|
+
return candidates[0];
|
|
509
|
+
}
|
|
510
|
+
return candidates.reduce((closest, candidate) => {
|
|
511
|
+
const closestDistance = getSingleTooltipDistance(closest, request);
|
|
512
|
+
const candidateDistance = getSingleTooltipDistance(candidate, request);
|
|
513
|
+
return candidateDistance < closestDistance ? candidate : closest;
|
|
514
|
+
});
|
|
515
|
+
}
|
|
516
|
+
function getSingleTooltipDistance(candidate, request) {
|
|
517
|
+
const deltaX = (request.pointerX ?? 0) - candidate.target.x;
|
|
518
|
+
const deltaY = (request.pointerY ?? 0) - candidate.target.y;
|
|
519
|
+
return Math.hypot(deltaX, deltaY);
|
|
520
|
+
}
|
|
521
|
+
function resolveSharedTooltipAnchor({ svgNode, dataPoint, dataPointPosition, series, x, y, isHorizontal, resolveSeriesValue, closestIndex, }) {
|
|
522
|
+
const svgRect = svgNode.getBoundingClientRect();
|
|
523
|
+
const values = series.map((currentSeries) => resolveSeriesValue(currentSeries, dataPoint, closestIndex));
|
|
524
|
+
const finiteValues = values.filter((value) => Number.isFinite(value));
|
|
525
|
+
const minValue = finiteValues.length ? Math.min(...finiteValues) : 0;
|
|
526
|
+
const maxValue = finiteValues.length ? Math.max(...finiteValues) : 0;
|
|
527
|
+
if (isHorizontal) {
|
|
528
|
+
const minX = x(minValue);
|
|
529
|
+
const maxX = x(maxValue);
|
|
530
|
+
const centerX = svgRect.left + window.scrollX + (minX + maxX) / 2;
|
|
531
|
+
const centerY = svgRect.top + window.scrollY + dataPointPosition;
|
|
532
|
+
return {
|
|
533
|
+
left: centerX,
|
|
534
|
+
right: centerX,
|
|
535
|
+
top: centerY,
|
|
536
|
+
bottom: centerY,
|
|
537
|
+
centerX,
|
|
538
|
+
centerY,
|
|
539
|
+
};
|
|
540
|
+
}
|
|
541
|
+
const minY = y(maxValue);
|
|
542
|
+
const maxY = y(minValue);
|
|
543
|
+
const centerX = svgRect.left + window.scrollX + dataPointPosition;
|
|
544
|
+
const topY = svgRect.top + window.scrollY + minY;
|
|
545
|
+
const bottomY = svgRect.top + window.scrollY + maxY;
|
|
546
|
+
return {
|
|
547
|
+
left: centerX,
|
|
548
|
+
right: centerX,
|
|
549
|
+
top: topY,
|
|
550
|
+
bottom: bottomY,
|
|
551
|
+
centerX,
|
|
552
|
+
centerY: (topY + bottomY) / 2,
|
|
553
|
+
};
|
|
554
|
+
}
|
|
555
|
+
function resolveBarTooltipAnchor(svgNode, dataKey, index) {
|
|
556
|
+
const barNode = svgNode.querySelector(`.bar-${sanitizeForCSS(dataKey)}[data-index="${index}"]`);
|
|
557
|
+
if (!barNode) {
|
|
558
|
+
return null;
|
|
559
|
+
}
|
|
560
|
+
const rect = barNode.getBoundingClientRect();
|
|
561
|
+
if (rect.width > 0 || rect.height > 0) {
|
|
562
|
+
return {
|
|
563
|
+
left: rect.left + window.scrollX,
|
|
564
|
+
right: rect.right + window.scrollX,
|
|
565
|
+
top: rect.top + window.scrollY,
|
|
566
|
+
bottom: rect.bottom + window.scrollY,
|
|
567
|
+
centerX: rect.left + window.scrollX + rect.width / 2,
|
|
568
|
+
centerY: rect.top + window.scrollY + rect.height / 2,
|
|
569
|
+
};
|
|
570
|
+
}
|
|
571
|
+
const svgRect = svgNode.getBoundingClientRect();
|
|
572
|
+
const xValue = Number(barNode.getAttribute('x') ?? '0');
|
|
573
|
+
const yValue = Number(barNode.getAttribute('y') ?? '0');
|
|
574
|
+
const widthValue = Number(barNode.getAttribute('width') ?? '0');
|
|
575
|
+
const heightValue = Number(barNode.getAttribute('height') ?? '0');
|
|
576
|
+
const left = svgRect.left + window.scrollX + xValue;
|
|
577
|
+
const top = svgRect.top + window.scrollY + yValue;
|
|
578
|
+
const right = left + widthValue;
|
|
579
|
+
const bottom = top + heightValue;
|
|
580
|
+
return {
|
|
581
|
+
left,
|
|
582
|
+
right,
|
|
583
|
+
top,
|
|
584
|
+
bottom,
|
|
585
|
+
centerX: left + widthValue / 2,
|
|
586
|
+
centerY: top + heightValue / 2,
|
|
587
|
+
};
|
|
588
|
+
}
|
|
589
|
+
function resolvePointTooltipAnchor(svgNode, isHorizontal, categoryPosition, valuePosition) {
|
|
590
|
+
if (!Number.isFinite(categoryPosition) || !Number.isFinite(valuePosition)) {
|
|
591
|
+
return null;
|
|
592
|
+
}
|
|
593
|
+
const svgRect = svgNode.getBoundingClientRect();
|
|
594
|
+
const anchorX = svgRect.left +
|
|
595
|
+
window.scrollX +
|
|
596
|
+
(isHorizontal ? valuePosition : categoryPosition);
|
|
597
|
+
const anchorY = svgRect.top +
|
|
598
|
+
window.scrollY +
|
|
599
|
+
(isHorizontal ? categoryPosition : valuePosition);
|
|
600
|
+
if (!Number.isFinite(anchorX) || !Number.isFinite(anchorY)) {
|
|
601
|
+
return null;
|
|
602
|
+
}
|
|
603
|
+
return {
|
|
604
|
+
left: anchorX,
|
|
605
|
+
right: anchorX,
|
|
606
|
+
top: anchorY,
|
|
607
|
+
bottom: anchorY,
|
|
608
|
+
centerX: anchorX,
|
|
609
|
+
centerY: anchorY,
|
|
610
|
+
};
|
|
611
|
+
}
|
|
612
|
+
function createQueuedTooltipRender(tooltipSvgNode, renderTooltip) {
|
|
613
|
+
let pendingTooltipFrame = null;
|
|
614
|
+
let pendingTooltipRequest = null;
|
|
615
|
+
const request = (nextRequest) => {
|
|
616
|
+
pendingTooltipRequest = nextRequest;
|
|
617
|
+
if (pendingTooltipFrame !== null) {
|
|
618
|
+
return;
|
|
619
|
+
}
|
|
620
|
+
pendingTooltipFrame = requestFrame(() => {
|
|
621
|
+
const currentRequest = pendingTooltipRequest;
|
|
622
|
+
pendingTooltipFrame = null;
|
|
623
|
+
pendingTooltipRequest = null;
|
|
624
|
+
if (currentRequest === null) {
|
|
625
|
+
return;
|
|
626
|
+
}
|
|
627
|
+
if (!tooltipSvgNode?.isConnected) {
|
|
628
|
+
return;
|
|
629
|
+
}
|
|
630
|
+
renderTooltip(currentRequest);
|
|
631
|
+
});
|
|
632
|
+
};
|
|
633
|
+
const cancel = () => {
|
|
634
|
+
if (pendingTooltipFrame === null) {
|
|
635
|
+
return;
|
|
636
|
+
}
|
|
637
|
+
cancelFrame(pendingTooltipFrame);
|
|
638
|
+
pendingTooltipFrame = null;
|
|
639
|
+
pendingTooltipRequest = null;
|
|
640
|
+
};
|
|
641
|
+
return { request, cancel };
|
|
642
|
+
}
|
|
643
|
+
function requestFrame(callback) {
|
|
644
|
+
if (typeof window.requestAnimationFrame === 'function') {
|
|
645
|
+
return window.requestAnimationFrame(callback);
|
|
646
|
+
}
|
|
647
|
+
return window.setTimeout(() => {
|
|
648
|
+
callback(window.performance.now());
|
|
649
|
+
}, 16);
|
|
650
|
+
}
|
|
651
|
+
function cancelFrame(frameId) {
|
|
652
|
+
if (typeof window.cancelAnimationFrame === 'function') {
|
|
653
|
+
window.cancelAnimationFrame(frameId);
|
|
654
|
+
return;
|
|
655
|
+
}
|
|
656
|
+
window.clearTimeout(frameId);
|
|
657
|
+
}
|
|
658
|
+
function getFocusTargetBounds(index, dataPointPositions, plotArea, x, y, isHorizontal) {
|
|
659
|
+
if (isHorizontal) {
|
|
660
|
+
if (y.bandwidth) {
|
|
661
|
+
const targetHeight = y.bandwidth();
|
|
662
|
+
return {
|
|
663
|
+
x: plotArea.left,
|
|
664
|
+
y: dataPointPositions[index] - targetHeight / 2,
|
|
665
|
+
width: plotArea.width,
|
|
666
|
+
height: targetHeight,
|
|
667
|
+
};
|
|
668
|
+
}
|
|
669
|
+
const top = index === 0
|
|
670
|
+
? plotArea.top
|
|
671
|
+
: (dataPointPositions[index - 1] + dataPointPositions[index]) /
|
|
672
|
+
2;
|
|
673
|
+
const bottom = index === dataPointPositions.length - 1
|
|
674
|
+
? plotArea.bottom
|
|
675
|
+
: (dataPointPositions[index] + dataPointPositions[index + 1]) /
|
|
676
|
+
2;
|
|
677
|
+
return {
|
|
678
|
+
x: plotArea.left,
|
|
679
|
+
y: top,
|
|
680
|
+
width: plotArea.width,
|
|
681
|
+
height: bottom - top,
|
|
682
|
+
};
|
|
683
|
+
}
|
|
684
|
+
if (x.bandwidth) {
|
|
685
|
+
const targetWidth = x.bandwidth();
|
|
686
|
+
return {
|
|
687
|
+
x: dataPointPositions[index] - targetWidth / 2,
|
|
688
|
+
y: plotArea.top,
|
|
689
|
+
width: targetWidth,
|
|
690
|
+
height: plotArea.height,
|
|
691
|
+
};
|
|
692
|
+
}
|
|
693
|
+
const left = index === 0
|
|
694
|
+
? plotArea.left
|
|
695
|
+
: (dataPointPositions[index - 1] + dataPointPositions[index]) / 2;
|
|
696
|
+
const right = index === dataPointPositions.length - 1
|
|
697
|
+
? plotArea.right
|
|
698
|
+
: (dataPointPositions[index] + dataPointPositions[index + 1]) / 2;
|
|
699
|
+
return {
|
|
700
|
+
x: left,
|
|
701
|
+
y: plotArea.top,
|
|
702
|
+
width: right - left,
|
|
703
|
+
height: plotArea.height,
|
|
704
|
+
};
|
|
705
|
+
}
|
|
706
|
+
function getNextFocusTargetIndex(currentIndex, key, focusTargetCount) {
|
|
707
|
+
switch (key) {
|
|
708
|
+
case 'ArrowRight':
|
|
709
|
+
case 'ArrowDown':
|
|
710
|
+
return currentIndex + 1;
|
|
711
|
+
case 'ArrowLeft':
|
|
712
|
+
case 'ArrowUp':
|
|
713
|
+
return currentIndex - 1;
|
|
714
|
+
case 'Home':
|
|
715
|
+
return 0;
|
|
716
|
+
case 'End':
|
|
717
|
+
return focusTargetCount - 1;
|
|
718
|
+
default:
|
|
719
|
+
return -1;
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
function getTooltipPlacementBounds(svgNode) {
|
|
723
|
+
let bounds = getSplitTooltipViewportBounds();
|
|
724
|
+
let currentElement = svgNode.parentElement;
|
|
725
|
+
while (currentElement) {
|
|
726
|
+
if (isScrollableElement(currentElement)) {
|
|
727
|
+
bounds = intersectTooltipBounds(bounds, getElementTooltipBounds(currentElement));
|
|
728
|
+
}
|
|
729
|
+
currentElement = currentElement.parentElement;
|
|
730
|
+
}
|
|
731
|
+
return bounds;
|
|
732
|
+
}
|
|
733
|
+
function getElementTooltipBounds(element) {
|
|
734
|
+
const rect = element.getBoundingClientRect();
|
|
735
|
+
const left = rect.left + window.scrollX;
|
|
736
|
+
const top = rect.top + window.scrollY;
|
|
737
|
+
return {
|
|
738
|
+
minLeft: left,
|
|
739
|
+
maxRight: left + rect.width,
|
|
740
|
+
minTop: top,
|
|
741
|
+
maxBottom: top + rect.height,
|
|
742
|
+
};
|
|
743
|
+
}
|
|
744
|
+
function intersectTooltipBounds(bounds, nextBounds) {
|
|
745
|
+
if (nextBounds.maxRight <= nextBounds.minLeft ||
|
|
746
|
+
nextBounds.maxBottom <= nextBounds.minTop) {
|
|
747
|
+
return bounds;
|
|
748
|
+
}
|
|
749
|
+
const minLeft = Math.max(bounds.minLeft, nextBounds.minLeft);
|
|
750
|
+
const maxRight = Math.min(bounds.maxRight, nextBounds.maxRight);
|
|
751
|
+
const minTop = Math.max(bounds.minTop, nextBounds.minTop);
|
|
752
|
+
const maxBottom = Math.min(bounds.maxBottom, nextBounds.maxBottom);
|
|
753
|
+
if (maxRight <= minLeft || maxBottom <= minTop) {
|
|
754
|
+
return bounds;
|
|
755
|
+
}
|
|
756
|
+
return {
|
|
757
|
+
minLeft,
|
|
758
|
+
maxRight,
|
|
759
|
+
minTop,
|
|
760
|
+
maxBottom,
|
|
761
|
+
};
|
|
762
|
+
}
|
|
763
|
+
function attachTooltipScrollListeners(svgNode, onScroll) {
|
|
764
|
+
if (!svgNode) {
|
|
765
|
+
return null;
|
|
766
|
+
}
|
|
767
|
+
const scrollTargets = getTooltipScrollTargets(svgNode);
|
|
768
|
+
scrollTargets.forEach((target) => {
|
|
769
|
+
target.addEventListener('scroll', onScroll, { passive: true });
|
|
770
|
+
});
|
|
771
|
+
return () => {
|
|
772
|
+
scrollTargets.forEach((target) => {
|
|
773
|
+
target.removeEventListener('scroll', onScroll);
|
|
774
|
+
});
|
|
775
|
+
};
|
|
776
|
+
}
|
|
777
|
+
function getTooltipScrollTargets(svgNode) {
|
|
778
|
+
const scrollTargets = new Set([window]);
|
|
779
|
+
let currentElement = svgNode.parentElement;
|
|
780
|
+
while (currentElement) {
|
|
781
|
+
if (isScrollableElement(currentElement)) {
|
|
782
|
+
scrollTargets.add(currentElement);
|
|
783
|
+
}
|
|
784
|
+
currentElement = currentElement.parentElement;
|
|
785
|
+
}
|
|
786
|
+
return [...scrollTargets];
|
|
787
|
+
}
|
|
788
|
+
function isScrollableElement(element) {
|
|
789
|
+
const style = window.getComputedStyle(element);
|
|
790
|
+
return [style.overflow, style.overflowX, style.overflowY].some((value) => value === 'auto' || value === 'scroll' || value === 'overlay');
|
|
791
|
+
}
|