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

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 (160) hide show
  1. package/dist/index.d.ts +124 -14
  2. package/dist/index.js +16254 -9417
  3. package/dist/index.js.map +1 -1
  4. package/package.json +11 -4
  5. package/src/__tests__/__snapshots__/compile-snapshot.test.ts.snap +232 -56
  6. package/src/__tests__/annotation-behavior.test.ts +852 -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__/facet.test.ts +144 -0
  12. package/src/__tests__/helpers/invariants.ts +54 -20
  13. package/src/__tests__/known-bugs.test.ts +455 -2
  14. package/src/__tests__/legend.test.ts +69 -4
  15. package/src/__tests__/ranking-recipes.test.ts +260 -0
  16. package/src/__tests__/scales.test.ts +9 -3
  17. package/src/__tests__/series-search.test.ts +92 -0
  18. package/src/__tests__/spec-sugar.test.ts +801 -0
  19. package/src/__tests__/you-draw-it.test.ts +160 -0
  20. package/src/annotations/__tests__/compute.test.ts +207 -92
  21. package/src/annotations/__tests__/geometry.test.ts +66 -0
  22. package/src/annotations/__tests__/resolve-text.test.ts +527 -27
  23. package/src/annotations/__tests__/rich-text.test.ts +91 -0
  24. package/src/annotations/__tests__/thinning.test.ts +148 -0
  25. package/src/annotations/collisions.ts +130 -84
  26. package/src/annotations/compute.ts +83 -85
  27. package/src/annotations/constants.ts +116 -6
  28. package/src/annotations/geometry.ts +228 -77
  29. package/src/annotations/move.ts +135 -0
  30. package/src/annotations/placement.ts +62 -14
  31. package/src/annotations/resolve-range.ts +2 -0
  32. package/src/annotations/resolve-refline.ts +2 -0
  33. package/src/annotations/resolve-text.ts +318 -111
  34. package/src/annotations/rich-text.ts +65 -0
  35. package/src/annotations/thinning.ts +84 -10
  36. package/src/barlist/compile-barlist.ts +16 -6
  37. package/src/charts/_shared/format-label-value.ts +9 -8
  38. package/src/charts/bar/__tests__/compute.test.ts +57 -10
  39. package/src/charts/bar/__tests__/labels.test.ts +5 -4
  40. package/src/charts/bar/compute.ts +2 -6
  41. package/src/charts/bar/index.ts +8 -1
  42. package/src/charts/bar/labels.ts +3 -3
  43. package/src/charts/beeswarm/__tests__/compute.test.ts +280 -0
  44. package/src/charts/beeswarm/__tests__/dodge.test.ts +148 -0
  45. package/src/charts/beeswarm/compute.ts +203 -0
  46. package/src/charts/beeswarm/dodge.ts +155 -0
  47. package/src/charts/beeswarm/index.ts +32 -0
  48. package/src/charts/builtin.ts +12 -1
  49. package/src/charts/calendar/__tests__/compute.test.ts +417 -0
  50. package/src/charts/calendar/compute.ts +319 -0
  51. package/src/charts/calendar/index.ts +32 -0
  52. package/src/charts/column/__tests__/compute.test.ts +21 -7
  53. package/src/charts/column/__tests__/labels.test.ts +76 -1
  54. package/src/charts/column/compute.ts +2 -6
  55. package/src/charts/column/index.ts +10 -2
  56. package/src/charts/column/labels.ts +95 -21
  57. package/src/charts/dot/index.ts +8 -1
  58. package/src/charts/dot/labels.ts +3 -3
  59. package/src/charts/line/__tests__/compute.test.ts +15 -19
  60. package/src/charts/line/area.ts +8 -23
  61. package/src/charts/line/compute.ts +32 -2
  62. package/src/charts/parliament/__tests__/compute.test.ts +411 -0
  63. package/src/charts/parliament/compute.ts +313 -0
  64. package/src/charts/parliament/index.ts +32 -0
  65. package/src/charts/pie/__tests__/compute.test.ts +103 -0
  66. package/src/charts/pie/compute.ts +63 -8
  67. package/src/charts/pie/labels.ts +1 -0
  68. package/src/charts/range/__tests__/compute.test.ts +486 -0
  69. package/src/charts/range/compute.ts +273 -0
  70. package/src/charts/range/index.ts +82 -0
  71. package/src/charts/range/labels.ts +169 -0
  72. package/src/charts/rect/__tests__/compute.test.ts +120 -0
  73. package/src/charts/rect/index.ts +127 -0
  74. package/src/charts/scatter/compute.ts +7 -21
  75. package/src/charts/text/__tests__/compute.test.ts +307 -0
  76. package/src/charts/text/index.ts +44 -7
  77. package/src/charts/tick/__tests__/compute.test.ts +75 -0
  78. package/src/charts/tick/index.ts +7 -4
  79. package/src/charts/utils.ts +5 -3
  80. package/src/charts/waffle/__tests__/compute.test.ts +305 -0
  81. package/src/charts/waffle/compute.ts +197 -0
  82. package/src/charts/waffle/index.ts +31 -0
  83. package/src/compile/__tests__/color-scale-reverse.test.ts +89 -0
  84. package/src/compile/__tests__/data-clip.test.ts +68 -0
  85. package/src/compile/__tests__/fill-patterns.test.ts +154 -0
  86. package/src/compile/__tests__/size-scale.test.ts +108 -0
  87. package/src/compile/color-scale-range.ts +54 -33
  88. package/src/compile/data-clip.ts +21 -6
  89. package/src/compile/fill-patterns.ts +74 -0
  90. package/src/compile/layer.ts +121 -21
  91. package/src/compile/size-scale.ts +141 -0
  92. package/src/compile/spec-sugar.ts +767 -0
  93. package/src/compile/you-draw-it.ts +123 -0
  94. package/src/compile.ts +415 -110
  95. package/src/compiler/__tests__/a11y-warnings.test.ts +148 -0
  96. package/src/compiler/__tests__/fixtures/vl-parity/axis-null.json +19 -0
  97. package/src/compiler/__tests__/fixtures/vl-parity/color-value.json +20 -0
  98. package/src/compiler/__tests__/fixtures/vl-parity/count-aggregate.json +22 -0
  99. package/src/compiler/__tests__/fixtures/vl-parity/data-values.json +19 -0
  100. package/src/compiler/__tests__/fixtures/vl-parity/layer-axis-null.json +14 -0
  101. package/src/compiler/__tests__/fixtures/vl-parity/layer-count.json +34 -0
  102. package/src/compiler/__tests__/fixtures/vl-parity/layer-data-values.json +22 -0
  103. package/src/compiler/__tests__/fixtures/vl-parity/layer-legend-null.json +41 -0
  104. package/src/compiler/__tests__/fixtures/vl-parity/layer-scheme.json +38 -0
  105. package/src/compiler/__tests__/fixtures/vl-parity/layer-sort.json +28 -0
  106. package/src/compiler/__tests__/fixtures/vl-parity/layer-theta.json +28 -0
  107. package/src/compiler/__tests__/fixtures/vl-parity/layer-title.json +30 -0
  108. package/src/compiler/__tests__/fixtures/vl-parity/layer-value-def.json +29 -0
  109. package/src/compiler/__tests__/fixtures/vl-parity/legend-null.json +32 -0
  110. package/src/compiler/__tests__/fixtures/vl-parity/scheme.json +25 -0
  111. package/src/compiler/__tests__/fixtures/vl-parity/sort-by-value.json +19 -0
  112. package/src/compiler/__tests__/fixtures/vl-parity/theta-arc.json +19 -0
  113. package/src/compiler/__tests__/fixtures/vl-parity/title-subtitle.json +21 -0
  114. package/src/compiler/__tests__/fixtures/vl-parity/width-height.json +23 -0
  115. package/src/compiler/__tests__/keys.test.ts +3 -9
  116. package/src/compiler/__tests__/normalize.test.ts +65 -1
  117. package/src/compiler/__tests__/validate-did-you-mean.test.ts +170 -0
  118. package/src/compiler/__tests__/validate-near-miss.test.ts +251 -0
  119. package/src/compiler/__tests__/validate.test.ts +258 -0
  120. package/src/compiler/__tests__/vl-parity.test.ts +68 -0
  121. package/src/compiler/a11y-warnings.ts +110 -0
  122. package/src/compiler/keys.ts +5 -4
  123. package/src/compiler/normalize.ts +151 -7
  124. package/src/compiler/types.ts +11 -0
  125. package/src/compiler/validate.ts +961 -32
  126. package/src/endpoint-labels/__tests__/compute.test.ts +88 -0
  127. package/src/endpoint-labels/compute.ts +40 -12
  128. package/src/endpoint-labels/format.ts +45 -15
  129. package/src/endpoint-labels/predict.ts +50 -36
  130. package/src/format/__tests__/field-format.test.ts +164 -0
  131. package/src/format/field-format.ts +27 -0
  132. package/src/geo/__tests__/compile-map.test.ts +1128 -0
  133. package/src/geo/compile-map.ts +1376 -0
  134. package/src/geo/join.ts +115 -0
  135. package/src/geo/projections.ts +65 -0
  136. package/src/geo/types.ts +26 -0
  137. package/src/graphs/__tests__/compile-graph.test.ts +25 -0
  138. package/src/graphs/compile-graph.ts +27 -18
  139. package/src/index.ts +20 -2
  140. package/src/layout/axes/ticks.ts +59 -10
  141. package/src/layout/axes.ts +35 -6
  142. package/src/layout/dimensions.ts +139 -83
  143. package/src/layout/facet.ts +14 -3
  144. package/src/layout/plan.ts +83 -33
  145. package/src/layout/scales.ts +132 -15
  146. package/src/legend/__tests__/continuous.test.ts +318 -0
  147. package/src/legend/__tests__/size.test.ts +348 -0
  148. package/src/legend/__tests__/suppression.test.ts +17 -1
  149. package/src/legend/compute.ts +123 -16
  150. package/src/legend/continuous.ts +327 -0
  151. package/src/legend/size.ts +407 -0
  152. package/src/legend/suppression.ts +8 -7
  153. package/src/sankey/compile-sankey.ts +31 -19
  154. package/src/tables/format-cells.ts +16 -7
  155. package/src/tilemap/__tests__/compile-tilemap.test.ts +39 -0
  156. package/src/tilemap/compile-tilemap.ts +24 -7
  157. package/src/tooltips/__tests__/compute.test.ts +2 -2
  158. package/src/tooltips/compute.ts +422 -41
  159. package/src/transforms/__tests__/conditional.test.ts +18 -0
  160. package/src/transforms/aggregate.ts +2 -1
