@opendata-ai/openchart-engine 6.12.0 → 6.15.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.js +1022 -648
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/axes.test.ts +12 -30
- package/src/__tests__/compile-chart.test.ts +4 -4
- package/src/__tests__/dimensions.test.ts +2 -2
- package/src/__tests__/encoding-sugar.test.ts +390 -0
- package/src/annotations/collisions.ts +268 -0
- package/src/annotations/compute.ts +9 -912
- package/src/annotations/constants.ts +32 -0
- package/src/annotations/geometry.ts +167 -0
- package/src/annotations/position.ts +95 -0
- package/src/annotations/resolve-range.ts +98 -0
- package/src/annotations/resolve-refline.ts +148 -0
- package/src/annotations/resolve-text.ts +134 -0
- package/src/charts/__tests__/post-process.test.ts +258 -0
- package/src/charts/bar/__tests__/labels.test.ts +31 -0
- package/src/charts/bar/compute.ts +27 -6
- package/src/charts/bar/index.ts +3 -0
- package/src/charts/bar/labels.ts +38 -14
- package/src/charts/column/__tests__/compute.test.ts +99 -0
- package/src/charts/column/compute.ts +27 -6
- package/src/charts/column/index.ts +3 -0
- package/src/charts/column/labels.ts +35 -13
- package/src/charts/dot/index.ts +10 -1
- package/src/charts/dot/labels.ts +37 -6
- package/src/charts/line/area.ts +31 -6
- package/src/charts/line/compute.ts +7 -2
- package/src/charts/line/index.ts +33 -2
- package/src/charts/post-process.ts +215 -0
- package/src/compile.ts +91 -158
- package/src/compiler/normalize.ts +2 -2
- package/src/layout/axes.ts +12 -15
- package/src/layout/dimensions.ts +3 -3
- package/src/layout/scales.ts +116 -36
- package/src/legend/compute.ts +2 -4
- package/src/tooltips/__tests__/compute.test.ts +188 -0
- package/src/tooltips/compute.ts +54 -12
- package/src/transforms/__tests__/aggregate.test.ts +159 -0
- package/src/transforms/__tests__/fold.test.ts +79 -0
- package/src/transforms/aggregate.ts +130 -0
- package/src/transforms/fold.ts +49 -0
- package/src/transforms/index.ts +8 -0
|
@@ -11,920 +11,17 @@
|
|
|
11
11
|
* At compact breakpoints, annotations are simplified or hidden.
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
|
-
import type {
|
|
15
|
-
AnnotationAnchor,
|
|
16
|
-
AnnotationOffset,
|
|
17
|
-
LayoutStrategy,
|
|
18
|
-
Point,
|
|
19
|
-
RangeAnnotation,
|
|
20
|
-
Rect,
|
|
21
|
-
RefLineAnnotation,
|
|
22
|
-
ResolvedAnnotation,
|
|
23
|
-
ResolvedLabel,
|
|
24
|
-
TextAnnotation,
|
|
25
|
-
TextStyle,
|
|
26
|
-
} from '@opendata-ai/openchart-core';
|
|
27
|
-
import { detectCollision, estimateTextWidth } from '@opendata-ai/openchart-core';
|
|
28
|
-
import type { ScaleBand, ScaleLinear, ScaleTime } from 'd3-scale';
|
|
14
|
+
import type { LayoutStrategy, Rect, ResolvedAnnotation } from '@opendata-ai/openchart-core';
|
|
29
15
|
import type { NormalizedChartSpec } from '../compiler/types';
|
|
30
16
|
import type { ResolvedScales } from '../layout/scales';
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
const DEFAULT_RANGE_FILL = '#f0c040';
|
|
40
|
-
const DEFAULT_RANGE_OPACITY = 0.15;
|
|
41
|
-
const DEFAULT_REFLINE_DASH = '4 3';
|
|
42
|
-
|
|
43
|
-
// Theme-aware defaults for text and stroke colors
|
|
44
|
-
const LIGHT_TEXT_FILL = '#333333';
|
|
45
|
-
const DARK_TEXT_FILL = '#d1d5db';
|
|
46
|
-
const LIGHT_REFLINE_STROKE = '#888888';
|
|
47
|
-
const DARK_REFLINE_STROKE = '#9ca3af';
|
|
48
|
-
|
|
49
|
-
/** Default label offset when using anchor directions. */
|
|
50
|
-
const ANCHOR_OFFSET = 8;
|
|
51
|
-
|
|
52
|
-
/**
|
|
53
|
-
* Interpolate a numeric value between sorted domain entries.
|
|
54
|
-
* Used when an annotation references a value not present in a categorical domain
|
|
55
|
-
* (e.g. "2008" on an axis with data points at "2007" and "2009").
|
|
56
|
-
* Returns null if domain values aren't numeric or the domain is too small.
|
|
57
|
-
*/
|
|
58
|
-
function interpolateInDomain(
|
|
59
|
-
numValue: number,
|
|
60
|
-
domain: string[],
|
|
61
|
-
positionOf: (entry: string) => number,
|
|
62
|
-
): number | null {
|
|
63
|
-
if (domain.length < 2) return null;
|
|
64
|
-
const nums = domain.map(Number);
|
|
65
|
-
if (!nums.every(Number.isFinite)) return null;
|
|
66
|
-
|
|
67
|
-
// Sort by numeric value so bracket-finding works regardless of data order
|
|
68
|
-
const sorted = nums.map((n, i) => ({ n, i })).sort((a, b) => a.n - b.n);
|
|
69
|
-
|
|
70
|
-
// Find the two sorted neighbors that bracket this value
|
|
71
|
-
let lower = 0;
|
|
72
|
-
let upper = sorted.length - 1;
|
|
73
|
-
for (let i = 0; i < sorted.length; i++) {
|
|
74
|
-
if (sorted[i].n <= numValue) lower = i;
|
|
75
|
-
if (sorted[i].n >= numValue) {
|
|
76
|
-
upper = i;
|
|
77
|
-
break;
|
|
78
|
-
}
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
const lowerPos = positionOf(domain[sorted[lower].i]);
|
|
82
|
-
const upperPos = positionOf(domain[sorted[upper].i]);
|
|
83
|
-
if (lower === upper) return lowerPos;
|
|
84
|
-
const t = (numValue - sorted[lower].n) / (sorted[upper].n - sorted[lower].n);
|
|
85
|
-
return lowerPos + t * (upperPos - lowerPos);
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
/** Resolve a data value to a pixel position on a given axis. */
|
|
89
|
-
function resolvePosition(
|
|
90
|
-
value: string | number,
|
|
91
|
-
scale: ResolvedScales['x'] | ResolvedScales['y'],
|
|
92
|
-
): number | null {
|
|
93
|
-
if (!scale) return null;
|
|
94
|
-
|
|
95
|
-
const s = scale.scale;
|
|
96
|
-
const type = scale.type;
|
|
97
|
-
|
|
98
|
-
if (type === 'time') {
|
|
99
|
-
const date = new Date(String(value));
|
|
100
|
-
if (Number.isNaN(date.getTime())) return null;
|
|
101
|
-
return (s as ScaleTime<number, number>)(date);
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
if (type === 'linear' || type === 'log') {
|
|
105
|
-
const num = typeof value === 'number' ? value : Number(value);
|
|
106
|
-
if (!Number.isFinite(num)) return null;
|
|
107
|
-
return (s as ScaleLinear<number, number>)(num);
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
if (type === 'band') {
|
|
111
|
-
const bandScale = s as ScaleBand<string>;
|
|
112
|
-
const strValue = String(value);
|
|
113
|
-
const pos = bandScale(strValue);
|
|
114
|
-
if (pos !== undefined) return pos + (bandScale.bandwidth?.() ?? 0) / 2;
|
|
115
|
-
|
|
116
|
-
const bw = bandScale.bandwidth?.() ?? 0;
|
|
117
|
-
return interpolateInDomain(
|
|
118
|
-
Number(strValue),
|
|
119
|
-
bandScale.domain(),
|
|
120
|
-
(entry) => (bandScale(entry) ?? 0) + bw / 2,
|
|
121
|
-
);
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
// point or ordinal: try direct lookup, fall back to interpolation
|
|
125
|
-
const strValue = String(value);
|
|
126
|
-
const directResult = (s as (v: string) => number | undefined)(strValue);
|
|
127
|
-
if (directResult !== undefined) return directResult;
|
|
128
|
-
|
|
129
|
-
if (type === 'point' || type === 'ordinal') {
|
|
130
|
-
const domain = (s as { domain(): string[] }).domain();
|
|
131
|
-
return interpolateInDomain(
|
|
132
|
-
Number(strValue),
|
|
133
|
-
domain,
|
|
134
|
-
(entry) => (s as (v: string) => number)(entry) ?? 0,
|
|
135
|
-
);
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
return null;
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
function makeAnnotationLabelStyle(
|
|
142
|
-
fontSize?: number,
|
|
143
|
-
fontWeight?: number,
|
|
144
|
-
fill?: string,
|
|
145
|
-
isDark?: boolean,
|
|
146
|
-
): TextStyle {
|
|
147
|
-
const defaultFill = isDark ? DARK_TEXT_FILL : LIGHT_TEXT_FILL;
|
|
148
|
-
return {
|
|
149
|
-
fontFamily: 'Inter, system-ui, sans-serif',
|
|
150
|
-
fontSize: fontSize ?? DEFAULT_ANNOTATION_FONT_SIZE,
|
|
151
|
-
fontWeight: fontWeight ?? DEFAULT_ANNOTATION_FONT_WEIGHT,
|
|
152
|
-
fill: fill ?? defaultFill,
|
|
153
|
-
lineHeight: DEFAULT_LINE_HEIGHT,
|
|
154
|
-
textAnchor: 'start',
|
|
155
|
-
};
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
/**
|
|
159
|
-
* Compute the bounding box of annotation text at a given label position.
|
|
160
|
-
* Multi-line text is centered at labelX; single-line starts at labelX.
|
|
161
|
-
*/
|
|
162
|
-
function computeTextBounds(
|
|
163
|
-
labelX: number,
|
|
164
|
-
labelY: number,
|
|
165
|
-
text: string,
|
|
166
|
-
fontSize: number,
|
|
167
|
-
fontWeight: number,
|
|
168
|
-
): Rect {
|
|
169
|
-
const lines = text.split('\n');
|
|
170
|
-
const isMultiLine = lines.length > 1;
|
|
171
|
-
const maxWidth = Math.max(...lines.map((line) => estimateTextWidth(line, fontSize, fontWeight)));
|
|
172
|
-
const totalHeight = lines.length * fontSize * DEFAULT_LINE_HEIGHT;
|
|
173
|
-
const x = isMultiLine ? labelX - maxWidth / 2 : labelX;
|
|
174
|
-
|
|
175
|
-
return {
|
|
176
|
-
x,
|
|
177
|
-
y: labelY - fontSize,
|
|
178
|
-
width: maxWidth,
|
|
179
|
-
height: totalHeight,
|
|
180
|
-
};
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
/**
|
|
184
|
-
* Apply anchor direction to compute label offset from data point.
|
|
185
|
-
* Returns { dx, dy } pixel offsets.
|
|
186
|
-
*/
|
|
187
|
-
function computeAnchorOffset(
|
|
188
|
-
anchor: AnnotationAnchor | undefined,
|
|
189
|
-
_px: number,
|
|
190
|
-
py: number,
|
|
191
|
-
chartArea: Rect,
|
|
192
|
-
): { dx: number; dy: number } {
|
|
193
|
-
if (!anchor || anchor === 'auto') {
|
|
194
|
-
// Auto: place above if in the lower half, below if upper half
|
|
195
|
-
const isUpperHalf = py < chartArea.y + chartArea.height / 2;
|
|
196
|
-
return isUpperHalf
|
|
197
|
-
? { dx: ANCHOR_OFFSET, dy: ANCHOR_OFFSET } // below-right
|
|
198
|
-
: { dx: ANCHOR_OFFSET, dy: -ANCHOR_OFFSET }; // above-right
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
switch (anchor) {
|
|
202
|
-
case 'top':
|
|
203
|
-
return { dx: 0, dy: -ANCHOR_OFFSET };
|
|
204
|
-
case 'bottom':
|
|
205
|
-
return { dx: 0, dy: ANCHOR_OFFSET };
|
|
206
|
-
case 'left':
|
|
207
|
-
return { dx: -ANCHOR_OFFSET, dy: 0 };
|
|
208
|
-
case 'right':
|
|
209
|
-
return { dx: ANCHOR_OFFSET, dy: 0 };
|
|
210
|
-
}
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
/** Apply user offset on top of computed anchor offset. */
|
|
214
|
-
function applyOffset(
|
|
215
|
-
base: { dx: number; dy: number },
|
|
216
|
-
offset: AnnotationOffset | undefined,
|
|
217
|
-
): { dx: number; dy: number } {
|
|
218
|
-
if (!offset) return base;
|
|
219
|
-
return {
|
|
220
|
-
dx: base.dx + (offset.dx ?? 0),
|
|
221
|
-
dy: base.dy + (offset.dy ?? 0),
|
|
222
|
-
};
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
// ---------------------------------------------------------------------------
|
|
226
|
-
// Connector origin: pick the edge midpoint closest to the data point
|
|
227
|
-
// ---------------------------------------------------------------------------
|
|
228
|
-
|
|
229
|
-
/**
|
|
230
|
-
* Compute the connector origin point on the text bounding box.
|
|
231
|
-
* For straight connectors, finds the edge midpoint (top, bottom, left, right)
|
|
232
|
-
* closest to the data point. For curve connectors, always uses the right edge.
|
|
233
|
-
*/
|
|
234
|
-
function computeConnectorOrigin(
|
|
235
|
-
labelX: number,
|
|
236
|
-
labelY: number,
|
|
237
|
-
text: string,
|
|
238
|
-
fontSize: number,
|
|
239
|
-
fontWeight: number,
|
|
240
|
-
targetX: number,
|
|
241
|
-
targetY: number,
|
|
242
|
-
connectorStyle: 'straight' | 'curve',
|
|
243
|
-
): { x: number; y: number } {
|
|
244
|
-
const box = computeTextBounds(labelX, labelY, text, fontSize, fontWeight);
|
|
245
|
-
const boxCenterX = box.x + box.width / 2;
|
|
246
|
-
const boxCenterY = box.y + box.height / 2;
|
|
247
|
-
|
|
248
|
-
// Curve connectors always start from the right edge
|
|
249
|
-
if (connectorStyle === 'curve') {
|
|
250
|
-
return {
|
|
251
|
-
x: box.x + box.width,
|
|
252
|
-
y: boxCenterY,
|
|
253
|
-
};
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
// Normalize the vector from box center to target by the box half-dimensions.
|
|
257
|
-
// This accounts for the box aspect ratio: a wide text box should prefer
|
|
258
|
-
// top/bottom exits even when the target is also offset horizontally.
|
|
259
|
-
const halfW = box.width / 2 || 1;
|
|
260
|
-
const halfH = box.height / 2 || 1;
|
|
261
|
-
const ndx = (targetX - boxCenterX) / halfW;
|
|
262
|
-
const ndy = (targetY - boxCenterY) / halfH;
|
|
263
|
-
|
|
264
|
-
if (Math.abs(ndy) >= Math.abs(ndx)) {
|
|
265
|
-
// Target is more above/below than left/right → use top or bottom edge
|
|
266
|
-
return ndy < 0
|
|
267
|
-
? { x: boxCenterX, y: box.y } // top
|
|
268
|
-
: { x: boxCenterX, y: box.y + box.height }; // bottom
|
|
269
|
-
}
|
|
270
|
-
// Target is more left/right → use left or right edge
|
|
271
|
-
return ndx < 0
|
|
272
|
-
? { x: box.x, y: boxCenterY } // left
|
|
273
|
-
: { x: box.x + box.width, y: boxCenterY }; // right
|
|
274
|
-
}
|
|
275
|
-
|
|
276
|
-
// ---------------------------------------------------------------------------
|
|
277
|
-
// Text annotation
|
|
278
|
-
// ---------------------------------------------------------------------------
|
|
279
|
-
|
|
280
|
-
function resolveTextAnnotation(
|
|
281
|
-
annotation: TextAnnotation,
|
|
282
|
-
scales: ResolvedScales,
|
|
283
|
-
chartArea: Rect,
|
|
284
|
-
isDark: boolean,
|
|
285
|
-
): ResolvedAnnotation | null {
|
|
286
|
-
const px = resolvePosition(annotation.x, scales.x);
|
|
287
|
-
const py = resolvePosition(annotation.y, scales.y);
|
|
288
|
-
|
|
289
|
-
if (px === null || py === null) return null;
|
|
290
|
-
|
|
291
|
-
const defaultTextFill = isDark ? DARK_TEXT_FILL : LIGHT_TEXT_FILL;
|
|
292
|
-
const labelStyle = makeAnnotationLabelStyle(
|
|
293
|
-
annotation.fontSize,
|
|
294
|
-
annotation.fontWeight,
|
|
295
|
-
annotation.fill ?? defaultTextFill,
|
|
296
|
-
isDark,
|
|
297
|
-
);
|
|
298
|
-
|
|
299
|
-
// Compute position from anchor direction + user offset
|
|
300
|
-
const anchorDelta = computeAnchorOffset(annotation.anchor, px, py, chartArea);
|
|
301
|
-
const finalDelta = applyOffset(anchorDelta, annotation.offset);
|
|
302
|
-
|
|
303
|
-
const labelX = px + finalDelta.dx;
|
|
304
|
-
const labelY = py + finalDelta.dy;
|
|
305
|
-
|
|
306
|
-
// Connector: draw unless explicitly disabled
|
|
307
|
-
const showConnector = annotation.connector !== false;
|
|
308
|
-
const connectorStyle = annotation.connector === 'curve' ? 'curve' : 'straight';
|
|
309
|
-
|
|
310
|
-
// Compute connector origin: pick the edge midpoint closest to the data point
|
|
311
|
-
const fontSize = annotation.fontSize ?? DEFAULT_ANNOTATION_FONT_SIZE;
|
|
312
|
-
const fontWeight = annotation.fontWeight ?? DEFAULT_ANNOTATION_FONT_WEIGHT;
|
|
313
|
-
const { x: connectorFromX, y: connectorFromY } = computeConnectorOrigin(
|
|
314
|
-
labelX,
|
|
315
|
-
labelY,
|
|
316
|
-
annotation.text,
|
|
317
|
-
fontSize,
|
|
318
|
-
fontWeight,
|
|
319
|
-
px,
|
|
320
|
-
py,
|
|
321
|
-
connectorStyle,
|
|
322
|
-
);
|
|
323
|
-
|
|
324
|
-
// Apply user-provided connector endpoint offsets
|
|
325
|
-
const baseFrom = { x: connectorFromX, y: connectorFromY };
|
|
326
|
-
const baseTo = { x: px, y: py };
|
|
327
|
-
const adjustedFrom = {
|
|
328
|
-
x: baseFrom.x + (annotation.connectorOffset?.from?.dx ?? 0),
|
|
329
|
-
y: baseFrom.y + (annotation.connectorOffset?.from?.dy ?? 0),
|
|
330
|
-
};
|
|
331
|
-
const adjustedToRaw = {
|
|
332
|
-
x: baseTo.x + (annotation.connectorOffset?.to?.dx ?? 0),
|
|
333
|
-
y: baseTo.y + (annotation.connectorOffset?.to?.dy ?? 0),
|
|
334
|
-
};
|
|
335
|
-
|
|
336
|
-
// Pull the "to" endpoint back along the connector direction so the
|
|
337
|
-
// line doesn't touch the data point directly (leaves a small gap).
|
|
338
|
-
const GAP = 4;
|
|
339
|
-
const cdx = adjustedToRaw.x - adjustedFrom.x;
|
|
340
|
-
const cdy = adjustedToRaw.y - adjustedFrom.y;
|
|
341
|
-
const dist = Math.sqrt(cdx * cdx + cdy * cdy);
|
|
342
|
-
const adjustedTo =
|
|
343
|
-
dist > GAP * 2
|
|
344
|
-
? { x: adjustedToRaw.x - (cdx / dist) * GAP, y: adjustedToRaw.y - (cdy / dist) * GAP }
|
|
345
|
-
: adjustedToRaw;
|
|
346
|
-
|
|
347
|
-
const label: ResolvedLabel = {
|
|
348
|
-
text: annotation.text,
|
|
349
|
-
x: labelX,
|
|
350
|
-
y: labelY,
|
|
351
|
-
style: labelStyle,
|
|
352
|
-
visible: true,
|
|
353
|
-
connector: showConnector
|
|
354
|
-
? {
|
|
355
|
-
from: adjustedFrom,
|
|
356
|
-
to: adjustedTo,
|
|
357
|
-
stroke: annotation.stroke ?? '#999999',
|
|
358
|
-
style: connectorStyle,
|
|
359
|
-
}
|
|
360
|
-
: undefined,
|
|
361
|
-
background: annotation.background,
|
|
362
|
-
};
|
|
363
|
-
|
|
364
|
-
return {
|
|
365
|
-
type: 'text',
|
|
366
|
-
id: annotation.id,
|
|
367
|
-
label,
|
|
368
|
-
stroke: annotation.stroke,
|
|
369
|
-
fill: annotation.fill,
|
|
370
|
-
opacity: annotation.opacity,
|
|
371
|
-
zIndex: annotation.zIndex,
|
|
372
|
-
};
|
|
373
|
-
}
|
|
374
|
-
|
|
375
|
-
// ---------------------------------------------------------------------------
|
|
376
|
-
// Range annotation
|
|
377
|
-
// ---------------------------------------------------------------------------
|
|
378
|
-
|
|
379
|
-
function resolveRangeAnnotation(
|
|
380
|
-
annotation: RangeAnnotation,
|
|
381
|
-
scales: ResolvedScales,
|
|
382
|
-
chartArea: Rect,
|
|
383
|
-
isDark: boolean,
|
|
384
|
-
): ResolvedAnnotation | null {
|
|
385
|
-
let x = chartArea.x;
|
|
386
|
-
let y = chartArea.y;
|
|
387
|
-
let width = chartArea.width;
|
|
388
|
-
let height = chartArea.height;
|
|
389
|
-
|
|
390
|
-
// X-range (vertical band)
|
|
391
|
-
if (annotation.x1 !== undefined && annotation.x2 !== undefined) {
|
|
392
|
-
const x1px = resolvePosition(annotation.x1, scales.x);
|
|
393
|
-
const x2px = resolvePosition(annotation.x2, scales.x);
|
|
394
|
-
if (x1px === null || x2px === null) return null;
|
|
395
|
-
|
|
396
|
-
x = Math.min(x1px, x2px);
|
|
397
|
-
width = Math.abs(x2px - x1px);
|
|
398
|
-
}
|
|
399
|
-
|
|
400
|
-
// Y-range (horizontal band)
|
|
401
|
-
if (annotation.y1 !== undefined && annotation.y2 !== undefined) {
|
|
402
|
-
const y1px = resolvePosition(annotation.y1, scales.y);
|
|
403
|
-
const y2px = resolvePosition(annotation.y2, scales.y);
|
|
404
|
-
if (y1px === null || y2px === null) return null;
|
|
405
|
-
|
|
406
|
-
y = Math.min(y1px, y2px);
|
|
407
|
-
height = Math.abs(y2px - y1px);
|
|
408
|
-
}
|
|
409
|
-
|
|
410
|
-
const rect: Rect = { x, y, width, height };
|
|
411
|
-
|
|
412
|
-
// Label positioned within the range, with optional offset.
|
|
413
|
-
// labelAnchor controls horizontal placement:
|
|
414
|
-
// "top" (default): horizontally centered, text-anchor middle
|
|
415
|
-
// "left": left edge, text-anchor start
|
|
416
|
-
// "right": right edge, text-anchor end
|
|
417
|
-
// "bottom"/"auto": horizontally centered, text-anchor middle
|
|
418
|
-
let label: ResolvedLabel | undefined;
|
|
419
|
-
if (annotation.label) {
|
|
420
|
-
const anchor = annotation.labelAnchor ?? 'top';
|
|
421
|
-
const centered = anchor === 'top' || anchor === 'bottom' || anchor === 'auto';
|
|
422
|
-
const baseDx = centered ? 0 : anchor === 'right' ? -4 : 4;
|
|
423
|
-
const baseDy = 14;
|
|
424
|
-
const labelDelta = applyOffset({ dx: baseDx, dy: baseDy }, annotation.labelOffset);
|
|
425
|
-
|
|
426
|
-
const style = makeAnnotationLabelStyle(11, 500, undefined, isDark);
|
|
427
|
-
if (centered) {
|
|
428
|
-
style.textAnchor = 'middle';
|
|
429
|
-
} else if (anchor === 'right') {
|
|
430
|
-
style.textAnchor = 'end';
|
|
431
|
-
}
|
|
432
|
-
|
|
433
|
-
// Position label horizontally centered within the range band by default.
|
|
434
|
-
// For left/right anchors, position at the respective edge.
|
|
435
|
-
const baseX = centered ? x + width / 2 : anchor === 'right' ? x + width : x;
|
|
436
|
-
|
|
437
|
-
label = {
|
|
438
|
-
text: annotation.label,
|
|
439
|
-
x: baseX + labelDelta.dx,
|
|
440
|
-
y: y + labelDelta.dy,
|
|
441
|
-
style,
|
|
442
|
-
visible: true,
|
|
443
|
-
};
|
|
444
|
-
}
|
|
445
|
-
|
|
446
|
-
// In dark mode, boost range opacity slightly for better visibility
|
|
447
|
-
const defaultOpacity = isDark ? 0.2 : DEFAULT_RANGE_OPACITY;
|
|
448
|
-
|
|
449
|
-
return {
|
|
450
|
-
type: 'range',
|
|
451
|
-
id: annotation.id,
|
|
452
|
-
rect,
|
|
453
|
-
label,
|
|
454
|
-
fill: annotation.fill ?? DEFAULT_RANGE_FILL,
|
|
455
|
-
opacity: annotation.opacity ?? defaultOpacity,
|
|
456
|
-
stroke: annotation.stroke,
|
|
457
|
-
zIndex: annotation.zIndex,
|
|
458
|
-
};
|
|
459
|
-
}
|
|
460
|
-
|
|
461
|
-
// ---------------------------------------------------------------------------
|
|
462
|
-
// Reference line annotation
|
|
463
|
-
// ---------------------------------------------------------------------------
|
|
464
|
-
|
|
465
|
-
function resolveRefLineAnnotation(
|
|
466
|
-
annotation: RefLineAnnotation,
|
|
467
|
-
scales: ResolvedScales,
|
|
468
|
-
chartArea: Rect,
|
|
469
|
-
isDark: boolean,
|
|
470
|
-
): ResolvedAnnotation | null {
|
|
471
|
-
let start: Point;
|
|
472
|
-
let end: Point;
|
|
473
|
-
|
|
474
|
-
if (annotation.y !== undefined) {
|
|
475
|
-
// Horizontal reference line
|
|
476
|
-
const yPx = resolvePosition(annotation.y, scales.y);
|
|
477
|
-
if (yPx === null) return null;
|
|
478
|
-
|
|
479
|
-
start = { x: chartArea.x, y: yPx };
|
|
480
|
-
end = { x: chartArea.x + chartArea.width, y: yPx };
|
|
481
|
-
} else if (annotation.x !== undefined) {
|
|
482
|
-
// Vertical reference line
|
|
483
|
-
const xPx = resolvePosition(annotation.x, scales.x);
|
|
484
|
-
if (xPx === null) return null;
|
|
485
|
-
|
|
486
|
-
start = { x: xPx, y: chartArea.y };
|
|
487
|
-
end = { x: xPx, y: chartArea.y + chartArea.height };
|
|
488
|
-
} else {
|
|
489
|
-
return null;
|
|
490
|
-
}
|
|
491
|
-
|
|
492
|
-
// Determine dash pattern from style
|
|
493
|
-
let strokeDasharray: string | undefined;
|
|
494
|
-
if (annotation.style === 'dashed' || annotation.style === undefined) {
|
|
495
|
-
strokeDasharray = DEFAULT_REFLINE_DASH;
|
|
496
|
-
} else if (annotation.style === 'dotted') {
|
|
497
|
-
strokeDasharray = '2 2';
|
|
498
|
-
}
|
|
499
|
-
// 'solid' gets no dasharray
|
|
500
|
-
|
|
501
|
-
// Label placement on reflines. labelAnchor controls position:
|
|
502
|
-
//
|
|
503
|
-
// Horizontal reflines (y set):
|
|
504
|
-
// "left": left end of line, above "right"/"top" (default): right end, above
|
|
505
|
-
// "bottom": right end of line, below
|
|
506
|
-
//
|
|
507
|
-
// Vertical reflines (x set):
|
|
508
|
-
// "right": label to the left of the line, near top
|
|
509
|
-
// "bottom": label to the right of the line, near bottom
|
|
510
|
-
// "left"/"top" (default): label to the right of the line, near top
|
|
511
|
-
let label: ResolvedLabel | undefined;
|
|
512
|
-
if (annotation.label) {
|
|
513
|
-
const isHorizontal = annotation.y !== undefined;
|
|
514
|
-
const anchor = annotation.labelAnchor ?? (isHorizontal ? 'top' : 'left');
|
|
515
|
-
|
|
516
|
-
let baseDx: number;
|
|
517
|
-
let baseDy: number;
|
|
518
|
-
let labelX: number;
|
|
519
|
-
let labelY: number;
|
|
520
|
-
let textAnchor: 'start' | 'middle' | 'end';
|
|
521
|
-
|
|
522
|
-
if (isHorizontal) {
|
|
523
|
-
if (anchor === 'left') {
|
|
524
|
-
baseDx = 4;
|
|
525
|
-
baseDy = -4;
|
|
526
|
-
labelX = start.x;
|
|
527
|
-
labelY = start.y;
|
|
528
|
-
textAnchor = 'start';
|
|
529
|
-
} else if (anchor === 'bottom') {
|
|
530
|
-
baseDx = -4;
|
|
531
|
-
baseDy = 14;
|
|
532
|
-
labelX = end.x;
|
|
533
|
-
labelY = end.y;
|
|
534
|
-
textAnchor = 'end';
|
|
535
|
-
} else {
|
|
536
|
-
// 'right', 'top' (default), 'auto'
|
|
537
|
-
baseDx = -4;
|
|
538
|
-
baseDy = -4;
|
|
539
|
-
labelX = end.x;
|
|
540
|
-
labelY = end.y;
|
|
541
|
-
textAnchor = 'end';
|
|
542
|
-
}
|
|
543
|
-
} else {
|
|
544
|
-
// Vertical refline
|
|
545
|
-
if (anchor === 'right') {
|
|
546
|
-
baseDx = -4;
|
|
547
|
-
baseDy = 14;
|
|
548
|
-
labelX = start.x;
|
|
549
|
-
labelY = start.y;
|
|
550
|
-
textAnchor = 'end';
|
|
551
|
-
} else if (anchor === 'bottom') {
|
|
552
|
-
baseDx = 4;
|
|
553
|
-
baseDy = -4;
|
|
554
|
-
labelX = start.x;
|
|
555
|
-
labelY = end.y;
|
|
556
|
-
textAnchor = 'start';
|
|
557
|
-
} else {
|
|
558
|
-
// 'left', 'top' (default), 'auto' — label to the right of the line, near top
|
|
559
|
-
baseDx = 4;
|
|
560
|
-
baseDy = 14;
|
|
561
|
-
labelX = start.x;
|
|
562
|
-
labelY = start.y;
|
|
563
|
-
textAnchor = 'start';
|
|
564
|
-
}
|
|
565
|
-
}
|
|
566
|
-
|
|
567
|
-
const labelDelta = applyOffset({ dx: baseDx, dy: baseDy }, annotation.labelOffset);
|
|
568
|
-
|
|
569
|
-
const defaultStroke = isDark ? DARK_REFLINE_STROKE : LIGHT_REFLINE_STROKE;
|
|
570
|
-
const style = makeAnnotationLabelStyle(11, 400, annotation.stroke ?? defaultStroke, isDark);
|
|
571
|
-
style.textAnchor = textAnchor;
|
|
572
|
-
|
|
573
|
-
label = {
|
|
574
|
-
text: annotation.label,
|
|
575
|
-
x: labelX + labelDelta.dx,
|
|
576
|
-
y: labelY + labelDelta.dy,
|
|
577
|
-
style,
|
|
578
|
-
visible: true,
|
|
579
|
-
};
|
|
580
|
-
}
|
|
581
|
-
|
|
582
|
-
const defaultStroke = isDark ? DARK_REFLINE_STROKE : LIGHT_REFLINE_STROKE;
|
|
583
|
-
|
|
584
|
-
return {
|
|
585
|
-
type: 'refline',
|
|
586
|
-
id: annotation.id,
|
|
587
|
-
line: { start, end },
|
|
588
|
-
label,
|
|
589
|
-
stroke: annotation.stroke ?? defaultStroke,
|
|
590
|
-
strokeDasharray,
|
|
591
|
-
strokeWidth: annotation.strokeWidth ?? 1,
|
|
592
|
-
zIndex: annotation.zIndex,
|
|
593
|
-
};
|
|
594
|
-
}
|
|
595
|
-
|
|
596
|
-
// ---------------------------------------------------------------------------
|
|
597
|
-
// Public API
|
|
598
|
-
// ---------------------------------------------------------------------------
|
|
599
|
-
|
|
600
|
-
// ---------------------------------------------------------------------------
|
|
601
|
-
// Collision avoidance
|
|
602
|
-
// ---------------------------------------------------------------------------
|
|
603
|
-
|
|
604
|
-
/** Estimate the bounding box of an annotation label. */
|
|
605
|
-
function estimateLabelBounds(label: ResolvedLabel): Rect {
|
|
606
|
-
const fontSize = label.style.fontSize ?? DEFAULT_ANNOTATION_FONT_SIZE;
|
|
607
|
-
const fontWeight = label.style.fontWeight ?? DEFAULT_ANNOTATION_FONT_WEIGHT;
|
|
608
|
-
return computeTextBounds(label.x, label.y, label.text, fontSize, fontWeight);
|
|
609
|
-
}
|
|
610
|
-
|
|
611
|
-
/** Padding between annotation and obstacle when nudging. */
|
|
612
|
-
const NUDGE_PADDING = 6;
|
|
613
|
-
|
|
614
|
-
/**
|
|
615
|
-
* Generate candidate displacement vectors to move `selfBounds` clear of each
|
|
616
|
-
* obstacle in 4 directions (below, above, left, right), sorted by smallest
|
|
617
|
-
* movement first.
|
|
618
|
-
*/
|
|
619
|
-
function generateNudgeCandidates(
|
|
620
|
-
selfBounds: Rect,
|
|
621
|
-
obstacles: Rect[],
|
|
622
|
-
padding: number,
|
|
623
|
-
): { dx: number; dy: number; distance: number }[] {
|
|
624
|
-
const candidates: { dx: number; dy: number; distance: number }[] = [];
|
|
625
|
-
|
|
626
|
-
for (const obs of obstacles) {
|
|
627
|
-
// Below: shift self so its top edge clears the obstacle bottom
|
|
628
|
-
const belowDy = obs.y + obs.height + padding - selfBounds.y;
|
|
629
|
-
candidates.push({ dx: 0, dy: belowDy, distance: Math.abs(belowDy) });
|
|
630
|
-
|
|
631
|
-
// Above: shift self so its bottom edge clears the obstacle top
|
|
632
|
-
const aboveDy = obs.y - padding - (selfBounds.y + selfBounds.height);
|
|
633
|
-
candidates.push({ dx: 0, dy: aboveDy, distance: Math.abs(aboveDy) });
|
|
634
|
-
|
|
635
|
-
// Left: shift self so its right edge clears the obstacle left
|
|
636
|
-
const leftDx = obs.x - padding - (selfBounds.x + selfBounds.width);
|
|
637
|
-
candidates.push({ dx: leftDx, dy: 0, distance: Math.abs(leftDx) });
|
|
638
|
-
|
|
639
|
-
// Right: shift self so its left edge clears the obstacle right
|
|
640
|
-
const rightDx = obs.x + obs.width + padding - selfBounds.x;
|
|
641
|
-
candidates.push({ dx: rightDx, dy: 0, distance: Math.abs(rightDx) });
|
|
642
|
-
}
|
|
643
|
-
|
|
644
|
-
candidates.sort((a, b) => a.distance - b.distance);
|
|
645
|
-
return candidates;
|
|
646
|
-
}
|
|
647
|
-
|
|
648
|
-
/**
|
|
649
|
-
* Try to reposition a text annotation to avoid overlapping with obstacle rects
|
|
650
|
-
* (legend bounds, etc.). First tries standard anchor alternatives, then
|
|
651
|
-
* calculates specific offsets needed to clear obstacles. Returns true if moved.
|
|
652
|
-
*/
|
|
653
|
-
function nudgeAnnotationFromObstacles(
|
|
654
|
-
annotation: ResolvedAnnotation,
|
|
655
|
-
originalAnnotation: TextAnnotation,
|
|
656
|
-
scales: ResolvedScales,
|
|
657
|
-
chartArea: Rect,
|
|
658
|
-
obstacles: Rect[],
|
|
659
|
-
): boolean {
|
|
660
|
-
if (annotation.type !== 'text' || !annotation.label) return false;
|
|
661
|
-
|
|
662
|
-
const labelBounds = estimateLabelBounds(annotation.label);
|
|
663
|
-
const collidingObs = obstacles.filter(
|
|
664
|
-
(obs) => obs.width > 0 && obs.height > 0 && detectCollision(labelBounds, obs),
|
|
665
|
-
);
|
|
666
|
-
|
|
667
|
-
if (collidingObs.length === 0) return false;
|
|
668
|
-
|
|
669
|
-
// Resolve the data point pixel position for offset calculations
|
|
670
|
-
const px = resolvePosition(originalAnnotation.x, scales.x);
|
|
671
|
-
const py = resolvePosition(originalAnnotation.y, scales.y);
|
|
672
|
-
if (px === null || py === null) return false;
|
|
673
|
-
|
|
674
|
-
const candidates = generateNudgeCandidates(labelBounds, collidingObs, NUDGE_PADDING);
|
|
675
|
-
const fontSize = labelBounds.height / Math.max(1, annotation.label.text.split('\n').length);
|
|
676
|
-
|
|
677
|
-
for (const { dx, dy } of candidates) {
|
|
678
|
-
const newLabelX = annotation.label.x + dx;
|
|
679
|
-
const newLabelY = annotation.label.y + dy;
|
|
680
|
-
|
|
681
|
-
// Recompute connector origin for the new label position so the connector
|
|
682
|
-
// exits from the edge closest to the data point after nudging.
|
|
683
|
-
let newConnector = annotation.label.connector;
|
|
684
|
-
if (newConnector) {
|
|
685
|
-
const annFontSize = annotation.label.style.fontSize ?? DEFAULT_ANNOTATION_FONT_SIZE;
|
|
686
|
-
const annFontWeight = annotation.label.style.fontWeight ?? DEFAULT_ANNOTATION_FONT_WEIGHT;
|
|
687
|
-
const connStyle = newConnector.style === 'curve' ? ('curve' as const) : ('straight' as const);
|
|
688
|
-
const newFrom = computeConnectorOrigin(
|
|
689
|
-
newLabelX,
|
|
690
|
-
newLabelY,
|
|
691
|
-
annotation.label.text,
|
|
692
|
-
annFontSize,
|
|
693
|
-
annFontWeight,
|
|
694
|
-
px,
|
|
695
|
-
py,
|
|
696
|
-
connStyle,
|
|
697
|
-
);
|
|
698
|
-
newConnector = { ...newConnector, from: newFrom };
|
|
699
|
-
}
|
|
700
|
-
|
|
701
|
-
const candidateLabel: ResolvedLabel = {
|
|
702
|
-
...annotation.label,
|
|
703
|
-
x: newLabelX,
|
|
704
|
-
y: newLabelY,
|
|
705
|
-
connector: newConnector,
|
|
706
|
-
};
|
|
707
|
-
|
|
708
|
-
const candidateBounds = estimateLabelBounds(candidateLabel);
|
|
709
|
-
|
|
710
|
-
// Check no collisions with any obstacle
|
|
711
|
-
const stillCollides = obstacles.some(
|
|
712
|
-
(obs) => obs.width > 0 && obs.height > 0 && detectCollision(candidateBounds, obs),
|
|
713
|
-
);
|
|
714
|
-
if (stillCollides) continue;
|
|
715
|
-
|
|
716
|
-
// Annotations render outside the clip path, so they can extend into margins.
|
|
717
|
-
// Only check that the label center is reasonably within the chart and that
|
|
718
|
-
// the text doesn't go completely off-screen.
|
|
719
|
-
const labelCenterX = candidateBounds.x + candidateBounds.width / 2;
|
|
720
|
-
const labelCenterY = candidateBounds.y + candidateBounds.height / 2;
|
|
721
|
-
// Allow nudged labels to extend into the chrome region below the chart
|
|
722
|
-
// (source/footer area) since annotations near the bottom edge often
|
|
723
|
-
// need to shift into that space to avoid marks or the brand watermark.
|
|
724
|
-
const inBounds =
|
|
725
|
-
labelCenterX >= chartArea.x &&
|
|
726
|
-
labelCenterX <= chartArea.x + chartArea.width + 10 &&
|
|
727
|
-
labelCenterY >= chartArea.y - fontSize &&
|
|
728
|
-
labelCenterY <= chartArea.y + chartArea.height + fontSize * 3;
|
|
729
|
-
|
|
730
|
-
if (inBounds) {
|
|
731
|
-
annotation.label = candidateLabel;
|
|
732
|
-
return true;
|
|
733
|
-
}
|
|
734
|
-
}
|
|
735
|
-
|
|
736
|
-
return false;
|
|
737
|
-
}
|
|
738
|
-
|
|
739
|
-
// ---------------------------------------------------------------------------
|
|
740
|
-
// Annotation-to-annotation collision resolution
|
|
741
|
-
// ---------------------------------------------------------------------------
|
|
742
|
-
|
|
743
|
-
/**
|
|
744
|
-
* Resolve collisions between text annotation labels using a greedy algorithm.
|
|
745
|
-
*
|
|
746
|
-
* Iterates through text annotations in order, building a list of "placed"
|
|
747
|
-
* bounding rects. When a later annotation overlaps an already-placed one,
|
|
748
|
-
* it tries offset positions (below, above, left, right) to find a
|
|
749
|
-
* non-colliding spot. Recomputes the connector origin after nudging.
|
|
750
|
-
*/
|
|
751
|
-
function resolveAnnotationCollisions(
|
|
752
|
-
annotations: ResolvedAnnotation[],
|
|
753
|
-
originalSpecs: NormalizedChartSpec['annotations'],
|
|
754
|
-
scales: ResolvedScales,
|
|
755
|
-
chartArea: Rect,
|
|
756
|
-
): void {
|
|
757
|
-
const placedBounds: Rect[] = [];
|
|
758
|
-
|
|
759
|
-
for (let i = 0; i < annotations.length; i++) {
|
|
760
|
-
const annotation = annotations[i];
|
|
761
|
-
if (annotation.type !== 'text' || !annotation.label) {
|
|
762
|
-
continue;
|
|
763
|
-
}
|
|
764
|
-
|
|
765
|
-
const bounds = estimateLabelBounds(annotation.label);
|
|
766
|
-
|
|
767
|
-
// Check against all previously placed annotation labels
|
|
768
|
-
const collidingBounds = placedBounds.filter(
|
|
769
|
-
(pb) => pb.width > 0 && pb.height > 0 && detectCollision(bounds, pb),
|
|
770
|
-
);
|
|
771
|
-
|
|
772
|
-
if (collidingBounds.length > 0) {
|
|
773
|
-
// Find the original spec to get data point coordinates for connector recomputation
|
|
774
|
-
const originalSpec = originalSpecs[i];
|
|
775
|
-
|
|
776
|
-
if (originalSpec?.type === 'text') {
|
|
777
|
-
const px = resolvePosition(originalSpec.x, scales.x);
|
|
778
|
-
const py = resolvePosition(originalSpec.y, scales.y);
|
|
779
|
-
|
|
780
|
-
if (px !== null && py !== null) {
|
|
781
|
-
const candidates = generateNudgeCandidates(bounds, collidingBounds, NUDGE_PADDING);
|
|
782
|
-
const fontSize = bounds.height / Math.max(1, annotation.label.text.split('\n').length);
|
|
783
|
-
|
|
784
|
-
for (const { dx, dy } of candidates) {
|
|
785
|
-
const newLabelX = annotation.label.x + dx;
|
|
786
|
-
const newLabelY = annotation.label.y + dy;
|
|
787
|
-
|
|
788
|
-
const candidateLabel: ResolvedLabel = {
|
|
789
|
-
...annotation.label,
|
|
790
|
-
x: newLabelX,
|
|
791
|
-
y: newLabelY,
|
|
792
|
-
};
|
|
793
|
-
const candidateBounds = estimateLabelBounds(candidateLabel);
|
|
794
|
-
|
|
795
|
-
// Check no collisions with any placed label
|
|
796
|
-
const stillCollides = placedBounds.some(
|
|
797
|
-
(pb) => pb.width > 0 && pb.height > 0 && detectCollision(candidateBounds, pb),
|
|
798
|
-
);
|
|
799
|
-
if (stillCollides) continue;
|
|
800
|
-
|
|
801
|
-
// Check the label center stays reasonably in bounds
|
|
802
|
-
const labelCenterX = candidateBounds.x + candidateBounds.width / 2;
|
|
803
|
-
const labelCenterY = candidateBounds.y + candidateBounds.height / 2;
|
|
804
|
-
const inBounds =
|
|
805
|
-
labelCenterX >= chartArea.x &&
|
|
806
|
-
labelCenterX <= chartArea.x + chartArea.width + 10 &&
|
|
807
|
-
labelCenterY >= chartArea.y - fontSize &&
|
|
808
|
-
labelCenterY <= chartArea.y + chartArea.height + fontSize;
|
|
809
|
-
|
|
810
|
-
if (inBounds) {
|
|
811
|
-
// Recompute connector origin for the new position
|
|
812
|
-
let newConnector = annotation.label.connector;
|
|
813
|
-
if (newConnector) {
|
|
814
|
-
const annFontSize = annotation.label.style.fontSize ?? DEFAULT_ANNOTATION_FONT_SIZE;
|
|
815
|
-
const annFontWeight =
|
|
816
|
-
annotation.label.style.fontWeight ?? DEFAULT_ANNOTATION_FONT_WEIGHT;
|
|
817
|
-
const connStyle =
|
|
818
|
-
newConnector.style === 'curve' ? ('curve' as const) : ('straight' as const);
|
|
819
|
-
const newFrom = computeConnectorOrigin(
|
|
820
|
-
newLabelX,
|
|
821
|
-
newLabelY,
|
|
822
|
-
annotation.label.text,
|
|
823
|
-
annFontSize,
|
|
824
|
-
annFontWeight,
|
|
825
|
-
px,
|
|
826
|
-
py,
|
|
827
|
-
connStyle,
|
|
828
|
-
);
|
|
829
|
-
newConnector = { ...newConnector, from: newFrom };
|
|
830
|
-
}
|
|
831
|
-
|
|
832
|
-
annotation.label = {
|
|
833
|
-
...annotation.label,
|
|
834
|
-
x: newLabelX,
|
|
835
|
-
y: newLabelY,
|
|
836
|
-
connector: newConnector,
|
|
837
|
-
};
|
|
838
|
-
break;
|
|
839
|
-
}
|
|
840
|
-
}
|
|
841
|
-
}
|
|
842
|
-
}
|
|
843
|
-
}
|
|
844
|
-
|
|
845
|
-
// Add this annotation's final bounds to the placed list
|
|
846
|
-
placedBounds.push(estimateLabelBounds(annotation.label));
|
|
847
|
-
}
|
|
848
|
-
}
|
|
849
|
-
|
|
850
|
-
// ---------------------------------------------------------------------------
|
|
851
|
-
// Boundary clamping
|
|
852
|
-
// ---------------------------------------------------------------------------
|
|
853
|
-
|
|
854
|
-
/** Small inset margin so labels don't touch the SVG edge. */
|
|
855
|
-
const CLAMP_MARGIN = 4;
|
|
856
|
-
|
|
857
|
-
/**
|
|
858
|
-
* Shift text annotation labels so they stay within the total SVG bounds.
|
|
859
|
-
* If a label overflows the right, left, top, or bottom edge, its position
|
|
860
|
-
* is adjusted inward by the overflow amount. Connector geometry is updated
|
|
861
|
-
* to match.
|
|
862
|
-
*/
|
|
863
|
-
function clampAnnotationsToBounds(
|
|
864
|
-
annotations: ResolvedAnnotation[],
|
|
865
|
-
svgWidth: number,
|
|
866
|
-
svgHeight: number,
|
|
867
|
-
): void {
|
|
868
|
-
for (const annotation of annotations) {
|
|
869
|
-
if (annotation.type !== 'text' || !annotation.label) continue;
|
|
870
|
-
|
|
871
|
-
const bounds = estimateLabelBounds(annotation.label);
|
|
872
|
-
let dx = 0;
|
|
873
|
-
let dy = 0;
|
|
874
|
-
|
|
875
|
-
// Right overflow
|
|
876
|
-
if (bounds.x + bounds.width > svgWidth - CLAMP_MARGIN) {
|
|
877
|
-
dx = svgWidth - CLAMP_MARGIN - (bounds.x + bounds.width);
|
|
878
|
-
}
|
|
879
|
-
// Left overflow
|
|
880
|
-
if (bounds.x + dx < CLAMP_MARGIN) {
|
|
881
|
-
dx = CLAMP_MARGIN - bounds.x;
|
|
882
|
-
}
|
|
883
|
-
// Top overflow
|
|
884
|
-
if (bounds.y < CLAMP_MARGIN) {
|
|
885
|
-
dy = CLAMP_MARGIN - bounds.y;
|
|
886
|
-
}
|
|
887
|
-
// Bottom overflow
|
|
888
|
-
if (bounds.y + bounds.height + dy > svgHeight - CLAMP_MARGIN) {
|
|
889
|
-
dy = svgHeight - CLAMP_MARGIN - (bounds.y + bounds.height);
|
|
890
|
-
}
|
|
891
|
-
|
|
892
|
-
if (dx === 0 && dy === 0) continue;
|
|
893
|
-
|
|
894
|
-
const newX = annotation.label.x + dx;
|
|
895
|
-
const newY = annotation.label.y + dy;
|
|
896
|
-
|
|
897
|
-
// Update connector origin if present
|
|
898
|
-
let newConnector = annotation.label.connector;
|
|
899
|
-
if (newConnector) {
|
|
900
|
-
const fontSize = annotation.label.style.fontSize ?? DEFAULT_ANNOTATION_FONT_SIZE;
|
|
901
|
-
const fontWeight = annotation.label.style.fontWeight ?? DEFAULT_ANNOTATION_FONT_WEIGHT;
|
|
902
|
-
const connStyle = newConnector.style === 'curve' ? ('curve' as const) : ('straight' as const);
|
|
903
|
-
const newFrom = computeConnectorOrigin(
|
|
904
|
-
newX,
|
|
905
|
-
newY,
|
|
906
|
-
annotation.label.text,
|
|
907
|
-
fontSize,
|
|
908
|
-
fontWeight,
|
|
909
|
-
newConnector.to.x,
|
|
910
|
-
newConnector.to.y,
|
|
911
|
-
connStyle,
|
|
912
|
-
);
|
|
913
|
-
newConnector = { ...newConnector, from: newFrom };
|
|
914
|
-
}
|
|
915
|
-
|
|
916
|
-
annotation.label = {
|
|
917
|
-
...annotation.label,
|
|
918
|
-
x: newX,
|
|
919
|
-
y: newY,
|
|
920
|
-
connector: newConnector,
|
|
921
|
-
};
|
|
922
|
-
}
|
|
923
|
-
}
|
|
924
|
-
|
|
925
|
-
// ---------------------------------------------------------------------------
|
|
926
|
-
// Public API
|
|
927
|
-
// ---------------------------------------------------------------------------
|
|
17
|
+
import {
|
|
18
|
+
clampAnnotationsToBounds,
|
|
19
|
+
nudgeAnnotationFromObstacles,
|
|
20
|
+
resolveAnnotationCollisions,
|
|
21
|
+
} from './collisions';
|
|
22
|
+
import { resolveRangeAnnotation } from './resolve-range';
|
|
23
|
+
import { resolveRefLineAnnotation } from './resolve-refline';
|
|
24
|
+
import { resolveTextAnnotation } from './resolve-text';
|
|
928
25
|
|
|
929
26
|
/**
|
|
930
27
|
* Compute resolved annotations from spec annotations using the resolved scales.
|