@opendata-ai/openchart-engine 7.11.0 → 8.0.0-rc.2

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 (159) hide show
  1. package/dist/index.d.ts +123 -14
  2. package/dist/index.js +16166 -10027
  3. package/dist/index.js.map +1 -1
  4. package/package.json +11 -4
  5. package/src/__tests__/__snapshots__/compile-snapshot.test.ts.snap +170 -56
  6. package/src/__tests__/annotation-behavior.test.ts +721 -0
  7. package/src/__tests__/axes.test.ts +50 -3
  8. package/src/__tests__/compile-chart.test.ts +30 -0
  9. package/src/__tests__/compile-footnotes.test.ts +277 -0
  10. package/src/__tests__/compile-layer.test.ts +120 -1
  11. package/src/__tests__/helpers/invariants.ts +54 -20
  12. package/src/__tests__/known-bugs.test.ts +455 -2
  13. package/src/__tests__/legend.test.ts +69 -4
  14. package/src/__tests__/ranking-recipes.test.ts +260 -0
  15. package/src/__tests__/scales.test.ts +9 -3
  16. package/src/__tests__/series-search.test.ts +92 -0
  17. package/src/__tests__/spec-sugar.test.ts +801 -0
  18. package/src/__tests__/you-draw-it.test.ts +160 -0
  19. package/src/annotations/__tests__/compute.test.ts +207 -92
  20. package/src/annotations/__tests__/geometry.test.ts +66 -0
  21. package/src/annotations/__tests__/resolve-text.test.ts +527 -27
  22. package/src/annotations/__tests__/rich-text.test.ts +91 -0
  23. package/src/annotations/__tests__/thinning.test.ts +148 -0
  24. package/src/annotations/collisions.ts +130 -84
  25. package/src/annotations/compute.ts +83 -85
  26. package/src/annotations/constants.ts +116 -6
  27. package/src/annotations/geometry.ts +222 -77
  28. package/src/annotations/move.ts +135 -0
  29. package/src/annotations/placement.ts +62 -14
  30. package/src/annotations/resolve-range.ts +2 -0
  31. package/src/annotations/resolve-refline.ts +2 -0
  32. package/src/annotations/resolve-text.ts +312 -111
  33. package/src/annotations/rich-text.ts +65 -0
  34. package/src/annotations/thinning.ts +84 -10
  35. package/src/barlist/compile-barlist.ts +16 -6
  36. package/src/charts/_shared/format-label-value.ts +9 -8
  37. package/src/charts/bar/__tests__/compute.test.ts +57 -10
  38. package/src/charts/bar/__tests__/labels.test.ts +5 -4
  39. package/src/charts/bar/compute.ts +2 -6
  40. package/src/charts/bar/index.ts +8 -1
  41. package/src/charts/bar/labels.ts +3 -3
  42. package/src/charts/beeswarm/__tests__/compute.test.ts +280 -0
  43. package/src/charts/beeswarm/__tests__/dodge.test.ts +148 -0
  44. package/src/charts/beeswarm/compute.ts +203 -0
  45. package/src/charts/beeswarm/dodge.ts +155 -0
  46. package/src/charts/beeswarm/index.ts +32 -0
  47. package/src/charts/builtin.ts +12 -1
  48. package/src/charts/calendar/__tests__/compute.test.ts +417 -0
  49. package/src/charts/calendar/compute.ts +319 -0
  50. package/src/charts/calendar/index.ts +32 -0
  51. package/src/charts/column/__tests__/compute.test.ts +21 -7
  52. package/src/charts/column/__tests__/labels.test.ts +76 -1
  53. package/src/charts/column/compute.ts +2 -6
  54. package/src/charts/column/index.ts +10 -2
  55. package/src/charts/column/labels.ts +95 -21
  56. package/src/charts/dot/index.ts +8 -1
  57. package/src/charts/dot/labels.ts +3 -3
  58. package/src/charts/line/__tests__/compute.test.ts +15 -19
  59. package/src/charts/line/area.ts +8 -23
  60. package/src/charts/line/compute.ts +32 -2
  61. package/src/charts/parliament/__tests__/compute.test.ts +411 -0
  62. package/src/charts/parliament/compute.ts +313 -0
  63. package/src/charts/parliament/index.ts +32 -0
  64. package/src/charts/pie/__tests__/compute.test.ts +103 -0
  65. package/src/charts/pie/compute.ts +63 -8
  66. package/src/charts/pie/labels.ts +1 -0
  67. package/src/charts/range/__tests__/compute.test.ts +486 -0
  68. package/src/charts/range/compute.ts +273 -0
  69. package/src/charts/range/index.ts +82 -0
  70. package/src/charts/range/labels.ts +169 -0
  71. package/src/charts/rect/__tests__/compute.test.ts +120 -0
  72. package/src/charts/rect/index.ts +127 -0
  73. package/src/charts/scatter/compute.ts +7 -21
  74. package/src/charts/text/__tests__/compute.test.ts +307 -0
  75. package/src/charts/text/index.ts +44 -7
  76. package/src/charts/tick/__tests__/compute.test.ts +75 -0
  77. package/src/charts/tick/index.ts +7 -4
  78. package/src/charts/utils.ts +5 -3
  79. package/src/charts/waffle/__tests__/compute.test.ts +305 -0
  80. package/src/charts/waffle/compute.ts +197 -0
  81. package/src/charts/waffle/index.ts +31 -0
  82. package/src/compile/__tests__/color-scale-reverse.test.ts +89 -0
  83. package/src/compile/__tests__/data-clip.test.ts +68 -0
  84. package/src/compile/__tests__/fill-patterns.test.ts +154 -0
  85. package/src/compile/__tests__/size-scale.test.ts +108 -0
  86. package/src/compile/color-scale-range.ts +54 -33
  87. package/src/compile/data-clip.ts +21 -6
  88. package/src/compile/fill-patterns.ts +74 -0
  89. package/src/compile/layer.ts +117 -21
  90. package/src/compile/size-scale.ts +139 -0
  91. package/src/compile/spec-sugar.ts +758 -0
  92. package/src/compile/you-draw-it.ts +123 -0
  93. package/src/compile.ts +310 -103
  94. package/src/compiler/__tests__/a11y-warnings.test.ts +148 -0
  95. package/src/compiler/__tests__/fixtures/vl-parity/axis-null.json +19 -0
  96. package/src/compiler/__tests__/fixtures/vl-parity/color-value.json +20 -0
  97. package/src/compiler/__tests__/fixtures/vl-parity/count-aggregate.json +22 -0
  98. package/src/compiler/__tests__/fixtures/vl-parity/data-values.json +19 -0
  99. package/src/compiler/__tests__/fixtures/vl-parity/layer-axis-null.json +14 -0
  100. package/src/compiler/__tests__/fixtures/vl-parity/layer-count.json +34 -0
  101. package/src/compiler/__tests__/fixtures/vl-parity/layer-data-values.json +22 -0
  102. package/src/compiler/__tests__/fixtures/vl-parity/layer-legend-null.json +41 -0
  103. package/src/compiler/__tests__/fixtures/vl-parity/layer-scheme.json +38 -0
  104. package/src/compiler/__tests__/fixtures/vl-parity/layer-sort.json +28 -0
  105. package/src/compiler/__tests__/fixtures/vl-parity/layer-theta.json +28 -0
  106. package/src/compiler/__tests__/fixtures/vl-parity/layer-title.json +30 -0
  107. package/src/compiler/__tests__/fixtures/vl-parity/layer-value-def.json +29 -0
  108. package/src/compiler/__tests__/fixtures/vl-parity/legend-null.json +32 -0
  109. package/src/compiler/__tests__/fixtures/vl-parity/scheme.json +25 -0
  110. package/src/compiler/__tests__/fixtures/vl-parity/sort-by-value.json +19 -0
  111. package/src/compiler/__tests__/fixtures/vl-parity/theta-arc.json +19 -0
  112. package/src/compiler/__tests__/fixtures/vl-parity/title-subtitle.json +21 -0
  113. package/src/compiler/__tests__/fixtures/vl-parity/width-height.json +23 -0
  114. package/src/compiler/__tests__/keys.test.ts +3 -9
  115. package/src/compiler/__tests__/normalize.test.ts +65 -1
  116. package/src/compiler/__tests__/validate-did-you-mean.test.ts +86 -0
  117. package/src/compiler/__tests__/validate-near-miss.test.ts +89 -0
  118. package/src/compiler/__tests__/validate.test.ts +156 -0
  119. package/src/compiler/__tests__/vl-parity.test.ts +68 -0
  120. package/src/compiler/a11y-warnings.ts +110 -0
  121. package/src/compiler/keys.ts +5 -4
  122. package/src/compiler/normalize.ts +150 -7
  123. package/src/compiler/types.ts +11 -0
  124. package/src/compiler/validate.ts +664 -18
  125. package/src/endpoint-labels/__tests__/compute.test.ts +88 -0
  126. package/src/endpoint-labels/compute.ts +40 -12
  127. package/src/endpoint-labels/format.ts +45 -15
  128. package/src/endpoint-labels/predict.ts +50 -36
  129. package/src/format/__tests__/field-format.test.ts +164 -0
  130. package/src/format/field-format.ts +27 -0
  131. package/src/geo/__tests__/compile-map.test.ts +621 -0
  132. package/src/geo/compile-map.ts +856 -0
  133. package/src/geo/join.ts +115 -0
  134. package/src/geo/projections.ts +45 -0
  135. package/src/geo/types.ts +24 -0
  136. package/src/graphs/__tests__/compile-graph.test.ts +25 -0
  137. package/src/graphs/compile-graph.ts +27 -18
  138. package/src/index.ts +20 -2
  139. package/src/layout/axes/ticks.ts +59 -10
  140. package/src/layout/axes.ts +35 -6
  141. package/src/layout/dimensions.ts +139 -83
  142. package/src/layout/facet.ts +14 -3
  143. package/src/layout/plan.ts +83 -33
  144. package/src/layout/scales.ts +132 -15
  145. package/src/legend/__tests__/continuous.test.ts +318 -0
  146. package/src/legend/__tests__/size.test.ts +348 -0
  147. package/src/legend/__tests__/suppression.test.ts +17 -1
  148. package/src/legend/compute.ts +119 -13
  149. package/src/legend/continuous.ts +327 -0
  150. package/src/legend/size.ts +407 -0
  151. package/src/legend/suppression.ts +8 -7
  152. package/src/sankey/compile-sankey.ts +31 -19
  153. package/src/tables/format-cells.ts +16 -7
  154. package/src/tilemap/__tests__/compile-tilemap.test.ts +39 -0
  155. package/src/tilemap/compile-tilemap.ts +24 -7
  156. package/src/tooltips/__tests__/compute.test.ts +2 -2
  157. package/src/tooltips/compute.ts +422 -41
  158. package/src/transforms/__tests__/conditional.test.ts +18 -0
  159. package/src/transforms/aggregate.ts +2 -1
