@internetstiftelsen/charts 0.21.0 → 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.
@@ -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,
package/dist/types.d.ts CHANGED
@@ -274,6 +274,8 @@ 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;
277
279
  /** Overrides automatic width for unrotated category labels; also caps numeric/time labels. */
278
280
  maxLabelWidth?: number;
279
281
  /** Automatically size category labels when maxLabelWidth is omitted. Default: true. */
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,6 +13,8 @@ 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?;
@@ -37,6 +40,11 @@ export declare class XAxis implements LayoutAwareComponent<XAxisConfigBase> {
37
40
  getRequiredSpace(): ComponentSpace;
38
41
  estimateLayoutSpace(labels: unknown[], theme: ChartTheme, svg: SVGSVGElement, data?: DataItem[], estimatedXAxisRangeWidth?: number, categoryScale?: D3Scale): void;
39
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;
40
48
  clearEstimatedSpace(): void;
41
49
  private getTickLabelVerticalFootprint;
42
50
  render(svg: Selection<SVGSVGElement, undefined, null, undefined>, x: D3Scale, theme: ChartTheme, yPosition: number, data?: DataItem[]): void;
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,7 @@ const DEFAULT_X_AXIS_CONFIG = {
8
8
  groupLabelGap: 10,
9
9
  groupLabelOversizedBehavior: 'truncate',
10
10
  rotatedLabels: false,
11
+ autoLabelMargins: true,
11
12
  autoMaxLabelWidth: true,
12
13
  oversizedBehavior: 'truncate',
13
14
  tickFormat: null,
@@ -102,6 +103,18 @@ export class XAxis {
102
103
  writable: true,
103
104
  value: void 0
104
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
+ });
105
118
  Object.defineProperty(this, "tickPadding", {
106
119
  enumerable: true,
107
120
  configurable: true,
@@ -200,6 +213,7 @@ export class XAxis {
200
213
  this.groupLabelOversizedBehavior =
201
214
  resolvedConfig.groupLabelOversizedBehavior;
202
215
  this.rotatedLabels = resolvedConfig.rotatedLabels;
216
+ this.autoLabelMargins = resolvedConfig.autoLabelMargins;
203
217
  this.maxLabelWidth = resolvedConfig.maxLabelWidth;
204
218
  this.autoMaxLabelWidth = resolvedConfig.autoMaxLabelWidth;
205
219
  this.oversizedBehavior = resolvedConfig.oversizedBehavior;
@@ -220,6 +234,7 @@ export class XAxis {
220
234
  groupLabelMaxWidth: this.groupLabelMaxWidth,
221
235
  groupLabelOversizedBehavior: this.groupLabelOversizedBehavior,
222
236
  rotatedLabels: this.rotatedLabels,
237
+ autoLabelMargins: this.autoLabelMargins,
223
238
  maxLabelWidth: this.maxLabelWidth,
224
239
  autoMaxLabelWidth: this.autoMaxLabelWidth,
225
240
  oversizedBehavior: this.oversizedBehavior,
@@ -252,6 +267,7 @@ export class XAxis {
252
267
  width: 0,
253
268
  height: this.estimatedHeight,
254
269
  position: 'bottom',
270
+ minMargins: this.estimatedSideMargins,
255
271
  };
256
272
  }
257
273
  // Height = tick padding + font size + some extra space for descenders
@@ -298,7 +314,7 @@ export class XAxis {
298
314
  maxLines = Math.max(maxLines, measurement.lines);
299
315
  maxWidth = Math.max(maxWidth, measurement.width);
300
316
  }
301
- this.groupLabelWrapLineCount = this.estimateGroupLabelWrapLineCount(data, theme, svg, estimatedXAxisRangeWidth);
317
+ this.groupLabelWrapLineCount = this.estimateGroupLabelWrapLineCount(data, theme, svg, estimatedXAxisRangeWidth, categoryScale);
302
318
  this.setEstimatedDimensions(maxWidth, maxLines, theme);
303
319
  this.wrapLineCount = maxLines;
304
320
  }
@@ -313,7 +329,86 @@ export class XAxis {
313
329
  : String(value ?? ''),
314
330
  }));
315
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
+ }
316
410
  clearEstimatedSpace() {
411
+ this.estimatedSideMargins = undefined;
317
412
  this.estimatedHeight = null;
318
413
  this.estimatedTickLabelVerticalFootprint = null;
319
414
  this.groupLabelWrapLineCount = 1;
@@ -622,20 +717,27 @@ export class XAxis {
622
717
  lines: 1,
623
718
  };
624
719
  }
