@opendata-ai/openchart-engine 8.0.0-rc.9 → 8.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (271) hide show
  1. package/dist/index.d.ts +149 -12
  2. package/dist/index.js +804 -155
  3. package/dist/index.js.map +1 -1
  4. package/package.json +3 -4
  5. package/src/__test-fixtures__/specs.ts +0 -182
  6. package/src/__tests__/__snapshots__/compile-snapshot.test.ts.snap +0 -2401
  7. package/src/__tests__/annotation-behavior.test.ts +0 -852
  8. package/src/__tests__/axes.test.ts +0 -1579
  9. package/src/__tests__/compile-animation.test.ts +0 -161
  10. package/src/__tests__/compile-chart.test.ts +0 -1303
  11. package/src/__tests__/compile-footnotes.test.ts +0 -277
  12. package/src/__tests__/compile-layer.test.ts +0 -969
  13. package/src/__tests__/compile-snapshot.test.ts +0 -156
  14. package/src/__tests__/compound-labels.test.ts +0 -147
  15. package/src/__tests__/dimensions.test.ts +0 -918
  16. package/src/__tests__/encoding-sugar.test.ts +0 -390
  17. package/src/__tests__/facet.test.ts +0 -215
  18. package/src/__tests__/helpers/invariants.ts +0 -299
  19. package/src/__tests__/known-bugs.test.ts +0 -566
  20. package/src/__tests__/layout-fuzz.test.ts +0 -358
  21. package/src/__tests__/layout-invariants.test.ts +0 -535
  22. package/src/__tests__/legend.test.ts +0 -721
  23. package/src/__tests__/mark-keys.test.ts +0 -294
  24. package/src/__tests__/ranking-recipes.test.ts +0 -260
  25. package/src/__tests__/rotation.test.ts +0 -83
  26. package/src/__tests__/scales.test.ts +0 -323
  27. package/src/__tests__/series-search.test.ts +0 -92
  28. package/src/__tests__/spec-sugar.test.ts +0 -801
  29. package/src/__tests__/you-draw-it.test.ts +0 -160
  30. package/src/annotations/__tests__/compute.test.ts +0 -1672
  31. package/src/annotations/__tests__/geometry.test.ts +0 -66
  32. package/src/annotations/__tests__/placement-fuzz.test.ts +0 -256
  33. package/src/annotations/__tests__/placement-invariants.test.ts +0 -345
  34. package/src/annotations/__tests__/placement.test.ts +0 -316
  35. package/src/annotations/__tests__/resolve-text.test.ts +0 -788
  36. package/src/annotations/__tests__/rich-text.test.ts +0 -91
  37. package/src/annotations/__tests__/thinning.test.ts +0 -285
  38. package/src/annotations/collisions.ts +0 -326
  39. package/src/annotations/compute.ts +0 -333
  40. package/src/annotations/constants.ts +0 -162
  41. package/src/annotations/geometry.ts +0 -376
  42. package/src/annotations/move.ts +0 -135
  43. package/src/annotations/placement.ts +0 -636
  44. package/src/annotations/position.ts +0 -131
  45. package/src/annotations/resolve-range.ts +0 -117
  46. package/src/annotations/resolve-refline.ts +0 -157
  47. package/src/annotations/resolve-text.ts +0 -545
  48. package/src/annotations/rich-text.ts +0 -65
  49. package/src/annotations/thinning.ts +0 -190
  50. package/src/barlist/__tests__/compile-barlist.test.ts +0 -200
  51. package/src/barlist/compile-barlist.ts +0 -391
  52. package/src/barlist/types.ts +0 -28
  53. package/src/charts/__tests__/post-process.test.ts +0 -237
  54. package/src/charts/__tests__/registry.test.ts +0 -116
  55. package/src/charts/__tests__/utils.test.ts +0 -195
  56. package/src/charts/_shared/__tests__/density-filter.test.ts +0 -32
  57. package/src/charts/_shared/density-filter.ts +0 -26
  58. package/src/charts/_shared/format-label-value.ts +0 -16
  59. package/src/charts/bar/__tests__/compute.test.ts +0 -934
  60. package/src/charts/bar/__tests__/gradient-orient.test.ts +0 -127
  61. package/src/charts/bar/__tests__/labels.test.ts +0 -292
  62. package/src/charts/bar/compute.ts +0 -539
  63. package/src/charts/bar/index.ts +0 -60
  64. package/src/charts/bar/labels.ts +0 -311
  65. package/src/charts/beeswarm/__tests__/compute.test.ts +0 -280
  66. package/src/charts/beeswarm/__tests__/dodge.test.ts +0 -148
  67. package/src/charts/beeswarm/compute.ts +0 -203
  68. package/src/charts/beeswarm/dodge.ts +0 -155
  69. package/src/charts/beeswarm/index.ts +0 -32
  70. package/src/charts/builtin.ts +0 -75
  71. package/src/charts/calendar/__tests__/compute.test.ts +0 -417
  72. package/src/charts/calendar/compute.ts +0 -319
  73. package/src/charts/calendar/index.ts +0 -32
  74. package/src/charts/column/__tests__/compute.test.ts +0 -540
  75. package/src/charts/column/__tests__/labels.test.ts +0 -179
  76. package/src/charts/column/compute.ts +0 -493
  77. package/src/charts/column/index.ts +0 -53
  78. package/src/charts/column/labels.ts +0 -229
  79. package/src/charts/dot/__tests__/compute.test.ts +0 -380
  80. package/src/charts/dot/__tests__/labels.test.ts +0 -98
  81. package/src/charts/dot/compute.ts +0 -285
  82. package/src/charts/dot/index.ts +0 -62
  83. package/src/charts/dot/labels.ts +0 -121
  84. package/src/charts/line/__tests__/compute.test.ts +0 -1194
  85. package/src/charts/line/__tests__/curves.test.ts +0 -51
  86. package/src/charts/line/__tests__/labels.test.ts +0 -94
  87. package/src/charts/line/__tests__/mark-options.test.ts +0 -175
  88. package/src/charts/line/area.ts +0 -484
  89. package/src/charts/line/compute.ts +0 -356
  90. package/src/charts/line/curves.ts +0 -85
  91. package/src/charts/line/index.ts +0 -176
  92. package/src/charts/line/labels.ts +0 -177
  93. package/src/charts/parliament/__tests__/compute.test.ts +0 -411
  94. package/src/charts/parliament/compute.ts +0 -313
  95. package/src/charts/parliament/index.ts +0 -32
  96. package/src/charts/pie/__tests__/compute.test.ts +0 -384
  97. package/src/charts/pie/__tests__/labels.test.ts +0 -132
  98. package/src/charts/pie/compute.ts +0 -324
  99. package/src/charts/pie/index.ts +0 -49
  100. package/src/charts/pie/labels.ts +0 -143
  101. package/src/charts/post-process.ts +0 -229
  102. package/src/charts/range/__tests__/compute.test.ts +0 -486
  103. package/src/charts/range/compute.ts +0 -273
  104. package/src/charts/range/index.ts +0 -82
  105. package/src/charts/range/labels.ts +0 -169
  106. package/src/charts/rect/__tests__/compute.test.ts +0 -120
  107. package/src/charts/rect/index.ts +0 -127
  108. package/src/charts/registry.ts +0 -72
  109. package/src/charts/rule/index.ts +0 -139
  110. package/src/charts/scatter/__tests__/compute.test.ts +0 -488
  111. package/src/charts/scatter/__tests__/trendline.test.ts +0 -191
  112. package/src/charts/scatter/compute.ts +0 -192
  113. package/src/charts/scatter/index.ts +0 -46
  114. package/src/charts/scatter/trendline.ts +0 -100
  115. package/src/charts/text/__tests__/compute.test.ts +0 -307
  116. package/src/charts/text/index.ts +0 -141
  117. package/src/charts/tick/__tests__/compute.test.ts +0 -75
  118. package/src/charts/tick/index.ts +0 -99
  119. package/src/charts/utils.ts +0 -170
  120. package/src/charts/waffle/__tests__/compute.test.ts +0 -305
  121. package/src/charts/waffle/compute.ts +0 -197
  122. package/src/charts/waffle/index.ts +0 -31
  123. package/src/compile/__tests__/color-scale-range.test.ts +0 -158
  124. package/src/compile/__tests__/color-scale-reverse.test.ts +0 -89
  125. package/src/compile/__tests__/data-clip.test.ts +0 -127
  126. package/src/compile/__tests__/fill-patterns.test.ts +0 -154
  127. package/src/compile/__tests__/size-scale.test.ts +0 -108
  128. package/src/compile/__tests__/watermark-obstacle.test.ts +0 -93
  129. package/src/compile/color-scale-range.ts +0 -101
  130. package/src/compile/data-clip.ts +0 -48
  131. package/src/compile/fill-patterns.ts +0 -74
  132. package/src/compile/layer.ts +0 -630
  133. package/src/compile/size-scale.ts +0 -141
  134. package/src/compile/spec-sugar.ts +0 -758
  135. package/src/compile/watermark-obstacle.ts +0 -54
  136. package/src/compile/you-draw-it.ts +0 -123
  137. package/src/compile.ts +0 -1503
  138. package/src/compiler/__tests__/a11y-warnings.test.ts +0 -148
  139. package/src/compiler/__tests__/animation.test.ts +0 -177
  140. package/src/compiler/__tests__/compile.test.ts +0 -87
  141. package/src/compiler/__tests__/fixtures/vl-parity/axis-null.json +0 -19
  142. package/src/compiler/__tests__/fixtures/vl-parity/color-value.json +0 -20
  143. package/src/compiler/__tests__/fixtures/vl-parity/count-aggregate.json +0 -22
  144. package/src/compiler/__tests__/fixtures/vl-parity/data-values.json +0 -19
  145. package/src/compiler/__tests__/fixtures/vl-parity/layer-axis-null.json +0 -14
  146. package/src/compiler/__tests__/fixtures/vl-parity/layer-count.json +0 -34
  147. package/src/compiler/__tests__/fixtures/vl-parity/layer-data-values.json +0 -22
  148. package/src/compiler/__tests__/fixtures/vl-parity/layer-legend-null.json +0 -41
  149. package/src/compiler/__tests__/fixtures/vl-parity/layer-scheme.json +0 -38
  150. package/src/compiler/__tests__/fixtures/vl-parity/layer-sort.json +0 -28
  151. package/src/compiler/__tests__/fixtures/vl-parity/layer-theta.json +0 -28
  152. package/src/compiler/__tests__/fixtures/vl-parity/layer-title.json +0 -30
  153. package/src/compiler/__tests__/fixtures/vl-parity/layer-value-def.json +0 -29
  154. package/src/compiler/__tests__/fixtures/vl-parity/legend-null.json +0 -32
  155. package/src/compiler/__tests__/fixtures/vl-parity/scheme.json +0 -25
  156. package/src/compiler/__tests__/fixtures/vl-parity/sort-by-value.json +0 -19
  157. package/src/compiler/__tests__/fixtures/vl-parity/theta-arc.json +0 -19
  158. package/src/compiler/__tests__/fixtures/vl-parity/title-subtitle.json +0 -21
  159. package/src/compiler/__tests__/fixtures/vl-parity/width-height.json +0 -23
  160. package/src/compiler/__tests__/keys.test.ts +0 -83
  161. package/src/compiler/__tests__/normalize-highlight.test.ts +0 -103
  162. package/src/compiler/__tests__/normalize.test.ts +0 -399
  163. package/src/compiler/__tests__/sparkline-defaults.test.ts +0 -153
  164. package/src/compiler/__tests__/validate-did-you-mean.test.ts +0 -170
  165. package/src/compiler/__tests__/validate-highlight.test.ts +0 -112
  166. package/src/compiler/__tests__/validate-near-miss.test.ts +0 -251
  167. package/src/compiler/__tests__/validate.test.ts +0 -629
  168. package/src/compiler/__tests__/vl-parity.test.ts +0 -68
  169. package/src/compiler/a11y-warnings.ts +0 -110
  170. package/src/compiler/animation.ts +0 -142
  171. package/src/compiler/index.ts +0 -47
  172. package/src/compiler/keys.ts +0 -55
  173. package/src/compiler/normalize.ts +0 -734
  174. package/src/compiler/sparkline-defaults.ts +0 -138
  175. package/src/compiler/types.ts +0 -241
  176. package/src/compiler/validate.ts +0 -2041
  177. package/src/endpoint-labels/__tests__/compute.test.ts +0 -587
  178. package/src/endpoint-labels/compute.ts +0 -621
  179. package/src/endpoint-labels/constants.ts +0 -54
  180. package/src/endpoint-labels/format.ts +0 -60
  181. package/src/endpoint-labels/predict.ts +0 -139
  182. package/src/format/__tests__/field-format.test.ts +0 -164
  183. package/src/format/field-format.ts +0 -27
  184. package/src/geo/__tests__/compile-map.test.ts +0 -1128
  185. package/src/geo/compile-map.ts +0 -1376
  186. package/src/geo/join.ts +0 -115
  187. package/src/geo/projections.ts +0 -65
  188. package/src/geo/types.ts +0 -26
  189. package/src/graphs/__tests__/community.test.ts +0 -228
  190. package/src/graphs/__tests__/compile-graph.test.ts +0 -396
  191. package/src/graphs/__tests__/encoding.test.ts +0 -415
  192. package/src/graphs/community.ts +0 -92
  193. package/src/graphs/compile-graph.ts +0 -329
  194. package/src/graphs/encoding.ts +0 -356
  195. package/src/graphs/types.ts +0 -106
  196. package/src/index.ts +0 -177
  197. package/src/layout/__tests__/metrics.test.ts +0 -97
  198. package/src/layout/__tests__/scales-highlight.test.ts +0 -192
  199. package/src/layout/axes/rotation.ts +0 -152
  200. package/src/layout/axes/thinning.ts +0 -98
  201. package/src/layout/axes/ticks.ts +0 -409
  202. package/src/layout/axes.ts +0 -757
  203. package/src/layout/dimensions.ts +0 -912
  204. package/src/layout/facet.ts +0 -91
  205. package/src/layout/gridlines.ts +0 -84
  206. package/src/layout/metrics.ts +0 -132
  207. package/src/layout/plan.ts +0 -631
  208. package/src/layout/scales.ts +0 -1053
  209. package/src/layout/shared.ts +0 -44
  210. package/src/legend/__tests__/continuous.test.ts +0 -318
  211. package/src/legend/__tests__/redundancy.test.ts +0 -108
  212. package/src/legend/__tests__/size.test.ts +0 -348
  213. package/src/legend/__tests__/suppression.test.ts +0 -382
  214. package/src/legend/compute.ts +0 -748
  215. package/src/legend/continuous.ts +0 -327
  216. package/src/legend/size.ts +0 -407
  217. package/src/legend/suppression.ts +0 -226
  218. package/src/legend/wrap.ts +0 -117
  219. package/src/sankey/__tests__/compile-sankey.test.ts +0 -458
  220. package/src/sankey/__tests__/layout.test.ts +0 -165
  221. package/src/sankey/__tests__/node-label-wrap.test.ts +0 -114
  222. package/src/sankey/__tests__/node-sort.test.ts +0 -45
  223. package/src/sankey/compile-sankey.ts +0 -706
  224. package/src/sankey/layout.ts +0 -184
  225. package/src/sankey/types.ts +0 -41
  226. package/src/tables/__tests__/bar-column.test.ts +0 -147
  227. package/src/tables/__tests__/category-colors.test.ts +0 -230
  228. package/src/tables/__tests__/compile-table.test.ts +0 -236
  229. package/src/tables/__tests__/format-cells.test.ts +0 -143
  230. package/src/tables/__tests__/heatmap.test.ts +0 -148
  231. package/src/tables/__tests__/pagination.test.ts +0 -78
  232. package/src/tables/__tests__/search.test.ts +0 -94
  233. package/src/tables/__tests__/sort.test.ts +0 -107
  234. package/src/tables/__tests__/sparkline.test.ts +0 -122
  235. package/src/tables/bar-column.ts +0 -94
  236. package/src/tables/category-colors.ts +0 -78
  237. package/src/tables/compile-table.ts +0 -427
  238. package/src/tables/format-cells.ts +0 -115
  239. package/src/tables/heatmap.ts +0 -141
  240. package/src/tables/pagination.ts +0 -46
  241. package/src/tables/search.ts +0 -66
  242. package/src/tables/sort.ts +0 -69
  243. package/src/tables/sparkline.ts +0 -113
  244. package/src/tables/utils.ts +0 -16
  245. package/src/tilemap/__tests__/compile-tilemap.test.ts +0 -576
  246. package/src/tilemap/compile-tilemap.ts +0 -755
  247. package/src/tilemap/layout.ts +0 -172
  248. package/src/tilemap/types.ts +0 -33
  249. package/src/tooltips/__tests__/compute.test.ts +0 -582
  250. package/src/tooltips/compute.ts +0 -700
  251. package/src/transforms/__tests__/aggregate.test.ts +0 -159
  252. package/src/transforms/__tests__/bin.test.ts +0 -88
  253. package/src/transforms/__tests__/calculate.test.ts +0 -146
  254. package/src/transforms/__tests__/conditional.test.ts +0 -127
  255. package/src/transforms/__tests__/filter-relative.test.ts +0 -202
  256. package/src/transforms/__tests__/filter.test.ts +0 -59
  257. package/src/transforms/__tests__/fold.test.ts +0 -79
  258. package/src/transforms/__tests__/index.test.ts +0 -93
  259. package/src/transforms/__tests__/predicates.test.ts +0 -193
  260. package/src/transforms/__tests__/timeunit.test.ts +0 -129
  261. package/src/transforms/__tests__/window.test.ts +0 -286
  262. package/src/transforms/aggregate.ts +0 -131
  263. package/src/transforms/bin.ts +0 -87
  264. package/src/transforms/calculate.ts +0 -60
  265. package/src/transforms/conditional.ts +0 -46
  266. package/src/transforms/filter.ts +0 -122
  267. package/src/transforms/fold.ts +0 -49
  268. package/src/transforms/index.ts +0 -60
  269. package/src/transforms/predicates.ts +0 -123
  270. package/src/transforms/timeunit.ts +0 -88
  271. package/src/transforms/window.ts +0 -185
