@internetstiftelsen/charts 0.13.1 → 0.13.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
@@ -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,19 @@ 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 resolveSideSplitTooltipPositions;
85
+ private resolveHorizontalSideSplitTooltipPositions;
86
+ private resolveHorizontalSideSplitTooltipCollisions;
87
+ private flipHorizontalSideTooltipIfItReducesCollisions;
88
+ private findReusableHorizontalTooltipLane;
89
+ private resolveNewHorizontalTooltipLaneTop;
90
+ private getHorizontalTooltipLaneTopCandidates;
91
+ private assignLayoutToHorizontalTooltipLane;
92
+ private doHorizontalTooltipLanesOverlap;
93
+ private doSplitTooltipLayoutsOverlapHorizontally;
94
+ private resolveVerticalSplitTooltipPositions;
80
95
  }
81
96
  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,13 @@ 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
+ if (!isHorizontal) {
470
+ this.resolveSplitTooltipCollisions(layouts, 'vertical', this.getOppositeVerticalArrowEdge);
471
+ this.resolveSplitTooltipCollisions(layouts, 'side', this.getOppositeSideArrowEdge);
472
+ }
473
+ this.resolveSplitTooltipPositions(layouts, isHorizontal);
460
474
  layouts.forEach((layout) => {
461
- this.renderTooltipWithConnector(layout.div, layout.arrowEdge, layout.left, layout.top, layout.width, layout.height, layout.targetX, layout.targetY);
475
+ this.renderTooltipWithConnector(layout.div, layout.arrowEdge, layout.left, layout.top, layout.width, layout.height, layout.targetX, layout.targetY, layout.anchor);
462
476
  });
463
477
  };
