@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
@@ -24,9 +24,10 @@ import {
24
24
  nudgeAnnotationFromObstacles,
25
25
  resolveAnnotationCollisions,
26
26
  } from './collisions';
27
- import { DEFAULT_ANNOTATION_FONT_WEIGHT, SUBTITLE_FONT_SIZE_RATIO } from './constants';
27
+ import { SUBTITLE_FONT_WEIGHT, subtitleFontSize } from './constants';
28
28
  import type { AnnotationMeasureTextFn } from './geometry';
29
29
  import { heuristicMeasure } from './geometry';
30
+ import { moveAnnotationTo } from './move';
30
31
  import {
31
32
  findBestPlacement,
32
33
  isAutoPlacement,
@@ -36,7 +37,11 @@ import {
36
37
  import { resolvePosition } from './position';
37
38
  import { resolveRangeAnnotation } from './resolve-range';
38
39
  import { resolveRefLineAnnotation } from './resolve-refline';
39
- import { makeAnnotationLabelStyle, resolveTextAnnotation } from './resolve-text';
40
+ import {
41
+ makeAnnotationLabelStyle,
42
+ resolveLedeFontWeight,
43
+ resolveTextAnnotation,
44
+ } from './resolve-text';
40
45
 
41
46
  export interface AnnotationContext {
42
47
  scales: ResolvedScales;
@@ -46,6 +51,8 @@ export interface AnnotationContext {
46
51
  obstacles: PlacementObstacle[];
47
52
  svg: { width: number; height: number };
48
53
  measure: AnnotationMeasureTextFn;
54
+ /** Theme font stack, so annotation text is measured and rendered in the same face. */
55
+ fontFamily?: string;
49
56
  debugPlacement?: boolean;
50
57
  /** When true, annotations that overlap are demoted to footnote markers instead of being hidden. */
51
58
  autoThin?: boolean;
@@ -122,7 +129,7 @@ export function computeAnnotations(
122
129
  }> = [];
123
130
 
124
131
  // ---- Pass 1: resolve explicit annotations and queue auto ones ----
125
- for (const annotation of spec.annotations) {
132
+ for (const [specIndex, annotation] of spec.annotations.entries()) {
126
133
  if (isCompact && annotation.responsive !== false) {
127
134
  continue;
128
135
  }
@@ -136,6 +143,7 @@ export function computeAnnotations(
136
143
  ctx.chartArea,
137
144
  ctx.isDark,
138
145
  ctx.measure,
146
+ ctx.fontFamily,
139
147
  );
140
148
  if (resolved && ctx.svg.width > 0 && ctx.svg.height > 0 && isAutoPlacement(annotation)) {
141
149
  const px = resolvePosition(annotation.x, ctx.scales.x);
@@ -148,16 +156,30 @@ export function computeAnnotations(
148
156
  resolved,
149
157
  index: annotations.length,
150
158
  });
159
+ // Same contract as the push below -- this branch `continue`s past it.
160
+ resolved.specIndex = specIndex;
151
161
  annotations.push(resolved);
152
162
  continue;
153
163
  }
154
164
  }
155
165
  break;
156
166
  case 'range':
157
- resolved = resolveRangeAnnotation(annotation, ctx.scales, ctx.chartArea, ctx.isDark);
167
+ resolved = resolveRangeAnnotation(
168
+ annotation,
169
+ ctx.scales,
170
+ ctx.chartArea,
171
+ ctx.isDark,
172
+ ctx.fontFamily,
173
+ );
158
174
  break;
159
175
  case 'refline':
160
- resolved = resolveRefLineAnnotation(annotation, ctx.scales, ctx.chartArea, ctx.isDark);
176
+ resolved = resolveRefLineAnnotation(
177
+ annotation,
178
+ ctx.scales,
179
+ ctx.chartArea,
180
+ ctx.isDark,
181
+ ctx.fontFamily,
182
+ );
161
183
  break;
162
184
  }
163
185
 
@@ -172,6 +194,10 @@ export function computeAnnotations(
172
194
  ctx.measure,
173
195
  );
174
196
  }
197
+ // The resolved array is filtered (an out-of-domain annotation resolves to
198
+ // nothing), so carry the spec index along -- it's the only stable way back
199
+ // to the authored annotation from here on.
200
+ resolved.specIndex = specIndex;
175
201
  annotations.push(resolved);
176
202
  }
177
203
  }
