@internetstiftelsen/charts 0.13.1 → 0.13.3

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
@@ -13,7 +13,8 @@ A framework-agnostic, composable charting library built on D3.js with TypeScript
13
13
  - **Custom Value Labels** - XY, pie, and donut charts support optional on-chart labels with custom formatters
14
14
  - **Optional XY Animation** - Animate XY series on first render and `chart.update(...)` with `animate`
15
15
  - **Optional Gauge Animation** - Animate gauge value transitions with `gauge.animate`
16
- - **Stacking Control** - Bar stacking modes with optional reversed visual series order
16
+ - **Stacking Control** - Bar and area stacking modes with optional reversed visual series order
17
+ - **Configurable Tooltips** - Shared or split tooltips with connectors, transitions, and default max-width wrapping
17
18
  - **Axis Direction Control** - Use `scales.x.reverse` / `scales.y.reverse` to flip an axis when needed
18
19
  - **Flexible Scales** - Band, linear, time, and logarithmic scales (bar value axes stay linear)
19
20
  - **Explicit or Responsive Sizing** - Set top-level `width`/`height` or let the container drive size
package/dist/tooltip.d.ts CHANGED
@@ -14,6 +14,7 @@ export declare class Tooltip implements ChartComponent<TooltipConfigBase> {
14
14
  readonly mode: TooltipMode;
15
15
  readonly position: TooltipPosition;
16
16
  readonly barAnchorPosition: TooltipBarAnchorPosition;
17
+ readonly maxWidth: number;
17
18
  readonly transition: Required<TooltipTransitionConfig>;
18
19
  readonly formatter?: (dataKey: string, value: DataValue, data: DataItem) => string;
19
20
  readonly labelFormatter?: (label: string, data: DataItem) => string;
@@ -68,6 +69,8 @@ export declare class Tooltip implements ChartComponent<TooltipConfigBase> {
68
69
  private resolveTooltipConnectorLayout;
69
70
  private resolveTooltipBoxArrowPosition;
70
71
  private resolveTooltipConnectorPath;
72
+ private isTooltipArrowTipInsideHorizontalAnchorSpan;
73
+ private isTooltipArrowTipInsideVerticalAnchorSpan;
71
74
  private resolveTooltipArrowTip;
72
75
  private hasFiniteNumbers;
73
76
  private resolveSplitTooltipCollisions;
@@ -75,7 +78,28 @@ export declare class Tooltip implements ChartComponent<TooltipConfigBase> {
75
78
  private getOppositeVerticalArrowEdge;
76
79
  private flipTooltipIfItReducesCollisions;
77
80
  private countSplitTooltipCollisions;
81
+ private countPlacedLayoutsOnEdge;
78
82
  private doSplitTooltipLayoutsOverlap;
79
83
  private resolveSplitTooltipPositions;
84
+ private resolveHorizontalChartSplitTooltipPositions;
85
+ private resolveVerticalChartSplitTooltipPositions;
86
+ private getSplitTooltipViewportBounds;
87
+ private groupSplitTooltipLayoutsByEdge;
88
+ private resolveSideSplitTooltipPositions;
89
+ private resolveHorizontalSideSplitTooltipPositions;
90
+ private resolveHorizontalSideSplitTooltipCollisions;
91
+ private flipHorizontalSideTooltipIfItReducesCollisions;
92
+ private findReusableHorizontalTooltipLane;
93
+ private resolveNewHorizontalTooltipLaneTop;
94
+ private getHorizontalTooltipLaneTopCandidates;
95
+ private assignLayoutToHorizontalTooltipLane;
96
+ private doHorizontalTooltipLanesOverlap;
97
+ private doSplitTooltipLayoutsOverlapHorizontally;
98
+ private resolveHorizontalChartAboveBelowSplitTooltipPositions;
99
+ private resolvePackedAboveBelowTooltipRow;
100
+ private resolveVerticalChartAboveBelowSplitTooltipPositions;
101
+ private resolveCollisionAwareAboveBelowTooltipPositions;
102
+ private resolveNonOverlappingAboveBelowTooltipLeft;
103
+ private doesSplitTooltipOverlapPlacedLayouts;
80
104
  }
81
105
  export {};
package/dist/tooltip.js CHANGED
@@ -16,6 +16,7 @@ const TOOLTIP_ARROW_FILL_Z_INDEX = 5;
16
16
  const TOOLTIP_BODY_Z_INDEX = 6;
17
17
  const TOOLTIP_TOTAL_BORDER_WIDTH_PX = TOOLTIP_BORDER_WIDTH_PX * 2;
18
18
  const SPLIT_TOOLTIP_GAP_PX = 8;
19
+ const DEFAULT_TOOLTIP_MAX_WIDTH_PX = 280;
19
20
  const DEFAULT_TOOLTIP_TRANSITION = {
20
21
  show: false,
21
22
  duration: 120,
@@ -55,6 +56,12 @@ export class Tooltip {
55
56
  writable: true,
56
57
  value: void 0
57
58
  });
59
+ Object.defineProperty(this, "maxWidth", {
60
+ enumerable: true,
61
+ configurable: true,
62
+ writable: true,
63
+ value: void 0
64
+ });
58
65
  Object.defineProperty(this, "transition", {
59
66
  enumerable: true,
60
67
  configurable: true,
@@ -109,13 +116,17 @@ export class Tooltip {
109
116
  writable: true,
110
117
  value: null
111
118
  });
112
- const { mode = 'split', position = 'side', barAnchorPosition = 'middle', transition, formatter, labelFormatter, customFormatter, exportHooks, } = config;
119
+ const { mode = 'split', position = 'side', barAnchorPosition = 'middle', maxWidth, transition, formatter, labelFormatter, customFormatter, exportHooks, } = config;
113
120
  const tooltipId = Tooltip.nextTooltipId++;
114
121
  this.id = `iisChartTooltip-${tooltipId}`;
115
122
  this.splitTooltipOwner = `${this.id}-split`;
116
123
  this.mode = mode;
117
124
  this.position = position;
118
125
  this.barAnchorPosition = barAnchorPosition;
126
+ this.maxWidth =
127
+ maxWidth !== undefined && Number.isFinite(maxWidth) && maxWidth > 0
128
+ ? maxWidth
129
+ : DEFAULT_TOOLTIP_MAX_WIDTH_PX;
119
130
  this.transition = {
120
131
  ...DEFAULT_TOOLTIP_TRANSITION,
121
132
  ...transition,
@@ -130,6 +141,7 @@ export class Tooltip {
130
141
  mode: this.mode,
131
142
  position: this.position,
132
143
  barAnchorPosition: this.barAnchorPosition,
144
+ maxWidth: this.maxWidth,
133
145
  transition: this.transition,
134
146
  formatter: this.formatter,
135
147
  labelFormatter: this.labelFormatter,
@@ -454,11 +466,9 @@ export class Tooltip {
454
466
  if (layouts.length === 0) {
455
467
  return;
456
468
  }
457
- this.resolveSplitTooltipCollisions(layouts, 'vertical', this.getOppositeVerticalArrowEdge);
458
- this.resolveSplitTooltipCollisions(layouts, 'side', this.getOppositeSideArrowEdge);
459
- this.resolveSplitTooltipPositions(layouts);
469
+ this.resolveSplitTooltipPositions(layouts, isHorizontal);
460
470
  layouts.forEach((layout) => {
461
- this.renderTooltipWithConnector(layout.div, layout.arrowEdge, layout.left, layout.top, layout.width, layout.height, layout.targetX, layout.targetY);
471
+ this.renderTooltipWithConnector(layout.div, layout.arrowEdge, layout.left, layout.top, layout.width, layout.height, layout.targetX, layout.targetY, layout.anchor);
462
472
  });
463
473
  };
464
474
  const hideTooltip = () => {
@@ -706,6 +716,7 @@ export class Tooltip {
706
716
  theme.tooltip.fontFamily,
707
717
  theme.tooltip.fontSize,
708
718
  theme.tooltip.fontWeight,
719
+ this.maxWidth,
709
720
  this.transition.show,
710
721
  this.transition.duration,
711
722
  this.transition.easing,
@@ -724,10 +735,13 @@ export class Tooltip {
724
735
  .style('font-family', theme.tooltip.fontFamily)
725
736
  .style('font-size', `${theme.tooltip.fontSize}px`)
726
737
  .style('font-weight', theme.tooltip.fontWeight)
738
+ .style('box-sizing', 'border-box')
739
+ .style('overflow-wrap', 'break-word')
727
740
  .style('overflow', 'visible')
728
741
  .style('isolation', 'isolate')
729
742
  .style('pointer-events', 'none')
730
743
  .style('z-index', '1000');
744
+ tooltip.style('max-width', `${this.maxWidth}px`);
731
745
  if (this.transition.show) {
732
746
  tooltip
733
747
  .style('transition', `opacity ${this.transition.duration}ms ${this.transition.easing}, transform ${this.transition.duration}ms ${this.transition.easing}`)
@@ -758,7 +772,7 @@ export class Tooltip {
758
772
  height: tooltipRect.height,
759
773
  };
760
774
  }
761
- renderTooltipWithConnector(tooltip, arrowEdge, left, top, tooltipWidth, tooltipHeight, targetX, targetY) {
775
+ renderTooltipWithConnector(tooltip, arrowEdge, left, top, tooltipWidth, tooltipHeight, targetX, targetY, anchor) {
762
776
  if (!Number.isFinite(left) ||
763
777
  !Number.isFinite(top) ||
764
778
  !Number.isFinite(targetX) ||
@@ -766,7 +780,7 @@ export class Tooltip {
766
780
  this.hideTooltipSelection(tooltip);
767
781
  return;
768
782
  }
769
- const connectorLayout = this.resolveTooltipConnectorLayout(arrowEdge, left, top, tooltipWidth, tooltipHeight, targetX, targetY);
783
+ const connectorLayout = this.resolveTooltipConnectorLayout(arrowEdge, left, top, tooltipWidth, tooltipHeight, targetX, targetY, anchor);
770
784
  if (!connectorLayout) {
771
785
  this.hideTooltipSelection(tooltip);
772
786
  return;
@@ -1100,7 +1114,7 @@ export class Tooltip {
1100
1114
  top: Math.max(minTop, Math.min(top, maxTop)),
1101
1115
  };
1102
1116
  }
1103
- resolveTooltipConnectorLayout(arrowEdge, tooltipLeft, tooltipTop, tooltipWidth, tooltipHeight, targetX, targetY) {
1117
+ resolveTooltipConnectorLayout(arrowEdge, tooltipLeft, tooltipTop, tooltipWidth, tooltipHeight, targetX, targetY, anchor) {
1104
1118
  const localTargetX = targetX - tooltipLeft;
1105
1119
  const localTargetY = targetY - tooltipTop;
1106
1120
  if (!Number.isFinite(localTargetX) || !Number.isFinite(localTargetY)) {
@@ -1120,7 +1134,9 @@ export class Tooltip {
1120
1134
  const startY = arrowTip.y - minY;
1121
1135
  const endX = localTargetX - minX;
1122
1136
  const endY = localTargetY - minY;
1123
- const connectorPath = this.resolveTooltipConnectorPath(arrowEdge, startX, startY, endX, endY);
1137
+ const arrowTipX = tooltipLeft + arrowTip.x;
1138
+ const arrowTipY = tooltipTop + arrowTip.y;
1139
+ const connectorPath = this.resolveTooltipConnectorPath(arrowEdge, startX, startY, endX, endY, arrowTipX, arrowTipY, anchor);
1124
1140
  if (!this.hasFiniteNumbers(width, height, boxX, boxY, startX, startY, endX, endY)) {
1125
1141
  return null;
1126
1142
  }
@@ -1163,21 +1179,31 @@ export class Tooltip {
1163
1179
  };
1164
1180
  }
1165
1181
  }
1166
- resolveTooltipConnectorPath(arrowEdge, startX, startY, endX, endY) {
1182
+ resolveTooltipConnectorPath(arrowEdge, startX, startY, endX, endY, arrowTipX, arrowTipY, anchor) {
1167
1183
  if (arrowEdge === 'left' || arrowEdge === 'right') {
1168
- if (Math.abs(endY - startY) <=
1169
- TOOLTIP_CONNECTOR_ALIGNMENT_TOLERANCE_PX) {
1184
+ if (this.isTooltipArrowTipInsideVerticalAnchorSpan(arrowTipY, anchor)) {
1170
1185
  return '';
1171
1186
  }
1172
1187
  const elbowX = startX + (endX - startX) * TOOLTIP_CONNECTOR_ELBOW_RATIO;
1173
1188
  return `M ${startX},${startY} L ${elbowX},${startY} L ${endX},${endY}`;
1174
1189
  }
1175
- if (Math.abs(endX - startX) <= TOOLTIP_CONNECTOR_ALIGNMENT_TOLERANCE_PX) {
1190
+ if (this.isTooltipArrowTipInsideHorizontalAnchorSpan(arrowTipX, anchor)) {
1176
1191
  return '';
1177
1192
  }
1178
1193
  const elbowY = startY + (endY - startY) * TOOLTIP_CONNECTOR_ELBOW_RATIO;
1179
1194
  return `M ${startX},${startY} L ${startX},${elbowY} L ${endX},${endY}`;
1180
1195
  }
1196
+ isTooltipArrowTipInsideHorizontalAnchorSpan(arrowTipX, anchor) {
1197
+ return (arrowTipX >=
1198
+ anchor.left - TOOLTIP_CONNECTOR_ALIGNMENT_TOLERANCE_PX &&
1199
+ arrowTipX <= anchor.right + TOOLTIP_CONNECTOR_ALIGNMENT_TOLERANCE_PX);
1200
+ }
1201
+ isTooltipArrowTipInsideVerticalAnchorSpan(arrowTipY, anchor) {
1202
+ return (arrowTipY >=
1203
+ anchor.top - TOOLTIP_CONNECTOR_ALIGNMENT_TOLERANCE_PX &&
1204
+ arrowTipY <=
1205
+ anchor.bottom + TOOLTIP_CONNECTOR_ALIGNMENT_TOLERANCE_PX);
1206
+ }
1181
1207
  resolveTooltipArrowTip(arrowEdge, boxX, boxY, length) {
1182
1208
  if (arrowEdge === 'left' || arrowEdge === 'right') {
1183
1209
  return {
@@ -1198,7 +1224,12 @@ export class Tooltip {
1198
1224
  return;
1199
1225
  }
1200
1226
  const placedLayouts = [];
1201
- const orderedLayouts = [...layouts].sort((a, b) => a.targetY - b.targetY);
1227
+ const orderedLayouts = [...layouts].sort((a, b) => {
1228
+ if (position === 'vertical') {
1229
+ return a.targetX - b.targetX || a.targetY - b.targetY;
1230
+ }
1231
+ return a.targetY - b.targetY || a.targetX - b.targetX;
1232
+ });
1202
1233
  orderedLayouts.forEach((layout) => {
1203
1234
  this.flipTooltipIfItReducesCollisions(layout, placedLayouts, getOppositeArrowEdge);
1204
1235
  placedLayouts.push(layout);
@@ -1242,7 +1273,12 @@ export class Tooltip {
1242
1273
  top: flippedPosition.top,
1243
1274
  };
1244
1275
  const flippedCollisions = this.countSplitTooltipCollisions(flippedLayout, placedLayouts);
1245
- if (flippedCollisions >= currentCollisions) {
1276
+ if (flippedCollisions > currentCollisions) {
1277
+ return;
1278
+ }
1279
+ if (flippedCollisions === currentCollisions &&
1280
+ this.countPlacedLayoutsOnEdge(placedLayouts, flippedArrowEdge) >=
1281
+ this.countPlacedLayoutsOnEdge(placedLayouts, layout.arrowEdge)) {
1246
1282
  return;
1247
1283
  }
1248
1284
  layout.arrowEdge = flippedArrowEdge;
@@ -1252,15 +1288,53 @@ export class Tooltip {
1252
1288
  countSplitTooltipCollisions(layout, placedLayouts) {
1253
1289
  return placedLayouts.filter((placedLayout) => this.doSplitTooltipLayoutsOverlap(layout, placedLayout)).length;
1254
1290
  }
1291
+ countPlacedLayoutsOnEdge(placedLayouts, arrowEdge) {
1292
+ return placedLayouts.filter((layout) => layout.arrowEdge === arrowEdge)
1293
+ .length;
1294
+ }
1255
1295
  doSplitTooltipLayoutsOverlap(a, b) {
1256
1296
  return (a.left < b.left + b.width + SPLIT_TOOLTIP_GAP_PX &&
1257
1297
  a.left + a.width + SPLIT_TOOLTIP_GAP_PX > b.left &&
1258
1298
  a.top < b.top + b.height + SPLIT_TOOLTIP_GAP_PX &&
1259
1299
  a.top + a.height + SPLIT_TOOLTIP_GAP_PX > b.top);
1260
1300
  }
1261
- resolveSplitTooltipPositions(layouts) {
1262
- const minTop = window.scrollY + TOOLTIP_VIEWPORT_PADDING_PX;
1263
- const maxBottom = window.scrollY + window.innerHeight - TOOLTIP_VIEWPORT_PADDING_PX;
1301
+ resolveSplitTooltipPositions(layouts, isHorizontal) {
1302
+ if (isHorizontal) {
1303
+ this.resolveHorizontalChartSplitTooltipPositions(layouts);
1304
+ return;
1305
+ }
1306
+ this.resolveVerticalChartSplitTooltipPositions(layouts);
1307
+ }
1308
+ resolveHorizontalChartSplitTooltipPositions(layouts) {
1309
+ if (this.position === 'vertical') {
1310
+ this.resolveSplitTooltipCollisions(layouts, 'vertical', this.getOppositeVerticalArrowEdge);
1311
+ this.resolveHorizontalChartAboveBelowSplitTooltipPositions(layouts);
1312
+ return;
1313
+ }
1314
+ this.resolveHorizontalSideSplitTooltipPositions(layouts);
1315
+ }
1316
+ resolveVerticalChartSplitTooltipPositions(layouts) {
1317
+ if (this.position === 'vertical') {
1318
+ this.resolveSplitTooltipCollisions(layouts, 'vertical', this.getOppositeVerticalArrowEdge);
1319
+ this.resolveVerticalChartAboveBelowSplitTooltipPositions(layouts);
1320
+ return;
1321
+ }
1322
+ this.resolveSplitTooltipCollisions(layouts, 'side', this.getOppositeSideArrowEdge);
1323
+ this.resolveSideSplitTooltipPositions(layouts);
1324
+ }
1325
+ getSplitTooltipViewportBounds() {
1326
+ return {
1327
+ minLeft: window.scrollX + TOOLTIP_VIEWPORT_PADDING_PX,
1328
+ maxRight: window.scrollX +
1329
+ window.innerWidth -
1330
+ TOOLTIP_VIEWPORT_PADDING_PX,
1331
+ minTop: window.scrollY + TOOLTIP_VIEWPORT_PADDING_PX,
1332
+ maxBottom: window.scrollY +
1333
+ window.innerHeight -
1334
+ TOOLTIP_VIEWPORT_PADDING_PX,
1335
+ };
1336
+ }
1337
+ groupSplitTooltipLayoutsByEdge(layouts) {
1264
1338
  const tooltipsByEdge = {
1265
1339
  left: [],
1266
1340
  right: [],
@@ -1270,6 +1344,11 @@ export class Tooltip {
1270
1344
  layouts.forEach((layout) => {
1271
1345
  tooltipsByEdge[layout.arrowEdge].push(layout);
1272
1346
  });
1347
+ return tooltipsByEdge;
1348
+ }
1349
+ resolveSideSplitTooltipPositions(layouts) {
1350
+ const { minTop, maxBottom } = this.getSplitTooltipViewportBounds();
1351
+ const tooltipsByEdge = this.groupSplitTooltipLayoutsByEdge(layouts);
1273
1352
  Object.values(tooltipsByEdge).forEach((edgeLayouts) => {
1274
1353
  if (edgeLayouts.length === 0) {
1275
1354
  return;
@@ -1309,6 +1388,197 @@ export class Tooltip {
1309
1388
  });
1310
1389
  });
1311
1390
  }
1391
+ resolveHorizontalSideSplitTooltipPositions(layouts) {
1392
+ this.resolveHorizontalSideSplitTooltipCollisions(layouts);
1393
+ const minTop = window.scrollY + TOOLTIP_VIEWPORT_PADDING_PX;
1394
+ const maxBottom = window.scrollY + window.innerHeight - TOOLTIP_VIEWPORT_PADDING_PX;
1395
+ const lanes = [];
1396
+ const orderedLayouts = [...layouts].sort((a, b) => a.targetX - b.targetX || a.left - b.left);
1397
+ orderedLayouts.forEach((layout) => {
1398
+ const maxTop = maxBottom - layout.height;
1399
+ const preferredTop = Math.max(minTop, Math.min(layout.top, maxTop));
1400
+ const reusableLane = this.findReusableHorizontalTooltipLane(lanes, layout, preferredTop);
1401
+ if (reusableLane) {
1402
+ this.assignLayoutToHorizontalTooltipLane(layout, reusableLane);
1403
+ return;
1404
+ }
1405
+ const nextLane = {
1406
+ top: this.resolveNewHorizontalTooltipLaneTop(preferredTop, layout.height, minTop, maxTop, lanes),
1407
+ layouts: [],
1408
+ };
1409
+ lanes.push(nextLane);
1410
+ this.assignLayoutToHorizontalTooltipLane(layout, nextLane);
1411
+ });
1412
+ }
1413
+ resolveHorizontalSideSplitTooltipCollisions(layouts) {
1414
+ const placedLayouts = [];
1415
+ const orderedLayouts = [...layouts].sort((a, b) => a.targetX - b.targetX || a.left - b.left);
1416
+ orderedLayouts.forEach((layout) => {
1417
+ this.flipHorizontalSideTooltipIfItReducesCollisions(layout, placedLayouts);
1418
+ placedLayouts.push(layout);
1419
+ });
1420
+ }
1421
+ flipHorizontalSideTooltipIfItReducesCollisions(layout, placedLayouts) {
1422
+ const currentCollisions = this.countSplitTooltipCollisions(layout, placedLayouts);
1423
+ if (currentCollisions === 0) {
1424
+ return;
1425
+ }
1426
+ const flippedArrowEdge = this.getOppositeSideArrowEdge(layout.arrowEdge);
1427
+ if (!flippedArrowEdge) {
1428
+ return;
1429
+ }
1430
+ const flippedPosition = this.getAnchoredTooltipPosition(layout.anchor, { x: layout.targetX, y: layout.targetY }, layout.width, layout.height, flippedArrowEdge);
1431
+ if (!flippedPosition) {
1432
+ return;
1433
+ }
1434
+ const flippedLayout = {
1435
+ ...layout,
1436
+ arrowEdge: flippedArrowEdge,
1437
+ left: flippedPosition.left,
1438
+ top: flippedPosition.top,
1439
+ };
1440
+ const flippedCollisions = this.countSplitTooltipCollisions(flippedLayout, placedLayouts);
1441
+ if (flippedCollisions > currentCollisions) {
1442
+ return;
1443
+ }
1444
+ if (flippedCollisions === currentCollisions &&
1445
+ this.countPlacedLayoutsOnEdge(placedLayouts, flippedArrowEdge) >=
1446
+ this.countPlacedLayoutsOnEdge(placedLayouts, layout.arrowEdge)) {
1447
+ return;
1448
+ }
1449
+ layout.arrowEdge = flippedArrowEdge;
1450
+ layout.left = flippedPosition.left;
1451
+ layout.top = flippedPosition.top;
1452
+ }
1453
+ findReusableHorizontalTooltipLane(lanes, layout, preferredTop) {
1454
+ const reusableLanes = lanes
1455
+ .filter((lane) => lane.layouts.every((placedLayout) => !this.doSplitTooltipLayoutsOverlapHorizontally(layout, placedLayout)))
1456
+ .sort((a, b) => Math.abs(a.top - preferredTop) -
1457
+ Math.abs(b.top - preferredTop));
1458
+ return reusableLanes[0] ?? null;
1459
+ }
1460
+ resolveNewHorizontalTooltipLaneTop(preferredTop, tooltipHeight, minTop, maxTop, lanes) {
1461
+ const usedTops = new Set(lanes.map((lane) => Math.round(lane.top)));
1462
+ const candidates = this.getHorizontalTooltipLaneTopCandidates(preferredTop, tooltipHeight, minTop, maxTop);
1463
+ return (candidates.find((candidate) => !usedTops.has(Math.round(candidate)) &&
1464
+ lanes.every((lane) => !this.doHorizontalTooltipLanesOverlap(candidate, tooltipHeight, lane))) ??
1465
+ candidates[0] ??
1466
+ preferredTop);
1467
+ }
1468
+ getHorizontalTooltipLaneTopCandidates(preferredTop, tooltipHeight, minTop, maxTop) {
1469
+ const step = tooltipHeight + SPLIT_TOOLTIP_GAP_PX;
1470
+ const candidates = [preferredTop];
1471
+ for (let index = 1; index <= 8; index++) {
1472
+ candidates.push(preferredTop - step * index);
1473
+ candidates.push(preferredTop + step * index);
1474
+ }
1475
+ return Array.from(new Set(candidates.map((candidate) => Math.round(Math.max(minTop, Math.min(candidate, maxTop)))))).sort((a, b) => Math.abs(a - preferredTop) - Math.abs(b - preferredTop));
1476
+ }
1477
+ assignLayoutToHorizontalTooltipLane(layout, lane) {
1478
+ layout.top = lane.top;
1479
+ lane.layouts.push(layout);
1480
+ }
1481
+ doHorizontalTooltipLanesOverlap(top, height, lane) {
1482
+ const laneHeight = lane.layouts.reduce((maxHeight, layout) => Math.max(maxHeight, layout.height), 0);
1483
+ return (top < lane.top + laneHeight + SPLIT_TOOLTIP_GAP_PX &&
1484
+ top + height + SPLIT_TOOLTIP_GAP_PX > lane.top);
1485
+ }
1486
+ doSplitTooltipLayoutsOverlapHorizontally(a, b) {
1487
+ return (a.left < b.left + b.width + SPLIT_TOOLTIP_GAP_PX &&
1488
+ a.left + a.width + SPLIT_TOOLTIP_GAP_PX > b.left);
1489
+ }
1490
+ resolveHorizontalChartAboveBelowSplitTooltipPositions(layouts) {
1491
+ const bounds = this.getSplitTooltipViewportBounds();
1492
+ const tooltipsByEdge = this.groupSplitTooltipLayoutsByEdge(layouts);
1493
+ this.resolvePackedAboveBelowTooltipRow(tooltipsByEdge.top, bounds);
1494
+ this.resolvePackedAboveBelowTooltipRow(tooltipsByEdge.bottom, bounds);
1495
+ this.resolveSideSplitTooltipPositions([
1496
+ ...tooltipsByEdge.left,
1497
+ ...tooltipsByEdge.right,
1498
+ ]);
1499
+ }
1500
+ resolvePackedAboveBelowTooltipRow(layouts, bounds) {
1501
+ if (layouts.length === 0) {
1502
+ return;
1503
+ }
1504
+ const orderedLayouts = [...layouts].sort((a, b) => a.left - b.left || a.targetX - b.targetX);
1505
+ orderedLayouts.forEach((layout) => {
1506
+ const maxTop = Math.max(bounds.minTop, bounds.maxBottom - layout.height);
1507
+ layout.top = Math.max(bounds.minTop, Math.min(layout.top, maxTop));
1508
+ });
1509
+ const firstLayout = orderedLayouts[0];
1510
+ const firstMaxLeft = Math.max(bounds.minLeft, bounds.maxRight - firstLayout.width);
1511
+ firstLayout.left = Math.max(bounds.minLeft, Math.min(firstLayout.left, firstMaxLeft));
1512
+ for (let i = 1; i < orderedLayouts.length; i++) {
1513
+ const previousLayout = orderedLayouts[i - 1];
1514
+ const currentLayout = orderedLayouts[i];
1515
+ const minAllowedLeft = previousLayout.left +
1516
+ previousLayout.width +
1517
+ SPLIT_TOOLTIP_GAP_PX;
1518
+ currentLayout.left = Math.max(currentLayout.left, minAllowedLeft);
1519
+ }
1520
+ const lastLayout = orderedLayouts[orderedLayouts.length - 1];
1521
+ const overflow = lastLayout.left + lastLayout.width - bounds.maxRight;
1522
+ if (overflow > 0) {
1523
+ lastLayout.left -= overflow;
1524
+ for (let i = orderedLayouts.length - 2; i >= 0; i--) {
1525
+ const currentLayout = orderedLayouts[i];
1526
+ const nextLayout = orderedLayouts[i + 1];
1527
+ const maxAllowedLeft = nextLayout.left -
1528
+ currentLayout.width -
1529
+ SPLIT_TOOLTIP_GAP_PX;
1530
+ currentLayout.left = Math.min(currentLayout.left, maxAllowedLeft);
1531
+ }
1532
+ const underflow = bounds.minLeft - orderedLayouts[0].left;
1533
+ if (underflow > 0) {
1534
+ orderedLayouts.forEach((layout) => {
1535
+ layout.left += underflow;
1536
+ });
1537
+ }
1538
+ }
1539
+ orderedLayouts.forEach((layout) => {
1540
+ const maxLeft = Math.max(bounds.minLeft, bounds.maxRight - layout.width);
1541
+ layout.left = Math.max(bounds.minLeft, Math.min(layout.left, maxLeft));
1542
+ });
1543
+ }
1544
+ resolveVerticalChartAboveBelowSplitTooltipPositions(layouts) {
1545
+ const bounds = this.getSplitTooltipViewportBounds();
1546
+ const tooltipsByEdge = this.groupSplitTooltipLayoutsByEdge(layouts);
1547
+ this.resolveCollisionAwareAboveBelowTooltipPositions(tooltipsByEdge.top, bounds);
1548
+ this.resolveCollisionAwareAboveBelowTooltipPositions(tooltipsByEdge.bottom, bounds);
1549
+ this.resolveSideSplitTooltipPositions([
1550
+ ...tooltipsByEdge.left,
1551
+ ...tooltipsByEdge.right,
1552
+ ]);
1553
+ }
1554
+ resolveCollisionAwareAboveBelowTooltipPositions(layouts, bounds) {
1555
+ const placedLayouts = [];
1556
+ const orderedLayouts = [...layouts].sort((a, b) => a.left - b.left || a.top - b.top);
1557
+ orderedLayouts.forEach((layout) => {
1558
+ const maxLeft = Math.max(bounds.minLeft, bounds.maxRight - layout.width);
1559
+ const maxTop = Math.max(bounds.minTop, bounds.maxBottom - layout.height);
1560
+ layout.top = Math.max(bounds.minTop, Math.min(layout.top, maxTop));
1561
+ layout.left = this.resolveNonOverlappingAboveBelowTooltipLeft(layout, placedLayouts, bounds.minLeft, maxLeft);
1562
+ placedLayouts.push(layout);
1563
+ });
1564
+ }
1565
+ resolveNonOverlappingAboveBelowTooltipLeft(layout, placedLayouts, minLeft, maxLeft) {
1566
+ const preferredLeft = Math.max(minLeft, Math.min(layout.left, maxLeft));
1567
+ if (!this.doesSplitTooltipOverlapPlacedLayouts({ ...layout, left: preferredLeft }, placedLayouts)) {
1568
+ return preferredLeft;
1569
+ }
1570
+ const candidates = placedLayouts.flatMap((placedLayout) => [
1571
+ placedLayout.left + placedLayout.width + SPLIT_TOOLTIP_GAP_PX,
1572
+ placedLayout.left - layout.width - SPLIT_TOOLTIP_GAP_PX,
1573
+ ]);
1574
+ return (Array.from(new Set(candidates.map((candidate) => Math.round(Math.max(minLeft, Math.min(candidate, maxLeft))))))
1575
+ .sort((a, b) => Math.abs(a - preferredLeft) -
1576
+ Math.abs(b - preferredLeft))
1577
+ .find((candidate) => !this.doesSplitTooltipOverlapPlacedLayouts({ ...layout, left: candidate }, placedLayouts)) ?? preferredLeft);
1578
+ }
1579
+ doesSplitTooltipOverlapPlacedLayouts(layout, placedLayouts) {
1580
+ return placedLayouts.some((placedLayout) => this.doSplitTooltipLayoutsOverlap(layout, placedLayout));
1581
+ }
1312
1582
  }
1313
1583
  Object.defineProperty(Tooltip, "nextTooltipId", {
1314
1584
  enumerable: true,
package/dist/types.d.ts CHANGED
@@ -253,6 +253,7 @@ export type BarStackConfig = {
253
253
  export type AreaStackMode = 'none' | 'normal' | 'percent';
254
254
  export type AreaStackConfig = {
255
255
  mode?: AreaStackMode;
256
+ reverseSeries?: boolean;
256
257
  };
257
258
  export declare function getSeriesColor(series: {
258
259
  stroke?: string;
@@ -308,6 +309,7 @@ export type TooltipConfigBase = {
308
309
  mode?: TooltipMode;
309
310
  position?: TooltipPosition;
310
311
  barAnchorPosition?: TooltipBarAnchorPosition;
312
+ maxWidth?: number;
311
313
  transition?: TooltipTransitionConfig;
312
314
  formatter?: SeriesValueFormatter;
313
315
  labelFormatter?: (label: string, data: DataItem) => string;
@@ -15,6 +15,7 @@ export declare class XYChart extends BaseChart {
15
15
  private barStackGap;
16
16
  private barStackReverseSeries;
17
17
  private areaStackMode;
18
+ private areaStackReverseSeries;
18
19
  private readonly orientation;
19
20
  private readonly motionDriver;
20
21
  private scaleConfigOverride;
@@ -39,6 +40,8 @@ export declare class XYChart extends BaseChart {
39
40
  private resolveValueAxisDomain;
40
41
  private getVisibleSeries;
41
42
  private getDisplaySeries;
43
+ private getBarDisplaySeries;
44
+ private getAreaDisplaySeries;
42
45
  private resolveSeriesDefaults;
43
46
  private shouldReplaceSeriesColor;
44
47
  private cloneSeriesWithOverride;
package/dist/xy-chart.js CHANGED
@@ -14,8 +14,11 @@ function resolveBarStackSettings(config) {
14
14
  reverseSeries: config.barStack?.reverseSeries ?? false,
15
15
  };
16
16
  }
17
- function resolveAreaStackMode(config) {
18
- return config.areaStack?.mode ?? 'none';
17
+ function resolveAreaStackSettings(config) {
18
+ return {
19
+ mode: config.areaStack?.mode ?? 'none',
20
+ reverseSeries: config.areaStack?.reverseSeries ?? false,
21
+ };
19
22
  }
20
23
  function isXYSeries(component) {
21
24
  return (component.type === 'line' ||
@@ -56,6 +59,12 @@ export class XYChart extends BaseChart {
56
59
  writable: true,
57
60
  value: void 0
58
61
  });
62
+ Object.defineProperty(this, "areaStackReverseSeries", {
63
+ enumerable: true,
64
+ configurable: true,
65
+ writable: true,
66
+ value: void 0
67
+ });
59
68
  Object.defineProperty(this, "orientation", {
60
69
  enumerable: true,
61
70
  configurable: true,
@@ -75,11 +84,13 @@ export class XYChart extends BaseChart {
75
84
  value: null
76
85
  });
77
86
  const barStack = resolveBarStackSettings(config);
87
+ const areaStack = resolveAreaStackSettings(config);
78
88
  this.orientation = config.orientation ?? 'vertical';
79
89
  this.barStackMode = barStack.mode;
80
90
  this.barStackGap = barStack.gap;
81
91
  this.barStackReverseSeries = barStack.reverseSeries;
82
- this.areaStackMode = resolveAreaStackMode(config);
92
+ this.areaStackMode = areaStack.mode;
93
+ this.areaStackReverseSeries = areaStack.reverseSeries;
83
94
  this.motionDriver = createXYMotionDriver(config.animate);
84
95
  }
85
96
  addChild(component) {
@@ -120,6 +131,7 @@ export class XYChart extends BaseChart {
120
131
  },
121
132
  areaStack: {
122
133
  mode: this.areaStackMode,
134
+ reverseSeries: this.areaStackReverseSeries,
123
135
  },
124
136
  animate: false,
125
137
  });
@@ -289,18 +301,22 @@ export class XYChart extends BaseChart {
289
301
  });
290
302
  }
291
303
  getDisplaySeries() {
304
+ const barOrderedSeries = this.getBarDisplaySeries(this.series);
305
+ return this.getAreaDisplaySeries(barOrderedSeries);
306
+ }
307
+ getBarDisplaySeries(series) {
292
308
  if (!this.barStackReverseSeries) {
293
- return this.series;
309
+ return series;
294
310
  }
295
- const barSeries = this.series.filter((entry) => {
311
+ const barSeries = series.filter((entry) => {
296
312
  return entry.type === 'bar';
297
313
  });
298
314
  if (barSeries.length < 2) {
299
- return this.series;
315
+ return series;
300
316
  }
301
317
  const reversedBars = [...barSeries].reverse();
302
318
  let reversedBarIndex = 0;
303
- return this.series.map((entry) => {
319
+ return series.map((entry) => {
304
320
  if (entry.type !== 'bar') {
305
321
  return entry;
306
322
  }
@@ -309,6 +325,31 @@ export class XYChart extends BaseChart {
309
325
  return nextBar;
310
326
  });
311
327
  }
328
+ getAreaDisplaySeries(series) {
329
+ if (!this.areaStackReverseSeries || this.areaStackMode === 'none') {
330
+ return series;
331
+ }
332
+ const areaSeries = series.filter((entry) => {
333
+ return entry.type === 'area';
334
+ });
335
+ const stackGroups = this.getStackedAreaGroups(areaSeries);
336
+ if (stackGroups.size === 0) {
337
+ return series;
338
+ }
339
+ const reversedSeriesByOriginal = new Map();
340
+ stackGroups.forEach((stackSeries) => {
341
+ const reversedStackSeries = [...stackSeries].reverse();
342
+ stackSeries.forEach((entry, index) => {
343
+ reversedSeriesByOriginal.set(entry, reversedStackSeries[index]);
344
+ });
345
+ });
346
+ return series.map((entry) => {
347
+ if (entry.type !== 'area') {
348
+ return entry;
349
+ }
350
+ return reversedSeriesByOriginal.get(entry) ?? entry;
351
+ });
352
+ }
312
353
  resolveSeriesDefaults(series) {
313
354
  const colorIndex = this.series.length % this.theme.colorPalette.length;
314
355
  const paletteColor = this.theme.colorPalette[colorIndex];
@@ -122,6 +122,7 @@ new Tooltip({
122
122
  mode?: 'shared' | 'split',
123
123
  position?: 'side' | 'vertical',
124
124
  barAnchorPosition?: 'top' | 'middle',
125
+ maxWidth?: number, // default: 280
125
126
  transition?: {
126
127
  show?: boolean,
127
128
  duration?: number,
@@ -155,6 +156,9 @@ possible.
155
156
  For bars, `barAnchorPosition` controls whether split tooltips point to the `top`
156
157
  or `middle` of each bar.
157
158
 
159
+ Tooltips default to a `280px` max width so longer content wraps. Set `maxWidth`
160
+ to choose a different cap in pixels.
161
+
158
162
  Set `transition.show: true` to fade tooltips in and out. Tooltip position and
159
163
  connector geometry update immediately; only opacity and the small entrance
160
164
  offset transition.
package/docs/xy-chart.md CHANGED
@@ -20,7 +20,7 @@ new XYChart(config: XYChartConfig)
20
20
  | `orientation` | `'vertical' \| 'horizontal'` | `'vertical'` | Chart orientation. Horizontal mode currently supports bar-only charts |
21
21
  | `responsive` | `ResponsiveConfig` | - | Container-query responsive overrides (theme + components) |
22
22
  | `barStack` | `BarStackConfig` | `{ mode: 'normal', gap: 0.1, reverseSeries: false }` | Bar stacking configuration |
23
- | `areaStack` | `AreaStackConfig` | `{ mode: 'none' }` | Area stacking configuration |
23
+ | `areaStack` | `AreaStackConfig` | `{ mode: 'none', reverseSeries: false }` | Area stacking configuration |
24
24
  | `animate` | `boolean \| XYAnimationConfig` | `false` | Opt-in XY series animation for initial render and `update()` |
25
25
 
26
26
  ### Theme Options
@@ -515,6 +515,8 @@ series at the hovered category. Use
515
515
  to override the grouping and split-tooltip placement.
516
516
  For bars, set `barAnchorPosition: 'top' | 'middle'` to choose whether split
517
517
  tooltips point to the top or middle of each bar.
518
+ Tooltips default to a `280px` max width. Set `maxWidth` to choose a different
519
+ cap in pixels.
518
520
  Use `transition: { show: true, duration: 120, easing: 'ease-out' }` to opt in
519
521
  to softer tooltip opacity transitions without delaying position updates.
520
522
 
@@ -576,12 +578,16 @@ Area charts support stacking when series share the same `stackId`:
576
578
  ```javascript
577
579
  const chart = new XYChart({
578
580
  data,
579
- areaStack: { mode: 'percent' },
581
+ areaStack: { mode: 'percent', reverseSeries: true },
580
582
  });
581
583
 
582
584
  chart.addChild(new Area({ dataKey: 'desktop', stackId: 'traffic' })).addChild(new Area({ dataKey: 'mobile', stackId: 'traffic' }));
583
585
  ```
584
586
 
587
+ Use `areaStack.reverseSeries: true` to reverse stacked area series display order
588
+ for rendering, legend entries, and split tooltip ordering without changing data
589
+ exports. The reversal applies within each shared `stackId` group.
590
+
585
591
  ---
586
592
 
587
593
  ## Mixed Charts
package/package.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.13.1",
2
+ "version": "0.13.3",
3
3
  "name": "@internetstiftelsen/charts",
4
4
  "type": "module",
5
5
  "sideEffects": false,