@opendata-ai/openchart-engine 1.2.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 (85) hide show
  1. package/dist/index.d.ts +366 -0
  2. package/dist/index.js +4227 -0
  3. package/dist/index.js.map +1 -0
  4. package/package.json +62 -0
  5. package/src/__test-fixtures__/specs.ts +124 -0
  6. package/src/__tests__/axes.test.ts +114 -0
  7. package/src/__tests__/compile-chart.test.ts +337 -0
  8. package/src/__tests__/dimensions.test.ts +151 -0
  9. package/src/__tests__/legend.test.ts +113 -0
  10. package/src/__tests__/scales.test.ts +109 -0
  11. package/src/annotations/__tests__/compute.test.ts +454 -0
  12. package/src/annotations/compute.ts +603 -0
  13. package/src/charts/__tests__/registry.test.ts +110 -0
  14. package/src/charts/bar/__tests__/compute.test.ts +294 -0
  15. package/src/charts/bar/__tests__/labels.test.ts +75 -0
  16. package/src/charts/bar/compute.ts +205 -0
  17. package/src/charts/bar/index.ts +33 -0
  18. package/src/charts/bar/labels.ts +132 -0
  19. package/src/charts/column/__tests__/compute.test.ts +277 -0
  20. package/src/charts/column/compute.ts +282 -0
  21. package/src/charts/column/index.ts +33 -0
  22. package/src/charts/column/labels.ts +108 -0
  23. package/src/charts/dot/__tests__/compute.test.ts +344 -0
  24. package/src/charts/dot/compute.ts +257 -0
  25. package/src/charts/dot/index.ts +46 -0
  26. package/src/charts/dot/labels.ts +97 -0
  27. package/src/charts/line/__tests__/compute.test.ts +437 -0
  28. package/src/charts/line/__tests__/labels.test.ts +93 -0
  29. package/src/charts/line/area.ts +288 -0
  30. package/src/charts/line/compute.ts +177 -0
  31. package/src/charts/line/index.ts +68 -0
  32. package/src/charts/line/labels.ts +144 -0
  33. package/src/charts/pie/__tests__/compute.test.ts +276 -0
  34. package/src/charts/pie/compute.ts +234 -0
  35. package/src/charts/pie/index.ts +49 -0
  36. package/src/charts/pie/labels.ts +142 -0
  37. package/src/charts/registry.ts +64 -0
  38. package/src/charts/scatter/__tests__/compute.test.ts +304 -0
  39. package/src/charts/scatter/__tests__/trendline.test.ts +191 -0
  40. package/src/charts/scatter/compute.ts +124 -0
  41. package/src/charts/scatter/index.ts +41 -0
  42. package/src/charts/scatter/trendline.ts +100 -0
  43. package/src/charts/utils.ts +120 -0
  44. package/src/compile.ts +368 -0
  45. package/src/compiler/__tests__/compile.test.ts +87 -0
  46. package/src/compiler/__tests__/normalize.test.ts +210 -0
  47. package/src/compiler/__tests__/validate.test.ts +440 -0
  48. package/src/compiler/index.ts +47 -0
  49. package/src/compiler/normalize.ts +269 -0
  50. package/src/compiler/types.ts +148 -0
  51. package/src/compiler/validate.ts +581 -0
  52. package/src/graphs/__tests__/community.test.ts +228 -0
  53. package/src/graphs/__tests__/compile-graph.test.ts +315 -0
  54. package/src/graphs/__tests__/encoding.test.ts +314 -0
  55. package/src/graphs/community.ts +92 -0
  56. package/src/graphs/compile-graph.ts +291 -0
  57. package/src/graphs/encoding.ts +302 -0
  58. package/src/graphs/types.ts +98 -0
  59. package/src/index.ts +74 -0
  60. package/src/layout/axes.ts +194 -0
  61. package/src/layout/dimensions.ts +199 -0
  62. package/src/layout/gridlines.ts +84 -0
  63. package/src/layout/scales.ts +426 -0
  64. package/src/legend/compute.ts +186 -0
  65. package/src/tables/__tests__/bar-column.test.ts +147 -0
  66. package/src/tables/__tests__/category-colors.test.ts +153 -0
  67. package/src/tables/__tests__/compile-table.test.ts +208 -0
  68. package/src/tables/__tests__/format-cells.test.ts +126 -0
  69. package/src/tables/__tests__/heatmap.test.ts +124 -0
  70. package/src/tables/__tests__/pagination.test.ts +78 -0
  71. package/src/tables/__tests__/search.test.ts +94 -0
  72. package/src/tables/__tests__/sort.test.ts +107 -0
  73. package/src/tables/__tests__/sparkline.test.ts +122 -0
  74. package/src/tables/bar-column.ts +94 -0
  75. package/src/tables/category-colors.ts +67 -0
  76. package/src/tables/compile-table.ts +420 -0
  77. package/src/tables/format-cells.ts +110 -0
  78. package/src/tables/heatmap.ts +121 -0
  79. package/src/tables/pagination.ts +46 -0
  80. package/src/tables/search.ts +66 -0
  81. package/src/tables/sort.ts +69 -0
  82. package/src/tables/sparkline.ts +113 -0
  83. package/src/tables/utils.ts +16 -0
  84. package/src/tooltips/__tests__/compute.test.ts +328 -0
  85. package/src/tooltips/compute.ts +231 -0