@@ -189,18 +215,23 @@ export function computeAnnotations(
189
215
 
190
216
  for (const entry of autoQueue) {
191
217
  const { annotation, anchorX, anchorY, resolved } = entry;
218
+ // Must mirror resolveTextAnnotation exactly (lede weight, theme font):
219
+ // findBestPlacement measures with this style, and the resolved annotation
220
+ // renders with the other one. Any drift and the scored bounds are a lie.
192
221
  const labelStyle = makeAnnotationLabelStyle(
193
222
  annotation.fontSize,
194
- annotation.fontWeight,
223
+ resolveLedeFontWeight(annotation),
195
224
  undefined,
196
225
  ctx.isDark,
226
+ ctx.fontFamily,
197
227
  );
198
228
 
199
229
  const subtitleStyle = annotation.subtitle
200
230
  ? {
201
- fontSize: labelStyle.fontSize * SUBTITLE_FONT_SIZE_RATIO,
202
- fontWeight: DEFAULT_ANNOTATION_FONT_WEIGHT,
231
+ fontSize: subtitleFontSize(labelStyle.fontSize),
232
+ fontWeight: SUBTITLE_FONT_WEIGHT,
203
233
  lineHeight: labelStyle.lineHeight,
234
+ fontFamily: labelStyle.fontFamily,
204
235
  }
205
236
  : undefined;
206
237
 
@@ -212,6 +243,7 @@ export function computeAnnotations(
212
243
  fontSize: labelStyle.fontSize,
213
244
  fontWeight: Number(labelStyle.fontWeight) || 400,
214
245
  lineHeight: labelStyle.lineHeight,
246
+ fontFamily: labelStyle.fontFamily,
215
247
  },
216
248
  annotation.subtitle,
217
249
  subtitleStyle,
@@ -222,46 +254,29 @@ export function computeAnnotations(
222
254
  ctx.debugPlacement,
223
255
  );
224
256
 
225
- // Apply placement result to the resolved annotation
257
+ // Commit the placement. `moveAnnotationTo` owns every piece of the block's
258
+ // geometry -- label, subtitle, both bounds caches, connector -- so this pass
259
+ // does not get to invent its own copy of any of them. It used to, and the
260
+ // copies drifted: this one re-derived the subtitle's baseline from a font-metric
261
+ // sum spelled `+ 2` while the resolver spelled it `SUBTITLE_GAP`, so a subtitled
262
+ // callout rendered 3px off from where the search had actually scored it.
226
263
  if (resolved.label) {
227
- resolved.label.x = result.labelX;
228
- resolved.label.y = result.labelY;
229
- resolved.label.style = {
230
- ...resolved.label.style,
231
- textAnchor: result.textAnchor,
232
- };
233
- resolved.label.bounds = result.bounds;
234
-
235
- if (resolved.label.connector) {
236
- const box = result.bounds;
237
- const cx = box.x + box.width / 2;
238
- const cy = box.y + box.height / 2;
239
- const isCurve = resolved.label.connector.style === 'curve';
240
- if (isCurve) {
241
- resolved.label.connector.from = { x: box.x + box.width, y: cy };
242
- } else {
243
- const halfW = box.width / 2 || 1;
244
- const halfH = box.height / 2 || 1;
245
- const ndx = (anchorX - cx) / halfW;
246
- const ndy = (anchorY - cy) / halfH;
247
- if (Math.abs(ndy) >= Math.abs(ndx)) {
248
- resolved.label.connector.from = {
249
- x: cx,
250
- y: ndy < 0 ? box.y : box.y + box.height,
251
- };
252
- } else {
253
- resolved.label.connector.from = {
254
- x: ndx < 0 ? box.x : box.x + box.width,
255
- y: cy,
256
- };
257
- }
258
- }
259
- }
264
+ moveAnnotationTo(
265
+ resolved,
266
+ resolved.label,
267
+ result.labelX,
268
+ result.labelY,
269
+ ctx.measure,
270
+ result.textAnchor,
271
+ );
260
272
  }
261
- resolved.bounds = result.bounds;
262
273
 
263
- // Add this annotation's bounds as obstacle for subsequent auto annotations
264
- workingObstacles.push({ ...result.bounds, kind: 'annotation' } as PlacementObstacle);
274
+ // Add this annotation's bounds as obstacle for subsequent auto annotations.
275
+ // Read it back off the annotation rather than reusing `result.bounds`: the
276
+ // mover is the authority on the block's extent, and `result.bounds` is the
277
+ // search's own estimate of it. One of them has to be the source of truth.
278
+ const placedBounds = resolved.bounds ?? result.bounds;
279
+ workingObstacles.push({ ...placedBounds, kind: 'annotation' } as PlacementObstacle);
265
280
  }
266
281
  } else if (autoQueue.length > 0) {
267
282
  // Fallback: no SVG dimensions, use legacy nudge for auto annotations
@@ -282,46 +297,29 @@ export function computeAnnotations(
282
297
  // Resolve annotation-to-annotation collisions for non-auto-placed annotations.
283
298
  // When auto placement ran, those annotations already avoid each other via the
284
299
  // obstacle-accumulation loop above, so only explicitly placed ones need this.
285
- if (autoQueue.length > 0) {
286
- // Build aligned pairs of explicit annotations + their specs
287
- const autoIndices = new Set(autoQueue.map((q) => q.index));
288
- const explicitAnnotations: ResolvedAnnotation[] = [];
289
- const explicitSpecs: NormalizedChartSpec['annotations'] = [];
290
- let specIdx = 0;
291
- for (let i = 0; i < annotations.length; i++) {
292
- // Walk specIdx past any compact-skipped specs
293
- while (specIdx < spec.annotations.length) {
294
- const s = spec.annotations[specIdx];
295
- if (!isCompact || s.responsive === false) break;
296
- specIdx++;
297
- }
298
- if (specIdx >= spec.annotations.length) break;
299
- if (autoIndices.has(i)) {
300
- specIdx++;
301
- continue;
302
- }
303
- explicitAnnotations.push(annotations[i]);
304
- explicitSpecs.push(spec.annotations[specIdx]);
305
- specIdx++;
306
- }
307
- if (explicitAnnotations.length > 1) {
308
- resolveAnnotationCollisions(
309
- explicitAnnotations,
310
- explicitSpecs,
311
- ctx.scales,
312
- ctx.chartArea,
313
- ctx.measure,
314
- );
315
- }
316
- } else {
317
- resolveAnnotationCollisions(
318
- annotations,
319
- spec.annotations,
320
- ctx.scales,
321
- ctx.chartArea,
322
- ctx.measure,
323
- );
324
- }
300
+ //
301
+ // There used to be a hand-rolled walk here that built a *compacted* pair of
302
+ // arrays (explicit annotations + their specs) and passed those. It was the
303
+ // second representation of the same list, and once `resolveAnnotationCollisions`
304
+ // started keying on the stamped `specIndex` — a GLOBAL index — the compacted
305
+ // spec array no longer matched it. Every lookup was off by the number of auto
306
+ // annotations, and the `type === 'text'` guard swallowed the resulting
307
+ // `undefined`, so collision avoidance silently did nothing the moment a single
308
+ // auto-placed annotation was present. Which is the *encouraged* authoring style:
309
+ // a bare `{ type: 'text' }` is auto-placed by definition.
310
+ //
311
+ // So don't rebuild the list. Pass the real one, and let the pass skip the
312
+ // auto-placed entries itself — `specIndex` already tells it which spec each
313
+ // resolved annotation came from.
314
+ const autoIndices = new Set(autoQueue.map((q) => q.index));
315
+ resolveAnnotationCollisions(
316
+ annotations,
317
+ spec.annotations,
318
+ ctx.scales,
319
+ ctx.chartArea,
320
+ ctx.measure,
321
+ autoQueue.length > 0 ? (i) => autoIndices.has(i) : undefined,
322
+ );
325
323
 
326
324
  // Clamp labels that overflow the SVG boundary back inside
327
325
  if (ctx.svg.width > 0 && ctx.svg.height > 0) {
@@ -1,9 +1,24 @@
1
1
  /** Default font size for annotation labels. */
2
- export const DEFAULT_ANNOTATION_FONT_SIZE = 12;
2
+ export const DEFAULT_ANNOTATION_FONT_SIZE = 13;
3
3
 
4
- /** Default font weight for annotation labels. */
4
+ /**
5
+ * Default font weight for annotation labels. Regular, not bold: emphasis comes
6
+ * from inline `**bold**` spans, per the NYT/Datawrapper voice.
7
+ */
5
8
  export const DEFAULT_ANNOTATION_FONT_WEIGHT = 400;
6
9
 
10
+ /**
11
+ * Weight the primary text takes when a subtitle is present and the author set no
12
+ * explicit `fontWeight` — the "Feb. 25 / 2015 maximum" lede + context stack.
13
+ */
14
+ export const LEDE_FONT_WEIGHT = 700;
15
+
16
+ /** Subtitles are always regular, so they never inherit a lede weight. */
17
+ export const SUBTITLE_FONT_WEIGHT = 400;
18
+
19
+ /** Font stack used when the theme font isn't threaded through (SSR/legacy paths). */
20
+ export const FALLBACK_FONT_FAMILY = 'Inter, system-ui, sans-serif';
21
+
7
22
  /** Default line height multiplier for annotation text. */
8
23
  export const DEFAULT_LINE_HEIGHT = 1.3;
9
24
 
@@ -22,6 +37,12 @@ export const DARK_TEXT_FILL = '#d1d5db';
22
37
  export const LIGHT_REFLINE_STROKE = '#888888';
23
38
  export const DARK_REFLINE_STROKE = '#9ca3af';
24
39
 
40
+ // Quiet connector voice: gray hairline leaders (non-arrowed straight, drop-line).
41
+ // Arrowed connectors take the label's text ink instead, so the emphasis callout
42
+ // reads as one gesture with the words.
43
+ export const LIGHT_CONNECTOR_STROKE = '#8f8f8f';
44
+ export const DARK_CONNECTOR_STROKE = '#9aa0a6';
45
+
25
46
  // Muted text fill for annotation subtitles (~60% perceived contrast vs primary)
26
47
  export const LIGHT_MUTED_TEXT_FILL = '#6b7280';
27
48
  export const DARK_MUTED_TEXT_FILL = '#9ca3af';
@@ -30,11 +51,17 @@ export const DARK_MUTED_TEXT_FILL = '#9ca3af';
30
51
  export const LIGHT_DOT_FILL = '#ffffff';
31
52
  export const DARK_DOT_FILL = '#0a0a0a';
32
53
 
54
+ // Surface fills for the text-annotation background plate (the mask that hides
55
+ // chart lines behind label text). `background: true` resolves to these, so
56
+ // authors don't hardcode '#ffffff' and get light-gray-on-white in dark mode.
57
+ export const LIGHT_LABEL_BACKGROUND = '#ffffff';
58
+ export const DARK_LABEL_BACKGROUND = '#0a0a0a';
59
+
33
60
  /** Default annotation dot radius in pixels. */
34
- export const DEFAULT_DOT_RADIUS = 5;
61
+ export const DEFAULT_DOT_RADIUS = 4;
35
62
 
36
63
  /** Default annotation dot stroke width in pixels. */
37
- export const DEFAULT_DOT_STROKE_WIDTH = 2;
64
+ export const DEFAULT_DOT_STROKE_WIDTH = 1.5;
38
65
 
39
66
  /** Vertical gap (px) between the primary annotation text and its subtitle. */
40
67
  export const SUBTITLE_GAP = 2;
@@ -42,8 +69,91 @@ export const SUBTITLE_GAP = 2;
42
69
  /** Subtitle font size multiplier (relative to primary annotation font size). */
43
70
  export const SUBTITLE_FONT_SIZE_RATIO = 0.85;
44
71
 
45
- /** Default label offset when using anchor directions. */
46
- export const ANCHOR_OFFSET = 8;
72
+ /**
73
+ * The subtitle's resolved font size. Rounded, and shared by every caller: the
74
+ * auto-placement pass measures the subtitle to score candidate bounds while the
75
+ * resolver sets the size that actually renders. If one rounds and the other
76
+ * doesn't (13 * 0.85 = 11.05 vs 11), the scored bounds are a lie and the drift
77
+ * flows into collisions and thinning.
78
+ */
79
+ export function subtitleFontSize(primaryFontSize: number): number {
80
+ return Math.round(primaryFontSize * SUBTITLE_FONT_SIZE_RATIO);
81
+ }
82
+
83
+ /**
84
+ * The subtitle's baseline, given the label's. Same contract as `subtitleFontSize`,
85
+ * and shared for the same reason: the auto-placement pass SCORES a candidate
86
+ * subtitle position and the resolver STAMPS the one that renders. Two copies of
87
+ * this sum is two chances to disagree -- and they did, one spelling the gap
88
+ * `SUBTITLE_GAP` and the other a bare `+ 2`.
89
+ *
90
+ * `primaryLineHeightPx` is the line's height in PIXELS, not the ratio -- so a caller
91
+ * holding a ratio multiplies by the font size on the way in. Taking the two factors
92
+ * separately meant the one caller that already had the product had to pass a magic
93
+ * `1` for the font size, and a signature you have to outsmart is a signature that
94
+ * will eventually be outsmarted wrong.
95
+ */
96
+ export function subtitleBaselineY(
97
+ labelY: number,
98
+ primaryLineHeightPx: number,
99
+ primaryLineCount: number,
100
+ ): number {
101
+ return labelY + primaryLineHeightPx * primaryLineCount + SUBTITLE_GAP;
102
+ }
103
+
104
+ /** Gap (px) between the label bounding box and the start of its connector. */
105
+ export const CONNECTOR_STANDOFF = 6;
106
+
107
+ /**
108
+ * Connectors shorter than this are suppressed: a nub between a label and the
109
+ * marker it's already touching reads as noise, not as a leader.
110
+ *
111
+ * Keep this well under the standoff + marker-pullback overhead (~18px), which
112
+ * is spent before the line is drawn at all. Setting it near or above that
113
+ * overhead makes the default annotation structurally unable to draw a
114
+ * connector, however far the author offsets the label.
115
+ */
116
+ export const MIN_CONNECTOR_LENGTH = 8;
117
+
118
+ /**
119
+ * Arrowhead length along the tangent. The renderer pulls the connector's line end
120
+ * back by exactly this much so the stroke stops at the open V instead of poking
121
+ * through its tip — a hand-copied duplicate on the vanilla side would silently
122
+ * drift the next time this changes.
123
+ */
124
+ export const ARROWHEAD_LENGTH = 7;
125
+
126
+ /** Arrowhead half-width perpendicular to the tangent. */
127
+ export const ARROWHEAD_HALF_WIDTH = 3.5;
128
+
129
+ /**
130
+ * Is a connector of this length worth drawing?
131
+ *
132
+ * An arrowed connector doesn't stroke its full length: the renderer stops the line
133
+ * `ARROWHEAD_LENGTH` short and spends that budget on the head. So a bare
134
+ * `length >= MIN_CONNECTOR_LENGTH` test is arrow-blind — an 8.1px arrowed
135
+ * connector clears it, and then ships a 1.1px stub with an arrowhead stuck on the
136
+ * end. The minimum has to be measured against the part that actually gets stroked,
137
+ * which means the gate has to know about the head.
138
+ *
139
+ * Both suppression sites (`resolveTextAnnotation`, `refreshConnector`) call this
140
+ * instead of comparing against the constant themselves.
141
+ */
142
+ export function connectorIsDrawable(length: number, arrow: boolean | undefined): boolean {
143
+ return length - (arrow ? ARROWHEAD_LENGTH : 0) >= MIN_CONNECTOR_LENGTH;
144
+ }
145
+
146
+ /**
147
+ * Default label setback from the data point when using anchor directions.
148
+ *
149
+ * Sized so a bare `{ type: 'text' }` annotation lands clear of its marker and
150
+ * draws a real leader with no offset authoring — a minimal spec should render
151
+ * publication-ready. Authors tighten it with `offset`.
152
+ */
153
+ export const ANCHOR_OFFSET = 28;
154
+
155
+ /** Vertical gap between the top of a drop-line and the top of its label box. */
156
+ export const DROP_LINE_TOP_GAP = 4;
47
157
 
48
158
  /** Padding between annotation and obstacle when nudging. */
49
159
  export const NUDGE_PADDING = 6;