@internetstiftelsen/charts 0.18.0 → 0.18.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -16,7 +16,7 @@ A framework-agnostic, composable charting library built on D3.js with TypeScript
16
16
  - **Optional Radial Animation** - Animate pie and donut segments on first render and `chart.update(...)` with `animate`
17
17
  - **Optional Gauge Animation** - Animate gauge value transitions with `gauge.animate`
18
18
  - **Stacking Control** - Bar and area stacking modes with optional reversed visual series order
19
- - **Configurable Tooltips** - Shared, split, or single tooltips with connectors, transitions, color-coded series styling, and default max-width wrapping
19
+ - **Configurable Tooltips** - Shared, split, or single tooltips with connectors, transitions, color-coded series styling, optional series border and connector overrides, and default max-width wrapping
20
20
  - **Axis Direction Control** - Use `scales.x.reverse` / `scales.y.reverse` to flip an axis when needed
21
21
  - **Flexible Scales** - Band, linear, time, and logarithmic scales (bar value axes stay linear)
22
22
  - **Explicit or Responsive Sizing** - Set top-level `width`/`height` or let the container drive size
@@ -65,6 +65,19 @@ pnpm dev:docs
65
65
  Runs the marketing landing page (`docs.html`) built on
66
66
  `@internetstiftelsen/styleguide`.
67
67
 
68
+ ```bash
69
+ pnpm storybook
70
+ ```
71
+
72
+ Runs Storybook on port 6006 with categorized chart examples for quickly
73
+ checking chart types and configuration combinations.
74
+
75
+ ```bash
76
+ pnpm build-storybook
77
+ ```
78
+
79
+ Builds the static Storybook output.
80
+
68
81
  ## Build Targets
69
82
 
