@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
@@ -9,19 +9,27 @@ import type { NormalizedChartSpec } from '../../compiler/types';
9
9
  import { computeScales } from '../../layout/scales';
10
10
  import { computeAnnotations } from '../compute';
11
11
  import {
12
+ DARK_CONNECTOR_STROKE,
12
13
  DARK_DOT_FILL,
14
+ DARK_LABEL_BACKGROUND,
13
15
  DARK_MUTED_TEXT_FILL,
14
- DARK_TEXT_FILL,
15
16
  DEFAULT_ANNOTATION_FONT_SIZE,
17
+ DEFAULT_ANNOTATION_FONT_WEIGHT,
16
18
  DEFAULT_DOT_RADIUS,
17
19
  DEFAULT_DOT_STROKE_WIDTH,
18
20
  DEFAULT_LINE_HEIGHT,
21
+ FALLBACK_FONT_FAMILY,
22
+ LEDE_FONT_WEIGHT,
23
+ LIGHT_CONNECTOR_STROKE,
19
24
  LIGHT_DOT_FILL,
25
+ LIGHT_LABEL_BACKGROUND,
20
26
  LIGHT_MUTED_TEXT_FILL,
21
- LIGHT_TEXT_FILL,
27
+ MIN_CONNECTOR_LENGTH,
22
28
  SUBTITLE_FONT_SIZE_RATIO,
29
+ SUBTITLE_FONT_WEIGHT,
23
30
  SUBTITLE_GAP,
24
31
  } from '../constants';
32
+ import { heuristicMeasure } from '../geometry';
25
33
 
26
34
  const chartArea: Rect = { x: 50, y: 20, width: 500, height: 300 };
27
35
 
@@ -56,8 +64,79 @@ function makeSpec(annotations: Annotation[]): NormalizedChartSpec {
56
64
  }
57
65
 
