@internetstiftelsen/charts 0.18.0 → 0.18.1

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,4 +1,4 @@
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
4
  export function getSplitTooltipViewportBounds() {
@@ -175,7 +175,7 @@ export function resolveSplitTooltipPositions(layouts, position, bounds = getSpli
175
175
  return;
176
176
  }
177
177
  const arrowEdge = resolveSidePlacementArrowEdge(getCombinedSplitTooltipAnchor(layouts), Math.max(...layouts.map((layout) => layout.width)), bounds);
178
- const orderedLayouts = getVerticallyOrderedSplitTooltipLayouts(layouts);
178
+ const orderedLayouts = getSideSplitTooltipLayouts(layouts);
179
179
  positionSideSplitTooltipStack(orderedLayouts, arrowEdge, bounds);
180
180
  }
181
181
  function resolveTooltipBoxArrowPosition(arrowEdge, tooltipLeft, tooltipTop, tooltipWidth, tooltipHeight, targetX, targetY) {
@@ -212,7 +212,7 @@ function getTooltipConnectorOffset(start, size, target) {
212
212
  }
213
213
  function resolveTooltipConnectorPath(arrowEdge, startX, startY, endX, endY, arrowTipX, arrowTipY, anchor) {
214
214
  if (arrowEdge === 'left' || arrowEdge === 'right') {
215
- if (isTooltipArrowTipInsideVerticalAnchorSpan(arrowTipY, anchor)) {
215
+ if (isTooltipArrowTipNearVerticalAnchorSpan(arrowTipY, anchor)) {
216
216
  return '';
217
217
  }
218
218
  const elbowX = startX + (endX - startX) * TOOLTIP_CONNECTOR_ELBOW_RATIO;
@@ -232,6 +232,13 @@ function isTooltipArrowTipInsideVerticalAnchorSpan(arrowTipY, anchor) {
232
232
  return (arrowTipY >= anchor.top - TOOLTIP_CONNECTOR_ALIGNMENT_TOLERANCE_PX &&
233
233
  arrowTipY <= anchor.bottom + TOOLTIP_CONNECTOR_ALIGNMENT_TOLERANCE_PX);
234
234
  }
235
+ function isTooltipArrowTipNearVerticalAnchorSpan(arrowTipY, anchor) {
236
+ if (isTooltipArrowTipInsideVerticalAnchorSpan(arrowTipY, anchor)) {
237
+ return true;
238
+ }
239
+ return (arrowTipY >= anchor.top - TOOLTIP_CONNECTOR_MIN_PATH_LENGTH_PX &&
240
+ arrowTipY <= anchor.bottom + TOOLTIP_CONNECTOR_MIN_PATH_LENGTH_PX);
241
+ }
235
242
  function resolveTooltipArrowTip(arrowEdge, boxX, boxY, length) {
236
243
  if (arrowEdge === 'left' || arrowEdge === 'right') {
237
244
  return {
@@ -278,6 +285,14 @@ function getCombinedSplitTooltipTarget(layouts) {
278
285
  y: (minY + maxY) / 2,
279
286
  };
280
287
  }
288
+ function getSideSplitTooltipLayouts(layouts) {
289
+ if (layouts.every((layout) => layout.targetMode === 'auto')) {
290
+ return [...layouts].sort((a, b) => a.anchor.top - b.anchor.top ||
291
+ a.anchor.left - b.anchor.left ||
292
+ a.order - b.order);
293
+ }
294
+ return getVerticallyOrderedSplitTooltipLayouts(layouts);
295
+ }
281
296
  function resolveHorizontalSideSplitTooltipPositions(layouts, bounds) {
282
297
  const rowPlacement = getBestHorizontalSideRowPlacement(getHorizontallyOrderedSplitTooltipLayouts(layouts), bounds);
283
298
  rowPlacement.placements.forEach(({ layout, left, top, arrowEdge }) => {
@@ -424,6 +439,7 @@ function getHorizontalAboveBelowRowPlacement(layouts, assignment, preferredEdges
424
439
  const rowLayouts = layouts.filter((_, index) => assignment[index] === arrowEdge);
425
440
  return packHorizontalAboveBelowRow(rowLayouts, arrowEdge, bounds);
426
441
  });
442
+ separateOverlappingHorizontalTooltipRows(placements, bounds);
427
443
  const preferredEdgeByLayout = new Map(layouts.map((layout, index) => [layout, preferredEdges[index]]));
428
444
  const score = placements.reduce((sum, placement) => {
429
445
  const preferredEdge = preferredEdgeByLayout.get(placement.layout) ??
@@ -493,6 +509,59 @@ function packHorizontalTooltipRow(placements, bounds) {
493
509
  }
494
510
  return placements;
495
511
  }
512
+ function separateOverlappingHorizontalTooltipRows(placements, bounds) {
513
+ if (placements.length < 2) {
514
+ return;
515
+ }
516
+ const orderedPlacements = [...placements].sort((a, b) => a.top - b.top ||
517
+ a.left - b.left ||
518
+ a.layout.targetX - b.layout.targetX ||
519
+ a.layout.order - b.layout.order);
520
+ for (let index = 0; index < orderedPlacements.length; index++) {
521
+ const placement = orderedPlacements[index];
522
+ for (let previousIndex = 0; previousIndex < index; previousIndex++) {
523
+ const previousPlacement = orderedPlacements[previousIndex];
524
+ if (!doHorizontalTooltipPlacementsOverlap(placement, previousPlacement)) {
525
+ continue;
526
+ }
527
+ placement.top = Math.max(placement.top, previousPlacement.top +
528
+ previousPlacement.layout.height +
529
+ SPLIT_TOOLTIP_GAP_PX);
530
+ }
531
+ }
532
+ const overflow = Math.max(...orderedPlacements.map((placement) => placement.top + placement.layout.height)) - bounds.maxBottom;
533
+ if (overflow > 0) {
534
+ orderedPlacements.forEach((placement) => {
535
+ placement.top -= overflow;
536
+ });
537
+ }
538
+ for (let index = orderedPlacements.length - 2; index >= 0; index--) {
539
+ const placement = orderedPlacements[index];
540
+ for (let nextIndex = orderedPlacements.length - 1; nextIndex > index; nextIndex--) {
541
+ const nextPlacement = orderedPlacements[nextIndex];
542
+ if (!doHorizontalTooltipPlacementsOverlap(placement, nextPlacement)) {
543
+ continue;
544
+ }
545
+ placement.top = Math.min(placement.top, nextPlacement.top -
546
+ placement.layout.height -
547
+ SPLIT_TOOLTIP_GAP_PX);
548
+ }
549
+ }
550
+ const underflow = bounds.minTop -
551
+ Math.min(...orderedPlacements.map((placement) => placement.top));
552
+ if (underflow <= 0) {
553
+ return;
554
+ }
555
+ orderedPlacements.forEach((placement) => {
556
+ placement.top += underflow;
557
+ });
558
+ }
559
+ function doHorizontalTooltipPlacementsOverlap(first, second) {
560
+ return (first.left < second.left + second.layout.width + SPLIT_TOOLTIP_GAP_PX &&
561
+ first.left + first.layout.width + SPLIT_TOOLTIP_GAP_PX > second.left &&
562
+ first.top < second.top + second.layout.height + SPLIT_TOOLTIP_GAP_PX &&
563
+ first.top + first.layout.height + SPLIT_TOOLTIP_GAP_PX > second.top);
564
+ }
496
565
  function getHorizontalAboveBelowIdealLeft(layout) {
497
566
  const centeredLeft = layout.targetX - layout.width / 2;
498
567
  if (layout.targetMode === 'auto') {
@@ -513,8 +582,13 @@ function getHorizontalAboveBelowPlacementCost(candidate, layout, preferredEdge,
513
582
  ? 0
514
583
  : layout.height * 4;
515
584
  const xDistance = Math.abs(layout.targetX - (candidate.left + layout.width / 2));
585
+ const yDistance = Math.abs(candidate.top - getHorizontalAboveBelowTop(layout, candidate.arrowEdge));
516
586
  const overflowPenalty = getSplitTooltipBoxPlacementOverflow(candidate, layout, bounds) * 20;
517
- return connectorPenalty + edgePenalty + xDistance + overflowPenalty;
587
+ return (connectorPenalty +
588
+ edgePenalty +
589
+ xDistance +
590
+ yDistance * 0.5 +
591
+ overflowPenalty);
518
592
  }
519
593
  function getPreferredAndOppositeEdges(preferredEdge) {
520
594
  if (preferredEdge === 'bottom') {
@@ -544,28 +618,14 @@ function getSplitTooltipBoxPlacementOverflow(candidate, layout, bounds) {
544
618
  Math.max(0, candidate.top + layout.height - bounds.maxBottom));
545
619
  }
546
620
  function positionSideSplitTooltipStack(layouts, arrowEdge, bounds) {
547
- const placementOrder = getSidePlacementOrder(layouts);
548
621
  const placedLayouts = [];
549
- placementOrder.forEach((layout) => {
622
+ layouts.forEach((layout) => {
550
623
  layout.arrowEdge = arrowEdge;
551
624
  layout.left = getSideTooltipLeft(layout, arrowEdge, bounds);
552
625
  positionSideSplitTooltip(layout, placedLayouts, layouts, bounds);
553
626
  placedLayouts.push(layout);
554
627
  });
555
628
  }
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
629
  function getSideTooltipLeft(layout, arrowEdge, bounds) {
570
630
  const preferredLeft = arrowEdge === 'left'
571
631
  ? layout.anchor.right + TOOLTIP_OFFSET_PX
@@ -630,53 +690,64 @@ function getSidePlacementCandidates(layout, placedLayouts, allLayouts, bounds) {
630
690
  getSidePlacementCost(b, layout, allLayouts));
631
691
  }
632
692
  function getSidePlacementCost(candidate, layout, allLayouts) {
633
- const connectorPenalty = isSidePlacementConnectorless(candidate, layout)
634
- ? 0
635
- : layout.height * 4;
693
+ const connectorless = isSidePlacementConnectorless(candidate, layout);
636
694
  const centerDistance = Math.abs(layout.targetY - (candidate.top + layout.height / 2));
637
- if (layout.targetMode === 'auto' && connectorPenalty === 0) {
638
- const spreadDirection = getAutoSideSpreadDirection(layout, allLayouts);
695
+ const arrowDistance = Math.abs(layout.targetY - getSidePlacementArrowTipY(candidate.top, layout));
696
+ if (layout.targetMode === 'auto' && connectorless) {
697
+ const preferredTop = layout.targetY - getTooltipConnectorMaxOffset(layout.height);
698
+ const preferredTopDistance = Math.abs(candidate.top - preferredTop);
699
+ return preferredTopDistance + arrowDistance * 0.1;
700
+ }
701
+ if (connectorless) {
702
+ const spreadDirection = getFixedPointSideSpreadDirection(layout, allLayouts);
639
703
  if (spreadDirection !== 0) {
640
- const connectorlessRange = getSideConnectorlessTopRange(layout);
641
- const preferredTop = spreadDirection > 0
642
- ? connectorlessRange.max
643
- : connectorlessRange.min;
704
+ const preferredTop = getFixedPointSideSpreadPreferredTop(layout, spreadDirection);
644
705
  return (Math.abs(candidate.top - preferredTop) + centerDistance * 0.05);
645
706
  }
707
+ return centerDistance;
646
708
  }
647
- return connectorPenalty + centerDistance;
709
+ return layout.height * 4 + arrowDistance + centerDistance * 0.2;
648
710
  }
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) {
711
+ function getFixedPointSideSpreadDirection(layout, allLayouts) {
712
+ if (!isFixedPointSideTooltipLayout(layout)) {
659
713
  return 0;
660
714
  }
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;
715
+ const nearbyLayouts = allLayouts
716
+ .filter((otherLayout) => isFixedPointSideTooltipLayout(otherLayout) &&
717
+ areFixedPointSideTargetsNear(layout, otherLayout))
718
+ .sort((a, b) => a.targetY - b.targetY ||
719
+ a.targetX - b.targetX ||
720
+ a.order - b.order);
721
+ if (nearbyLayouts.length < 2) {
722
+ return 0;
665
723
  }
666
- if (belowDistance < aboveDistance) {
724
+ const layoutIndex = nearbyLayouts.indexOf(layout);
725
+ const clusterCenterIndex = (nearbyLayouts.length - 1) / 2;
726
+ if (layoutIndex < clusterCenterIndex) {
667
727
  return -1;
668
728
  }
729
+ if (layoutIndex > clusterCenterIndex) {
730
+ return 1;
731
+ }
669
732
  return 0;
670
733
  }
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];
734
+ function isFixedPointSideTooltipLayout(layout) {
735
+ return (layout.targetMode === 'fixed' &&
736
+ Math.abs(layout.anchor.left - layout.anchor.right) <=
737
+ TOOLTIP_CONNECTOR_ALIGNMENT_TOLERANCE_PX &&
738
+ Math.abs(layout.anchor.top - layout.anchor.bottom) <=
739
+ TOOLTIP_CONNECTOR_ALIGNMENT_TOLERANCE_PX);
740
+ }
741
+ function areFixedPointSideTargetsNear(layout, otherLayout) {
742
+ const centeredTooltipsOverlapDistance = (layout.height + otherLayout.height) / 2 + SPLIT_TOOLTIP_GAP_PX;
743
+ return (Math.abs(layout.targetY - otherLayout.targetY) <
744
+ centeredTooltipsOverlapDistance);
745
+ }
746
+ function getFixedPointSideSpreadPreferredTop(layout, direction) {
747
+ if (direction < 0) {
748
+ return layout.targetY - getTooltipConnectorMaxOffset(layout.height);
749
+ }
750
+ return layout.targetY - TOOLTIP_CONNECTOR_INSET_PX;
680
751
  }
681
752
  function getSideConnectorlessTopRange(layout) {
682
753
  return {
@@ -7,6 +7,7 @@ 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
9
  export declare const TOOLTIP_CONNECTOR_INSET_PX = 14;
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;
@@ -1,6 +1,7 @@
1
1
  export const TOOLTIP_OFFSET_PX = 12;
2
2
  export const TOOLTIP_VIEWPORT_PADDING_PX = 10;
3
3
  export const TOOLTIP_CONNECTOR_INSET_PX = 14;
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;
@@ -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.1",
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",