@opendata-ai/openchart-engine 7.2.3 → 7.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +84 -36
- package/dist/index.js +4013 -2872
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/__snapshots__/compile-snapshot.test.ts.snap +139 -63
- package/src/__tests__/axes.test.ts +67 -1
- package/src/__tests__/compile-chart.test.ts +6 -0
- package/src/__tests__/compile-layer.test.ts +49 -0
- package/src/__tests__/dimensions.test.ts +7 -2
- package/src/__tests__/helpers/invariants.ts +267 -0
- package/src/__tests__/known-bugs.test.ts +113 -0
- package/src/__tests__/layout-fuzz.test.ts +342 -0
- package/src/__tests__/layout-invariants.test.ts +533 -0
- package/src/__tests__/legend.test.ts +37 -0
- package/src/annotations/__tests__/compute.test.ts +8 -15
- package/src/annotations/__tests__/placement-fuzz.test.ts +256 -0
- package/src/annotations/__tests__/placement-invariants.test.ts +345 -0
- package/src/annotations/__tests__/placement.test.ts +316 -0
- package/src/annotations/collisions.ts +19 -7
- package/src/annotations/compute.ts +257 -16
- package/src/annotations/geometry.ts +73 -15
- package/src/annotations/placement.ts +588 -0
- package/src/annotations/resolve-text.ts +58 -10
- package/src/charts/__tests__/post-process.test.ts +3 -3
- package/src/charts/bar/__tests__/compute.test.ts +67 -0
- package/src/charts/bar/compute.ts +8 -1
- package/src/charts/post-process.ts +21 -7
- package/src/compile/layer.ts +31 -9
- package/src/compile.ts +85 -148
- package/src/index.ts +9 -0
- package/src/layout/__tests__/metrics.test.ts +97 -0
- package/src/layout/axes.ts +93 -1
- package/src/layout/dimensions.ts +80 -98
- package/src/layout/plan.ts +524 -0
- package/src/layout/shared.ts +44 -0
- package/src/legend/compute.ts +315 -78
- package/src/legend/wrap.ts +11 -7
- package/src/sankey/__tests__/node-label-wrap.test.ts +2 -2
- package/src/tables/__tests__/heatmap.test.ts +24 -20
- package/src/tables/heatmap.ts +25 -9
|
@@ -3,13 +3,12 @@
|
|
|
3
3
|
* optional callout connector to the data point.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
import {
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
type TextStyle,
|
|
6
|
+
import type {
|
|
7
|
+
Rect,
|
|
8
|
+
ResolvedAnnotation,
|
|
9
|
+
ResolvedLabel,
|
|
10
|
+
TextAnnotation,
|
|
11
|
+
TextStyle,
|
|
13
12
|
} from '@opendata-ai/openchart-core';
|
|
14
13
|
import type { ResolvedScales } from '../layout/scales';
|
|
15
14
|
import {
|
|
@@ -27,7 +26,15 @@ import {
|
|
|
27
26
|
SUBTITLE_FONT_SIZE_RATIO,
|
|
28
27
|
SUBTITLE_GAP,
|
|
29
28
|
} from './constants';
|
|
30
|
-
import {
|
|
29
|
+
import {
|
|
30
|
+
type AnnotationMeasureTextFn,
|
|
31
|
+
applyOffset,
|
|
32
|
+
computeAnchorOffset,
|
|
33
|
+
computeConnectorOrigin,
|
|
34
|
+
computeTextBlockBounds,
|
|
35
|
+
heuristicMeasure,
|
|
36
|
+
unionRects,
|
|
37
|
+
} from './geometry';
|
|
31
38
|
import { resolvePosition } from './position';
|
|
32
39
|
|
|
33
40
|
/** Horizontal gap between the drop-line and the label text. */
|
|
@@ -59,6 +66,7 @@ export function resolveTextAnnotation(
|
|
|
59
66
|
scales: ResolvedScales,
|
|
60
67
|
chartArea: Rect,
|
|
61
68
|
isDark: boolean,
|
|
69
|
+
measure: AnnotationMeasureTextFn = heuristicMeasure,
|
|
62
70
|
): ResolvedAnnotation | null {
|
|
63
71
|
const px = resolvePosition(annotation.x, scales.x);
|
|
64
72
|
const py = resolvePosition(annotation.y, scales.y);
|
|
@@ -78,7 +86,23 @@ export function resolveTextAnnotation(
|
|
|
78
86
|
// label sitting flush beside it. Auto-flips to the opposite side if the
|
|
79
87
|
// chosen side would overflow the chart area.
|
|
80
88
|
if (annotation.connector === 'drop-line') {
|
|
81
|
-
return resolveDropLineAnnotation(
|
|
89
|
+
return resolveDropLineAnnotation(
|
|
90
|
+
annotation,
|
|
91
|
+
px,
|
|
92
|
+
py,
|
|
93
|
+
chartArea,
|
|
94
|
+
labelStyle,
|
|
95
|
+
defaultTextFill,
|
|
96
|
+
measure,
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Multi-line non-drop-line: engine sets textAnchor to 'middle' so bounds
|
|
101
|
+
// and rendering derive from the same anchor (previously the renderer
|
|
102
|
+
// forced this override, causing bounds to lie).
|
|
103
|
+
const isMultiLine = annotation.text.includes('\n');
|
|
104
|
+
if (isMultiLine) {
|
|
105
|
+
labelStyle.textAnchor = 'middle';
|
|
82
106
|
}
|
|
83
107
|
|
|
84
108
|
// Compute position from anchor direction + user offset
|
|
@@ -130,6 +154,8 @@ export function resolveTextAnnotation(
|
|
|
130
154
|
? { x: adjustedToRaw.x - (cdx / dist) * GAP, y: adjustedToRaw.y - (cdy / dist) * GAP }
|
|
131
155
|
: adjustedToRaw;
|
|
132
156
|
|
|
157
|
+
const labelBounds = computeTextBlockBounds(labelX, labelY, annotation.text, labelStyle, measure);
|
|
158
|
+
|
|
133
159
|
const label: ResolvedLabel = {
|
|
134
160
|
text: annotation.text,
|
|
135
161
|
x: labelX,
|
|
@@ -147,6 +173,7 @@ export function resolveTextAnnotation(
|
|
|
147
173
|
: undefined,
|
|
148
174
|
background: annotation.background,
|
|
149
175
|
halo: annotation.halo,
|
|
176
|
+
bounds: labelBounds,
|
|
150
177
|
};
|
|
151
178
|
|
|
152
179
|
// Resolve dot marker. Uses the connector's "to" endpoint coordinates
|
|
@@ -170,6 +197,7 @@ export function resolveTextAnnotation(
|
|
|
170
197
|
// Resolve subtitle. Positioned below the primary text block by
|
|
171
198
|
// (lineHeight * primaryLineCount * fontSize) + gap.
|
|
172
199
|
let subtitle: ResolvedAnnotation['subtitle'] | undefined;
|
|
200
|
+
let annotationBounds: Rect = labelBounds;
|
|
173
201
|
if (annotation.subtitle) {
|
|
174
202
|
const primaryLineCount = annotation.text.split('\n').length;
|
|
175
203
|
const subtitleFontSize = Math.round(fontSize * SUBTITLE_FONT_SIZE_RATIO);
|
|
@@ -187,6 +215,14 @@ export function resolveTextAnnotation(
|
|
|
187
215
|
y: subtitleY,
|
|
188
216
|
style: subtitleStyle,
|
|
189
217
|
};
|
|
218
|
+
const subtitleBounds = computeTextBlockBounds(
|
|
219
|
+
labelX,
|
|
220
|
+
subtitleY,
|
|
221
|
+
annotation.subtitle,
|
|
222
|
+
subtitleStyle,
|
|
223
|
+
measure,
|
|
224
|
+
);
|
|
225
|
+
annotationBounds = unionRects(labelBounds, subtitleBounds);
|
|
190
226
|
}
|
|
191
227
|
|
|
192
228
|
return {
|
|
@@ -199,6 +235,7 @@ export function resolveTextAnnotation(
|
|
|
199
235
|
zIndex: annotation.zIndex,
|
|
200
236
|
dot,
|
|
201
237
|
subtitle,
|
|
238
|
+
bounds: annotationBounds,
|
|
202
239
|
};
|
|
203
240
|
}
|
|
204
241
|
|
|
@@ -214,13 +251,14 @@ function resolveDropLineAnnotation(
|
|
|
214
251
|
chartArea: Rect,
|
|
215
252
|
labelStyle: TextStyle,
|
|
216
253
|
defaultTextFill: string,
|
|
254
|
+
measure: AnnotationMeasureTextFn = heuristicMeasure,
|
|
217
255
|
): ResolvedAnnotation {
|
|
218
256
|
const fontSize = annotation.fontSize ?? DEFAULT_ANNOTATION_FONT_SIZE;
|
|
219
257
|
const fontWeight = annotation.fontWeight ?? DEFAULT_ANNOTATION_FONT_WEIGHT;
|
|
220
258
|
const lines = annotation.text.split('\n');
|
|
221
259
|
const estimatedWidth = Math.max(
|
|
222
260
|
0,
|
|
223
|
-
...lines.map((line) =>
|
|
261
|
+
...lines.map((line) => measure(line, { fontSize, fontWeight })),
|
|
224
262
|
);
|
|
225
263
|
|
|
226
264
|
// Pick initial side from anchor; default to 'left' (label sits to the left
|
|
@@ -261,6 +299,14 @@ function resolveDropLineAnnotation(
|
|
|
261
299
|
const from = { x: px, y: labelTopY - DROP_LINE_TOP_GAP };
|
|
262
300
|
const to = { x: px, y: py - DROP_LINE_BOTTOM_GAP };
|
|
263
301
|
|
|
302
|
+
const labelBounds = computeTextBlockBounds(
|
|
303
|
+
labelX,
|
|
304
|
+
labelBaselineY,
|
|
305
|
+
annotation.text,
|
|
306
|
+
resolvedStyle,
|
|
307
|
+
measure,
|
|
308
|
+
);
|
|
309
|
+
|
|
264
310
|
const label: ResolvedLabel = {
|
|
265
311
|
text: annotation.text,
|
|
266
312
|
x: labelX,
|
|
@@ -276,6 +322,7 @@ function resolveDropLineAnnotation(
|
|
|
276
322
|
},
|
|
277
323
|
background: annotation.background,
|
|
278
324
|
halo: annotation.halo,
|
|
325
|
+
bounds: labelBounds,
|
|
279
326
|
};
|
|
280
327
|
|
|
281
328
|
return {
|
|
@@ -286,5 +333,6 @@ function resolveDropLineAnnotation(
|
|
|
286
333
|
fill: annotation.fill,
|
|
287
334
|
opacity: annotation.opacity,
|
|
288
335
|
zIndex: annotation.zIndex,
|
|
336
|
+
bounds: labelBounds,
|
|
289
337
|
};
|
|
290
338
|
}
|
|
@@ -16,8 +16,8 @@ describe('computeMarkObstacles', () => {
|
|
|
16
16
|
const scales = { y: { type: 'linear' } } as unknown as ResolvedScales;
|
|
17
17
|
const result = computeMarkObstacles(marks, scales);
|
|
18
18
|
expect(result).toHaveLength(2);
|
|
19
|
-
expect(result[0]).toEqual({ x: 10, y: 20, width: 50, height: 30 });
|
|
20
|
-
expect(result[1]).toEqual({ x: 80, y: 20, width: 50, height: 30 });
|
|
19
|
+
expect(result[0]).toEqual({ x: 10, y: 20, width: 50, height: 30, kind: 'mark' });
|
|
20
|
+
expect(result[1]).toEqual({ x: 80, y: 20, width: 50, height: 30, kind: 'mark' });
|
|
21
21
|
});
|
|
22
22
|
|
|
23
23
|
it('returns point mark bounds as bounding box from cx/cy/r', () => {
|
|
@@ -25,7 +25,7 @@ describe('computeMarkObstacles', () => {
|
|
|
25
25
|
const scales = { y: { type: 'linear' } } as unknown as ResolvedScales;
|
|
26
26
|
const result = computeMarkObstacles(marks, scales);
|
|
27
27
|
expect(result).toHaveLength(1);
|
|
28
|
-
expect(result[0]).toEqual({ x: 90, y: 90, width: 20, height: 20 });
|
|
28
|
+
expect(result[0]).toEqual({ x: 90, y: 90, width: 20, height: 20, kind: 'mark' });
|
|
29
29
|
});
|
|
30
30
|
|
|
31
31
|
it('returns grouped row obstacles for band-scale charts', () => {
|
|
@@ -490,6 +490,73 @@ describe('computeBarMarks', () => {
|
|
|
490
490
|
});
|
|
491
491
|
});
|
|
492
492
|
|
|
493
|
+
describe('x-domain excludes zero', () => {
|
|
494
|
+
it('simple bars anchor at the domain minimum, not xScale(0)', () => {
|
|
495
|
+
const spec = makeSimpleBarSpec();
|
|
496
|
+
(spec.encoding.x as { scale?: { domain: number[] } }).scale = { domain: [65, 95] };
|
|
497
|
+
const scales = computeScales(spec, chartArea, spec.data);
|
|
498
|
+
const marks = computeBarMarks(spec, scales, chartArea, fullStrategy);
|
|
499
|
+
|
|
500
|
+
const xScale = scales.x!.scale as (v: number) => number;
|
|
501
|
+
const left = xScale(65);
|
|
502
|
+
|
|
503
|
+
// Every bar starts at the plot-area left edge (xScale of the domain min),
|
|
504
|
+
// not at the far-left extrapolation of xScale(0).
|
|
505
|
+
for (const mark of marks) {
|
|
506
|
+
expect(mark.x).toBeCloseTo(left, 5);
|
|
507
|
+
// Bars never bleed left of the plot area into the y-axis label gutter.
|
|
508
|
+
expect(mark.x).toBeGreaterThanOrEqual(chartArea.x);
|
|
509
|
+
}
|
|
510
|
+
// Guard against the old behavior: xScale(0) lands well left of the plot area.
|
|
511
|
+
expect(xScale(0)).toBeLessThan(chartArea.x);
|
|
512
|
+
});
|
|
513
|
+
|
|
514
|
+
it('grouped bars anchor at the domain minimum, not xScale(0)', () => {
|
|
515
|
+
const spec = makeGroupedBarSpec();
|
|
516
|
+
(spec.encoding.x as { scale?: { domain: number[] } }).scale = { domain: [65, 95] };
|
|
517
|
+
// Lift values into the [65, 95] window so widths stay positive.
|
|
518
|
+
spec.data = spec.data.map((d) => ({ ...d, value: 70 + (d.value as number) / 10 }));
|
|
519
|
+
const scales = computeScales(spec, chartArea, spec.data);
|
|
520
|
+
const marks = computeBarMarks(spec, scales, chartArea, fullStrategy);
|
|
521
|
+
|
|
522
|
+
const xScale = scales.x!.scale as (v: number) => number;
|
|
523
|
+
const left = xScale(65);
|
|
524
|
+
|
|
525
|
+
for (const mark of marks) {
|
|
526
|
+
expect(mark.x).toBeCloseTo(left, 5);
|
|
527
|
+
expect(mark.x).toBeGreaterThanOrEqual(chartArea.x);
|
|
528
|
+
}
|
|
529
|
+
});
|
|
530
|
+
});
|
|
531
|
+
|
|
532
|
+
describe('x-domain includes zero (no regression)', () => {
|
|
533
|
+
it('simple bars still anchor at xScale(0) for a default [0, max] domain', () => {
|
|
534
|
+
const spec = makeSimpleBarSpec();
|
|
535
|
+
const scales = computeScales(spec, chartArea, spec.data);
|
|
536
|
+
const marks = computeBarMarks(spec, scales, chartArea, fullStrategy);
|
|
537
|
+
|
|
538
|
+
const xScale = scales.x!.scale as (v: number) => number;
|
|
539
|
+
for (const mark of marks) {
|
|
540
|
+
expect(mark.x).toBeCloseTo(xScale(0), 5);
|
|
541
|
+
}
|
|
542
|
+
});
|
|
543
|
+
|
|
544
|
+
it('diverging bars (domain crosses zero) still anchor at xScale(0)', () => {
|
|
545
|
+
const spec = makeNegativeBarSpec();
|
|
546
|
+
const scales = computeScales(spec, chartArea, spec.data);
|
|
547
|
+
const marks = computeBarMarks(spec, scales, chartArea, fullStrategy);
|
|
548
|
+
|
|
549
|
+
const xScale = scales.x!.scale as (v: number) => number;
|
|
550
|
+
const zero = xScale(0);
|
|
551
|
+
|
|
552
|
+
const growth = marks.find((m) => m.aria.label.includes('Growth'))!;
|
|
553
|
+
const decline = marks.find((m) => m.aria.label.includes('Decline'))!;
|
|
554
|
+
// Positive bar starts at zero; negative bar ends at zero.
|
|
555
|
+
expect(growth.x).toBeCloseTo(zero, 5);
|
|
556
|
+
expect(decline.x + decline.width).toBeCloseTo(zero, 5);
|
|
557
|
+
});
|
|
558
|
+
});
|
|
559
|
+
|
|
493
560
|
describe('edge cases', () => {
|
|
494
561
|
it('returns empty array when no x encoding', () => {
|
|
495
562
|
const spec: NormalizedChartSpec = {
|
|
@@ -88,7 +88,14 @@ export function computeBarMarks(
|
|
|
88
88
|
}
|
|
89
89
|
|
|
90
90
|
const bandwidth = yScale.bandwidth();
|
|
91
|
-
|
|
91
|
+
// Baseline = pixel x where the bar's left edge anchors. When the x-domain
|
|
92
|
+
// includes zero (the common case), this is xScale(0). When the domain
|
|
93
|
+
// excludes zero (explicit non-zero domain or scale.zero: false), xScale(0)
|
|
94
|
+
// would extrapolate outside the plot area and overrun the y-axis labels, so
|
|
95
|
+
// anchor to the domain minimum (the plot-area left edge) instead.
|
|
96
|
+
const xDomain = xScale.domain() as [number, number];
|
|
97
|
+
const xIncludesZero = xDomain[0] <= 0 && xDomain[1] >= 0;
|
|
98
|
+
const baseline = xIncludesZero ? xScale(0) : xScale(xDomain[0]);
|
|
92
99
|
const colorEnc = encoding.color && 'field' in encoding.color ? encoding.color : undefined;
|
|
93
100
|
const conditionalColor =
|
|
94
101
|
encoding.color && isConditionalValueDef(encoding.color)
|
|
@@ -5,14 +5,17 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import type {
|
|
8
|
+
AreaMark,
|
|
8
9
|
Encoding,
|
|
10
|
+
LineMark,
|
|
9
11
|
Mark,
|
|
10
12
|
MarkDef,
|
|
11
13
|
PointMark,
|
|
12
|
-
Rect,
|
|
13
14
|
RectMark,
|
|
14
15
|
ResolvedAnimation,
|
|
15
16
|
} from '@opendata-ai/openchart-core';
|
|
17
|
+
import type { PlacementObstacle } from '../annotations/placement';
|
|
18
|
+
import { sampleAreaObstacles, samplePolylineObstacles } from '../annotations/placement';
|
|
16
19
|
import type { ResolvedScales } from '../layout/scales';
|
|
17
20
|
|
|
18
21
|
// ---------------------------------------------------------------------------
|
|
@@ -28,18 +31,17 @@ import type { ResolvedScales } from '../layout/scales';
|
|
|
28
31
|
* For other charts (column, scatter): returns individual mark bounds so
|
|
29
32
|
* annotations avoid overlapping any visible data mark.
|
|
30
33
|
*/
|
|
31
|
-
export function computeMarkObstacles(marks: Mark[], scales: ResolvedScales):
|
|
34
|
+
export function computeMarkObstacles(marks: Mark[], scales: ResolvedScales): PlacementObstacle[] {
|
|
32
35
|
// Band-scale y-axis: group marks by row for efficient obstacle computation
|
|
33
36
|
if (scales.y?.type === 'band') {
|
|
34
37
|
return computeBandRowObstacles(marks, scales);
|
|
35
38
|
}
|
|
36
39
|
|
|
37
|
-
|
|
38
|
-
const obstacles: Rect[] = [];
|
|
40
|
+
const obstacles: PlacementObstacle[] = [];
|
|
39
41
|
for (const mark of marks) {
|
|
40
42
|
if (mark.type === 'rect') {
|
|
41
43
|
const rm = mark as RectMark;
|
|
42
|
-
obstacles.push({ x: rm.x, y: rm.y, width: rm.width, height: rm.height });
|
|
44
|
+
obstacles.push({ x: rm.x, y: rm.y, width: rm.width, height: rm.height, kind: 'mark' });
|
|
43
45
|
} else if (mark.type === 'point') {
|
|
44
46
|
const pm = mark as PointMark;
|
|
45
47
|
obstacles.push({
|
|
@@ -47,14 +49,25 @@ export function computeMarkObstacles(marks: Mark[], scales: ResolvedScales): Rec
|
|
|
47
49
|
y: pm.cy - pm.r,
|
|
48
50
|
width: pm.r * 2,
|
|
49
51
|
height: pm.r * 2,
|
|
52
|
+
kind: 'mark',
|
|
50
53
|
});
|
|
54
|
+
} else if (mark.type === 'line') {
|
|
55
|
+
const lm = mark as LineMark;
|
|
56
|
+
if (lm.points.length > 0) {
|
|
57
|
+
obstacles.push(...samplePolylineObstacles(lm.points, 'line'));
|
|
58
|
+
}
|
|
59
|
+
} else if (mark.type === 'area') {
|
|
60
|
+
const am = mark as AreaMark;
|
|
61
|
+
if (am.topPoints.length > 0) {
|
|
62
|
+
obstacles.push(...sampleAreaObstacles(am.topPoints, am.bottomPoints));
|
|
63
|
+
}
|
|
51
64
|
}
|
|
52
65
|
}
|
|
53
66
|
return obstacles;
|
|
54
67
|
}
|
|
55
68
|
|
|
56
69
|
/** Group band-scale marks by row, returning one obstacle per band. */
|
|
57
|
-
function computeBandRowObstacles(marks: Mark[], scales: ResolvedScales):
|
|
70
|
+
function computeBandRowObstacles(marks: Mark[], scales: ResolvedScales): PlacementObstacle[] {
|
|
58
71
|
const rows = new Map<number, { minX: number; maxX: number; bandY: number }>();
|
|
59
72
|
|
|
60
73
|
for (const mark of marks) {
|
|
@@ -92,13 +105,14 @@ function computeBandRowObstacles(marks: Mark[], scales: ResolvedScales): Rect[]
|
|
|
92
105
|
const bandwidth = bandScale.bandwidth?.() ?? 0;
|
|
93
106
|
if (bandwidth === 0) return [];
|
|
94
107
|
|
|
95
|
-
const obstacles:
|
|
108
|
+
const obstacles: PlacementObstacle[] = [];
|
|
96
109
|
for (const { minX, maxX, bandY } of rows.values()) {
|
|
97
110
|
obstacles.push({
|
|
98
111
|
x: minX,
|
|
99
112
|
y: bandY - bandwidth / 2,
|
|
100
113
|
width: maxX - minX,
|
|
101
114
|
height: bandwidth,
|
|
115
|
+
kind: 'mark',
|
|
102
116
|
});
|
|
103
117
|
}
|
|
104
118
|
|
package/src/compile/layer.ts
CHANGED
|
@@ -9,8 +9,10 @@ import type {
|
|
|
9
9
|
} from '@opendata-ai/openchart-core';
|
|
10
10
|
import {
|
|
11
11
|
AXIS_TITLE_TRAILING_PAD,
|
|
12
|
+
abbreviateNumber,
|
|
12
13
|
BREAKPOINT_COMPACT_MAX,
|
|
13
14
|
estimateTextWidth,
|
|
15
|
+
formatNumber,
|
|
14
16
|
getAxisTitleOffset,
|
|
15
17
|
resolveTheme,
|
|
16
18
|
TICK_LABEL_OFFSET,
|
|
@@ -19,6 +21,8 @@ import { format as d3Format } from 'd3-format';
|
|
|
19
21
|
import { scaleLinear } from 'd3-scale';
|
|
20
22
|
import { curveMonotoneX, area as d3area, line as d3line } from 'd3-shape';
|
|
21
23
|
import { flattenLayers } from '../compiler/index';
|
|
24
|
+
import type { MeasureFn } from '../layout/plan';
|
|
25
|
+
import { createMeasureFn } from '../layout/plan';
|
|
22
26
|
|
|
23
27
|
type ChartCompiler = (spec: unknown, options: CompileOptions) => ChartLayout;
|
|
24
28
|
|
|
@@ -100,18 +104,21 @@ function estimateYAxisLabelWidth(
|
|
|
100
104
|
data: DataRow[],
|
|
101
105
|
encoding: Encoding | undefined,
|
|
102
106
|
baseFontSize: number,
|
|
107
|
+
measure?: MeasureFn,
|
|
103
108
|
): number {
|
|
104
109
|
if (!encoding?.y) return 40;
|
|
105
110
|
const yEnc = encoding.y;
|
|
106
111
|
const yField = yEnc.field;
|
|
107
112
|
if (!yField) return 40;
|
|
108
113
|
|
|
114
|
+
const m = measure ?? estimateTextWidth;
|
|
115
|
+
|
|
109
116
|
const yType = yEnc.type;
|
|
110
117
|
if (yType === 'nominal' || yType === 'ordinal') {
|
|
111
118
|
let maxWidth = 0;
|
|
112
119
|
for (const row of data) {
|
|
113
120
|
const label = String(row[yField] ?? '');
|
|
114
|
-
const w =
|
|
121
|
+
const w = m(label, baseFontSize, 400);
|
|
115
122
|
if (w > maxWidth) maxWidth = w;
|
|
116
123
|
}
|
|
117
124
|
return maxWidth > 0 ? maxWidth + 10 : 40;
|
|
@@ -134,16 +141,13 @@ function estimateYAxisLabelWidth(
|
|
|
134
141
|
sampleLabel = String(maxAbsVal);
|
|
135
142
|
}
|
|
136
143
|
} else {
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
else if (maxAbsVal >= 100) sampleLabel = '100';
|
|
141
|
-
else if (maxAbsVal >= 10) sampleLabel = '10';
|
|
142
|
-
else sampleLabel = '0.0';
|
|
144
|
+
// Use the same formatting as tick labels instead of hardcoded magnitude guesses
|
|
145
|
+
sampleLabel =
|
|
146
|
+
Math.abs(maxAbsVal) >= 1000 ? abbreviateNumber(maxAbsVal) : formatNumber(maxAbsVal);
|
|
143
147
|
}
|
|
144
148
|
const hasNeg = data.some((r) => Number(r[yField]) < 0);
|
|
145
149
|
const labelEst = (hasNeg ? '-' : '') + sampleLabel;
|
|
146
|
-
return
|
|
150
|
+
return m(labelEst, baseFontSize, 400) + 10;
|
|
147
151
|
}
|
|
148
152
|
|
|
149
153
|
function compileLayerIndependent(
|
|
@@ -173,7 +177,13 @@ function compileLayerIndependent(
|
|
|
173
177
|
|
|
174
178
|
const theme = resolveTheme(layerSpec.theme ?? leaf1.theme);
|
|
175
179
|
const axisFontSize = theme.fonts?.sizes?.axisTick ?? 11;
|
|
176
|
-
const
|
|
180
|
+
const measureFn = createMeasureFn(options.measureText);
|
|
181
|
+
const rightAxisWidth = estimateYAxisLabelWidth(
|
|
182
|
+
leaf1.data,
|
|
183
|
+
leaf1.encoding,
|
|
184
|
+
axisFontSize,
|
|
185
|
+
measureFn,
|
|
186
|
+
);
|
|
177
187
|
const yAxisConfig = leaf1.encoding?.y?.axis || undefined;
|
|
178
188
|
const hasRightAxisTitle = !!yAxisConfig?.title;
|
|
179
189
|
const tickExtent = TICK_LABEL_OFFSET + rightAxisWidth;
|
|
@@ -227,6 +237,18 @@ function compileLayerIndependent(
|
|
|
227
237
|
x: layout0.area.x + layout0.area.width,
|
|
228
238
|
y: layout0.area.y + layout0.area.height,
|
|
229
239
|
},
|
|
240
|
+
...(layout1.axes.y.label
|
|
241
|
+
? {
|
|
242
|
+
titlePosition: {
|
|
243
|
+
x:
|
|
244
|
+
layout0.area.x +
|
|
245
|
+
layout0.area.width +
|
|
246
|
+
getAxisTitleOffset(layout0.dimensions.width),
|
|
247
|
+
y: layout0.area.y + layout0.area.height / 2,
|
|
248
|
+
angle: 90,
|
|
249
|
+
},
|
|
250
|
+
}
|
|
251
|
+
: {}),
|
|
230
252
|
}
|
|
231
253
|
: undefined;
|
|
232
254
|
|