@internetstiftelsen/charts 0.16.0 → 0.18.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.
@@ -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 resolveTooltipArrowEdge(position, anchor, target, tooltipWidth, tooltipHeight) {
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
- const bounds = getSplitTooltipViewportBounds();
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 anchor = getCombinedSplitTooltipAnchor(layouts);
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
- layouts.forEach((layout) => {
549
+ placementOrder.forEach((layout) => {
259
550
  layout.arrowEdge = arrowEdge;
260
- layout.left =
261
- arrowEdge === 'left'
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 positionSideSplitTooltip(layout, placedLayouts, bounds) {
269
- const candidates = getSidePlacementCandidates(layout, placedLayouts, bounds);
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
- !doesSplitTooltipVerticalCandidateOverlap(candidate, layout.height, placedLayouts)) ?? candidates[0];
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 getSidePlacementCost(candidate, layout) {
300
- return Math.abs(layout.targetY - (candidate.top + layout.height / 2));
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
- !doesSplitTooltipVerticalCandidateOverlap(candidate, layout.height, placedLayouts)) ?? candidates[0];
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 Math.abs(layout.targetY - (candidate.top + layout.height));
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
- return Math.abs(candidate.top - layout.targetY);
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 doesSplitTooltipVerticalCandidateOverlap(candidate, tooltipHeight, placedLayouts) {
837
+ function doesSplitTooltipCandidateOverlap(candidate, layout, placedLayouts) {
380
838
  return placedLayouts.some((placedLayout) => {
381
- return (candidate.top <
382
- placedLayout.top + placedLayout.height + SPLIT_TOOLTIP_GAP_PX &&
383
- candidate.top + tooltipHeight + SPLIT_TOOLTIP_GAP_PX >
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
  }
@@ -64,6 +64,8 @@ export type SplitTooltipLayout = {
64
64
  targetX: number;
65
65
  targetY: number;
66
66
  order: number;
67
+ targetMode: 'fixed' | 'auto';
68
+ valueSign: -1 | 0 | 1;
67
69
  };
68
70
  export type SplitTooltipPlacementCandidate = {
69
71
  top: number;
@@ -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: {