@internetstiftelsen/charts 0.16.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 +1 -1
- package/dist/tooltip/dom.d.ts +6 -2
- package/dist/tooltip/dom.js +46 -27
- package/dist/tooltip/geometry.d.ts +4 -3
- package/dist/tooltip/geometry.js +496 -34
- package/dist/tooltip/types.d.ts +2 -0
- package/dist/tooltip/xy-interaction.d.ts +2 -1
- package/dist/tooltip/xy-interaction.js +219 -36
- package/dist/tooltip.d.ts +2 -1
- package/dist/tooltip.js +10 -1
- package/dist/types.d.ts +5 -3
- package/docs/components.md +20 -10
- package/package.json +1 -1
package/dist/tooltip/geometry.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
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;
|
|
2
4
|
export function getSplitTooltipViewportBounds() {
|
|
3
5
|
const visualViewport = window.visualViewport;
|
|
4
6
|
const viewportLeft = window.scrollX + (visualViewport?.offsetLeft ?? 0);
|
|
@@ -12,11 +14,29 @@ export function getSplitTooltipViewportBounds() {
|
|
|
12
14
|
maxBottom: viewportTop + viewportHeight - TOOLTIP_VIEWPORT_PADDING_PX,
|
|
13
15
|
};
|
|
14
16
|
}
|
|
15
|
-
export function
|
|
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()) {
|
|
16
36
|
if (position === 'vertical') {
|
|
17
|
-
return resolveVerticalPlacementArrowEdge(target, tooltipHeight);
|
|
37
|
+
return resolveVerticalPlacementArrowEdge(target, tooltipHeight, bounds);
|
|
18
38
|
}
|
|
19
|
-
return resolveSidePlacementArrowEdge(anchor, tooltipWidth);
|
|
39
|
+
return resolveSidePlacementArrowEdge(anchor, tooltipWidth, bounds);
|
|
20
40
|
}
|
|
21
41
|
export function resolveSidePlacementArrowEdge(anchor, tooltipWidth, bounds = getSplitTooltipViewportBounds()) {
|
|
22
42
|
const availableRightSpace = bounds.maxRight - anchor.right - TOOLTIP_OFFSET_PX;
|
|
@@ -54,8 +74,7 @@ export function resolveSplitTooltipTarget(currentSeries, anchor, barAnchorPositi
|
|
|
54
74
|
y: anchor.centerY,
|
|
55
75
|
};
|
|
56
76
|
}
|
|
57
|
-
export function getAnchoredTooltipPosition(anchor, target, tooltipWidth, tooltipHeight, arrowEdge) {
|
|
58
|
-
const bounds = getSplitTooltipViewportBounds();
|
|
77
|
+
export function getAnchoredTooltipPosition(anchor, target, tooltipWidth, tooltipHeight, arrowEdge, bounds = getSplitTooltipViewportBounds()) {
|
|
59
78
|
let left = target.x - tooltipWidth / 2;
|
|
60
79
|
let top = target.y - tooltipHeight / 2;
|
|
61
80
|
if (arrowEdge === 'left') {
|
|
@@ -139,11 +158,18 @@ export function resolveTooltipArrowPosition(arrowEdge, boxX, boxY, length, halfH
|
|
|
139
158
|
};
|
|
140
159
|
}
|
|
141
160
|
}
|
|
142
|
-
export function resolveSplitTooltipPositions(layouts, position) {
|
|
161
|
+
export function resolveSplitTooltipPositions(layouts, position, bounds = getSplitTooltipViewportBounds(), isHorizontal = false) {
|
|
143
162
|
if (layouts.length === 0) {
|
|
144
163
|
return;
|
|
145
164
|
}
|
|
146
|
-
|
|
165
|
+
if (isHorizontal) {
|
|
166
|
+
if (position === 'vertical') {
|
|
167
|
+
resolveHorizontalAboveBelowSplitTooltipPositions(layouts, bounds);
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
resolveHorizontalSideSplitTooltipPositions(layouts, bounds);
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
147
173
|
if (position === 'vertical') {
|
|
148
174
|
resolveAboveBelowSplitTooltipPositions(layouts, bounds);
|
|
149
175
|
return;
|
|
@@ -223,6 +249,11 @@ function getVerticallyOrderedSplitTooltipLayouts(layouts) {
|
|
|
223
249
|
return (a.targetY - b.targetY || a.targetX - b.targetX || a.order - b.order);
|
|
224
250
|
});
|
|
225
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
|
+
}
|
|
226
257
|
function getCombinedSplitTooltipAnchor(layouts) {
|
|
227
258
|
const left = Math.min(...layouts.map((layout) => layout.anchor.left));
|
|
228
259
|
const right = Math.max(...layouts.map((layout) => layout.anchor.right));
|
|
@@ -247,37 +278,339 @@ function getCombinedSplitTooltipTarget(layouts) {
|
|
|
247
278
|
y: (minY + maxY) / 2,
|
|
248
279
|
};
|
|
249
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
|
+
}
|
|
250
546
|
function positionSideSplitTooltipStack(layouts, arrowEdge, bounds) {
|
|
251
|
-
const
|
|
252
|
-
const maxWidth = Math.max(...layouts.map((layout) => layout.width));
|
|
253
|
-
const preferredLeft = arrowEdge === 'left'
|
|
254
|
-
? anchor.right + TOOLTIP_OFFSET_PX
|
|
255
|
-
: anchor.left - maxWidth - TOOLTIP_OFFSET_PX;
|
|
256
|
-
const stackLeft = clampTooltipCoordinate(preferredLeft, bounds.minLeft, bounds.maxRight - maxWidth);
|
|
547
|
+
const placementOrder = getSidePlacementOrder(layouts);
|
|
257
548
|
const placedLayouts = [];
|
|
258
|
-
|
|
549
|
+
placementOrder.forEach((layout) => {
|
|
259
550
|
layout.arrowEdge = arrowEdge;
|
|
260
|
-
layout.left =
|
|
261
|
-
|
|
262
|
-
? stackLeft
|
|
263
|
-
: stackLeft + maxWidth - layout.width;
|
|
264
|
-
positionSideSplitTooltip(layout, placedLayouts, bounds);
|
|
551
|
+
layout.left = getSideTooltipLeft(layout, arrowEdge, bounds);
|
|
552
|
+
positionSideSplitTooltip(layout, placedLayouts, layouts, bounds);
|
|
265
553
|
placedLayouts.push(layout);
|
|
266
554
|
});
|
|
267
555
|
}
|
|
268
|
-
function
|
|
269
|
-
|
|
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);
|
|
270
577
|
const placement = candidates.find((candidate) => isSplitTooltipCandidateWithinBounds(candidate, layout.height, bounds) &&
|
|
271
|
-
!
|
|
578
|
+
!doesSplitTooltipCandidateOverlap(candidate, layout, placedLayouts)) ?? candidates[0];
|
|
272
579
|
layout.top = clampTooltipCoordinate(placement.top, bounds.minTop, bounds.maxBottom - layout.height);
|
|
580
|
+
alignAutoSplitTooltipTarget(layout);
|
|
273
581
|
}
|
|
274
|
-
function getSidePlacementCandidates(layout, placedLayouts, bounds) {
|
|
582
|
+
function getSidePlacementCandidates(layout, placedLayouts, allLayouts, bounds) {
|
|
275
583
|
const candidates = [
|
|
276
584
|
{
|
|
277
585
|
top: layout.targetY - layout.height / 2,
|
|
278
586
|
arrowEdge: layout.arrowEdge,
|
|
279
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
|
+
},
|
|
280
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
|
+
}
|
|
281
614
|
placedLayouts.forEach((placedLayout) => {
|
|
282
615
|
candidates.push({
|
|
283
616
|
top: placedLayout.top - layout.height - SPLIT_TOOLTIP_GAP_PX,
|
|
@@ -293,11 +626,76 @@ function getSidePlacementCandidates(layout, placedLayouts, bounds) {
|
|
|
293
626
|
...candidate,
|
|
294
627
|
top: clampTooltipCoordinate(candidate.top, bounds.minTop, bounds.maxBottom - layout.height),
|
|
295
628
|
}))
|
|
296
|
-
.sort((a, b) => getSidePlacementCost(a, layout) -
|
|
297
|
-
getSidePlacementCost(b, layout));
|
|
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;
|
|
298
648
|
}
|
|
299
|
-
function
|
|
300
|
-
|
|
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);
|
|
301
699
|
}
|
|
302
700
|
function resolveAboveBelowSplitTooltipPositions(layouts, bounds) {
|
|
303
701
|
const orderedLayouts = getVerticallyOrderedSplitTooltipLayouts(layouts);
|
|
@@ -316,9 +714,10 @@ function positionAboveBelowSplitTooltip(layout, placedLayouts, bounds) {
|
|
|
316
714
|
const candidates = getAboveBelowPlacementCandidates(layout, placedLayouts, bounds, preferredEdge);
|
|
317
715
|
const placement = candidates.find((candidate) => isAboveBelowCandidateOnArrowSide(candidate, layout) &&
|
|
318
716
|
isSplitTooltipCandidateWithinBounds(candidate, layout.height, bounds) &&
|
|
319
|
-
!
|
|
717
|
+
!doesSplitTooltipCandidateOverlap(candidate, layout, placedLayouts)) ?? candidates[0];
|
|
320
718
|
layout.arrowEdge = placement.arrowEdge;
|
|
321
719
|
layout.top = clampTooltipCoordinate(placement.top, bounds.minTop, bounds.maxBottom - layout.height);
|
|
720
|
+
alignAutoSplitTooltipTarget(layout);
|
|
322
721
|
}
|
|
323
722
|
function getAboveBelowPlacementCandidates(layout, placedLayouts, bounds, preferredEdge) {
|
|
324
723
|
const oppositeEdge = preferredEdge === 'bottom' ? 'top' : 'bottom';
|
|
@@ -357,10 +756,69 @@ function getIdealAboveBelowPlacement(layout, arrowEdge) {
|
|
|
357
756
|
};
|
|
358
757
|
}
|
|
359
758
|
function getAboveBelowPlacementCost(candidate, layout) {
|
|
759
|
+
const connectorPenalty = isAboveBelowPlacementConnectorless(layout)
|
|
760
|
+
? 0
|
|
761
|
+
: layout.height * 4;
|
|
360
762
|
if (candidate.arrowEdge === 'bottom') {
|
|
361
|
-
return
|
|
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));
|
|
362
771
|
}
|
|
363
|
-
|
|
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;
|
|
364
822
|
}
|
|
365
823
|
function resolveAboveBelowArrowEdgeForTop(layout, top) {
|
|
366
824
|
const tooltipCenterY = top + layout.height / 2;
|
|
@@ -376,11 +834,15 @@ function isAboveBelowCandidateOnArrowSide(candidate, layout) {
|
|
|
376
834
|
}
|
|
377
835
|
return candidate.top >= layout.targetY + TOOLTIP_OFFSET_PX;
|
|
378
836
|
}
|
|
379
|
-
function
|
|
837
|
+
function doesSplitTooltipCandidateOverlap(candidate, layout, placedLayouts) {
|
|
380
838
|
return placedLayouts.some((placedLayout) => {
|
|
381
|
-
return (
|
|
382
|
-
placedLayout.
|
|
383
|
-
|
|
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 >
|
|
384
846
|
placedLayout.top);
|
|
385
847
|
});
|
|
386
848
|
}
|
package/dist/tooltip/types.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { type Selection } from 'd3';
|
|
2
|
-
import type { ChartTheme, DataItem, DataValue, D3Scale, ScaleType, TooltipBarAnchorPosition, TooltipMode, TooltipPosition } from '../types.js';
|
|
2
|
+
import type { ChartTheme, DataItem, DataValue, D3Scale, ScaleType, TooltipBarAnchorPosition, TooltipColorMode, TooltipMode, TooltipPosition } from '../types.js';
|
|
3
3
|
import type { PlotAreaBounds } from '../layout-manager.js';
|
|
4
4
|
import type { TooltipDom } from './dom.js';
|
|
5
5
|
import type { XYTooltipSeries } from './types.js';
|
|
@@ -20,6 +20,7 @@ export type XYTooltipAreaConfig = {
|
|
|
20
20
|
mode: TooltipMode;
|
|
21
21
|
position: TooltipPosition;
|
|
22
22
|
barAnchorPosition: TooltipBarAnchorPosition;
|
|
23
|
+
colorMode: TooltipColorMode;
|
|
23
24
|
formatter?: (dataKey: string, value: DataValue, data: DataItem) => string;
|
|
24
25
|
labelFormatter?: (label: string, data: DataItem) => string;
|
|
25
26
|
customFormatter?: (data: DataItem, series: {
|