@@ -11,19 +11,57 @@ import type {
11
11
  import { estimateTextWidth } from '@opendata-ai/openchart-core';
12
12
  import {
13
13
  ANCHOR_OFFSET,
14
+ ARROWHEAD_HALF_WIDTH,
15
+ ARROWHEAD_LENGTH,
16
+ CONNECTOR_STANDOFF,
17
+ connectorIsDrawable,
14
18
  DEFAULT_ANNOTATION_FONT_SIZE,
15
19
  DEFAULT_ANNOTATION_FONT_WEIGHT,
16
20
  DEFAULT_LINE_HEIGHT,
21
+ DROP_LINE_TOP_GAP,
17
22
  } from './constants';
23
+ import { BOLD_SPAN_FONT_WEIGHT, parseAnnotationSpans } from './rich-text';
18
24
 
25
+ /**
26
+ * Measure one run of text at a given size and weight.
27
+ *
28
+ * Deliberately takes no `fontFamily`: neither implementation could use one.
29
+ * `heuristicMeasure` below is family-blind, and the real canvas measurer gets its
30
+ * family from the `createMeasureText(fontFamily)` closure, not per call -- so a
31
+ * family passed here would be silently dropped while implying the opposite.
32
+ * `style.fontFamily` on a *resolved label* is a different thing: that one is real
33
+ * and gets rendered.
34
+ */
19
35
  export type AnnotationMeasureTextFn = (
20
36
  text: string,
21
- font: { fontSize: number; fontWeight: number; fontFamily?: string },
37
+ font: { fontSize: number; fontWeight: number },
22
38
  ) => number;
23
39
 
24
40
  export const heuristicMeasure: AnnotationMeasureTextFn = (text, { fontSize, fontWeight }) =>
25
41
  estimateTextWidth(text, fontSize, fontWeight);
26
42
 
43
+ /**
44
+ * Width of one line of annotation text, `**bold**` spans included: each span is
45
+ * measured at its own weight and the widths are summed. Measuring the raw string
46
+ * would count the `**` markers and use one weight for the whole line, so bounds,
47
+ * collisions, placement, and connector exits would all be wrong for rich text.
48
+ */
49
+ export function measureRichLine(
50
+ line: string,
51
+ style: { fontSize: number; fontWeight: number },
52
+ measure: AnnotationMeasureTextFn,
53
+ ): number {
54
+ const spans = parseAnnotationSpans(line);
55
+ let width = 0;
56
+ for (const span of spans) {
57
+ width += measure(span.text, {
58
+ fontSize: style.fontSize,
59
+ fontWeight: span.bold ? BOLD_SPAN_FONT_WEIGHT : style.fontWeight,
60
+ });
61
+ }
62
+ return width;
63
+ }
64
+
27
65
  /**
28
66
  * Compute the bounding box of a text block, aware of textAnchor and multi-line layout.
29
67
  * labelY is the first-line baseline.
@@ -37,15 +75,12 @@ export function computeTextBlockBounds(
37
75
  fontWeight: number;
38
76
  lineHeight: number;
39
77
  textAnchor?: 'start' | 'middle' | 'end';
78
+ fontFamily?: string;
40
79
  },
41
80
  measure: AnnotationMeasureTextFn = heuristicMeasure,
42
81
  ): Rect {
43
82
  const lines = text.split('\n');
44
- const maxWidth = Math.max(
45
- ...lines.map((line) =>
46
- measure(line, { fontSize: style.fontSize, fontWeight: style.fontWeight }),
47
- ),
48
- );
83
+ const maxWidth = Math.max(...lines.map((line) => measureRichLine(line, style, measure)));
49
84
  const height =
50
85
  style.fontSize + (lines.length - 1) * style.fontSize * style.lineHeight + style.fontSize * 0.3;
51
86
 
@@ -119,59 +154,73 @@ export function applyOffset(
119
154
  };
120
155
  }
121
156
 
157
+ /** Inflation applied to the label box before intersecting the connector ray. */
158
+ const BOX_INFLATE = 2;
159
+
122
160
  /**
123
- * Compute the connector origin point on the text bounding box.
124
- * For straight connectors, finds the edge midpoint (top, bottom, left, right)
125
- * closest to the data point. For curve connectors, always uses the right edge.
161
+ * Compute where a connector leaves the annotation's text block on its way to the
162
+ * target. Casts a ray from the box center toward the target, intersects the
163
+ * (slightly inflated) box, then advances `standoff` px along the ray so the line
164
+ * never touches the glyphs.
165
+ *
166
+ * Returns `null` when the target sits inside the inflated box (a connector would
167
+ * cross its own text) or when the standoff would overshoot the target.
126
168
  */
127
- export function computeConnectorOrigin(
128
- labelX: number,
129
- labelY: number,
130
- text: string,
131
- fontSize: number,
132
- fontWeight: number,
169
+ export function connectorExit(
170
+ bounds: Rect,
133
171
  targetX: number,
134
172
  targetY: number,
135
- connectorStyle: 'straight' | 'curve',
136
- ): { x: number; y: number } {
137
- const lines = text.split('\n');
138
- const isMultiLine = lines.length > 1;
139
- const anchor: 'start' | 'middle' = isMultiLine ? 'middle' : 'start';
140
- const box = computeTextBlockBounds(labelX, labelY, text, {
141
- fontSize,
142
- fontWeight,
143
- lineHeight: DEFAULT_LINE_HEIGHT,
144
- textAnchor: anchor,
145
- });
146
- const boxCenterX = box.x + box.width / 2;
147
- const boxCenterY = box.y + box.height / 2;
148
-
149
- // Curve connectors always start from the right edge
150
- if (connectorStyle === 'curve') {
151
- return {
152
- x: box.x + box.width,
153
- y: boxCenterY,
154
- };
155
- }
173
+ standoff = CONNECTOR_STANDOFF,
174
+ ): { x: number; y: number; exit: 'horizontal' | 'vertical' } | null {
175
+ const box = {
176
+ x: bounds.x - BOX_INFLATE,
177
+ y: bounds.y - BOX_INFLATE,
178
+ width: bounds.width + BOX_INFLATE * 2,
179
+ height: bounds.height + BOX_INFLATE * 2,
180
+ };
181
+ const cx = box.x + box.width / 2;
182
+ const cy = box.y + box.height / 2;
183
+ const halfW = box.width / 2;
184
+ const halfH = box.height / 2;
156
185
 
157
- // Normalize the vector from box center to target by the box half-dimensions.
158
- // This accounts for the box aspect ratio: a wide text box should prefer
159
- // top/bottom exits even when the target is also offset horizontally.
160
- const halfW = box.width / 2 || 1;
161
- const halfH = box.height / 2 || 1;
162
- const ndx = (targetX - boxCenterX) / halfW;
163
- const ndy = (targetY - boxCenterY) / halfH;
164
-
165
- if (Math.abs(ndy) >= Math.abs(ndx)) {
166
- // Target is more above/below than left/right → use top or bottom edge
167
- return ndy < 0
168
- ? { x: boxCenterX, y: box.y } // top
169
- : { x: boxCenterX, y: box.y + box.height }; // bottom
170
- }
171
- // Target is more left/right → use left or right edge
172
- return ndx < 0
173
- ? { x: box.x, y: boxCenterY } // left
174
- : { x: box.x + box.width, y: boxCenterY }; // right
186
+ const dx = targetX - cx;
187
+ const dy = targetY - cy;
188
+
189
+ // Target inside the inflated box: any connector would cross the text.
190
+ if (Math.abs(dx) <= halfW && Math.abs(dy) <= halfH) return null;
191
+ if (dx === 0 && dy === 0) return null;
192
+
193
+ // Parametric ray: center + t * (dx, dy). Find the smallest t that lands on an
194
+ // edge of the box, and which axis that edge belongs to.
195
+ const tx = halfW > 0 && dx !== 0 ? halfW / Math.abs(dx) : Number.POSITIVE_INFINITY;
196
+ const ty = halfH > 0 && dy !== 0 ? halfH / Math.abs(dy) : Number.POSITIVE_INFINITY;
197
+ const t = Math.min(tx, ty);
198
+ if (!Number.isFinite(t)) return null;
199
+
200
+ const exit: 'horizontal' | 'vertical' = tx <= ty ? 'horizontal' : 'vertical';
201
+
202
+ const edgeX = cx + dx * t;
203
+ const edgeY = cy + dy * t;
204
+
205
+ // Advance along the ray by the standoff distance.
206
+ const rayLen = Math.sqrt(dx * dx + dy * dy);
207
+ const ux = dx / rayLen;
208
+ const uy = dy / rayLen;
209
+
210
+ // Overshoot guard: the standoff must not push the origin past the target.
211
+ // Measure edge-to-target in pixels rather than scaling the ray by (1 - t):
212
+ // t is a ratio of box extents, so that form understates the clearance when the
213
+ // ray leaves through the long side of a wide label, and killed connectors that
214
+ // had ample room. Whether the line is long enough to be worth drawing is
215
+ // decided downstream against MIN_CONNECTOR_LENGTH, once the marker pullback is
216
+ // known — this guard only rejects a standoff that would overshoot the target.
217
+ const edgeToTarget = Math.hypot(targetX - edgeX, targetY - edgeY);
218
+ if (standoff >= edgeToTarget) return null;
219
+
220
+ const x = edgeX + ux * standoff;
221
+ const y = edgeY + uy * standoff;
222
+
223
+ return { x, y, exit };
175
224
  }
176
225
 
177
226
  /** Estimate the bounding box of an annotation label using its resolved style. */
@@ -190,36 +239,132 @@ export function estimateLabelBounds(
190
239
  fontWeight,
191
240
  lineHeight: label.style.lineHeight ?? DEFAULT_LINE_HEIGHT,
192
241
  textAnchor: label.style.textAnchor ?? 'start',
242
+ fontFamily: label.style.fontFamily,
193
243
  },
194
244
  measure,
195
245
  );
196
246
  }
