@internetstiftelsen/charts 0.20.0 → 0.21.0

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.
@@ -208,7 +208,7 @@ export declare abstract class BaseChart {
208
208
  protected prepareForLegendChange(): void;
209
209
  protected initializeDataState(): void;
210
210
  protected prepareLayout(context: BaseLayoutContext): void;
211
- private applyPlotAreaOverride;
211
+ protected applyPlotAreaOverride(plotArea: PlotAreaBounds): PlotAreaBounds;
212
212
  /**
213
213
  * Setup ResizeObserver for automatic resize handling
214
214
  */
@@ -11,6 +11,7 @@ type ResolvedTooltipStyle = ChartTheme['tooltip'] & {
11
11
  };
12
12
  export type TooltipStyleOverrides = Partial<Pick<ResolvedTooltipStyle, 'background' | 'border' | 'color' | 'connectorColor'>>;
13
13
  export declare class TooltipDom {
14
+ private static activeTooltip;
14
15
  private readonly id;
15
16
  private readonly splitTooltipOwner;
16
17
  private readonly maxWidth;
@@ -20,6 +21,7 @@ export declare class TooltipDom {
20
21
  private readonly tooltipPositionResetTimeoutIds;
21
22
  private readonly tooltipStyles;
22
23
  private tooltipDiv;
24
+ private hideListener;
23
25
  constructor(config: TooltipDomConfig);
24
26
  initialize(theme: ChartTheme): void;
25
27
  getRootTooltip(): TooltipDivSelection | null;
@@ -27,7 +29,11 @@ export declare class TooltipDom {
27
29
  setContent(content: string): void;
28
30
  getBounds(): DOMRect | null;
29
31
  showAt(left: number, top: number): void;
30
- hide(): void;
32
+ hasVisibleTooltips(): boolean;
33
+ activate(): void;
34
+ setHideListener(listener: (() => void) | null): void;
35
+ moveVisibleTooltips(deltaX: number, deltaY: number): void;
36
+ hide(immediate?: boolean): void;
31
37
  cleanup(): void;
32
38
  measureTooltip(tooltip: TooltipDivSelection, content: string): {
33
39
  width: number;
@@ -50,6 +56,7 @@ export declare class TooltipDom {
50
56
  private scheduleTooltipPositionReset;
51
57
  private cancelTooltipPositionReset;
52
58
  private getTooltipTransitionStyle;
59
+ private getTooltipNodes;
53
60
  private isTooltipVisible;
54
61
  private getTooltipPosition;
55
62
  private hasVisibleSlideOffset;
@@ -58,6 +58,12 @@ export class TooltipDom {
58
58
  writable: true,
59
59
  value: null
60
60
  });
61
+ Object.defineProperty(this, "hideListener", {
62
+ enumerable: true,
63
+ configurable: true,
64
+ writable: true,
65
+ value: null
66
+ });
61
67
  this.id = config.id;
62
68
  this.splitTooltipOwner = config.splitTooltipOwner;
63
69
  this.maxWidth = config.maxWidth;
@@ -108,14 +114,45 @@ export class TooltipDom {
108
114
  }
109
115
  this.showTooltipAt(this.tooltipDiv, left, top);
110
116
  }
111
- hide() {
112
- const tooltip = this.tooltipDiv ?? select(`#${this.id}`);
113
- if (!tooltip.empty()) {
114
- this.hideTooltipSelection(tooltip);
117
+ hasVisibleTooltips() {
118
+ return this.getTooltipNodes().some((node) => this.isTooltipVisible(node));
119
+ }
120
+ activate() {
121
+ if (TooltipDom.activeTooltip === this) {
122
+ return;
115
123
  }
116
- this.hideSplitTooltips();
124
+ TooltipDom.activeTooltip?.hide(true);
125
+ TooltipDom.activeTooltip = this;
126
+ }
127
+ setHideListener(listener) {
128
+ this.hideListener = listener;
129
+ }
130
+ moveVisibleTooltips(deltaX, deltaY) {
131
+ this.getTooltipNodes().forEach((node) => {
132
+ if (!this.isTooltipVisible(node)) {
133
+ return;
134
+ }
135
+ const position = this.getTooltipPosition(node);
136
+ if (!position) {
137
+ return;
138
+ }
139
+ node.style.left = `${position.left + deltaX}px`;
140
+ node.style.top = `${position.top + deltaY}px`;
141
+ });
142
+ }
143
+ hide(immediate = false) {
144
+ this.hideListener?.();
145
+ this.getTooltipNodes().forEach((node) => {
146
+ this.hideTooltipElement(node, immediate);
147
+ });
148
+ // Retain ownership during fade-out so the next chart can hide it immediately.
117
149
  }
118
150
  cleanup() {
151
+ this.hide(true);
152
+ this.hideListener = null;
153
+ if (TooltipDom.activeTooltip === this) {
154
+ TooltipDom.activeTooltip = null;
155
+ }
119
156
  this.removeRootTooltip();
120
157
  this.removeSplitTooltips();
121
158
  this.tooltipDiv = null;
@@ -295,6 +332,7 @@ export class TooltipDom {
295
332
  if (!node) {
296
333
  return;
297
334
  }
335
+ this.activate();
298
336
  this.cancelTooltipPositionReset(node);
299
337
  tooltip.style('visibility', 'visible');
300
338
  if (!this.transition.show) {
@@ -308,7 +346,7 @@ export class TooltipDom {
308
346
  }
309
347
  this.slideTooltipFromOffset(node, slideOffset);
310
348
  }
311
- hideTooltipElement(node) {
349
+ hideTooltipElement(node, immediate = false) {
312
350
  const wasVisible = this.isTooltipVisible(node);
313
351
  this.cancelTooltipPositionReset(node);
314
352
  this.cancelTooltipTransitionFrame(node);
@@ -317,10 +355,10 @@ export class TooltipDom {
317
355
  this.resetTooltipPosition(node);
318
356
  return;
319
357
  }
320
- node.style.visibility = 'visible';
358
+ node.style.visibility = wasVisible && !immediate ? 'visible' : 'hidden';
321
359
  node.style.opacity = '0';
322
360
  node.style.transform = TOOLTIP_HIDDEN_TRANSFORM;
323
- if (!wasVisible) {
361
+ if (!wasVisible || immediate) {
324
362
  this.resetTooltipPosition(node);
325
363
  return;
326
364
  }
@@ -350,6 +388,9 @@ export class TooltipDom {
350
388
  getTooltipTransitionStyle() {
351
389
  return `opacity ${this.transition.duration}ms ${this.transition.easing}, transform ${this.transition.duration}ms ${this.transition.easing}`;
352
390
  }
391
+ getTooltipNodes() {
392
+ return Array.from(document.querySelectorAll(`#${this.id}, [data-chart-tooltip-owner="${this.splitTooltipOwner}"]`));
393
+ }
353
394
  isTooltipVisible(node) {
354
395
  return (node.style.visibility === 'visible' && node.style.opacity !== '0');
355
396
  }
@@ -498,3 +539,9 @@ export class TooltipDom {
498
539
  }
499
540
  }
500
541
  }
542
+ Object.defineProperty(TooltipDom, "activeTooltip", {
543
+ enumerable: true,
544
+ configurable: true,
545
+ writable: true,
546
+ value: null
547
+ });
@@ -301,11 +301,11 @@ export function attachXYTooltipArea(config) {
301
301
  const svgNode = svg.node();
302
302
  if (!svgNode) {
303
303
  dom.hideTooltipSelection(tooltip);
304
- return null;
304
+ return;
305
305
  }
306
306
  const placementBounds = getTooltipPlacementBounds(svgNode);
307
307
  const candidates = [];
308
- series.forEach((currentSeries, seriesIndex) => {
308
+ series.forEach((currentSeries) => {
309
309
  const rawValue = dataPoint[currentSeries.dataKey];
310
310
  if (rawValue === null || rawValue === undefined) {
311
311
  return;
@@ -319,59 +319,93 @@ export function attachXYTooltipArea(config) {
319
319
  const target = resolveSplitTooltipTarget(currentSeries, visibleAnchor, resolvedBarAnchorPosition);
320
320
  candidates.push({
321
321
  series: currentSeries,
322
- seriesIndex,
323
322
  anchor: visibleAnchor,
324
323
  target,
325
324
  });
326
325
  });
327
- const selectedCandidate = candidates.find((candidate) => candidate.seriesIndex === request.seriesIndex) ?? getClosestSingleTooltipCandidate(candidates, request);
326
+ const selectedCandidate = getClosestSingleTooltipCandidate(candidates, request);
328
327
  updateVisualStateAtIndex(request.index);
329
328
  dom.hideSplitTooltips();
330
329
  if (!selectedCandidate) {
331
330
  dom.hideTooltipSelection(tooltip);
332
- return null;
331
+ return;
333
332
  }
334
333
  dom.applyRootTooltipStyles(theme, getSeriesTooltipStyle(selectedCandidate.series, dataPoint, request.index));
335
334
  const content = buildSplitTooltipContent(dataPoint, selectedCandidate.series);
336
335
  const measuredTooltip = dom.measureTooltip(tooltip, content);
337
336
  if (!measuredTooltip) {
338
337
  dom.hideTooltipSelection(tooltip);
339
- return null;
338
+ return;
340
339
  }
341
340
  const arrowEdge = resolveTooltipArrowEdge(resolvedPosition, selectedCandidate.anchor, selectedCandidate.target, measuredTooltip.width, measuredTooltip.height, placementBounds);
342
341
  const tooltipPosition = getAnchoredTooltipPosition(selectedCandidate.anchor, selectedCandidate.target, measuredTooltip.width, measuredTooltip.height, arrowEdge, placementBounds);
343
342
  if (!tooltipPosition) {
344
343
  dom.hideTooltipSelection(tooltip);
345
- return null;
344
+ return;
346
345
  }
347
346
  dom.renderTooltipWithConnector(tooltip, arrowEdge, tooltipPosition.left, tooltipPosition.top, measuredTooltip.width, measuredTooltip.height, selectedCandidate.target.x, selectedCandidate.target.y, selectedCandidate.anchor);
348
- return {
349
- index: request.index,
350
- seriesIndex: selectedCandidate.seriesIndex,
351
- };
352
- };
353
- let activeTooltipRequest = null;
354
- const hideTooltip = () => {
355
- activeTooltipRequest = null;
356
- dom.hideTooltipSelection(tooltip);
357
- dom.hideSplitTooltips();
358
- clearVisualState();
359
347
  };
360
348
  const tooltipSvgNode = svg.node();
361
- const queuedRender = createQueuedTooltipRender(tooltipSvgNode, (request) => {
349
+ let activeSvgBounds = null;
350
+ let positionTrackingFrame = null;
351
+ const stopPositionTracking = () => {
352
+ activeSvgBounds = null;
353
+ if (positionTrackingFrame !== null) {
354
+ cancelFrame(positionTrackingFrame);
355
+ positionTrackingFrame = null;
356
+ }
357
+ };
358
+ const updateTooltipPosition = () => {
359
+ if (!activeSvgBounds || !tooltipSvgNode) {
360
+ return;
361
+ }
362
+ const nextSvgBounds = getElementTooltipBounds(tooltipSvgNode);
363
+ const deltaX = nextSvgBounds.minLeft - activeSvgBounds.minLeft;
364
+ const deltaY = nextSvgBounds.minTop - activeSvgBounds.minTop;
365
+ if (deltaX === 0 && deltaY === 0) {
366
+ return;
367
+ }
368
+ dom.moveVisibleTooltips(deltaX, deltaY);
369
+ activeSvgBounds = nextSvgBounds;
370
+ };
371
+ const trackTooltipPosition = () => {
372
+ positionTrackingFrame = null;
373
+ if (!tooltipSvgNode?.isConnected || !dom.hasVisibleTooltips()) {
374
+ stopPositionTracking();
375
+ return;
376
+ }
377
+ // Scroll events can arrive late during touch or momentum scrolling.
378
+ updateTooltipPosition();
379
+ positionTrackingFrame = requestFrame(trackTooltipPosition);
380
+ };
381
+ const renderTooltip = (request) => {
382
+ if (!tooltipSvgNode) {
383
+ return;
384
+ }
385
+ dom.activate();
386
+ activeSvgBounds = getElementTooltipBounds(tooltipSvgNode);
387
+ if (positionTrackingFrame === null) {
388
+ positionTrackingFrame = requestFrame(trackTooltipPosition);
389
+ }
362
390
  if (mode === 'single') {
363
- activeTooltipRequest = showSingleTooltip(request);
391
+ showSingleTooltip(request);
364
392
  return;
365
393
  }
366
- activeTooltipRequest = { index: request.index };
367
394
  if (mode === 'split') {
368
395
  showSplitTooltipAtIndex(request.index);
369
396
  return;
370
397
  }
371
398
  showSharedTooltipAtIndex(request.index);
399
+ };
400
+ const queuedRender = createQueuedTooltipRender(tooltipSvgNode, renderTooltip);
401
+ dom.setHideListener(() => {
402
+ queuedRender.cancel();
403
+ stopPositionTracking();
404
+ clearVisualState();
372
405
  });
373
406
  overlay
374
407
  .on('mousemove', (event) => {
408
+ dom.activate();
375
409
  const [mouseX, mouseY] = pointer(event, svg.node());
376
410
  const closestIndex = getClosestIndexFromPointer(mouseX, mouseY, dataPointPositions, isHorizontal);
377
411
  const pointerPosition = getDocumentPointerPosition(event);
@@ -385,8 +419,7 @@ export function attachXYTooltipArea(config) {
385
419
  if (isTooltipFocusTarget(document.activeElement)) {
386
420
  return;
387
421
  }
388
- queuedRender.cancel();
389
- hideTooltip();
422
+ dom.hide();
390
423
  });
391
424
  const focusTargets = svg
392
425
  .append('g')
@@ -415,26 +448,14 @@ export function attachXYTooltipArea(config) {
415
448
  }
416
449
  queuedRender.cancel();
417
450
  select(this).attr('stroke', '#111827').attr('stroke-width', 2);
418
- if (mode === 'single') {
419
- activeTooltipRequest = showSingleTooltip({
420
- index: currentIndex,
421
- });
422
- return;
423
- }
424
- activeTooltipRequest = { index: currentIndex };
425
- if (mode === 'split') {
426
- showSplitTooltipAtIndex(currentIndex);
427
- return;
428
- }
429
- showSharedTooltipAtIndex(currentIndex);
451
+ renderTooltip({ index: currentIndex });
430
452
  })
431
453
  .on('blur', function (event) {
432
454
  select(this).attr('stroke', 'none').attr('stroke-width', 0);
433
455
  if (isTooltipFocusTarget(event.relatedTarget)) {
434
456
  return;
435
457
  }
436
- queuedRender.cancel();
437
- hideTooltip();
458
+ dom.hide();
438
459
  })
439
460
  .on('keydown', function (event) {
440
461
  const currentIndex = focusTargetNodes.indexOf(this);
@@ -448,12 +469,13 @@ export function attachXYTooltipArea(config) {
448
469
  event.preventDefault();
449
470
  focusTargetNodes[nextIndex].focus();
450
471
  });
451
- return attachTooltipScrollListeners(tooltipSvgNode, () => {
452
- if (activeTooltipRequest === null) {
453
- return;
454
- }
455
- queuedRender.request(activeTooltipRequest);
456
- });
472
+ const detachScrollListeners = attachTooltipScrollListeners(tooltipSvgNode, updateTooltipPosition);
473
+ return () => {
474
+ detachScrollListeners?.();
475
+ queuedRender.cancel();
476
+ stopPositionTracking();
477
+ dom.setHideListener(null);
478
+ };
457
479
  }
458
480
  function normalizeFormatterValue(value) {
459
481
  if (value === null ||
package/dist/types.d.ts CHANGED
@@ -274,10 +274,15 @@ export type XAxisConfigBase = {
274
274
  groupLabelMaxWidth?: number;
275
275
  groupLabelOversizedBehavior?: LabelOversizedBehavior;
276
276
  rotatedLabels?: boolean;
277
+ /** Overrides automatic width for unrotated category labels; also caps numeric/time labels. */
277
278
  maxLabelWidth?: number;
279
+ /** Automatically size category labels when maxLabelWidth is omitted. Default: true. */
280
+ autoMaxLabelWidth?: boolean;
281
+ /** Handling for labels exceeding their available width. Default: 'truncate'. */
278
282
  oversizedBehavior?: LabelOversizedBehavior;
279
283
  tickFormat?: string | AxisTickFormatter | null;
280
284
  autoHideOverlapping?: boolean;
285
+ /** Gap for automatic category-label sizing and overlap hiding. Default: 8px. */
281
286
  minLabelGap?: number;
282
287
  preserveEndLabels?: boolean;
283
288
  };
@@ -288,7 +293,11 @@ export type YAxisConfigBase = {
288
293
  display?: boolean;
289
294
  tickFormat?: string | AxisTickFormatter | null;
290
295
  rotatedLabels?: boolean;
296
+ /** Overrides the automatic category-axis budget (one third of inner chart width, less tick padding). */
291
297
  maxLabelWidth?: number;
298
+ /** Automatically size category labels when maxLabelWidth is omitted. Default: true. */
299
+ autoMaxLabelWidth?: boolean;
300
+ /** Handling for labels exceeding their available width. Default: 'truncate'. */
292
301
  oversizedBehavior?: LabelOversizedBehavior;
293
302
  };
294
303
  export type YAxisConfig = YAxisConfigBase & {
package/dist/x-axis.d.ts CHANGED
@@ -15,6 +15,7 @@ export declare class XAxis implements LayoutAwareComponent<XAxisConfigBase> {
15
15
  private readonly tickPadding;
16
16
  private fontSize;
17
17
  private readonly maxLabelWidth?;
18
+ private readonly autoMaxLabelWidth;
18
19
  private readonly oversizedBehavior;
19
20
  private readonly tickFormat;
20
21
  private wrapLineCount;
@@ -34,7 +35,8 @@ export declare class XAxis implements LayoutAwareComponent<XAxisConfigBase> {
34
35
  * Returns the space required by the x-axis
35
36
  */
36
37
  getRequiredSpace(): ComponentSpace;
37
- estimateLayoutSpace(labels: unknown[], theme: ChartTheme, svg: SVGSVGElement, data?: DataItem[], estimatedXAxisRangeWidth?: number): void;
38
+ estimateLayoutSpace(labels: unknown[], theme: ChartTheme, svg: SVGSVGElement, data?: DataItem[], estimatedXAxisRangeWidth?: number, categoryScale?: D3Scale): void;
39
+ private getEstimateLabels;
38
40
  clearEstimatedSpace(): void;
39
41
  private getTickLabelVerticalFootprint;
40
42
  render(svg: Selection<SVGSVGElement, undefined, null, undefined>, x: D3Scale, theme: ChartTheme, yPosition: number, data?: DataItem[]): void;
@@ -42,6 +44,7 @@ export declare class XAxis implements LayoutAwareComponent<XAxisConfigBase> {
42
44
  private renderGroupLabels;
43
45
  private buildGroupRanges;
44
46
  private applyLabelConstraints;
47
+ private getAutomaticLabelWidths;
45
48
  private applyGroupLabelConstraints;
46
49
  private resolveGroupLabelMaxWidth;
47
50
  private wrapTextElement;
@@ -54,7 +57,6 @@ export declare class XAxis implements LayoutAwareComponent<XAxisConfigBase> {
54
57
  private getLabelBlockHeight;
55
58
  private setEstimatedDimensions;
56
59
  private createAxisGenerator;
57
- private applyAxisTextConstraints;
58
60
  private applyLabelRotation;
59
61
  private resolveGroupRangeInput;
60
62
  private resolveGroupLabelKey;
package/dist/x-axis.js CHANGED
@@ -8,6 +8,7 @@ const DEFAULT_X_AXIS_CONFIG = {
8
8
  groupLabelGap: 10,
9
9
  groupLabelOversizedBehavior: 'truncate',
10
10
  rotatedLabels: false,
11
+ autoMaxLabelWidth: true,
11
12
  oversizedBehavior: 'truncate',
12
13
  tickFormat: null,
13
14
  autoHideOverlapping: false,
@@ -119,6 +120,12 @@ export class XAxis {
119
120
  writable: true,
120
121
  value: void 0
121
122
  });
123
+ Object.defineProperty(this, "autoMaxLabelWidth", {
124
+ enumerable: true,
125
+ configurable: true,
126
+ writable: true,
127
+ value: void 0
128
+ });
122
129
  Object.defineProperty(this, "oversizedBehavior", {
123
130
  enumerable: true,
124
131
  configurable: true,
@@ -194,6 +201,7 @@ export class XAxis {
194
201
  resolvedConfig.groupLabelOversizedBehavior;
195
202
  this.rotatedLabels = resolvedConfig.rotatedLabels;
196
203
  this.maxLabelWidth = resolvedConfig.maxLabelWidth;
204
+ this.autoMaxLabelWidth = resolvedConfig.autoMaxLabelWidth;
197
205
  this.oversizedBehavior = resolvedConfig.oversizedBehavior;
198
206
  this.tickFormat = resolvedConfig.tickFormat;
199
207
  this.autoHideOverlapping = resolvedConfig.autoHideOverlapping;
@@ -213,6 +221,7 @@ export class XAxis {
213
221
  groupLabelOversizedBehavior: this.groupLabelOversizedBehavior,
214
222
  rotatedLabels: this.rotatedLabels,
215
223
  maxLabelWidth: this.maxLabelWidth,
224
+ autoMaxLabelWidth: this.autoMaxLabelWidth,
216
225
  oversizedBehavior: this.oversizedBehavior,
217
226
  tickFormat: this.tickFormat,
218
227
  autoHideOverlapping: this.autoHideOverlapping,
@@ -250,9 +259,7 @@ export class XAxis {
250
259
  const baseHeight = this.tickPadding + this.fontSize + 5;
251
260
  let height = this.rotatedLabels ? baseHeight * 2.5 : baseHeight;
252
261
  // Account for wrapped text height (multiply by estimated line count)
253
- if (this.maxLabelWidth &&
254
- this.oversizedBehavior === 'wrap' &&
255
- this.wrapLineCount > 1) {
262
+ if (this.oversizedBehavior === 'wrap' && this.wrapLineCount > 1) {
256
263
  height += (this.wrapLineCount - 1) * this.fontSize * 1.2;
257
264
  }
258
265
  if (this.showGroupLabels) {
@@ -267,7 +274,7 @@ export class XAxis {
267
274
  position: 'bottom',
268
275
  };
269
276
  }
270
- estimateLayoutSpace(labels, theme, svg, data = [], estimatedXAxisRangeWidth) {
277
+ estimateLayoutSpace(labels, theme, svg, data = [], estimatedXAxisRangeWidth, categoryScale) {
271
278
  if (!labels.length) {
272
279
  this.estimatedHeight = null;
273
280
  this.estimatedTickLabelVerticalFootprint = null;
@@ -281,12 +288,13 @@ export class XAxis {
281
288
  const fontWeight = theme.axis.fontWeight || 'normal';
282
289
  let maxWidth = 0;
283
290
  let maxLines = 1;
284
- for (const label of labels) {
285
- const text = String(label ?? '');
291
+ const automaticWidths = this.getAutomaticLabelWidths(categoryScale);
292
+ const tickLabels = this.getEstimateLabels(labels, data, categoryScale);
293
+ for (const { value, text } of tickLabels) {
286
294
  if (!text) {
287
295
  continue;
288
296
  }
289
- const measurement = this.measureLabel(text, fontSize, fontFamily, fontWeight, svg);
297
+ const measurement = this.measureLabel(text, fontSize, fontFamily, fontWeight, svg, this.maxLabelWidth ?? automaticWidths.get(value));
290
298
  maxLines = Math.max(maxLines, measurement.lines);
291
299
  maxWidth = Math.max(maxWidth, measurement.width);
292
300
  }
@@ -294,10 +302,22 @@ export class XAxis {
294
302
  this.setEstimatedDimensions(maxWidth, maxLines, theme);
295
303
  this.wrapLineCount = maxLines;
296
304
  }
305
+ getEstimateLabels(labels, data, scale) {
306
+ const formatter = scale
307
+ ? this.createAxisGenerator(scale, this.buildLabelLookup(data)).tickFormat()
308
+ : null;
309
+ return (scale?.domain() ?? labels).map((value, index) => ({
310
+ value: value,
311
+ text: formatter
312
+ ? formatter(value, index)
313
+ : String(value ?? ''),
314
+ }));
315
+ }
297
316
  clearEstimatedSpace() {
298
317
  this.estimatedHeight = null;
299
318
  this.estimatedTickLabelVerticalFootprint = null;
300
319
  this.groupLabelWrapLineCount = 1;
320
+ this.wrapLineCount = 1;
301
321
  }
302
322
  getTickLabelVerticalFootprint() {
303
323
  if (this.estimatedTickLabelVerticalFootprint !== null) {
@@ -325,7 +345,9 @@ export class XAxis {
325
345
  .attr('font-family', theme.axis.fontFamily)
326
346
  .attr('font-weight', theme.axis.fontWeight || 'normal')
327
347
  .attr('stroke', 'none');
328
- this.applyAxisTextConstraints(axis, svg.node(), theme);
348
+ this.fontSize = this.resolveFontSizeValue(theme.axis.fontSize, this.fontSize);
349
+ this.wrapLineCount = 1;
350
+ this.applyLabelConstraints(axis, svg.node(), theme.axis.fontSize, theme.axis.fontFamily, theme.axis.fontWeight || 'normal', x);
329
351
  this.applyLabelRotation(axis);
330
352
  this.applyAutoHiding(axis, x);
331
353
  axis.selectAll('.domain').remove();
@@ -419,15 +441,21 @@ export class XAxis {
419
441
  }
420
442
  return ranges.filter((range) => range.label.trim() !== '');
421
443
  }
422
- applyLabelConstraints(axisGroup, svg, fontSize, fontFamily, fontWeight) {
423
- if (!this.maxLabelWidth)
424
- return;
425
- const maxWidth = this.maxLabelWidth;
444
+ applyLabelConstraints(axisGroup, svg, fontSize, fontFamily, fontWeight, scale) {
445
+ const automaticWidths = this.getAutomaticLabelWidths(scale);
426
446
  const behavior = this.oversizedBehavior;
427
447
  axisGroup
428
448
  .selectAll('text')
429
- .each((_d, i, nodes) => {
449
+ .each((value, i, nodes) => {
430
450
  const textEl = nodes[i];
451
+ const maxWidth = this.maxLabelWidth ??
452
+ automaticWidths.get(value);
453
+ if (maxWidth === undefined)
454
+ return;
455
+ if (maxWidth <= 0) {
456
+ textEl.style.visibility = 'hidden';
457
+ return;
458
+ }
431
459
  const originalText = textEl.textContent || '';
432
460
  const textWidth = measureTextWidth(originalText, fontSize, fontFamily, fontWeight, svg);
433
461
  if (textWidth <= maxWidth) {
@@ -454,6 +482,35 @@ export class XAxis {
454
482
  }
455
483
  });
456
484
  }
485
+ getAutomaticLabelWidths(scale) {
486
+ const widths = new Map();
487
+ if (!this.autoMaxLabelWidth ||
488
+ this.maxLabelWidth !== undefined ||
489
+ this.rotatedLabels ||
490
+ !scale?.bandwidth) {
491
+ return widths;
492
+ }
493
+ const domain = scale.domain();
494
+ const halfBand = scale.bandwidth() / 2;
495
+ const positions = domain.map((value) => scale(value) + halfBand);
496
+ const rangeStart = Math.min(...scale.range());
497
+ const rangeEnd = Math.max(...scale.range());
498
+ domain.forEach((value, index) => {
499
+ const position = positions[index];
500
+ // Centered labels share the distance between neighboring ticks.
501
+ // Edge labels must also stay inside the axis range.
502
+ const previousGap = index > 0
503
+ ? Math.abs(position - positions[index - 1]) -
504
+ this.minLabelGap
505
+ : Infinity;
506
+ const nextGap = index < domain.length - 1
507
+ ? Math.abs(positions[index + 1] - position) -
508
+ this.minLabelGap
509
+ : Infinity;
510
+ widths.set(value, Math.max(0, Math.min(previousGap, nextGap, 2 * (position - rangeStart), 2 * (rangeEnd - position))));
511
+ });
512
+ return widths;
513
+ }
457
514
  applyGroupLabelConstraints(groupLabels, svg, groupLabelStyle) {
458
515
  groupLabels.each((range, i, nodes) => {
459
516
  const textEl = nodes[i];
@@ -507,7 +564,7 @@ export class XAxis {
507
564
  const tspan = document.createElementNS('http://www.w3.org/2000/svg', 'tspan');
508
565
  tspan.textContent = line;
509
566
  tspan.setAttribute('x', textEl.getAttribute('x') || '0');
510
- tspan.setAttribute('dy', i === 0 ? '0' : `${lineHeight}px`);
567
+ tspan.setAttribute('dy', i === 0 ? textEl.getAttribute('dy') || '0' : `${lineHeight}px`);
511
568
  textEl.appendChild(tspan);
512
569
  });
513
570
  // Add tooltip with full text
@@ -541,19 +598,27 @@ export class XAxis {
541
598
  this.hideLastOverlappingIntervalLabel(labelEntries, skipInterval);
542
599
  }
543
600
  }
544
- measureLabel(text, fontSize, fontFamily, fontWeight, svg) {
545
- if (this.maxLabelWidth && this.oversizedBehavior === 'wrap') {
546
- const lines = wrapText(text, this.maxLabelWidth, fontSize, fontFamily, fontWeight, svg);
601
+ measureLabel(text, fontSize, fontFamily, fontWeight, svg, maxWidth) {
602
+ if (maxWidth !== undefined && maxWidth <= 0) {
603
+ return { width: 0, lines: 1 };
604
+ }
605
+ if (maxWidth !== undefined && this.oversizedBehavior === 'wrap') {
606
+ const lines = wrapText(text, maxWidth, fontSize, fontFamily, fontWeight, svg);
547
607
  return {
548
- width: this.maxLabelWidth,
608
+ width: Math.max(...lines.map((line) => measureTextWidth(line, fontSize, fontFamily, fontWeight, svg))),
549
609
  lines: lines.length || 1,
550
610
  };
551
611
  }
552
612
  const textWidth = measureTextWidth(text, fontSize, fontFamily, fontWeight, svg);
613
+ if (maxWidth !== undefined &&
614
+ this.oversizedBehavior === 'hide' &&
615
+ textWidth > maxWidth) {
616
+ return { width: 0, lines: 1 };
617
+ }
553
618
  return {
554
- width: this.maxLabelWidth
555
- ? Math.min(textWidth, this.maxLabelWidth)
556
- : textWidth,
619
+ width: maxWidth === undefined
620
+ ? textWidth
621
+ : Math.min(textWidth, maxWidth),
557
622
  lines: 1,
558
623
  };
559
624
  }
@@ -664,12 +729,6 @@ export class XAxis {
664
729
  axisGenerator.ticks(5, tickFormat);
665
730
  return axisGenerator;
666
731
  }
667
- applyAxisTextConstraints(axis, svg, theme) {
668
- if (!this.maxLabelWidth) {
669
- return;
670
- }
671
- this.applyLabelConstraints(axis, svg, theme.axis.fontSize, theme.axis.fontFamily, theme.axis.fontWeight || 'normal');
672
- }
673
732
  applyLabelRotation(axis) {
674
733
  if (!this.rotatedLabels) {
675
734
  return;
@@ -742,6 +801,9 @@ export class XAxis {
742
801
  }
743
802
  applyAutoHideVisibility(labelEntries, skipInterval) {
744
803
  labelEntries.forEach((textEl, index) => {
804
+ // Overflow hiding takes precedence over interval/end-label rules.
805
+ if (textEl.style.visibility === 'hidden')
806
+ return;
745
807
  const isFirst = index === 0;
746
808
  const isLast = index === labelEntries.length - 1;
747
809
  const isAtInterval = index % skipInterval === 0;
@@ -26,7 +26,6 @@ export declare class XYChart extends BaseChart {
26
26
  update(data: ChartData): void;
27
27
  protected applyComponentOverrides(overrides: Map<ChartComponentBase, ChartComponentBase>): () => void;
28
28
  protected prepareLayout(context: BaseLayoutContext): void;
29
- private getEstimatedXAxisRangeWidth;
30
29
  private getYAxisEstimateLabels;
31
30
  private createContinuousScaleForLayoutEstimate;
32
31
  protected renderChart({ svg, plotGroup, plotArea, }: BaseRenderContext): void;
package/dist/xy-chart.js CHANGED
@@ -4,6 +4,7 @@ import { ChartValidationError, ChartValidator } from './validation.js';
4
4
  import { GROUPED_GAP_TICK_PREFIX, GROUPED_GROUP_LABEL_KEY, } from './grouped-data.js';
5
5
  import { resolveScaleValue } from './scale-utils.js';
6
6
  import { mergeDeep } from './utils.js';
7
+ import { LayoutManager } from './layout-manager.js';
7
8
  import { createXYMotionDriver, } from './xy-motion/driver.js';
8
9
  import { createXYSeriesSnapshotId } from './xy-motion/helpers.js';
9
10
  const DEFAULT_SERIES_COLOR = '#8884d8';
@@ -153,10 +154,15 @@ export class XYChart extends BaseChart {
153
154
  }
154
155
  prepareLayout(context) {
155
156
  super.prepareLayout(context);
156
- this.xAxis?.clearEstimatedSpace?.();
157
- this.yAxis?.clearEstimatedSpace?.();
157
+ this.xAxis?.clearEstimatedSpace();
158
+ this.yAxis?.clearEstimatedSpace();
159
+ const { x: xConfig, y: yConfig } = this.getResolvedAxisConfigs();
158
160
  if (this.yAxis) {
159
- this.yAxis.estimateLayoutSpace?.(this.getYAxisEstimateLabels(), this.renderTheme, context.svgNode);
161
+ this.yAxis.estimateLayoutSpace(this.getYAxisEstimateLabels(), this.renderTheme, context.svgNode, yConfig.type === 'band'
162
+ ? Math.max(0, this.width -
163
+ this.renderTheme.margins.left -
164
+ this.renderTheme.margins.right) / 3
165
+ : undefined);
160
166
  }
161
167
  if (this.xAxis) {
162
168
  const xKey = this.getXKey();
@@ -167,15 +173,17 @@ export class XYChart extends BaseChart {
167
173
  }
168
174
  return item[xKey];
169
175
  });
170
- this.xAxis.estimateLayoutSpace?.(labels, this.renderTheme, context.svgNode, this.data, this.getEstimatedXAxisRangeWidth());
176
+ const plotArea = this.applyPlotAreaOverride(new LayoutManager(this.resolvedRenderTheme).calculateLayout(this.getLayoutComponents()));
177
+ const range = [
178
+ plotArea.left + 10,
179
+ Math.max(plotArea.left + 10, plotArea.right),
180
+ ];
181
+ const categoryScale = xConfig.type === 'band'
182
+ ? this.buildScale('band', this.resolveScaleDomain(xConfig, xKey), xConfig.reverse ? [range[1], range[0]] : range, xConfig)
183
+ : undefined;
184
+ this.xAxis.estimateLayoutSpace(labels, this.renderTheme, context.svgNode, this.data, range[1] - range[0], categoryScale);
171
185
  }
172
186
  }
173
- getEstimatedXAxisRangeWidth() {
174
- const yAxisWidth = this.yAxis?.getRequiredSpace().width ?? 0;
175
- const { left, right } = this.renderTheme.margins;
176
- const xScalePadding = 10;
177
- return Math.max(0, this.width - left - right - yAxisWidth - xScalePadding);
178
- }
179
187
  getYAxisEstimateLabels() {
180
188
  if (!this.yAxis) {
181
189
  return [];
package/dist/y-axis.d.ts CHANGED
@@ -7,6 +7,8 @@ export declare class YAxis implements LayoutAwareComponent<YAxisConfigBase> {
7
7
  private readonly tickPadding;
8
8
  private fontSize;
9
9
  private readonly maxLabelWidth?;
10
+ private readonly autoMaxLabelWidth;
11
+ private automaticMaxLabelWidth?;
10
12
  private readonly tickFormat;
11
13
  private readonly rotatedLabels;
12
14
  private readonly oversizedBehavior;
@@ -20,8 +22,9 @@ export declare class YAxis implements LayoutAwareComponent<YAxisConfigBase> {
20
22
  * Returns the space required by the y-axis
21
23
  */
22
24
  getRequiredSpace(): ComponentSpace;
23
- estimateLayoutSpace(labels: unknown[], theme: ChartTheme, svg: SVGSVGElement): void;
25
+ estimateLayoutSpace(labels: unknown[], theme: ChartTheme, svg: SVGSVGElement, categoryAxisWidth?: number): void;
24
26
  clearEstimatedSpace(): void;
27
+ private getAutomaticLabelWidth;
25
28
  render(svg: Selection<SVGSVGElement, undefined, null, undefined>, y: D3Scale, theme: ChartTheme, xPosition: number): void;
26
29
  private applyLabelConstraints;
27
30
  private measureLabelDimensions;
package/dist/y-axis.js CHANGED
@@ -44,6 +44,18 @@ export class YAxis {
44
44
  writable: true,
45
45
  value: void 0
46
46
  });
47
+ Object.defineProperty(this, "autoMaxLabelWidth", {
48
+ enumerable: true,
49
+ configurable: true,
50
+ writable: true,
51
+ value: void 0
52
+ });
53
+ Object.defineProperty(this, "automaticMaxLabelWidth", {
54
+ enumerable: true,
55
+ configurable: true,
56
+ writable: true,
57
+ value: void 0
58
+ });
47
59
  Object.defineProperty(this, "tickFormat", {
48
60
  enumerable: true,
49
61
  configurable: true,
@@ -74,11 +86,12 @@ export class YAxis {
74
86
  writable: true,
75
87
  value: void 0
76
88
  });
77
- const { display = true, tickFormat = null, rotatedLabels = false, maxLabelWidth, oversizedBehavior = 'truncate', exportHooks, } = config ?? {};
89
+ const { display = true, tickFormat = null, rotatedLabels = false, maxLabelWidth, autoMaxLabelWidth = true, oversizedBehavior = 'truncate', exportHooks, } = config ?? {};
78
90
  this.display = display;
79
91
  this.tickFormat = tickFormat;
80
92
  this.rotatedLabels = rotatedLabels;
81
93
  this.maxLabelWidth = maxLabelWidth;
94
+ this.autoMaxLabelWidth = autoMaxLabelWidth;
82
95
  this.oversizedBehavior = oversizedBehavior;
83
96
  this.exportHooks = exportHooks;
84
97
  }
@@ -88,6 +101,7 @@ export class YAxis {
88
101
  tickFormat: this.tickFormat,
89
102
  rotatedLabels: this.rotatedLabels,
90
103
  maxLabelWidth: this.maxLabelWidth,
104
+ autoMaxLabelWidth: this.autoMaxLabelWidth,
91
105
  oversizedBehavior: this.oversizedBehavior,
92
106
  };
93
107
  }
@@ -124,7 +138,9 @@ export class YAxis {
124
138
  position: 'left',
125
139
  };
126
140
  }
127
- estimateLayoutSpace(labels, theme, svg) {
141
+ estimateLayoutSpace(labels, theme, svg, categoryAxisWidth) {
142
+ this.automaticMaxLabelWidth =
143
+ this.getAutomaticLabelWidth(categoryAxisWidth);
128
144
  if (!labels.length) {
129
145
  this.estimatedWidth = 0;
130
146
  return;
@@ -139,7 +155,7 @@ export class YAxis {
139
155
  const text = String(label ?? '');
140
156
  if (!text)
141
157
  continue;
142
- const { width, height } = this.measureLabelDimensions(text, fontSize, fontFamily, fontWeight, svg);
158
+ const { width, height } = this.measureLabelDimensions(text, fontSize, fontFamily, fontWeight, svg, this.maxLabelWidth ?? this.automaticMaxLabelWidth);
143
159
  maxWidth = Math.max(maxWidth, width);
144
160
  maxHeight = Math.max(maxHeight, height);
145
161
  }
@@ -151,6 +167,16 @@ export class YAxis {
151
167
  }
152
168
  clearEstimatedSpace() {
153
169
  this.estimatedWidth = null;
170
+ this.automaticMaxLabelWidth = undefined;
171
+ }
172
+ getAutomaticLabelWidth(categoryAxisWidth) {
173
+ if (!this.autoMaxLabelWidth ||
174
+ this.rotatedLabels ||
175
+ categoryAxisWidth === undefined) {
176
+ return undefined;
177
+ }
178
+ // Vertical tick spacing is a height, so use a chart-width budget instead.
179
+ return Math.max(0, categoryAxisWidth - this.tickPadding);
154
180
  }
155
181
  render(svg, y, theme, xPosition) {
156
182
  if (!this.display) {
@@ -180,7 +206,8 @@ export class YAxis {
180
206
  .attr('font-family', theme.axis.fontFamily)
181
207
  .attr('font-weight', theme.axis.fontWeight || 'normal');
182
208
  // Apply label constraints before rotation
183
- this.applyLabelConstraints(axisGroup, svg.node(), theme.axis.fontSize, theme.axis.fontFamily, theme.axis.fontWeight || 'normal');
209
+ this.applyLabelConstraints(axisGroup, svg.node(), theme.axis.fontSize, theme.axis.fontFamily, theme.axis.fontWeight || 'normal', this.maxLabelWidth ??
210
+ (y.bandwidth ? this.automaticMaxLabelWidth : undefined));
184
211
  // Apply rotation to labels if enabled
185
212
  if (this.rotatedLabels) {
186
213
  axisGroup
@@ -190,16 +217,19 @@ export class YAxis {
190
217
  }
191
218
  axisGroup.selectAll('.domain').remove();
192
219
  }
193
- applyLabelConstraints(axisGroup, svg, fontSize, fontFamily, fontWeight) {
194
- if (this.maxLabelWidth === undefined) {
220
+ applyLabelConstraints(axisGroup, svg, fontSize, fontFamily, fontWeight, maxWidth) {
221
+ if (maxWidth === undefined) {
195
222
  return;
196
223
  }
197
- const maxWidth = this.maxLabelWidth;
198
224
  const behavior = this.oversizedBehavior;
199
225
  axisGroup
200
226
  .selectAll('text')
201
227
  .each((_d, i, nodes) => {
202
228
  const textEl = nodes[i];
229
+ if (maxWidth <= 0) {
230
+ textEl.style.visibility = 'hidden';
231
+ return;
232
+ }
203
233
  const originalText = textEl.textContent || '';
204
234
  const textWidth = measureTextWidth(originalText, fontSize, fontFamily, fontWeight, svg);
205
235
  if (textWidth <= maxWidth) {
@@ -226,11 +256,13 @@ export class YAxis {
226
256
  }
227
257
  });
228
258
  }
229
- measureLabelDimensions(text, fontSize, fontFamily, fontWeight, svg) {
259
+ measureLabelDimensions(text, fontSize, fontFamily, fontWeight, svg, maxWidth) {
260
+ if (maxWidth !== undefined && maxWidth <= 0) {
261
+ return { width: 0, height: 0 };
262
+ }
230
263
  const textWidth = measureTextWidth(text, fontSize, fontFamily, fontWeight, svg);
231
264
  const lineHeight = fontSize * 1.2;
232
- if (this.maxLabelWidth === undefined ||
233
- textWidth <= this.maxLabelWidth) {
265
+ if (maxWidth === undefined || textWidth <= maxWidth) {
234
266
  return {
235
267
  width: textWidth,
236
268
  height: fontSize,
@@ -239,11 +271,11 @@ export class YAxis {
239
271
  switch (this.oversizedBehavior) {
240
272
  case 'truncate':
241
273
  return {
242
- width: this.maxLabelWidth,
274
+ width: maxWidth,
243
275
  height: fontSize,
244
276
  };
245
277
  case 'wrap': {
246
- const lines = wrapText(text, this.maxLabelWidth, fontSize, fontFamily, fontWeight, svg);
278
+ const lines = wrapText(text, maxWidth, fontSize, fontFamily, fontWeight, svg);
247
279
  const widestLine = lines.reduce((widest, line) => {
248
280
  return Math.max(widest, measureTextWidth(line, fontSize, fontFamily, fontWeight, svg));
249
281
  }, 0);
@@ -17,15 +17,36 @@ new XAxis({
17
17
  groupLabelMaxWidth?: number, // Optional cap for grouped-label width
18
18
  groupLabelOversizedBehavior?: 'truncate' | 'wrap' | 'hide', // Defaults to truncate
19
19
  rotatedLabels?: boolean, // Rotate tick labels -45deg
20
- maxLabelWidth?: number, // Optional cap for tick-label width
21
- oversizedBehavior?: 'truncate' | 'wrap' | 'hide', // Only applies when maxLabelWidth is set
20
+ maxLabelWidth?: number, // Override automatic category-label width, in pixels
21
+ autoMaxLabelWidth?: boolean, // Automatically size category labels (default: true)
22
+ oversizedBehavior?: 'truncate' | 'wrap' | 'hide', // Default: truncate
22
23
  autoHideOverlapping?: boolean, // Automatically hide overlapping labels
23
- minLabelGap?: number, // Minimum gap between visible labels when auto-hide is enabled
24
- preserveEndLabels?: boolean, // Keep first/last labels visible when auto-hide is enabled
24
+ minLabelGap?: number, // Gap for automatic category sizing and auto-hide (default: 8px)
25
+ preserveEndLabels?: boolean, // Preserve end labels during auto-hide unless overflow hides them
25
26
  tickFormat?: string | ((value: string | number | Date) => string) | null
26
27
  })
27
28
  ```
28
29
 
30
+ Unrotated category (band-scale) labels automatically fit the rendered axis when
31
+ `maxLabelWidth` is omitted. Each label uses the distance to its neighboring ticks,
32
+ less `minLabelGap`; the first and last labels are also bounded by the axis range.
33
+ This uses the scale's actual domain and positions, including band padding,
34
+ reversed axes, and grouped gaps.
35
+
36
+ `oversizedBehavior` controls labels that exceed that space: `wrap` creates
37
+ multiple lines (including breaking long words), `truncate` adds an ellipsis,
38
+ and `hide` hides the label. Wrapping and truncation retain the full text in an
39
+ SVG title. Widths and wrapped bottom margins are recalculated on resize and
40
+ chart updates, using the display text from `labelKey` or `tickFormat`.
41
+
42
+ An explicit `maxLabelWidth` overrides automatic sizing. Numeric/time axes and
43
+ rotated labels retain their existing uncapped behavior unless a width is supplied.
44
+ Set `autoMaxLabelWidth: false` to disable automatic sizing and leave category
45
+ labels uncapped too. An explicit `maxLabelWidth` still applies when this is off;
46
+ group-label sizing is controlled separately.
47
+ `autoHideOverlapping` remains a separate interval-based visibility option;
48
+ `preserveEndLabels` does not restore labels hidden by `oversizedBehavior: 'hide'`.
49
+
29
50
  Grouped label styles come from `theme.axis.groupLabel` and are bold by
30
51
  default. When `groupLabelMaxWidth` is omitted, grouped labels are automatically
31
52
  capped to their rendered group range so adjacent group labels do not collide.
@@ -38,6 +59,14 @@ that cap.
38
59
  chart.addChild(new XAxis({ dataKey: 'date' }));
39
60
  ```
40
61
 
62
+ Automatically wrap category labels such as “ChatGPT”, “Copilot”, and
63
+ “My AI (på Snapchat)” without a fixed pixel width:
64
+
65
+ ```typescript
66
+ chart.addChild(new XAxis({ dataKey: 'category', oversizedBehavior: 'wrap' }));
67
+ // Use oversizedBehavior: 'truncate' or 'hide' for the other overflow modes.
68
+ ```
69
+
41
70
  Callback formatter example:
42
71
 
43
72
  ```typescript
@@ -67,15 +96,26 @@ Renders the Y axis.
67
96
  new YAxis({
68
97
  display?: boolean, // Render axis and reserve layout space (default: true)
69
98
  rotatedLabels?: boolean, // Rotate tick labels -45deg
70
- maxLabelWidth?: number, // Optional cap for tick-label width
71
- oversizedBehavior?: 'truncate' | 'wrap' | 'hide', // Only applies when maxLabelWidth is set
99
+ maxLabelWidth?: number, // Override automatic category-label width, in pixels
100
+ autoMaxLabelWidth?: boolean, // Automatically size category labels (default: true)
101
+ oversizedBehavior?: 'truncate' | 'wrap' | 'hide', // Default: truncate
72
102
  tickFormat?: string | ((value: string | number | Date) => string) | null
73
103
  })
74
104
  ```
75
105
 
76
- When `maxLabelWidth` is omitted, `YAxis` reserves the measured width of its
77
- rendered tick labels. Set `maxLabelWidth` to cap the reserved width and use
78
- `oversizedBehavior` to control truncation, wrapping, or hiding.
106
+ For unrotated category axes (including horizontal bar charts), omitting
107
+ `maxLabelWidth` automatically budgets up to one third of the chart width inside
108
+ the outer left/right margins for the axis. The label cap is that budget minus
109
+ the 10px tick padding. Vertical tick spacing is a height, so it is not used as a
110
+ text-width limit. Shorter labels reserve only their measured width, and wrapped
111
+ lines are centered vertically on each tick.
112
+
113
+ The same `wrap`, `truncate`, and `hide` modes apply, and sizing is recalculated on
114
+ resize and chart updates. An explicit `maxLabelWidth` overrides the budget.
115
+ Set `autoMaxLabelWidth: false` to disable the automatic budget; an explicit
116
+ `maxLabelWidth` still applies.
117
+ Numeric/time axes and rotated labels continue to reserve their measured width
118
+ unless `maxLabelWidth` is supplied.
79
119
 
80
120
  ### Format Examples
81
121
 
@@ -123,6 +163,11 @@ chart.addChild(new Grid());
123
163
 
124
164
  Renders interactive tooltips on hover and keyboard focus.
125
165
 
166
+ Only one chart can show tooltips at a time, whether charts are standalone or
167
+ inside a `ChartGroup`. Hovering, tapping, or focusing another chart dismisses
168
+ the previous chart's tooltips immediately, including during transitions.
169
+ Split mode can still show multiple series tooltips within the active chart.
170
+
126
171
  ```typescript
127
172
  new Tooltip({
128
173
  mode?: 'shared' | 'split' | 'single',
@@ -167,6 +212,11 @@ paths are omitted when the arrow is already close to its target. For line,
167
212
  area, and scatter points, side split tooltips spread nearby point targets
168
213
  within the connectorless arrow range before drawing connector paths.
169
214
 
215
+ Visible XY tooltips track their targets on every animation frame, including
216
+ during touch and momentum scrolling. Their initial placement is preserved,
217
+ allowing them to pass beyond the container edges instead of sticking there
218
+ until blur. Tracking stops when the tooltips are hidden or the chart is destroyed.
219
+
170
220
  For horizontal bar charts, prefer `position: 'auto'` or `position: 'vertical'`.
171
221
  `position: 'side'` and `barAnchorPosition: 'top' | 'middle'` are kept for
172
222
  legacy configs, but horizontal bars resolve bar anchoring automatically.
package/package.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.20.0",
2
+ "version": "0.21.0",
3
3
  "name": "@internetstiftelsen/charts",
4
4
  "type": "module",
5
5
  "sideEffects": false,