@@ -21,6 +21,7 @@ export function resolveRefLineAnnotation(
21
21
  scales: ResolvedScales,
22
22
  chartArea: Rect,
23
23
  isDark: boolean,
24
+ fontFamily?: string,
24
25
  ): ResolvedAnnotation | null {
25
26
  let start: Point;
26
27
  let end: Point;
@@ -128,6 +129,7 @@ export function resolveRefLineAnnotation(
128
129
  annotation.fontWeight ?? 400,
129
130
  annotation.stroke ?? defaultStroke,
130
131
  isDark,
132
+ fontFamily,
131
133
  );
132
134
  style.textAnchor = textAnchor;
133
135
 
@@ -4,6 +4,8 @@
4
4
  */
5
5
 
6
6
  import type {
7
+ ConnectorConfig,
8
+ ConnectorType,
7
9
  Rect,
8
10
  ResolvedAnnotation,
9
11
  ResolvedLabel,
@@ -12,7 +14,10 @@ import type {
12
14
  } from '@opendata-ai/openchart-core';
13
15
  import type { ResolvedScales } from '../layout/scales';
14
16
  import {
17
+ connectorIsDrawable,
18
+ DARK_CONNECTOR_STROKE,
15
19
  DARK_DOT_FILL,
20
+ DARK_LABEL_BACKGROUND,
16
21
  DARK_MUTED_TEXT_FILL,
17
22
  DARK_TEXT_FILL,
18
23
  DEFAULT_ANNOTATION_FONT_SIZE,
@@ -20,39 +25,131 @@ import {
20
25
  DEFAULT_DOT_RADIUS,
21
26
  DEFAULT_DOT_STROKE_WIDTH,
22
27
  DEFAULT_LINE_HEIGHT,
28
+ DROP_LINE_TOP_GAP,
29
+ FALLBACK_FONT_FAMILY,
30
+ LEDE_FONT_WEIGHT,
31
+ LIGHT_CONNECTOR_STROKE,
23
32
  LIGHT_DOT_FILL,
33
+ LIGHT_LABEL_BACKGROUND,
24
34
  LIGHT_MUTED_TEXT_FILL,
25
35
  LIGHT_TEXT_FILL,
26
- SUBTITLE_FONT_SIZE_RATIO,
36
+ SUBTITLE_FONT_WEIGHT,
27
37
  SUBTITLE_GAP,
38
+ subtitleBaselineY,
39
+ subtitleFontSize,
28
40
  } from './constants';
29
41
  import {
30
42
  type AnnotationMeasureTextFn,
31
43
  applyOffset,
32
44
  computeAnchorOffset,
33
- computeConnectorOrigin,
34
45
  computeTextBlockBounds,
46
+ connectorExit,
47
+ connectorPullbackGap,
35
48
  heuristicMeasure,
49
+ measureRichLine,
36
50
  unionRects,
37
51
  } from './geometry';
38
52
  import { resolvePosition } from './position';
39
53
 
54
+ /** The resolved endpoint marker on a text annotation. */
55
+ type ResolvedDot = NonNullable<ResolvedAnnotation['dot']>;
56
+
57
+ /**
58
+ * Two connector voices. An arrowed callout is the emphasis gesture, so it takes
59
+ * the label's text ink and reads as one stroke with the words. Everything else
60
+ * (plain leaders, drop-lines) is quiet infrastructure: a gray hairline.
61
+ *
62
+ * The marker inherits the same color, so leader and dot read as one system.
63
+ */
64
+ function defaultConnectorStroke(hasArrow: boolean, isDark: boolean, textInk: string): string {
65
+ if (hasArrow) return textInk;
66
+ return isDark ? DARK_CONNECTOR_STROKE : LIGHT_CONNECTOR_STROKE;
67
+ }
68
+
69
+ /**
70
+ * Resolve the endpoint marker at the data point.
71
+ *
72
+ * A default marker (equivalent to `dot: true`) appears whenever a connector is
73
+ * enabled with no arrowhead: the leader stops short and the marker terminates
74
+ * it. An arrowhead is already a terminator, so it gets no marker unless the
75
+ * author asks for one. `dot: false` always means bare.
76
+ */
77
+ function resolveDot(
78
+ dotSpec: TextAnnotation['dot'],
79
+ hasConnector: boolean,
80
+ hasArrow: boolean,
81
+ x: number,
82
+ y: number,
83
+ isDark: boolean,
84
+ connectorStroke: string,
85
+ ): ResolvedDot | undefined {
86
+ const useDefault = dotSpec === undefined && hasConnector && !hasArrow;
87
+ if (!dotSpec && !useDefault) return undefined;
88
+
89
+ const dotConfig = typeof dotSpec === 'object' ? dotSpec : {};
90
+ return {
91
+ x,
92
+ y,
93
+ radius: dotConfig.radius ?? DEFAULT_DOT_RADIUS,
94
+ fill: dotConfig.fill ?? (isDark ? DARK_DOT_FILL : LIGHT_DOT_FILL),
95
+ stroke: dotConfig.stroke ?? connectorStroke,
96
+ strokeWidth: dotConfig.strokeWidth ?? DEFAULT_DOT_STROKE_WIDTH,
97
+ };
98
+ }
99
+
100
+ /** Radius the connector must clear at the data point, marker stroke included. */
101
+ export function markerClearance(dot: ResolvedDot | undefined): number {
102
+ return dot ? dot.radius + dot.strokeWidth / 2 : 0;
103
+ }
104
+
105
+ /**
106
+ * Resolve the connector spec shorthand into a normalized { type, arrow } object.
107
+ * Returns null when the connector is disabled.
108
+ */
109
+ function resolveConnectorConfig(
110
+ connector: TextAnnotation['connector'],
111
+ ): { type: ConnectorType; arrow: boolean } | null {
112
+ if (connector === false) return null;
113
+
114
+ if (typeof connector === 'object' && connector !== null && 'type' in connector) {
115
+ const cfg = connector as ConnectorConfig;
116
+ const arrow = cfg.type === 'drop-line' ? false : (cfg.arrow ?? cfg.type === 'curve');
117
+ return { type: cfg.type, arrow };
118
+ }
119
+
120
+ if (connector === 'curve') return { type: 'curve', arrow: true };
121
+ if (connector === 'drop-line') return { type: 'drop-line', arrow: false };
122
+
123
+ // true, 'straight', or undefined all map to straight with no arrow
124
+ return { type: 'straight', arrow: false };
125
+ }
126
+
127
+ /**
128
+ * Resolve `background` to a concrete color. `true` picks the theme surface so
129
+ * the plate follows light/dark; a string passes through as an explicit override.
130
+ */
131
+ function resolveLabelBackground(
132
+ background: string | true | undefined,
133
+ isDark: boolean,
134
+ ): string | undefined {
135
+ if (background === undefined) return undefined;
136
+ if (background === true) return isDark ? DARK_LABEL_BACKGROUND : LIGHT_LABEL_BACKGROUND;
137
+ return background;
138
+ }
139
+
40
140
  /** Horizontal gap between the drop-line and the label text. */
41
141
  const DROP_LINE_LABEL_GAP = 8;
42
- /** Vertical gap between the top of the drop-line and the top of the label box. */
43
- const DROP_LINE_TOP_GAP = 4;
44
- /** Vertical gap between the bottom of the drop-line and the data point. */
45
- const DROP_LINE_BOTTOM_GAP = 4;
46
142
 
47
143
  export function makeAnnotationLabelStyle(
48
144
  fontSize?: number,
49
145
  fontWeight?: number,
50
146
  fill?: string,
51
147
  isDark?: boolean,
148
+ fontFamily?: string,
52
149
  ): TextStyle {
53
150
  const defaultFill = isDark ? DARK_TEXT_FILL : LIGHT_TEXT_FILL;
54
151
  return {
55
- fontFamily: 'Inter, system-ui, sans-serif',
152
+ fontFamily: fontFamily ?? FALLBACK_FONT_FAMILY,
56
153
  fontSize: fontSize ?? DEFAULT_ANNOTATION_FONT_SIZE,
57
154
  fontWeight: fontWeight ?? DEFAULT_ANNOTATION_FONT_WEIGHT,
58
155
  fill: fill ?? defaultFill,
@@ -61,12 +158,22 @@ export function makeAnnotationLabelStyle(
61
158
  };
62
159
  }
63
160
 
161
+ /**
162
+ * The primary text's resolved weight. A subtitle turns the primary line into a
163
+ * lede, so it goes bold unless the author asked for a specific weight.
164
+ */
165
+ export function resolveLedeFontWeight(annotation: TextAnnotation): number | undefined {
166
+ if (annotation.fontWeight !== undefined) return annotation.fontWeight;
167
+ return annotation.subtitle ? LEDE_FONT_WEIGHT : undefined;
168
+ }
169
+
64
170
  export function resolveTextAnnotation(
65
171
  annotation: TextAnnotation,
66
172
  scales: ResolvedScales,
67
173
  chartArea: Rect,
68
174
  isDark: boolean,
69
175
  measure: AnnotationMeasureTextFn = heuristicMeasure,
176
+ fontFamily?: string,
70
177
  ): ResolvedAnnotation | null {
71
178
  const px = resolvePosition(annotation.x, scales.x);
72
179
  const py = resolvePosition(annotation.y, scales.y);
@@ -74,141 +181,106 @@ export function resolveTextAnnotation(
74
181
  if (px === null || py === null) return null;
75
182
 
76
183
  const defaultTextFill = isDark ? DARK_TEXT_FILL : LIGHT_TEXT_FILL;
184
+ const labelBackground = resolveLabelBackground(annotation.background, isDark);
77
185
 
78
186
  const labelStyle = makeAnnotationLabelStyle(
79
187
  annotation.fontSize,
80
- annotation.fontWeight,
188
+ resolveLedeFontWeight(annotation),
81
189
  annotation.fill ?? defaultTextFill,
82
190
  isDark,
191
+ fontFamily,
83
192
  );
84
193
 
194
+ // Parse connector spec into { type, arrow } or null (disabled).
195
+ const connectorConfig = resolveConnectorConfig(annotation.connector);
196
+
85
197
  // Drop-line connector: vertical line through the data point's x with the
86
198
  // label sitting flush beside it. Auto-flips to the opposite side if the
87
199
  // chosen side would overflow the chart area.
88
- if (annotation.connector === 'drop-line') {
200
+ if (connectorConfig?.type === 'drop-line') {
89
201
  return resolveDropLineAnnotation(
90
202
  annotation,
91
203
  px,
92
204
  py,
93
205
  chartArea,
94
206
  labelStyle,
95
- defaultTextFill,
207
+ labelBackground,
208
+ isDark,
96
209
  measure,
97
210
  );
98
211
  }
99
212
 
100
- // Multi-line non-drop-line: engine sets textAnchor to 'middle' so bounds
101
- // and rendering derive from the same anchor (previously the renderer
102
- // forced this override, causing bounds to lie).
103
- const isMultiLine = annotation.text.includes('\n');
104
- if (isMultiLine) {
105
- labelStyle.textAnchor = 'middle';
106
- }
213
+ // Text blocks are never center-aligned: the reference annotations align on the
214
+ // edge that faces the data point, so the block reads as a flag on the leader.
215
+ // A left anchor puts the block to the left of the point, so its right edge
216
+ // faces it (`end`); everything else faces left (`start`).
217
+ labelStyle.textAnchor = annotation.anchor === 'left' ? 'end' : 'start';
107
218
 
108
219
  // Compute position from anchor direction + user offset
109
220
  const anchorDelta = computeAnchorOffset(annotation.anchor, px, py, chartArea);
110
221
  const finalDelta = applyOffset(anchorDelta, annotation.offset);
111
222
 
112
- const labelX = px + finalDelta.dx;
113
- const labelY = py + finalDelta.dy;
114
-
115
- // Connector: draw unless explicitly disabled
116
- const showConnector = annotation.connector !== false;
117
- const connectorStyle: 'straight' | 'curve' =
118
- annotation.connector === 'curve' ? 'curve' : 'straight';
119
-
120
- // Compute connector origin: pick the edge midpoint closest to the data point
121
223
  const fontSize = annotation.fontSize ?? DEFAULT_ANNOTATION_FONT_SIZE;
122
- const fontWeight = annotation.fontWeight ?? DEFAULT_ANNOTATION_FONT_WEIGHT;
123
- const { x: connectorFromX, y: connectorFromY } = computeConnectorOrigin(
124
- labelX,
125
- labelY,
126
- annotation.text,
127
- fontSize,
128
- fontWeight,
129
- px,
130
- py,
131
- connectorStyle,
132
- );
133
-
134
- // Apply user-provided connector endpoint offsets
135
- const baseFrom = { x: connectorFromX, y: connectorFromY };
136
- const baseTo = { x: px, y: py };
137
- const adjustedFrom = {
138
- x: baseFrom.x + (annotation.connectorOffset?.from?.dx ?? 0),
139
- y: baseFrom.y + (annotation.connectorOffset?.from?.dy ?? 0),
140
- };
141
- const adjustedToRaw = {
142
- x: baseTo.x + (annotation.connectorOffset?.to?.dx ?? 0),
143
- y: baseTo.y + (annotation.connectorOffset?.to?.dy ?? 0),
144
- };
145
224
 
146
- // Pull the "to" endpoint back along the connector direction so the
147
- // line doesn't touch the data point directly (leaves a small gap).
148
- const GAP = 4;
149
- const cdx = adjustedToRaw.x - adjustedFrom.x;
150
- const cdy = adjustedToRaw.y - adjustedFrom.y;
151
- const dist = Math.sqrt(cdx * cdx + cdy * cdy);
152
- const adjustedTo =
153
- dist > GAP * 2
154
- ? { x: adjustedToRaw.x - (cdx / dist) * GAP, y: adjustedToRaw.y - (cdy / dist) * GAP }
155
- : adjustedToRaw;
225
+ // A top/bottom anchor means the block sits ABOVE/BELOW the point, so it has to
226
+ // straddle it horizontally. The text renders with textAnchor 'start', which
227
+ // would otherwise hang the whole block off to the right and make `anchor: 'top'`
228
+ // read as "up and to the right" — forcing every author to hand-compute a
229
+ // negative dx of roughly half the block width. Centering the block is separate
230
+ // from aligning the text: the lines inside stay left-aligned and ragged right.
231
+ //
232
+ // Measured against the widest line of the block (primary vs subtitle), since a
233
+ // subtitle is often wider and centering on the primary alone reads as off-center.
234
+ let centeringShift = 0;
235
+ if (annotation.anchor === 'top' || annotation.anchor === 'bottom') {
236
+ const primaryWidth = computeTextBlockBounds(
237
+ 0,
238
+ fontSize,
239
+ annotation.text,
240
+ { ...labelStyle, textAnchor: 'start' },
241
+ measure,
242
+ ).width;
243
+ const subtitleWidth = annotation.subtitle
244
+ ? computeTextBlockBounds(
245
+ 0,
246
+ fontSize,
247
+ annotation.subtitle,
248
+ {
249
+ ...labelStyle,
250
+ fontSize: subtitleFontSize(fontSize),
251
+ fontWeight: SUBTITLE_FONT_WEIGHT,
252
+ textAnchor: 'start',
253
+ },
254
+ measure,
255
+ ).width
256
+ : 0;
257
+ centeringShift = -Math.max(primaryWidth, subtitleWidth) / 2;
258
+ }
156
259
 
157
- const labelBounds = computeTextBlockBounds(labelX, labelY, annotation.text, labelStyle, measure);
260
+ const labelX = px + finalDelta.dx + centeringShift;
261
+ const labelY = py + finalDelta.dy;
158
262
 
159
- const label: ResolvedLabel = {
160
- text: annotation.text,
161
- x: labelX,
162
- y: labelY,
163
- style: labelStyle,
164
- visible: true,
165
- connector: showConnector
166
- ? {
167
- from: adjustedFrom,
168
- to: adjustedTo,
169
- endpoint: { x: px, y: py },
170
- stroke: annotation.stroke ?? '#999999',
171
- style: connectorStyle,
172
- }
173
- : undefined,
174
- background: annotation.background,
175
- halo: annotation.halo,
176
- bounds: labelBounds,
177
- };
263
+ const showConnector = connectorConfig !== null;
264
+ const connectorStyle: 'straight' | 'curve' =
265
+ connectorConfig?.type === 'curve' ? 'curve' : 'straight';
266
+ const connectorArrow = connectorConfig?.arrow ?? false;
178
267
 
179
- // Resolve dot marker. Uses the connector's "to" endpoint coordinates
180
- // (post user-supplied connectorOffset.to) so it sits exactly where the
181
- // connector terminates at the data point.
182
- let dot: ResolvedAnnotation['dot'] | undefined;
183
- if (annotation.dot) {
184
- const dotConfig = typeof annotation.dot === 'object' ? annotation.dot : {};
185
- const defaultDotFill = isDark ? DARK_DOT_FILL : LIGHT_DOT_FILL;
186
- const defaultDotStroke = isDark ? DARK_TEXT_FILL : LIGHT_TEXT_FILL;
187
- dot = {
188
- x: adjustedTo.x,
189
- y: adjustedTo.y,
190
- radius: dotConfig.radius ?? DEFAULT_DOT_RADIUS,
191
- fill: dotConfig.fill ?? defaultDotFill,
192
- stroke: dotConfig.stroke ?? defaultDotStroke,
193
- strokeWidth: dotConfig.strokeWidth ?? DEFAULT_DOT_STROKE_WIDTH,
194
- };
195
- }
268
+ const labelBounds = computeTextBlockBounds(labelX, labelY, annotation.text, labelStyle, measure);
196
269
 
197
- // Resolve subtitle. Positioned below the primary text block by
198
- // (lineHeight * primaryLineCount * fontSize) + gap.
270
+ // Resolve subtitle first: its bounds are part of the block the connector must
271
+ // clear, and the subtitle is often the wider of the two lines.
199
272
  let subtitle: ResolvedAnnotation['subtitle'] | undefined;
200
273
  let annotationBounds: Rect = labelBounds;
201
274
  if (annotation.subtitle) {
202
275
  const primaryLineCount = annotation.text.split('\n').length;
203
- const subtitleFontSize = Math.round(fontSize * SUBTITLE_FONT_SIZE_RATIO);
204
276
  const mutedFill = isDark ? DARK_MUTED_TEXT_FILL : LIGHT_MUTED_TEXT_FILL;
205
277
  const subtitleStyle: TextStyle = {
206
278
  ...labelStyle,
207
- fontSize: subtitleFontSize,
208
- fontWeight: DEFAULT_ANNOTATION_FONT_WEIGHT,
279
+ fontSize: subtitleFontSize(fontSize),
280
+ fontWeight: SUBTITLE_FONT_WEIGHT,
209
281
  fill: mutedFill,
210
282
  };
211
- const subtitleY = labelY + fontSize * DEFAULT_LINE_HEIGHT * primaryLineCount + SUBTITLE_GAP;
283
+ const subtitleY = subtitleBaselineY(labelY, fontSize * DEFAULT_LINE_HEIGHT, primaryLineCount);
212
284
  subtitle = {
213
285
  text: annotation.subtitle,
214
286
  x: labelX,
@@ -225,6 +297,87 @@ export function resolveTextAnnotation(
225
297
  annotationBounds = unionRects(labelBounds, subtitleBounds);
226
298
  }
227
299
 
300
+ // The endpoint marker sits on the data point itself (plus any user offset on
301
+ // the connector's data-side end), never on the pulled-back connector tip.
302
+ const endpoint = {
303
+ x: px + (annotation.connectorOffset?.to?.dx ?? 0),
304
+ y: py + (annotation.connectorOffset?.to?.dy ?? 0),
305
+ };
306
+ // Emphasis (arrowed) connectors take the label ink; quiet leaders take gray.
307
+ // The marker shares the color so it reads as part of the same gesture.
308
+ const connectorStroke =
309
+ annotation.stroke ??
310
+ defaultConnectorStroke(connectorArrow, isDark, labelStyle.fill ?? defaultTextFill);
311
+ const dot = resolveDot(
312
+ annotation.dot,
313
+ showConnector,
314
+ connectorArrow,
315
+ endpoint.x,
316
+ endpoint.y,
317
+ isDark,
318
+ connectorStroke,
319
+ );
320
+
321
+ // Connector origin: ray from the block center (label ∪ subtitle) to the data
322
+ // point, exiting the box with a standoff gap.
323
+ let connector: ResolvedLabel['connector'];
324
+ if (showConnector) {
325
+ const exit = connectorExit(annotationBounds, endpoint.x, endpoint.y);
326
+ if (exit) {
327
+ const from = {
328
+ x: exit.x + (annotation.connectorOffset?.from?.dx ?? 0),
329
+ y: exit.y + (annotation.connectorOffset?.from?.dy ?? 0),
330
+ };
331
+
332
+ // Pull the data-point end back so the line stops short of the marker.
333
+ const gap = connectorPullbackGap(markerClearance(dot), connectorArrow);
334
+ const cdx = endpoint.x - from.x;
335
+ const cdy = endpoint.y - from.y;
336
+ const dist = Math.sqrt(cdx * cdx + cdy * cdy);
337
+ const to =
338
+ dist > 0
339
+ ? { x: endpoint.x - (cdx / dist) * gap, y: endpoint.y - (cdy / dist) * gap }
340
+ : { ...endpoint };
341
+
342
+ // Min-length check runs after the pullback: it can flip a near-degenerate
343
+ // line's direction, and a stub shorter than MIN_CONNECTOR_LENGTH reads as
344
+ // noise — the marker alone says it better. An arrowed connector spends
345
+ // ARROWHEAD_LENGTH of its length on the head, so the gate measures what's
346
+ // actually left to stroke.
347
+ const lx = to.x - from.x;
348
+ const ly = to.y - from.y;
349
+ if (connectorIsDrawable(Math.sqrt(lx * lx + ly * ly), connectorArrow)) {
350
+ connector = {
351
+ from,
352
+ to,
353
+ endpoint,
354
+ fromOffset: annotation.connectorOffset?.from
355
+ ? {
356
+ dx: annotation.connectorOffset.from.dx ?? 0,
357
+ dy: annotation.connectorOffset.from.dy ?? 0,
358
+ }
359
+ : undefined,
360
+ stroke: connectorStroke,
361
+ style: connectorStyle,
362
+ arrow: connectorArrow,
363
+ exit: exit.exit,
364
+ };
365
+ }
366
+ }
367
+ }
368
+
369
+ const label: ResolvedLabel = {
370
+ text: annotation.text,
371
+ x: labelX,
372
+ y: labelY,
373
+ style: labelStyle,
374
+ visible: true,
375
+ connector,
376
+ background: labelBackground,
377
+ halo: annotation.halo,
378
+ bounds: labelBounds,
379
+ };
380
+
228
381
  return {
229
382
  type: 'text',
230
383
  id: annotation.id,
@@ -250,15 +403,23 @@ function resolveDropLineAnnotation(
250
403
  py: number,
251
404
  chartArea: Rect,
252
405
  labelStyle: TextStyle,
253
- defaultTextFill: string,
406
+ labelBackground: string | undefined,
407
+ isDark: boolean,
254
408
  measure: AnnotationMeasureTextFn = heuristicMeasure,
255
409
  ): ResolvedAnnotation {
256
410
  const fontSize = annotation.fontSize ?? DEFAULT_ANNOTATION_FONT_SIZE;
257
- const fontWeight = annotation.fontWeight ?? DEFAULT_ANNOTATION_FONT_WEIGHT;
411
+ const fontWeight = Number(labelStyle.fontWeight) || DEFAULT_ANNOTATION_FONT_WEIGHT;
258
412
  const lines = annotation.text.split('\n');
413
+ const subtitleLines = annotation.subtitle ? annotation.subtitle.split('\n') : [];
414
+ const subFontSize = subtitleFontSize(fontSize);
259
415
  const estimatedWidth = Math.max(
260
416
  0,
261
- ...lines.map((line) => measure(line, { fontSize, fontWeight })),
417
+ ...lines.map((line) => measureRichLine(line, { fontSize, fontWeight }, measure)),
418
+ // The subtitle is often the wider line, and it's what decides whether the
419
+ // block fits on a side.
420
+ ...subtitleLines.map((line) =>
421
+ measureRichLine(line, { fontSize: subFontSize, fontWeight: SUBTITLE_FONT_WEIGHT }, measure),
422
+ ),
262
423
  );
263
424
 
264
425
  // Pick initial side from anchor; default to 'left' (label sits to the left
@@ -285,8 +446,13 @@ function resolveDropLineAnnotation(
285
446
  // Drop the label box top a bit above the data point so the label and line
286
447
  // share a baseline that reads as "callout above the point".
287
448
  const lineHeight = fontSize * DEFAULT_LINE_HEIGHT;
288
- const totalHeight = lineHeight * lines.length;
289
- // Position the first line so the bottom of the label sits ~12px above py.
449
+ // The subtitle hangs below the primary lines, so it's part of the block the
450
+ // 12px setback has to clear -- otherwise it lands on top of the data point.
451
+ const subtitleHeight = annotation.subtitle
452
+ ? SUBTITLE_GAP + subFontSize * DEFAULT_LINE_HEIGHT * subtitleLines.length
453
+ : 0;
454
+ const totalHeight = lineHeight * lines.length + subtitleHeight;
455
+ // Position the first line so the bottom of the block sits ~12px above py.
290
456
  // Clamp to the chart-area top so multi-line labels near peaks don't escape
291
457
  // upward into chrome / metric-bar territory.
292
458
  const desiredLabelTopY = py - totalHeight - 12;
@@ -296,8 +462,16 @@ function resolveDropLineAnnotation(
296
462
 
297
463
  const resolvedStyle: TextStyle = { ...labelStyle, textAnchor };
298
464
 
465
+ // The drop-line is a connector with no arrow, so it gets the default endpoint
466
+ // marker too — and the line stops short of it by the same rule as the other
467
+ // connector styles. Quiet voice: gray hairline, marker in the same gray.
468
+ const connectorStroke =
469
+ annotation.stroke ?? (isDark ? DARK_CONNECTOR_STROKE : LIGHT_CONNECTOR_STROKE);
470
+ const dot = resolveDot(annotation.dot, true, false, px, py, isDark, connectorStroke);
471
+ const bottomGap = connectorPullbackGap(markerClearance(dot), false);
472
+
299
473
  const from = { x: px, y: labelTopY - DROP_LINE_TOP_GAP };
300
- const to = { x: px, y: py - DROP_LINE_BOTTOM_GAP };
474
+ const to = { x: px, y: py - bottomGap };
301
475
 
302
476
  const labelBounds = computeTextBlockBounds(
303
477
  labelX,
@@ -307,6 +481,33 @@ function resolveDropLineAnnotation(
307
481
  measure,
308
482
  );
309
483
 
484
+ // A subtitle works on a drop-line the same as on any other connector style:
485
+ // the lede rule already bolded the primary line on the way in, so dropping the
486
+ // subtitle here would leave a mystery-bold label with no context under it.
487
+ let subtitle: ResolvedAnnotation['subtitle'] | undefined;
488
+ let annotationBounds = labelBounds;
489
+ if (annotation.subtitle) {
490
+ const subtitleStyle: TextStyle = {
491
+ ...resolvedStyle,
492
+ fontSize: subFontSize,
493
+ fontWeight: SUBTITLE_FONT_WEIGHT,
494
+ fill: isDark ? DARK_MUTED_TEXT_FILL : LIGHT_MUTED_TEXT_FILL,
495
+ };
496
+ // `lineHeight` here is already the pixel line-height, which is what the shared
497
+ // formula takes.
498
+ const subtitleY = subtitleBaselineY(labelBaselineY, lineHeight, lines.length);
499
+ subtitle = {
500
+ text: annotation.subtitle,
501
+ x: labelX,
502
+ y: subtitleY,
503
+ style: subtitleStyle,
504
+ };
505
+ annotationBounds = unionRects(
506
+ labelBounds,
507
+ computeTextBlockBounds(labelX, subtitleY, annotation.subtitle, subtitleStyle, measure),
508
+ );
509
+ }
510
+
310
511
  const label: ResolvedLabel = {
311
512
  text: annotation.text,
312
513
  x: labelX,
@@ -317,10 +518,12 @@ function resolveDropLineAnnotation(
317
518
  from,
318
519
  to,
319
520
  endpoint: { x: px, y: py },
320
- stroke: annotation.stroke ?? defaultTextFill,
521
+ stroke: connectorStroke,
321
522
  style: 'drop-line',
523
+ arrow: false,
524
+ exit: 'vertical',
322
525
  },
323
- background: annotation.background,
526
+ background: labelBackground,
324
527
  halo: annotation.halo,
325
528
  bounds: labelBounds,
326
529
  };
@@ -329,10 +532,14 @@ function resolveDropLineAnnotation(
329
532
  type: 'text',
330
533
  id: annotation.id,
331
534
  label,
535
+ subtitle,
332
536
  stroke: annotation.stroke,
333
537
  fill: annotation.fill,
334
538
  opacity: annotation.opacity,
335
539
  zIndex: annotation.zIndex,
336
- bounds: labelBounds,
540
+ dot,
541
+ // Union of label + subtitle. `label.bounds` stays label-only -- the renderer
542
+ // sizes the `background` plate from that.
543
+ bounds: annotationBounds,
337
544
  };
338
545
  }
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Inline rich text for annotation labels: `**bold**` spans inside an otherwise
3
+ * regular-weight sentence. This is the NYT/Datawrapper voice — emphasis lands on
4
+ * the key phrase, not on the whole block.
5
+ */
6
+
7
+ /** One run of annotation text at a single weight. */
8
+ export interface RichSpan {
9
+ text: string;
10
+ bold: boolean;
11
+ }
12
+
13
+ /** Weight bold spans are measured and rendered at. */
14
+ export const BOLD_SPAN_FONT_WEIGHT = 700;
15
+
16
+ const BOLD_DELIMITER = '**';
17
+
18
+ /**
19
+ * Parse `**bold**` markers in a single line of annotation text.
20
+ *
21
+ * Only matched pairs turn bold; an unmatched `**` renders literally, so specs
22
+ * that legitimately contain asterisks are never mangled. Empty `****` also
23
+ * renders literally (there's nothing to emphasize).
24
+ *
25
+ * The line is expected to be a single line — callers split on `\n` first.
26
+ */
27
+ export function parseAnnotationSpans(line: string): RichSpan[] {
28
+ if (!line.includes(BOLD_DELIMITER)) {
29
+ return [{ text: line, bold: false }];
30
+ }
31
+
32
+ const spans: RichSpan[] = [];
33
+ let plain = '';
34
+ let i = 0;
35
+
36
+ const flushPlain = (): void => {
37
+ if (plain) {
38
+ spans.push({ text: plain, bold: false });
39
+ plain = '';
40
+ }
41
+ };
42
+
43
+ while (i < line.length) {
44
+ if (line.startsWith(BOLD_DELIMITER, i)) {
45
+ const close = line.indexOf(BOLD_DELIMITER, i + BOLD_DELIMITER.length);
46
+ const inner = close === -1 ? '' : line.slice(i + BOLD_DELIMITER.length, close);
47
+ if (close === -1 || inner.length === 0) {
48
+ // Unmatched (or empty) delimiter: render it literally.
49
+ plain += BOLD_DELIMITER;
50
+ i += BOLD_DELIMITER.length;
51
+ continue;
52
+ }
53
+ flushPlain();
54
+ spans.push({ text: inner, bold: true });
55
+ i = close + BOLD_DELIMITER.length;
56
+ continue;
57
+ }
58
+ plain += line[i];
59
+ i++;
60
+ }
61
+
62
+ flushPlain();
63
+
64
+ return spans.length > 0 ? spans : [{ text: '', bold: false }];
65
+ }