464
478
  const hideTooltip = () => {
@@ -706,6 +720,7 @@ export class Tooltip {
706
720
  theme.tooltip.fontFamily,
707
721
  theme.tooltip.fontSize,
708
722
  theme.tooltip.fontWeight,
723
+ this.maxWidth,
709
724
  this.transition.show,
710
725
  this.transition.duration,
711
726
  this.transition.easing,
@@ -724,10 +739,13 @@ export class Tooltip {
724
739
  .style('font-family', theme.tooltip.fontFamily)
725
740
  .style('font-size', `${theme.tooltip.fontSize}px`)
726
741
  .style('font-weight', theme.tooltip.fontWeight)
742
+ .style('box-sizing', 'border-box')
743
+ .style('overflow-wrap', 'break-word')
727
744
  .style('overflow', 'visible')
728
745
  .style('isolation', 'isolate')
729
746
  .style('pointer-events', 'none')
730
747
  .style('z-index', '1000');
748
+ tooltip.style('max-width', `${this.maxWidth}px`);
731
749
  if (this.transition.show) {
732
750
  tooltip
733
751
  .style('transition', `opacity ${this.transition.duration}ms ${this.transition.easing}, transform ${this.transition.duration}ms ${this.transition.easing}`)
@@ -758,7 +776,7 @@ export class Tooltip {
758
776
  height: tooltipRect.height,
759
777
  };
760
778
  }
761
- renderTooltipWithConnector(tooltip, arrowEdge, left, top, tooltipWidth, tooltipHeight, targetX, targetY) {
779
+ renderTooltipWithConnector(tooltip, arrowEdge, left, top, tooltipWidth, tooltipHeight, targetX, targetY, anchor) {
762
780
  if (!Number.isFinite(left) ||
763
781
  !Number.isFinite(top) ||
764
782
  !Number.isFinite(targetX) ||
@@ -766,7 +784,7 @@ export class Tooltip {
766
784
  this.hideTooltipSelection(tooltip);
767
785
  return;
768
786
  }
769
- const connectorLayout = this.resolveTooltipConnectorLayout(arrowEdge, left, top, tooltipWidth, tooltipHeight, targetX, targetY);
787
+ const connectorLayout = this.resolveTooltipConnectorLayout(arrowEdge, left, top, tooltipWidth, tooltipHeight, targetX, targetY, anchor);
770
788
  if (!connectorLayout) {
771
789
  this.hideTooltipSelection(tooltip);
772
790
  return;
@@ -1100,7 +1118,7 @@ export class Tooltip {
1100
1118
  top: Math.max(minTop, Math.min(top, maxTop)),
1101
1119
  };
1102
1120
  }
1103
- resolveTooltipConnectorLayout(arrowEdge, tooltipLeft, tooltipTop, tooltipWidth, tooltipHeight, targetX, targetY) {
1121
+ resolveTooltipConnectorLayout(arrowEdge, tooltipLeft, tooltipTop, tooltipWidth, tooltipHeight, targetX, targetY, anchor) {
1104
1122
  const localTargetX = targetX - tooltipLeft;
1105
1123
  const localTargetY = targetY - tooltipTop;
1106
1124
  if (!Number.isFinite(localTargetX) || !Number.isFinite(localTargetY)) {
@@ -1120,7 +1138,9 @@ export class Tooltip {
1120
1138
  const startY = arrowTip.y - minY;
1121
1139
  const endX = localTargetX - minX;
1122
1140
  const endY = localTargetY - minY;
1123
- const connectorPath = this.resolveTooltipConnectorPath(arrowEdge, startX, startY, endX, endY);
1141
+ const arrowTipX = tooltipLeft + arrowTip.x;
1142
+ const arrowTipY = tooltipTop + arrowTip.y;
1143
+ const connectorPath = this.resolveTooltipConnectorPath(arrowEdge, startX, startY, endX, endY, arrowTipX, arrowTipY, anchor);
1124
1144
  if (!this.hasFiniteNumbers(width, height, boxX, boxY, startX, startY, endX, endY)) {
1125
1145
  return null;
1126
1146
  }
@@ -1163,21 +1183,31 @@ export class Tooltip {
1163
1183
  };
1164
1184
  }
1165
1185
  }
1166
- resolveTooltipConnectorPath(arrowEdge, startX, startY, endX, endY) {
1186
+ resolveTooltipConnectorPath(arrowEdge, startX, startY, endX, endY, arrowTipX, arrowTipY, anchor) {
1167
1187
  if (arrowEdge === 'left' || arrowEdge === 'right') {
1168
- if (Math.abs(endY - startY) <=
1169
- TOOLTIP_CONNECTOR_ALIGNMENT_TOLERANCE_PX) {
1188
+ if (this.isTooltipArrowTipInsideVerticalAnchorSpan(arrowTipY, anchor)) {
1170
1189
  return '';
1171
1190
  }
1172
1191
  const elbowX = startX + (endX - startX) * TOOLTIP_CONNECTOR_ELBOW_RATIO;
1173
1192
  return `M ${startX},${startY} L ${elbowX},${startY} L ${endX},${endY}`;
1174
1193
  }
1175
- if (Math.abs(endX - startX) <= TOOLTIP_CONNECTOR_ALIGNMENT_TOLERANCE_PX) {
1194
+ if (this.isTooltipArrowTipInsideHorizontalAnchorSpan(arrowTipX, anchor)) {
1176
1195
  return '';
1177
1196
  }
1178
1197
  const elbowY = startY + (endY - startY) * TOOLTIP_CONNECTOR_ELBOW_RATIO;
1179
1198
  return `M ${startX},${startY} L ${startX},${elbowY} L ${endX},${endY}`;
1180
1199
  }
1200
+ isTooltipArrowTipInsideHorizontalAnchorSpan(arrowTipX, anchor) {
1201
+ return (arrowTipX >=
1202
+ anchor.left - TOOLTIP_CONNECTOR_ALIGNMENT_TOLERANCE_PX &&
1203
+ arrowTipX <= anchor.right + TOOLTIP_CONNECTOR_ALIGNMENT_TOLERANCE_PX);
1204
+ }
1205
+ isTooltipArrowTipInsideVerticalAnchorSpan(arrowTipY, anchor) {
1206
+ return (arrowTipY >=
1207
+ anchor.top - TOOLTIP_CONNECTOR_ALIGNMENT_TOLERANCE_PX &&
1208
+ arrowTipY <=
1209
+ anchor.bottom + TOOLTIP_CONNECTOR_ALIGNMENT_TOLERANCE_PX);
1210
+ }
1181
1211
  resolveTooltipArrowTip(arrowEdge, boxX, boxY, length) {
1182
1212
  if (arrowEdge === 'left' || arrowEdge === 'right') {
1183
1213
  return {
@@ -1198,7 +1228,12 @@ export class Tooltip {
1198
1228
  return;
1199
1229
  }
1200
1230
  const placedLayouts = [];
1201
- const orderedLayouts = [...layouts].sort((a, b) => a.targetY - b.targetY);
1231
+ const orderedLayouts = [...layouts].sort((a, b) => {
1232
+ if (position === 'vertical') {
1233
+ return a.targetX - b.targetX || a.targetY - b.targetY;
1234
+ }
1235
+ return a.targetY - b.targetY || a.targetX - b.targetX;
1236
+ });
1202
1237
  orderedLayouts.forEach((layout) => {
1203
1238
  this.flipTooltipIfItReducesCollisions(layout, placedLayouts, getOppositeArrowEdge);
1204
1239
  placedLayouts.push(layout);
@@ -1242,7 +1277,12 @@ export class Tooltip {
1242
1277
  top: flippedPosition.top,
1243
1278
  };
1244
1279
  const flippedCollisions = this.countSplitTooltipCollisions(flippedLayout, placedLayouts);
1245
- if (flippedCollisions >= currentCollisions) {
1280
+ if (flippedCollisions > currentCollisions) {
1281
+ return;
1282
+ }
1283
+ if (flippedCollisions === currentCollisions &&
1284
+ this.countPlacedLayoutsOnEdge(placedLayouts, flippedArrowEdge) >=
1285
+ this.countPlacedLayoutsOnEdge(placedLayouts, layout.arrowEdge)) {
1246
1286
  return;
1247
1287
  }
1248
1288
  layout.arrowEdge = flippedArrowEdge;
@@ -1252,13 +1292,28 @@ export class Tooltip {
1252
1292
  countSplitTooltipCollisions(layout, placedLayouts) {
1253
1293
  return placedLayouts.filter((placedLayout) => this.doSplitTooltipLayoutsOverlap(layout, placedLayout)).length;
1254
1294
  }
1295
+ countPlacedLayoutsOnEdge(placedLayouts, arrowEdge) {
1296
+ return placedLayouts.filter((layout) => layout.arrowEdge === arrowEdge)
1297
+ .length;
1298
+ }
1255
1299
  doSplitTooltipLayoutsOverlap(a, b) {
1256
1300
  return (a.left < b.left + b.width + SPLIT_TOOLTIP_GAP_PX &&
1257
1301
  a.left + a.width + SPLIT_TOOLTIP_GAP_PX > b.left &&
1258
1302
  a.top < b.top + b.height + SPLIT_TOOLTIP_GAP_PX &&
1259
1303
  a.top + a.height + SPLIT_TOOLTIP_GAP_PX > b.top);
1260
1304
  }
1261
- resolveSplitTooltipPositions(layouts) {
1305
+ resolveSplitTooltipPositions(layouts, isHorizontal) {
1306
+ if (isHorizontal && this.position === 'side') {
1307
+ this.resolveHorizontalSideSplitTooltipPositions(layouts);
1308
+ return;
1309
+ }
1310
+ if (this.position === 'vertical') {
1311
+ this.resolveVerticalSplitTooltipPositions(layouts);
1312
+ return;
1313
+ }
1314
+ this.resolveSideSplitTooltipPositions(layouts);
1315
+ }
1316
+ resolveSideSplitTooltipPositions(layouts) {
1262
1317
  const minTop = window.scrollY + TOOLTIP_VIEWPORT_PADDING_PX;
1263
1318
  const maxBottom = window.scrollY + window.innerHeight - TOOLTIP_VIEWPORT_PADDING_PX;
1264
1319
  const tooltipsByEdge = {
@@ -1309,6 +1364,164 @@ export class Tooltip {
1309
1364
  });
1310
1365
  });
1311
1366
  }
1367
+ resolveHorizontalSideSplitTooltipPositions(layouts) {
1368
+ this.resolveHorizontalSideSplitTooltipCollisions(layouts);
1369
+ const minTop = window.scrollY + TOOLTIP_VIEWPORT_PADDING_PX;
1370
+ const maxBottom = window.scrollY + window.innerHeight - TOOLTIP_VIEWPORT_PADDING_PX;
1371
+ const lanes = [];
1372
+ const orderedLayouts = [...layouts].sort((a, b) => a.targetX - b.targetX || a.left - b.left);
1373
+ orderedLayouts.forEach((layout) => {
1374
+ const maxTop = maxBottom - layout.height;
1375
+ const preferredTop = Math.max(minTop, Math.min(layout.top, maxTop));
1376
+ const reusableLane = this.findReusableHorizontalTooltipLane(lanes, layout, preferredTop);
1377
+ if (reusableLane) {
1378
+ this.assignLayoutToHorizontalTooltipLane(layout, reusableLane);
1379
+ return;
1380
+ }
1381
+ const nextLane = {
1382
+ top: this.resolveNewHorizontalTooltipLaneTop(preferredTop, layout.height, minTop, maxTop, lanes),
1383
+ layouts: [],
1384
+ };
1385
+ lanes.push(nextLane);
1386
+ this.assignLayoutToHorizontalTooltipLane(layout, nextLane);
1387
+ });
1388
+ }
1389
+ resolveHorizontalSideSplitTooltipCollisions(layouts) {
1390
+ const placedLayouts = [];
1391
+ const orderedLayouts = [...layouts].sort((a, b) => a.targetX - b.targetX || a.left - b.left);
1392
+ orderedLayouts.forEach((layout) => {
1393
+ this.flipHorizontalSideTooltipIfItReducesCollisions(layout, placedLayouts);
1394
+ placedLayouts.push(layout);
1395
+ });
1396
+ }
1397
+ flipHorizontalSideTooltipIfItReducesCollisions(layout, placedLayouts) {
1398
+ const currentCollisions = this.countSplitTooltipCollisions(layout, placedLayouts);
1399
+ if (currentCollisions === 0) {
1400
+ return;
1401
+ }
1402
+ const flippedArrowEdge = this.getOppositeSideArrowEdge(layout.arrowEdge);
1403
+ if (!flippedArrowEdge) {
1404
+ return;
1405
+ }
1406
+ const flippedPosition = this.getAnchoredTooltipPosition(layout.anchor, { x: layout.targetX, y: layout.targetY }, layout.width, layout.height, flippedArrowEdge);
1407
+ if (!flippedPosition) {
1408
+ return;
1409
+ }
1410
+ const flippedLayout = {
1411
+ ...layout,
1412
+ arrowEdge: flippedArrowEdge,
1413
+ left: flippedPosition.left,
1414
+ top: flippedPosition.top,
1415
+ };
1416
+ const flippedCollisions = this.countSplitTooltipCollisions(flippedLayout, placedLayouts);
1417
+ if (flippedCollisions > currentCollisions) {
1418
+ return;
1419
+ }
1420
+ if (flippedCollisions === currentCollisions &&
1421
+ this.countPlacedLayoutsOnEdge(placedLayouts, flippedArrowEdge) >=
1422
+ this.countPlacedLayoutsOnEdge(placedLayouts, layout.arrowEdge)) {
1423
+ return;
1424
+ }
1425
+ layout.arrowEdge = flippedArrowEdge;
1426
+ layout.left = flippedPosition.left;
1427
+ layout.top = flippedPosition.top;
1428
+ }
1429
+ findReusableHorizontalTooltipLane(lanes, layout, preferredTop) {
1430
+ const reusableLanes = lanes
1431
+ .filter((lane) => lane.layouts.every((placedLayout) => !this.doSplitTooltipLayoutsOverlapHorizontally(layout, placedLayout)))
1432
+ .sort((a, b) => Math.abs(a.top - preferredTop) -
1433
+ Math.abs(b.top - preferredTop));
1434
+ return reusableLanes[0] ?? null;
1435
+ }
1436
+ resolveNewHorizontalTooltipLaneTop(preferredTop, tooltipHeight, minTop, maxTop, lanes) {
1437
+ const usedTops = new Set(lanes.map((lane) => Math.round(lane.top)));
1438
+ const candidates = this.getHorizontalTooltipLaneTopCandidates(preferredTop, tooltipHeight, minTop, maxTop);
1439
+ return (candidates.find((candidate) => !usedTops.has(Math.round(candidate)) &&
1440
+ lanes.every((lane) => !this.doHorizontalTooltipLanesOverlap(candidate, tooltipHeight, lane))) ??
1441
+ candidates[0] ??
1442
+ preferredTop);
1443
+ }
1444
+ getHorizontalTooltipLaneTopCandidates(preferredTop, tooltipHeight, minTop, maxTop) {
1445
+ const step = tooltipHeight + SPLIT_TOOLTIP_GAP_PX;
1446
+ const candidates = [preferredTop];
1447
+ for (let index = 1; index <= 8; index++) {
1448
+ candidates.push(preferredTop - step * index);
1449
+ candidates.push(preferredTop + step * index);
1450
+ }
1451
+ 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));
1452
+ }
1453
+ assignLayoutToHorizontalTooltipLane(layout, lane) {
1454
+ layout.top = lane.top;
1455
+ lane.layouts.push(layout);
1456
+ }
1457
+ doHorizontalTooltipLanesOverlap(top, height, lane) {
1458
+ const laneHeight = lane.layouts.reduce((maxHeight, layout) => Math.max(maxHeight, layout.height), 0);
1459
+ return (top < lane.top + laneHeight + SPLIT_TOOLTIP_GAP_PX &&
1460
+ top + height + SPLIT_TOOLTIP_GAP_PX > lane.top);
1461
+ }
1462
+ doSplitTooltipLayoutsOverlapHorizontally(a, b) {
1463
+ return (a.left < b.left + b.width + SPLIT_TOOLTIP_GAP_PX &&
1464
+ a.left + a.width + SPLIT_TOOLTIP_GAP_PX > b.left);
1465
+ }
1466
+ resolveVerticalSplitTooltipPositions(layouts) {
1467
+ const minLeft = window.scrollX + TOOLTIP_VIEWPORT_PADDING_PX;
1468
+ const maxRight = window.scrollX + window.innerWidth - TOOLTIP_VIEWPORT_PADDING_PX;
1469
+ const minTop = window.scrollY + TOOLTIP_VIEWPORT_PADDING_PX;
1470
+ const maxBottom = window.scrollY + window.innerHeight - TOOLTIP_VIEWPORT_PADDING_PX;
1471
+ const tooltipsByEdge = {
1472
+ left: [],
1473
+ right: [],
1474
+ top: [],
1475
+ bottom: [],
1476
+ };
1477
+ layouts.forEach((layout) => {
1478
+ tooltipsByEdge[layout.arrowEdge].push(layout);
1479
+ });
1480
+ [tooltipsByEdge.top, tooltipsByEdge.bottom].forEach((edgeLayouts) => {
1481
+ if (edgeLayouts.length === 0) {
1482
+ return;
1483
+ }
1484
+ edgeLayouts.sort((a, b) => a.left - b.left);
1485
+ edgeLayouts[0].left = Math.max(minLeft, edgeLayouts[0].left);
1486
+ for (let i = 1; i < edgeLayouts.length; i++) {
1487
+ const previousLayout = edgeLayouts[i - 1];
1488
+ const currentLayout = edgeLayouts[i];
1489
+ const minAllowedLeft = previousLayout.left +
1490
+ previousLayout.width +
1491
+ SPLIT_TOOLTIP_GAP_PX;
1492
+ currentLayout.left = Math.max(currentLayout.left, minAllowedLeft);
1493
+ }
1494
+ const lastLayout = edgeLayouts[edgeLayouts.length - 1];
1495
+ const overflow = lastLayout.left + lastLayout.width - maxRight;
1496
+ if (overflow > 0) {
1497
+ lastLayout.left -= overflow;
1498
+ for (let i = edgeLayouts.length - 2; i >= 0; i--) {
1499
+ const currentLayout = edgeLayouts[i];
1500
+ const nextLayout = edgeLayouts[i + 1];
1501
+ const maxAllowedLeft = nextLayout.left -
1502
+ currentLayout.width -
1503
+ SPLIT_TOOLTIP_GAP_PX;
1504
+ currentLayout.left = Math.min(currentLayout.left, maxAllowedLeft);
1505
+ }
1506
+ const underflow = minLeft - edgeLayouts[0].left;
1507
+ if (underflow > 0) {
1508
+ edgeLayouts.forEach((layout) => {
1509
+ layout.left += underflow;
1510
+ });
1511
+ }
1512
+ }
1513
+ edgeLayouts.forEach((layout) => {
1514
+ const maxLeft = maxRight - layout.width;
1515
+ const maxTop = maxBottom - layout.height;
1516
+ layout.left = Math.max(minLeft, Math.min(layout.left, maxLeft));
1517
+ layout.top = Math.max(minTop, Math.min(layout.top, maxTop));
1518
+ });
1519
+ });
1520
+ this.resolveSideSplitTooltipPositions([
1521
+ ...tooltipsByEdge.left,
1522
+ ...tooltipsByEdge.right,
1523
+ ]);
1524
+ }
1312
1525
  }
1313
1526
  Object.defineProperty(Tooltip, "nextTooltipId", {
1314
1527
  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.2",
3
3
  "name": "@internetstiftelsen/charts",
4
4
  "type": "module",
5
5
  "sideEffects": false,