@@ -0,0 +1,603 @@
1
+ /**
2
+ * Annotation computation: converts spec-level annotations to pixel-positioned
3
+ * ResolvedAnnotation objects using the resolved scales.
4
+ *
5
+ * Handles three annotation types:
6
+ * - text: positioned at a data coordinate with an optional callout
7
+ * - range: a highlighted rectangle between two data values
8
+ * - refline: a horizontal or vertical reference line at a data value
9
+ *
10
+ * Supports fine-grained positioning via offset, anchor, connector, and zIndex.
11
+ * At compact breakpoints, annotations are simplified or hidden.
12
+ */
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 { estimateTextWidth } from '@opendata-ai/openchart-core';
28
+ import type { ScaleBand, ScaleLinear, ScaleTime } from 'd3-scale';
29
+ import type { NormalizedChartSpec } from '../compiler/types';
30
+ 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_RANGE_FILL = '#f0c040';
39
+ const DEFAULT_RANGE_OPACITY = 0.15;
40
+ const DEFAULT_REFLINE_DASH = '4 3';
41
+
42
+ // Theme-aware defaults for text and stroke colors
43
+ const LIGHT_TEXT_FILL = '#333333';
44
+ const DARK_TEXT_FILL = '#d1d5db';
45
+ const LIGHT_REFLINE_STROKE = '#888888';
46
+ const DARK_REFLINE_STROKE = '#9ca3af';
47
+
48
+ /** Default label offset when using anchor directions. */
49
+ const ANCHOR_OFFSET = 8;
50
+
51
+ /** Resolve a data value to a pixel position on a given axis. */
52
+ function resolvePosition(
53
+ value: string | number,
54
+ scale: ResolvedScales['x'] | ResolvedScales['y'],
55
+ ): number | null {
56
+ if (!scale) return null;
57
+
58
+ const s = scale.scale;
59
+ const type = scale.type;
60
+
61
+ if (type === 'time') {
62
+ const date = new Date(String(value));
63
+ if (Number.isNaN(date.getTime())) return null;
64
+ return (s as ScaleTime<number, number>)(date);
65
+ }
66
+
67
+ if (type === 'linear' || type === 'log') {
68
+ const num = typeof value === 'number' ? value : Number(value);
69
+ if (!Number.isFinite(num)) return null;
70
+ return (s as ScaleLinear<number, number>)(num);
71
+ }
72
+
73
+ if (type === 'band') {
74
+ const bandScale = s as ScaleBand<string>;
75
+ const pos = bandScale(String(value));
76
+ if (pos === undefined) return null;
77
+ return pos + (bandScale.bandwidth?.() ?? 0) / 2;
78
+ }
79
+
80
+ // point or ordinal: try calling it directly
81
+ try {
82
+ return (s as (v: string) => number)(String(value));
83
+ } catch {
84
+ return null;
85
+ }
86
+ }
87
+
88
+ function makeAnnotationLabelStyle(
89
+ fontSize?: number,
90
+ fontWeight?: number,
91
+ fill?: string,
92
+ isDark?: boolean,
93
+ ): TextStyle {
94
+ const defaultFill = isDark ? DARK_TEXT_FILL : LIGHT_TEXT_FILL;
95
+ return {
96
+ fontFamily: 'Inter, system-ui, sans-serif',
97
+ fontSize: fontSize ?? DEFAULT_ANNOTATION_FONT_SIZE,
98
+ fontWeight: fontWeight ?? DEFAULT_ANNOTATION_FONT_WEIGHT,
99
+ fill: fill ?? defaultFill,
100
+ lineHeight: 1.3,
101
+ textAnchor: 'start',
102
+ };
103
+ }
104
+
105
+ /**
106
+ * Apply anchor direction to compute label offset from data point.
107
+ * Returns { dx, dy } pixel offsets.
108
+ */
109
+ function computeAnchorOffset(
110
+ anchor: AnnotationAnchor | undefined,
111
+ _px: number,
112
+ py: number,
113
+ chartArea: Rect,
114
+ ): { dx: number; dy: number } {
115
+ if (!anchor || anchor === 'auto') {
116
+ // Auto: place above if in the lower half, below if upper half
117
+ const isUpperHalf = py < chartArea.y + chartArea.height / 2;
118
+ return isUpperHalf
119
+ ? { dx: ANCHOR_OFFSET, dy: ANCHOR_OFFSET } // below-right
120
+ : { dx: ANCHOR_OFFSET, dy: -ANCHOR_OFFSET }; // above-right
121
+ }
122
+
123
+ switch (anchor) {
124
+ case 'top':
125
+ return { dx: 0, dy: -ANCHOR_OFFSET };
126
+ case 'bottom':
127
+ return { dx: 0, dy: ANCHOR_OFFSET };
128
+ case 'left':
129
+ return { dx: -ANCHOR_OFFSET, dy: 0 };
130
+ case 'right':
131
+ return { dx: ANCHOR_OFFSET, dy: 0 };
132
+ }
133
+ }
134
+
135
+ /** Apply user offset on top of computed anchor offset. */
136
+ function applyOffset(
137
+ base: { dx: number; dy: number },
138
+ offset: AnnotationOffset | undefined,
139
+ ): { dx: number; dy: number } {
140
+ if (!offset) return base;
141
+ return {
142
+ dx: base.dx + (offset.dx ?? 0),
143
+ dy: base.dy + (offset.dy ?? 0),
144
+ };
145
+ }
146
+
147
+ // ---------------------------------------------------------------------------
148
+ // Text annotation
149
+ // ---------------------------------------------------------------------------
150
+
151
+ function resolveTextAnnotation(
152
+ annotation: TextAnnotation,
153
+ scales: ResolvedScales,
154
+ chartArea: Rect,
155
+ isDark: boolean,
156
+ ): ResolvedAnnotation | null {
157
+ const px = resolvePosition(annotation.x, scales.x);
158
+ const py = resolvePosition(annotation.y, scales.y);
159
+
160
+ if (px === null || py === null) return null;
161
+
162
+ const defaultTextFill = isDark ? DARK_TEXT_FILL : LIGHT_TEXT_FILL;
163
+ const labelStyle = makeAnnotationLabelStyle(
164
+ annotation.fontSize,
165
+ annotation.fontWeight,
166
+ annotation.fill ?? defaultTextFill,
167
+ isDark,
168
+ );
169
+
170
+ // Compute position from anchor direction + user offset
171
+ const anchorDelta = computeAnchorOffset(annotation.anchor, px, py, chartArea);
172
+ const finalDelta = applyOffset(anchorDelta, annotation.offset);
173
+
174
+ const labelX = px + finalDelta.dx;
175
+ const labelY = py + finalDelta.dy;
176
+
177
+ // Connector: draw unless explicitly disabled
178
+ const showConnector = annotation.connector !== false;
179
+ const connectorStyle = annotation.connector === 'curve' ? 'curve' : 'straight';
180
+
181
+ // Compute connector origin based on style and text layout
182
+ const fontSize = annotation.fontSize ?? DEFAULT_ANNOTATION_FONT_SIZE;
183
+ const fontWeight = annotation.fontWeight ?? DEFAULT_ANNOTATION_FONT_WEIGHT;
184
+ const lines = annotation.text.split('\n');
185
+ const lineHeight = 1.3;
186
+ let connectorFromX: number;
187
+ if (connectorStyle === 'curve') {
188
+ // Curved connectors start from the right edge of the text
189
+ connectorFromX = labelX + estimateTextWidth(annotation.text, fontSize, fontWeight);
190
+ } else if (lines.length > 1) {
191
+ // Multi-line text uses text-anchor: middle, so labelX is already the center
192
+ connectorFromX = labelX;
193
+ } else {
194
+ // Straight connectors start from the horizontal center of the text
195
+ connectorFromX = labelX + estimateTextWidth(annotation.text, fontSize, fontWeight) / 2;
196
+ }
197
+
198
+ // Connector from.y sits at the bottom of the text block
199
+ const connectorFromY = labelY + (lines.length - 1) * fontSize * lineHeight + fontSize * 0.3;
200
+
201
+ // Apply user-provided connector endpoint offsets
202
+ const baseFrom = { x: connectorFromX, y: connectorFromY };
203
+ const baseTo = { x: px, y: py };
204
+ const adjustedFrom = {
205
+ x: baseFrom.x + (annotation.connectorOffset?.from?.dx ?? 0),
206
+ y: baseFrom.y + (annotation.connectorOffset?.from?.dy ?? 0),
207
+ };
208
+ const adjustedToRaw = {
209
+ x: baseTo.x + (annotation.connectorOffset?.to?.dx ?? 0),
210
+ y: baseTo.y + (annotation.connectorOffset?.to?.dy ?? 0),
211
+ };
212
+
213
+ // Pull the "to" endpoint back along the connector direction so the
214
+ // line doesn't touch the data point directly (leaves a small gap).
215
+ const GAP = 4;
216
+ const cdx = adjustedToRaw.x - adjustedFrom.x;
217
+ const cdy = adjustedToRaw.y - adjustedFrom.y;
218
+ const dist = Math.sqrt(cdx * cdx + cdy * cdy);
219
+ const adjustedTo =
220
+ dist > GAP * 2
221
+ ? { x: adjustedToRaw.x - (cdx / dist) * GAP, y: adjustedToRaw.y - (cdy / dist) * GAP }
222
+ : adjustedToRaw;
223
+
224
+ const label: ResolvedLabel = {
225
+ text: annotation.text,
226
+ x: labelX,
227
+ y: labelY,
228
+ style: labelStyle,
229
+ visible: true,
230
+ connector: showConnector
231
+ ? {
232
+ from: adjustedFrom,
233
+ to: adjustedTo,
234
+ stroke: annotation.stroke ?? '#999999',
235
+ style: connectorStyle,
236
+ }
237
+ : undefined,
238
+ background: annotation.background,
239
+ };
240
+
241
+ return {
242
+ type: 'text',
243
+ label,
244
+ stroke: annotation.stroke,
245
+ fill: annotation.fill,
246
+ opacity: annotation.opacity,
247
+ zIndex: annotation.zIndex,
248
+ };
249
+ }
250
+
251
+ // ---------------------------------------------------------------------------
252
+ // Range annotation
253
+ // ---------------------------------------------------------------------------
254
+
255
+ function resolveRangeAnnotation(
256
+ annotation: RangeAnnotation,
257
+ scales: ResolvedScales,
258
+ chartArea: Rect,
259
+ isDark: boolean,
260
+ ): ResolvedAnnotation | null {
261
+ let x = chartArea.x;
262
+ let y = chartArea.y;
263
+ let width = chartArea.width;
264
+ let height = chartArea.height;
265
+
266
+ // X-range (vertical band)
267
+ if (annotation.x1 !== undefined && annotation.x2 !== undefined) {
268
+ const x1px = resolvePosition(annotation.x1, scales.x);
269
+ const x2px = resolvePosition(annotation.x2, scales.x);
270
+ if (x1px === null || x2px === null) return null;
271
+
272
+ x = Math.min(x1px, x2px);
273
+ width = Math.abs(x2px - x1px);
274
+ }
275
+
276
+ // Y-range (horizontal band)
277
+ if (annotation.y1 !== undefined && annotation.y2 !== undefined) {
278
+ const y1px = resolvePosition(annotation.y1, scales.y);
279
+ const y2px = resolvePosition(annotation.y2, scales.y);
280
+ if (y1px === null || y2px === null) return null;
281
+
282
+ y = Math.min(y1px, y2px);
283
+ height = Math.abs(y2px - y1px);
284
+ }
285
+
286
+ const rect: Rect = { x, y, width, height };
287
+
288
+ // Label at the top-left of the range, with optional offset
289
+ let label: ResolvedLabel | undefined;
290
+ if (annotation.label) {
291
+ const baseDx = 4;
292
+ const baseDy = 14;
293
+ const labelDelta = applyOffset({ dx: baseDx, dy: baseDy }, annotation.labelOffset);
294
+
295
+ label = {
296
+ text: annotation.label,
297
+ x: x + labelDelta.dx,
298
+ y: y + labelDelta.dy,
299
+ style: makeAnnotationLabelStyle(11, 500, undefined, isDark),
300
+ visible: true,
301
+ };
302
+ }
303
+
304
+ // In dark mode, boost range opacity slightly for better visibility
305
+ const defaultOpacity = isDark ? 0.2 : DEFAULT_RANGE_OPACITY;
306
+
307
+ return {
308
+ type: 'range',
309
+ rect,
310
+ label,
311
+ fill: annotation.fill ?? DEFAULT_RANGE_FILL,
312
+ opacity: annotation.opacity ?? defaultOpacity,
313
+ stroke: annotation.stroke,
314
+ zIndex: annotation.zIndex,
315
+ };
316
+ }
317
+
318
+ // ---------------------------------------------------------------------------
319
+ // Reference line annotation
320
+ // ---------------------------------------------------------------------------
321
+
322
+ function resolveRefLineAnnotation(
323
+ annotation: RefLineAnnotation,
324
+ scales: ResolvedScales,
325
+ chartArea: Rect,
326
+ isDark: boolean,
327
+ ): ResolvedAnnotation | null {
328
+ let start: Point;
329
+ let end: Point;
330
+
331
+ if (annotation.y !== undefined) {
332
+ // Horizontal reference line
333
+ const yPx = resolvePosition(annotation.y, scales.y);
334
+ if (yPx === null) return null;
335
+
336
+ start = { x: chartArea.x, y: yPx };
337
+ end = { x: chartArea.x + chartArea.width, y: yPx };
338
+ } else if (annotation.x !== undefined) {
339
+ // Vertical reference line
340
+ const xPx = resolvePosition(annotation.x, scales.x);
341
+ if (xPx === null) return null;
342
+
343
+ start = { x: xPx, y: chartArea.y };
344
+ end = { x: xPx, y: chartArea.y + chartArea.height };
345
+ } else {
346
+ return null;
347
+ }
348
+
349
+ // Determine dash pattern from style
350
+ let strokeDasharray: string | undefined;
351
+ if (annotation.style === 'dashed' || annotation.style === undefined) {
352
+ strokeDasharray = DEFAULT_REFLINE_DASH;
353
+ } else if (annotation.style === 'dotted') {
354
+ strokeDasharray = '2 2';
355
+ }
356
+ // 'solid' gets no dasharray
357
+
358
+ // Label at the right end for horizontal, top end for vertical, with optional offset.
359
+ // Horizontal refline labels use text-anchor 'end' so text stays inside the chart.
360
+ let label: ResolvedLabel | undefined;
361
+ if (annotation.label) {
362
+ const isHorizontal = annotation.y !== undefined;
363
+ const baseDx = isHorizontal ? -4 : 4;
364
+ const baseDy = -4;
365
+ const labelDelta = applyOffset({ dx: baseDx, dy: baseDy }, annotation.labelOffset);
366
+
367
+ const defaultStroke = isDark ? DARK_REFLINE_STROKE : LIGHT_REFLINE_STROKE;
368
+ const style = makeAnnotationLabelStyle(11, 400, annotation.stroke ?? defaultStroke, isDark);
369
+ if (isHorizontal) {
370
+ style.textAnchor = 'end';
371
+ }
372
+
373
+ label = {
374
+ text: annotation.label,
375
+ x: (isHorizontal ? end.x : start.x) + labelDelta.dx,
376
+ y: (isHorizontal ? end.y : start.y) + labelDelta.dy,
377
+ style,
378
+ visible: true,
379
+ };
380
+ }
381
+
382
+ const defaultStroke = isDark ? DARK_REFLINE_STROKE : LIGHT_REFLINE_STROKE;
383
+
384
+ return {
385
+ type: 'refline',
386
+ line: { start, end },
387
+ label,
388
+ stroke: annotation.stroke ?? defaultStroke,
389
+ strokeDasharray,
390
+ strokeWidth: annotation.strokeWidth ?? 1,
391
+ zIndex: annotation.zIndex,
392
+ };
393
+ }
394
+
395
+ // ---------------------------------------------------------------------------
396
+ // Public API
397
+ // ---------------------------------------------------------------------------
398
+
399
+ // ---------------------------------------------------------------------------
400
+ // Collision avoidance
401
+ // ---------------------------------------------------------------------------
402
+
403
+ /** Estimate the bounding box of an annotation label. */
404
+ function estimateLabelBounds(label: ResolvedLabel): Rect {
405
+ const lines = label.text.split('\n');
406
+ const fontSize = label.style.fontSize ?? DEFAULT_ANNOTATION_FONT_SIZE;
407
+ const fontWeight = label.style.fontWeight ?? DEFAULT_ANNOTATION_FONT_WEIGHT;
408
+ const lineHeight = label.style.lineHeight ?? 1.3;
409
+
410
+ const maxWidth = Math.max(...lines.map((line) => estimateTextWidth(line, fontSize, fontWeight)));
411
+ const totalHeight = lines.length * fontSize * lineHeight;
412
+
413
+ // Multi-line text is rendered with text-anchor: middle by the SVG renderer,
414
+ // so the text is centered at label.x. Single-line uses the style's textAnchor.
415
+ const isMultiLine = lines.length > 1;
416
+ const anchorX = isMultiLine ? label.x - maxWidth / 2 : label.x;
417
+
418
+ return {
419
+ x: anchorX,
420
+ y: label.y - fontSize,
421
+ width: maxWidth,
422
+ height: totalHeight,
423
+ };
424
+ }
425
+
426
+ /** Check if two rects overlap. */
427
+ function rectsOverlap(a: Rect, b: Rect): boolean {
428
+ return a.x < b.x + b.width && a.x + a.width > b.x && a.y < b.y + b.height && a.y + a.height > b.y;
429
+ }
430
+
431
+ /** Padding between annotation and obstacle when nudging. */
432
+ const NUDGE_PADDING = 6;
433
+
434
+ /**
435
+ * Try to reposition a text annotation to avoid overlapping with obstacle rects
436
+ * (legend bounds, etc.). First tries standard anchor alternatives, then
437
+ * calculates specific offsets needed to clear obstacles. Returns true if moved.
438
+ */
439
+ function nudgeAnnotationFromObstacles(
440
+ annotation: ResolvedAnnotation,
441
+ originalAnnotation: TextAnnotation,
442
+ scales: ResolvedScales,
443
+ chartArea: Rect,
444
+ obstacles: Rect[],
445
+ ): boolean {
446
+ if (annotation.type !== 'text' || !annotation.label) return false;
447
+
448
+ const labelBounds = estimateLabelBounds(annotation.label);
449
+ const collidingObs = obstacles.filter(
450
+ (obs) => obs.width > 0 && obs.height > 0 && rectsOverlap(labelBounds, obs),
451
+ );
452
+
453
+ if (collidingObs.length === 0) return false;
454
+
455
+ // Resolve the data point pixel position for offset calculations
456
+ const px = resolvePosition(originalAnnotation.x, scales.x);
457
+ const py = resolvePosition(originalAnnotation.y, scales.y);
458
+ if (px === null || py === null) return false;
459
+
460
+ // Generate candidate positions: calculated offsets to clear each obstacle
461
+ const candidates: { dx: number; dy: number; distance: number }[] = [];
462
+ const fontSize = labelBounds.height / Math.max(1, annotation.label.text.split('\n').length);
463
+
464
+ for (const obs of collidingObs) {
465
+ // Below obstacle: shift label so its top edge clears the obstacle bottom
466
+ const currentLabelTop = labelBounds.y;
467
+ const targetLabelTop = obs.y + obs.height + NUDGE_PADDING;
468
+ const belowDy = targetLabelTop - currentLabelTop;
469
+ candidates.push({ dx: 0, dy: belowDy, distance: Math.abs(belowDy) });
470
+
471
+ // Above obstacle: shift label so its bottom edge clears the obstacle top
472
+ const currentLabelBottom = labelBounds.y + labelBounds.height;
473
+ const targetLabelBottom = obs.y - NUDGE_PADDING;
474
+ const aboveDy = targetLabelBottom - currentLabelBottom;
475
+ candidates.push({ dx: 0, dy: aboveDy, distance: Math.abs(aboveDy) });
476
+
477
+ // Left of obstacle: shift label so its right edge clears the obstacle left
478
+ const currentLabelRight = labelBounds.x + labelBounds.width;
479
+ const targetLabelRight = obs.x - NUDGE_PADDING;
480
+ const leftDx = targetLabelRight - currentLabelRight;
481
+ candidates.push({ dx: leftDx, dy: 0, distance: Math.abs(leftDx) });
482
+
483
+ // Right of obstacle: shift label so its left edge clears the obstacle right
484
+ const currentLabelLeft = labelBounds.x;
485
+ const targetLabelLeft = obs.x + obs.width + NUDGE_PADDING;
486
+ const rightDx = targetLabelLeft - currentLabelLeft;
487
+ candidates.push({ dx: rightDx, dy: 0, distance: Math.abs(rightDx) });
488
+ }
489
+
490
+ // Sort candidates by distance (prefer smallest movement)
491
+ candidates.sort((a, b) => a.distance - b.distance);
492
+
493
+ for (const { dx, dy } of candidates) {
494
+ const candidateLabel: ResolvedLabel = {
495
+ ...annotation.label,
496
+ x: annotation.label.x + dx,
497
+ y: annotation.label.y + dy,
498
+ connector: annotation.label.connector
499
+ ? {
500
+ ...annotation.label.connector,
501
+ from: {
502
+ x: annotation.label.connector.from.x + dx,
503
+ y: annotation.label.connector.from.y + dy,
504
+ },
505
+ }
506
+ : undefined,
507
+ };
508
+
509
+ const candidateBounds = estimateLabelBounds(candidateLabel);
510
+
511
+ // Check no collisions with any obstacle
512
+ const stillCollides = obstacles.some(
513
+ (obs) => obs.width > 0 && obs.height > 0 && rectsOverlap(candidateBounds, obs),
514
+ );
515
+ if (stillCollides) continue;
516
+
517
+ // Annotations render outside the clip path, so they can extend into margins.
518
+ // Only check that the label center is reasonably within the chart and that
519
+ // the text doesn't go completely off-screen.
520
+ const labelCenterX = candidateBounds.x + candidateBounds.width / 2;
521
+ const labelCenterY = candidateBounds.y + candidateBounds.height / 2;
522
+ const inBounds =
523
+ labelCenterX >= chartArea.x &&
524
+ labelCenterX <= chartArea.x + chartArea.width + 100 &&
525
+ labelCenterY >= chartArea.y - fontSize &&
526
+ labelCenterY <= chartArea.y + chartArea.height + fontSize;
527
+
528
+ if (inBounds) {
529
+ // When nudged vertically (directly above/below the data), use a caret
530
+ // instead of a connector line for a cleaner editorial look.
531
+ if (candidateLabel.connector && dx === 0 && dy !== 0) {
532
+ candidateLabel.connector = {
533
+ ...candidateLabel.connector,
534
+ style: 'caret',
535
+ };
536
+ }
537
+ annotation.label = candidateLabel;
538
+ return true;
539
+ }
540
+ }
541
+
542
+ return false;
543
+ }
544
+
545
+ // ---------------------------------------------------------------------------
546
+ // Public API
547
+ // ---------------------------------------------------------------------------
548
+
549
+ /**
550
+ * Compute resolved annotations from spec annotations using the resolved scales.
551
+ *
552
+ * Converts data-coordinate annotations to pixel-positioned ResolvedAnnotation
553
+ * objects. Supports offset, anchor, connector, and zIndex. At compact
554
+ * breakpoints, annotations are hidden (strategy says "tooltip-only").
555
+ *
556
+ * When obstacle rects are provided (e.g. legend bounds), text annotations
557
+ * that overlap with them are automatically repositioned using alternate
558
+ * anchor directions.
559
+ */
560
+ export function computeAnnotations(
561
+ spec: NormalizedChartSpec,
562
+ scales: ResolvedScales,
563
+ chartArea: Rect,
564
+ strategy: LayoutStrategy,
565
+ isDark = false,
566
+ obstacles: Rect[] = [],
567
+ ): ResolvedAnnotation[] {
568
+ // At compact breakpoints, skip all annotations
569
+ if (strategy.annotationPosition === 'tooltip-only') {
570
+ return [];
571
+ }
572
+
573
+ const annotations: ResolvedAnnotation[] = [];
574
+
575
+ for (const annotation of spec.annotations) {
576
+ let resolved: ResolvedAnnotation | null = null;
577
+
578
+ switch (annotation.type) {
579
+ case 'text':
580
+ resolved = resolveTextAnnotation(annotation, scales, chartArea, isDark);
581
+ break;
582
+ case 'range':
583
+ resolved = resolveRangeAnnotation(annotation, scales, chartArea, isDark);
584
+ break;
585
+ case 'refline':
586
+ resolved = resolveRefLineAnnotation(annotation, scales, chartArea, isDark);
587
+ break;
588
+ }
589
+
590
+ if (resolved) {
591
+ // For text annotations, check for collisions with obstacles and nudge if needed
592
+ if (annotation.type === 'text' && obstacles.length > 0) {
593
+ nudgeAnnotationFromObstacles(resolved, annotation, scales, chartArea, obstacles);
594
+ }
595
+ annotations.push(resolved);
596
+ }
597
+ }
598
+
599
+ // Sort by zIndex (lower first, undefined treated as 0)
600
+ annotations.sort((a, b) => (a.zIndex ?? 0) - (b.zIndex ?? 0));
601
+
602
+ return annotations;
603
+ }
@@ -0,0 +1,110 @@
1
+ import type { LayoutStrategy, Mark, Rect } from '@opendata-ai/openchart-core';
2
+ import { afterEach, describe, expect, it } from 'vitest';
3
+ import type { NormalizedChartSpec } from '../../compiler/types';
4
+ import type { ResolvedScales } from '../../layout/scales';
5
+ import { clearRenderers, getChartRenderer, registerChartRenderer } from '../registry';
6
+
7
+ // ---------------------------------------------------------------------------
8
+ // Fixtures
9
+ // ---------------------------------------------------------------------------
10
+
11
+ /**
12
+ * A minimal renderer that returns a single rect mark.
13
+ * Used to verify the registry lifecycle without needing real chart logic.
14
+ */
15
+ function stubRenderer(
16
+ _spec: NormalizedChartSpec,
17
+ _scales: ResolvedScales,
18
+ _chartArea: Rect,
19
+ _strategy: LayoutStrategy,
20
+ ): Mark[] {
21
+ return [
22
+ {
23
+ type: 'rect',
24
+ x: 10,
25
+ y: 20,
26
+ width: 100,
27
+ height: 50,
28
+ fill: '#ff0000',
29
+ data: {},
30
+ aria: { label: 'stub mark' },
31
+ },
32
+ ];
33
+ }
34
+
35
+ // ---------------------------------------------------------------------------
36
+ // Tests
37
+ // ---------------------------------------------------------------------------
38
+
39
+ describe('chart renderer registry', () => {
40
+ afterEach(() => {
41
+ clearRenderers();
42
+ });
43
+
44
+ it('returns undefined for an unregistered chart type', () => {
45
+ expect(getChartRenderer('nonexistent')).toBeUndefined();
46
+ });
47
+
48
+ it('registers and retrieves a renderer by type', () => {
49
+ registerChartRenderer('test-type', stubRenderer);
50
+
51
+ const retrieved = getChartRenderer('test-type');
52
+ expect(retrieved).toBe(stubRenderer);
53
+ });
54
+
55
+ it('registered renderer produces marks when called', () => {
56
+ registerChartRenderer('test-type', stubRenderer);
57
+
58
+ const renderer = getChartRenderer('test-type')!;
59
+ const marks = renderer(
60
+ {} as NormalizedChartSpec,
61
+ {} as ResolvedScales,
62
+ { x: 0, y: 0, width: 600, height: 400 },
63
+ {} as LayoutStrategy,
64
+ );
65
+
66
+ expect(marks).toHaveLength(1);
67
+ expect(marks[0].type).toBe('rect');
68
+ if (marks[0].type === 'rect') {
69
+ expect(marks[0].width).toBe(100);
70
+ expect(marks[0].fill).toBe('#ff0000');
71
+ }
72
+ });
73
+
74
+ it('overwrites a previously registered renderer for the same type', () => {
75
+ const secondRenderer = () => [] as Mark[];
76
+
77
+ registerChartRenderer('test-type', stubRenderer);
78
+ registerChartRenderer('test-type', secondRenderer);
79
+
80
+ expect(getChartRenderer('test-type')).toBe(secondRenderer);
81
+ });
82
+
83
+ it('clearRenderers removes all registered renderers', () => {
84
+ registerChartRenderer('type-a', stubRenderer);
85
+ registerChartRenderer('type-b', stubRenderer);
86
+
87
+ // Both should be registered
88
+ expect(getChartRenderer('type-a')).toBe(stubRenderer);
89
+ expect(getChartRenderer('type-b')).toBe(stubRenderer);
90
+
91
+ clearRenderers();
92
+
93
+ // Both should be gone
94
+ expect(getChartRenderer('type-a')).toBeUndefined();
95
+ expect(getChartRenderer('type-b')).toBeUndefined();
96
+ });
97
+
98
+ it('multiple types can be registered independently', () => {
99
+ const rendererA = () => [] as Mark[];
100
+ const rendererB = () => [] as Mark[];
101
+
102
+ registerChartRenderer('type-a', rendererA);
103
+ registerChartRenderer('type-b', rendererB);
104
+
105
+ expect(getChartRenderer('type-a')).toBe(rendererA);
106
+ expect(getChartRenderer('type-b')).toBe(rendererB);
107
+ // Unregistered type still returns undefined
108
+ expect(getChartRenderer('type-c')).toBeUndefined();
109
+ });
110
+ });