@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.
Files changed (43) hide show
  1. package/dist/index.js +1022 -648
  2. package/dist/index.js.map +1 -1
  3. package/package.json +2 -2
  4. package/src/__tests__/axes.test.ts +12 -30
  5. package/src/__tests__/compile-chart.test.ts +4 -4
  6. package/src/__tests__/dimensions.test.ts +2 -2
  7. package/src/__tests__/encoding-sugar.test.ts +390 -0
  8. package/src/annotations/collisions.ts +268 -0
  9. package/src/annotations/compute.ts +9 -912
  10. package/src/annotations/constants.ts +32 -0
  11. package/src/annotations/geometry.ts +167 -0
  12. package/src/annotations/position.ts +95 -0
  13. package/src/annotations/resolve-range.ts +98 -0
  14. package/src/annotations/resolve-refline.ts +148 -0
  15. package/src/annotations/resolve-text.ts +134 -0
  16. package/src/charts/__tests__/post-process.test.ts +258 -0
  17. package/src/charts/bar/__tests__/labels.test.ts +31 -0
  18. package/src/charts/bar/compute.ts +27 -6
  19. package/src/charts/bar/index.ts +3 -0
  20. package/src/charts/bar/labels.ts +38 -14
  21. package/src/charts/column/__tests__/compute.test.ts +99 -0
  22. package/src/charts/column/compute.ts +27 -6
  23. package/src/charts/column/index.ts +3 -0
  24. package/src/charts/column/labels.ts +35 -13
  25. package/src/charts/dot/index.ts +10 -1
  26. package/src/charts/dot/labels.ts +37 -6
  27. package/src/charts/line/area.ts +31 -6
  28. package/src/charts/line/compute.ts +7 -2
  29. package/src/charts/line/index.ts +33 -2
  30. package/src/charts/post-process.ts +215 -0
  31. package/src/compile.ts +91 -158
  32. package/src/compiler/normalize.ts +2 -2
  33. package/src/layout/axes.ts +12 -15
  34. package/src/layout/dimensions.ts +3 -3
  35. package/src/layout/scales.ts +116 -36
  36. package/src/legend/compute.ts +2 -4
  37. package/src/tooltips/__tests__/compute.test.ts +188 -0
  38. package/src/tooltips/compute.ts +54 -12
  39. package/src/transforms/__tests__/aggregate.test.ts +159 -0
  40. package/src/transforms/__tests__/fold.test.ts +79 -0
  41. package/src/transforms/aggregate.ts +130 -0
  42. package/src/transforms/fold.ts +49 -0
  43. 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
- // Helpers
34
- // ---------------------------------------------------------------------------
35
-
36
- const DEFAULT_ANNOTATION_FONT_SIZE = 12;
37
- const DEFAULT_ANNOTATION_FONT_WEIGHT = 400;
38
- const DEFAULT_LINE_HEIGHT = 1.3;
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.