@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
|
@@ -11,7 +11,12 @@
|
|
|
11
11
|
* At compact breakpoints, annotations are simplified or hidden.
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
|
-
import type {
|
|
14
|
+
import type {
|
|
15
|
+
LayoutStrategy,
|
|
16
|
+
Rect,
|
|
17
|
+
ResolvedAnnotation,
|
|
18
|
+
TextAnnotation,
|
|
19
|
+
} from '@opendata-ai/openchart-core';
|
|
15
20
|
import type { NormalizedChartSpec } from '../compiler/types';
|
|
16
21
|
import type { ResolvedScales } from '../layout/scales';
|
|
17
22
|
import {
|
|
@@ -19,9 +24,30 @@ import {
|
|
|
19
24
|
nudgeAnnotationFromObstacles,
|
|
20
25
|
resolveAnnotationCollisions,
|
|
21
26
|
} from './collisions';
|
|
27
|
+
import { DEFAULT_ANNOTATION_FONT_WEIGHT, SUBTITLE_FONT_SIZE_RATIO } from './constants';
|
|
28
|
+
import type { AnnotationMeasureTextFn } from './geometry';
|
|
29
|
+
import { heuristicMeasure } from './geometry';
|
|
30
|
+
import {
|
|
31
|
+
findBestPlacement,
|
|
32
|
+
isAutoPlacement,
|
|
33
|
+
normalizeObstacles,
|
|
34
|
+
type PlacementObstacle,
|
|
35
|
+
} from './placement';
|
|
36
|
+
import { resolvePosition } from './position';
|
|
22
37
|
import { resolveRangeAnnotation } from './resolve-range';
|
|
23
38
|
import { resolveRefLineAnnotation } from './resolve-refline';
|
|
24
|
-
import { resolveTextAnnotation } from './resolve-text';
|
|
39
|
+
import { makeAnnotationLabelStyle, resolveTextAnnotation } from './resolve-text';
|
|
40
|
+
|
|
41
|
+
export interface AnnotationContext {
|
|
42
|
+
scales: ResolvedScales;
|
|
43
|
+
chartArea: Rect;
|
|
44
|
+
strategy: LayoutStrategy;
|
|
45
|
+
isDark: boolean;
|
|
46
|
+
obstacles: PlacementObstacle[];
|
|
47
|
+
svg: { width: number; height: number };
|
|
48
|
+
measure: AnnotationMeasureTextFn;
|
|
49
|
+
debugPlacement?: boolean;
|
|
50
|
+
}
|
|
25
51
|
|
|
26
52
|
/**
|
|
27
53
|
* Compute resolved annotations from spec annotations using the resolved scales.
|
|
@@ -36,21 +62,65 @@ import { resolveTextAnnotation } from './resolve-text';
|
|
|
36
62
|
* annotation collisions are resolved using a greedy placement algorithm.
|
|
37
63
|
* Finally, labels are clamped to stay within the total SVG bounds.
|
|
38
64
|
*/
|
|
65
|
+
export function computeAnnotations(
|
|
66
|
+
spec: NormalizedChartSpec,
|
|
67
|
+
ctx: AnnotationContext,
|
|
68
|
+
): ResolvedAnnotation[];
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* @deprecated Use the context-object overload instead.
|
|
72
|
+
*/
|
|
39
73
|
export function computeAnnotations(
|
|
40
74
|
spec: NormalizedChartSpec,
|
|
41
75
|
scales: ResolvedScales,
|
|
42
76
|
chartArea: Rect,
|
|
43
77
|
strategy: LayoutStrategy,
|
|
44
|
-
isDark
|
|
45
|
-
obstacles
|
|
78
|
+
isDark?: boolean,
|
|
79
|
+
obstacles?: Rect[],
|
|
80
|
+
svgDimensions?: { width: number; height: number },
|
|
81
|
+
): ResolvedAnnotation[];
|
|
82
|
+
|
|
83
|
+
export function computeAnnotations(
|
|
84
|
+
spec: NormalizedChartSpec,
|
|
85
|
+
scalesOrCtx: ResolvedScales | AnnotationContext,
|
|
86
|
+
chartArea?: Rect,
|
|
87
|
+
strategy?: LayoutStrategy,
|
|
88
|
+
isDark?: boolean,
|
|
89
|
+
obstacles?: Rect[],
|
|
46
90
|
svgDimensions?: { width: number; height: number },
|
|
47
91
|
): ResolvedAnnotation[] {
|
|
48
|
-
|
|
92
|
+
let ctx: AnnotationContext;
|
|
93
|
+
|
|
94
|
+
if ('measure' in scalesOrCtx) {
|
|
95
|
+
ctx = scalesOrCtx as AnnotationContext;
|
|
96
|
+
} else {
|
|
97
|
+
ctx = {
|
|
98
|
+
scales: scalesOrCtx as ResolvedScales,
|
|
99
|
+
chartArea: chartArea!,
|
|
100
|
+
strategy: strategy!,
|
|
101
|
+
isDark: isDark ?? false,
|
|
102
|
+
obstacles: obstacles ? normalizeObstacles(obstacles) : [],
|
|
103
|
+
svg: svgDimensions ?? { width: 0, height: 0 },
|
|
104
|
+
measure: heuristicMeasure,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
49
107
|
|
|
108
|
+
const isCompact = ctx.strategy.annotationPosition === 'tooltip-only';
|
|
109
|
+
|
|
110
|
+
const svgRect: Rect = { x: 0, y: 0, width: ctx.svg.width, height: ctx.svg.height };
|
|
50
111
|
const annotations: ResolvedAnnotation[] = [];
|
|
51
112
|
|
|
113
|
+
// Deferred auto-placement entries (resolved in Pass 2)
|
|
114
|
+
const autoQueue: Array<{
|
|
115
|
+
annotation: TextAnnotation;
|
|
116
|
+
anchorX: number;
|
|
117
|
+
anchorY: number;
|
|
118
|
+
resolved: ResolvedAnnotation;
|
|
119
|
+
index: number;
|
|
120
|
+
}> = [];
|
|
121
|
+
|
|
122
|
+
// ---- Pass 1: resolve explicit annotations and queue auto ones ----
|
|
52
123
|
for (const annotation of spec.annotations) {
|
|
53
|
-
// At compact breakpoints, skip annotations unless they opt out with responsive: false
|
|
54
124
|
if (isCompact && annotation.responsive !== false) {
|
|
55
125
|
continue;
|
|
56
126
|
}
|
|
@@ -58,31 +128,202 @@ export function computeAnnotations(
|
|
|
58
128
|
|
|
59
129
|
switch (annotation.type) {
|
|
60
130
|
case 'text':
|
|
61
|
-
resolved = resolveTextAnnotation(
|
|
131
|
+
resolved = resolveTextAnnotation(
|
|
132
|
+
annotation,
|
|
133
|
+
ctx.scales,
|
|
134
|
+
ctx.chartArea,
|
|
135
|
+
ctx.isDark,
|
|
136
|
+
ctx.measure,
|
|
137
|
+
);
|
|
138
|
+
if (resolved && ctx.svg.width > 0 && ctx.svg.height > 0 && isAutoPlacement(annotation)) {
|
|
139
|
+
const px = resolvePosition(annotation.x, ctx.scales.x);
|
|
140
|
+
const py = resolvePosition(annotation.y, ctx.scales.y);
|
|
141
|
+
if (px !== null && py !== null) {
|
|
142
|
+
autoQueue.push({
|
|
143
|
+
annotation,
|
|
144
|
+
anchorX: px,
|
|
145
|
+
anchorY: py,
|
|
146
|
+
resolved,
|
|
147
|
+
index: annotations.length,
|
|
148
|
+
});
|
|
149
|
+
annotations.push(resolved);
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
62
153
|
break;
|
|
63
154
|
case 'range':
|
|
64
|
-
resolved = resolveRangeAnnotation(annotation, scales, chartArea, isDark);
|
|
155
|
+
resolved = resolveRangeAnnotation(annotation, ctx.scales, ctx.chartArea, ctx.isDark);
|
|
65
156
|
break;
|
|
66
157
|
case 'refline':
|
|
67
|
-
resolved = resolveRefLineAnnotation(annotation, scales, chartArea, isDark);
|
|
158
|
+
resolved = resolveRefLineAnnotation(annotation, ctx.scales, ctx.chartArea, ctx.isDark);
|
|
68
159
|
break;
|
|
69
160
|
}
|
|
70
161
|
|
|
71
162
|
if (resolved) {
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
163
|
+
if (annotation.type === 'text' && ctx.obstacles.length > 0) {
|
|
164
|
+
nudgeAnnotationFromObstacles(
|
|
165
|
+
resolved,
|
|
166
|
+
annotation,
|
|
167
|
+
ctx.scales,
|
|
168
|
+
ctx.chartArea,
|
|
169
|
+
ctx.obstacles,
|
|
170
|
+
ctx.measure,
|
|
171
|
+
);
|
|
75
172
|
}
|
|
76
173
|
annotations.push(resolved);
|
|
77
174
|
}
|
|
78
175
|
}
|
|
79
176
|
|
|
80
|
-
//
|
|
81
|
-
|
|
177
|
+
// ---- Pass 2: scored placement search for auto annotations ----
|
|
178
|
+
if (autoQueue.length > 0 && ctx.svg.width > 0 && ctx.svg.height > 0) {
|
|
179
|
+
// Build the working obstacle list: base obstacles + explicitly placed annotations
|
|
180
|
+
const workingObstacles: PlacementObstacle[] = [...ctx.obstacles];
|
|
181
|
+
for (let i = 0; i < annotations.length; i++) {
|
|
182
|
+
const a = annotations[i];
|
|
183
|
+
if (a.bounds && !autoQueue.some((q) => q.index === i)) {
|
|
184
|
+
workingObstacles.push({ ...a.bounds, kind: 'annotation' } as PlacementObstacle);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
for (const entry of autoQueue) {
|
|
189
|
+
const { annotation, anchorX, anchorY, resolved } = entry;
|
|
190
|
+
const labelStyle = makeAnnotationLabelStyle(
|
|
191
|
+
annotation.fontSize,
|
|
192
|
+
annotation.fontWeight,
|
|
193
|
+
undefined,
|
|
194
|
+
ctx.isDark,
|
|
195
|
+
);
|
|
196
|
+
|
|
197
|
+
const subtitleStyle = annotation.subtitle
|
|
198
|
+
? {
|
|
199
|
+
fontSize: labelStyle.fontSize * SUBTITLE_FONT_SIZE_RATIO,
|
|
200
|
+
fontWeight: DEFAULT_ANNOTATION_FONT_WEIGHT,
|
|
201
|
+
lineHeight: labelStyle.lineHeight,
|
|
202
|
+
}
|
|
203
|
+
: undefined;
|
|
204
|
+
|
|
205
|
+
const result = findBestPlacement(
|
|
206
|
+
anchorX,
|
|
207
|
+
anchorY,
|
|
208
|
+
annotation.text,
|
|
209
|
+
{
|
|
210
|
+
fontSize: labelStyle.fontSize,
|
|
211
|
+
fontWeight: Number(labelStyle.fontWeight) || 400,
|
|
212
|
+
lineHeight: labelStyle.lineHeight,
|
|
213
|
+
},
|
|
214
|
+
annotation.subtitle,
|
|
215
|
+
subtitleStyle,
|
|
216
|
+
workingObstacles,
|
|
217
|
+
ctx.chartArea,
|
|
218
|
+
svgRect,
|
|
219
|
+
ctx.measure,
|
|
220
|
+
ctx.debugPlacement,
|
|
221
|
+
);
|
|
222
|
+
|
|
223
|
+
// Apply placement result to the resolved annotation
|
|
224
|
+
if (resolved.label) {
|
|
225
|
+
resolved.label.x = result.labelX;
|
|
226
|
+
resolved.label.y = result.labelY;
|
|
227
|
+
resolved.label.style = {
|
|
228
|
+
...resolved.label.style,
|
|
229
|
+
textAnchor: result.textAnchor,
|
|
230
|
+
};
|
|
231
|
+
resolved.label.bounds = result.bounds;
|
|
232
|
+
|
|
233
|
+
if (resolved.label.connector) {
|
|
234
|
+
const box = result.bounds;
|
|
235
|
+
const cx = box.x + box.width / 2;
|
|
236
|
+
const cy = box.y + box.height / 2;
|
|
237
|
+
const isCurve = resolved.label.connector.style === 'curve';
|
|
238
|
+
if (isCurve) {
|
|
239
|
+
resolved.label.connector.from = { x: box.x + box.width, y: cy };
|
|
240
|
+
} else {
|
|
241
|
+
const halfW = box.width / 2 || 1;
|
|
242
|
+
const halfH = box.height / 2 || 1;
|
|
243
|
+
const ndx = (anchorX - cx) / halfW;
|
|
244
|
+
const ndy = (anchorY - cy) / halfH;
|
|
245
|
+
if (Math.abs(ndy) >= Math.abs(ndx)) {
|
|
246
|
+
resolved.label.connector.from = {
|
|
247
|
+
x: cx,
|
|
248
|
+
y: ndy < 0 ? box.y : box.y + box.height,
|
|
249
|
+
};
|
|
250
|
+
} else {
|
|
251
|
+
resolved.label.connector.from = {
|
|
252
|
+
x: ndx < 0 ? box.x : box.x + box.width,
|
|
253
|
+
y: cy,
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
resolved.bounds = result.bounds;
|
|
260
|
+
|
|
261
|
+
// Add this annotation's bounds as obstacle for subsequent auto annotations
|
|
262
|
+
workingObstacles.push({ ...result.bounds, kind: 'annotation' } as PlacementObstacle);
|
|
263
|
+
}
|
|
264
|
+
} else if (autoQueue.length > 0) {
|
|
265
|
+
// Fallback: no SVG dimensions, use legacy nudge for auto annotations
|
|
266
|
+
for (const entry of autoQueue) {
|
|
267
|
+
if (ctx.obstacles.length > 0) {
|
|
268
|
+
nudgeAnnotationFromObstacles(
|
|
269
|
+
entry.resolved,
|
|
270
|
+
entry.annotation,
|
|
271
|
+
ctx.scales,
|
|
272
|
+
ctx.chartArea,
|
|
273
|
+
ctx.obstacles,
|
|
274
|
+
ctx.measure,
|
|
275
|
+
);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// Resolve annotation-to-annotation collisions for non-auto-placed annotations.
|
|
281
|
+
// When auto placement ran, those annotations already avoid each other via the
|
|
282
|
+
// obstacle-accumulation loop above, so only explicitly placed ones need this.
|
|
283
|
+
if (autoQueue.length > 0) {
|
|
284
|
+
// Build aligned pairs of explicit annotations + their specs
|
|
285
|
+
const autoIndices = new Set(autoQueue.map((q) => q.index));
|
|
286
|
+
const explicitAnnotations: ResolvedAnnotation[] = [];
|
|
287
|
+
const explicitSpecs: NormalizedChartSpec['annotations'] = [];
|
|
288
|
+
let specIdx = 0;
|
|
289
|
+
for (let i = 0; i < annotations.length; i++) {
|
|
290
|
+
// Walk specIdx past any compact-skipped specs
|
|
291
|
+
while (specIdx < spec.annotations.length) {
|
|
292
|
+
const s = spec.annotations[specIdx];
|
|
293
|
+
if (!isCompact || s.responsive === false) break;
|
|
294
|
+
specIdx++;
|
|
295
|
+
}
|
|
296
|
+
if (specIdx >= spec.annotations.length) break;
|
|
297
|
+
if (autoIndices.has(i)) {
|
|
298
|
+
specIdx++;
|
|
299
|
+
continue;
|
|
300
|
+
}
|
|
301
|
+
explicitAnnotations.push(annotations[i]);
|
|
302
|
+
explicitSpecs.push(spec.annotations[specIdx]);
|
|
303
|
+
specIdx++;
|
|
304
|
+
}
|
|
305
|
+
if (explicitAnnotations.length > 1) {
|
|
306
|
+
resolveAnnotationCollisions(
|
|
307
|
+
explicitAnnotations,
|
|
308
|
+
explicitSpecs,
|
|
309
|
+
ctx.scales,
|
|
310
|
+
ctx.chartArea,
|
|
311
|
+
ctx.measure,
|
|
312
|
+
);
|
|
313
|
+
}
|
|
314
|
+
} else {
|
|
315
|
+
resolveAnnotationCollisions(
|
|
316
|
+
annotations,
|
|
317
|
+
spec.annotations,
|
|
318
|
+
ctx.scales,
|
|
319
|
+
ctx.chartArea,
|
|
320
|
+
ctx.measure,
|
|
321
|
+
);
|
|
322
|
+
}
|
|
82
323
|
|
|
83
324
|
// Clamp labels that overflow the SVG boundary back inside
|
|
84
|
-
if (
|
|
85
|
-
clampAnnotationsToBounds(annotations,
|
|
325
|
+
if (ctx.svg.width > 0 && ctx.svg.height > 0) {
|
|
326
|
+
clampAnnotationsToBounds(annotations, ctx.svg.width, ctx.svg.height, ctx.measure);
|
|
86
327
|
}
|
|
87
328
|
|
|
88
329
|
// Sort by zIndex (lower first, undefined treated as 0)
|
|
@@ -16,28 +16,64 @@ import {
|
|
|
16
16
|
DEFAULT_LINE_HEIGHT,
|
|
17
17
|
} from './constants';
|
|
18
18
|
|
|
19
|
+
export type AnnotationMeasureTextFn = (
|
|
20
|
+
text: string,
|
|
21
|
+
font: { fontSize: number; fontWeight: number; fontFamily?: string },
|
|
22
|
+
) => number;
|
|
23
|
+
|
|
24
|
+
export const heuristicMeasure: AnnotationMeasureTextFn = (text, { fontSize, fontWeight }) =>
|
|
25
|
+
estimateTextWidth(text, fontSize, fontWeight);
|
|
26
|
+
|
|
19
27
|
/**
|
|
20
|
-
* Compute the bounding box of
|
|
21
|
-
*
|
|
28
|
+
* Compute the bounding box of a text block, aware of textAnchor and multi-line layout.
|
|
29
|
+
* labelY is the first-line baseline.
|
|
22
30
|
*/
|
|
23
|
-
export function
|
|
31
|
+
export function computeTextBlockBounds(
|
|
24
32
|
labelX: number,
|
|
25
33
|
labelY: number,
|
|
26
34
|
text: string,
|
|
27
|
-
|
|
28
|
-
|
|
35
|
+
style: {
|
|
36
|
+
fontSize: number;
|
|
37
|
+
fontWeight: number;
|
|
38
|
+
lineHeight: number;
|
|
39
|
+
textAnchor?: 'start' | 'middle' | 'end';
|
|
40
|
+
},
|
|
41
|
+
measure: AnnotationMeasureTextFn = heuristicMeasure,
|
|
29
42
|
): Rect {
|
|
30
43
|
const lines = text.split('\n');
|
|
31
|
-
const
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
44
|
+
const maxWidth = Math.max(
|
|
45
|
+
...lines.map((line) =>
|
|
46
|
+
measure(line, { fontSize: style.fontSize, fontWeight: style.fontWeight }),
|
|
47
|
+
),
|
|
48
|
+
);
|
|
49
|
+
const height =
|
|
50
|
+
style.fontSize + (lines.length - 1) * style.fontSize * style.lineHeight + style.fontSize * 0.3;
|
|
51
|
+
|
|
52
|
+
let x: number;
|
|
53
|
+
if (style.textAnchor === 'middle') {
|
|
54
|
+
x = labelX - maxWidth / 2;
|
|
55
|
+
} else if (style.textAnchor === 'end') {
|
|
56
|
+
x = labelX - maxWidth;
|
|
57
|
+
} else {
|
|
58
|
+
x = labelX;
|
|
59
|
+
}
|
|
35
60
|
|
|
36
61
|
return {
|
|
37
62
|
x,
|
|
38
|
-
y: labelY - fontSize,
|
|
63
|
+
y: labelY - style.fontSize,
|
|
39
64
|
width: maxWidth,
|
|
40
|
-
height
|
|
65
|
+
height,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function unionRects(a: Rect, b: Rect): Rect {
|
|
70
|
+
const x = Math.min(a.x, b.x);
|
|
71
|
+
const y = Math.min(a.y, b.y);
|
|
72
|
+
return {
|
|
73
|
+
x,
|
|
74
|
+
y,
|
|
75
|
+
width: Math.max(a.x + a.width, b.x + b.width) - x,
|
|
76
|
+
height: Math.max(a.y + a.height, b.y + b.height) - y,
|
|
41
77
|
};
|
|
42
78
|
}
|
|
43
79
|
|
|
@@ -98,7 +134,15 @@ export function computeConnectorOrigin(
|
|
|
98
134
|
targetY: number,
|
|
99
135
|
connectorStyle: 'straight' | 'curve',
|
|
100
136
|
): { x: number; y: number } {
|
|
101
|
-
const
|
|
137
|
+
const lines = text.split('\n');
|
|
138
|
+
const isMultiLine = lines.length > 1;
|
|
139
|
+
const anchor: 'start' | 'middle' = isMultiLine ? 'middle' : 'start';
|
|
140
|
+
const box = computeTextBlockBounds(labelX, labelY, text, {
|
|
141
|
+
fontSize,
|
|
142
|
+
fontWeight,
|
|
143
|
+
lineHeight: DEFAULT_LINE_HEIGHT,
|
|
144
|
+
textAnchor: anchor,
|
|
145
|
+
});
|
|
102
146
|
const boxCenterX = box.x + box.width / 2;
|
|
103
147
|
const boxCenterY = box.y + box.height / 2;
|
|
104
148
|
|
|
@@ -130,11 +174,25 @@ export function computeConnectorOrigin(
|
|
|
130
174
|
: { x: box.x + box.width, y: boxCenterY }; // right
|
|
131
175
|
}
|
|
132
176
|
|
|
133
|
-
/** Estimate the bounding box of an annotation label. */
|
|
134
|
-
export function estimateLabelBounds(
|
|
177
|
+
/** Estimate the bounding box of an annotation label using its resolved style. */
|
|
178
|
+
export function estimateLabelBounds(
|
|
179
|
+
label: ResolvedLabel,
|
|
180
|
+
measure: AnnotationMeasureTextFn = heuristicMeasure,
|
|
181
|
+
): Rect {
|
|
135
182
|
const fontSize = label.style.fontSize ?? DEFAULT_ANNOTATION_FONT_SIZE;
|
|
136
183
|
const fontWeight = label.style.fontWeight ?? DEFAULT_ANNOTATION_FONT_WEIGHT;
|
|
137
|
-
return
|
|
184
|
+
return computeTextBlockBounds(
|
|
185
|
+
label.x,
|
|
186
|
+
label.y,
|
|
187
|
+
label.text,
|
|
188
|
+
{
|
|
189
|
+
fontSize,
|
|
190
|
+
fontWeight,
|
|
191
|
+
lineHeight: label.style.lineHeight ?? DEFAULT_LINE_HEIGHT,
|
|
192
|
+
textAnchor: label.style.textAnchor ?? 'start',
|
|
193
|
+
},
|
|
194
|
+
measure,
|
|
195
|
+
);
|
|
138
196
|
}
|
|
139
197
|
|
|
140
198
|
/**
|