@opendata-ai/openchart-engine 8.0.0-rc.8 → 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 +894 -171
  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 -2339
  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 -626
  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 -1450
  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 -1004
  185. package/src/geo/compile-map.ts +0 -1341
  186. package/src/geo/join.ts +0 -115
  187. package/src/geo/projections.ts +0 -45
  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 -747
  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,566 +0,0 @@
1
- import type { ChartLayout, LineMark } from '@opendata-ai/openchart-core';
2
- import { estimateTextWidth } from '@opendata-ai/openchart-core';
3
- import { describe, expect, test } from 'vitest';
4
- import { ANCHOR_OFFSET, ARROWHEAD_LENGTH, MIN_CONNECTOR_LENGTH } from '../annotations/constants';
5
- import { compileChart } from '../compile';
6
-
7
- describe('known layout bugs', () => {
8
- test('annotation label does not overlap the line it annotates', () => {
9
- // Generate ~25 smooth sine-wave data points over two years
10
- const data: { date: string; value: number }[] = [];
11
- const startDate = new Date('2022-01-01');
12
- for (let i = 0; i < 25; i++) {
13
- const d = new Date(startDate);
14
- d.setDate(d.getDate() + i * 30); // ~monthly
15
- // Sine wave: value ranges ~20-80 with a peak near i=6 (mid-2022)
16
- const value = 50 + 30 * Math.sin((i / 25) * 2 * Math.PI);
17
- data.push({ date: d.toISOString().slice(0, 10), value });
18
- }
19
-
20
- // The peak is near i=6 (~July 2022), where sin peaks
21
- const peakIndex = 6;
22
- const peakDate = data[peakIndex].date;
23
- const peakValue = data[peakIndex].value;
24
-
25
- const spec = {
26
- mark: 'line' as const,
27
- data,
28
- encoding: {
29
- x: { field: 'date' as const, type: 'temporal' as const },
30
- y: { field: 'value' as const, type: 'quantitative' as const },
31
- },
32
- annotations: [
33
- {
34
- type: 'text' as const,
35
- x: peakDate,
36
- y: peakValue,
37
- text: 'Peak season',
38
- },
39
- ],
40
- };
41
-
42
- const layout: ChartLayout = compileChart(spec, { width: 600, height: 400 });
43
-
44
- // Find the annotation
45
- const annotation = layout.annotations[0];
46
- expect(annotation).toBeDefined();
47
- expect(annotation.label).toBeDefined();
48
-
49
- const label = annotation.label!;
50
- const fontSize = label.style.fontSize;
51
- const fontWeight = label.style.fontWeight;
52
-
53
- // Estimate the label bounding box
54
- const labelWidth = estimateTextWidth(label.text, fontSize, fontWeight);
55
- const labelHeight = fontSize * 1.3;
56
-
57
- // Label bounding box (y is the baseline, so top of text is y - fontSize)
58
- const labelLeft =
59
- label.style.textAnchor === 'middle'
60
- ? label.x - labelWidth / 2
61
- : label.style.textAnchor === 'end'
62
- ? label.x - labelWidth
63
- : label.x;
64
- const labelRight = labelLeft + labelWidth;
65
- const labelTop = label.y - fontSize;
66
- const labelBottom = labelTop + labelHeight;
67
-
68
- // Find the LineMark
69
- const lineMark = layout.marks.find((m): m is LineMark => m.type === 'line');
70
- expect(lineMark).toBeDefined();
71
-
72
- const points = lineMark!.points;
73
-
74
- // Find line segments that fall within the label's x-range
75
- let lineMinY = Infinity;
76
- let lineMaxY = -Infinity;
77
-
78
- for (let i = 0; i < points.length - 1; i++) {
79
- const p1 = points[i];
80
- const p2 = points[i + 1];
81
-
82
- // Check if this segment overlaps the label's x-range
83
- const segLeft = Math.min(p1.x, p2.x);
84
- const segRight = Math.max(p1.x, p2.x);
85
-
86
- if (segRight >= labelLeft && segLeft <= labelRight) {
87
- // This segment is within the label's x-range
88
- // Find the y-range of the line within the label's x-span
89
- const overlapLeft = Math.max(segLeft, labelLeft);
90
- const overlapRight = Math.min(segRight, labelRight);
91
-
92
- // Interpolate y values at the overlap boundaries
93
- const segWidth = p2.x - p1.x;
94
- if (Math.abs(segWidth) < 0.001) {
95
- lineMinY = Math.min(lineMinY, p1.y, p2.y);
96
- lineMaxY = Math.max(lineMaxY, p1.y, p2.y);
97
- } else {
98
- const t1 = (overlapLeft - p1.x) / segWidth;
99
- const t2 = (overlapRight - p1.x) / segWidth;
100
- const y1 = p1.y + t1 * (p2.y - p1.y);
101
- const y2 = p1.y + t2 * (p2.y - p1.y);
102
- lineMinY = Math.min(lineMinY, y1, y2);
103
- lineMaxY = Math.max(lineMaxY, y1, y2);
104
- }
105
- }
106
- }
107
-
108
- // The label bounding box should NOT vertically overlap with the line's y-range.
109
- // The label sits ANCHOR_OFFSET (28px) off its anchor point on the line, which
110
- // is sized to clear both the marker and the line path.
111
- const verticallyOverlaps = labelTop < lineMaxY && labelBottom > lineMinY;
112
- expect(verticallyOverlaps).toBe(false);
113
- });
114
-
115
- // Regression: the collision pass calls `refreshConnector` on every annotation it
116
- // moves, and that rebuilt `from`/`to` from a ray-box exit toward the target. A
117
- // drop-line is not a leader — it's a vertical rule pinned to the data point's x
118
- // — so the rebuild tilted it into a diagonal (which the renderer then draws with
119
- // shape-rendering: crispEdges, assuming axis alignment), and the
120
- // MIN_CONNECTOR_LENGTH rule could delete it outright.
121
- //
122
- // Two drop-lines placed close enough to collide is what makes this observable:
123
- // a lone annotation is never shifted, so `refreshConnector` never runs and the
124
- // bug hides. Against the unfixed code this pair yields a 12.9px-diagonal first
125
- // connector and a *deleted* second one.
126
- describe('drop-line connectors survive the collision pass', () => {
127
- const collidingDropLines = () =>
128
- compileChart(
129
- {
130
- mark: 'line' as const,
131
- data: [
132
- { m: 'Jan', v: 10 },
133
- { m: 'Feb', v: 50 },
134
- { m: 'Mar', v: 48 },
135
- { m: 'Apr', v: 30 },
136
- ],
137
- encoding: {
138
- x: { field: 'm' as const, type: 'ordinal' as const },
139
- y: { field: 'v' as const, type: 'quantitative' as const },
140
- },
141
- annotations: [
142
- {
143
- type: 'text' as const,
144
- x: 'Feb',
145
- y: 50,
146
- text: 'First drop-line here',
147
- connector: 'drop-line' as const,
148
- },
149
- {
150
- type: 'text' as const,
151
- x: 'Mar',
152
- y: 48,
153
- text: 'Second drop-line here',
154
- connector: 'drop-line' as const,
155
- },
156
- ],
157
- },
158
- { width: 800, height: 460 },
159
- ).annotations ?? [];
160
-
161
- // One test, not three. The `x` pinning is the only assertion the generic
162
- // ray-box rebuild actually breaks -- it preserves `style` through its
163
- // `{ ...connector }` spread, and this geometry happens to land on
164
- // `exit: 'vertical'` either way, so splitting those into their own tests just
165
- // manufactured two that pass with the fix reverted.
166
- test('stay vertical, with x pinned to the data point', () => {
167
- const annotations = collidingDropLines();
168
- expect(annotations).toHaveLength(2);
169
- for (const annotation of annotations) {
170
- const connector = annotation.label?.connector;
171
- // Survived the min-length rule (the generic rebuild can shorten a
172
- // drop-line enough to delete it outright).
173
- expect(connector).toBeDefined();
174
- // The whole contract of a drop-line: from.x === to.x === the point's x.
175
- expect(connector?.from.x).toBeCloseTo(connector?.to.x ?? Number.NaN, 5);
176
- expect(connector?.from.x).toBeCloseTo(annotation.dot?.x ?? Number.NaN, 5);
177
- expect(connector?.style).toBe('drop-line');
178
- expect(connector?.exit).toBe('vertical');
179
- }
180
- });
181
- });
182
-
183
- // Regression: the engine gated connectors on `length >= MIN_CONNECTOR_LENGTH`,
184
- // but the renderer stops an ARROWED stroke `ARROWHEAD_LENGTH` (7px) short and
185
- // spends that budget on the head. So an 8.1px arrowed connector cleared the 8px
186
- // gate and then shipped a 1.1px stub with an arrowhead stuck on the end. Sharing
187
- // the constant didn't fix it: both sides had the number, only one reasoned about
188
- // it. The gate now measures what actually gets stroked.
189
- describe('the connector min-length gate accounts for the arrowhead', () => {
190
- const strokeFor = (arrow: boolean, dy: number) => {
191
- const annotation = compileChart(
192
- {
193
- mark: 'line' as const,
194
- data: [
195
- { m: 'Jan', v: 10 },
196
- { m: 'Feb', v: 50 },
197
- { m: 'Mar', v: 30 },
198
- ],
199
- encoding: {
200
- x: { field: 'm' as const, type: 'ordinal' as const },
201
- y: { field: 'v' as const, type: 'quantitative' as const },
202
- },
203
- annotations: [
204
- {
205
- type: 'text' as const,
206
- x: 'Mar',
207
- y: 30,
208
- text: 'Note',
209
- anchor: 'top' as const,
210
- offset: { dx: 0, dy },
211
- connector: arrow ? { type: 'straight' as const, arrow: true } : 'straight',
212
- },
213
- ],
214
- },
215
- { width: 800, height: 460 },
216
- ).annotations?.[0];
217
-
218
- const connector = annotation?.label?.connector;
219
- if (!connector) return null;
220
- const length = Math.hypot(
221
- connector.to.x - connector.from.x,
222
- connector.to.y - connector.from.y,
223
- );
224
- // What the renderer actually strokes.
225
- return arrow ? length - ARROWHEAD_LENGTH : length;
226
- };
227
-
228
- test('an arrowed connector never ships a stroke shorter than the minimum', () => {
229
- // dy: 6 is the case that used to slip through -- an 8.10px connector that
230
- // stroked 1.10px once the head took its 7px.
231
- for (let dy = 0; dy <= 8; dy++) {
232
- const stroke = strokeFor(true, dy);
233
- if (stroke === null) continue; // suppressed outright, which is correct
234
- expect(stroke).toBeGreaterThanOrEqual(MIN_CONNECTOR_LENGTH);
235
- }
236
- });
237
-
238
- test('the plain (non-arrowed) gate is unchanged', () => {
239
- // The head costs nothing here, so the full length is the stroke and the
240
- // original 8px rule still holds. Guards against "fixing" the arrow case by
241
- // tightening the gate for everyone.
242
- for (let dy = 0; dy <= 8; dy++) {
243
- const stroke = strokeFor(false, dy);
244
- if (stroke === null) continue;
245
- expect(stroke).toBeGreaterThanOrEqual(MIN_CONNECTOR_LENGTH);
246
- }
247
- // And a short-but-legal plain connector still draws.
248
- expect(strokeFor(false, 0)).not.toBeNull();
249
- });
250
- });
251
-
252
- // Regression: `resolveTextAnnotation` bolts the lede rule on (a `subtitle`
253
- // promotes the primary line to 700) BEFORE branching to the drop-line resolver,
254
- // which used to resolve no subtitle at all. Net effect: authoring a subtitle on
255
- // a drop-line silently threw the subtitle away but still bolded the primary --
256
- // a field whose only visible effect was a mystery weight change.
257
- describe('a drop-line annotation honors its subtitle', () => {
258
- // Annotate Mar, not the Feb maximum: a callout on the chart's top value clamps
259
- // against the chart edge and has nowhere to go, which is a separate (and
260
- // legitimate) degradation. This is the ordinary case.
261
- const dropLineWithSubtitle = () =>
262
- compileChart(
263
- {
264
- mark: 'line' as const,
265
- data: [
266
- { m: 'Jan', v: 10 },
267
- { m: 'Feb', v: 50 },
268
- { m: 'Mar', v: 30 },
269
- ],
270
- encoding: {
271
- x: { field: 'm' as const, type: 'ordinal' as const },
272
- y: { field: 'v' as const, type: 'quantitative' as const },
273
- },
274
- annotations: [
275
- {
276
- type: 'text' as const,
277
- x: 'Mar',
278
- y: 30,
279
- text: 'Peak',
280
- subtitle: 'the highest month',
281
- connector: 'drop-line' as const,
282
- },
283
- ],
284
- },
285
- { width: 800, height: 460 },
286
- ).annotations?.[0];
287
-
288
- test('resolves the subtitle instead of dropping it', () => {
289
- const annotation = dropLineWithSubtitle();
290
- expect(annotation?.subtitle?.text).toBe('the highest month');
291
- });
292
-
293
- test('still promotes the primary line to a bold lede', () => {
294
- expect(dropLineWithSubtitle()?.label?.style.fontWeight).toBe(700);
295
- });
296
-
297
- test('renders the subtitle below the primary line, left edges aligned', () => {
298
- const annotation = dropLineWithSubtitle();
299
- expect(annotation?.subtitle?.y).toBeGreaterThan(annotation?.label?.y ?? Number.NaN);
300
- expect(annotation?.subtitle?.x).toBeCloseTo(annotation?.label?.x ?? Number.NaN, 5);
301
- });
302
-
303
- test('the whole block stays above the data point, so the line still drops', () => {
304
- // A drop-line runs DOWN from the block to the point. The subtitle makes the
305
- // block taller and wider, which made it collide with the line mark -- and the
306
- // obstacle nudge then shoved it *below* the point, inverting the connector so
307
- // it pointed up, away from the data, with the block sitting on the mark.
308
- const annotation = dropLineWithSubtitle();
309
- const bounds = annotation?.bounds;
310
- const dotY = annotation?.dot?.y ?? Number.NaN;
311
- expect((bounds?.y ?? Number.NaN) + (bounds?.height ?? Number.NaN)).toBeLessThanOrEqual(dotY);
312
-
313
- const connector = annotation?.label?.connector;
314
- expect(connector?.from.y).toBeLessThan(connector?.to.y ?? Number.NaN);
315
- });
316
-
317
- test('the block bounds cover the subtitle, so collisions see it', () => {
318
- const annotation = dropLineWithSubtitle();
319
- const bounds = annotation?.bounds;
320
- const labelBounds = annotation?.label?.bounds;
321
- // `bounds` is the union; `label.bounds` stays label-only (the renderer sizes
322
- // the background plate from it).
323
- expect(bounds?.height).toBeGreaterThan(labelBounds?.height ?? Number.NaN);
324
- });
325
- });
326
-
327
- // Regression: `compute.ts` stamped `result.bounds` (the UNION of label and
328
- // subtitle, which is what placement scores) into `label.bounds`, whose contract
329
- // is the label-only text box. The renderer sizes the `background` plate from
330
- // `label.bounds`, so an auto-placed annotation with a subtitle got a plate sized
331
- // to both lines while the explicit path got one sized to the label.
332
- test('auto-placed label bounds are the label box, not the label+subtitle union', () => {
333
- const layout = compileChart(
334
- {
335
- mark: 'line' as const,
336
- data: [
337
- { m: 'Jan', v: 10 },
338
- { m: 'Feb', v: 50 },
339
- { m: 'Mar', v: 30 },
340
- ],
341
- encoding: {
342
- x: { field: 'm' as const, type: 'ordinal' as const },
343
- y: { field: 'v' as const, type: 'quantitative' as const },
344
- },
345
- annotations: [
346
- // No anchor/offset => the auto-placement path.
347
- {
348
- type: 'text' as const,
349
- x: 'Feb',
350
- y: 50,
351
- text: 'Peak',
352
- subtitle: 'a considerably wider subtitle line',
353
- },
354
- ],
355
- },
356
- { width: 800, height: 460 },
357
- );
358
- const annotation = layout.annotations?.[0];
359
- const labelBounds = annotation?.label?.bounds;
360
- const unionBounds = annotation?.bounds;
361
- expect(labelBounds).toBeDefined();
362
- expect(unionBounds).toBeDefined();
363
-
364
- // The subtitle is wider and sits below, so the union must be strictly bigger.
365
- // If label.bounds were the union, these would be identical.
366
- expect(labelBounds?.width).toBeLessThan(unionBounds?.width ?? 0);
367
- expect(labelBounds?.height).toBeLessThan(unionBounds?.height ?? 0);
368
- });
369
-
370
- // Regression: `anchor: 'top'`/`'bottom'` render with textAnchor 'start', so the
371
- // block used to hang entirely to the RIGHT of its data point — "above" read as
372
- // "up and to the right", and authors had to hand-compute a negative dx of about
373
- // half the block width to re-center it. The block now straddles the point
374
- // horizontally; the TEXT inside stays left-aligned (ragged right).
375
- describe('top/bottom anchored blocks straddle their data point', () => {
376
- const centerOffsetFor = (annotation: Record<string, unknown>) => {
377
- const layout = compileChart(
378
- {
379
- mark: 'line' as const,
380
- data: [
381
- { m: 'Jan', v: 10 },
382
- { m: 'Feb', v: 50 },
383
- { m: 'Mar', v: 30 },
384
- ],
385
- encoding: {
386
- x: { field: 'm' as const, type: 'ordinal' as const },
387
- y: { field: 'v' as const, type: 'quantitative' as const },
388
- },
389
- annotations: [annotation],
390
- },
391
- { width: 800, height: 460 },
392
- );
393
- const resolved = layout.annotations?.[0];
394
- const bounds = resolved?.label?.bounds;
395
- const pointX = resolved?.dot?.x;
396
- if (!bounds || pointX === undefined) throw new Error('annotation did not resolve');
397
- return bounds.x + bounds.width / 2 - pointX;
398
- };
399
-
400
- /** The block's horizontal span expressed relative to the data point. */
401
- const spanAround = (annotation: Record<string, unknown>) => {
402
- const layout = compileChart(
403
- {
404
- mark: 'line' as const,
405
- data: [
406
- { m: 'Jan', v: 10 },
407
- { m: 'Feb', v: 50 },
408
- { m: 'Mar', v: 30 },
409
- ],
410
- encoding: {
411
- x: { field: 'm' as const, type: 'ordinal' as const },
412
- y: { field: 'v' as const, type: 'quantitative' as const },
413
- },
414
- annotations: [annotation],
415
- },
416
- { width: 800, height: 460 },
417
- );
418
- const resolved = layout.annotations?.[0];
419
- const bounds = resolved?.label?.bounds;
420
- const pointX = resolved?.dot?.x;
421
- if (!bounds || pointX === undefined) throw new Error('annotation did not resolve');
422
- return { left: bounds.x - pointX, right: bounds.x + bounds.width - pointX };
423
- };
424
-
425
- test('a single-line top-anchored block centers on its point', () => {
426
- expect(
427
- centerOffsetFor({ type: 'text', x: 'Feb', y: 50, text: 'Peak here', anchor: 'top' }),
428
- ).toBeCloseTo(0, 1);
429
- });
430
-
431
- test('a multi-line top-anchored block centers on its point', () => {
432
- expect(
433
- centerOffsetFor({
434
- type: 'text',
435
- x: 'Feb',
436
- y: 50,
437
- text: 'Hurricanes and\na strike cut',
438
- anchor: 'top',
439
- }),
440
- ).toBeCloseTo(0, 1);
441
- });
442
-
443
- test('a multi-line bottom-anchored block centers on its point', () => {
444
- expect(
445
- centerOffsetFor({
446
- type: 'text',
447
- x: 'Feb',
448
- y: 50,
449
- text: 'Two lines\nhere now',
450
- anchor: 'bottom',
451
- }),
452
- ).toBeCloseTo(0, 1);
453
- });
454
-
455
- test('the text inside stays left-aligned, not center-aligned', () => {
456
- const layout = compileChart(
457
- {
458
- mark: 'line' as const,
459
- data: [
460
- { m: 'Jan', v: 10 },
461
- { m: 'Feb', v: 50 },
462
- ],
463
- encoding: {
464
- x: { field: 'm' as const, type: 'ordinal' as const },
465
- y: { field: 'v' as const, type: 'quantitative' as const },
466
- },
467
- annotations: [
468
- { type: 'text' as const, x: 'Feb', y: 50, text: 'Two\nlines', anchor: 'top' as const },
469
- ],
470
- },
471
- { width: 800, height: 460 },
472
- );
473
- expect(layout.annotations?.[0]?.label?.style?.textAnchor).toBe('start');
474
- });
475
-
476
- test('left/right anchors are unaffected (block sits beside the point)', () => {
477
- // "Beside", not "straddling": the whole block clears the point by exactly the
478
- // ANCHOR_OFFSET setback (28px), so the point lies OUTSIDE the block's x-span.
479
- // A sign check alone (`centerOffset < 0`) still passes for a centered block
480
- // that has merely drifted — which is the regression this guards.
481
-
482
- const left = spanAround({ type: 'text', x: 'Feb', y: 50, text: 'Left side', anchor: 'left' });
483
- // Whole block to the LEFT: its near (right) edge stops one setback short of
484
- // the point, so the point never falls inside the span.
485
- expect(left.right).toBeCloseTo(-ANCHOR_OFFSET, 1);
486
- expect(left.left).toBeLessThan(left.right);
487
-
488
- const right = spanAround({
489
- type: 'text',
490
- x: 'Feb',
491
- y: 50,
492
- text: 'Right side',
493
- anchor: 'right',
494
- });
495
- // Mirror image: near (left) edge starts one setback past the point.
496
- expect(right.left).toBeCloseTo(ANCHOR_OFFSET, 1);
497
- expect(right.right).toBeGreaterThan(right.left);
498
-
499
- // And the contrast that gives "beside" its meaning: a top anchor STRADDLES,
500
- // i.e. the point lies strictly inside the block's x-span.
501
- const top = spanAround({ type: 'text', x: 'Feb', y: 50, text: 'Left side', anchor: 'top' });
502
- expect(top.left).toBeLessThan(0);
503
- expect(top.right).toBeGreaterThan(0);
504
- });
505
- });
506
-
507
- describe('annotation typography survives normalization', () => {
508
- const baseSpec = (annotation: Record<string, unknown>) => ({
509
- mark: 'line' as const,
510
- data: [
511
- { year: '1980', value: 0.26 },
512
- { year: '2025', value: 1.17 },
513
- ],
514
- encoding: {
515
- x: { field: 'year' as const, type: 'ordinal' as const },
516
- y: { field: 'value' as const, type: 'quantitative' as const },
517
- },
518
- annotations: [annotation],
519
- });
520
-
521
- const styleOf = (annotation: Record<string, unknown>) => {
522
- const layout = compileChart(baseSpec(annotation), { width: 800, height: 460 });
523
- const style = layout.annotations?.[0]?.label?.style;
524
- if (!style) throw new Error('annotation did not resolve a label style');
525
- return style;
526
- };
527
-
528
- test('a subtitle promotes the primary line to a bold lede', () => {
529
- const style = styleOf({
530
- type: 'text',
531
- x: '2025',
532
- y: 1.17,
533
- text: 'Peak',
534
- subtitle: 'the warmest year on record',
535
- });
536
- expect(style.fontWeight).toBe(700);
537
- });
538
-
539
- test('a plain annotation stays regular weight', () => {
540
- const style = styleOf({ type: 'text', x: '2025', y: 1.17, text: 'Peak' });
541
- expect(style.fontWeight).toBe(400);
542
- });
543
-
544
- test('the default font size reaches the compiled output', () => {
545
- const style = styleOf({ type: 'text', x: '2025', y: 1.17, text: 'Peak' });
546
- expect(style.fontSize).toBe(13);
547
- });
548
-
549
- test('an explicit fontWeight beats the lede rule', () => {
550
- const style = styleOf({
551
- type: 'text',
552
- x: '2025',
553
- y: 1.17,
554
- text: 'Peak',
555
- subtitle: 'context',
556
- fontWeight: 300,
557
- });
558
- expect(style.fontWeight).toBe(300);
559
- });
560
-
561
- test('an explicit fontSize beats the default', () => {
562
- const style = styleOf({ type: 'text', x: '2025', y: 1.17, text: 'Peak', fontSize: 18 });
563
- expect(style.fontSize).toBe(18);
564
- });
565
- });
566
- });