197
247
 
248
+ /** Arrowhead triangle points: tip + two base corners. */
249
+ export interface ArrowheadPoints {
250
+ tip: { x: number; y: number };
251
+ baseLeft: { x: number; y: number };
252
+ baseRight: { x: number; y: number };
253
+ }
254
+
255
+ // Both live in `constants.ts` (the min-length gate has to reason about the
256
+ // arrowhead's stroke budget, and constants.ts can't import from here without a
257
+ // cycle). Re-exported so the renderer's import path doesn't move.
258
+ export { ARROWHEAD_HALF_WIDTH, ARROWHEAD_LENGTH };
259
+
198
260
  /**
199
- * Recompute the connector origin for a label after it has been repositioned.
200
- * Encapsulates the pattern of recalculating which edge of the text box the
201
- * connector should exit from based on the target data point.
261
+ * Compute arrowhead triangle geometry at a connector endpoint.
262
+ * Returns the tip (at the endpoint) and two base corners perpendicular to the
263
+ * tangent direction.
264
+ *
265
+ * @param tipX - X coordinate of the arrowhead tip
266
+ * @param tipY - Y coordinate of the arrowhead tip
267
+ * @param tangentX - X component of the tangent direction (toward the tip)
268
+ * @param tangentY - Y component of the tangent direction (toward the tip)
269
+ * @param length - Arrow length along the tangent (default ARROWHEAD_LENGTH)
270
+ * @param halfWidth - Arrow half-width perpendicular to tangent (default ARROWHEAD_HALF_WIDTH)
202
271
  */
