@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.
- package/dist/base-chart.d.ts +1 -1
- package/dist/chart-interface.d.ts +5 -0
- package/dist/layout-manager.js +7 -0
- package/dist/tooltip/dom.d.ts +5 -1
- package/dist/tooltip/dom.js +37 -9
- package/dist/tooltip/xy-interaction.js +10 -10
- package/dist/types.d.ts +11 -0
- package/dist/x-axis.d.ts +13 -3
- package/dist/x-axis.js +198 -34
- package/dist/xy-chart.d.ts +1 -1
- package/dist/xy-chart.js +37 -19
- package/dist/y-axis.d.ts +4 -1
- package/dist/y-axis.js +44 -12
- package/docs/components.md +65 -9
- package/package.json +1 -1
package/dist/base-chart.d.ts
CHANGED
|
@@ -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
|
-
|
|
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;
|
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/tooltip/dom.d.ts
CHANGED
|
@@ -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;
|
package/dist/tooltip/dom.js
CHANGED
|
@@ -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
|
-
|
|
129
|
-
|
|
130
|
-
this.
|
|
131
|
-
}
|
|
132
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
285
|
-
|
|
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.
|
|
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
|
-
|
|
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((
|
|
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 (
|
|
546
|
-
|
|
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:
|
|
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:
|
|
555
|
-
?
|
|
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 =
|
|
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 =
|
|
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;
|
package/dist/xy-chart.d.ts
CHANGED
|
@@ -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
|
|
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
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
const
|
|
177
|
-
|
|
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 (
|
|
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 (
|
|
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:
|
|
274
|
+
width: maxWidth,
|
|
243
275
|
height: fontSize,
|
|
244
276
|
};
|
|
245
277
|
case 'wrap': {
|
|
246
|
-
const lines = wrapText(text,
|
|
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);
|
package/docs/components.md
CHANGED
|
@@ -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
|
-
|
|
21
|
-
|
|
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, //
|
|
24
|
-
preserveEndLabels?: boolean, //
|
|
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, //
|
|
71
|
-
|
|
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
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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