@@ -1,852 +0,0 @@
1
- /**
2
- * Behavior characterization suite for the annotation layer.
3
- *
4
- * WHY THIS FILE EXISTS
5
- *
6
- * The rest of the annotation tests (compute.test.ts, resolve-text.test.ts,
7
- * placement*.test.ts) call the resolvers and passes DIRECTLY. That is how a
8
- * `normalize.ts` bug once shipped two whole features as dead code while 3305
9
- * tests passed: every one of them bypassed the pipeline that had the bug.
10
- *
11
- * These tests only ever call `compileChart`. They assert on the `ChartLayout`
12
- * the renderer consumes -- which IS the engine's observable output -- and never
13
- * on which function produced it. That makes them the safety net for refactoring
14
- * the annotation internals: if a change is genuinely behavior-preserving, every
15
- * test here passes untouched. If one fails, behavior moved.
16
- *
17
- * Rules for anything added here:
18
- * - Go through `compileChart`. No importing from `../annotations/*` except
19
- * constants (so a retuned default doesn't fail the test for the wrong reason).
20
- * - Assert on what a reader would SEE: is the label above the point, does the
21
- * leader reach it, is the text bold, does the block overlap the mark.
22
- * - Never assert on a function being called, or on an intermediate field that
23
- * only exists because of how the code is currently organized.
24
- */
25
-
26
- import type {
27
- Annotation,
28
- ChartLayout,
29
- ChartSpec,
30
- ResolvedAnnotation,
31
- } from '@opendata-ai/openchart-core';
32
- import { describe, expect, test } from 'vitest';
33
- import {
34
- ANCHOR_OFFSET,
35
- ARROWHEAD_LENGTH,
36
- DEFAULT_ANNOTATION_FONT_SIZE,
37
- DEFAULT_ANNOTATION_FONT_WEIGHT,
38
- DEFAULT_DOT_RADIUS,
39
- DEFAULT_DOT_STROKE_WIDTH,
40
- LEDE_FONT_WEIGHT,
41
- MIN_CONNECTOR_LENGTH,
42
- SUBTITLE_FONT_WEIGHT,
43
- subtitleFontSize,
44
- } from '../annotations/constants';
45
- import { compileChart } from '../compile';
46
-
47
- // --- helpers ---------------------------------------------------------------
48
-
49
- const SERIES = [
50
- { m: 'Jan', v: 10 },
51
- { m: 'Feb', v: 50 },
52
- { m: 'Mar', v: 30 },
53
- { m: 'Apr', v: 42 },
54
- ];
55
-
56
- type Ann = Annotation;
57
-
58
- /** Compile a line chart carrying `annotations`, and hand back the resolved ones. */
59
- function annotationsFor(annotations: Ann[], width = 800, height = 460): ResolvedAnnotation[] {
60
- const layout: ChartLayout = compileChart(
61
- {
62
- mark: 'line',
63
- data: SERIES,
64
- encoding: {
65
- x: { field: 'm', type: 'ordinal' },
66
- y: { field: 'v', type: 'quantitative', scale: { zero: true } },
67
- },
68
- annotations,
69
- } as ChartSpec,
70
- { width, height },
71
- );
72
- return layout.annotations ?? [];
73
- }
74
-
75
- const first = (annotations: Ann[]) => annotationsFor(annotations)[0];
76
-
77
- /** Length of the leader the renderer will actually stroke. */
78
- function strokedLength(a: ResolvedAnnotation): number | null {
79
- const c = a.label?.connector;
80
- if (!c) return null;
81
- const len = Math.hypot(c.to.x - c.from.x, c.to.y - c.from.y);
82
- return c.arrow ? len - ARROWHEAD_LENGTH : len;
83
- }
84
-
85
- function overlaps(a: { x: number; y: number; width: number; height: number }, b: typeof a) {
86
- return a.x < b.x + b.width && a.x + a.width > b.x && a.y < b.y + b.height && a.y + a.height > b.y;
87
- }
88
-
89
- // --- the block sits where the author asked -------------------------------
90
-
91
- describe('a callout lands where the anchor says it should', () => {
92
- test('a top anchor puts the block above the point, straddling it', () => {
93
- const a = first([{ type: 'text', x: 'Mar', y: 30, text: 'Peak', anchor: 'top' }]);
94
- const b = a.bounds!;
95
- const dot = a.dot!;
96
-
97
- // Above: the whole block clears the point.
98
- expect(b.y + b.height).toBeLessThan(dot.y);
99
- // Straddling: the point is inside the block's horizontal span, not off one end.
100
- expect(b.x).toBeLessThan(dot.x);
101
- expect(b.x + b.width).toBeGreaterThan(dot.x);
102
- });
103
-
104
- test('a bottom anchor puts the block below the point, straddling it', () => {
105
- const a = first([{ type: 'text', x: 'Mar', y: 30, text: 'Peak', anchor: 'bottom' }]);
106
- const b = a.bounds!;
107
- const dot = a.dot!;
108
-
109
- expect(b.y).toBeGreaterThan(dot.y);
110
- expect(b.x).toBeLessThan(dot.x);
111
- expect(b.x + b.width).toBeGreaterThan(dot.x);
112
- });
113
-
114
- test('a left anchor puts the block beside the point, not over it', () => {
115
- const a = first([{ type: 'text', x: 'Mar', y: 30, text: 'Peak', anchor: 'left' }]);
116
- const b = a.bounds!;
117
- const dot = a.dot!;
118
-
119
- // Entirely to the left, clear of the point by the anchor setback.
120
- expect(b.x + b.width).toBeLessThan(dot.x);
121
- expect(dot.x - (b.x + b.width)).toBeGreaterThanOrEqual(ANCHOR_OFFSET - 1);
122
- });
123
-
124
- test('a right anchor puts the block beside the point, not over it', () => {
125
- const a = first([{ type: 'text', x: 'Mar', y: 30, text: 'Peak', anchor: 'right' }]);
126
- const b = a.bounds!;
127
- const dot = a.dot!;
128
-
129
- expect(b.x).toBeGreaterThan(dot.x);
130
- expect(b.x - dot.x).toBeGreaterThanOrEqual(ANCHOR_OFFSET - 1);
131
- });
132
-
133
- // An offset is measured FROM THE DATA POINT, never between two compiles. Adding an
134
- // annotation changes the reserved margins, which resizes the plot, which moves every
135
- // data point -- so `movedLayout.label.x - bareLayout.label.x` is not the offset, it's
136
- // the offset plus a scale shift. `label - dot` is the quantity the reader actually sees.
137
- const labelFromPoint = (a: ResolvedAnnotation) => ({
138
- dx: a.label!.x - a.dot!.x,
139
- dy: a.label!.y - a.dot!.y,
140
- });
141
-
142
- /** Where the block sits relative to its data point, for a given authored offset. */
143
- const offsetBy = (anchor: string, dx: number, dy: number) =>
144
- labelFromPoint(
145
- first([{ type: 'text', x: 'Mar', y: 30, text: 'Peak', anchor, offset: { dx, dy } }]),
146
- );
147
-
148
- // Every reading below carries an offset, so every one takes the explicit path. A
149
- // zero-offset baseline would NOT: with no offset the block is still eligible for the
150
- // obstacle nudge, so it sits wherever that pass shoved it, and a delta measured
151
- // against it is the offset PLUS the nudge. Compare like with like.
152
- test.each([
153
- 'top',
154
- 'bottom',
155
- 'left',
156
- 'right',
157
- ] as const)('an offset translates a %s-anchored block by exactly that much', (anchor) => {
158
- const base = offsetBy(anchor, 1, 1);
159
- const moved = offsetBy(anchor, 26, -14);
160
-
161
- expect(moved.dx - base.dx).toBeCloseTo(25, 5);
162
- expect(moved.dy - base.dy).toBeCloseTo(-15, 5);
163
- });
164
-
165
- test.each([
166
- 'top',
167
- 'bottom',
168
- 'left',
169
- 'right',
170
- ] as const)('an offset on a %s-anchored block is monotonic -- no dead zones, no jumps', (anchor) => {
171
- // The knob has to behave like a knob. This failed hard before explicit offsets
172
- // were made to outrank the obstacle pass: on a line chart the polyline is one
173
- // long obstacle, so a right-anchored `dy: -10` moved the block ZERO pixels while
174
- // `dy: -15` teleported it 56px. Same knob, no monotonicity -- unaimable.
175
- const base = offsetBy(anchor, 0, -5).dy;
176
-
177
- for (const dy of [-10, -15, -20, -40]) {
178
- expect(offsetBy(anchor, 0, dy).dy - base).toBeCloseTo(dy + 5, 5);
179
- }
180
- });
181
-
182
- test('an offset with NO anchor is honored too -- an offset is a placement either way', () => {
183
- // There is exactly one definition of "the author placed this": `isAutoPlacement`,
184
- // which disqualifies ANY annotation carrying an offset from the scored search. So
185
- // an anchorless offset is hand-placed just like an anchored one, and must be just
186
- // as aimable.
187
- //
188
- // A gate here that additionally demanded an `anchor` did not create a second tier
189
- // of annotation -- it just disagreed with the function that had already routed the
190
- // thing, and the whole dead-zone/teleport pathology stayed alive on exactly the
191
- // annotations the extra condition excluded:
192
- //
193
- // dy=-5 -> moved -2.42px dy=-10 -> moved -2.42px (dead zone)
194
- // dy=-15 -> moved -39.75px dy=-20 -> moved -39.75px (teleport, then dead again)
195
- const rel = (dy: number) => {
196
- const a = first([{ type: 'text', x: 'Mar', y: 30, text: 'Peak', offset: { dy } }]);
197
- return a.label!.y - a.dot!.y;
198
- };
199
-
200
- const base = rel(-5);
201
- for (const dy of [-10, -15, -20, -40]) {
202
- expect(rel(dy) - base).toBeCloseTo(dy + 5, 5);
203
- }
204
- });
205
-
206
- test('a pinned callout still gets clamped onto the canvas', () => {
207
- // Pinning exempts a callout from AVOIDANCE, not from staying on the page. An
208
- // offset that would fling it into the void still gets reeled back in -- otherwise
209
- // "explicit wins" becomes a way to render a label nobody can see.
210
- const a = first([
211
- {
212
- type: 'text',
213
- x: 'Mar',
214
- y: 30,
215
- text: 'Way out there',
216
- anchor: 'right',
217
- offset: { dx: 5000, dy: -4000 },
218
- },
219
- ]);
220
- const b = a.bounds!;
221
-
222
- expect(b.x).toBeGreaterThanOrEqual(0);
223
- expect(b.y).toBeGreaterThanOrEqual(0);
224
- expect(b.x + b.width).toBeLessThanOrEqual(800);
225
- expect(b.y + b.height).toBeLessThanOrEqual(460);
226
- });
227
-
228
- // Two callouts on the SAME point, so they genuinely fight for the same space. An
229
- // earlier version of this test used `dy: -30`, which parked them 13px apart -- they
230
- // never collided, so "routes around it" was asserted against a pair that had nothing
231
- // to route around, and the ordering bug below shipped underneath it.
232
- const PINNED: Ann = {
233
- type: 'text',
234
- x: 'Mar',
235
- y: 30,
236
- text: 'Pinned block here',
237
- anchor: 'top',
238
- offset: { dy: -25 },
239
- };
240
- const MOVABLE: Ann = { type: 'text', x: 'Mar', y: 30, text: 'Other block here', anchor: 'top' };
241
-
242
- test.each([
243
- ['pinned first', [PINNED, MOVABLE]],
244
- ['pinned second', [MOVABLE, PINNED]],
245
- ] as const)('a hand-offset block keeps its spot and the other routes around it (%s)', (_, specs) => {
246
- const resolved = annotationsFor([...specs]);
247
- const pinned = resolved[specs.indexOf(PINNED)];
248
- const movable = resolved[specs.indexOf(MOVABLE)];
249
-
250
- // Pinned: exactly where it was told, neighbour or no neighbour.
251
- expect(pinned.label!.y - pinned.dot!.y).toBeCloseTo(offsetBy('top', 0, -25).dy, 5);
252
-
253
- // Movable: actually got out of the way. Both halves matter -- asserting only
254
- // "they don't overlap" passes on a pair that never overlapped to begin with.
255
- const solo = first([MOVABLE]);
256
- expect(movable.label!.y - movable.dot!.y).not.toBeCloseTo(solo.label!.y - solo.dot!.y, 1);
257
- expect(overlaps(pinned.bounds!, movable.bounds!)).toBe(false);
258
- });
259
-
260
- test('a callout does not sit on top of the line it annotates', () => {
261
- const layout: ChartLayout = compileChart(
262
- {
263
- mark: 'line',
264
- data: SERIES,
265
- encoding: {
266
- x: { field: 'm', type: 'ordinal' },
267
- y: { field: 'v', type: 'quantitative' },
268
- },
269
- annotations: [{ type: 'text', x: 'Feb', y: 50, text: 'The peak' }],
270
- } as ChartSpec,
271
- { width: 800, height: 460 },
272
- );
273
-
274
- const a = layout.annotations![0];
275
- const points = layout.marks.flatMap((m) => ('points' in m ? (m.points ?? []) : []));
276
- const b = a.bounds!;
277
-
278
- // No vertex of the series falls inside the label block.
279
- for (const p of points) {
280
- const inside = p.x >= b.x && p.x <= b.x + b.width && p.y >= b.y && p.y <= b.y + b.height;
281
- expect(inside).toBe(false);
282
- }
283
- });
284
- });
285
-
286
- // --- auto-placement commits to the side it chose ---------------------------
287
-
288
- describe('an auto-placed callout on the right edge anchors its text to the left', () => {
289
- // A point at the far right edge forces the scored search to place the block on
290
- // its LEFT, which it signals by returning `textAnchor: 'end'`. The anchor must
291
- // actually be STAMPED on the label (and its subtitle): with a `start` anchor the
292
- // same origin makes the text extend right, over the data point. This is the
293
- // mutation the suite used to miss -- deleting the anchor assignment in
294
- // `moveAnnotationBy` kept every test green, because the only test that looked at
295
- // the anchor compared it against bounds derived from the same (wrong) anchor.
296
- test('the search picks the left side and the anchor stamp reaches the whole block', () => {
297
- const data = Array.from({ length: 21 }, (_, i) => ({
298
- x: `2024-01-${String(i + 1).padStart(2, '0')}`,
299
- y: 40 + 2 * i,
300
- }));
301
- const layout: ChartLayout = compileChart(
302
- {
303
- mark: 'line',
304
- data,
305
- encoding: {
306
- x: { field: 'x', type: 'temporal' },
307
- y: { field: 'y', type: 'quantitative' },
308
- },
309
- annotations: [
310
- {
311
- type: 'text',
312
- x: data[20].x,
313
- y: data[20].y,
314
- text: 'Peak of the series',
315
- subtitle: 'highest reading of the month',
316
- },
317
- ],
318
- } as ChartSpec,
319
- { width: 800, height: 460 },
320
- );
321
-
322
- const a = layout.annotations![0];
323
- const label = a.label!;
324
- const bounds = label.bounds!;
325
- const dot = a.dot!;
326
-
327
- // The search really chose the left side. If a future engine change makes it
328
- // pick another candidate, fail loudly here rather than silently stop guarding.
329
- expect(label.style.textAnchor).toBe('end');
330
-
331
- // An end-anchored box extends LEFT of its origin.
332
- expect(Math.abs(bounds.x + bounds.width - label.x)).toBeLessThanOrEqual(1);
333
-
334
- // The whole block sits clear of the point, on its left.
335
- expect(bounds.x + bounds.width).toBeLessThan(dot.x);
336
-
337
- // The anchor stamp reaches the subtitle too -- `moveAnnotationBy` applies it
338
- // to both lines, and an unstamped subtitle would jut right from a shared origin.
339
- expect(a.subtitle!.style.textAnchor).toBe('end');
340
- expect(a.subtitle!.x).toBeCloseTo(label.x, 0);
341
- });
342
- });
343
-
344
- // --- the leader connects the block to the point --------------------------
345
-
346
- describe('the leader actually connects the words to the data', () => {
347
- test('a bare callout draws a leader and a marker with no authoring', () => {
348
- const a = first([{ type: 'text', x: 'Mar', y: 30, text: 'Peak' }]);
349
-
350
- // The "minimal spec is publication-ready" rule.
351
- expect(a.label?.connector).toBeDefined();
352
- expect(a.dot).toBeDefined();
353
- });
354
-
355
- test("the leader ends on the data point, not on the label's own edge", () => {
356
- const a = first([{ type: 'text', x: 'Mar', y: 30, text: 'Peak', anchor: 'top' }]);
357
- const c = a.label!.connector!;
358
- const dot = a.dot!;
359
-
360
- // `to` is pulled back off the marker, but it aims at the point: within the
361
- // marker's own radius plus its stroke.
362
- const gap = Math.hypot(dot.x - c.to.x, dot.y - c.to.y);
363
- expect(gap).toBeLessThanOrEqual(DEFAULT_DOT_RADIUS + DEFAULT_DOT_STROKE_WIDTH + 3);
364
- });
365
-
366
- test('the leader starts outside the text, never inside it', () => {
367
- const a = first([{ type: 'text', x: 'Mar', y: 30, text: 'A longer callout', anchor: 'top' }]);
368
- const c = a.label!.connector!;
369
- const b = a.bounds!;
370
-
371
- const insideText =
372
- c.from.x > b.x && c.from.x < b.x + b.width && c.from.y > b.y && c.from.y < b.y + b.height;
373
- expect(insideText).toBe(false);
374
- });
375
-
376
- test('a leader too short to read is dropped, and the marker carries it alone', () => {
377
- // Label pulled right down onto its point.
378
- const a = first([
379
- { type: 'text', x: 'Mar', y: 30, text: 'Peak', anchor: 'top', offset: { dx: 0, dy: 26 } },
380
- ]);
381
-
382
- expect(a.label?.connector).toBeUndefined();
383
- // The marker survives: the reader still knows which point is meant.
384
- expect(a.dot).toBeDefined();
385
- });
386
-
387
- test.each([
388
- false,
389
- true,
390
- ])('whatever leader IS drawn is long enough to read as a line (arrow: %s)', (arrow) => {
391
- // Sweep the label away from its point and back in. Every connector that
392
- // survives must have a strokable length -- never a 1px nub with a head on it.
393
- //
394
- // The sweep has to START far out. An earlier version ran dy 0..26, which only
395
- // ever moves the block TOWARD the point: 53 of its 54 iterations hit the
396
- // `suppressed` continue, the arrowed branch drew nothing at all, and the whole
397
- // test asserted once. It could not have failed if the arrow-aware gate were
398
- // deleted, which is the one thing it exists to protect.
399
- let asserted = 0;
400
-
401
- for (let dy = -60; dy <= 26; dy++) {
402
- const a = first([
403
- {
404
- type: 'text',
405
- x: 'Mar',
406
- y: 30,
407
- text: 'Peak',
408
- anchor: 'top',
409
- offset: { dx: 0, dy },
410
- connector: arrow ? { type: 'straight', arrow: true } : 'straight',
411
- },
412
- ]);
413
- const stroke = strokedLength(a);
414
- if (stroke === null) continue; // suppressed, which is a valid outcome
415
- expect(stroke).toBeGreaterThanOrEqual(MIN_CONNECTOR_LENGTH);
416
- asserted++;
417
- }
418
-
419
- // Guard the guard: a sweep that suppresses everything asserts nothing.
420
- expect(asserted).toBeGreaterThan(30);
421
- });
422
-
423
- test("the author's connectorOffset.from survives auto-placement", () => {
424
- // `connectorOffset.to` is baked into `connector.endpoint`, so it survives every
425
- // later geometry rebuild for free. `from` used to be applied once at resolve and
426
- // then thrown away by `refreshConnector` -- so on an auto-placed annotation
427
- // (which ALWAYS moves in Pass 2) the author's nudge did exactly nothing, and an
428
- // edit-mode drag of the from handle was a silent no-op after re-render.
429
- const control = first([{ type: 'text', x: 'Mar', y: 30, text: 'Peak' }]);
430
- const nudged = first([
431
- {
432
- type: 'text',
433
- x: 'Mar',
434
- y: 30,
435
- text: 'Peak',
436
- connectorOffset: { from: { dx: 15, dy: 9 } },
437
- },
438
- ]);
439
-
440
- const from = nudged.label!.connector!.from;
441
- const controlFrom = control.label!.connector!.from;
442
- expect(from.x - controlFrom.x).toBeCloseTo(15, 5);
443
- expect(from.y - controlFrom.y).toBeCloseTo(9, 5);
444
- });
445
-
446
- test('a drop-line hangs straight down from the words to the point', () => {
447
- const a = first([{ type: 'text', x: 'Mar', y: 30, text: 'Peak', connector: 'drop-line' }]);
448
- const c = a.label!.connector!;
449
- const b = a.bounds!;
450
-
451
- // Vertical, pinned to the point's x.
452
- expect(c.from.x).toBeCloseTo(c.to.x, 5);
453
- expect(c.from.x).toBeCloseTo(a.dot!.x, 5);
454
- // Runs downward, from a block that sits above the point.
455
- expect(c.from.y).toBeLessThan(c.to.y);
456
- expect(b.y + b.height).toBeLessThanOrEqual(a.dot!.y);
457
- });
458
- });
459
-
460
- // --- typography the reader sees ------------------------------------------
461
-
462
- describe('the words are styled the way the design says', () => {
463
- test('a plain callout is regular weight at the default size', () => {
464
- const a = first([{ type: 'text', x: 'Mar', y: 30, text: 'Peak' }]);
465
-
466
- expect(a.label!.style.fontSize).toBe(DEFAULT_ANNOTATION_FONT_SIZE);
467
- expect(a.label!.style.fontWeight).toBe(DEFAULT_ANNOTATION_FONT_WEIGHT);
468
- });
469
-
470
- test('adding a subtitle promotes the first line to a bold lede', () => {
471
- const a = first([
472
- { type: 'text', x: 'Mar', y: 30, text: '+1.17°C', subtitle: 'the warmest year' },
473
- ]);
474
-
475
- // This is the pairing the whole lede rule exists for. It once shipped dead.
476
- expect(a.label!.style.fontWeight).toBe(LEDE_FONT_WEIGHT);
477
- expect(a.subtitle!.text).toBe('the warmest year');
478
- expect(a.subtitle!.style.fontWeight).toBe(SUBTITLE_FONT_WEIGHT);
479
- expect(a.subtitle!.style.fontSize).toBe(subtitleFontSize(DEFAULT_ANNOTATION_FONT_SIZE));
480
- });
481
-
482
- test('an explicit weight beats the lede rule', () => {
483
- const a = first([
484
- { type: 'text', x: 'Mar', y: 30, text: 'Peak', subtitle: 'context', fontWeight: 500 },
485
- ]);
486
- expect(a.label!.style.fontWeight).toBe(500);
487
- });
488
-
489
- test('the subtitle sits under the primary line and shares its left edge', () => {
490
- const a = first([{ type: 'text', x: 'Mar', y: 30, text: 'Peak', subtitle: 'context' }]);
491
-
492
- expect(a.subtitle!.y).toBeGreaterThan(a.label!.y);
493
- expect(a.subtitle!.x).toBeCloseTo(a.label!.x, 5);
494
- });
495
-
496
- test('a subtitle works on a drop-line too', () => {
497
- const a = first([
498
- { type: 'text', x: 'Mar', y: 30, text: 'Peak', subtitle: 'context', connector: 'drop-line' },
499
- ]);
500
- expect(a.subtitle?.text).toBe('context');
501
- expect(a.label!.style.fontWeight).toBe(LEDE_FONT_WEIGHT);
502
- });
503
-
504
- test('text is never center-aligned; it faces the data', () => {
505
- const left = first([{ type: 'text', x: 'Mar', y: 30, text: 'a\nb', anchor: 'left' }]);
506
- const right = first([{ type: 'text', x: 'Mar', y: 30, text: 'a\nb', anchor: 'right' }]);
507
- const top = first([{ type: 'text', x: 'Mar', y: 30, text: 'a\nb', anchor: 'top' }]);
508
-
509
- // A left-anchored block's right edge faces the point, so it rags right-aligned.
510
- expect(left.label!.style.textAnchor).toBe('end');
511
- expect(right.label!.style.textAnchor).toBe('start');
512
- expect(top.label!.style.textAnchor).toBe('start');
513
- });
514
-
515
- test('the block grows to fit a subtitle, so collisions can see it', () => {
516
- const plain = first([{ type: 'text', x: 'Mar', y: 30, text: 'Peak' }]);
517
- const withSub = first([{ type: 'text', x: 'Mar', y: 30, text: 'Peak', subtitle: 'context' }]);
518
-
519
- expect(withSub.bounds!.height).toBeGreaterThan(plain.bounds!.height);
520
- // ...but the label's own box stays the label's own box (the plate is sized
521
- // from it, so a subtitle must not inflate it).
522
- expect(withSub.label!.bounds!.height).toBeCloseTo(plain.label!.bounds!.height, 5);
523
- });
524
- });
525
-
526
- // --- bold spans ----------------------------------------------------------
527
-
528
- describe('**bold** emphasis rides inside the sentence', () => {
529
- /**
530
- * Compile with a WEIGHT-AWARE measurer, i.e. what a browser gives us.
531
- *
532
- * The default `heuristicMeasure` (the SSR/static fallback) is weight-blind, so
533
- * bold and regular measure identically there and this test would prove nothing.
534
- * The real canvas path does distinguish them, and that's the path where a bold
535
- * span's extra width has to reach bounds -- otherwise collisions and connector
536
- * exits are computed against a box narrower than the drawn text.
537
- */
538
- const widthWith = (text: string): number => {
539
- const layout: ChartLayout = compileChart(
540
- {
541
- mark: 'line',
542
- data: SERIES,
543
- encoding: {
544
- x: { field: 'm', type: 'ordinal' },
545
- y: { field: 'v', type: 'quantitative' },
546
- },
547
- annotations: [{ type: 'text', x: 'Mar', y: 30, text }],
548
- } as never,
549
- {
550
- width: 800,
551
- height: 460,
552
- measureText: (t: string, fontSize: number, fontWeight = 400) => ({
553
- width: t.length * fontSize * (fontWeight >= 600 ? 0.62 : 0.5),
554
- height: fontSize,
555
- }),
556
- },
557
- );
558
- return layout.annotations![0].bounds!.width;
559
- };
560
-
561
- test('a bold span widens the block, and the ** markers take no space', () => {
562
- const plain = widthWith('Jobs fell to 12,000');
563
- const bold = widthWith('Jobs fell to **12,000**');
564
-
565
- // Bold is wider than regular: the span is measured at its own weight.
566
- expect(bold).toBeGreaterThan(plain);
567
-
568
- // And the `**` markers are consumed, not measured. Four asterisks at 13px
569
- // would add ~26px on their own; the real gain is just the 6 bold characters
570
- // going from 0.5 to 0.62 width (6 * 13 * 0.12 ≈ 9.4px).
571
- expect(bold - plain).toBeLessThan(15);
572
- });
573
-
574
- test('an unmatched ** is just text', () => {
575
- const a = first([{ type: 'text', x: 'Mar', y: 30, text: 'unmatched ** here' }]);
576
- // Round-trips verbatim; the renderer will draw the asterisks.
577
- expect(a.label!.text).toBe('unmatched ** here');
578
- });
579
-
580
- test('a bold subtitle span is measured too', () => {
581
- const plain = first([{ type: 'text', x: 'Mar', y: 30, text: 'Peak', subtitle: 'under 0.3C' }]);
582
- const bold = first([
583
- { type: 'text', x: 'Mar', y: 30, text: 'Peak', subtitle: 'under **0.3C**' },
584
- ]);
585
- // The markers are stripped from the rendered string in both engines.
586
- expect(bold.subtitle!.text).toBe('under **0.3C**');
587
- expect(plain.subtitle!.text).toBe('under 0.3C');
588
- });
589
- });
590
-
591
- // --- the marker ----------------------------------------------------------
592
-
593
- describe('the endpoint marker', () => {
594
- test('an arrowed callout gets no marker: the arrowhead already points', () => {
595
- const a = first([
596
- { type: 'text', x: 'Mar', y: 30, text: 'Peak', connector: { type: 'curve', arrow: true } },
597
- ]);
598
- expect(a.dot).toBeUndefined();
599
- });
600
-
601
- test('a quiet leader gets an open-ring marker to terminate it', () => {
602
- const a = first([{ type: 'text', x: 'Mar', y: 30, text: 'Peak', connector: 'straight' }]);
603
- expect(a.dot).toBeDefined();
604
- expect(a.dot!.radius).toBe(DEFAULT_DOT_RADIUS);
605
- // Ring, not disc: the fill is the surface, the stroke is the connector's ink.
606
- expect(a.dot!.stroke).toBe(a.label!.connector!.stroke);
607
- });
608
-
609
- test('dot:false means bare, always', () => {
610
- const a = first([
611
- { type: 'text', x: 'Mar', y: 30, text: 'Peak', connector: 'straight', dot: false },
612
- ]);
613
- expect(a.dot).toBeUndefined();
614
- });
615
-
616
- test('the marker sits on the data point', () => {
617
- const a = first([{ type: 'text', x: 'Mar', y: 30, text: 'Peak', anchor: 'top' }]);
618
- const layout = compileChart(
619
- {
620
- mark: 'line',
621
- data: SERIES,
622
- encoding: {
623
- x: { field: 'm', type: 'ordinal' },
624
- y: { field: 'v', type: 'quantitative', scale: { zero: true } },
625
- },
626
- annotations: [{ type: 'text', x: 'Mar', y: 30, text: 'Peak', anchor: 'top' }],
627
- } as ChartSpec,
628
- { width: 800, height: 460 },
629
- );
630
- const marks = layout.marks.flatMap((m) => ('points' in m ? (m.points ?? []) : []));
631
- const mar = marks[2]; // third datum
632
- expect(a.dot!.x).toBeCloseTo(mar.x, 1);
633
- expect(a.dot!.y).toBeCloseTo(mar.y, 1);
634
- });
635
- });
636
-
637
- // --- collisions: the reason the mutation passes exist --------------------
638
-
639
- describe('callouts get out of each other and stay readable', () => {
640
- const crowded = () =>
641
- annotationsFor([
642
- { type: 'text', x: 'Jan', y: 10, text: 'First callout here' },
643
- { type: 'text', x: 'Feb', y: 50, text: 'Second callout here' },
644
- { type: 'text', x: 'Mar', y: 30, text: 'Third callout here' },
645
- { type: 'text', x: 'Apr', y: 42, text: 'Fourth callout here' },
646
- ]);
647
-
648
- test('an auto-placed block sits on the side its text alignment says it does', () => {
649
- // The scored search picks a side per callout, and a left-side result gets
650
- // textAnchor 'end' -- the text then extends LEFT of `label.x` instead of right.
651
- // If `bounds` were computed before that flip were applied, it would sit on the
652
- // wrong side of the origin: the box other callouts avoid, and that thinning
653
- // measures, would be a mirror image of the words on screen.
654
- for (const a of crowded()) {
655
- if (a.footnoteIndex != null || !a.bounds || !a.label) continue;
656
- const b = a.bounds;
657
- const lx = a.label.x;
658
-
659
- if (a.label.style.textAnchor === 'end') {
660
- expect(b.x + b.width).toBeCloseTo(lx, 0);
661
- } else {
662
- expect(b.x).toBeCloseTo(lx, 0);
663
- }
664
- }
665
- });
666
-
667
- test('no two callout blocks overlap each other', () => {
668
- const blocks = crowded()
669
- .filter((a) => a.footnoteIndex == null)
670
- .map((a) => a.bounds!)
671
- .filter(Boolean);
672
-
673
- for (let i = 0; i < blocks.length; i++) {
674
- for (let j = i + 1; j < blocks.length; j++) {
675
- expect(overlaps(blocks[i], blocks[j])).toBe(false);
676
- }
677
- }
678
- });
679
-
680
- test('every callout keeps a leader that still reaches its own point', () => {
681
- for (const a of crowded()) {
682
- if (!a.label?.connector || !a.dot) continue;
683
- const c = a.label.connector;
684
- const gap = Math.hypot(a.dot.x - c.to.x, a.dot.y - c.to.y);
685
- // After being shoved around, the leader still terminates at its marker.
686
- expect(gap).toBeLessThanOrEqual(DEFAULT_DOT_RADIUS + DEFAULT_DOT_STROKE_WIDTH + 3);
687
- }
688
- });
689
-
690
- test('a nudged callout brings its subtitle with it', () => {
691
- const nudged = annotationsFor([
692
- { type: 'text', x: 'Jan', y: 10, text: 'First callout', subtitle: 'with context' },
693
- { type: 'text', x: 'Feb', y: 50, text: 'Second callout', subtitle: 'with context' },
694
- { type: 'text', x: 'Mar', y: 30, text: 'Third callout', subtitle: 'with context' },
695
- ]);
696
-
697
- for (const a of nudged) {
698
- if (!a.subtitle || !a.label) continue;
699
- // The subtitle carries absolute coords; if a pass moves the label and
700
- // forgets the subtitle, they separate. They must stay locked.
701
- expect(a.subtitle.x).toBeCloseTo(a.label.x, 5);
702
- expect(a.subtitle.y).toBeGreaterThan(a.label.y);
703
- }
704
- });
705
-
706
- test('drop-lines stay vertical even when they collide', () => {
707
- const both = annotationsFor([
708
- { type: 'text', x: 'Feb', y: 50, text: 'First drop-line here', connector: 'drop-line' },
709
- { type: 'text', x: 'Mar', y: 30, text: 'Second drop-line here', connector: 'drop-line' },
710
- ]);
711
-
712
- for (const a of both) {
713
- const c = a.label?.connector;
714
- expect(c).toBeDefined();
715
- expect(c!.from.x).toBeCloseTo(c!.to.x, 5);
716
- expect(c!.from.x).toBeCloseTo(a.dot!.x, 5);
717
- }
718
- });
719
- });
720
-
721
- // --- thinning ------------------------------------------------------------
722
-
723
- describe('when callouts cannot all fit, they demote to footnotes', () => {
724
- const long = (s: string) => s.repeat(10);
725
-
726
- const thinned = () =>
727
- compileChart(
728
- {
729
- mark: 'line',
730
- autoThin: true,
731
- data: SERIES,
732
- encoding: {
733
- x: { field: 'm', type: 'ordinal' },
734
- y: { field: 'v', type: 'quantitative' },
735
- },
736
- annotations: [
737
- { type: 'text', x: 'Jan', y: 10, text: long('AAA ') },
738
- { type: 'text', x: 'Feb', y: 50, text: long('BBB ') },
739
- { type: 'text', x: 'Mar', y: 30, text: long('CCC ') },
740
- { type: 'text', x: 'Apr', y: 42, text: long('DDD ') },
741
- ],
742
- } as ChartSpec,
743
- { width: 420, height: 300 },
744
- );
745
-
746
- test('a demoted callout keeps its marker so the number has something to sit by', () => {
747
- const layout = thinned();
748
- const demoted = layout.annotations!.filter((a) => a.footnoteIndex != null);
749
- expect(demoted.length).toBeGreaterThan(0);
750
-
751
- for (const a of demoted) {
752
- expect(a.dot).toBeDefined();
753
- }
754
- });
755
-
756
- test('every footnote marker points at its own text', () => {
757
- const layout = thinned();
758
- const byNumber = new Map((layout.chrome.footnotes ?? []).map((f) => [f.index, f.text]));
759
-
760
- let checked = 0;
761
- for (const a of layout.annotations!) {
762
- if (a.footnoteIndex == null) continue;
763
- expect(byNumber.get(a.footnoteIndex)).toBe(a.label?.text);
764
- checked++;
765
- }
766
- expect(checked).toBeGreaterThan(0);
767
- });
768
-
769
- test('the footnote list is numbered from one, with no gaps or repeats', () => {
770
- const footnotes = thinned().chrome.footnotes ?? [];
771
- expect(footnotes.length).toBeGreaterThan(0);
772
- expect(footnotes.map((f) => f.index)).toEqual(
773
- Array.from({ length: footnotes.length }, (_, i) => i + 1),
774
- );
775
- });
776
- });
777
-
778
- // --- data-coordinate stability -------------------------------------------
779
-
780
- describe('callouts are anchored in the data, not in pixels', () => {
781
- test('resizing the chart moves a callout with its data point', () => {
782
- const narrow = annotationsFor([{ type: 'text', x: 'Mar', y: 30, text: 'Peak' }], 600, 400)[0];
783
- const wide = annotationsFor([{ type: 'text', x: 'Mar', y: 30, text: 'Peak' }], 900, 400)[0];
784
-
785
- // Different pixel positions...
786
- expect(wide.dot!.x).not.toBeCloseTo(narrow.dot!.x, 1);
787
- // ...but the label keeps the same relationship to its point in both.
788
- const offsetNarrow = narrow.label!.x - narrow.dot!.x;
789
- const offsetWide = wide.label!.x - wide.dot!.x;
790
- expect(offsetWide).toBeCloseTo(offsetNarrow, 1);
791
- });
792
-
793
- test('an annotation on a value outside the domain is dropped, not misplaced', () => {
794
- const all = annotationsFor([
795
- { type: 'text', x: 'Mar', y: 30, text: 'Real' },
796
- { type: 'text', x: 'NotAMonth', y: 30, text: 'Bogus' },
797
- ]);
798
-
799
- expect(all).toHaveLength(1);
800
- expect(all[0].label!.text).toBe('Real');
801
- });
802
-
803
- test('a dropped annotation does not shift which of its neighbours counts as pinned', () => {
804
- // The resolved array is a FILTERED view of the spec array: dropping spec 0
805
- // shifts every later annotation down one slot. Anything that reaches back to
806
- // the authored spec (collision resolution's "does it carry an offset?" check)
807
- // must key on the stamped `specIndex`, not on resolved position -- indexed by
808
- // position, the pinned annotation reads the dropped spec's (offset-less) slot,
809
- // loses its pin, and gets displaced off its authored spot.
810
- const bogus: Ann = { type: 'text', x: 'NotAMonth', y: 30, text: 'Bogus' };
811
- const pinned: Ann = {
812
- type: 'text',
813
- x: 'Mar',
814
- y: 30,
815
- text: 'Pinned block here',
816
- anchor: 'top',
817
- offset: { dy: -25 },
818
- };
819
- const movable: Ann = {
820
- type: 'text',
821
- x: 'Mar',
822
- y: 30,
823
- text: 'Other block here',
824
- anchor: 'top',
825
- };
826
-
827
- const resolved = annotationsFor([bogus, pinned, movable]);
828
-
829
- // The bogus one is dropped; the survivors keep their spec identities.
830
- expect(resolved).toHaveLength(2);
831
- expect(resolved.map((a) => a.specIndex)).toEqual([1, 2]);
832
-
833
- const [pinnedResolved, movableResolved] = resolved;
834
-
835
- // Pinning held despite the collision: same spot as when compiled alone.
836
- const pinnedSolo = first([pinned]);
837
- expect(pinnedResolved.label!.y - pinnedResolved.dot!.y).toBeCloseTo(
838
- pinnedSolo.label!.y - pinnedSolo.dot!.y,
839
- 0,
840
- );
841
-
842
- // And the movable one actually moved -- without this half, the test passes
843
- // on a pair that never collided in the first place.
844
- const movableSolo = first([movable]);
845
- expect(movableResolved.label!.y - movableResolved.dot!.y).not.toBeCloseTo(
846
- movableSolo.label!.y - movableSolo.dot!.y,
847
- 1,
848
- );
849
-
850
- expect(overlaps(pinnedResolved.bounds!, movableResolved.bounds!)).toBe(false);
851
- });
852
- });