@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,857 @@
|
|
|
1
|
+
import { SPLIT_TOOLTIP_GAP_PX, TOOLTIP_BOX_ARROW_LENGTH_PX, TOOLTIP_CONNECTOR_ALIGNMENT_TOLERANCE_PX, TOOLTIP_CONNECTOR_ELBOW_RATIO, TOOLTIP_CONNECTOR_INSET_PX, TOOLTIP_CONNECTOR_PADDING_PX, TOOLTIP_OFFSET_PX, TOOLTIP_TOTAL_BORDER_WIDTH_PX, TOOLTIP_VIEWPORT_PADDING_PX, } from './types.js';
|
|
2
|
+
const MAX_EXHAUSTIVE_HORIZONTAL_ROW_LAYOUTS = 10;
|
|
3
|
+
const HORIZONTAL_ROW_ORDER_FLIP_PENALTY_PX = 128;
|
|
4
|
+
export function getSplitTooltipViewportBounds() {
|
|
5
|
+
const visualViewport = window.visualViewport;
|
|
6
|
+
const viewportLeft = window.scrollX + (visualViewport?.offsetLeft ?? 0);
|
|
7
|
+
const viewportTop = window.scrollY + (visualViewport?.offsetTop ?? 0);
|
|
8
|
+
const viewportWidth = visualViewport?.width ?? window.innerWidth;
|
|
9
|
+
const viewportHeight = visualViewport?.height ?? window.innerHeight;
|
|
10
|
+
return {
|
|
11
|
+
minLeft: viewportLeft + TOOLTIP_VIEWPORT_PADDING_PX,
|
|
12
|
+
maxRight: viewportLeft + viewportWidth - TOOLTIP_VIEWPORT_PADDING_PX,
|
|
13
|
+
minTop: viewportTop + TOOLTIP_VIEWPORT_PADDING_PX,
|
|
14
|
+
maxBottom: viewportTop + viewportHeight - TOOLTIP_VIEWPORT_PADDING_PX,
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
export function clipTooltipAnchorToBounds(anchor, bounds) {
|
|
18
|
+
const left = clampTooltipCoordinate(anchor.left, bounds.minLeft, bounds.maxRight);
|
|
19
|
+
const right = clampTooltipCoordinate(anchor.right, bounds.minLeft, bounds.maxRight);
|
|
20
|
+
const top = clampTooltipCoordinate(anchor.top, bounds.minTop, bounds.maxBottom);
|
|
21
|
+
const bottom = clampTooltipCoordinate(anchor.bottom, bounds.minTop, bounds.maxBottom);
|
|
22
|
+
const clippedLeft = Math.min(left, right);
|
|
23
|
+
const clippedRight = Math.max(left, right);
|
|
24
|
+
const clippedTop = Math.min(top, bottom);
|
|
25
|
+
const clippedBottom = Math.max(top, bottom);
|
|
26
|
+
return {
|
|
27
|
+
left: clippedLeft,
|
|
28
|
+
right: clippedRight,
|
|
29
|
+
top: clippedTop,
|
|
30
|
+
bottom: clippedBottom,
|
|
31
|
+
centerX: (clippedLeft + clippedRight) / 2,
|
|
32
|
+
centerY: (clippedTop + clippedBottom) / 2,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
export function resolveTooltipArrowEdge(position, anchor, target, tooltipWidth, tooltipHeight, bounds = getSplitTooltipViewportBounds()) {
|
|
36
|
+
if (position === 'vertical') {
|
|
37
|
+
return resolveVerticalPlacementArrowEdge(target, tooltipHeight, bounds);
|
|
38
|
+
}
|
|
39
|
+
return resolveSidePlacementArrowEdge(anchor, tooltipWidth, bounds);
|
|
40
|
+
}
|
|
41
|
+
export function resolveSidePlacementArrowEdge(anchor, tooltipWidth, bounds = getSplitTooltipViewportBounds()) {
|
|
42
|
+
const availableRightSpace = bounds.maxRight - anchor.right - TOOLTIP_OFFSET_PX;
|
|
43
|
+
const availableLeftSpace = anchor.left - bounds.minLeft - TOOLTIP_OFFSET_PX;
|
|
44
|
+
if (availableRightSpace >= tooltipWidth ||
|
|
45
|
+
availableRightSpace >= availableLeftSpace) {
|
|
46
|
+
return 'left';
|
|
47
|
+
}
|
|
48
|
+
return 'right';
|
|
49
|
+
}
|
|
50
|
+
export function resolveVerticalPlacementArrowEdge(target, tooltipHeight, bounds = getSplitTooltipViewportBounds()) {
|
|
51
|
+
const availableTopSpace = target.y - bounds.minTop - TOOLTIP_OFFSET_PX;
|
|
52
|
+
const availableBottomSpace = bounds.maxBottom - target.y - TOOLTIP_OFFSET_PX;
|
|
53
|
+
if (availableTopSpace >= tooltipHeight ||
|
|
54
|
+
availableTopSpace >= availableBottomSpace) {
|
|
55
|
+
return 'bottom';
|
|
56
|
+
}
|
|
57
|
+
return 'top';
|
|
58
|
+
}
|
|
59
|
+
export function resolveSharedTooltipTarget(anchor) {
|
|
60
|
+
return {
|
|
61
|
+
x: anchor.centerX,
|
|
62
|
+
y: anchor.centerY,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
export function resolveSplitTooltipTarget(currentSeries, anchor, barAnchorPosition) {
|
|
66
|
+
if (currentSeries.type === 'bar') {
|
|
67
|
+
return {
|
|
68
|
+
x: anchor.centerX,
|
|
69
|
+
y: barAnchorPosition === 'top' ? anchor.top : anchor.centerY,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
return {
|
|
73
|
+
x: anchor.centerX,
|
|
74
|
+
y: anchor.centerY,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
export function getAnchoredTooltipPosition(anchor, target, tooltipWidth, tooltipHeight, arrowEdge, bounds = getSplitTooltipViewportBounds()) {
|
|
78
|
+
let left = target.x - tooltipWidth / 2;
|
|
79
|
+
let top = target.y - tooltipHeight / 2;
|
|
80
|
+
if (arrowEdge === 'left') {
|
|
81
|
+
left = anchor.right + TOOLTIP_OFFSET_PX;
|
|
82
|
+
}
|
|
83
|
+
else if (arrowEdge === 'right') {
|
|
84
|
+
left = anchor.left - tooltipWidth - TOOLTIP_OFFSET_PX;
|
|
85
|
+
}
|
|
86
|
+
else if (arrowEdge === 'bottom') {
|
|
87
|
+
top = target.y - tooltipHeight - TOOLTIP_OFFSET_PX;
|
|
88
|
+
}
|
|
89
|
+
else {
|
|
90
|
+
top = target.y + TOOLTIP_OFFSET_PX;
|
|
91
|
+
}
|
|
92
|
+
if (!Number.isFinite(left) || !Number.isFinite(top)) {
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
return {
|
|
96
|
+
left: clampTooltipCoordinate(left, bounds.minLeft, bounds.maxRight - tooltipWidth),
|
|
97
|
+
top: clampTooltipCoordinate(top, bounds.minTop, bounds.maxBottom - tooltipHeight),
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
export function resolveTooltipConnectorLayout(arrowEdge, tooltipLeft, tooltipTop, tooltipWidth, tooltipHeight, targetX, targetY, anchor) {
|
|
101
|
+
const localTargetX = targetX - tooltipLeft;
|
|
102
|
+
const localTargetY = targetY - tooltipTop;
|
|
103
|
+
if (!Number.isFinite(localTargetX) || !Number.isFinite(localTargetY)) {
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
const boxArrowPosition = resolveTooltipBoxArrowPosition(arrowEdge, tooltipLeft, tooltipTop, tooltipWidth, tooltipHeight, targetX, targetY);
|
|
107
|
+
const arrowTip = resolveTooltipArrowTip(arrowEdge, boxArrowPosition.x, boxArrowPosition.y, TOOLTIP_BOX_ARROW_LENGTH_PX);
|
|
108
|
+
const minX = Math.min(arrowTip.x, localTargetX) - TOOLTIP_CONNECTOR_PADDING_PX;
|
|
109
|
+
const maxX = Math.max(arrowTip.x, localTargetX) + TOOLTIP_CONNECTOR_PADDING_PX;
|
|
110
|
+
const minY = Math.min(arrowTip.y, localTargetY) - TOOLTIP_CONNECTOR_PADDING_PX;
|
|
111
|
+
const maxY = Math.max(arrowTip.y, localTargetY) + TOOLTIP_CONNECTOR_PADDING_PX;
|
|
112
|
+
const width = Math.max(1, maxX - minX);
|
|
113
|
+
const height = Math.max(1, maxY - minY);
|
|
114
|
+
const boxX = boxArrowPosition.x - minX;
|
|
115
|
+
const boxY = boxArrowPosition.y - minY;
|
|
116
|
+
const startX = arrowTip.x - minX;
|
|
117
|
+
const startY = arrowTip.y - minY;
|
|
118
|
+
const endX = localTargetX - minX;
|
|
119
|
+
const endY = localTargetY - minY;
|
|
120
|
+
const arrowTipX = tooltipLeft + arrowTip.x;
|
|
121
|
+
const arrowTipY = tooltipTop + arrowTip.y;
|
|
122
|
+
const connectorPath = resolveTooltipConnectorPath(arrowEdge, startX, startY, endX, endY, arrowTipX, arrowTipY, anchor);
|
|
123
|
+
if (!hasFiniteNumbers(width, height, boxX, boxY, startX, startY, endX, endY)) {
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
return {
|
|
127
|
+
left: minX,
|
|
128
|
+
top: minY,
|
|
129
|
+
arrowEdge,
|
|
130
|
+
width,
|
|
131
|
+
height,
|
|
132
|
+
path: connectorPath,
|
|
133
|
+
arrowX: boxArrowPosition.x,
|
|
134
|
+
arrowY: boxArrowPosition.y,
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
export function resolveTooltipArrowPosition(arrowEdge, boxX, boxY, length, halfHeight) {
|
|
138
|
+
switch (arrowEdge) {
|
|
139
|
+
case 'left':
|
|
140
|
+
return {
|
|
141
|
+
left: boxX - length,
|
|
142
|
+
top: boxY - halfHeight,
|
|
143
|
+
};
|
|
144
|
+
case 'right':
|
|
145
|
+
return {
|
|
146
|
+
left: boxX,
|
|
147
|
+
top: boxY - halfHeight,
|
|
148
|
+
};
|
|
149
|
+
case 'top':
|
|
150
|
+
return {
|
|
151
|
+
left: boxX - halfHeight,
|
|
152
|
+
top: boxY - length,
|
|
153
|
+
};
|
|
154
|
+
case 'bottom':
|
|
155
|
+
return {
|
|
156
|
+
left: boxX - halfHeight,
|
|
157
|
+
top: boxY,
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
export function resolveSplitTooltipPositions(layouts, position, bounds = getSplitTooltipViewportBounds(), isHorizontal = false) {
|
|
162
|
+
if (layouts.length === 0) {
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
if (isHorizontal) {
|
|
166
|
+
if (position === 'vertical') {
|
|
167
|
+
resolveHorizontalAboveBelowSplitTooltipPositions(layouts, bounds);
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
resolveHorizontalSideSplitTooltipPositions(layouts, bounds);
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
if (position === 'vertical') {
|
|
174
|
+
resolveAboveBelowSplitTooltipPositions(layouts, bounds);
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
const arrowEdge = resolveSidePlacementArrowEdge(getCombinedSplitTooltipAnchor(layouts), Math.max(...layouts.map((layout) => layout.width)), bounds);
|
|
178
|
+
const orderedLayouts = getVerticallyOrderedSplitTooltipLayouts(layouts);
|
|
179
|
+
positionSideSplitTooltipStack(orderedLayouts, arrowEdge, bounds);
|
|
180
|
+
}
|
|
181
|
+
function resolveTooltipBoxArrowPosition(arrowEdge, tooltipLeft, tooltipTop, tooltipWidth, tooltipHeight, targetX, targetY) {
|
|
182
|
+
const rightInnerBorderX = tooltipWidth - TOOLTIP_TOTAL_BORDER_WIDTH_PX;
|
|
183
|
+
const bottomInnerBorderY = tooltipHeight - TOOLTIP_TOTAL_BORDER_WIDTH_PX;
|
|
184
|
+
switch (arrowEdge) {
|
|
185
|
+
case 'left':
|
|
186
|
+
return {
|
|
187
|
+
x: 0,
|
|
188
|
+
y: getTooltipConnectorOffset(tooltipTop, tooltipHeight, targetY),
|
|
189
|
+
};
|
|
190
|
+
case 'right':
|
|
191
|
+
return {
|
|
192
|
+
x: rightInnerBorderX,
|
|
193
|
+
y: getTooltipConnectorOffset(tooltipTop, tooltipHeight, targetY),
|
|
194
|
+
};
|
|
195
|
+
case 'top':
|
|
196
|
+
return {
|
|
197
|
+
x: getTooltipConnectorOffset(tooltipLeft, tooltipWidth, targetX),
|
|
198
|
+
y: 0,
|
|
199
|
+
};
|
|
200
|
+
case 'bottom':
|
|
201
|
+
return {
|
|
202
|
+
x: getTooltipConnectorOffset(tooltipLeft, tooltipWidth, targetX),
|
|
203
|
+
y: bottomInnerBorderY,
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
function getTooltipConnectorOffset(start, size, target) {
|
|
208
|
+
const minOffset = TOOLTIP_CONNECTOR_INSET_PX;
|
|
209
|
+
const maxOffset = Math.max(minOffset, size - minOffset);
|
|
210
|
+
const preferredOffset = target - start;
|
|
211
|
+
return Math.max(minOffset, Math.min(preferredOffset, maxOffset));
|
|
212
|
+
}
|
|
213
|
+
function resolveTooltipConnectorPath(arrowEdge, startX, startY, endX, endY, arrowTipX, arrowTipY, anchor) {
|
|
214
|
+
if (arrowEdge === 'left' || arrowEdge === 'right') {
|
|
215
|
+
if (isTooltipArrowTipInsideVerticalAnchorSpan(arrowTipY, anchor)) {
|
|
216
|
+
return '';
|
|
217
|
+
}
|
|
218
|
+
const elbowX = startX + (endX - startX) * TOOLTIP_CONNECTOR_ELBOW_RATIO;
|
|
219
|
+
return `M ${startX},${startY} L ${elbowX},${startY} L ${endX},${endY}`;
|
|
220
|
+
}
|
|
221
|
+
if (isTooltipArrowTipInsideHorizontalAnchorSpan(arrowTipX, anchor)) {
|
|
222
|
+
return '';
|
|
223
|
+
}
|
|
224
|
+
const elbowY = startY + (endY - startY) * TOOLTIP_CONNECTOR_ELBOW_RATIO;
|
|
225
|
+
return `M ${startX},${startY} L ${startX},${elbowY} L ${endX},${endY}`;
|
|
226
|
+
}
|
|
227
|
+
function isTooltipArrowTipInsideHorizontalAnchorSpan(arrowTipX, anchor) {
|
|
228
|
+
return (arrowTipX >= anchor.left - TOOLTIP_CONNECTOR_ALIGNMENT_TOLERANCE_PX &&
|
|
229
|
+
arrowTipX <= anchor.right + TOOLTIP_CONNECTOR_ALIGNMENT_TOLERANCE_PX);
|
|
230
|
+
}
|
|
231
|
+
function isTooltipArrowTipInsideVerticalAnchorSpan(arrowTipY, anchor) {
|
|
232
|
+
return (arrowTipY >= anchor.top - TOOLTIP_CONNECTOR_ALIGNMENT_TOLERANCE_PX &&
|
|
233
|
+
arrowTipY <= anchor.bottom + TOOLTIP_CONNECTOR_ALIGNMENT_TOLERANCE_PX);
|
|
234
|
+
}
|
|
235
|
+
function resolveTooltipArrowTip(arrowEdge, boxX, boxY, length) {
|
|
236
|
+
if (arrowEdge === 'left' || arrowEdge === 'right') {
|
|
237
|
+
return {
|
|
238
|
+
x: arrowEdge === 'left' ? boxX - length : boxX + length,
|
|
239
|
+
y: boxY,
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
return {
|
|
243
|
+
x: boxX,
|
|
244
|
+
y: arrowEdge === 'top' ? boxY - length : boxY + length,
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
function getVerticallyOrderedSplitTooltipLayouts(layouts) {
|
|
248
|
+
return [...layouts].sort((a, b) => {
|
|
249
|
+
return (a.targetY - b.targetY || a.targetX - b.targetX || a.order - b.order);
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
function getHorizontallyOrderedSplitTooltipLayouts(layouts) {
|
|
253
|
+
return [...layouts].sort((a, b) => {
|
|
254
|
+
return (a.targetX - b.targetX || a.targetY - b.targetY || a.order - b.order);
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
function getCombinedSplitTooltipAnchor(layouts) {
|
|
258
|
+
const left = Math.min(...layouts.map((layout) => layout.anchor.left));
|
|
259
|
+
const right = Math.max(...layouts.map((layout) => layout.anchor.right));
|
|
260
|
+
const top = Math.min(...layouts.map((layout) => layout.anchor.top));
|
|
261
|
+
const bottom = Math.max(...layouts.map((layout) => layout.anchor.bottom));
|
|
262
|
+
return {
|
|
263
|
+
left,
|
|
264
|
+
right,
|
|
265
|
+
top,
|
|
266
|
+
bottom,
|
|
267
|
+
centerX: (left + right) / 2,
|
|
268
|
+
centerY: (top + bottom) / 2,
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
function getCombinedSplitTooltipTarget(layouts) {
|
|
272
|
+
const minX = Math.min(...layouts.map((layout) => layout.targetX));
|
|
273
|
+
const maxX = Math.max(...layouts.map((layout) => layout.targetX));
|
|
274
|
+
const minY = Math.min(...layouts.map((layout) => layout.targetY));
|
|
275
|
+
const maxY = Math.max(...layouts.map((layout) => layout.targetY));
|
|
276
|
+
return {
|
|
277
|
+
x: (minX + maxX) / 2,
|
|
278
|
+
y: (minY + maxY) / 2,
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
function resolveHorizontalSideSplitTooltipPositions(layouts, bounds) {
|
|
282
|
+
const rowPlacement = getBestHorizontalSideRowPlacement(getHorizontallyOrderedSplitTooltipLayouts(layouts), bounds);
|
|
283
|
+
rowPlacement.placements.forEach(({ layout, left, top, arrowEdge }) => {
|
|
284
|
+
applySplitTooltipBoxPlacement(layout, { left, top, arrowEdge }, bounds);
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
function getBestHorizontalSideRowPlacement(layouts, bounds) {
|
|
288
|
+
const preferredEdges = layouts.map((layout) => resolveSidePlacementArrowEdge(layout.anchor, layout.width, bounds));
|
|
289
|
+
const assignments = getHorizontalEdgeAssignments(layouts, preferredEdges, 'left', 'right');
|
|
290
|
+
let bestPlacement = null;
|
|
291
|
+
assignments.forEach((assignment) => {
|
|
292
|
+
const rowPlacement = getHorizontalSideRowPlacement(layouts, assignment, preferredEdges, bounds);
|
|
293
|
+
if (!bestPlacement || rowPlacement.score < bestPlacement.score) {
|
|
294
|
+
bestPlacement = rowPlacement;
|
|
295
|
+
}
|
|
296
|
+
});
|
|
297
|
+
return bestPlacement ?? { placements: [], score: 0 };
|
|
298
|
+
}
|
|
299
|
+
function getHorizontalSideRowPlacement(layouts, assignment, preferredEdges, bounds) {
|
|
300
|
+
const placements = packHorizontalSideRow(layouts, assignment, bounds);
|
|
301
|
+
const preferredEdgeByLayout = new Map(layouts.map((layout, index) => [layout, preferredEdges[index]]));
|
|
302
|
+
const score = placements.reduce((sum, placement) => {
|
|
303
|
+
const preferredEdge = preferredEdgeByLayout.get(placement.layout) ?? placement.arrowEdge;
|
|
304
|
+
return (sum +
|
|
305
|
+
getHorizontalSidePlacementCost(placement, placement.layout, preferredEdge, bounds));
|
|
306
|
+
}, 0);
|
|
307
|
+
return { placements, score };
|
|
308
|
+
}
|
|
309
|
+
function packHorizontalSideRow(layouts, assignment, bounds) {
|
|
310
|
+
const edgeByLayout = new Map(layouts.map((layout, index) => [layout, assignment[index]]));
|
|
311
|
+
const placements = getHorizontallyOrderedSplitTooltipLayouts(layouts)
|
|
312
|
+
.map((layout) => {
|
|
313
|
+
const arrowEdge = edgeByLayout.get(layout) ?? 'left';
|
|
314
|
+
return {
|
|
315
|
+
layout,
|
|
316
|
+
left: getHorizontalSideIdealLeft(layout, arrowEdge),
|
|
317
|
+
top: getHorizontalSideIdealTop(layout),
|
|
318
|
+
arrowEdge,
|
|
319
|
+
};
|
|
320
|
+
})
|
|
321
|
+
.sort((a, b) => a.left - b.left ||
|
|
322
|
+
a.layout.targetX - b.layout.targetX ||
|
|
323
|
+
a.layout.order - b.layout.order);
|
|
324
|
+
if (placements.length === 0) {
|
|
325
|
+
return placements;
|
|
326
|
+
}
|
|
327
|
+
return packHorizontalTooltipRow(placements, bounds);
|
|
328
|
+
}
|
|
329
|
+
function getHorizontalSideIdealLeft(layout, arrowEdge) {
|
|
330
|
+
return arrowEdge === 'left'
|
|
331
|
+
? layout.anchor.right + TOOLTIP_OFFSET_PX
|
|
332
|
+
: layout.anchor.left - layout.width - TOOLTIP_OFFSET_PX;
|
|
333
|
+
}
|
|
334
|
+
function getHorizontalSideIdealTop(layout) {
|
|
335
|
+
const centeredTop = layout.targetY - layout.height / 2;
|
|
336
|
+
if (layout.targetMode === 'auto') {
|
|
337
|
+
const connectorlessRange = getSideConnectorlessTopRange(layout);
|
|
338
|
+
return clampTooltipCoordinate(centeredTop, connectorlessRange.min, connectorlessRange.max);
|
|
339
|
+
}
|
|
340
|
+
return centeredTop;
|
|
341
|
+
}
|
|
342
|
+
function getHorizontalSidePlacementCost(candidate, layout, preferredEdge, bounds) {
|
|
343
|
+
const edgePenalty = candidate.arrowEdge === preferredEdge ? 0 : 8;
|
|
344
|
+
const connectorPenalty = isSidePlacementConnectorless(candidate, layout)
|
|
345
|
+
? 0
|
|
346
|
+
: layout.width;
|
|
347
|
+
const xDistance = Math.abs(layout.targetX - (candidate.left + layout.width / 2));
|
|
348
|
+
const yDistance = Math.abs(layout.targetY - (candidate.top + layout.height / 2));
|
|
349
|
+
const idealLeftDistance = Math.abs(candidate.left -
|
|
350
|
+
getHorizontalSideIdealLeft(layout, candidate.arrowEdge));
|
|
351
|
+
const overflowPenalty = getSplitTooltipBoxPlacementOverflow(candidate, layout, bounds) * 20;
|
|
352
|
+
return (connectorPenalty +
|
|
353
|
+
edgePenalty +
|
|
354
|
+
xDistance +
|
|
355
|
+
yDistance * 0.2 +
|
|
356
|
+
idealLeftDistance * 2 +
|
|
357
|
+
overflowPenalty);
|
|
358
|
+
}
|
|
359
|
+
function resolveHorizontalAboveBelowSplitTooltipPositions(layouts, bounds) {
|
|
360
|
+
const rowPlacement = getBestHorizontalAboveBelowRowPlacement(getHorizontallyOrderedSplitTooltipLayouts(layouts), bounds);
|
|
361
|
+
rowPlacement.placements.forEach(({ layout, left, top, arrowEdge }) => {
|
|
362
|
+
applySplitTooltipBoxPlacement(layout, { left, top, arrowEdge }, bounds);
|
|
363
|
+
});
|
|
364
|
+
}
|
|
365
|
+
function getBestHorizontalAboveBelowRowPlacement(layouts, bounds) {
|
|
366
|
+
const preferredEdges = layouts.map((layout) => resolveVerticalPlacementArrowEdge({ x: layout.targetX, y: layout.targetY }, layout.height, bounds));
|
|
367
|
+
const assignments = getHorizontalEdgeAssignments(layouts, preferredEdges, 'bottom', 'top');
|
|
368
|
+
let bestPlacement = null;
|
|
369
|
+
assignments.forEach((assignment) => {
|
|
370
|
+
const rowPlacement = getHorizontalAboveBelowRowPlacement(layouts, assignment, preferredEdges, bounds);
|
|
371
|
+
if (!bestPlacement || rowPlacement.score < bestPlacement.score) {
|
|
372
|
+
bestPlacement = rowPlacement;
|
|
373
|
+
}
|
|
374
|
+
});
|
|
375
|
+
return bestPlacement ?? { placements: [], score: 0 };
|
|
376
|
+
}
|
|
377
|
+
function getHorizontalEdgeAssignments(layouts, preferredEdges, firstEdge, secondEdge) {
|
|
378
|
+
if (layouts.length <= MAX_EXHAUSTIVE_HORIZONTAL_ROW_LAYOUTS) {
|
|
379
|
+
return getExhaustiveHorizontalEdgeAssignments(preferredEdges);
|
|
380
|
+
}
|
|
381
|
+
return getFallbackHorizontalEdgeAssignments(layouts, preferredEdges, firstEdge, secondEdge);
|
|
382
|
+
}
|
|
383
|
+
function getExhaustiveHorizontalEdgeAssignments(preferredEdges) {
|
|
384
|
+
const assignmentCount = 2 ** preferredEdges.length;
|
|
385
|
+
return Array.from({ length: assignmentCount }, (_, mask) => preferredEdges.map((preferredEdge, index) => mask & (1 << index)
|
|
386
|
+
? getOppositeTooltipEdge(preferredEdge)
|
|
387
|
+
: preferredEdge));
|
|
388
|
+
}
|
|
389
|
+
function getFallbackHorizontalEdgeAssignments(layouts, preferredEdges, firstEdge, secondEdge) {
|
|
390
|
+
const assignments = [];
|
|
391
|
+
const seenAssignments = new Set();
|
|
392
|
+
const addAssignment = (assignment) => {
|
|
393
|
+
const key = assignment.join('|');
|
|
394
|
+
if (seenAssignments.has(key)) {
|
|
395
|
+
return;
|
|
396
|
+
}
|
|
397
|
+
seenAssignments.add(key);
|
|
398
|
+
assignments.push(assignment);
|
|
399
|
+
};
|
|
400
|
+
addAssignment([...preferredEdges]);
|
|
401
|
+
addAssignment(preferredEdges.map((preferredEdge, index) => index % 2 === 0
|
|
402
|
+
? preferredEdge
|
|
403
|
+
: getOppositeTooltipEdge(preferredEdge)));
|
|
404
|
+
addAssignment(preferredEdges.map((preferredEdge, index) => index % 2 === 0
|
|
405
|
+
? getOppositeTooltipEdge(preferredEdge)
|
|
406
|
+
: preferredEdge));
|
|
407
|
+
addAssignment(getBalancedHorizontalEdgeAssignment(layouts, firstEdge, secondEdge));
|
|
408
|
+
return assignments;
|
|
409
|
+
}
|
|
410
|
+
function getBalancedHorizontalEdgeAssignment(layouts, firstEdge, secondEdge) {
|
|
411
|
+
let firstWidth = 0;
|
|
412
|
+
let secondWidth = 0;
|
|
413
|
+
return layouts.map((layout) => {
|
|
414
|
+
if (firstWidth < secondWidth) {
|
|
415
|
+
firstWidth += layout.width;
|
|
416
|
+
return firstEdge;
|
|
417
|
+
}
|
|
418
|
+
secondWidth += layout.width;
|
|
419
|
+
return secondEdge;
|
|
420
|
+
});
|
|
421
|
+
}
|
|
422
|
+
function getHorizontalAboveBelowRowPlacement(layouts, assignment, preferredEdges, bounds) {
|
|
423
|
+
const placements = ['bottom', 'top'].flatMap((arrowEdge) => {
|
|
424
|
+
const rowLayouts = layouts.filter((_, index) => assignment[index] === arrowEdge);
|
|
425
|
+
return packHorizontalAboveBelowRow(rowLayouts, arrowEdge, bounds);
|
|
426
|
+
});
|
|
427
|
+
const preferredEdgeByLayout = new Map(layouts.map((layout, index) => [layout, preferredEdges[index]]));
|
|
428
|
+
const score = placements.reduce((sum, placement) => {
|
|
429
|
+
const preferredEdge = preferredEdgeByLayout.get(placement.layout) ??
|
|
430
|
+
placement.arrowEdge;
|
|
431
|
+
return (sum +
|
|
432
|
+
getHorizontalAboveBelowPlacementCost(placement, placement.layout, preferredEdge, bounds));
|
|
433
|
+
}, getHorizontalEdgeAssignmentCost(layouts, assignment, preferredEdges));
|
|
434
|
+
return { placements, score };
|
|
435
|
+
}
|
|
436
|
+
function getHorizontalEdgeAssignmentCost(layouts, assignment, preferredEdges) {
|
|
437
|
+
let score = 0;
|
|
438
|
+
let runStart = 0;
|
|
439
|
+
while (runStart < layouts.length) {
|
|
440
|
+
const valueSign = layouts[runStart].valueSign;
|
|
441
|
+
let runEnd = runStart + 1;
|
|
442
|
+
while (runEnd < layouts.length &&
|
|
443
|
+
layouts[runEnd].valueSign === valueSign) {
|
|
444
|
+
runEnd++;
|
|
445
|
+
}
|
|
446
|
+
for (let index = runStart; index < runEnd; index++) {
|
|
447
|
+
if (assignment[index] === preferredEdges[index]) {
|
|
448
|
+
continue;
|
|
449
|
+
}
|
|
450
|
+
score += (runEnd - index) * HORIZONTAL_ROW_ORDER_FLIP_PENALTY_PX;
|
|
451
|
+
}
|
|
452
|
+
runStart = runEnd;
|
|
453
|
+
}
|
|
454
|
+
return score;
|
|
455
|
+
}
|
|
456
|
+
function packHorizontalAboveBelowRow(layouts, arrowEdge, bounds) {
|
|
457
|
+
const placements = getHorizontallyOrderedSplitTooltipLayouts(layouts).map((layout) => ({
|
|
458
|
+
layout,
|
|
459
|
+
left: getHorizontalAboveBelowIdealLeft(layout),
|
|
460
|
+
top: getHorizontalAboveBelowTop(layout, arrowEdge),
|
|
461
|
+
arrowEdge,
|
|
462
|
+
}));
|
|
463
|
+
return packHorizontalTooltipRow(placements, bounds);
|
|
464
|
+
}
|
|
465
|
+
function packHorizontalTooltipRow(placements, bounds) {
|
|
466
|
+
if (placements.length === 0) {
|
|
467
|
+
return placements;
|
|
468
|
+
}
|
|
469
|
+
for (let index = 0; index < placements.length; index++) {
|
|
470
|
+
const previous = placements[index - 1];
|
|
471
|
+
const minLeft = previous
|
|
472
|
+
? previous.left + previous.layout.width + SPLIT_TOOLTIP_GAP_PX
|
|
473
|
+
: bounds.minLeft;
|
|
474
|
+
placements[index].left = Math.max(placements[index].left, minLeft);
|
|
475
|
+
}
|
|
476
|
+
const lastPlacement = placements[placements.length - 1];
|
|
477
|
+
const overflow = lastPlacement.left + lastPlacement.layout.width - bounds.maxRight;
|
|
478
|
+
if (overflow > 0) {
|
|
479
|
+
placements.forEach((placement) => {
|
|
480
|
+
placement.left -= overflow;
|
|
481
|
+
});
|
|
482
|
+
}
|
|
483
|
+
for (let index = placements.length - 2; index >= 0; index--) {
|
|
484
|
+
const next = placements[index + 1];
|
|
485
|
+
const maxLeft = next.left - placements[index].layout.width - SPLIT_TOOLTIP_GAP_PX;
|
|
486
|
+
placements[index].left = Math.min(placements[index].left, maxLeft);
|
|
487
|
+
}
|
|
488
|
+
const underflow = bounds.minLeft - placements[0].left;
|
|
489
|
+
if (underflow > 0) {
|
|
490
|
+
placements.forEach((placement) => {
|
|
491
|
+
placement.left += underflow;
|
|
492
|
+
});
|
|
493
|
+
}
|
|
494
|
+
return placements;
|
|
495
|
+
}
|
|
496
|
+
function getHorizontalAboveBelowIdealLeft(layout) {
|
|
497
|
+
const centeredLeft = layout.targetX - layout.width / 2;
|
|
498
|
+
if (layout.targetMode === 'auto') {
|
|
499
|
+
const connectorlessRange = getAboveBelowConnectorlessLeftRange(layout);
|
|
500
|
+
return clampTooltipCoordinate(centeredLeft, connectorlessRange.min, connectorlessRange.max);
|
|
501
|
+
}
|
|
502
|
+
return centeredLeft;
|
|
503
|
+
}
|
|
504
|
+
function getHorizontalAboveBelowTop(layout, arrowEdge) {
|
|
505
|
+
if (arrowEdge === 'bottom') {
|
|
506
|
+
return layout.anchor.top - TOOLTIP_OFFSET_PX - layout.height;
|
|
507
|
+
}
|
|
508
|
+
return layout.anchor.bottom + TOOLTIP_OFFSET_PX;
|
|
509
|
+
}
|
|
510
|
+
function getHorizontalAboveBelowPlacementCost(candidate, layout, preferredEdge, bounds) {
|
|
511
|
+
const edgePenalty = candidate.arrowEdge === preferredEdge ? 0 : 8;
|
|
512
|
+
const connectorPenalty = isAboveBelowBoxPlacementConnectorless(candidate, layout)
|
|
513
|
+
? 0
|
|
514
|
+
: layout.height * 4;
|
|
515
|
+
const xDistance = Math.abs(layout.targetX - (candidate.left + layout.width / 2));
|
|
516
|
+
const overflowPenalty = getSplitTooltipBoxPlacementOverflow(candidate, layout, bounds) * 20;
|
|
517
|
+
return connectorPenalty + edgePenalty + xDistance + overflowPenalty;
|
|
518
|
+
}
|
|
519
|
+
function getPreferredAndOppositeEdges(preferredEdge) {
|
|
520
|
+
if (preferredEdge === 'bottom') {
|
|
521
|
+
return ['bottom', 'top'];
|
|
522
|
+
}
|
|
523
|
+
if (preferredEdge === 'top') {
|
|
524
|
+
return ['top', 'bottom'];
|
|
525
|
+
}
|
|
526
|
+
if (preferredEdge === 'left') {
|
|
527
|
+
return ['left', 'right'];
|
|
528
|
+
}
|
|
529
|
+
return ['right', 'left'];
|
|
530
|
+
}
|
|
531
|
+
function getOppositeTooltipEdge(edge) {
|
|
532
|
+
return getPreferredAndOppositeEdges(edge)[1];
|
|
533
|
+
}
|
|
534
|
+
function applySplitTooltipBoxPlacement(layout, placement, bounds) {
|
|
535
|
+
layout.arrowEdge = placement.arrowEdge;
|
|
536
|
+
layout.left = clampTooltipCoordinate(placement.left, bounds.minLeft, bounds.maxRight - layout.width);
|
|
537
|
+
layout.top = clampTooltipCoordinate(placement.top, bounds.minTop, bounds.maxBottom - layout.height);
|
|
538
|
+
alignAutoSplitTooltipTarget(layout);
|
|
539
|
+
}
|
|
540
|
+
function getSplitTooltipBoxPlacementOverflow(candidate, layout, bounds) {
|
|
541
|
+
return (Math.max(0, bounds.minLeft - candidate.left) +
|
|
542
|
+
Math.max(0, candidate.left + layout.width - bounds.maxRight) +
|
|
543
|
+
Math.max(0, bounds.minTop - candidate.top) +
|
|
544
|
+
Math.max(0, candidate.top + layout.height - bounds.maxBottom));
|
|
545
|
+
}
|
|
546
|
+
function positionSideSplitTooltipStack(layouts, arrowEdge, bounds) {
|
|
547
|
+
const placementOrder = getSidePlacementOrder(layouts);
|
|
548
|
+
const placedLayouts = [];
|
|
549
|
+
placementOrder.forEach((layout) => {
|
|
550
|
+
layout.arrowEdge = arrowEdge;
|
|
551
|
+
layout.left = getSideTooltipLeft(layout, arrowEdge, bounds);
|
|
552
|
+
positionSideSplitTooltip(layout, placedLayouts, layouts, bounds);
|
|
553
|
+
placedLayouts.push(layout);
|
|
554
|
+
});
|
|
555
|
+
}
|
|
556
|
+
function getSidePlacementOrder(layouts) {
|
|
557
|
+
if (!layouts.every((layout) => layout.targetMode === 'auto')) {
|
|
558
|
+
return layouts;
|
|
559
|
+
}
|
|
560
|
+
return [...layouts].sort((a, b) => {
|
|
561
|
+
return (getTooltipAnchorVerticalSpan(a) - getTooltipAnchorVerticalSpan(b) ||
|
|
562
|
+
a.targetY - b.targetY ||
|
|
563
|
+
a.order - b.order);
|
|
564
|
+
});
|
|
565
|
+
}
|
|
566
|
+
function getTooltipAnchorVerticalSpan(layout) {
|
|
567
|
+
return layout.anchor.bottom - layout.anchor.top;
|
|
568
|
+
}
|
|
569
|
+
function getSideTooltipLeft(layout, arrowEdge, bounds) {
|
|
570
|
+
const preferredLeft = arrowEdge === 'left'
|
|
571
|
+
? layout.anchor.right + TOOLTIP_OFFSET_PX
|
|
572
|
+
: layout.anchor.left - layout.width - TOOLTIP_OFFSET_PX;
|
|
573
|
+
return clampTooltipCoordinate(preferredLeft, bounds.minLeft, bounds.maxRight - layout.width);
|
|
574
|
+
}
|
|
575
|
+
function positionSideSplitTooltip(layout, placedLayouts, allLayouts, bounds) {
|
|
576
|
+
const candidates = getSidePlacementCandidates(layout, placedLayouts, allLayouts, bounds);
|
|
577
|
+
const placement = candidates.find((candidate) => isSplitTooltipCandidateWithinBounds(candidate, layout.height, bounds) &&
|
|
578
|
+
!doesSplitTooltipCandidateOverlap(candidate, layout, placedLayouts)) ?? candidates[0];
|
|
579
|
+
layout.top = clampTooltipCoordinate(placement.top, bounds.minTop, bounds.maxBottom - layout.height);
|
|
580
|
+
alignAutoSplitTooltipTarget(layout);
|
|
581
|
+
}
|
|
582
|
+
function getSidePlacementCandidates(layout, placedLayouts, allLayouts, bounds) {
|
|
583
|
+
const candidates = [
|
|
584
|
+
{
|
|
585
|
+
top: layout.targetY - layout.height / 2,
|
|
586
|
+
arrowEdge: layout.arrowEdge,
|
|
587
|
+
},
|
|
588
|
+
{
|
|
589
|
+
top: layout.anchor.centerY - layout.height / 2,
|
|
590
|
+
arrowEdge: layout.arrowEdge,
|
|
591
|
+
},
|
|
592
|
+
{
|
|
593
|
+
top: layout.anchor.top - TOOLTIP_CONNECTOR_INSET_PX,
|
|
594
|
+
arrowEdge: layout.arrowEdge,
|
|
595
|
+
},
|
|
596
|
+
{
|
|
597
|
+
top: layout.anchor.bottom -
|
|
598
|
+
layout.height +
|
|
599
|
+
TOOLTIP_CONNECTOR_INSET_PX,
|
|
600
|
+
arrowEdge: layout.arrowEdge,
|
|
601
|
+
},
|
|
602
|
+
];
|
|
603
|
+
if (layout.targetMode === 'auto') {
|
|
604
|
+
const connectorlessRange = getSideConnectorlessTopRange(layout);
|
|
605
|
+
candidates.push({
|
|
606
|
+
top: connectorlessRange.min,
|
|
607
|
+
arrowEdge: layout.arrowEdge,
|
|
608
|
+
});
|
|
609
|
+
candidates.push({
|
|
610
|
+
top: connectorlessRange.max,
|
|
611
|
+
arrowEdge: layout.arrowEdge,
|
|
612
|
+
});
|
|
613
|
+
}
|
|
614
|
+
placedLayouts.forEach((placedLayout) => {
|
|
615
|
+
candidates.push({
|
|
616
|
+
top: placedLayout.top - layout.height - SPLIT_TOOLTIP_GAP_PX,
|
|
617
|
+
arrowEdge: layout.arrowEdge,
|
|
618
|
+
});
|
|
619
|
+
candidates.push({
|
|
620
|
+
top: placedLayout.top + placedLayout.height + SPLIT_TOOLTIP_GAP_PX,
|
|
621
|
+
arrowEdge: layout.arrowEdge,
|
|
622
|
+
});
|
|
623
|
+
});
|
|
624
|
+
return candidates
|
|
625
|
+
.map((candidate) => ({
|
|
626
|
+
...candidate,
|
|
627
|
+
top: clampTooltipCoordinate(candidate.top, bounds.minTop, bounds.maxBottom - layout.height),
|
|
628
|
+
}))
|
|
629
|
+
.sort((a, b) => getSidePlacementCost(a, layout, allLayouts) -
|
|
630
|
+
getSidePlacementCost(b, layout, allLayouts));
|
|
631
|
+
}
|
|
632
|
+
function getSidePlacementCost(candidate, layout, allLayouts) {
|
|
633
|
+
const connectorPenalty = isSidePlacementConnectorless(candidate, layout)
|
|
634
|
+
? 0
|
|
635
|
+
: layout.height * 4;
|
|
636
|
+
const centerDistance = Math.abs(layout.targetY - (candidate.top + layout.height / 2));
|
|
637
|
+
if (layout.targetMode === 'auto' && connectorPenalty === 0) {
|
|
638
|
+
const spreadDirection = getAutoSideSpreadDirection(layout, allLayouts);
|
|
639
|
+
if (spreadDirection !== 0) {
|
|
640
|
+
const connectorlessRange = getSideConnectorlessTopRange(layout);
|
|
641
|
+
const preferredTop = spreadDirection > 0
|
|
642
|
+
? connectorlessRange.max
|
|
643
|
+
: connectorlessRange.min;
|
|
644
|
+
return (Math.abs(candidate.top - preferredTop) + centerDistance * 0.05);
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
return connectorPenalty + centerDistance;
|
|
648
|
+
}
|
|
649
|
+
function getAutoSideSpreadDirection(layout, allLayouts) {
|
|
650
|
+
const closestAbove = getClosestAutoSideNeighbor(layout, allLayouts, 'above');
|
|
651
|
+
const closestBelow = getClosestAutoSideNeighbor(layout, allLayouts, 'below');
|
|
652
|
+
if (!closestAbove && closestBelow) {
|
|
653
|
+
return -1;
|
|
654
|
+
}
|
|
655
|
+
if (closestAbove && !closestBelow) {
|
|
656
|
+
return 1;
|
|
657
|
+
}
|
|
658
|
+
if (!closestAbove || !closestBelow) {
|
|
659
|
+
return 0;
|
|
660
|
+
}
|
|
661
|
+
const aboveDistance = layout.anchor.centerY - closestAbove.anchor.centerY;
|
|
662
|
+
const belowDistance = closestBelow.anchor.centerY - layout.anchor.centerY;
|
|
663
|
+
if (aboveDistance < belowDistance) {
|
|
664
|
+
return 1;
|
|
665
|
+
}
|
|
666
|
+
if (belowDistance < aboveDistance) {
|
|
667
|
+
return -1;
|
|
668
|
+
}
|
|
669
|
+
return 0;
|
|
670
|
+
}
|
|
671
|
+
function getClosestAutoSideNeighbor(layout, allLayouts, side) {
|
|
672
|
+
const centerY = layout.anchor.centerY;
|
|
673
|
+
return allLayouts
|
|
674
|
+
.filter((candidate) => candidate !== layout && candidate.targetMode === 'auto')
|
|
675
|
+
.filter((candidate) => side === 'above'
|
|
676
|
+
? candidate.anchor.centerY < centerY
|
|
677
|
+
: candidate.anchor.centerY > centerY)
|
|
678
|
+
.sort((a, b) => Math.abs(a.anchor.centerY - centerY) -
|
|
679
|
+
Math.abs(b.anchor.centerY - centerY))[0];
|
|
680
|
+
}
|
|
681
|
+
function getSideConnectorlessTopRange(layout) {
|
|
682
|
+
return {
|
|
683
|
+
min: layout.anchor.top - getTooltipConnectorMaxOffset(layout.height),
|
|
684
|
+
max: layout.anchor.bottom - TOOLTIP_CONNECTOR_INSET_PX,
|
|
685
|
+
};
|
|
686
|
+
}
|
|
687
|
+
function isSidePlacementConnectorless(candidate, layout) {
|
|
688
|
+
if (layout.targetMode === 'auto') {
|
|
689
|
+
return doRangesOverlap(layout.anchor.top, layout.anchor.bottom, candidate.top + TOOLTIP_CONNECTOR_INSET_PX, candidate.top + getTooltipConnectorMaxOffset(layout.height));
|
|
690
|
+
}
|
|
691
|
+
const arrowTipY = getSidePlacementArrowTipY(candidate.top, layout);
|
|
692
|
+
return (arrowTipY >=
|
|
693
|
+
layout.anchor.top - TOOLTIP_CONNECTOR_ALIGNMENT_TOLERANCE_PX &&
|
|
694
|
+
arrowTipY <=
|
|
695
|
+
layout.anchor.bottom + TOOLTIP_CONNECTOR_ALIGNMENT_TOLERANCE_PX);
|
|
696
|
+
}
|
|
697
|
+
function getSidePlacementArrowTipY(top, layout) {
|
|
698
|
+
return top + getTooltipConnectorOffset(top, layout.height, layout.targetY);
|
|
699
|
+
}
|
|
700
|
+
function resolveAboveBelowSplitTooltipPositions(layouts, bounds) {
|
|
701
|
+
const orderedLayouts = getVerticallyOrderedSplitTooltipLayouts(layouts);
|
|
702
|
+
const target = getCombinedSplitTooltipTarget(layouts);
|
|
703
|
+
const maxWidth = Math.max(...layouts.map((layout) => layout.width));
|
|
704
|
+
const columnLeft = clampTooltipCoordinate(target.x - maxWidth / 2, bounds.minLeft, bounds.maxRight - maxWidth);
|
|
705
|
+
const placedLayouts = [];
|
|
706
|
+
orderedLayouts.forEach((layout) => {
|
|
707
|
+
layout.left = columnLeft + (maxWidth - layout.width) / 2;
|
|
708
|
+
positionAboveBelowSplitTooltip(layout, placedLayouts, bounds);
|
|
709
|
+
placedLayouts.push(layout);
|
|
710
|
+
});
|
|
711
|
+
}
|
|
712
|
+
function positionAboveBelowSplitTooltip(layout, placedLayouts, bounds) {
|
|
713
|
+
const preferredEdge = resolveVerticalPlacementArrowEdge({ x: layout.targetX, y: layout.targetY }, layout.height, bounds);
|
|
714
|
+
const candidates = getAboveBelowPlacementCandidates(layout, placedLayouts, bounds, preferredEdge);
|
|
715
|
+
const placement = candidates.find((candidate) => isAboveBelowCandidateOnArrowSide(candidate, layout) &&
|
|
716
|
+
isSplitTooltipCandidateWithinBounds(candidate, layout.height, bounds) &&
|
|
717
|
+
!doesSplitTooltipCandidateOverlap(candidate, layout, placedLayouts)) ?? candidates[0];
|
|
718
|
+
layout.arrowEdge = placement.arrowEdge;
|
|
719
|
+
layout.top = clampTooltipCoordinate(placement.top, bounds.minTop, bounds.maxBottom - layout.height);
|
|
720
|
+
alignAutoSplitTooltipTarget(layout);
|
|
721
|
+
}
|
|
722
|
+
function getAboveBelowPlacementCandidates(layout, placedLayouts, bounds, preferredEdge) {
|
|
723
|
+
const oppositeEdge = preferredEdge === 'bottom' ? 'top' : 'bottom';
|
|
724
|
+
const candidates = [
|
|
725
|
+
getIdealAboveBelowPlacement(layout, preferredEdge),
|
|
726
|
+
getIdealAboveBelowPlacement(layout, oppositeEdge),
|
|
727
|
+
];
|
|
728
|
+
placedLayouts.forEach((placedLayout) => {
|
|
729
|
+
const maxAboveTargetTop = layout.targetY - TOOLTIP_OFFSET_PX - layout.height;
|
|
730
|
+
const minBelowTargetTop = layout.targetY + TOOLTIP_OFFSET_PX;
|
|
731
|
+
const abovePlacedTop = Math.min(placedLayout.top - layout.height - SPLIT_TOOLTIP_GAP_PX, maxAboveTargetTop);
|
|
732
|
+
const belowPlacedTop = Math.max(placedLayout.top + placedLayout.height + SPLIT_TOOLTIP_GAP_PX, minBelowTargetTop);
|
|
733
|
+
candidates.push({
|
|
734
|
+
top: abovePlacedTop,
|
|
735
|
+
arrowEdge: resolveAboveBelowArrowEdgeForTop(layout, abovePlacedTop),
|
|
736
|
+
});
|
|
737
|
+
candidates.push({
|
|
738
|
+
top: belowPlacedTop,
|
|
739
|
+
arrowEdge: resolveAboveBelowArrowEdgeForTop(layout, belowPlacedTop),
|
|
740
|
+
});
|
|
741
|
+
});
|
|
742
|
+
return candidates
|
|
743
|
+
.map((candidate) => ({
|
|
744
|
+
...candidate,
|
|
745
|
+
top: clampTooltipCoordinate(candidate.top, bounds.minTop, bounds.maxBottom - layout.height),
|
|
746
|
+
}))
|
|
747
|
+
.sort((a, b) => getAboveBelowPlacementCost(a, layout) -
|
|
748
|
+
getAboveBelowPlacementCost(b, layout));
|
|
749
|
+
}
|
|
750
|
+
function getIdealAboveBelowPlacement(layout, arrowEdge) {
|
|
751
|
+
return {
|
|
752
|
+
arrowEdge,
|
|
753
|
+
top: arrowEdge === 'bottom'
|
|
754
|
+
? layout.targetY - TOOLTIP_OFFSET_PX - layout.height
|
|
755
|
+
: layout.targetY + TOOLTIP_OFFSET_PX,
|
|
756
|
+
};
|
|
757
|
+
}
|
|
758
|
+
function getAboveBelowPlacementCost(candidate, layout) {
|
|
759
|
+
const connectorPenalty = isAboveBelowPlacementConnectorless(layout)
|
|
760
|
+
? 0
|
|
761
|
+
: layout.height * 4;
|
|
762
|
+
if (candidate.arrowEdge === 'bottom') {
|
|
763
|
+
return (connectorPenalty +
|
|
764
|
+
Math.abs(layout.targetY - (candidate.top + layout.height)));
|
|
765
|
+
}
|
|
766
|
+
return connectorPenalty + Math.abs(candidate.top - layout.targetY);
|
|
767
|
+
}
|
|
768
|
+
function isAboveBelowPlacementConnectorless(layout) {
|
|
769
|
+
if (layout.targetMode === 'auto') {
|
|
770
|
+
return doRangesOverlap(layout.anchor.left, layout.anchor.right, layout.left + TOOLTIP_CONNECTOR_INSET_PX, layout.left + getTooltipConnectorMaxOffset(layout.width));
|
|
771
|
+
}
|
|
772
|
+
const arrowTipX = getAboveBelowPlacementArrowTipX(layout);
|
|
773
|
+
return (arrowTipX >=
|
|
774
|
+
layout.anchor.left - TOOLTIP_CONNECTOR_ALIGNMENT_TOLERANCE_PX &&
|
|
775
|
+
arrowTipX <=
|
|
776
|
+
layout.anchor.right + TOOLTIP_CONNECTOR_ALIGNMENT_TOLERANCE_PX);
|
|
777
|
+
}
|
|
778
|
+
function isAboveBelowBoxPlacementConnectorless(candidate, layout) {
|
|
779
|
+
if (layout.targetMode === 'auto') {
|
|
780
|
+
return doRangesOverlap(layout.anchor.left, layout.anchor.right, candidate.left + TOOLTIP_CONNECTOR_INSET_PX, candidate.left + getTooltipConnectorMaxOffset(layout.width));
|
|
781
|
+
}
|
|
782
|
+
const arrowTipX = candidate.left +
|
|
783
|
+
getTooltipConnectorOffset(candidate.left, layout.width, layout.targetX);
|
|
784
|
+
return (arrowTipX >=
|
|
785
|
+
layout.anchor.left - TOOLTIP_CONNECTOR_ALIGNMENT_TOLERANCE_PX &&
|
|
786
|
+
arrowTipX <=
|
|
787
|
+
layout.anchor.right + TOOLTIP_CONNECTOR_ALIGNMENT_TOLERANCE_PX);
|
|
788
|
+
}
|
|
789
|
+
function getAboveBelowPlacementArrowTipX(layout) {
|
|
790
|
+
return (layout.left +
|
|
791
|
+
getTooltipConnectorOffset(layout.left, layout.width, layout.targetX));
|
|
792
|
+
}
|
|
793
|
+
function getAboveBelowConnectorlessLeftRange(layout) {
|
|
794
|
+
return {
|
|
795
|
+
min: layout.anchor.left - getTooltipConnectorMaxOffset(layout.width),
|
|
796
|
+
max: layout.anchor.right - TOOLTIP_CONNECTOR_INSET_PX,
|
|
797
|
+
};
|
|
798
|
+
}
|
|
799
|
+
function alignAutoSplitTooltipTarget(layout) {
|
|
800
|
+
if (layout.targetMode !== 'auto') {
|
|
801
|
+
return;
|
|
802
|
+
}
|
|
803
|
+
if (layout.arrowEdge === 'left' || layout.arrowEdge === 'right') {
|
|
804
|
+
layout.targetY = getConnectorlessTargetCoordinate(layout.targetY, layout.anchor.top, layout.anchor.bottom, layout.top + TOOLTIP_CONNECTOR_INSET_PX, layout.top + getTooltipConnectorMaxOffset(layout.height));
|
|
805
|
+
return;
|
|
806
|
+
}
|
|
807
|
+
layout.targetX = getConnectorlessTargetCoordinate(layout.targetX, layout.anchor.left, layout.anchor.right, layout.left + TOOLTIP_CONNECTOR_INSET_PX, layout.left + getTooltipConnectorMaxOffset(layout.width));
|
|
808
|
+
}
|
|
809
|
+
function getConnectorlessTargetCoordinate(preferredValue, anchorStart, anchorEnd, tooltipStart, tooltipEnd) {
|
|
810
|
+
const minValue = Math.max(anchorStart, tooltipStart);
|
|
811
|
+
const maxValue = Math.min(anchorEnd, tooltipEnd);
|
|
812
|
+
if (maxValue < minValue) {
|
|
813
|
+
return preferredValue;
|
|
814
|
+
}
|
|
815
|
+
return clampTooltipCoordinate(preferredValue, minValue, maxValue);
|
|
816
|
+
}
|
|
817
|
+
function getTooltipConnectorMaxOffset(size) {
|
|
818
|
+
return Math.max(TOOLTIP_CONNECTOR_INSET_PX, size - TOOLTIP_CONNECTOR_INSET_PX);
|
|
819
|
+
}
|
|
820
|
+
function doRangesOverlap(firstStart, firstEnd, secondStart, secondEnd) {
|
|
821
|
+
return firstStart <= secondEnd && secondStart <= firstEnd;
|
|
822
|
+
}
|
|
823
|
+
function resolveAboveBelowArrowEdgeForTop(layout, top) {
|
|
824
|
+
const tooltipCenterY = top + layout.height / 2;
|
|
825
|
+
return tooltipCenterY <= layout.targetY ? 'bottom' : 'top';
|
|
826
|
+
}
|
|
827
|
+
function isSplitTooltipCandidateWithinBounds(candidate, tooltipHeight, bounds) {
|
|
828
|
+
return (candidate.top >= bounds.minTop &&
|
|
829
|
+
candidate.top + tooltipHeight <= bounds.maxBottom);
|
|
830
|
+
}
|
|
831
|
+
function isAboveBelowCandidateOnArrowSide(candidate, layout) {
|
|
832
|
+
if (candidate.arrowEdge === 'bottom') {
|
|
833
|
+
return (candidate.top + layout.height <= layout.targetY - TOOLTIP_OFFSET_PX);
|
|
834
|
+
}
|
|
835
|
+
return candidate.top >= layout.targetY + TOOLTIP_OFFSET_PX;
|
|
836
|
+
}
|
|
837
|
+
function doesSplitTooltipCandidateOverlap(candidate, layout, placedLayouts) {
|
|
838
|
+
return placedLayouts.some((placedLayout) => {
|
|
839
|
+
return (layout.left <
|
|
840
|
+
placedLayout.left + placedLayout.width + SPLIT_TOOLTIP_GAP_PX &&
|
|
841
|
+
layout.left + layout.width + SPLIT_TOOLTIP_GAP_PX >
|
|
842
|
+
placedLayout.left &&
|
|
843
|
+
candidate.top <
|
|
844
|
+
placedLayout.top + placedLayout.height + SPLIT_TOOLTIP_GAP_PX &&
|
|
845
|
+
candidate.top + layout.height + SPLIT_TOOLTIP_GAP_PX >
|
|
846
|
+
placedLayout.top);
|
|
847
|
+
});
|
|
848
|
+
}
|
|
849
|
+
function clampTooltipCoordinate(value, minValue, maxValue) {
|
|
850
|
+
if (maxValue < minValue) {
|
|
851
|
+
return minValue;
|
|
852
|
+
}
|
|
853
|
+
return Math.max(minValue, Math.min(value, maxValue));
|
|
854
|
+
}
|
|
855
|
+
function hasFiniteNumbers(...values) {
|
|
856
|
+
return values.every((value) => Number.isFinite(value));
|
|
857
|
+
}
|