70
83
  ```bash
package/dist/theme.js CHANGED
@@ -107,6 +107,7 @@ export const defaultTheme = {
107
107
  tooltip: {
108
108
  background: '#ffffff',
109
109
  border: '#dddddd',
110
+ seriesBorderColor: '#ffffff',
110
111
  color: '#1f2a36',
111
112
  fontFamily: SYSTEM_FONT,
112
113
  fontSize: 12,
@@ -268,6 +269,7 @@ export const newspaperTheme = {
268
269
  tooltip: {
269
270
  background: '#111111',
270
271
  border: '#111111',
272
+ seriesBorderColor: '#ffffff',
271
273
  color: '#ffffff',
272
274
  fontFamily: 'Georgia, "Times New Roman", Times, serif',
273
275
  fontSize: 11,
@@ -6,7 +6,10 @@ type TooltipDomConfig = {
6
6
  maxWidth: number;
7
7
  transition: Required<TooltipTransitionConfig>;
8
8
  };
9
- export type TooltipStyleOverrides = Partial<Pick<ChartTheme['tooltip'], 'background' | 'border' | 'color'>>;
9
+ type ResolvedTooltipStyle = ChartTheme['tooltip'] & {
10
+ connectorColor: string;
11
+ };
12
+ export type TooltipStyleOverrides = Partial<Pick<ResolvedTooltipStyle, 'background' | 'border' | 'color' | 'connectorColor'>>;
10
13
  export declare class TooltipDom {
11
14
  private readonly id;
12
15
  private readonly splitTooltipOwner;
@@ -214,15 +214,21 @@ export class TooltipDom {
214
214
  this.writeTooltipStyles(tooltip, tooltipStyle);
215
215
  }
216
216
  resolveTooltipStyle(theme, styleOverrides) {
217
+ const border = styleOverrides?.border ?? theme.tooltip.border;
217
218
  return {
218
219
  ...theme.tooltip,
219
220
  ...styleOverrides,
221
+ border,
222
+ connectorColor: styleOverrides?.connectorColor ??
223
+ styleOverrides?.border ??
224
+ theme.tooltip.border,
220
225
  };
221
226
  }
222
227
  getTooltipStyleKey(tooltipStyle) {
223
228
  return [
224
229
  tooltipStyle.background,
225
230
  tooltipStyle.border,
231
+ tooltipStyle.connectorColor,
226
232
  tooltipStyle.color,
227
233
  tooltipStyle.fontFamily,
228
234
  tooltipStyle.fontSize,
@@ -365,7 +371,7 @@ export class TooltipDom {
365
371
  }
366
372
  appendTooltipConnector(tooltip, connectorLayout) {
367
373
  const tooltipStyle = this.getTooltipStyle(tooltip);
368
- const tooltipBorder = tooltipStyle?.border ?? '#dddddd';
374
+ const connectorColor = tooltipStyle?.connectorColor ?? '#dddddd';
369
375
  const connector = tooltip
370
376
  .append('svg')
371
377
  .attr('data-chart-tooltip-connector', 'true')
@@ -385,7 +391,7 @@ export class TooltipDom {
385
391
  .attr('data-chart-tooltip-connector-path', 'true')
386
392
  .attr('d', connectorLayout.path)
387
393
  .attr('fill', 'none')
388
- .attr('stroke', tooltipBorder)
394
+ .attr('stroke', connectorColor)
389
395
  .attr('stroke-width', 1.25)
390
396
  .attr('stroke-linecap', 'round')
391
397
  .attr('stroke-linejoin', 'round');
@@ -1,6 +1,7 @@
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';
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_MIN_PATH_LENGTH_PX, TOOLTIP_CONNECTOR_PADDING_PX, TOOLTIP_OFFSET_PX, TOOLTIP_TOTAL_BORDER_WIDTH_PX, TOOLTIP_VIEWPORT_PADDING_PX, } from './types.js';
2
2
  const MAX_EXHAUSTIVE_HORIZONTAL_ROW_LAYOUTS = 10;
3
3
  const HORIZONTAL_ROW_ORDER_FLIP_PENALTY_PX = 128;
4
+ const AUTO_BAR_TOOLTIP_CONNECTOR_MIN_PATH_LENGTH_PX = 8;
4
5
  export function getSplitTooltipViewportBounds() {
5
6
  const visualViewport = window.visualViewport;
6
7
  const viewportLeft = window.scrollX + (visualViewport?.offsetLeft ?? 0);
@@ -98,13 +99,16 @@ export function getAnchoredTooltipPosition(anchor, target, tooltipWidth, tooltip
98
99
  };
99
100
  }
100
101
  export function resolveTooltipConnectorLayout(arrowEdge, tooltipLeft, tooltipTop, tooltipWidth, tooltipHeight, targetX, targetY, anchor) {
101
- const localTargetX = targetX - tooltipLeft;
102
- const localTargetY = targetY - tooltipTop;
102
+ const boxArrowPosition = resolveTooltipBoxArrowPosition(arrowEdge, tooltipLeft, tooltipTop, tooltipWidth, tooltipHeight, targetX, targetY);
103
+ const arrowTip = resolveTooltipArrowTip(arrowEdge, boxArrowPosition.x, boxArrowPosition.y, TOOLTIP_BOX_ARROW_LENGTH_PX);
104
+ const arrowTipX = tooltipLeft + arrowTip.x;
105
+ const arrowTipY = tooltipTop + arrowTip.y;
106
+ const connectorTarget = resolveTooltipConnectorTarget(arrowEdge, targetX, targetY, arrowTipX, arrowTipY, anchor);
107
+ const localTargetX = connectorTarget.x - tooltipLeft;
108
+ const localTargetY = connectorTarget.y - tooltipTop;
103
109
  if (!Number.isFinite(localTargetX) || !Number.isFinite(localTargetY)) {
104
110
  return null;
105
111
  }
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
112
  const minX = Math.min(arrowTip.x, localTargetX) - TOOLTIP_CONNECTOR_PADDING_PX;
109
113
  const maxX = Math.max(arrowTip.x, localTargetX) + TOOLTIP_CONNECTOR_PADDING_PX;
110
114
  const minY = Math.min(arrowTip.y, localTargetY) - TOOLTIP_CONNECTOR_PADDING_PX;
@@ -117,9 +121,7 @@ export function resolveTooltipConnectorLayout(arrowEdge, tooltipLeft, tooltipTop
117
121
  const startY = arrowTip.y - minY;
118
122
  const endX = localTargetX - minX;
119
123
  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);
124
+ const connectorPath = resolveTooltipConnectorPath(arrowEdge, startX, startY, endX, endY, arrowTipX, arrowTipY, anchor, connectorTarget.preferDirectPath);
123
125
  if (!hasFiniteNumbers(width, height, boxX, boxY, startX, startY, endX, endY)) {
124
126
  return null;
125
127
  }
@@ -134,6 +136,43 @@ export function resolveTooltipConnectorLayout(arrowEdge, tooltipLeft, tooltipTop
134
136
  arrowY: boxArrowPosition.y,
135
137
  };
136
138
  }
139
+ function resolveTooltipConnectorTarget(arrowEdge, targetX, targetY, arrowTipX, arrowTipY, anchor) {
140
+ if (isPointTooltipAnchor(anchor)) {
141
+ return { x: targetX, y: targetY, preferDirectPath: false };
142
+ }
143
+ if (arrowEdge === 'left') {
144
+ return {
145
+ x: anchor.right,
146
+ y: clampTooltipCoordinate(arrowTipY, anchor.top, anchor.bottom),
147
+ preferDirectPath: true,
148
+ };
149
+ }
150
+ if (arrowEdge === 'right') {
151
+ return {
152
+ x: anchor.left,
153
+ y: clampTooltipCoordinate(arrowTipY, anchor.top, anchor.bottom),
154
+ preferDirectPath: true,
155
+ };
156
+ }
157
+ if (arrowEdge === 'top') {
158
+ return {
159
+ x: clampTooltipCoordinate(arrowTipX, anchor.left, anchor.right),
160
+ y: anchor.bottom,
161
+ preferDirectPath: true,
162
+ };
163
+ }
164
+ return {
165
+ x: clampTooltipCoordinate(arrowTipX, anchor.left, anchor.right),
166
+ y: anchor.top,
167
+ preferDirectPath: true,
168
+ };
169
+ }
170
+ function isPointTooltipAnchor(anchor) {
171
+ return (Math.abs(anchor.left - anchor.right) <=
172
+ TOOLTIP_CONNECTOR_ALIGNMENT_TOLERANCE_PX &&
173
+ Math.abs(anchor.top - anchor.bottom) <=
174
+ TOOLTIP_CONNECTOR_ALIGNMENT_TOLERANCE_PX);
175
+ }
137
176
  export function resolveTooltipArrowPosition(arrowEdge, boxX, boxY, length, halfHeight) {
138
177
  switch (arrowEdge) {
139
178
  case 'left':
@@ -175,7 +214,7 @@ export function resolveSplitTooltipPositions(layouts, position, bounds = getSpli
175
214
  return;
176
215
  }
177
216
  const arrowEdge = resolveSidePlacementArrowEdge(getCombinedSplitTooltipAnchor(layouts), Math.max(...layouts.map((layout) => layout.width)), bounds);
178
- const orderedLayouts = getVerticallyOrderedSplitTooltipLayouts(layouts);
217
+ const orderedLayouts = getSideSplitTooltipLayouts(layouts);
179
218
  positionSideSplitTooltipStack(orderedLayouts, arrowEdge, bounds);
180
219
  }
181
220
  function resolveTooltipBoxArrowPosition(arrowEdge, tooltipLeft, tooltipTop, tooltipWidth, tooltipHeight, targetX, targetY) {
@@ -210,17 +249,26 @@ function getTooltipConnectorOffset(start, size, target) {
210
249
  const preferredOffset = target - start;
211
250
  return Math.max(minOffset, Math.min(preferredOffset, maxOffset));
212
251
  }
213
- function resolveTooltipConnectorPath(arrowEdge, startX, startY, endX, endY, arrowTipX, arrowTipY, anchor) {
252
+ function resolveTooltipConnectorPath(arrowEdge, startX, startY, endX, endY, arrowTipX, arrowTipY, anchor, preferDirectPath) {
214
253
  if (arrowEdge === 'left' || arrowEdge === 'right') {
215
- if (isTooltipArrowTipInsideVerticalAnchorSpan(arrowTipY, anchor)) {
254
+ const minPathLength = preferDirectPath
255
+ ? AUTO_BAR_TOOLTIP_CONNECTOR_MIN_PATH_LENGTH_PX
256
+ : TOOLTIP_CONNECTOR_MIN_PATH_LENGTH_PX;
257
+ if (isTooltipArrowTipNearVerticalAnchorSpan(arrowTipY, anchor, minPathLength)) {
216
258
  return '';
217
259
  }
260
+ if (preferDirectPath) {
261
+ return `M ${startX},${startY} L ${endX},${endY}`;
262
+ }
218
263
  const elbowX = startX + (endX - startX) * TOOLTIP_CONNECTOR_ELBOW_RATIO;
219
264
  return `M ${startX},${startY} L ${elbowX},${startY} L ${endX},${endY}`;
220
265
  }
221
266
  if (isTooltipArrowTipInsideHorizontalAnchorSpan(arrowTipX, anchor)) {
222
267
  return '';
223
268
  }
269
+ if (preferDirectPath) {
270
+ return `M ${startX},${startY} L ${endX},${endY}`;
271
+ }
224
272
  const elbowY = startY + (endY - startY) * TOOLTIP_CONNECTOR_ELBOW_RATIO;
225
273
  return `M ${startX},${startY} L ${startX},${elbowY} L ${endX},${endY}`;
226
274
  }
@@ -232,6 +280,13 @@ function isTooltipArrowTipInsideVerticalAnchorSpan(arrowTipY, anchor) {
232
280
  return (arrowTipY >= anchor.top - TOOLTIP_CONNECTOR_ALIGNMENT_TOLERANCE_PX &&
233
281
  arrowTipY <= anchor.bottom + TOOLTIP_CONNECTOR_ALIGNMENT_TOLERANCE_PX);
234
282
  }
283
+ function isTooltipArrowTipNearVerticalAnchorSpan(arrowTipY, anchor, minPathLength = TOOLTIP_CONNECTOR_MIN_PATH_LENGTH_PX) {
284
+ if (isTooltipArrowTipInsideVerticalAnchorSpan(arrowTipY, anchor)) {
285
+ return true;
286
+ }
287
+ return (arrowTipY >= anchor.top - minPathLength &&
288
+ arrowTipY <= anchor.bottom + minPathLength);
289
+ }
235
290
  function resolveTooltipArrowTip(arrowEdge, boxX, boxY, length) {
236
291
  if (arrowEdge === 'left' || arrowEdge === 'right') {
237
292
  return {
@@ -278,6 +333,15 @@ function getCombinedSplitTooltipTarget(layouts) {
278
333
  y: (minY + maxY) / 2,
279
334
  };
280
335
  }
336
+ function getSideSplitTooltipLayouts(layouts) {
337
+ if (layouts.every((layout) => layout.targetMode === 'auto')) {
338
+ return [...layouts].sort((a, b) => a.anchor.top - b.anchor.top ||
339
+ a.anchor.bottom - b.anchor.bottom ||
340
+ a.anchor.left - b.anchor.left ||
341
+ a.order - b.order);
342
+ }
343
+ return getVerticallyOrderedSplitTooltipLayouts(layouts);
344
+ }
281
345
  function resolveHorizontalSideSplitTooltipPositions(layouts, bounds) {
282
346
  const rowPlacement = getBestHorizontalSideRowPlacement(getHorizontallyOrderedSplitTooltipLayouts(layouts), bounds);
283
347
  rowPlacement.placements.forEach(({ layout, left, top, arrowEdge }) => {
@@ -424,6 +488,7 @@ function getHorizontalAboveBelowRowPlacement(layouts, assignment, preferredEdges
424
488
  const rowLayouts = layouts.filter((_, index) => assignment[index] === arrowEdge);
425
489
  return packHorizontalAboveBelowRow(rowLayouts, arrowEdge, bounds);
426
490
  });
491
+ separateOverlappingHorizontalTooltipRows(placements, bounds);
427
492
  const preferredEdgeByLayout = new Map(layouts.map((layout, index) => [layout, preferredEdges[index]]));
428
493
  const score = placements.reduce((sum, placement) => {
429
494
  const preferredEdge = preferredEdgeByLayout.get(placement.layout) ??
@@ -493,6 +558,59 @@ function packHorizontalTooltipRow(placements, bounds) {
493
558
  }
494
559
  return placements;
495
560
  }
561
+ function separateOverlappingHorizontalTooltipRows(placements, bounds) {
562
+ if (placements.length < 2) {
563
+ return;
564
+ }
565
+ const orderedPlacements = [...placements].sort((a, b) => a.top - b.top ||
566
+ a.left - b.left ||
567
+ a.layout.targetX - b.layout.targetX ||
568
+ a.layout.order - b.layout.order);
569
+ for (let index = 0; index < orderedPlacements.length; index++) {
570
+ const placement = orderedPlacements[index];
571
+ for (let previousIndex = 0; previousIndex < index; previousIndex++) {
572
+ const previousPlacement = orderedPlacements[previousIndex];
573
+ if (!doHorizontalTooltipPlacementsOverlap(placement, previousPlacement)) {
574
+ continue;
575
+ }
576
+ placement.top = Math.max(placement.top, previousPlacement.top +
577
+ previousPlacement.layout.height +
578
+ SPLIT_TOOLTIP_GAP_PX);
579
+ }
580
+ }
581
+ const overflow = Math.max(...orderedPlacements.map((placement) => placement.top + placement.layout.height)) - bounds.maxBottom;
582
+ if (overflow > 0) {
583
+ orderedPlacements.forEach((placement) => {
584
+ placement.top -= overflow;
585
+ });
586
+ }
587
+ for (let index = orderedPlacements.length - 2; index >= 0; index--) {
588
+ const placement = orderedPlacements[index];
589
+ for (let nextIndex = orderedPlacements.length - 1; nextIndex > index; nextIndex--) {
590
+ const nextPlacement = orderedPlacements[nextIndex];
591
+ if (!doHorizontalTooltipPlacementsOverlap(placement, nextPlacement)) {
592
+ continue;
593
+ }
594
+ placement.top = Math.min(placement.top, nextPlacement.top -
595
+ placement.layout.height -
596
+ SPLIT_TOOLTIP_GAP_PX);
597
+ }
598
+ }
599
+ const underflow = bounds.minTop -
600
+ Math.min(...orderedPlacements.map((placement) => placement.top));
601
+ if (underflow <= 0) {
602
+ return;
603
+ }
604
+ orderedPlacements.forEach((placement) => {
605
+ placement.top += underflow;
606
+ });
607
+ }
608
+ function doHorizontalTooltipPlacementsOverlap(first, second) {
609
+ return (first.left < second.left + second.layout.width + SPLIT_TOOLTIP_GAP_PX &&
610
+ first.left + first.layout.width + SPLIT_TOOLTIP_GAP_PX > second.left &&
611
+ first.top < second.top + second.layout.height + SPLIT_TOOLTIP_GAP_PX &&
612
+ first.top + first.layout.height + SPLIT_TOOLTIP_GAP_PX > second.top);
613
+ }
496
614
  function getHorizontalAboveBelowIdealLeft(layout) {
497
615
  const centeredLeft = layout.targetX - layout.width / 2;
498
616
  if (layout.targetMode === 'auto') {
@@ -513,8 +631,13 @@ function getHorizontalAboveBelowPlacementCost(candidate, layout, preferredEdge,
513
631
  ? 0
514
632
  : layout.height * 4;
515
633
  const xDistance = Math.abs(layout.targetX - (candidate.left + layout.width / 2));
634
+ const yDistance = Math.abs(candidate.top - getHorizontalAboveBelowTop(layout, candidate.arrowEdge));
516
635
  const overflowPenalty = getSplitTooltipBoxPlacementOverflow(candidate, layout, bounds) * 20;
517
- return connectorPenalty + edgePenalty + xDistance + overflowPenalty;
636
+ return (connectorPenalty +
637
+ edgePenalty +
638
+ xDistance +
639
+ yDistance * 0.5 +
640
+ overflowPenalty);
518
641
  }
519
642
  function getPreferredAndOppositeEdges(preferredEdge) {
520
643
  if (preferredEdge === 'bottom') {
@@ -544,28 +667,14 @@ function getSplitTooltipBoxPlacementOverflow(candidate, layout, bounds) {
544
667
  Math.max(0, candidate.top + layout.height - bounds.maxBottom));
545
668
  }
546
669
  function positionSideSplitTooltipStack(layouts, arrowEdge, bounds) {
547
- const placementOrder = getSidePlacementOrder(layouts);
548
670
  const placedLayouts = [];
549
- placementOrder.forEach((layout) => {
671
+ layouts.forEach((layout) => {
550
672
  layout.arrowEdge = arrowEdge;
551
673
  layout.left = getSideTooltipLeft(layout, arrowEdge, bounds);
552
674
  positionSideSplitTooltip(layout, placedLayouts, layouts, bounds);
553
675
  placedLayouts.push(layout);
554
676
  });
555
677
  }
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
678
  function getSideTooltipLeft(layout, arrowEdge, bounds) {
570
679
  const preferredLeft = arrowEdge === 'left'
571
680
  ? layout.anchor.right + TOOLTIP_OFFSET_PX
@@ -602,6 +711,15 @@ function getSidePlacementCandidates(layout, placedLayouts, allLayouts, bounds) {
602
711
  ];
603
712
  if (layout.targetMode === 'auto') {
604
713
  const connectorlessRange = getSideConnectorlessTopRange(layout);
714
+ const exactConnectorlessRange = getExactSideConnectorlessTopRange(layout);
715
+ candidates.push({
716
+ top: exactConnectorlessRange.min,
717
+ arrowEdge: layout.arrowEdge,
718
+ });
719
+ candidates.push({
720
+ top: exactConnectorlessRange.max,
721
+ arrowEdge: layout.arrowEdge,
722
+ });
605
723
  candidates.push({
606
724
  top: connectorlessRange.min,
607
725
  arrowEdge: layout.arrowEdge,
@@ -630,55 +748,77 @@ function getSidePlacementCandidates(layout, placedLayouts, allLayouts, bounds) {
630
748
  getSidePlacementCost(b, layout, allLayouts));
631
749
  }
632
750
  function getSidePlacementCost(candidate, layout, allLayouts) {
633
- const connectorPenalty = isSidePlacementConnectorless(candidate, layout)
634
- ? 0
635
- : layout.height * 4;
751
+ const connectorless = isSidePlacementConnectorless(candidate, layout);
636
752
  const centerDistance = Math.abs(layout.targetY - (candidate.top + layout.height / 2));
637
- if (layout.targetMode === 'auto' && connectorPenalty === 0) {
638
- const spreadDirection = getAutoSideSpreadDirection(layout, allLayouts);
753
+ const arrowDistance = Math.abs(layout.targetY - getSidePlacementArrowTipY(candidate.top, layout));
754
+ if (layout.targetMode === 'auto' && connectorless) {
755
+ const preferredTop = layout.targetY - getTooltipConnectorMaxOffset(layout.height);
756
+ const preferredTopDistance = Math.abs(candidate.top - preferredTop);
757
+ return preferredTopDistance + arrowDistance * 0.1;
758
+ }
759
+ if (connectorless) {
760
+ const spreadDirection = getFixedPointSideSpreadDirection(layout, allLayouts);
639
761
  if (spreadDirection !== 0) {
640
- const connectorlessRange = getSideConnectorlessTopRange(layout);
641
- const preferredTop = spreadDirection > 0
642
- ? connectorlessRange.max
643
- : connectorlessRange.min;
762
+ const preferredTop = getFixedPointSideSpreadPreferredTop(layout, spreadDirection);
644
763
  return (Math.abs(candidate.top - preferredTop) + centerDistance * 0.05);
645
764
  }
765
+ return centerDistance;
646
766
  }
647
- return connectorPenalty + centerDistance;
767
+ return layout.height * 4 + arrowDistance + centerDistance * 0.2;
648
768
  }
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) {
769
+ function getFixedPointSideSpreadDirection(layout, allLayouts) {
770
+ if (!isFixedPointSideTooltipLayout(layout)) {
659
771
  return 0;
660
772
  }
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;
773
+ const nearbyLayouts = allLayouts
774
+ .filter((otherLayout) => isFixedPointSideTooltipLayout(otherLayout) &&
775
+ areFixedPointSideTargetsNear(layout, otherLayout))
776
+ .sort((a, b) => a.targetY - b.targetY ||
777
+ a.targetX - b.targetX ||
778
+ a.order - b.order);
779
+ if (nearbyLayouts.length < 2) {
780
+ return 0;
665
781
  }
666
- if (belowDistance < aboveDistance) {
782
+ const layoutIndex = nearbyLayouts.indexOf(layout);
783
+ const clusterCenterIndex = (nearbyLayouts.length - 1) / 2;
784
+ if (layoutIndex < clusterCenterIndex) {
667
785
  return -1;
668
786
  }
787
+ if (layoutIndex > clusterCenterIndex) {
788
+ return 1;
789
+ }
669
790
  return 0;
670
791
  }
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];
792
+ function isFixedPointSideTooltipLayout(layout) {
793
+ return (layout.targetMode === 'fixed' &&
794
+ Math.abs(layout.anchor.left - layout.anchor.right) <=
795
+ TOOLTIP_CONNECTOR_ALIGNMENT_TOLERANCE_PX &&
796
+ Math.abs(layout.anchor.top - layout.anchor.bottom) <=
797
+ TOOLTIP_CONNECTOR_ALIGNMENT_TOLERANCE_PX);
798
+ }
799
+ function areFixedPointSideTargetsNear(layout, otherLayout) {
800
+ const centeredTooltipsOverlapDistance = (layout.height + otherLayout.height) / 2 + SPLIT_TOOLTIP_GAP_PX;
801
+ return (Math.abs(layout.targetY - otherLayout.targetY) <
802
+ centeredTooltipsOverlapDistance);
803
+ }
804
+ function getFixedPointSideSpreadPreferredTop(layout, direction) {
805
+ if (direction < 0) {
806
+ return layout.targetY - getTooltipConnectorMaxOffset(layout.height);
807
+ }
808
+ return layout.targetY - TOOLTIP_CONNECTOR_INSET_PX;
680
809
  }
681
810
  function getSideConnectorlessTopRange(layout) {
811
+ const nearPathPadding = layout.targetMode === 'auto'
812
+ ? AUTO_BAR_TOOLTIP_CONNECTOR_MIN_PATH_LENGTH_PX
813
+ : 0;
814
+ return {
815
+ min: layout.anchor.top -
816
+ getTooltipConnectorMaxOffset(layout.height) -
817
+ nearPathPadding,
818
+ max: layout.anchor.bottom - TOOLTIP_CONNECTOR_INSET_PX + nearPathPadding,
819
+ };
820
+ }
821
+ function getExactSideConnectorlessTopRange(layout) {
682
822
  return {
683
823
  min: layout.anchor.top - getTooltipConnectorMaxOffset(layout.height),
684
824
  max: layout.anchor.bottom - TOOLTIP_CONNECTOR_INSET_PX,
@@ -686,7 +826,9 @@ function getSideConnectorlessTopRange(layout) {
686
826
  }
687
827
  function isSidePlacementConnectorless(candidate, layout) {
688
828
  if (layout.targetMode === 'auto') {
689
- return doRangesOverlap(layout.anchor.top, layout.anchor.bottom, candidate.top + TOOLTIP_CONNECTOR_INSET_PX, candidate.top + getTooltipConnectorMaxOffset(layout.height));
829
+ const connectorlessRange = getSideConnectorlessTopRange(layout);
830
+ return (candidate.top >= connectorlessRange.min &&
831
+ candidate.top <= connectorlessRange.max);
690
832
  }
691
833
  const arrowTipY = getSidePlacementArrowTipY(candidate.top, layout);
692
834
  return (arrowTipY >=
@@ -6,7 +6,8 @@ import type { Scatter } from '../scatter.js';
6
6
  import type { TooltipTransitionConfig } from '../types.js';
7
7
  export declare const TOOLTIP_OFFSET_PX = 12;
8
8
  export declare const TOOLTIP_VIEWPORT_PADDING_PX = 10;
9
- export declare const TOOLTIP_CONNECTOR_INSET_PX = 14;
9
+ export declare const TOOLTIP_CONNECTOR_INSET_PX = 10;
10
+ export declare const TOOLTIP_CONNECTOR_MIN_PATH_LENGTH_PX = 16;
10
11
  export declare const TOOLTIP_CONNECTOR_PADDING_PX = 4;
11
12
  export declare const TOOLTIP_CONNECTOR_ELBOW_RATIO = 0.45;
12
13
  export declare const TOOLTIP_BORDER_WIDTH_PX = 1;
@@ -19,7 +20,7 @@ export declare const TOOLTIP_ARROW_FILL_Z_INDEX = 5;
19
20
  export declare const TOOLTIP_BODY_Z_INDEX = 6;
20
21
  export declare const TOOLTIP_TOTAL_BORDER_WIDTH_PX: number;
21
22
  export declare const TOOLTIP_ROOT_Z_INDEX = 30;
22
- export declare const SPLIT_TOOLTIP_GAP_PX = 8;
23
+ export declare const SPLIT_TOOLTIP_GAP_PX = 4;
23
24
  export declare const DEFAULT_TOOLTIP_MAX_WIDTH_PX = 280;
24
25
  export declare const DEFAULT_TOOLTIP_TRANSITION: Required<TooltipTransitionConfig>;
25
26
  export declare const TOOLTIP_HIDDEN_TRANSFORM = "translateY(2px)";
@@ -1,6 +1,7 @@
1
1
  export const TOOLTIP_OFFSET_PX = 12;
2
2
  export const TOOLTIP_VIEWPORT_PADDING_PX = 10;
3
- export const TOOLTIP_CONNECTOR_INSET_PX = 14;
3
+ export const TOOLTIP_CONNECTOR_INSET_PX = 10;
4
+ export const TOOLTIP_CONNECTOR_MIN_PATH_LENGTH_PX = 16;
4
5
  export const TOOLTIP_CONNECTOR_PADDING_PX = 4;
5
6
  export const TOOLTIP_CONNECTOR_ELBOW_RATIO = 0.45;
6
7
  export const TOOLTIP_BORDER_WIDTH_PX = 1;
@@ -13,7 +14,7 @@ export const TOOLTIP_ARROW_FILL_Z_INDEX = 5;
13
14
  export const TOOLTIP_BODY_Z_INDEX = 6;
14
15
  export const TOOLTIP_TOTAL_BORDER_WIDTH_PX = TOOLTIP_BORDER_WIDTH_PX * 2;
15
16
  export const TOOLTIP_ROOT_Z_INDEX = 30;
16
- export const SPLIT_TOOLTIP_GAP_PX = 8;
17
+ export const SPLIT_TOOLTIP_GAP_PX = 4;
17
18
  export const DEFAULT_TOOLTIP_MAX_WIDTH_PX = 280;
18
19
  export const DEFAULT_TOOLTIP_TRANSITION = {
19
20
  show: false,
@@ -21,6 +21,8 @@ export type XYTooltipAreaConfig = {
21
21
  position: TooltipPosition;
22
22
  barAnchorPosition: TooltipBarAnchorPosition;
23
23
  colorMode: TooltipColorMode;
24
+ seriesBorderColor?: string;
25
+ seriesConnectorColor?: string;
24
26
  formatter?: (dataKey: string, value: DataValue, data: DataItem) => string;
25
27
  labelFormatter?: (label: string, data: DataItem) => string;
26
28
  customFormatter?: (data: DataItem, series: {
@@ -3,7 +3,7 @@ import { getSeriesColor } from '../types.js';
3
3
  import { getContrastTextColor, sanitizeForCSS } from '../utils.js';
4
4
  import { clipTooltipAnchorToBounds, getAnchoredTooltipPosition, getSplitTooltipViewportBounds, resolveSharedTooltipTarget, resolveSplitTooltipPositions, resolveSplitTooltipTarget, resolveTooltipArrowEdge, } from './geometry.js';
5
5
  export function attachXYTooltipArea(config) {
6
- const { svg, data, series, xKey, x, y, theme, plotArea, parseValue, isHorizontal, categoryScaleType, mode, position, barAnchorPosition, colorMode, formatter, labelFormatter, customFormatter, dom, } = config;
6
+ const { svg, data, series, xKey, x, y, theme, plotArea, parseValue, isHorizontal, categoryScaleType, mode, position, barAnchorPosition, colorMode, seriesBorderColor, seriesConnectorColor, formatter, labelFormatter, customFormatter, dom, } = config;
7
7
  const tooltip = dom.getRootTooltip();
8
8
  if (!tooltip || data.length === 0) {
9
9
  return null;
@@ -63,7 +63,12 @@ export function attachXYTooltipArea(config) {
63
63
  : getSeriesColor(currentSeries);
64
64
  return {
65
65
  background: seriesColor,
66
- border: seriesColor,
66
+ border: seriesBorderColor ??
67
+ theme.tooltip.seriesBorderColor ??
68
+ seriesColor,
69
+ connectorColor: seriesConnectorColor ??
70
+ theme.tooltip.seriesConnectorColor ??
71
+ seriesColor,
67
72
  color: getContrastTextColor(seriesColor),
68
73
  };
69
74
  };
@@ -257,6 +262,11 @@ export function attachXYTooltipArea(config) {
257
262
  return;
258
263
  }
259
264
  const target = resolveSplitTooltipTarget(currentSeries, visibleAnchor, resolvedBarAnchorPosition);
265
+ if (currentSeries.type === 'bar' &&
266
+ !isHorizontal &&
267
+ resolvedBarAnchorPosition === 'auto') {
268
+ target.y = visibleAnchor.top;
269
+ }
260
270
  const targetMode = currentSeries.type === 'bar' &&
261
271
  resolvedBarAnchorPosition === 'auto'
262
272
  ? 'auto'
package/dist/tooltip.d.ts CHANGED
@@ -11,6 +11,8 @@ export declare class Tooltip implements ChartComponent<TooltipConfigBase> {
11
11
  readonly position: TooltipPosition;
12
12
  readonly barAnchorPosition: TooltipBarAnchorPosition;
13
13
  readonly colorMode: TooltipColorMode;
14
+ readonly seriesBorderColor?: string;
15
+ readonly seriesConnectorColor?: string;
14
16
  readonly maxWidth: number;
15
17
  readonly transition: Required<TooltipTransitionConfig>;
16
18
  readonly formatter?: (dataKey: string, value: DataValue, data: DataItem) => string;
package/dist/tooltip.js CHANGED
@@ -40,6 +40,18 @@ export class Tooltip {
40
40
  writable: true,
41
41
  value: void 0
42
42
  });
43
+ Object.defineProperty(this, "seriesBorderColor", {
44
+ enumerable: true,
45
+ configurable: true,
46
+ writable: true,
47
+ value: void 0
48
+ });
49
+ Object.defineProperty(this, "seriesConnectorColor", {
50
+ enumerable: true,
51
+ configurable: true,
52
+ writable: true,
53
+ value: void 0
54
+ });
43
55
  Object.defineProperty(this, "maxWidth", {
44
56
  enumerable: true,
45
57
  configurable: true,
@@ -88,13 +100,15 @@ export class Tooltip {
88
100
  writable: true,
89
101
  value: null
90
102
  });
91
- const { mode = 'split', position = 'auto', barAnchorPosition = 'auto', colorMode = 'theme', maxWidth, transition, formatter, labelFormatter, customFormatter, exportHooks, } = config;
103
+ const { mode = 'split', position = 'auto', barAnchorPosition = 'auto', colorMode = 'theme', seriesBorderColor, seriesConnectorColor, maxWidth, transition, formatter, labelFormatter, customFormatter, exportHooks, } = config;
92
104
  const tooltipId = Tooltip.nextTooltipId++;
93
105
  this.id = `iisChartTooltip-${tooltipId}`;
94
106
  this.mode = mode;
95
107
  this.position = position;
96
108
  this.barAnchorPosition = barAnchorPosition;
97
109
  this.colorMode = colorMode;
110
+ this.seriesBorderColor = seriesBorderColor;
111
+ this.seriesConnectorColor = seriesConnectorColor;
98
112
  this.maxWidth =
99
113
  maxWidth !== undefined && Number.isFinite(maxWidth) && maxWidth > 0
100
114
  ? maxWidth
@@ -120,6 +134,8 @@ export class Tooltip {
120
134
  position: this.position,
121
135
  barAnchorPosition: this.barAnchorPosition,
122
136
  colorMode: this.colorMode,
137
+ seriesBorderColor: this.seriesBorderColor,
138
+ seriesConnectorColor: this.seriesConnectorColor,
123
139
  maxWidth: this.maxWidth,
124
140
  transition: this.transition,
125
141
  formatter: this.formatter,
@@ -162,6 +178,8 @@ export class Tooltip {
162
178
  position: this.position,
163
179
  barAnchorPosition: this.barAnchorPosition,
164
180
  colorMode: this.colorMode,
181
+ seriesBorderColor: this.seriesBorderColor,
182
+ seriesConnectorColor: this.seriesConnectorColor,
165
183
  formatter: this.formatter,
166
184
  labelFormatter: this.labelFormatter,
167
185
  customFormatter: this.customFormatter,
package/dist/types.d.ts CHANGED
@@ -74,6 +74,8 @@ export type ChartTheme = {
74
74
  tooltip: {
75
75
  background: string;
76
76
  border: string;
77
+ seriesBorderColor: string;
78
+ seriesConnectorColor?: string;
77
79
  color: string;
78
80
  fontFamily: string;
79
81
  fontSize: number;
@@ -313,6 +315,8 @@ export type TooltipConfigBase = {
313
315
  position?: TooltipPosition;
314
316
  barAnchorPosition?: TooltipBarAnchorPosition;
315
317
  colorMode?: TooltipColorMode;
318
+ seriesBorderColor?: string;
319
+ seriesConnectorColor?: string;
316
320
  maxWidth?: number;
317
321
  transition?: TooltipTransitionConfig;
318
322
  formatter?: SeriesValueFormatter;
@@ -129,6 +129,8 @@ new Tooltip({
129
129
  position?: 'auto' | 'side' | 'vertical',
130
130
  barAnchorPosition?: 'auto' | 'top' | 'middle',
131
131
  colorMode?: 'theme' | 'series',
132
+ seriesBorderColor?: string,
133
+ seriesConnectorColor?: string,
132
134
  maxWidth?: number, // default: 280
133
135
  transition?: {
134
136
  show?: boolean,
@@ -159,6 +161,11 @@ Use `position: 'auto' | 'side' | 'vertical'` for XY tooltip placement.
159
161
  `auto` is the default. It uses above/below placement for horizontal XY charts
160
162
  and side placement elsewhere. For bar tooltips, `barAnchorPosition` defaults to
161
163
  `auto`, which aims arrows inside the visible bar segment when possible.
164
+ Vertical bar split tooltips aim at each bar's top edge and use series/legend
165
+ order to keep equal or near-equal placements stable. Very short connector
166
+ paths are omitted when the arrow is already close to its target. For line,
167
+ area, and scatter points, side split tooltips spread nearby point targets
168
+ within the connectorless arrow range before drawing connector paths.
162
169
 
163
170
  For horizontal bar charts, prefer `position: 'auto'` or `position: 'vertical'`.
164
171
  `position: 'side'` and `barAnchorPosition: 'top' | 'middle'` are kept for
@@ -167,7 +174,13 @@ legacy configs, but horizontal bars resolve bar anchoring automatically.
167
174
  Tooltips default to a `280px` max width. Set `transition.show: true` to fade and
168
175
  slide tooltips between hovered positions. Tooltip colors use `theme.tooltip` by
169
176
  default. Set `colorMode: 'series'` to color-code XY tooltips from the series
170
- color, with matching background and border colors plus automatic contrast text.
177
+ color, using series-colored backgrounds plus automatic contrast text.
178
+ Set `theme.tooltip.seriesBorderColor` to keep series-colored backgrounds while
179
+ using one custom box and arrow border color. It defaults to `'#ffffff'`.
180
+ Connector lines use the series color by default; set
181
+ `theme.tooltip.seriesConnectorColor` when they need a custom color. Tooltip
182
+ `seriesBorderColor` and `seriesConnectorColor` options override the theme for a
183
+ single tooltip component.
171
184
 
172
185
  `defaultResponsiveConfig` switches tooltip components to `mode: 'shared'` at
173
186
  the `sm` breakpoint so compact XY charts use one grouped tooltip by default.
package/docs/theming.md CHANGED
@@ -30,6 +30,8 @@ const chart = new XYChart({
30
30
  tooltip: {
31
31
  background: '#102030',
32
32
  border: '#405060',
33
+ seriesBorderColor: '#ffffff',
34
+ seriesConnectorColor: '#405060',
33
35
  color: '#f7fafc',
34
36
  fontFamily: 'Inter, sans-serif',
35
37
  fontSize: 13,
@@ -66,6 +68,8 @@ through the `themes` map as `themes.default`, `themes.ruby`,
66
68
  | `legend.itemSpacingY` | `number` | `8` | Vertical spacing between wrapped legend rows |
67
69
  | `tooltip.background` | `string` | `'#ffffff'` | Tooltip box and arrow fill color |
68
70
  | `tooltip.border` | `string` | `'#dddddd'` | Tooltip box, connector, and arrow border |
71
+ | `tooltip.seriesBorderColor` | `string` | `'#ffffff'` | Box and arrow border color for series-colored tooltips |
72
+ | `tooltip.seriesConnectorColor` | `string` | Series color | Connector line color for series-colored tooltips |
69
73
  | `tooltip.color` | `string` | `'#1f2a36'` | Tooltip text color |
70
74
  | `tooltip.fontFamily` | `string` | - | Tooltip text font |
71
75
  | `tooltip.fontSize` | `number` | `12` | Tooltip text size in pixels |
package/package.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.18.0",
2
+ "version": "0.18.2",
3
3
  "name": "@internetstiftelsen/charts",
4
4
  "type": "module",
5
5
  "sideEffects": false,
@@ -33,12 +33,14 @@
33
33
  "format": "prettier --write ./src",
34
34
  "preview": "vite preview",
35
35
  "preview:docs": "vite preview --config vite.docs.config.ts",
36
- "test": "vitest",
37
- "test:run": "vitest run",
36
+ "test": "vitest --project unit",
37
+ "test:run": "vitest run --project unit",
38
38
  "verify": "pnpm lint && pnpm test:run && pnpm build",
39
39
  "build:lib": "npm run build",
40
40
  "prepublishOnly": "pnpm verify",
41
- "pub": "npm publish --access public"
41
+ "pub": "npm publish --access public",
42
+ "storybook": "storybook dev -p 6006",
43
+ "build-storybook": "storybook build"
42
44
  },
43
45
  "dependencies": {
44
46
  "d3": "^7.9.0",
@@ -48,6 +50,7 @@
48
50
  "write-excel-file": "^4.1.1"
49
51
  },
50
52
  "devDependencies": {
53
+ "@chromatic-com/storybook": "^5.2.1",
51
54
  "@eslint/js": "^10.0.1",
52
55
  "@handsontable/react-wrapper": "^17.1.0",
53
56
  "@internetstiftelsen/styleguide": "^5.1.27",
@@ -56,6 +59,11 @@
56
59
  "@radix-ui/react-switch": "^1.3.0",
57
60
  "@radix-ui/react-tabs": "^1.1.14",
58
61
  "@speed-highlight/core": "^1.2.17",
62
+ "@storybook/addon-a11y": "^10.4.6",
63
+ "@storybook/addon-docs": "^10.4.6",
64
+ "@storybook/addon-mcp": "^0.6.0",
65
+ "@storybook/addon-vitest": "^10.4.6",
66
+ "@storybook/react-vite": "^10.4.6",
59
67
  "@tailwindcss/vite": "^4.3.1",
60
68
  "@testing-library/dom": "^10.4.1",
61
69
  "@testing-library/jest-dom": "^6.9.1",
@@ -65,21 +73,26 @@
65
73
  "@types/node": "^25.9.3",
66
74
  "@types/react": "^19.2.17",
67
75
  "@types/react-dom": "^19.2.3",
76
+ "@vitest/browser-playwright": "4.1.8",
77
+ "@vitest/coverage-v8": "4.1.8",
68
78
  "@vitejs/plugin-react-swc": "^4.3.1",
69
79
  "class-variance-authority": "^0.7.1",
70
80
  "clsx": "^2.1.1",
71
81
  "eslint": "^10.5.0",
72
82
  "eslint-plugin-react-hooks": "^7.1.1",
73
83
  "eslint-plugin-react-refresh": "^0.5.2",
84
+ "eslint-plugin-storybook": "^10.4.6",
74
85
  "globals": "^17.6.0",
75
86
  "handsontable": "^17.1.0",
76
87
  "jsdom": "^29.1.1",
77
88
  "lucide-react": "^1.18.0",
89
+ "playwright": "^1.61.1",
78
90
  "prettier": "3.8.4",
79
91
  "radix-ui": "^1.5.0",
80
92
  "react": "^19.2.7",
81
93
  "react-dom": "^19.2.7",
82
94
  "sass": "^1.101.0",
95
+ "storybook": "^10.4.6",
83
96
  "tailwind-merge": "^3.6.0",
84
97
  "tailwindcss": "^4.3.1",
85
98
  "tsc-alias": "^1.8.17",