58
66
  describe('text annotation: dot', () => {
59
- it('does not populate dot when not specified', () => {
60
- const spec = makeSpec([{ type: 'text', x: '2020-01-01', y: 20, text: 'No dot' }]);
67
+ // A connector with no arrowhead needs a terminator at the data point, so the
68
+ // engine resolves the default marker for it. `dot: false` opts out.
69
+ it('resolves the default dot when a connector is enabled and no dot key is set', () => {
70
+ const spec = makeSpec([{ type: 'text', x: '2020-01-01', y: 20, text: 'No dot key' }]);
71
+ const scales = computeScales(spec, chartArea, spec.data);
72
+ const annotations = computeAnnotations(spec, scales, chartArea, fullStrategy);
73
+
74
+ const dot = annotations[0].dot;
75
+ expect(dot).toBeDefined();
76
+ expect(dot!.radius).toBe(DEFAULT_DOT_RADIUS);
77
+ });
78
+
79
+ it('resolves the default dot for an explicit straight connector', () => {
80
+ const spec = makeSpec([
81
+ { type: 'text', x: '2020-01-01', y: 20, text: 'Straight', connector: 'straight' },
82
+ ]);
83
+ const scales = computeScales(spec, chartArea, spec.data);
84
+ const annotations = computeAnnotations(spec, scales, chartArea, fullStrategy);
85
+
86
+ expect(annotations[0].dot).toBeDefined();
87
+ });
88
+
89
+ it('resolves the default dot for a drop-line connector', () => {
90
+ const spec = makeSpec([
91
+ { type: 'text', x: '2020-01-01', y: 20, text: 'Drop', connector: 'drop-line' },
92
+ ]);
93
+ const scales = computeScales(spec, chartArea, spec.data);
94
+ const annotations = computeAnnotations(spec, scales, chartArea, fullStrategy);
95
+
96
+ expect(annotations[0].dot).toBeDefined();
97
+ });
98
+
99
+ it('honors an explicit dot: true on a drop-line connector', () => {
100
+ const spec = makeSpec([
101
+ { type: 'text', x: '2020-01-01', y: 20, text: 'Drop', connector: 'drop-line', dot: true },
102
+ ]);
103
+ const scales = computeScales(spec, chartArea, spec.data);
104
+ const annotations = computeAnnotations(spec, scales, chartArea, fullStrategy);
105
+
106
+ const ann = annotations[0];
107
+ const px = scales.x!.scale(new Date('2020-01-01')) as number;
108
+ const py = scales.y!.scale(20) as number;
109
+ expect(ann.dot).toBeDefined();
110
+ expect(ann.dot!.x).toBeCloseTo(px, 5);
111
+ expect(ann.dot!.y).toBeCloseTo(py, 5);
112
+ });
113
+
114
+ // An arrowhead is already a terminator; adding a marker under it double-marks
115
+ // the data point.
116
+ it('does not resolve a default dot when the connector has an arrowhead', () => {
117
+ const spec = makeSpec([
118
+ { type: 'text', x: '2020-01-01', y: 20, text: 'Arrowed', connector: 'curve' },
119
+ ]);
120
+ const scales = computeScales(spec, chartArea, spec.data);
121
+ const annotations = computeAnnotations(spec, scales, chartArea, fullStrategy);
122
+
123
+ expect(annotations[0].dot).toBeUndefined();
124
+ });
125
+
126
+ it('does not populate dot when the connector is disabled', () => {
127
+ const spec = makeSpec([
128
+ { type: 'text', x: '2020-01-01', y: 20, text: 'Bare', connector: false },
129
+ ]);
130
+ const scales = computeScales(spec, chartArea, spec.data);
131
+ const annotations = computeAnnotations(spec, scales, chartArea, fullStrategy);
132
+
133
+ expect(annotations[0].dot).toBeUndefined();
134
+ });
135
+
136
+ it('dot: false suppresses the default marker', () => {
137
+ const spec = makeSpec([
138
+ { type: 'text', x: '2020-01-01', y: 20, text: 'No marker', dot: false },
139
+ ]);
61
140
  const scales = computeScales(spec, chartArea, spec.data);
62
141
  const annotations = computeAnnotations(spec, scales, chartArea, fullStrategy);
63
142
 
@@ -74,7 +153,8 @@ describe('text annotation: dot', () => {
74
153
  expect(dot!.radius).toBe(DEFAULT_DOT_RADIUS);
75
154
  expect(dot!.strokeWidth).toBe(DEFAULT_DOT_STROKE_WIDTH);
76
155
  expect(dot!.fill).toBe(LIGHT_DOT_FILL);
77
- expect(dot!.stroke).toBe(LIGHT_TEXT_FILL);
156
+ // Marker and its (non-arrowed) leader share the quiet gray.
157
+ expect(dot!.stroke).toBe(LIGHT_CONNECTOR_STROKE);
78
158
  });
79
159
 
80
160
  it('populates dot with dark-mode defaults when isDark is true', () => {
@@ -85,7 +165,7 @@ describe('text annotation: dot', () => {
85
165
  const dot = annotations[0].dot;
86
166
  expect(dot).toBeDefined();
87
167
  expect(dot!.fill).toBe(DARK_DOT_FILL);
88
- expect(dot!.stroke).toBe(DARK_TEXT_FILL);
168
+ expect(dot!.stroke).toBe(DARK_CONNECTOR_STROKE);
89
169
  });
90
170
 
91
171
  it('respects user-supplied dot style overrides', () => {
@@ -121,29 +201,45 @@ describe('text annotation: dot', () => {
121
201
  expect(dot!.radius).toBe(3);
122
202
  // Other fields fall back to defaults.
123
203
  expect(dot!.fill).toBe(LIGHT_DOT_FILL);
124
- expect(dot!.stroke).toBe(LIGHT_TEXT_FILL);
204
+ expect(dot!.stroke).toBe(LIGHT_CONNECTOR_STROKE);
125
205
  expect(dot!.strokeWidth).toBe(DEFAULT_DOT_STROKE_WIDTH);
126
206
  });
127
207
 
128
- it('dot coordinates match the connector "to" endpoint exactly', () => {
129
- const spec = makeSpec([{ type: 'text', x: '2020-01-01', y: 20, text: 'Co-render', dot: true }]);
208
+ // The marker sits ON the data point; the connector stops short of it. The old
209
+ // behavior put the dot at the pulled-back connector tip, which offset it from
210
+ // the point it was marking.
211
+ it('dot coordinates sit exactly on the data point, not the pulled-back connector tip', () => {
212
+ const spec = makeSpec([
213
+ {
214
+ type: 'text',
215
+ x: '2020-01-01',
216
+ y: 20,
217
+ text: 'Co-render',
218
+ dot: true,
219
+ offset: { dx: 60, dy: -60 },
220
+ },
221
+ ]);
130
222
  const scales = computeScales(spec, chartArea, spec.data);
131
223
  const annotations = computeAnnotations(spec, scales, chartArea, fullStrategy);
132
224
 
133
225
  const resolved = annotations[0];
226
+ const px = scales.x!.scale(new Date('2020-01-01')) as number;
227
+ const py = scales.y!.scale(20) as number;
228
+
134
229
  expect(resolved.dot).toBeDefined();
135
- expect(resolved.label?.connector).toBeDefined();
136
- expect(resolved.dot!.x).toBe(resolved.label!.connector!.to.x);
137
- expect(resolved.dot!.y).toBe(resolved.label!.connector!.to.y);
230
+ expect(resolved.dot!.x).toBeCloseTo(px, 5);
231
+ expect(resolved.dot!.y).toBeCloseTo(py, 5);
232
+
233
+ // The connector stops short of the marker.
234
+ const connector = resolved.label!.connector!;
235
+ const gap = Math.hypot(px - connector.to.x, py - connector.to.y);
236
+ expect(gap).toBeGreaterThan(resolved.dot!.radius);
138
237
  });
139
238
 
140
239
  it('dot coordinates apply user connectorOffset.to', () => {
141
- const withoutOffset = computeAnnotations(
142
- makeSpec([{ type: 'text', x: '2020-01-01', y: 20, text: 'A', dot: true }]),
143
- computeScales(makeSpec([]), chartArea, []),
144
- chartArea,
145
- fullStrategy,
146
- );
240
+ const baseSpec = makeSpec([{ type: 'text', x: '2020-01-01', y: 20, text: 'A', dot: true }]);
241
+ const baseScales = computeScales(baseSpec, chartArea, baseSpec.data);
242
+ const withoutOffset = computeAnnotations(baseSpec, baseScales, chartArea, fullStrategy);
147
243
  const baseDot = withoutOffset[0].dot!;
148
244
 
149
245
  const withOffsetSpec = makeSpec([
@@ -160,12 +256,57 @@ describe('text annotation: dot', () => {
160
256
  const withOffset = computeAnnotations(withOffsetSpec, scales2, chartArea, fullStrategy);
161
257
  const offsetDot = withOffset[0].dot!;
162
258
 
163
- // The user's connector offset shifts the data-side endpoint, so the dot
164
- // should track it. Exact equality with connector.to must hold.
165
- expect(offsetDot.x).toBe(withOffset[0].label!.connector!.to.x);
166
- expect(offsetDot.y).toBe(withOffset[0].label!.connector!.to.y);
167
- // And it should differ from the un-offset case (sanity check).
168
- expect(offsetDot.x).not.toBe(baseDot.x);
259
+ // The user's connector offset shifts the data-side endpoint, so the marker
260
+ // tracks it.
261
+ expect(offsetDot.x).toBeCloseTo(baseDot.x + 10, 5);
262
+ expect(offsetDot.y).toBeCloseTo(baseDot.y - 5, 5);
263
+ });
264
+ });
265
+
266
+ describe('text annotation: background', () => {
267
+ it('does not populate background when not specified', () => {
268
+ const spec = makeSpec([{ type: 'text', x: '2020-01-01', y: 20, text: 'No plate' }]);
269
+ const scales = computeScales(spec, chartArea, spec.data);
270
+ const annotations = computeAnnotations(spec, scales, chartArea, fullStrategy);
271
+
272
+ expect(annotations[0].label.background).toBeUndefined();
273
+ });
274
+
275
+ it('resolves background: true to the light surface in light mode', () => {
276
+ const spec = makeSpec([
277
+ { type: 'text', x: '2020-01-01', y: 20, text: 'Plate', background: true },
278
+ ]);
279
+ const scales = computeScales(spec, chartArea, spec.data);
280
+ const annotations = computeAnnotations(spec, scales, chartArea, fullStrategy, false);
281
+
282
+ expect(annotations[0].label.background).toBe(LIGHT_LABEL_BACKGROUND);
283
+ });
284
+
285
+ // The dark-mode regression: `background: true` must NOT stay white, or the
286
+ // theme's light text renders light-gray-on-white and is unreadable.
287
+ it('resolves background: true to the dark surface when isDark is true', () => {
288
+ const spec = makeSpec([
289
+ { type: 'text', x: '2020-01-01', y: 20, text: 'Plate', background: true },
290
+ ]);
291
+ const scales = computeScales(spec, chartArea, spec.data);
292
+ const annotations = computeAnnotations(spec, scales, chartArea, fullStrategy, true);
293
+
294
+ expect(annotations[0].label.background).toBe(DARK_LABEL_BACKGROUND);
295
+ expect(annotations[0].label.background).not.toBe(LIGHT_LABEL_BACKGROUND);
296
+ });
297
+
298
+ it('passes an explicit background color through unchanged in both modes', () => {
299
+ const spec = makeSpec([
300
+ { type: 'text', x: '2020-01-01', y: 20, text: 'Plate', background: '#ff00ff' },
301
+ ]);
302
+ const scales = computeScales(spec, chartArea, spec.data);
303
+
304
+ expect(
305
+ computeAnnotations(spec, scales, chartArea, fullStrategy, false)[0].label.background,
306
+ ).toBe('#ff00ff');
307
+ expect(
308
+ computeAnnotations(spec, scales, chartArea, fullStrategy, true)[0].label.background,
309
+ ).toBe('#ff00ff');
169
310
  });
170
311
  });
171
312
 
@@ -273,16 +414,375 @@ describe('text annotation: dot + subtitle co-resolution', () => {
273
414
  text: 'Big moment',
274
415
  subtitle: 'Adjusted for inflation',
275
416
  dot: true,
417
+ offset: { dx: 60, dy: -60 },
276
418
  },
277
419
  ]);
278
420
  const scales = computeScales(spec, chartArea, spec.data);
279
421
  const annotations = computeAnnotations(spec, scales, chartArea, fullStrategy);
280
422
 
281
423
  const resolved = annotations[0];
424
+ const px = scales.x!.scale(new Date('2020-01-01')) as number;
425
+ const py = scales.y!.scale(20) as number;
426
+
282
427
  expect(resolved.dot).toBeDefined();
283
428
  expect(resolved.subtitle).toBeDefined();
284
- // Connector + dot still co-render at the same point.
285
- expect(resolved.dot!.x).toBe(resolved.label!.connector!.to.x);
286
- expect(resolved.dot!.y).toBe(resolved.label!.connector!.to.y);
429
+ expect(resolved.dot!.x).toBeCloseTo(px, 5);
430
+ expect(resolved.dot!.y).toBeCloseTo(py, 5);
431
+ expect(resolved.label!.connector).toBeDefined();
432
+ });
433
+
434
+ // The connector must clear the subtitle too, not just the primary line. The
435
+ // subtitle is often the wider of the two.
436
+ it('connector origin clears the subtitle when the subtitle is the wider line', () => {
437
+ const spec = makeSpec([
438
+ {
439
+ type: 'text',
440
+ x: '2020-01-01',
441
+ y: 20,
442
+ text: 'Short',
443
+ subtitle: 'A considerably longer subtitle line than the primary',
444
+ // Label sits below-left of the point, so the connector exits up/right
445
+ // and would cross the subtitle if bounds ignored it.
446
+ offset: { dx: 0, dy: 90 },
447
+ },
448
+ ]);
449
+ const scales = computeScales(spec, chartArea, spec.data);
450
+ const annotations = computeAnnotations(spec, scales, chartArea, fullStrategy);
451
+
452
+ const ann = annotations[0];
453
+ const connector = ann.label!.connector!;
454
+ const bounds = ann.bounds!;
455
+
456
+ // The origin must sit outside the full (label ∪ subtitle) block.
457
+ const insideX = connector.from.x >= bounds.x && connector.from.x <= bounds.x + bounds.width;
458
+ const insideY = connector.from.y >= bounds.y && connector.from.y <= bounds.y + bounds.height;
459
+ expect(insideX && insideY).toBe(false);
460
+ });
461
+ });
462
+
463
+ describe('text annotation: connector suppression', () => {
464
+ // A label pulled back toward its point leaves no room for a leader: the
465
+ // standoff and marker pullback eat the whole span, and what's left is a nub
466
+ // touching the marker it's meant to point at. Suppress it, keep the dot.
467
+ // (anchor 'top' sets the label ANCHOR_OFFSET above the point; +dy drags it
468
+ // back down onto it.)
469
+ it('suppresses a connector shorter than MIN_CONNECTOR_LENGTH but keeps the dot', () => {
470
+ const spec = makeSpec([
471
+ {
472
+ type: 'text',
473
+ x: '2020-01-01',
474
+ y: 20,
475
+ text: 'Tiny leader',
476
+ anchor: 'top',
477
+ offset: { dx: 0, dy: 16 },
478
+ },
479
+ ]);
480
+ const scales = computeScales(spec, chartArea, spec.data);
481
+ const annotations = computeAnnotations(spec, scales, chartArea, fullStrategy);
482
+
483
+ expect(annotations[0].label!.connector).toBeUndefined();
484
+ expect(annotations[0].dot).toBeDefined();
485
+ });
486
+
487
+ // The default (no offset, no anchor) must draw a leader. A bare annotation is
488
+ // the spec an author writes first, and floating text with no line back to the
489
+ // data is exactly what this redesign set out to kill.
490
+ it('draws a connector for a bare text annotation with no offset', () => {
491
+ const spec = makeSpec([{ type: 'text', x: '2020-01-01', y: 20, text: 'Default callout' }]);
492
+ const scales = computeScales(spec, chartArea, spec.data);
493
+ const annotations = computeAnnotations(spec, scales, chartArea, fullStrategy);
494
+
495
+ const connector = annotations[0].label!.connector;
496
+ expect(connector).toBeDefined();
497
+ const length = Math.hypot(
498
+ connector!.to.x - connector!.from.x,
499
+ connector!.to.y - connector!.from.y,
500
+ );
501
+ expect(length).toBeGreaterThanOrEqual(MIN_CONNECTOR_LENGTH);
502
+ expect(annotations[0].dot).toBeDefined();
503
+ });
504
+
505
+ it('suppresses the connector when the data point lands inside the text block', () => {
506
+ // Offset pulls the label back over its own point so the box straddles it.
507
+ // A connector here would be drawn straight through the text.
508
+ const spec = makeSpec([
509
+ {
510
+ type: 'text',
511
+ x: '2020-01-01',
512
+ y: 20,
513
+ text: 'The point sits inside this label',
514
+ offset: { dx: -110, dy: 4 },
515
+ },
516
+ ]);
517
+ const scales = computeScales(spec, chartArea, spec.data);
518
+ const annotations = computeAnnotations(spec, scales, chartArea, fullStrategy);
519
+
520
+ expect(annotations[0].label!.connector).toBeUndefined();
521
+ expect(annotations[0].dot).toBeDefined();
522
+ });
523
+
524
+ it('keeps the connector once the label is far enough from the point', () => {
525
+ const spec = makeSpec([
526
+ { type: 'text', x: '2020-01-01', y: 20, text: 'Far label', offset: { dx: 0, dy: -60 } },
527
+ ]);
528
+ const scales = computeScales(spec, chartArea, spec.data);
529
+ const annotations = computeAnnotations(spec, scales, chartArea, fullStrategy);
530
+
531
+ const connector = annotations[0].label!.connector!;
532
+ expect(connector).toBeDefined();
533
+ const length = Math.hypot(connector.to.x - connector.from.x, connector.to.y - connector.from.y);
534
+ expect(length).toBeGreaterThanOrEqual(MIN_CONNECTOR_LENGTH);
535
+ });
536
+ });
537
+
538
+ describe('text annotation: connector config', () => {
539
+ it('bare "curve" string defaults arrow to true', () => {
540
+ const spec = makeSpec([
541
+ {
542
+ type: 'text',
543
+ x: '2020-01-01',
544
+ y: 20,
545
+ text: 'Curve',
546
+ connector: 'curve',
547
+ offset: { dx: 0, dy: -60 },
548
+ },
549
+ ]);
550
+ const scales = computeScales(spec, chartArea, spec.data);
551
+ const annotations = computeAnnotations(spec, scales, chartArea, fullStrategy);
552
+
553
+ const c = annotations[0].label!.connector!;
554
+ expect(c.style).toBe('curve');
555
+ expect(c.arrow).toBe(true);
556
+ });
557
+
558
+ it('bare "straight" string defaults arrow to false', () => {
559
+ const spec = makeSpec([
560
+ {
561
+ type: 'text',
562
+ x: '2020-01-01',
563
+ y: 20,
564
+ text: 'Straight',
565
+ connector: 'straight',
566
+ offset: { dx: 0, dy: -60 },
567
+ },
568
+ ]);
569
+ const scales = computeScales(spec, chartArea, spec.data);
570
+ const annotations = computeAnnotations(spec, scales, chartArea, fullStrategy);
571
+
572
+ const c = annotations[0].label!.connector!;
573
+ expect(c.style).toBe('straight');
574
+ expect(c.arrow).toBe(false);
575
+ });
576
+
577
+ it('boolean true defaults to straight with no arrow', () => {
578
+ const spec = makeSpec([
579
+ {
580
+ type: 'text',
581
+ x: '2020-01-01',
582
+ y: 20,
583
+ text: 'Default',
584
+ connector: true,
585
+ offset: { dx: 0, dy: -60 },
586
+ },
587
+ ]);
588
+ const scales = computeScales(spec, chartArea, spec.data);
589
+ const annotations = computeAnnotations(spec, scales, chartArea, fullStrategy);
590
+
591
+ const c = annotations[0].label!.connector!;
592
+ expect(c.style).toBe('straight');
593
+ expect(c.arrow).toBe(false);
594
+ });
595
+
596
+ it('object form { type: "straight", arrow: true } enables arrow on straight', () => {
597
+ const spec = makeSpec([
598
+ {
599
+ type: 'text',
600
+ x: '2020-01-01',
601
+ y: 20,
602
+ text: 'Arrow straight',
603
+ connector: { type: 'straight', arrow: true },
604
+ offset: { dx: 0, dy: -60 },
605
+ },
606
+ ]);
607
+ const scales = computeScales(spec, chartArea, spec.data);
608
+ const annotations = computeAnnotations(spec, scales, chartArea, fullStrategy);
609
+
610
+ const c = annotations[0].label!.connector!;
611
+ expect(c.style).toBe('straight');
612
+ expect(c.arrow).toBe(true);
613
+ });
614
+
615
+ it('object form { type: "curve", arrow: false } disables arrow on curve', () => {
616
+ const spec = makeSpec([
617
+ {
618
+ type: 'text',
619
+ x: '2020-01-01',
620
+ y: 20,
621
+ text: 'No arrow curve',
622
+ connector: { type: 'curve', arrow: false },
623
+ offset: { dx: 0, dy: -60 },
624
+ },
625
+ ]);
626
+ const scales = computeScales(spec, chartArea, spec.data);
627
+ const annotations = computeAnnotations(spec, scales, chartArea, fullStrategy);
628
+
629
+ const c = annotations[0].label!.connector!;
630
+ expect(c.style).toBe('curve');
631
+ expect(c.arrow).toBe(false);
632
+ });
633
+
634
+ it('object form { type: "curve" } defaults arrow to true', () => {
635
+ const spec = makeSpec([
636
+ {
637
+ type: 'text',
638
+ x: '2020-01-01',
639
+ y: 20,
640
+ text: 'Default curve',
641
+ connector: { type: 'curve' },
642
+ offset: { dx: 0, dy: -60 },
643
+ },
644
+ ]);
645
+ const scales = computeScales(spec, chartArea, spec.data);
646
+ const annotations = computeAnnotations(spec, scales, chartArea, fullStrategy);
647
+
648
+ const c = annotations[0].label!.connector!;
649
+ expect(c.style).toBe('curve');
650
+ expect(c.arrow).toBe(true);
651
+ });
652
+
653
+ it('connector: false disables connector entirely', () => {
654
+ const spec = makeSpec([
655
+ { type: 'text', x: '2020-01-01', y: 20, text: 'No connector', connector: false },
656
+ ]);
657
+ const scales = computeScales(spec, chartArea, spec.data);
658
+ const annotations = computeAnnotations(spec, scales, chartArea, fullStrategy);
659
+
660
+ expect(annotations[0].label!.connector).toBeUndefined();
661
+ });
662
+
663
+ it('drop-line string connector always has arrow: false', () => {
664
+ const spec = makeSpec([
665
+ { type: 'text', x: '2020-01-01', y: 20, text: 'Drop line', connector: 'drop-line' },
666
+ ]);
667
+ const scales = computeScales(spec, chartArea, spec.data);
668
+ const annotations = computeAnnotations(spec, scales, chartArea, fullStrategy);
669
+
670
+ const c = annotations[0].label!.connector!;
671
+ expect(c.style).toBe('drop-line');
672
+ expect(c.arrow).toBe(false);
673
+ });
674
+
675
+ it('object form { type: "drop-line", arrow: true } clamps arrow to false', () => {
676
+ const spec = makeSpec([
677
+ {
678
+ type: 'text',
679
+ x: '2020-01-01',
680
+ y: 20,
681
+ text: 'Drop line forced',
682
+ connector: { type: 'drop-line', arrow: true },
683
+ },
684
+ ]);
685
+ const scales = computeScales(spec, chartArea, spec.data);
686
+ const annotations = computeAnnotations(spec, scales, chartArea, fullStrategy);
687
+
688
+ const c = annotations[0].label!.connector!;
689
+ expect(c.style).toBe('drop-line');
690
+ expect(c.arrow).toBe(false);
691
+ });
692
+ });
693
+
694
+ describe('text annotation: typography', () => {
695
+ it('defaults to the regular annotation font size and weight', () => {
696
+ const spec = makeSpec([{ type: 'text', x: '2020-01-01', y: 20, text: 'Plain' }]);
697
+ const scales = computeScales(spec, chartArea, spec.data);
698
+ const annotations = computeAnnotations(spec, scales, chartArea, fullStrategy);
699
+
700
+ const style = annotations[0].label!.style;
701
+ expect(style.fontSize).toBe(DEFAULT_ANNOTATION_FONT_SIZE);
702
+ expect(style.fontWeight).toBe(DEFAULT_ANNOTATION_FONT_WEIGHT);
703
+ });
704
+
705
+ // Lede rule: a subtitle turns the primary line into a lede, so it goes bold.
706
+ it('promotes the primary text to the lede weight when a subtitle is present', () => {
707
+ const spec = makeSpec([
708
+ { type: 'text', x: '2020-01-01', y: 20, text: 'Feb. 25', subtitle: '2015 maximum' },
709
+ ]);
710
+ const scales = computeScales(spec, chartArea, spec.data);
711
+ const annotations = computeAnnotations(spec, scales, chartArea, fullStrategy);
712
+
713
+ expect(annotations[0].label!.style.fontWeight).toBe(LEDE_FONT_WEIGHT);
714
+ // The subtitle never inherits it.
715
+ expect(annotations[0].subtitle!.style.fontWeight).toBe(SUBTITLE_FONT_WEIGHT);
716
+ });
717
+
718
+ it('an explicit fontWeight wins over the lede rule', () => {
719
+ const spec = makeSpec([
720
+ {
721
+ type: 'text',
722
+ x: '2020-01-01',
723
+ y: 20,
724
+ text: 'Feb. 25',
725
+ subtitle: '2015 maximum',
726
+ fontWeight: 500,
727
+ },
728
+ ]);
729
+ const scales = computeScales(spec, chartArea, spec.data);
730
+ const annotations = computeAnnotations(spec, scales, chartArea, fullStrategy);
731
+
732
+ expect(annotations[0].label!.style.fontWeight).toBe(500);
733
+ });
734
+
735
+ it('threads the theme font family into text, range, and refline labels', () => {
736
+ const spec = makeSpec([
737
+ { type: 'text', x: '2020-01-01', y: 20, text: 'Callout' },
738
+ { type: 'range', x: ['2019-01-01', '2020-01-01'], label: 'Band' },
739
+ { type: 'refline', y: 25, label: 'Target' },
740
+ ]);
741
+ const scales = computeScales(spec, chartArea, spec.data);
742
+ const annotations = computeAnnotations(spec, {
743
+ scales,
744
+ chartArea,
745
+ strategy: fullStrategy,
746
+ isDark: false,
747
+ obstacles: [],
748
+ svg: { width: 600, height: 360 },
749
+ measure: heuristicMeasure,
750
+ fontFamily: 'Georgia, serif',
751
+ });
752
+
753
+ for (const ann of annotations) {
754
+ expect(ann.label!.style.fontFamily).toBe('Georgia, serif');
755
+ }
756
+ });
757
+
758
+ it('falls back to the default font stack when no theme font is threaded', () => {
759
+ const spec = makeSpec([{ type: 'text', x: '2020-01-01', y: 20, text: 'Callout' }]);
760
+ const scales = computeScales(spec, chartArea, spec.data);
761
+ const annotations = computeAnnotations(spec, scales, chartArea, fullStrategy);
762
+
763
+ expect(annotations[0].label!.style.fontFamily).toBe(FALLBACK_FONT_FAMILY);
764
+ });
765
+ });
766
+
767
+ describe('text annotation: alignment', () => {
768
+ // Text blocks are never centered: they align on the edge facing the point.
769
+ it('left-aligns multi-line text instead of centering it', () => {
770
+ const spec = makeSpec([
771
+ { type: 'text', x: '2020-01-01', y: 20, text: 'First line\nSecond line' },
772
+ ]);
773
+ const scales = computeScales(spec, chartArea, spec.data);
774
+ const annotations = computeAnnotations(spec, scales, chartArea, fullStrategy);
775
+
776
+ expect(annotations[0].label!.style.textAnchor).toBe('start');
777
+ });
778
+
779
+ it('right-aligns the block when it sits to the left of the point', () => {
780
+ const spec = makeSpec([
781
+ { type: 'text', x: '2020-01-01', y: 20, text: 'First line\nSecond line', anchor: 'left' },
782
+ ]);
783
+ const scales = computeScales(spec, chartArea, spec.data);
784
+ const annotations = computeAnnotations(spec, scales, chartArea, fullStrategy);
785
+
786
+ expect(annotations[0].label!.style.textAnchor).toBe('end');
287
787
  });
288
788
  });