203
- export function recomputeConnector(
204
- label: ResolvedLabel,
205
- targetX: number,
206
- targetY: number,
272
+ export function computeArrowheadPoints(
273
+ tipX: number,
274
+ tipY: number,
275
+ tangentX: number,
276
+ tangentY: number,
277
+ length = ARROWHEAD_LENGTH,
278
+ halfWidth = ARROWHEAD_HALF_WIDTH,
279
+ ): ArrowheadPoints {
280
+ const tLen = Math.sqrt(tangentX * tangentX + tangentY * tangentY) || 1;
281
+ const ux = tangentX / tLen;
282
+ const uy = tangentY / tLen;
283
+
284
+ const baseX = tipX - ux * length;
285
+ const baseY = tipY - uy * length;
286
+
287
+ const px = -uy;
288
+ const py = ux;
289
+
290
+ return {
291
+ tip: { x: tipX, y: tipY },
292
+ baseLeft: { x: baseX + px * halfWidth, y: baseY + py * halfWidth },
293
+ baseRight: { x: baseX - px * halfWidth, y: baseY - py * halfWidth },
294
+ };
295
+ }
296
+
297
+ /**
298
+ * Distance the connector's data-point end is pulled back so the line stops short
299
+ * of the endpoint marker instead of piercing it.
300
+ */
301
+ export function connectorPullbackGap(markerRadius: number, arrow: boolean): number {
302
+ return arrow ? Math.max(markerRadius + 2, 2) : Math.max(markerRadius + 3, 4);
303
+ }
304
+
305
+ /**
306
+ * Rebuild a connector's geometry against a (possibly moved) annotation bounds.
307
+ *
308
+ * `from` comes from the ray-box exit; `to` is recomputed by pulling back from
309
+ * `connector.endpoint` — never from the already-pulled-back `to`, which would
310
+ * shrink the line a little more on every pass.
311
+ *
312
+ * Returns `undefined` when the connector should be suppressed: the target sits
313
+ * inside the text block, or the resulting line is shorter than
314
+ * `MIN_CONNECTOR_LENGTH`.
315
+ */
316
+ export function refreshConnector(
317
+ connector: NonNullable<ResolvedLabel['connector']>,
318
+ bounds: Rect,
319
+ markerRadius: number,
207
320
  ): ResolvedLabel['connector'] {
208
- const connector = label.connector;
209
- if (!connector) return connector;
321
+ // `endpoint` is the un-pulled-back data point, and the only valid basis for a
322
+ // rebuild: pulling back from `to` (already pulled back) shrinks the line a
323
+ // little more on every pass. Every resolver sets it, but the field is optional
324
+ // on the public `ResolvedLabel` type, so a layout from another producer could
325
+ // omit it — bail rather than silently compound the error.
326
+ if (!connector.endpoint) return undefined;
327
+ const endpoint = connector.endpoint;
210
328
 
211
- const fontSize = label.style.fontSize ?? DEFAULT_ANNOTATION_FONT_SIZE;
212
- const fontWeight = label.style.fontWeight ?? DEFAULT_ANNOTATION_FONT_WEIGHT;
213
- const connStyle = connector.style === 'curve' ? ('curve' as const) : ('straight' as const);
214
- const newFrom = computeConnectorOrigin(
215
- label.x,
216
- label.y,
217
- label.text,
218
- fontSize,
219
- fontWeight,
220
- targetX,
221
- targetY,
222
- connStyle,
223
- );
224
- return { ...connector, from: newFrom };
329
+ // A drop-line is not a leader: it's a vertical rule pinned to the data point's
330
+ // x, running from just above the label box down to the marker. Rebuilding it
331
+ // from a ray-box exit tilts it into a diagonal (which the renderer draws with
332
+ // shape-rendering: crispEdges, assuming axis alignment), and the
333
+ // MIN_CONNECTOR_LENGTH rule below would delete it outright once the label sits
334
+ // near its point.
335
+ //
336
+ // This mirrors `resolveDropLineAnnotation`'s formula exactly — `from` above the
337
+ // label top, `to` pulled back from the point — so a nudged drop-line keeps the
338
+ // same geometry it resolved with, just at the label's new position.
339
+ if (connector.style === 'drop-line') {
340
+ const gap = connectorPullbackGap(markerRadius, false);
341
+ return {
342
+ ...connector,
343
+ from: { x: endpoint.x, y: bounds.y - DROP_LINE_TOP_GAP },
344
+ to: { x: endpoint.x, y: endpoint.y - gap },
345
+ exit: 'vertical',
346
+ };
347
+ }
348
+
349
+ const exit = connectorExit(bounds, endpoint.x, endpoint.y);
350
+ if (!exit) return undefined;
351
+
352
+ const from = { x: exit.x, y: exit.y };
353
+
354
+ const gap = connectorPullbackGap(markerRadius, connector.arrow);
355
+ const cdx = endpoint.x - from.x;
356
+ const cdy = endpoint.y - from.y;
357
+ const dist = Math.sqrt(cdx * cdx + cdy * cdy);
358
+ const to =
359
+ dist > 0
360
+ ? { x: endpoint.x - (cdx / dist) * gap, y: endpoint.y - (cdy / dist) * gap }
361
+ : { ...endpoint };
362
+
363
+ // Min-length check runs AFTER the pullback: a near-degenerate line can flip
364
+ // direction once its end is pulled back, and checking first would miss it.
365
+ const lx = to.x - from.x;
366
+ const ly = to.y - from.y;
367
+ if (!connectorIsDrawable(Math.sqrt(lx * lx + ly * ly), connector.arrow)) return undefined;
368
+
369
+ return { ...connector, from, to, exit: exit.exit };
225
370
  }
@@ -0,0 +1,135 @@
1
+ /**
2
+ * The single owner of "where a resolved annotation sits".
3
+ *
4
+ * WHY THIS MODULE EXISTS
5
+ *
6
+ * An annotation's position is not one number, it's five that have to agree: the
7
+ * label origin, the subtitle origin (absolute, not relative), the label-only
8
+ * bounds, the label-union-subtitle block bounds, and the connector endpoints. The
9
+ * resolver establishes that agreement -- and then FOUR later passes re-position
10
+ * the block (obstacle nudge, mutual-collision nudge, clamp-to-bounds, and the
11
+ * auto-placement search). Every one of them has to re-establish all five.
12
+ *
13
+ * When two of those passes each did their own arithmetic, the two copies drifted:
14
+ * one spelled the subtitle gap `SUBTITLE_GAP`, the other spelled it `+ 2`, and a
15
+ * subtitled auto-placed callout rendered its second line 3px off from where the
16
+ * search had scored it. That is the bug this module exists to make unrepresentable.
17
+ *
18
+ * So: nothing outside this file writes annotation geometry. `moveAnnotationTo`
19
+ * (absolute) and `moveAnnotationBy` (delta) are the only doors, and they always
20
+ * leave all five in agreement.
21
+ */
22
+
23
+ import type { Rect, ResolvedAnnotation, ResolvedLabel } from '@opendata-ai/openchart-core';
24
+ import {
25
+ DEFAULT_ANNOTATION_FONT_SIZE,
26
+ DEFAULT_LINE_HEIGHT,
27
+ SUBTITLE_FONT_WEIGHT,
28
+ subtitleFontSize,
29
+ } from './constants';
30
+ import {
31
+ type AnnotationMeasureTextFn,
32
+ computeTextBlockBounds,
33
+ estimateLabelBounds,
34
+ refreshConnector,
35
+ unionRects,
36
+ } from './geometry';
37
+ import { markerClearance } from './resolve-text';
38
+
39
+ /**
40
+ * The full block an annotation occupies: label text plus its subtitle.
41
+ *
42
+ * This is the box other annotations must avoid, and the box the connector exits.
43
+ * It is NOT `label.bounds` -- that one is label-only by contract, because the
44
+ * renderer sizes the `background` plate from it and a subtitled annotation must
45
+ * not get a plate sized to both lines.
46
+ */
47
+ export function annotationBlockBounds(
48
+ annotation: ResolvedAnnotation,
49
+ label: ResolvedLabel,
50
+ measure: AnnotationMeasureTextFn,
51
+ ): Rect {
52
+ const labelBounds = estimateLabelBounds(label, measure);
53
+ const sub = annotation.subtitle;
54
+ if (!sub) return labelBounds;
55
+
56
+ const subBounds = computeTextBlockBounds(
57
+ sub.x,
58
+ sub.y,
59
+ sub.text,
60
+ {
61
+ // Every resolver stamps all three, so these fallbacks are unreachable today.
62
+ // They still come from the constants: a hand-rolled `?? 10` here is a second
63
+ // definition of the subtitle's type, free to drift from the real one.
64
+ fontSize: sub.style.fontSize ?? subtitleFontSize(DEFAULT_ANNOTATION_FONT_SIZE),
65
+ fontWeight: Number(sub.style.fontWeight) || SUBTITLE_FONT_WEIGHT,
66
+ lineHeight: sub.style.lineHeight ?? DEFAULT_LINE_HEIGHT,
67
+ textAnchor: sub.style.textAnchor ?? 'start',
68
+ },
69
+ measure,
70
+ );
71
+ return unionRects(labelBounds, subBounds);
72
+ }
73
+
74
+ /**
75
+ * Put a resolved annotation's block at an absolute origin, keeping every piece of
76
+ * its geometry in agreement.
77
+ *
78
+ * The subtitle is TRANSLATED with the label rather than re-derived from a font-metric
79
+ * sum: the resolver already placed it correctly relative to the label, and a second
80
+ * derivation here is a second chance to disagree with the first.
81
+ *
82
+ * `textAnchor` is optional because only the auto-placement search chooses a side;
83
+ * the nudge passes slide a block that has already picked one.
84
+ */
85
+ export function moveAnnotationTo(
86
+ annotation: ResolvedAnnotation,
87
+ label: ResolvedLabel,
88
+ x: number,
89
+ y: number,
90
+ measure: AnnotationMeasureTextFn,
91
+ textAnchor?: 'start' | 'middle' | 'end',
92
+ ): void {
93
+ moveAnnotationBy(annotation, label, x - label.x, y - label.y, measure, textAnchor);
94
+ }
95
+
96
+ /**
97
+ * Slide a resolved annotation's block by (dx, dy), keeping every piece of its
98
+ * geometry in agreement. See `moveAnnotationTo`.
99
+ */
100
+ export function moveAnnotationBy(
101
+ annotation: ResolvedAnnotation,
102
+ label: ResolvedLabel,
103
+ dx: number,
104
+ dy: number,
105
+ measure: AnnotationMeasureTextFn,
106
+ textAnchor?: 'start' | 'middle' | 'end',
107
+ ): void {
108
+ const movedLabel: ResolvedLabel = { ...label, x: label.x + dx, y: label.y + dy };
109
+ if (textAnchor) {
110
+ movedLabel.style = { ...movedLabel.style, textAnchor };
111
+ }
112
+
113
+ if (annotation.subtitle) {
114
+ annotation.subtitle = {
115
+ ...annotation.subtitle,
116
+ x: annotation.subtitle.x + dx,
117
+ y: annotation.subtitle.y + dy,
118
+ ...(textAnchor ? { style: { ...annotation.subtitle.style, textAnchor } } : {}),
119
+ };
120
+ }
121
+
122
+ movedLabel.bounds = estimateLabelBounds(movedLabel, measure);
123
+ const blockBounds = annotationBlockBounds(annotation, movedLabel, measure);
124
+
125
+ if (movedLabel.connector) {
126
+ movedLabel.connector = refreshConnector(
127
+ movedLabel.connector,
128
+ blockBounds,
129
+ markerClearance(annotation.dot),
130
+ );
131
+ }
132
+
133
+ annotation.label = movedLabel;
134
+ annotation.bounds = blockBounds;
135
+ }
@@ -4,6 +4,7 @@
4
4
  */
5
5
 
6
6
  import { overlapArea, type Rect } from '@opendata-ai/openchart-core';
7
+ import { ANCHOR_OFFSET, subtitleBaselineY } from './constants';
7
8
  import {
8
9
  type AnnotationMeasureTextFn,
9
10
  computeTextBlockBounds,
@@ -240,7 +241,7 @@ const CLEAN_THRESHOLD = EPSILON * 16;
240
241
  interface DirectionDef {
241
242
  ux: number;
242
243
  uy: number;
243
- textAnchor: 'start' | 'middle' | 'end';
244
+ textAnchor: 'start' | 'end';
244
245
  attach:
245
246
  | 'bottom-center'
246
247
  | 'bottom-left'
@@ -255,11 +256,11 @@ interface DirectionDef {
255
256
  const D = Math.SQRT1_2;
256
257
 
257
258
  const DIRECTIONS: DirectionDef[] = [
258
- { ux: 0, uy: -1, textAnchor: 'middle', attach: 'bottom-center' }, // N
259
+ { ux: 0, uy: -1, textAnchor: 'start', attach: 'bottom-center' }, // N
259
260
  { ux: D, uy: -D, textAnchor: 'start', attach: 'bottom-left' }, // NE
260
261
  { ux: 1, uy: 0, textAnchor: 'start', attach: 'left-middle' }, // E
261
262
  { ux: D, uy: D, textAnchor: 'start', attach: 'top-left' }, // SE
262
- { ux: 0, uy: 1, textAnchor: 'middle', attach: 'top-center' }, // S
263
+ { ux: 0, uy: 1, textAnchor: 'start', attach: 'top-center' }, // S
263
264
  { ux: -D, uy: D, textAnchor: 'end', attach: 'top-right' }, // SW
264
265
  { ux: -1, uy: 0, textAnchor: 'end', attach: 'right-middle' }, // W
265
266
  { ux: -D, uy: -D, textAnchor: 'end', attach: 'bottom-right' }, // NW
@@ -272,7 +273,7 @@ const UPPER_HALF_ORDER = [3, 4, 5, 2, 1, 0, 7, 6]; // SE, S, SW, E, NE, N, NW, W
272
273
  export interface PlacementCandidate {
273
274
  labelX: number;
274
275
  labelY: number;
275
- textAnchor: 'start' | 'middle' | 'end';
276
+ textAnchor: 'start' | 'end';
276
277
  box: Rect;
277
278
  directionIndex: number;
278
279
  ring: 1 | 2 | 3;
@@ -309,11 +310,20 @@ function labelPositionFromAttachment(
309
310
  dir: DirectionDef,
310
311
  boxHeight: number,
311
312
  fontSize: number,
313
+ boxWidth: number,
312
314
  ): { labelX: number; labelY: number } {
313
- const labelX = attachPt.x;
314
- let labelY: number;
315
-
316
315
  const attach = dir.attach;
316
+
317
+ // A `*-center` attach means the block straddles the point, so `labelX` (the
318
+ // text's start edge, since these directions render textAnchor 'start') has to
319
+ // back off by half the block width. Centering the BLOCK is not the same as
320
+ // center-aligning the TEXT: the lines inside stay left-aligned and ragged
321
+ // right, per the reference voice. Without this the point sits at the block's
322
+ // left edge and a "top" anchor reads as "up and to the right".
323
+ const centered = attach === 'top-center' || attach === 'bottom-center';
324
+ const labelX = centered ? attachPt.x - boxWidth / 2 : attachPt.x;
325
+
326
+ let labelY: number;
317
327
  if (attach === 'left-middle' || attach === 'right-middle') {
318
328
  labelY = attachPt.y - boxHeight / 2 + fontSize;
319
329
  } else if (attach === 'top-left' || attach === 'top-center' || attach === 'top-right') {
@@ -414,7 +424,7 @@ function scoreCandidate(
414
424
  export interface PlacementResult {
415
425
  labelX: number;
416
426
  labelY: number;
417
- textAnchor: 'start' | 'middle' | 'end';
427
+ textAnchor: 'start' | 'end';
418
428
  bounds: Rect;
419
429
  score: number;
420
430
  debug?: CandidateScoreBreakdown[];
@@ -428,9 +438,11 @@ export function findBestPlacement(
428
438
  anchorX: number,
429
439
  anchorY: number,
430
440
  text: string,
431
- style: { fontSize: number; fontWeight: number; lineHeight: number },
441
+ style: { fontSize: number; fontWeight: number; lineHeight: number; fontFamily?: string },
432
442
  subtitleText: string | undefined,
433
- subtitleStyle: { fontSize: number; fontWeight: number; lineHeight: number } | undefined,
443
+ subtitleStyle:
444
+ | { fontSize: number; fontWeight: number; lineHeight: number; fontFamily?: string }
445
+ | undefined,
434
446
  obstacles: Rect[],
435
447
  chartArea: Rect,
436
448
  svgRect: Rect,
@@ -454,7 +466,29 @@ export function findBestPlacement(
454
466
  );
455
467
  const boxHeight = sampleBounds.height;
456
468
 
457
- const r1 = 12;
469
+ // Centering a `*-center` attach needs the width of the WHOLE block, not just
470
+ // the primary line: a subtitle is often the wider of the two, and centering on
471
+ // the primary width alone would leave a lede+subtitle stack visibly off-center
472
+ // over its point.
473
+ const subtitleSampleWidth =
474
+ subtitleText && subtitleStyle
475
+ ? computeTextBlockBounds(
476
+ 0,
477
+ subtitleStyle.fontSize,
478
+ subtitleText,
479
+ { ...subtitleStyle, textAnchor: 'start' },
480
+ measure,
481
+ ).width
482
+ : 0;
483
+ const boxWidth = Math.max(sampleBounds.width, subtitleSampleWidth);
484
+
485
+ // The innermost ring is the default setback for an auto-placed label, so it's
486
+ // the same quantity ANCHOR_OFFSET expresses for the explicit path — keep them
487
+ // as one constant. A 12px ring sat inside the standoff + marker-pullback
488
+ // overhead, so an auto-placed label's connector was always shorter than
489
+ // MIN_CONNECTOR_LENGTH and got suppressed: six bare callouts rendered as
490
+ // floating text with no leader back to the data.
491
+ const r1 = ANCHOR_OFFSET;
458
492
  const r2 = r1 + boxHeight + 8;
459
493
  const r3 = 2 * r2;
460
494
 
@@ -473,6 +507,7 @@ export function findBestPlacement(
473
507
  dir,
474
508
  boxHeight,
475
509
  style.fontSize,
510
+ boxWidth,
476
511
  );
477
512
 
478
513
  let box = computeTextBlockBounds(
@@ -488,8 +523,15 @@ export function findBestPlacement(
488
523
 
489
524
  // Union with subtitle bounds if present
490
525
  if (subtitleText && subtitleStyle) {
491
- const primaryLineCount = text.split('\n').length;
492
- const subtitleY = labelY + style.fontSize * style.lineHeight * primaryLineCount + 2;
526
+ // This pass SCORES a candidate subtitle position; the resolver STAMPS the
527
+ // one that renders. They must agree, or the scored block height is a lie
528
+ // and the bounds other annotations avoid no longer contain the subtitle.
529
+ // Hence the shared formula rather than a second copy of the sum.
530
+ const subtitleY = subtitleBaselineY(
531
+ labelY,
532
+ style.fontSize * style.lineHeight,
533
+ text.split('\n').length,
534
+ );
493
535
  const subBounds = computeTextBlockBounds(
494
536
  labelX,
495
537
  subtitleY,
@@ -579,10 +621,16 @@ export function findBestPlacement(
579
621
  export function isAutoPlacement(annotation: {
580
622
  anchor?: string;
581
623
  offset?: { dx?: number; dy?: number };
582
- connector?: string | boolean;
624
+ connector?: string | boolean | { type?: string };
583
625
  }): boolean {
584
626
  if (annotation.offset) return false;
585
627
  if (annotation.anchor && annotation.anchor !== 'auto') return false;
586
628
  if (annotation.connector === 'drop-line') return false;
629
+ if (
630
+ typeof annotation.connector === 'object' &&
631
+ annotation.connector !== null &&
632
+ annotation.connector.type === 'drop-line'
633
+ )
634
+ return false;
587
635
  return true;
588
636
  }
@@ -20,6 +20,7 @@ export function resolveRangeAnnotation(
20
20
  scales: ResolvedScales,
21
21
  chartArea: Rect,
22
22
  isDark: boolean,
23
+ fontFamily?: string,
23
24
  ): ResolvedAnnotation | null {
24
25
  let x = chartArea.x;
25
26
  let y = chartArea.y;
@@ -79,6 +80,7 @@ export function resolveRangeAnnotation(
79
80
  annotation.fontWeight ?? 500,
80
81
  undefined,
81
82
  isDark,
83
+ fontFamily,
82
84
  );
83
85
  if (centered) {
84
86
  style.textAnchor = 'middle';