625
- estimateGroupLabelWrapLineCount(data, theme, svg, estimatedXAxisRangeWidth) {
720
+ estimateGroupLabelWrapLineCount(data, theme, svg, estimatedXAxisRangeWidth, categoryScale) {
626
721
  if (!this.showGroupLabels ||
627
722
  this.groupLabelOversizedBehavior !== 'wrap') {
628
723
  return 1;
629
724
  }
630
- 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
+ }));
631
735
  if (groupLabels.length === 0) {
632
736
  return 1;
633
737
  }
634
738
  const groupLabelStyle = this.resolveGroupLabelStyle(theme);
635
- const totalItemCount = data.length;
636
739
  return groupLabels.reduce((maxLines, groupLabel) => {
637
- const maxWidth = this.groupLabelMaxWidth ??
638
- this.resolveEstimatedGroupLabelMaxWidth(groupLabel.itemCount, totalItemCount, estimatedXAxisRangeWidth);
740
+ const { maxWidth } = groupLabel;
639
741
  if (maxWidth === undefined || maxWidth <= 0) {
640
742
  return maxLines;
641
743
  }
@@ -26,6 +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 estimateXAxisLayout;
29
30
  private getYAxisEstimateLabels;
30
31
  private createContinuousScaleForLayoutEstimate;
31
32
  protected renderChart({ svg, plotGroup, plotArea, }: BaseRenderContext): void;
package/dist/xy-chart.js CHANGED
@@ -156,7 +156,7 @@ export class XYChart extends BaseChart {
156
156
  super.prepareLayout(context);
157
157
  this.xAxis?.clearEstimatedSpace();
158
158
  this.yAxis?.clearEstimatedSpace();
159
- const { x: xConfig, y: yConfig } = this.getResolvedAxisConfigs();
159
+ const { y: yConfig } = this.getResolvedAxisConfigs();
160
160
  if (this.yAxis) {
161
161
  this.yAxis.estimateLayoutSpace(this.getYAxisEstimateLabels(), this.renderTheme, context.svgNode, yConfig.type === 'band'
162
162
  ? Math.max(0, this.width -
@@ -164,25 +164,35 @@ export class XYChart extends BaseChart {
164
164
  this.renderTheme.margins.right) / 3
165
165
  : undefined);
166
166
  }
167
- if (this.xAxis) {
168
- const xKey = this.getXKey();
169
- const labelKey = this.xAxis.labelKey;
170
- const labels = this.data.map((item) => {
171
- if (labelKey) {
172
- return item[labelKey];
173
- }
174
- return item[xKey];
175
- });
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);
185
- }
167
+ this.estimateXAxisLayout(context);
168
+ }
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);
186
196
  }
187
197
  getYAxisEstimateLabels() {
188
198
  if (!this.yAxis) {
@@ -17,6 +17,7 @@ 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
+ autoLabelMargins?: boolean, // Reserve side margins for rotated category labels (default: true)
20
21
  maxLabelWidth?: number, // Override automatic category-label width, in pixels
21
22
  autoMaxLabelWidth?: boolean, // Automatically size category labels (default: true)
22
23
  oversizedBehavior?: 'truncate' | 'wrap' | 'hide', // Default: truncate
@@ -41,6 +42,16 @@ chart updates, using the display text from `labelKey` or `tickFormat`.
41
42
 
42
43
  An explicit `maxLabelWidth` overrides automatic sizing. Numeric/time axes and
43
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
+
44
55
  Set `autoMaxLabelWidth: false` to disable automatic sizing and leave category
45
56
  labels uncapped too. An explicit `maxLabelWidth` still applies when this is off;
46
57
  group-label sizing is controlled separately.
package/package.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.21.0",
2
+ "version": "0.21.1",
3
3
  "name": "@internetstiftelsen/charts",
4
4
  "type": "module",
5
5
  "sideEffects": false,