@internetstiftelsen/charts 0.20.1 → 0.21.1

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,11 @@ export type ComponentSpace = {
11
11
  width: number;
12
12
  height: number;
13
13
  position: 'top' | 'bottom' | 'left' | 'right';
14
+ /** Minimum chart-edge space required for labels extending sideways. */
15
+ minMargins?: {
16
+ left: number;
17
+ right: number;
18
+ };
14
19
  };
15
20
  export interface LayoutAwareComponentBase extends ChartComponentBase {
16
21
  getRequiredSpace(theme?: ChartTheme): ComponentSpace;
@@ -52,6 +52,13 @@ export class LayoutManager {
52
52
  break;
53
53
  }
54
54
  }
55
+ for (const component of components) {
56
+ const minMargins = component.getRequiredSpace(this.theme).minMargins;
57
+ if (minMargins) {
58
+ marginLeft = Math.max(marginLeft, minMargins.left);
59
+ marginRight = Math.max(marginRight, minMargins.right);
60
+ }
61
+ }
55
62
  // Calculate plot area bounds
56
63
  this.plotBounds = {
57
64
  left: marginLeft,
@@ -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,17 @@ export type XAxisConfigBase = {
274
274
  groupLabelMaxWidth?: number;
275
275
  groupLabelOversizedBehavior?: LabelOversizedBehavior;
276
276
  rotatedLabels?: boolean;
277
+ /** Reserve side margins for rotated category labels. Default: true. */
278
+ autoLabelMargins?: boolean;
279
+ /** Overrides automatic width for unrotated category labels; also caps numeric/time labels. */
277
280
  maxLabelWidth?: number;
281
+ /** Automatically size category labels when maxLabelWidth is omitted. Default: true. */
282
+ autoMaxLabelWidth?: boolean;
283
+ /** Handling for labels exceeding their available width. Default: 'truncate'. */
278
284
  oversizedBehavior?: LabelOversizedBehavior;
279
285
  tickFormat?: string | AxisTickFormatter | null;
280
286
  autoHideOverlapping?: boolean;
287
+ /** Gap for automatic category-label sizing and overlap hiding. Default: 8px. */
281
288
  minLabelGap?: number;
282
289
  preserveEndLabels?: boolean;
283
290
  };
@@ -288,7 +295,11 @@ export type YAxisConfigBase = {
288
295
  display?: boolean;
289
296
  tickFormat?: string | AxisTickFormatter | null;
290
297
  rotatedLabels?: boolean;
298
+ /** Overrides the automatic category-axis budget (one third of inner chart width, less tick padding). */
291
299
  maxLabelWidth?: number;
300
+ /** Automatically size category labels when maxLabelWidth is omitted. Default: true. */
301
+ autoMaxLabelWidth?: boolean;
302
+ /** Handling for labels exceeding their available width. Default: 'truncate'. */
292
303
  oversizedBehavior?: LabelOversizedBehavior;
293
304
  };
294
305
  export type YAxisConfig = YAxisConfigBase & {
package/dist/x-axis.d.ts CHANGED
@@ -1,4 +1,5 @@
1
- import { type Selection } from 'd3';
1
+ import { type ScaleBand, type Selection } from 'd3';
2
+ import type { PlotAreaBounds } from './layout-manager.js';
2
3
  import type { XAxisConfig, ChartTheme, D3Scale, DataItem, ExportHooks, XAxisConfigBase } from './types.js';
3
4
  import type { LayoutAwareComponent, ComponentSpace } from './chart-interface.js';
4
5
  export declare class XAxis implements LayoutAwareComponent<XAxisConfigBase> {
@@ -12,9 +13,12 @@ export declare class XAxis implements LayoutAwareComponent<XAxisConfigBase> {
12
13
  private readonly groupLabelMaxWidth?;
13
14
  private readonly groupLabelOversizedBehavior;
14
15
  private readonly rotatedLabels;
16
+ private readonly autoLabelMargins;
17
+ private estimatedSideMargins?;
15
18
  private readonly tickPadding;
16
19
  private fontSize;
17
20
  private readonly maxLabelWidth?;
21
+ private readonly autoMaxLabelWidth;
18
22
  private readonly oversizedBehavior;
19
23
  private readonly tickFormat;
20
24
  private wrapLineCount;
@@ -34,7 +38,13 @@ export declare class XAxis implements LayoutAwareComponent<XAxisConfigBase> {
34
38
  * Returns the space required by the x-axis
35
39
  */
36
40
  getRequiredSpace(): ComponentSpace;
37
- estimateLayoutSpace(labels: unknown[], theme: ChartTheme, svg: SVGSVGElement, data?: DataItem[], estimatedXAxisRangeWidth?: number): void;
41
+ estimateLayoutSpace(labels: unknown[], theme: ChartTheme, svg: SVGSVGElement, data?: DataItem[], estimatedXAxisRangeWidth?: number, categoryScale?: D3Scale): void;
42
+ private getEstimateLabels;
43
+ estimateSideMargins(scale: ScaleBand<string> | undefined, theme: ChartTheme, svg: SVGSVGElement, data: DataItem[], plotArea: PlotAreaBounds, chartWidth: number): {
44
+ left: number;
45
+ right: number;
46
+ } | undefined;
47
+ private resolveSideMargins;
38
48
  clearEstimatedSpace(): void;
39
49
  private getTickLabelVerticalFootprint;
40
50
  render(svg: Selection<SVGSVGElement, undefined, null, undefined>, x: D3Scale, theme: ChartTheme, yPosition: number, data?: DataItem[]): void;
@@ -42,6 +52,7 @@ export declare class XAxis implements LayoutAwareComponent<XAxisConfigBase> {
42
52
  private renderGroupLabels;
43
53
  private buildGroupRanges;
44
54
  private applyLabelConstraints;
55
+ private getAutomaticLabelWidths;
45
56
  private applyGroupLabelConstraints;
46
57
  private resolveGroupLabelMaxWidth;
47
58
  private wrapTextElement;
@@ -54,7 +65,6 @@ export declare class XAxis implements LayoutAwareComponent<XAxisConfigBase> {
54
65
  private getLabelBlockHeight;
55
66
  private setEstimatedDimensions;
56
67
  private createAxisGenerator;
57
- private applyAxisTextConstraints;
58
68
  private applyLabelRotation;
59
69
  private resolveGroupRangeInput;
60
70
  private resolveGroupLabelKey;
package/dist/x-axis.js CHANGED
@@ -1,4 +1,4 @@
1
- import { axisBottom } from 'd3';
1
+ import { axisBottom, select } from 'd3';
2
2
  import { measureTextWidth, truncateText, wrapText, mergeDeep } from './utils.js';
3
3
  import { GROUPED_CATEGORY_ID_KEY, GROUPED_CATEGORY_LABEL_KEY, GROUPED_GAP_TICK_PREFIX, GROUPED_GROUP_LABEL_KEY, } from './grouped-data.js';
4
4
  const GROUP_LABEL_HORIZONTAL_PADDING = 4;
@@ -8,6 +8,8 @@ const DEFAULT_X_AXIS_CONFIG = {
8
8
  groupLabelGap: 10,
9
9
  groupLabelOversizedBehavior: 'truncate',
10
10
  rotatedLabels: false,
11
+ autoLabelMargins: true,
12
+ autoMaxLabelWidth: true,
11
13
  oversizedBehavior: 'truncate',
12
14
  tickFormat: null,
13
15
  autoHideOverlapping: false,
@@ -101,6 +103,18 @@ export class XAxis {
101
103
  writable: true,
102
104
  value: void 0
103
105
  });
106
+ Object.defineProperty(this, "autoLabelMargins", {
107
+ enumerable: true,
108
+ configurable: true,
109
+ writable: true,
110
+ value: void 0
111
+ });
112
+ Object.defineProperty(this, "estimatedSideMargins", {
113
+ enumerable: true,
114
+ configurable: true,
115
+ writable: true,
116
+ value: void 0
117
+ });
104
118
  Object.defineProperty(this, "tickPadding", {
105
119
  enumerable: true,
106
120
  configurable: true,
@@ -119,6 +133,12 @@ export class XAxis {
119
133
  writable: true,
120
134
  value: void 0
121
135
  });
136
+ Object.defineProperty(this, "autoMaxLabelWidth", {
137
+ enumerable: true,
138
+ configurable: true,
139
+ writable: true,
140
+ value: void 0
141
+ });
122
142
  Object.defineProperty(this, "oversizedBehavior", {
123
143
  enumerable: true,
124
144
  configurable: true,
@@ -193,7 +213,9 @@ export class XAxis {
193
213
  this.groupLabelOversizedBehavior =
194
214
  resolvedConfig.groupLabelOversizedBehavior;
195
215
  this.rotatedLabels = resolvedConfig.rotatedLabels;
216
+ this.autoLabelMargins = resolvedConfig.autoLabelMargins;
196
217
  this.maxLabelWidth = resolvedConfig.maxLabelWidth;
218
+ this.autoMaxLabelWidth = resolvedConfig.autoMaxLabelWidth;
197
219
  this.oversizedBehavior = resolvedConfig.oversizedBehavior;
198
220
  this.tickFormat = resolvedConfig.tickFormat;
199
221
  this.autoHideOverlapping = resolvedConfig.autoHideOverlapping;
@@ -212,7 +234,9 @@ export class XAxis {
212
234
  groupLabelMaxWidth: this.groupLabelMaxWidth,
213
235
  groupLabelOversizedBehavior: this.groupLabelOversizedBehavior,
214
236
  rotatedLabels: this.rotatedLabels,
237
+ autoLabelMargins: this.autoLabelMargins,
215
238
  maxLabelWidth: this.maxLabelWidth,
239
+ autoMaxLabelWidth: this.autoMaxLabelWidth,
216
240
  oversizedBehavior: this.oversizedBehavior,
217
241
  tickFormat: this.tickFormat,
218
242
  autoHideOverlapping: this.autoHideOverlapping,
@@ -243,6 +267,7 @@ export class XAxis {
243
267
  width: 0,
244
268
  height: this.estimatedHeight,
245
269
  position: 'bottom',
270
+ minMargins: this.estimatedSideMargins,
246
271
  };
247
272
  }
248
273
  // Height = tick padding + font size + some extra space for descenders
@@ -250,9 +275,7 @@ export class XAxis {
250
275
  const baseHeight = this.tickPadding + this.fontSize + 5;
251
276
  let height = this.rotatedLabels ? baseHeight * 2.5 : baseHeight;
252
277
  // Account for wrapped text height (multiply by estimated line count)
253
- if (this.maxLabelWidth &&
254
- this.oversizedBehavior === 'wrap' &&
255
- this.wrapLineCount > 1) {
278
+ if (this.oversizedBehavior === 'wrap' && this.wrapLineCount > 1) {
256
279
  height += (this.wrapLineCount - 1) * this.fontSize * 1.2;
257
280
  }
258
281
  if (this.showGroupLabels) {
@@ -267,7 +290,7 @@ export class XAxis {
267
290
  position: 'bottom',
268
291
  };
269
292
  }
270
- estimateLayoutSpace(labels, theme, svg, data = [], estimatedXAxisRangeWidth) {
293
+ estimateLayoutSpace(labels, theme, svg, data = [], estimatedXAxisRangeWidth, categoryScale) {
271
294
  if (!labels.length) {
272
295
  this.estimatedHeight = null;
273
296
  this.estimatedTickLabelVerticalFootprint = null;
@@ -281,23 +304,115 @@ export class XAxis {
281
304
  const fontWeight = theme.axis.fontWeight || 'normal';
282
305
  let maxWidth = 0;
283
306
  let maxLines = 1;
284
- for (const label of labels) {
285
- const text = String(label ?? '');
307
+ const automaticWidths = this.getAutomaticLabelWidths(categoryScale);
308
+ const tickLabels = this.getEstimateLabels(labels, data, categoryScale);
309
+ for (const { value, text } of tickLabels) {
286
310
  if (!text) {
287
311
  continue;
288
312
  }
289
- const measurement = this.measureLabel(text, fontSize, fontFamily, fontWeight, svg);
313
+ const measurement = this.measureLabel(text, fontSize, fontFamily, fontWeight, svg, this.maxLabelWidth ?? automaticWidths.get(value));
290
314
  maxLines = Math.max(maxLines, measurement.lines);
291
315
  maxWidth = Math.max(maxWidth, measurement.width);
292
316
  }
293
- this.groupLabelWrapLineCount = this.estimateGroupLabelWrapLineCount(data, theme, svg, estimatedXAxisRangeWidth);
317
+ this.groupLabelWrapLineCount = this.estimateGroupLabelWrapLineCount(data, theme, svg, estimatedXAxisRangeWidth, categoryScale);
294
318
  this.setEstimatedDimensions(maxWidth, maxLines, theme);
295
319
  this.wrapLineCount = maxLines;
296
320
  }
321
+ getEstimateLabels(labels, data, scale) {
322
+ const formatter = scale
323
+ ? this.createAxisGenerator(scale, this.buildLabelLookup(data)).tickFormat()
324
+ : null;
325
+ return (scale?.domain() ?? labels).map((value, index) => ({
326
+ value: value,
327
+ text: formatter
328
+ ? formatter(value, index)
329
+ : String(value ?? ''),
330
+ }));
331
+ }
332
+ estimateSideMargins(scale, theme, svg, data, plotArea, chartWidth) {
333
+ this.estimatedSideMargins = undefined;
334
+ if (!this.display ||
335
+ !this.rotatedLabels ||
336
+ !this.autoLabelMargins ||
337
+ !scale) {
338
+ return;
339
+ }
340
+ // Measure the same formatted, wrapped and anchored text that is rendered.
341
+ this.fontSize = this.resolveFontSizeValue(theme.axis.fontSize, this.fontSize);
342
+ const axis = select(svg)
343
+ .append('g')
344
+ .call(this.createAxisGenerator(scale, this.buildLabelLookup(data)))
345
+ .attr('font-size', theme.axis.fontSize)
346
+ .attr('font-family', theme.axis.fontFamily)
347
+ .attr('font-weight', theme.axis.fontWeight || 'normal');
348
+ this.applyLabelConstraints(axis, svg, theme.axis.fontSize, theme.axis.fontFamily, theme.axis.fontWeight || 'normal', scale);
349
+ this.applyLabelRotation(axis);
350
+ const range = scale.range();
351
+ const start = Math.min(...range);
352
+ const rangeWidth = Math.abs(range[range.length - 1] - range[0]);
353
+ const footprints = [];
354
+ axis.selectAll('.tick text').each(function (value) {
355
+ if (this.style.visibility === 'hidden' ||
356
+ !this.textContent ||
357
+ rangeWidth <= 0)
358
+ return;
359
+ const box = this.getBBox();
360
+ // At -45 degrees, the horizontal projection is (x + y) / sqrt(2).
361
+ const left = (box.x + box.y) * Math.SQRT1_2;
362
+ const right = (box.x + box.width + box.y + box.height) * Math.SQRT1_2;
363
+ footprints.push({
364
+ value,
365
+ left: Math.max(0, -left) + 4,
366
+ right: Math.max(0, right) + 4,
367
+ });
368
+ });
369
+ axis.remove();
370
+ this.estimatedSideMargins = this.resolveSideMargins(footprints, scale, plotArea, chartWidth, start - plotArea.left);
371
+ return this.estimatedSideMargins;
372
+ }
373
+ resolveSideMargins(footprints, scale, plotArea, chartWidth, leadingPadding) {
374
+ let left = plotArea.left;
375
+ let right = chartWidth - plotArea.right;
376
+ const adjustedScale = scale.copy();
377
+ const reverse = scale.range()[0] > scale.range()[1];
378
+ // Moving a margin also moves band centers. Resolve both sides together,
379
+ // with a fixed pass limit and at least 40px retained for the plot.
380
+ for (let pass = 0; pass < 8; pass += 1) {
381
+ const previousLeft = left;
382
+ const previousRight = right;
383
+ const rangeStart = left + leadingPadding;
384
+ const rangeEnd = Math.max(rangeStart, chartWidth - right);
385
+ const rangeWidth = rangeEnd - rangeStart;
386
+ if (rangeWidth <= 0)
387
+ break;
388
+ adjustedScale.range(reverse ? [rangeEnd, rangeStart] : [rangeStart, rangeEnd]);
389
+ for (const label of footprints) {
390
+ // Recompute positions because rounded band steps change with the range.
391
+ const center = adjustedScale(label.value) + adjustedScale.bandwidth() / 2;
392
+ const p = (center - rangeStart) / rangeWidth;
393
+ if (p < 1) {
394
+ const required = (label.left - p * (chartWidth - right)) / (1 - p) -
395
+ leadingPadding;
396
+ left = Math.max(left, Math.min(required, chartWidth - right - 40));
397
+ }
398
+ if (p > 0) {
399
+ const required = (label.right -
400
+ (1 - p) * (chartWidth - left - leadingPadding)) /
401
+ p;
402
+ right = Math.max(right, Math.min(required, chartWidth - left - 40));
403
+ }
404
+ }
405
+ if (left - previousLeft < 0.01 && right - previousRight < 0.01)
406
+ break;
407
+ }
408
+ return { left, right };
409
+ }
297
410
  clearEstimatedSpace() {
411
+ this.estimatedSideMargins = undefined;
298
412
  this.estimatedHeight = null;
299
413
  this.estimatedTickLabelVerticalFootprint = null;
300
414
  this.groupLabelWrapLineCount = 1;
415
+ this.wrapLineCount = 1;
301
416
  }
302
417
  getTickLabelVerticalFootprint() {
303
418
  if (this.estimatedTickLabelVerticalFootprint !== null) {
@@ -325,7 +440,9 @@ export class XAxis {
325
440
  .attr('font-family', theme.axis.fontFamily)
326
441
  .attr('font-weight', theme.axis.fontWeight || 'normal')
327
442
  .attr('stroke', 'none');
328
- this.applyAxisTextConstraints(axis, svg.node(), theme);
443
+ this.fontSize = this.resolveFontSizeValue(theme.axis.fontSize, this.fontSize);
444
+ this.wrapLineCount = 1;
445
+ this.applyLabelConstraints(axis, svg.node(), theme.axis.fontSize, theme.axis.fontFamily, theme.axis.fontWeight || 'normal', x);
329
446
  this.applyLabelRotation(axis);
330
447
  this.applyAutoHiding(axis, x);
331
448
  axis.selectAll('.domain').remove();
@@ -419,15 +536,21 @@ export class XAxis {
419
536
  }
420
537
  return ranges.filter((range) => range.label.trim() !== '');
421
538
  }
422
- applyLabelConstraints(axisGroup, svg, fontSize, fontFamily, fontWeight) {
423
- if (!this.maxLabelWidth)
424
- return;
425
- const maxWidth = this.maxLabelWidth;
539
+ applyLabelConstraints(axisGroup, svg, fontSize, fontFamily, fontWeight, scale) {
540
+ const automaticWidths = this.getAutomaticLabelWidths(scale);
426
541
  const behavior = this.oversizedBehavior;
427
542
  axisGroup
428
543
  .selectAll('text')
429
- .each((_d, i, nodes) => {
544
+ .each((value, i, nodes) => {
430
545
  const textEl = nodes[i];
546
+ const maxWidth = this.maxLabelWidth ??
547
+ automaticWidths.get(value);
548
+ if (maxWidth === undefined)
549
+ return;
550
+ if (maxWidth <= 0) {
551
+ textEl.style.visibility = 'hidden';
552
+ return;
553
+ }
431
554
  const originalText = textEl.textContent || '';
432
555
  const textWidth = measureTextWidth(originalText, fontSize, fontFamily, fontWeight, svg);
433
556
  if (textWidth <= maxWidth) {
@@ -454,6 +577,35 @@ export class XAxis {
454
577
  }
455
578
  });
456
579
  }
580
+ getAutomaticLabelWidths(scale) {
581
+ const widths = new Map();
582
+ if (!this.autoMaxLabelWidth ||
583
+ this.maxLabelWidth !== undefined ||
584
+ this.rotatedLabels ||
585
+ !scale?.bandwidth) {
586
+ return widths;
587
+ }
588
+ const domain = scale.domain();
589
+ const halfBand = scale.bandwidth() / 2;
590
+ const positions = domain.map((value) => scale(value) + halfBand);
591
+ const rangeStart = Math.min(...scale.range());
592
+ const rangeEnd = Math.max(...scale.range());
593
+ domain.forEach((value, index) => {
594
+ const position = positions[index];
595
+ // Centered labels share the distance between neighboring ticks.
596
+ // Edge labels must also stay inside the axis range.
597
+ const previousGap = index > 0
598
+ ? Math.abs(position - positions[index - 1]) -
599
+ this.minLabelGap
600
+ : Infinity;
601
+ const nextGap = index < domain.length - 1
602
+ ? Math.abs(positions[index + 1] - position) -
603
+ this.minLabelGap
604
+ : Infinity;
605
+ widths.set(value, Math.max(0, Math.min(previousGap, nextGap, 2 * (position - rangeStart), 2 * (rangeEnd - position))));
606
+ });
607
+ return widths;
608
+ }
457
609
  applyGroupLabelConstraints(groupLabels, svg, groupLabelStyle) {
458
610
  groupLabels.each((range, i, nodes) => {
459
611
  const textEl = nodes[i];
@@ -507,7 +659,7 @@ export class XAxis {
507
659
  const tspan = document.createElementNS('http://www.w3.org/2000/svg', 'tspan');
508
660
  tspan.textContent = line;
509
661
  tspan.setAttribute('x', textEl.getAttribute('x') || '0');
510
- tspan.setAttribute('dy', i === 0 ? '0' : `${lineHeight}px`);
662
+ tspan.setAttribute('dy', i === 0 ? textEl.getAttribute('dy') || '0' : `${lineHeight}px`);
511
663
  textEl.appendChild(tspan);
512
664
  });
513
665
  // Add tooltip with full text
@@ -541,36 +693,51 @@ export class XAxis {
541
693
  this.hideLastOverlappingIntervalLabel(labelEntries, skipInterval);
542
694
  }
543
695
  }
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);
696
+ measureLabel(text, fontSize, fontFamily, fontWeight, svg, maxWidth) {
697
+ if (maxWidth !== undefined && maxWidth <= 0) {
698
+ return { width: 0, lines: 1 };
699
+ }
700
+ if (maxWidth !== undefined && this.oversizedBehavior === 'wrap') {
701
+ const lines = wrapText(text, maxWidth, fontSize, fontFamily, fontWeight, svg);
547
702
  return {
548
- width: this.maxLabelWidth,
703
+ width: Math.max(...lines.map((line) => measureTextWidth(line, fontSize, fontFamily, fontWeight, svg))),
549
704
  lines: lines.length || 1,
550
705
  };
551
706
  }
552
707
  const textWidth = measureTextWidth(text, fontSize, fontFamily, fontWeight, svg);
708
+ if (maxWidth !== undefined &&
709
+ this.oversizedBehavior === 'hide' &&
710
+ textWidth > maxWidth) {
711
+ return { width: 0, lines: 1 };
712
+ }
553
713
  return {
554
- width: this.maxLabelWidth
555
- ? Math.min(textWidth, this.maxLabelWidth)
556
- : textWidth,
714
+ width: maxWidth === undefined
715
+ ? textWidth
716
+ : Math.min(textWidth, maxWidth),
557
717
  lines: 1,
558
718
  };
559
719
  }
560
- estimateGroupLabelWrapLineCount(data, theme, svg, estimatedXAxisRangeWidth) {
720
+ estimateGroupLabelWrapLineCount(data, theme, svg, estimatedXAxisRangeWidth, categoryScale) {
561
721
  if (!this.showGroupLabels ||
562
722
  this.groupLabelOversizedBehavior !== 'wrap') {
563
723
  return 1;
564
724
  }
565
- const groupLabels = this.buildGroupLabelEstimates(data);
725
+ const groupLabels = categoryScale
726
+ ? this.buildGroupRanges(categoryScale, data).map((range) => ({
727
+ label: range.label,
728
+ maxWidth: this.resolveGroupLabelMaxWidth(range),
729
+ }))
730
+ : this.buildGroupLabelEstimates(data).map((groupLabel) => ({
731
+ label: groupLabel.label,
732
+ maxWidth: this.groupLabelMaxWidth ??
733
+ this.resolveEstimatedGroupLabelMaxWidth(groupLabel.itemCount, data.length, estimatedXAxisRangeWidth),
734
+ }));
566
735
  if (groupLabels.length === 0) {
567
736
  return 1;
568
737
  }
569
738
  const groupLabelStyle = this.resolveGroupLabelStyle(theme);
570
- const totalItemCount = data.length;
571
739
  return groupLabels.reduce((maxLines, groupLabel) => {
572
- const maxWidth = this.groupLabelMaxWidth ??
573
- this.resolveEstimatedGroupLabelMaxWidth(groupLabel.itemCount, totalItemCount, estimatedXAxisRangeWidth);
740
+ const { maxWidth } = groupLabel;
574
741
  if (maxWidth === undefined || maxWidth <= 0) {
575
742
  return maxLines;
576
743
  }
@@ -664,12 +831,6 @@ export class XAxis {
664
831
  axisGenerator.ticks(5, tickFormat);
665
832
  return axisGenerator;
666
833
  }
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
834
  applyLabelRotation(axis) {
674
835
  if (!this.rotatedLabels) {
675
836
  return;
@@ -742,6 +903,9 @@ export class XAxis {
742
903
  }
743
904
  applyAutoHideVisibility(labelEntries, skipInterval) {
744
905
  labelEntries.forEach((textEl, index) => {
906
+ // Overflow hiding takes precedence over interval/end-label rules.
907
+ if (textEl.style.visibility === 'hidden')
908
+ return;
745
909
  const isFirst = index === 0;
746
910
  const isLast = index === labelEntries.length - 1;
747
911
  const isAtInterval = index % skipInterval === 0;
@@ -26,7 +26,7 @@ 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;
29
+ private estimateXAxisLayout;
30
30
  private getYAxisEstimateLabels;
31
31
  private createContinuousScaleForLayoutEstimate;
32
32
  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,28 +154,45 @@ 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 { y: yConfig } = this.getResolvedAxisConfigs();
158
160
  if (this.yAxis) {
159
- this.yAxis.estimateLayoutSpace?.(this.getYAxisEstimateLabels(), this.renderTheme, context.svgNode);
160
- }
161
- if (this.xAxis) {
162
- const xKey = this.getXKey();
163
- const labelKey = this.xAxis.labelKey;
164
- const labels = this.data.map((item) => {
165
- if (labelKey) {
166
- return item[labelKey];
167
- }
168
- return item[xKey];
169
- });
170
- this.xAxis.estimateLayoutSpace?.(labels, this.renderTheme, context.svgNode, this.data, this.getEstimatedXAxisRangeWidth());
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);
171
166
  }
167
+ this.estimateXAxisLayout(context);
172
168
  }
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);
169
+ estimateXAxisLayout(context) {
170
+ if (!this.xAxis)
171
+ return;
172
+ const { x: xConfig } = this.getResolvedAxisConfigs();
173
+ const xKey = this.getXKey();
174
+ const labelKey = this.xAxis.labelKey;
175
+ const labels = this.data.map((item) => {
176
+ if (labelKey) {
177
+ return item[labelKey];
178
+ }
179
+ return item[xKey];
180
+ });
181
+ const plotArea = this.applyPlotAreaOverride(new LayoutManager(this.resolvedRenderTheme).calculateLayout(this.getLayoutComponents()));
182
+ const range = [
183
+ plotArea.left + 10,
184
+ Math.max(plotArea.left + 10, plotArea.right),
185
+ ];
186
+ const categoryScale = xConfig.type === 'band'
187
+ ? this.buildScale('band', this.resolveScaleDomain(xConfig, xKey), xConfig.reverse ? [range[1], range[0]] : range, xConfig)
188
+ : undefined;
189
+ const sideMargins = this.xAxis.estimateSideMargins(categoryScale, this.renderTheme, context.svgNode, this.data, plotArea, this.width);
190
+ if (sideMargins && categoryScale) {
191
+ range[0] = sideMargins.left + 10;
192
+ range[1] = Math.max(range[0], this.width - sideMargins.right);
193
+ categoryScale.range(xConfig.reverse ? [range[1], range[0]] : range);
194
+ }
195
+ this.xAxis.estimateLayoutSpace(labels, this.renderTheme, context.svgNode, this.data, range[1] - range[0], categoryScale);
178
196
  }
179
197
  getYAxisEstimateLabels() {
180
198
  if (!this.yAxis) {
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,47 @@ 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
+ autoLabelMargins?: boolean, // Reserve side margins for rotated category labels (default: true)
21
+ maxLabelWidth?: number, // Override automatic category-label width, in pixels
22
+ autoMaxLabelWidth?: boolean, // Automatically size category labels (default: true)
23
+ oversizedBehavior?: 'truncate' | 'wrap' | 'hide', // Default: truncate
22
24
  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
25
+ minLabelGap?: number, // Gap for automatic category sizing and auto-hide (default: 8px)
26
+ preserveEndLabels?: boolean, // Preserve end labels during auto-hide unless overflow hides them
25
27
  tickFormat?: string | ((value: string | number | Date) => string) | null
26
28
  })
27
29
  ```
28
30
 
31
+ Unrotated category (band-scale) labels automatically fit the rendered axis when
32
+ `maxLabelWidth` is omitted. Each label uses the distance to its neighboring ticks,
33
+ less `minLabelGap`; the first and last labels are also bounded by the axis range.
34
+ This uses the scale's actual domain and positions, including band padding,
35
+ reversed axes, and grouped gaps.
36
+
37
+ `oversizedBehavior` controls labels that exceed that space: `wrap` creates
38
+ multiple lines (including breaking long words), `truncate` adds an ellipsis,
39
+ and `hide` hides the label. Wrapping and truncation retain the full text in an
40
+ SVG title. Widths and wrapped bottom margins are recalculated on resize and
41
+ chart updates, using the display text from `labelKey` or `tickFormat`.
42
+
43
+ An explicit `maxLabelWidth` overrides automatic sizing. Numeric/time axes and
44
+ rotated labels retain their existing uncapped behavior unless a width is supplied.
45
+ Rotated category labels automatically reserve side margins inside their own chart,
46
+ including in `ChartGroup`. The layout measures the formatted text after explicit
47
+ width constraints and recalculates on resize, updates, and visual export. Rounded
48
+ tick positions and group-label wrapping use the adjusted plot width. Theme
49
+ side margins remain minimums. Set `autoLabelMargins: false` to use theme margins
50
+ exactly, for example when providing a manual margin override. Numeric/time axes
51
+ are unchanged. Layout adjustment is bounded and retains at least 40px of plot
52
+ width; labels that cannot fit while preserving this plot width still need an
53
+ explicit width limit.
54
+
55
+ Set `autoMaxLabelWidth: false` to disable automatic sizing and leave category
56
+ labels uncapped too. An explicit `maxLabelWidth` still applies when this is off;
57
+ group-label sizing is controlled separately.
58
+ `autoHideOverlapping` remains a separate interval-based visibility option;
59
+ `preserveEndLabels` does not restore labels hidden by `oversizedBehavior: 'hide'`.
60
+
29
61
  Grouped label styles come from `theme.axis.groupLabel` and are bold by
30
62
  default. When `groupLabelMaxWidth` is omitted, grouped labels are automatically
31
63
  capped to their rendered group range so adjacent group labels do not collide.
@@ -38,6 +70,14 @@ that cap.
38
70
  chart.addChild(new XAxis({ dataKey: 'date' }));
39
71
  ```
40
72
 
73
+ Automatically wrap category labels such as “ChatGPT”, “Copilot”, and
74
+ “My AI (på Snapchat)” without a fixed pixel width:
75
+
76
+ ```typescript
77
+ chart.addChild(new XAxis({ dataKey: 'category', oversizedBehavior: 'wrap' }));
78
+ // Use oversizedBehavior: 'truncate' or 'hide' for the other overflow modes.
79
+ ```
80
+
41
81
  Callback formatter example:
42
82
 
43
83
  ```typescript
@@ -67,15 +107,26 @@ Renders the Y axis.
67
107
  new YAxis({
68
108
  display?: boolean, // Render axis and reserve layout space (default: true)
69
109
  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
110
+ maxLabelWidth?: number, // Override automatic category-label width, in pixels
111
+ autoMaxLabelWidth?: boolean, // Automatically size category labels (default: true)
112
+ oversizedBehavior?: 'truncate' | 'wrap' | 'hide', // Default: truncate
72
113
  tickFormat?: string | ((value: string | number | Date) => string) | null
73
114
  })
74
115
  ```
75
116
 
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.
117
+ For unrotated category axes (including horizontal bar charts), omitting
118
+ `maxLabelWidth` automatically budgets up to one third of the chart width inside
119
+ the outer left/right margins for the axis. The label cap is that budget minus
120
+ the 10px tick padding. Vertical tick spacing is a height, so it is not used as a
121
+ text-width limit. Shorter labels reserve only their measured width, and wrapped
122
+ lines are centered vertically on each tick.
123
+
124
+ The same `wrap`, `truncate`, and `hide` modes apply, and sizing is recalculated on
125
+ resize and chart updates. An explicit `maxLabelWidth` overrides the budget.
126
+ Set `autoMaxLabelWidth: false` to disable the automatic budget; an explicit
127
+ `maxLabelWidth` still applies.
128
+ Numeric/time axes and rotated labels continue to reserve their measured width
129
+ unless `maxLabelWidth` is supplied.
79
130
 
80
131
  ### Format Examples
81
132
 
@@ -123,6 +174,11 @@ chart.addChild(new Grid());
123
174
 
124
175
  Renders interactive tooltips on hover and keyboard focus.
125
176
 
177
+ Only one chart can show tooltips at a time, whether charts are standalone or
178
+ inside a `ChartGroup`. Hovering, tapping, or focusing another chart dismisses
179
+ the previous chart's tooltips immediately, including during transitions.
180
+ Split mode can still show multiple series tooltips within the active chart.
181
+
126
182
  ```typescript
127
183
  new Tooltip({
128
184
  mode?: 'shared' | 'split' | 'single',
package/package.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.20.1",
2
+ "version": "0.21.1",
3
3
  "name": "@internetstiftelsen/charts",
4
4
  "type": "module",
5
5
  "sideEffects": false,