@internetstiftelsen/charts 0.20.1 → 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;
@@ -28,8 +30,10 @@ export declare class TooltipDom {
28
30
  getBounds(): DOMRect | null;
29
31
  showAt(left: number, top: number): void;
30
32
  hasVisibleTooltips(): boolean;
33
+ activate(): void;
34
+ setHideListener(listener: (() => void) | null): void;
31
35
  moveVisibleTooltips(deltaX: number, deltaY: number): void;
32
- hide(): void;
36
+ hide(immediate?: boolean): void;
33
37
  cleanup(): void;
34
38
  measureTooltip(tooltip: TooltipDivSelection, content: string): {
35
39
  width: number;
@@ -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;
@@ -111,6 +117,16 @@ export class TooltipDom {
111
117
  hasVisibleTooltips() {
112
118
  return this.getTooltipNodes().some((node) => this.isTooltipVisible(node));
113
119
  }
120
+ activate() {
121
+ if (TooltipDom.activeTooltip === this) {
122
+ return;
123
+ }
124
+ TooltipDom.activeTooltip?.hide(true);
125
+ TooltipDom.activeTooltip = this;
126
+ }
127
+ setHideListener(listener) {
128
+ this.hideListener = listener;
129
+ }
114
130
  moveVisibleTooltips(deltaX, deltaY) {
115
131
  this.getTooltipNodes().forEach((node) => {
116
132
  if (!this.isTooltipVisible(node)) {
@@ -124,14 +140,19 @@ export class TooltipDom {
124
140
  node.style.top = `${position.top + deltaY}px`;
125
141
  });
126
142
  }
127
- hide() {
128
- const tooltip = this.tooltipDiv ?? select(`#${this.id}`);
129
- if (!tooltip.empty()) {
130
- this.hideTooltipSelection(tooltip);
131
- }
132
- this.hideSplitTooltips();
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.
133
149
  }
134
150
  cleanup() {
151
+ this.hide(true);
152
+ this.hideListener = null;
153
+ if (TooltipDom.activeTooltip === this) {
154
+ TooltipDom.activeTooltip = null;
155
+ }
135
156
  this.removeRootTooltip();
136
157
  this.removeSplitTooltips();
137
158
  this.tooltipDiv = null;
@@ -311,6 +332,7 @@ export class TooltipDom {
311
332
  if (!node) {
312
333
  return;
313
334
  }
335
+ this.activate();
314
336
  this.cancelTooltipPositionReset(node);
315
337
  tooltip.style('visibility', 'visible');
316
338
  if (!this.transition.show) {
@@ -324,7 +346,7 @@ export class TooltipDom {
324
346
  }
325
347
  this.slideTooltipFromOffset(node, slideOffset);
326
348
  }
327
- hideTooltipElement(node) {
349
+ hideTooltipElement(node, immediate = false) {
328
350
  const wasVisible = this.isTooltipVisible(node);
329
351
  this.cancelTooltipPositionReset(node);
330
352
  this.cancelTooltipTransitionFrame(node);
@@ -333,10 +355,10 @@ export class TooltipDom {
333
355
  this.resetTooltipPosition(node);
334
356
  return;
335
357
  }
336
- node.style.visibility = 'visible';
358
+ node.style.visibility = wasVisible && !immediate ? 'visible' : 'hidden';
337
359
  node.style.opacity = '0';
338
360
  node.style.transform = TOOLTIP_HIDDEN_TRANSFORM;
339
- if (!wasVisible) {
361
+ if (!wasVisible || immediate) {
340
362
  this.resetTooltipPosition(node);
341
363
  return;
342
364
  }
@@ -517,3 +539,9 @@ export class TooltipDom {
517
539
  }
518
540
  }
519
541
  }
542
+ Object.defineProperty(TooltipDom, "activeTooltip", {
543
+ enumerable: true,
544
+ configurable: true,
545
+ writable: true,
546
+ value: null
547
+ });
@@ -378,16 +378,11 @@ export function attachXYTooltipArea(config) {
378
378
  updateTooltipPosition();
379
379
  positionTrackingFrame = requestFrame(trackTooltipPosition);
380
380
  };
381
- const hideTooltip = () => {
382
- stopPositionTracking();
383
- dom.hideTooltipSelection(tooltip);
384
- dom.hideSplitTooltips();
385
- clearVisualState();
386
- };
387
381
  const renderTooltip = (request) => {
388
382
  if (!tooltipSvgNode) {
389
383
  return;
390
384
  }
385
+ dom.activate();
391
386
  activeSvgBounds = getElementTooltipBounds(tooltipSvgNode);
392
387
  if (positionTrackingFrame === null) {
393
388
  positionTrackingFrame = requestFrame(trackTooltipPosition);
@@ -403,8 +398,14 @@ export function attachXYTooltipArea(config) {
403
398
  showSharedTooltipAtIndex(request.index);
404
399
  };
405
400
  const queuedRender = createQueuedTooltipRender(tooltipSvgNode, renderTooltip);
401
+ dom.setHideListener(() => {
402
+ queuedRender.cancel();
403
+ stopPositionTracking();
404
+ clearVisualState();
405
+ });
406
406
  overlay
407
407
  .on('mousemove', (event) => {
408
+ dom.activate();
408
409
  const [mouseX, mouseY] = pointer(event, svg.node());
409
410
  const closestIndex = getClosestIndexFromPointer(mouseX, mouseY, dataPointPositions, isHorizontal);
410
411
  const pointerPosition = getDocumentPointerPosition(event);
@@ -418,8 +419,7 @@ export function attachXYTooltipArea(config) {
418
419
  if (isTooltipFocusTarget(document.activeElement)) {
419
420
  return;
420
421
  }
421
- queuedRender.cancel();
422
- hideTooltip();
422
+ dom.hide();
423
423
  });
424
424
  const focusTargets = svg
425
425
  .append('g')
@@ -455,8 +455,7 @@ export function attachXYTooltipArea(config) {
455
455
  if (isTooltipFocusTarget(event.relatedTarget)) {
456
456
  return;
457
457
  }
458
- queuedRender.cancel();
459
- hideTooltip();
458
+ dom.hide();
460
459
  })
461
460
  .on('keydown', function (event) {
462
461
  const currentIndex = focusTargetNodes.indexOf(this);
@@ -475,6 +474,7 @@ export function attachXYTooltipArea(config) {
475
474
  detachScrollListeners?.();
476
475
  queuedRender.cancel();
477
476
  stopPositionTracking();
477
+ dom.setHideListener(null);
478
478
  };
479
479
  }
480
480
  function normalizeFormatterValue(value) {
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',
package/package.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.20.1",
2
+ "version": "0.21.0",
3
3
  "name": "@internetstiftelsen/charts",
4
4
  "type": "module",
5
5
  "sideEffects": false,