@internetstiftelsen/charts 0.13.0 → 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/line.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { type Selection } from 'd3';
2
- import type { LineConfig, DataItem, D3Scale, ScaleType, ChartTheme, LineValueLabelConfig, ExportHooks, LineConfigBase } from './types.js';
2
+ import type { LineConfig, DataItem, D3Scale, ScaleType, ChartTheme, LineValueLabelConfig, ExportHooks, LineConfigBase, LineCurveType, LinePointsConfig } from './types.js';
3
3
  import type { ChartComponent } from './chart-interface.js';
4
4
  import type { XYPointAnimationContext, XYPointSnapshot, XYSeriesRenderResult } from './xy-motion/types.js';
5
5
  export declare class Line implements ChartComponent<LineConfigBase> {
@@ -7,6 +7,8 @@ export declare class Line implements ChartComponent<LineConfigBase> {
7
7
  readonly dataKey: string;
8
8
  readonly stroke: string;
9
9
  readonly strokeWidth?: number;
10
+ readonly curve: LineCurveType;
11
+ readonly points: Required<LinePointsConfig>;
10
12
  readonly valueLabel?: LineValueLabelConfig;
11
13
  readonly exportHooks?: ExportHooks<LineConfigBase>;
12
14
  constructor(config: LineConfig);
package/dist/line.js CHANGED
@@ -1,7 +1,18 @@
1
- import { line } from 'd3';
1
+ import { curveBasis, curveCardinal, curveLinear, curveMonotoneX, curveNatural, curveStep, line, } from 'd3';
2
2
  import { sanitizeForCSS, mergeDeep } from './utils.js';
3
3
  import { getScalePosition } from './scale-utils.js';
4
4
  import { buildXYDatumSnapshotKeys, createTransitionCompletionPromise, createLeftToRightRevealTransition, getEnterStaggerTiming, } from './xy-motion/helpers.js';
5
+ const DEFAULT_LINE_POINTS = {
6
+ show: 'always',
7
+ };
8
+ const LINE_CURVE_FACTORIES = {
9
+ linear: curveLinear,
10
+ monotone: curveMonotoneX,
11
+ step: curveStep,
12
+ natural: curveNatural,
13
+ basis: curveBasis,
14
+ cardinal: curveCardinal,
15
+ };
5
16
  export class Line {
6
17
  constructor(config) {
7
18
  Object.defineProperty(this, "type", {
@@ -28,6 +39,18 @@ export class Line {
28
39
  writable: true,
29
40
  value: void 0
30
41
  });
42
+ Object.defineProperty(this, "curve", {
43
+ enumerable: true,
44
+ configurable: true,
45
+ writable: true,
46
+ value: void 0
47
+ });
48
+ Object.defineProperty(this, "points", {
49
+ enumerable: true,
50
+ configurable: true,
51
+ writable: true,
52
+ value: void 0
53
+ });
31
54
  Object.defineProperty(this, "valueLabel", {
32
55
  enumerable: true,
33
56
  configurable: true,
@@ -43,6 +66,10 @@ export class Line {
43
66
  this.dataKey = config.dataKey;
44
67
  this.stroke = config.stroke || '#8884d8';
45
68
  this.strokeWidth = config.strokeWidth;
69
+ this.curve = config.curve || 'linear';
70
+ this.points = {
71
+ show: config.points?.show ?? DEFAULT_LINE_POINTS.show,
72
+ };
46
73
  this.valueLabel = config.valueLabel;
47
74
  this.exportHooks = config.exportHooks;
48
75
  }
@@ -51,6 +78,8 @@ export class Line {
51
78
  dataKey: this.dataKey,
52
79
  stroke: this.stroke,
53
80
  strokeWidth: this.strokeWidth,
81
+ curve: this.curve,
82
+ points: this.points,
54
83
  valueLabel: this.valueLabel,
55
84
  };
56
85
  }
@@ -80,7 +109,9 @@ export class Line {
80
109
  });
81
110
  const transitions = [
82
111
  ...this.renderLinePath(plotGroup, lineData, animatedLineData, theme, sanitizeForCSS(this.dataKey), animation),
83
- ...this.renderLinePoints(plotGroup, validLineData, validAnimatedLineData, theme, animation),
112
+ ...(this.points.show === 'always'
113
+ ? this.renderLinePoints(plotGroup, validLineData, validAnimatedLineData, theme, animation)
114
+ : []),
84
115
  ];
85
116
  const snapshot = this.createSnapshot(validLineData);
86
117
  // Render value labels if enabled (only for valid values)
@@ -129,8 +160,10 @@ export class Line {
129
160
  }
130
161
  renderLinePath(plotGroup, lineData, animatedLineData, theme, sanitizedKey, animation) {
131
162
  const lineStrokeWidth = this.strokeWidth ?? theme.line.strokeWidth;
163
+ const curveFactory = LINE_CURVE_FACTORIES[this.curve] || curveLinear;
132
164
  const lineGenerator = line()
133
165
  .defined((entry) => entry.valid)
166
+ .curve(curveFactory)
134
167
  .x((entry) => entry.x)
135
168
  .y((entry) => entry.y);
136
169
  const finalPath = lineGenerator(lineData);
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,
@@ -269,7 +281,11 @@ export class Tooltip {
269
281
  .attr('aria-hidden', 'true')
270
282
  .style('fill', 'none')
271
283
  .style('pointer-events', 'all');
272
- const pointSeries = series.filter((currentSeries) => {
284
+ const focusCircleSeries = series.filter((currentSeries) => {
285
+ if (currentSeries.type === 'line' &&
286
+ currentSeries.points.show === 'never') {
287
+ return false;
288
+ }
273
289
  return (currentSeries.type === 'line' ||
274
290
  currentSeries.type === 'area' ||
275
291
  currentSeries.type === 'scatter');
@@ -278,7 +294,7 @@ export class Tooltip {
278
294
  return currentSeries.type === 'bar';
279
295
  });
280
296
  const hasBarSeries = barSeries.length > 0;
281
- const focusCircles = pointSeries.map((currentSeries) => {
297
+ const focusCircles = focusCircleSeries.map((currentSeries) => {
282
298
  const seriesColor = getSeriesColor(currentSeries);
283
299
  return svg
284
300
  .append('circle')
@@ -303,7 +319,7 @@ export class Tooltip {
303
319
  const updateVisualStateAtIndex = (closestIndex) => {
304
320
  const dataPoint = data[closestIndex];
305
321
  const dataPointPosition = dataPointPositions[closestIndex];
306
- pointSeries.forEach((currentSeries, seriesIndex) => {
322
+ focusCircleSeries.forEach((currentSeries, seriesIndex) => {
307
323
  const value = resolveSeriesValue(currentSeries, dataPoint, closestIndex);
308
324
  if (!Number.isFinite(value)) {
309
325
  focusCircles[seriesIndex].style('opacity', 0);
@@ -450,11 +466,13 @@ export class Tooltip {
450
466
  if (layouts.length === 0) {
451
467
  return;
452
468
  }
453
- this.resolveSplitTooltipCollisions(layouts, 'vertical', this.getOppositeVerticalArrowEdge);
454
- this.resolveSplitTooltipCollisions(layouts, 'side', this.getOppositeSideArrowEdge);
455
- 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);
456
474
  layouts.forEach((layout) => {
457
- 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);
458
476
  });
459
477
  };
460
478
  const hideTooltip = () => {
@@ -702,6 +720,7 @@ export class Tooltip {
702
720
  theme.tooltip.fontFamily,
703
721
  theme.tooltip.fontSize,
704
722
  theme.tooltip.fontWeight,
723
+ this.maxWidth,
705
724
  this.transition.show,
706
725
  this.transition.duration,
707
726
  this.transition.easing,
@@ -720,10 +739,13 @@ export class Tooltip {
720
739
  .style('font-family', theme.tooltip.fontFamily)
721
740
  .style('font-size', `${theme.tooltip.fontSize}px`)
722
741
  .style('font-weight', theme.tooltip.fontWeight)
742
+ .style('box-sizing', 'border-box')
743
+ .style('overflow-wrap', 'break-word')
723
744
  .style('overflow', 'visible')
724
745
  .style('isolation', 'isolate')
725
746
  .style('pointer-events', 'none')
726
747
  .style('z-index', '1000');
748
+ tooltip.style('max-width', `${this.maxWidth}px`);
727
749
  if (this.transition.show) {
728
750
  tooltip
729
751
  .style('transition', `opacity ${this.transition.duration}ms ${this.transition.easing}, transform ${this.transition.duration}ms ${this.transition.easing}`)
@@ -754,7 +776,7 @@ export class Tooltip {
754
776
  height: tooltipRect.height,
755
777
  };
756
778
  }
757
- renderTooltipWithConnector(tooltip, arrowEdge, left, top, tooltipWidth, tooltipHeight, targetX, targetY) {
779
+ renderTooltipWithConnector(tooltip, arrowEdge, left, top, tooltipWidth, tooltipHeight, targetX, targetY, anchor) {
758
780
  if (!Number.isFinite(left) ||
759
781
  !Number.isFinite(top) ||
760
782
  !Number.isFinite(targetX) ||
@@ -762,7 +784,7 @@ export class Tooltip {
762
784
  this.hideTooltipSelection(tooltip);
763
785
  return;
764
786
  }
765
- const connectorLayout = this.resolveTooltipConnectorLayout(arrowEdge, left, top, tooltipWidth, tooltipHeight, targetX, targetY);
787
+ const connectorLayout = this.resolveTooltipConnectorLayout(arrowEdge, left, top, tooltipWidth, tooltipHeight, targetX, targetY, anchor);
766
788
  if (!connectorLayout) {
767
789
  this.hideTooltipSelection(tooltip);
768
790
  return;
@@ -1096,7 +1118,7 @@ export class Tooltip {
1096
1118
  top: Math.max(minTop, Math.min(top, maxTop)),
1097
1119
  };
1098
1120
  }
1099
- resolveTooltipConnectorLayout(arrowEdge, tooltipLeft, tooltipTop, tooltipWidth, tooltipHeight, targetX, targetY) {
1121
+ resolveTooltipConnectorLayout(arrowEdge, tooltipLeft, tooltipTop, tooltipWidth, tooltipHeight, targetX, targetY, anchor) {
1100
1122
  const localTargetX = targetX - tooltipLeft;
1101
1123
  const localTargetY = targetY - tooltipTop;
1102
1124
  if (!Number.isFinite(localTargetX) || !Number.isFinite(localTargetY)) {
@@ -1116,7 +1138,9 @@ export class Tooltip {
1116
1138
  const startY = arrowTip.y - minY;
1117
1139
  const endX = localTargetX - minX;
1118
1140
  const endY = localTargetY - minY;
1119
- 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);
1120
1144
  if (!this.hasFiniteNumbers(width, height, boxX, boxY, startX, startY, endX, endY)) {
1121
1145
  return null;
1122
1146
  }
@@ -1159,21 +1183,31 @@ export class Tooltip {
1159
1183
  };
1160
1184
  }
1161
1185
  }
1162
- resolveTooltipConnectorPath(arrowEdge, startX, startY, endX, endY) {
1186
+ resolveTooltipConnectorPath(arrowEdge, startX, startY, endX, endY, arrowTipX, arrowTipY, anchor) {
1163
1187
  if (arrowEdge === 'left' || arrowEdge === 'right') {
1164
- if (Math.abs(endY - startY) <=
1165
- TOOLTIP_CONNECTOR_ALIGNMENT_TOLERANCE_PX) {
1188
+ if (this.isTooltipArrowTipInsideVerticalAnchorSpan(arrowTipY, anchor)) {
1166
1189
  return '';
1167
1190
  }
1168
1191
  const elbowX = startX + (endX - startX) * TOOLTIP_CONNECTOR_ELBOW_RATIO;
1169
1192
  return `M ${startX},${startY} L ${elbowX},${startY} L ${endX},${endY}`;
1170
1193
  }
1171
- if (Math.abs(endX - startX) <= TOOLTIP_CONNECTOR_ALIGNMENT_TOLERANCE_PX) {
1194
+ if (this.isTooltipArrowTipInsideHorizontalAnchorSpan(arrowTipX, anchor)) {
1172
1195
  return '';
1173
1196
  }
1174
1197
  const elbowY = startY + (endY - startY) * TOOLTIP_CONNECTOR_ELBOW_RATIO;
1175
1198
  return `M ${startX},${startY} L ${startX},${elbowY} L ${endX},${endY}`;
1176
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
+ }
1177
1211
  resolveTooltipArrowTip(arrowEdge, boxX, boxY, length) {
1178
1212
  if (arrowEdge === 'left' || arrowEdge === 'right') {
1179
1213
  return {
@@ -1194,7 +1228,12 @@ export class Tooltip {
1194
1228
  return;
1195
1229
  }
1196
1230
  const placedLayouts = [];
1197
- 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
+ });
1198
1237
  orderedLayouts.forEach((layout) => {
1199
1238
  this.flipTooltipIfItReducesCollisions(layout, placedLayouts, getOppositeArrowEdge);
1200
1239
  placedLayouts.push(layout);
@@ -1238,7 +1277,12 @@ export class Tooltip {
1238
1277
  top: flippedPosition.top,
1239
1278
  };
1240
1279
  const flippedCollisions = this.countSplitTooltipCollisions(flippedLayout, placedLayouts);
1241
- 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)) {
1242
1286
  return;
1243
1287
  }
1244
1288
  layout.arrowEdge = flippedArrowEdge;
@@ -1248,13 +1292,28 @@ export class Tooltip {
1248
1292
  countSplitTooltipCollisions(layout, placedLayouts) {
1249
1293
  return placedLayouts.filter((placedLayout) => this.doSplitTooltipLayoutsOverlap(layout, placedLayout)).length;
1250
1294
  }
1295
+ countPlacedLayoutsOnEdge(placedLayouts, arrowEdge) {
1296
+ return placedLayouts.filter((layout) => layout.arrowEdge === arrowEdge)
1297
+ .length;
1298
+ }
1251
1299
  doSplitTooltipLayoutsOverlap(a, b) {
1252
1300
  return (a.left < b.left + b.width + SPLIT_TOOLTIP_GAP_PX &&
1253
1301
  a.left + a.width + SPLIT_TOOLTIP_GAP_PX > b.left &&
1254
1302
  a.top < b.top + b.height + SPLIT_TOOLTIP_GAP_PX &&
1255
1303
  a.top + a.height + SPLIT_TOOLTIP_GAP_PX > b.top);
1256
1304
  }
1257
- 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) {
1258
1317
  const minTop = window.scrollY + TOOLTIP_VIEWPORT_PADDING_PX;
1259
1318
  const maxBottom = window.scrollY + window.innerHeight - TOOLTIP_VIEWPORT_PADDING_PX;
1260
1319
  const tooltipsByEdge = {
@@ -1305,6 +1364,164 @@ export class Tooltip {
1305
1364
  });
1306
1365
  });
1307
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
+ }
1308
1525
  }
1309
1526
  Object.defineProperty(Tooltip, "nextTooltipId", {
1310
1527
  enumerable: true,
package/dist/types.d.ts CHANGED
@@ -184,16 +184,25 @@ export type ValueLabelConfig = {
184
184
  export type LineValueLabelConfig = ValueLabelConfig & {
185
185
  show?: boolean;
186
186
  };
187
+ export type LinePointVisibility = 'always' | 'hover' | 'never';
188
+ export type LinePointsConfig = {
189
+ show?: LinePointVisibility;
190
+ };
187
191
  export type BarValueLabelConfig = ValueLabelConfig & {
188
192
  show?: boolean;
189
193
  position?: 'inside' | 'outside';
190
194
  insidePosition?: 'top' | 'middle' | 'bottom';
191
195
  };
192
196
  export type BarSide = 'left' | 'right';
197
+ export type CurveType = 'linear' | 'monotone' | 'step' | 'natural' | 'basis' | 'cardinal';
198
+ export type LineCurveType = CurveType;
199
+ export type AreaCurveType = CurveType;
193
200
  export type LineConfigBase = {
194
201
  dataKey: string;
195
202
  stroke?: string;
196
203
  strokeWidth?: number;
204
+ curve?: LineCurveType;
205
+ points?: LinePointsConfig;
197
206
  valueLabel?: LineValueLabelConfig;
198
207
  };
199
208
  export type LineConfig = LineConfigBase & {
@@ -219,7 +228,6 @@ export type BarConfigBase = {
219
228
  export type BarConfig = BarConfigBase & {
220
229
  exportHooks?: ExportHooks<BarConfigBase>;
221
230
  };
222
- export type AreaCurveType = 'linear' | 'monotone' | 'step' | 'natural' | 'basis' | 'cardinal';
223
231
  export type AreaConfigBase = {
224
232
  dataKey: string;
225
233
  fill?: string;
@@ -245,6 +253,7 @@ export type BarStackConfig = {
245
253
  export type AreaStackMode = 'none' | 'normal' | 'percent';
246
254
  export type AreaStackConfig = {
247
255
  mode?: AreaStackMode;
256
+ reverseSeries?: boolean;
248
257
  };
249
258
  export declare function getSeriesColor(series: {
250
259
  stroke?: string;
@@ -300,6 +309,7 @@ export type TooltipConfigBase = {
300
309
  mode?: TooltipMode;
301
310
  position?: TooltipPosition;
302
311
  barAnchorPosition?: TooltipBarAnchorPosition;
312
+ maxWidth?: number;
303
313
  transition?: TooltipTransitionConfig;
304
314
  formatter?: SeriesValueFormatter;
305
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
@@ -338,6 +338,10 @@ new Line({
338
338
  dataKey: string, // Key in data objects for Y values (required)
339
339
  stroke?: string, // Line color (auto-assigned if omitted)
340
340
  strokeWidth?: number, // Line width in pixels (default: 2)
341
+ curve?: 'linear' | 'monotone' | 'step' | 'natural' | 'basis' | 'cardinal',
342
+ points?: {
343
+ show?: 'always' | 'hover' | 'never'
344
+ }, // Data point visibility (default: 'always')
341
345
  valueLabel?: {
342
346
  show?: boolean,
343
347
  formatter?: (dataKey, value, data) => string
@@ -355,8 +359,20 @@ chart.addChild(new Line({ dataKey: 'expenses' }));
355
359
  // Manual colors
356
360
  chart.addChild(new Line({ dataKey: 'revenue', stroke: '#00ff00' }));
357
361
  chart.addChild(new Line({ dataKey: 'expenses', stroke: '#ff0000' }));
362
+
363
+ // Smooth line interpolation
364
+ chart.addChild(new Line({ dataKey: 'revenue', curve: 'monotone' }));
365
+
366
+ // Show point circles only while hovering or focusing a tooltip target
367
+ chart
368
+ .addChild(new Line({ dataKey: 'revenue', points: { show: 'hover' } }))
369
+ .addChild(new Tooltip());
358
370
  ```
359
371
 
372
+ `points.show: 'hover'` uses `Tooltip` focus markers, so add a `Tooltip`
373
+ component when hover-only points should be visible. Use `'never'` to suppress
374
+ both static line points and tooltip focus markers for that line.
375
+
360
376
  ---
361
377
 
362
378
  ## Scatter
@@ -499,6 +515,8 @@ series at the hovered category. Use
499
515
  to override the grouping and split-tooltip placement.
500
516
  For bars, set `barAnchorPosition: 'top' | 'middle'` to choose whether split
501
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.
502
520
  Use `transition: { show: true, duration: 120, easing: 'ease-out' }` to opt in
503
521
  to softer tooltip opacity transitions without delaying position updates.
504
522
 
@@ -560,12 +578,16 @@ Area charts support stacking when series share the same `stackId`:
560
578
  ```javascript
561
579
  const chart = new XYChart({
562
580
  data,
563
- areaStack: { mode: 'percent' },
581
+ areaStack: { mode: 'percent', reverseSeries: true },
564
582
  });
565
583
 
566
584
  chart.addChild(new Area({ dataKey: 'desktop', stackId: 'traffic' })).addChild(new Area({ dataKey: 'mobile', stackId: 'traffic' }));
567
585
  ```
568
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
+
569
591
  ---
570
592
 
571
593
  ## Mixed Charts
package/package.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.13.0",
2
+ "version": "0.13.2",
3
3
  "name": "@internetstiftelsen/charts",
4
4
  "type": "module",
5
5
  "sideEffects": false,