@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.
- package/dist/chart-interface.d.ts +5 -0
- package/dist/layout-manager.js +7 -0
- package/dist/types.d.ts +2 -0
- package/dist/x-axis.d.ts +9 -1
- package/dist/x-axis.js +109 -7
- package/dist/xy-chart.d.ts +1 -0
- package/dist/xy-chart.js +30 -20
- package/docs/components.md +11 -0
- package/package.json +1 -1
|
@@ -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;
|
package/dist/layout-manager.js
CHANGED
|
@@ -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 =
|
|
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 =
|
|
638
|
-
this.resolveEstimatedGroupLabelMaxWidth(groupLabel.itemCount, totalItemCount, estimatedXAxisRangeWidth);
|
|
740
|
+
const { maxWidth } = groupLabel;
|
|
639
741
|
if (maxWidth === undefined || maxWidth <= 0) {
|
|
640
742
|
return maxLines;
|
|
641
743
|
}
|
package/dist/xy-chart.d.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
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) {
|
package/docs/components.md
CHANGED
|
@@ -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