@opendata-ai/openchart-engine 8.0.0-rc.2 → 8.0.0-rc.20

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 +140 -2
  2. package/dist/index.js +1403 -210
  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 -721
  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 -370
  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 -539
  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 -139
  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 -86
  165. package/src/compiler/__tests__/validate-highlight.test.ts +0 -112
  166. package/src/compiler/__tests__/validate-near-miss.test.ts +0 -89
  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 -733
  174. package/src/compiler/sparkline-defaults.ts +0 -138
  175. package/src/compiler/types.ts +0 -241
  176. package/src/compiler/validate.ts +0 -1810
  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 -621
  185. package/src/geo/compile-map.ts +0 -856
  186. package/src/geo/join.ts +0 -115
  187. package/src/geo/projections.ts +0 -45
  188. package/src/geo/types.ts +0 -24
  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,1672 +0,0 @@
1
- import type { Annotation, LayoutStrategy, Rect } from '@opendata-ai/openchart-core';
2
- import type { ScaleBand, ScalePoint } from 'd3-scale';
3
- import { describe, expect, it } from 'vitest';
4
- import type { NormalizedChartSpec } from '../../compiler/types';
5
- import { computeScales } from '../../layout/scales';
6
- import { computeAnnotations } from '../compute';
7
- import { resolvePosition } from '../position';
8
-
9
- // ---------------------------------------------------------------------------
10
- // Fixtures
11
- // ---------------------------------------------------------------------------
12
-
13
- const chartArea: Rect = { x: 50, y: 20, width: 500, height: 300 };
14
-
15
- const fullStrategy: LayoutStrategy = {
16
- labelMode: 'all',
17
- legendPosition: 'right',
18
- annotationPosition: 'inline',
19
- axisLabelDensity: 'full',
20
- };
21
-
22
- const compactStrategy: LayoutStrategy = {
23
- labelMode: 'none',
24
- legendPosition: 'top',
25
- annotationPosition: 'tooltip-only',
26
- axisLabelDensity: 'minimal',
27
- };
28
-
29
- function makeSpec(annotations: Annotation[]): NormalizedChartSpec {
30
- return {
31
- markType: 'line',
32
- markDef: { type: 'line' },
33
- data: [
34
- { date: '2019-01-01', value: 10 },
35
- { date: '2020-01-01', value: 20 },
36
- { date: '2021-01-01', value: 30 },
37
- { date: '2022-01-01', value: 40 },
38
- ],
39
- encoding: {
40
- x: { field: 'date', type: 'temporal' },
41
- y: { field: 'value', type: 'quantitative' },
42
- },
43
- chrome: {},
44
- annotations,
45
- responsive: true,
46
- theme: {},
47
- darkMode: 'off',
48
- labels: { density: 'auto', format: '' },
49
- };
50
- }
51
-
52
- // ---------------------------------------------------------------------------
53
- // Tests
54
- // ---------------------------------------------------------------------------
55
-
56
- describe('computeAnnotations', () => {
57
- describe('text annotations', () => {
58
- it('resolves text annotation to pixel position', () => {
59
- const spec = makeSpec([{ type: 'text', x: '2020-01-01', y: 20, text: 'Important point' }]);
60
- const scales = computeScales(spec, chartArea, spec.data);
61
- const annotations = computeAnnotations(spec, scales, chartArea, fullStrategy);
62
-
63
- expect(annotations).toHaveLength(1);
64
- expect(annotations[0].type).toBe('text');
65
- expect(annotations[0].label).toBeDefined();
66
- expect(annotations[0].label!.text).toBe('Important point');
67
- expect(annotations[0].label!.visible).toBe(true);
68
- });
69
-
70
- it('text annotation label has a connector to the data point', () => {
71
- // The default 8px anchor offset produces a sub-MIN_CONNECTOR_LENGTH stub,
72
- // which is suppressed. Push the label out so a real leader survives.
73
- const spec = makeSpec([
74
- { type: 'text', x: '2020-01-01', y: 20, text: 'Note', offset: { dx: 0, dy: -60 } },
75
- ]);
76
- const scales = computeScales(spec, chartArea, spec.data);
77
- const annotations = computeAnnotations(spec, scales, chartArea, fullStrategy);
78
-
79
- expect(annotations[0].label!.connector).toBeDefined();
80
- expect(annotations[0].label!.connector!.from).toBeDefined();
81
- expect(annotations[0].label!.connector!.to).toBeDefined();
82
- });
83
-
84
- it('returns null for text annotation with invalid data value', () => {
85
- const spec = makeSpec([{ type: 'text', x: 'not-a-date', y: 999, text: 'Missing' }]);
86
- const scales = computeScales(spec, chartArea, spec.data);
87
- const annotations = computeAnnotations(spec, scales, chartArea, fullStrategy);
88
-
89
- // Invalid date should result in null annotation (filtered out)
90
- expect(annotations).toHaveLength(0);
91
- });
92
-
93
- describe('drop-line connector', () => {
94
- it('produces a vertical line through the data point with end-anchored text on the left', () => {
95
- const spec = makeSpec([
96
- {
97
- type: 'text',
98
- x: '2020-01-01',
99
- y: 20,
100
- text: 'Peak',
101
- connector: 'drop-line',
102
- anchor: 'left',
103
- },
104
- ]);
105
- const scales = computeScales(spec, chartArea, spec.data);
106
- const annotations = computeAnnotations(spec, scales, chartArea, fullStrategy);
107
-
108
- const ann = annotations[0];
109
- const c = ann.label?.connector;
110
- expect(c).toBeDefined();
111
- expect(c?.style).toBe('drop-line');
112
- // Vertical line: from.x === to.x and equals the data point's x
113
- expect(c?.from.x).toBe(c?.to.x);
114
- const px = resolvePosition('2020-01-01', scales.x);
115
- expect(c?.from.x).toBe(px);
116
- // Label sits to the left of the data point with end anchor
117
- expect(ann.label?.style.textAnchor).toBe('end');
118
- expect(ann.label?.x).toBeLessThan(px ?? Infinity);
119
- });
120
-
121
- it('flips a left-anchored label to the right when there is no room on the left', () => {
122
- // Place a long label very near the chart-area left edge — left side is too tight
123
- const spec = makeSpec([
124
- {
125
- type: 'text',
126
- x: '2019-01-01',
127
- y: 10,
128
- text: 'A long annotation that needs lots of horizontal room',
129
- connector: 'drop-line',
130
- anchor: 'left',
131
- },
132
- ]);
133
- const scales = computeScales(spec, chartArea, spec.data);
134
- const annotations = computeAnnotations(spec, scales, chartArea, fullStrategy);
135
-
136
- const ann = annotations[0];
137
- const px = resolvePosition('2019-01-01', scales.x);
138
- expect(ann.label?.style.textAnchor).toBe('start');
139
- expect(ann.label?.x).toBeGreaterThan(px ?? -Infinity);
140
- });
141
-
142
- it('flips a right-anchored label to the left when there is no room on the right', () => {
143
- const spec = makeSpec([
144
- {
145
- type: 'text',
146
- x: '2022-01-01',
147
- y: 40,
148
- text: 'A long annotation that needs lots of horizontal room',
149
- connector: 'drop-line',
150
- anchor: 'right',
151
- },
152
- ]);
153
- const scales = computeScales(spec, chartArea, spec.data);
154
- const annotations = computeAnnotations(spec, scales, chartArea, fullStrategy);
155
-
156
- const ann = annotations[0];
157
- const px = resolvePosition('2022-01-01', scales.x);
158
- expect(ann.label?.style.textAnchor).toBe('end');
159
- expect(ann.label?.x).toBeLessThan(px ?? Infinity);
160
- });
161
-
162
- it('picks the wider side when neither side fits cleanly', () => {
163
- // Tiny chart area + long label + data point near right edge means
164
- // neither side can fit the full label. Left side has more room
165
- // (x=50 ... px), so the auto-flip should land left.
166
- const tinyArea: typeof chartArea = { x: 50, y: 20, width: 120, height: 200 };
167
- const spec = makeSpec([
168
- {
169
- type: 'text',
170
- x: '2022-01-01',
171
- y: 40,
172
- text: 'A genuinely long annotation label that exceeds both sides',
173
- connector: 'drop-line',
174
- anchor: 'right',
175
- },
176
- ]);
177
- const scales = computeScales(spec, tinyArea, spec.data);
178
- const annotations = computeAnnotations(spec, scales, tinyArea, fullStrategy);
179
-
180
- // anchor=right but the right side is even narrower than left, so flip
181
- expect(annotations[0].label?.style.textAnchor).toBe('end');
182
- });
183
-
184
- it('preserves the resolved text-anchor on multi-line drop-line labels', () => {
185
- const spec = makeSpec([
186
- {
187
- type: 'text',
188
- x: '2020-01-01',
189
- y: 20,
190
- text: 'Line one\nLine two',
191
- connector: 'drop-line',
192
- anchor: 'left',
193
- },
194
- ]);
195
- const scales = computeScales(spec, chartArea, spec.data);
196
- const annotations = computeAnnotations(spec, scales, chartArea, fullStrategy);
197
-
198
- // Engine output retains end anchor; the renderer relies on this to
199
- // not override it back to middle.
200
- expect(annotations[0].label?.style.textAnchor).toBe('end');
201
- });
202
- });
203
- });
204
-
205
- describe('range annotations', () => {
206
- it('resolves x-range annotation to a rect', () => {
207
- const spec = makeSpec([
208
- { type: 'range', x1: '2020-01-01', x2: '2021-01-01', label: 'Period' },
209
- ]);
210
- const scales = computeScales(spec, chartArea, spec.data);
211
- const annotations = computeAnnotations(spec, scales, chartArea, fullStrategy);
212
-
213
- expect(annotations).toHaveLength(1);
214
- expect(annotations[0].type).toBe('range');
215
- expect(annotations[0].rect).toBeDefined();
216
- expect(annotations[0].rect!.width).toBeGreaterThan(0);
217
- // Height should span the full chart area for x-only range
218
- expect(annotations[0].rect!.height).toBe(chartArea.height);
219
- });
220
-
221
- it('range annotation has a label', () => {
222
- const spec = makeSpec([
223
- { type: 'range', x1: '2020-01-01', x2: '2021-01-01', label: 'Recession' },
224
- ]);
225
- const scales = computeScales(spec, chartArea, spec.data);
226
- const annotations = computeAnnotations(spec, scales, chartArea, fullStrategy);
227
-
228
- expect(annotations[0].label).toBeDefined();
229
- expect(annotations[0].label!.text).toBe('Recession');
230
- });
231
-
232
- it('range label defaults to 11px / weight 500', () => {
233
- const spec = makeSpec([
234
- { type: 'range', x1: '2020-01-01', x2: '2021-01-01', label: 'Period' },
235
- ]);
236
- const scales = computeScales(spec, chartArea, spec.data);
237
- const annotations = computeAnnotations(spec, scales, chartArea, fullStrategy);
238
-
239
- expect(annotations[0].label!.style.fontSize).toBe(11);
240
- expect(annotations[0].label!.style.fontWeight).toBe(500);
241
- });
242
-
243
- it('range label honors fontSize and fontWeight overrides', () => {
244
- const spec = makeSpec([
245
- {
246
- type: 'range',
247
- x1: '2020-01-01',
248
- x2: '2021-01-01',
249
- label: 'Period',
250
- fontSize: 19,
251
- fontWeight: 600,
252
- },
253
- ]);
254
- const scales = computeScales(spec, chartArea, spec.data);
255
- const annotations = computeAnnotations(spec, scales, chartArea, fullStrategy);
256
-
257
- expect(annotations[0].label!.style.fontSize).toBe(19);
258
- expect(annotations[0].label!.style.fontWeight).toBe(600);
259
- });
260
-
261
- it('range has fill and opacity', () => {
262
- const spec = makeSpec([
263
- {
264
- type: 'range',
265
- x1: '2020-01-01',
266
- x2: '2021-01-01',
267
- fill: '#ff0000',
268
- opacity: 0.2,
269
- },
270
- ]);
271
- const scales = computeScales(spec, chartArea, spec.data);
272
- const annotations = computeAnnotations(spec, scales, chartArea, fullStrategy);
273
-
274
- expect(annotations[0].fill).toBe('#ff0000');
275
- expect(annotations[0].opacity).toBe(0.2);
276
- });
277
-
278
- it('uses default fill and opacity when not specified', () => {
279
- const spec = makeSpec([{ type: 'range', x1: '2020-01-01', x2: '2021-01-01' }]);
280
- const scales = computeScales(spec, chartArea, spec.data);
281
- const annotations = computeAnnotations(spec, scales, chartArea, fullStrategy);
282
-
283
- expect(annotations[0].fill).toBeDefined();
284
- expect(annotations[0].opacity).toBeDefined();
285
- });
286
-
287
- it('interpolates range position for values between ordinal data points', () => {
288
- const ordinalSpec: NormalizedChartSpec = {
289
- markType: 'line',
290
- markDef: { type: 'line' },
291
- data: [
292
- { year: '2005', value: 10 },
293
- { year: '2007', value: 20 },
294
- { year: '2009', value: 30 },
295
- { year: '2012', value: 40 },
296
- ],
297
- encoding: {
298
- x: { field: 'year', type: 'ordinal' },
299
- y: { field: 'value', type: 'quantitative' },
300
- },
301
- chrome: {},
302
- annotations: [{ type: 'range', x1: '2008', x2: '2010', label: 'Interpolated' }],
303
- responsive: true,
304
- theme: {},
305
- darkMode: 'off',
306
- labels: { density: 'auto', format: '' },
307
- };
308
- const scales = computeScales(ordinalSpec, chartArea, ordinalSpec.data);
309
- const annotations = computeAnnotations(ordinalSpec, scales, chartArea, fullStrategy);
310
-
311
- expect(annotations).toHaveLength(1);
312
- expect(annotations[0].rect).toBeDefined();
313
- expect(annotations[0].rect!.width).toBeGreaterThan(0);
314
-
315
- // Also verify the interpolated range sits between the known data points
316
- const rangeWithKnownPoints: NormalizedChartSpec = {
317
- ...ordinalSpec,
318
- annotations: [{ type: 'range', x1: '2007', x2: '2012', label: 'Known' }],
319
- };
320
- const knownAnnotations = computeAnnotations(
321
- rangeWithKnownPoints,
322
- scales,
323
- chartArea,
324
- fullStrategy,
325
- );
326
- // The interpolated range (2008-2010) should be narrower than and inside (2007-2012)
327
- expect(annotations[0].rect!.width).toBeLessThan(knownAnnotations[0].rect!.width);
328
- expect(annotations[0].rect!.x).toBeGreaterThan(knownAnnotations[0].rect!.x);
329
- });
330
-
331
- it('clamps interpolation for values outside the ordinal domain range', () => {
332
- const ordinalSpec: NormalizedChartSpec = {
333
- markType: 'line',
334
- markDef: { type: 'line' },
335
- data: [
336
- { year: '2005', value: 10 },
337
- { year: '2007', value: 20 },
338
- { year: '2009', value: 30 },
339
- ],
340
- encoding: {
341
- x: { field: 'year', type: 'ordinal' },
342
- y: { field: 'value', type: 'quantitative' },
343
- },
344
- chrome: {},
345
- annotations: [{ type: 'range', x1: '2003', x2: '2011', label: 'Outside range' }],
346
- responsive: true,
347
- theme: {},
348
- darkMode: 'off',
349
- labels: { density: 'auto', format: '' },
350
- };
351
- const scales = computeScales(ordinalSpec, chartArea, ordinalSpec.data);
352
- const annotations = computeAnnotations(ordinalSpec, scales, chartArea, fullStrategy);
353
-
354
- expect(annotations).toHaveLength(1);
355
- expect(annotations[0].rect).toBeDefined();
356
- expect(annotations[0].rect!.width).toBeGreaterThan(0);
357
- });
358
-
359
- it('returns null for non-numeric ordinal domain values', () => {
360
- const catSpec: NormalizedChartSpec = {
361
- markType: 'bar',
362
- markDef: { type: 'bar', orient: 'vertical' },
363
- data: [
364
- { category: 'Jan', value: 10 },
365
- { category: 'Feb', value: 20 },
366
- { category: 'Mar', value: 30 },
367
- ],
368
- encoding: {
369
- x: { field: 'category', type: 'ordinal' },
370
- y: { field: 'value', type: 'quantitative' },
371
- },
372
- chrome: {},
373
- annotations: [{ type: 'range', x1: 'Jan-15', x2: 'Feb-15', label: 'Non-numeric' }],
374
- responsive: true,
375
- theme: {},
376
- darkMode: 'off',
377
- labels: { density: 'auto', format: '' },
378
- };
379
- const scales = computeScales(catSpec, chartArea, catSpec.data);
380
- const annotations = computeAnnotations(catSpec, scales, chartArea, fullStrategy);
381
-
382
- // Non-numeric domain can't interpolate, annotation is dropped
383
- expect(annotations).toHaveLength(0);
384
- });
385
-
386
- it('two adjacent point-scale ranges have a non-zero pixel gap between them', () => {
387
- // Regression: ranges ending/starting at point-scale centers would share the same
388
- // pixel boundary, visually merging. resolvePositionEdge now extends each range
389
- // by half a step so adjacent ranges are truly separated.
390
- const ordinalSpec: NormalizedChartSpec = {
391
- markType: 'line',
392
- markDef: { type: 'line' },
393
- data: [
394
- { year: '2006', value: 10 },
395
- { year: '2008', value: 20 },
396
- { year: '2010', value: 30 },
397
- { year: '2012', value: 40 },
398
- { year: '2020', value: 50 },
399
- { year: '2022', value: 60 },
400
- ],
401
- encoding: {
402
- x: { field: 'year', type: 'ordinal' },
403
- y: { field: 'value', type: 'quantitative' },
404
- },
405
- chrome: {},
406
- annotations: [
407
- { type: 'range', x1: '2008', x2: '2010', label: 'First range' },
408
- { type: 'range', x1: '2020', x2: '2022', label: 'Second range' },
409
- ],
410
- responsive: true,
411
- theme: {},
412
- darkMode: 'off',
413
- labels: { density: 'auto', format: '' },
414
- };
415
- const scales = computeScales(ordinalSpec, chartArea, ordinalSpec.data);
416
- const annotations = computeAnnotations(ordinalSpec, scales, chartArea, fullStrategy);
417
-
418
- expect(annotations).toHaveLength(2);
419
- const rect1 = annotations[0].rect!;
420
- const rect2 = annotations[1].rect!;
421
-
422
- // First range must end before second range starts (non-zero gap)
423
- expect(rect1.x + rect1.width).toBeLessThan(rect2.x);
424
- });
425
-
426
- it('single range on point-scale ordinal x covers at least a full step', () => {
427
- // A range spanning a single domain value should be at least step-wide,
428
- // since resolvePositionEdge extends by half a step on each side.
429
- const domainValues = ['2006', '2008', '2010', '2012'];
430
- const ordinalSpec: NormalizedChartSpec = {
431
- markType: 'line',
432
- markDef: { type: 'line' },
433
- data: domainValues.map((year, i) => ({ year, value: i * 10 })),
434
- encoding: {
435
- x: { field: 'year', type: 'ordinal' },
436
- y: { field: 'value', type: 'quantitative' },
437
- },
438
- chrome: {},
439
- annotations: [{ type: 'range', x1: '2008', x2: '2010', label: 'Single step' }],
440
- responsive: true,
441
- theme: {},
442
- darkMode: 'off',
443
- labels: { density: 'auto', format: '' },
444
- };
445
- const scales = computeScales(ordinalSpec, chartArea, ordinalSpec.data);
446
- const annotations = computeAnnotations(ordinalSpec, scales, chartArea, fullStrategy);
447
-
448
- expect(annotations).toHaveLength(1);
449
- const rect = annotations[0].rect!;
450
-
451
- // step = chartArea.width / (numPoints - 1) roughly. With 4 points and default 0.5 padding,
452
- // the step on a point scale = width / (n - 1 + 2*padding) -- but the key property is that
453
- // the range width should be at least as wide as the distance between two domain centers.
454
- const xScale = scales.x!.scale as ScalePoint<string>;
455
- const step = xScale.step();
456
- expect(rect.width).toBeGreaterThanOrEqual(step);
457
- });
458
-
459
- it('band-scale (bar chart) range covers full bands, not band centers', () => {
460
- // On a bar chart the x scale is a band scale. resolvePositionEdge extends from the
461
- // center (what resolvePosition returns) to the band edge.
462
- const barSpec: NormalizedChartSpec = {
463
- markType: 'bar',
464
- markDef: { type: 'bar', orient: 'vertical' },
465
- data: [
466
- { year: '2008', value: 10 },
467
- { year: '2010', value: 20 },
468
- { year: '2012', value: 30 },
469
- { year: '2014', value: 40 },
470
- ],
471
- encoding: {
472
- x: { field: 'year', type: 'ordinal' },
473
- y: { field: 'value', type: 'quantitative' },
474
- },
475
- chrome: {},
476
- annotations: [{ type: 'range', x1: '2010', x2: '2012', label: 'Band range' }],
477
- responsive: true,
478
- theme: {},
479
- darkMode: 'off',
480
- labels: { density: 'auto', format: '' },
481
- };
482
- const scales = computeScales(barSpec, chartArea, barSpec.data);
483
- const annotations = computeAnnotations(barSpec, scales, chartArea, fullStrategy);
484
-
485
- expect(annotations).toHaveLength(1);
486
- const rect = annotations[0].rect!;
487
-
488
- const bandScale = scales.x!.scale as ScaleBand<string>;
489
- const bandwidth = bandScale.bandwidth();
490
- const x1BandStart = bandScale('2010')!;
491
- const x2BandStart = bandScale('2012')!;
492
-
493
- // Left edge should be at the start of the 2010 band (not the center)
494
- expect(rect.x).toBeCloseTo(x1BandStart, 1);
495
- // Right edge should be at the end of the 2012 band
496
- expect(rect.x + rect.width).toBeCloseTo(x2BandStart + bandwidth, 1);
497
- });
498
-
499
- it('extendToEdges:false anchors a point-scale range at data point centers', () => {
500
- // With extendToEdges:false the band starts/ends exactly at the first/last
501
- // data point centers instead of extending half a step to the plot edge,
502
- // so the range is inset from the axis.
503
- const domainValues = ['2006', '2008', '2010', '2012'];
504
- const ordinalSpec: NormalizedChartSpec = {
505
- markType: 'line',
506
- markDef: { type: 'line' },
507
- data: domainValues.map((year, i) => ({ year, value: i * 10 })),
508
- encoding: {
509
- x: { field: 'year', type: 'ordinal' },
510
- y: { field: 'value', type: 'quantitative' },
511
- },
512
- chrome: {},
513
- annotations: [{ type: 'range', x1: '2006', x2: '2012', extendToEdges: false }],
514
- responsive: true,
515
- theme: {},
516
- darkMode: 'off',
517
- labels: { density: 'auto', format: '' },
518
- };
519
- const scales = computeScales(ordinalSpec, chartArea, ordinalSpec.data);
520
- const annotations = computeAnnotations(ordinalSpec, scales, chartArea, fullStrategy);
521
-
522
- expect(annotations).toHaveLength(1);
523
- const rect = annotations[0].rect!;
524
-
525
- const xScale = scales.x!.scale as ScalePoint<string>;
526
- // Left/right edges land at the point centers, not extended past them.
527
- expect(rect.x).toBeCloseTo(xScale('2006')!, 1);
528
- expect(rect.x + rect.width).toBeCloseTo(xScale('2012')!, 1);
529
- // And the band is strictly inside the plot (inset from both edges).
530
- expect(rect.x).toBeGreaterThan(chartArea.x);
531
- expect(rect.x + rect.width).toBeLessThan(chartArea.x + chartArea.width);
532
- });
533
-
534
- it('linear-scale range is unaffected by edge extension', () => {
535
- // For linear scales, resolvePositionEdge is identical to resolvePosition.
536
- // This ensures the fix doesn't introduce any drift on continuous axes.
537
- const linearSpec = makeSpec([{ type: 'range', x1: '2020-01-01', x2: '2021-01-01' }]);
538
- const scales = computeScales(linearSpec, chartArea, linearSpec.data);
539
- const annotations = computeAnnotations(linearSpec, scales, chartArea, fullStrategy);
540
-
541
- expect(annotations).toHaveLength(1);
542
- const rect = annotations[0].rect!;
543
-
544
- // The x positions should exactly match what resolvePosition would return
545
- const x1Expected = resolvePosition('2020-01-01', scales.x)!;
546
- const x2Expected = resolvePosition('2021-01-01', scales.x)!;
547
-
548
- expect(rect.x).toBeCloseTo(Math.min(x1Expected, x2Expected), 1);
549
- expect(rect.x + rect.width).toBeCloseTo(Math.max(x1Expected, x2Expected), 1);
550
- });
551
-
552
- it('y1/y2 range on ordinal point-scale y-axis has a non-zero pixel gap between two annotations', () => {
553
- // Horizontal band: y-axis is ordinal (point scale), x-axis is quantitative.
554
- // Two y-range annotations with a gap should produce two distinct rects.
555
- const ordinalYSpec: NormalizedChartSpec = {
556
- markType: 'line',
557
- markDef: { type: 'line' },
558
- data: [
559
- { year: '2006', value: 10 },
560
- { year: '2008', value: 20 },
561
- { year: '2010', value: 30 },
562
- { year: '2015', value: 40 },
563
- { year: '2020', value: 50 },
564
- { year: '2022', value: 60 },
565
- ],
566
- encoding: {
567
- x: { field: 'value', type: 'quantitative' },
568
- y: { field: 'year', type: 'ordinal' },
569
- },
570
- chrome: {},
571
- annotations: [
572
- { type: 'range', y1: '2006', y2: '2008' },
573
- { type: 'range', y1: '2020', y2: '2022' },
574
- ],
575
- responsive: true,
576
- theme: {},
577
- darkMode: 'off',
578
- labels: { density: 'auto', format: '' },
579
- };
580
- const scales = computeScales(ordinalYSpec, chartArea, ordinalYSpec.data);
581
- const annotations = computeAnnotations(ordinalYSpec, scales, chartArea, fullStrategy);
582
-
583
- expect(annotations).toHaveLength(2);
584
- const rects = annotations.map((a) => a.rect!);
585
-
586
- // In SVG, y increases downward. Sort by y so rect[0] is the top one.
587
- rects.sort((a, b) => a.y - b.y);
588
-
589
- // There must be a non-zero pixel gap between the bottom of rect[0] and top of rect[1]
590
- const bottomOfFirst = rects[0].y + rects[0].height;
591
- expect(bottomOfFirst).toBeLessThan(rects[1].y);
592
- });
593
- });
594
-
595
- describe('reference line annotations', () => {
596
- it('resolves horizontal refline at y value', () => {
597
- const spec = makeSpec([{ type: 'refline', y: 20, label: 'Target' }]);
598
- const scales = computeScales(spec, chartArea, spec.data);
599
- const annotations = computeAnnotations(spec, scales, chartArea, fullStrategy);
600
-
601
- expect(annotations).toHaveLength(1);
602
- expect(annotations[0].type).toBe('refline');
603
- expect(annotations[0].line).toBeDefined();
604
- // Horizontal line: start.x = chartArea.x, end.x = chartArea.x + chartArea.width
605
- expect(annotations[0].line!.start.x).toBe(chartArea.x);
606
- expect(annotations[0].line!.end.x).toBe(chartArea.x + chartArea.width);
607
- // Both y values should be the same (horizontal line)
608
- expect(annotations[0].line!.start.y).toBe(annotations[0].line!.end.y);
609
- });
610
-
611
- it('resolves vertical refline at x value', () => {
612
- const spec = makeSpec([{ type: 'refline', x: '2020-06-01', label: 'Event' }]);
613
- const scales = computeScales(spec, chartArea, spec.data);
614
- const annotations = computeAnnotations(spec, scales, chartArea, fullStrategy);
615
-
616
- expect(annotations).toHaveLength(1);
617
- expect(annotations[0].line).toBeDefined();
618
- // Vertical line: start.y = chartArea.y, end.y = chartArea.y + chartArea.height
619
- expect(annotations[0].line!.start.y).toBe(chartArea.y);
620
- expect(annotations[0].line!.end.y).toBe(chartArea.y + chartArea.height);
621
- });
622
-
623
- it('refline has dashed style by default', () => {
624
- const spec = makeSpec([{ type: 'refline', y: 20 }]);
625
- const scales = computeScales(spec, chartArea, spec.data);
626
- const annotations = computeAnnotations(spec, scales, chartArea, fullStrategy);
627
-
628
- expect(annotations[0].strokeDasharray).toBeDefined();
629
- });
630
-
631
- it('refline label defaults to fontSize 11', () => {
632
- const spec = makeSpec([{ type: 'refline', x: '2020-06-01', label: 'Event' }]);
633
- const scales = computeScales(spec, chartArea, spec.data);
634
- const annotations = computeAnnotations(spec, scales, chartArea, fullStrategy);
635
-
636
- expect(annotations[0].label!.style.fontSize).toBe(11);
637
- });
638
-
639
- it('refline label honors fontSize and fontWeight overrides', () => {
640
- const spec = makeSpec([
641
- { type: 'refline', x: '2020-06-01', label: 'Event', fontSize: 24, fontWeight: 600 },
642
- ]);
643
- const scales = computeScales(spec, chartArea, spec.data);
644
- const annotations = computeAnnotations(spec, scales, chartArea, fullStrategy);
645
-
646
- expect(annotations[0].label!.style.fontSize).toBe(24);
647
- expect(annotations[0].label!.style.fontWeight).toBe(600);
648
- });
649
-
650
- it('solid refline has no dasharray', () => {
651
- const spec = makeSpec([{ type: 'refline', y: 20, style: 'solid' }]);
652
- const scales = computeScales(spec, chartArea, spec.data);
653
- const annotations = computeAnnotations(spec, scales, chartArea, fullStrategy);
654
-
655
- expect(annotations[0].strokeDasharray).toBeUndefined();
656
- });
657
-
658
- it('returns nothing for refline with neither x nor y', () => {
659
- const spec = makeSpec([{ type: 'refline', label: 'Orphan' } as Annotation]);
660
- const scales = computeScales(spec, chartArea, spec.data);
661
- const annotations = computeAnnotations(spec, scales, chartArea, fullStrategy);
662
-
663
- expect(annotations).toHaveLength(0);
664
- });
665
- });
666
-
667
- describe('responsive behavior', () => {
668
- it('returns empty annotations at compact breakpoint', () => {
669
- const spec = makeSpec([
670
- { type: 'text', x: '2020-01-01', y: 20, text: 'Hidden' },
671
- { type: 'range', x1: '2020-01-01', x2: '2021-01-01' },
672
- { type: 'refline', y: 20 },
673
- ]);
674
- const scales = computeScales(spec, chartArea, spec.data);
675
- const annotations = computeAnnotations(spec, scales, chartArea, compactStrategy);
676
-
677
- expect(annotations).toHaveLength(0);
678
- });
679
-
680
- it('keeps annotations at compact breakpoint when autoThin is on', () => {
681
- const spec = makeSpec([{ type: 'text', x: '2020-01-01', y: 20, text: 'Kept' }]);
682
- const scales = computeScales(spec, chartArea, spec.data);
683
- const annotations = computeAnnotations(spec, {
684
- scales,
685
- chartArea,
686
- strategy: compactStrategy,
687
- isDark: false,
688
- obstacles: [],
689
- svg: { width: 320, height: 300 },
690
- measure: (text, font) => text.length * font.fontSize * 0.6,
691
- autoThin: true,
692
- });
693
-
694
- expect(annotations).toHaveLength(1);
695
- expect(annotations[0].label!.text).toBe('Kept');
696
- });
697
- });
698
-
699
- describe('multiple annotations', () => {
700
- it('resolves multiple mixed annotations', () => {
701
- const spec = makeSpec([
702
- { type: 'text', x: '2020-01-01', y: 20, text: 'Note' },
703
- { type: 'range', x1: '2020-01-01', x2: '2021-01-01' },
704
- { type: 'refline', y: 25 },
705
- ]);
706
- const scales = computeScales(spec, chartArea, spec.data);
707
- const annotations = computeAnnotations(spec, scales, chartArea, fullStrategy);
708
-
709
- expect(annotations).toHaveLength(3);
710
- expect(annotations.map((a) => a.type)).toEqual(['text', 'range', 'refline']);
711
- });
712
- });
713
-
714
- describe('empty annotations', () => {
715
- it('returns empty array when spec has no annotations', () => {
716
- const spec = makeSpec([]);
717
- const scales = computeScales(spec, chartArea, spec.data);
718
- const annotations = computeAnnotations(spec, scales, chartArea, fullStrategy);
719
-
720
- expect(annotations).toHaveLength(0);
721
- });
722
- });
723
-
724
- // -----------------------------------------------------------------
725
- // Fine-grained positioning (offset, anchor, connector, zIndex)
726
- // -----------------------------------------------------------------
727
-
728
- describe('text annotation offset', () => {
729
- it('applies dx/dy offset to text annotation position', () => {
730
- const spec = makeSpec([
731
- {
732
- type: 'text',
733
- x: '2020-01-01',
734
- y: 20,
735
- text: 'Offset label',
736
- offset: { dx: 20, dy: -30 },
737
- },
738
- ]);
739
- const scales = computeScales(spec, chartArea, spec.data);
740
-
741
- // Compute with and without offset
742
- const specNoOffset = makeSpec([{ type: 'text', x: '2020-01-01', y: 20, text: 'No offset' }]);
743
- const withOffset = computeAnnotations(spec, scales, chartArea, fullStrategy);
744
- const withoutOffset = computeAnnotations(specNoOffset, scales, chartArea, fullStrategy);
745
-
746
- expect(withOffset).toHaveLength(1);
747
- expect(withoutOffset).toHaveLength(1);
748
-
749
- // The offset annotation should be shifted by the dx/dy amount
750
- const dx = withOffset[0].label!.x - withoutOffset[0].label!.x;
751
- const dy = withOffset[0].label!.y - withoutOffset[0].label!.y;
752
- expect(dx).toBeCloseTo(20);
753
- expect(dy).toBeCloseTo(-30);
754
- });
755
- });
756
-
757
- describe('text annotation anchor', () => {
758
- it('anchor "top" places label above the data point', () => {
759
- const spec = makeSpec([
760
- { type: 'text', x: '2020-01-01', y: 20, text: 'Above', anchor: 'top' },
761
- ]);
762
- const scales = computeScales(spec, chartArea, spec.data);
763
-
764
- const py = scales.y?.scale(20);
765
- const annotations = computeAnnotations(spec, scales, chartArea, fullStrategy);
766
-
767
- expect(annotations).toHaveLength(1);
768
- // Label y should be above the data point y
769
- expect(annotations[0].label!.y).toBeLessThan(py as number);
770
- });
771
-
772
- it('anchor "bottom" places label below the data point', () => {
773
- const spec = makeSpec([
774
- { type: 'text', x: '2020-01-01', y: 20, text: 'Below', anchor: 'bottom' },
775
- ]);
776
- const scales = computeScales(spec, chartArea, spec.data);
777
-
778
- const py = scales.y?.scale(20);
779
- const annotations = computeAnnotations(spec, scales, chartArea, fullStrategy);
780
-
781
- expect(annotations).toHaveLength(1);
782
- // Label y should be below the data point y
783
- expect(annotations[0].label!.y).toBeGreaterThan(py as number);
784
- });
785
-
786
- it('anchor "left" places label to the left of the data point', () => {
787
- const spec = makeSpec([
788
- { type: 'text', x: '2020-01-01', y: 20, text: 'Left', anchor: 'left' },
789
- ]);
790
- const scales = computeScales(spec, chartArea, spec.data);
791
-
792
- const px = scales.x?.scale(new Date('2020-01-01'));
793
- const annotations = computeAnnotations(spec, scales, chartArea, fullStrategy);
794
-
795
- expect(annotations).toHaveLength(1);
796
- // Label x should be to the left of the data point x
797
- expect(annotations[0].label!.x).toBeLessThan(px as number);
798
- });
799
-
800
- it('anchor "right" places label to the right of the data point', () => {
801
- const spec = makeSpec([
802
- { type: 'text', x: '2020-01-01', y: 20, text: 'Right', anchor: 'right' },
803
- ]);
804
- const scales = computeScales(spec, chartArea, spec.data);
805
-
806
- const px = scales.x?.scale(new Date('2020-01-01'));
807
- const annotations = computeAnnotations(spec, scales, chartArea, fullStrategy);
808
-
809
- expect(annotations).toHaveLength(1);
810
- // Label x should be to the right of the data point x
811
- expect(annotations[0].label!.x).toBeGreaterThan(px as number);
812
- });
813
- });
814
-
815
- describe('text annotation connector', () => {
816
- it('connector is present by default once the label clears the data point', () => {
817
- const spec = makeSpec([
818
- {
819
- type: 'text',
820
- x: '2020-01-01',
821
- y: 20,
822
- text: 'Has connector',
823
- offset: { dx: 0, dy: -60 },
824
- },
825
- ]);
826
- const scales = computeScales(spec, chartArea, spec.data);
827
- const annotations = computeAnnotations(spec, scales, chartArea, fullStrategy);
828
-
829
- expect(annotations[0].label!.connector).toBeDefined();
830
- });
831
-
832
- it('connector can be disabled', () => {
833
- const spec = makeSpec([
834
- { type: 'text', x: '2020-01-01', y: 20, text: 'No connector', connector: false },
835
- ]);
836
- const scales = computeScales(spec, chartArea, spec.data);
837
- const annotations = computeAnnotations(spec, scales, chartArea, fullStrategy);
838
-
839
- expect(annotations[0].label!.connector).toBeUndefined();
840
- });
841
-
842
- it('connector "to" stops short of the endpoint marker', () => {
843
- const spec = makeSpec([
844
- {
845
- type: 'text',
846
- x: '2020-01-01',
847
- y: 20,
848
- text: 'Connector check',
849
- offset: { dx: 0, dy: -60 },
850
- },
851
- ]);
852
- const scales = computeScales(spec, chartArea, spec.data);
853
-
854
- const px = scales.x?.scale(new Date('2020-01-01')) as number;
855
- const py = scales.y?.scale(20) as number;
856
- const annotations = computeAnnotations(spec, scales, chartArea, fullStrategy);
857
-
858
- const resolved = annotations[0];
859
- const connector = resolved.label!.connector!;
860
- const dot = resolved.dot!;
861
-
862
- // The "to" endpoint is pulled back so the line clears the marker's edge
863
- // instead of piercing it.
864
- const dist = Math.hypot(px - connector.to.x, py - connector.to.y);
865
- expect(dist).toBeGreaterThan(dot.radius);
866
- expect(dist).toBeLessThanOrEqual(dot.radius + dot.strokeWidth / 2 + 3 + 0.001);
867
- });
868
- });
869
-
870
- describe('zIndex', () => {
871
- it('annotations are sorted by zIndex', () => {
872
- const spec = makeSpec([
873
- { type: 'text', x: '2020-01-01', y: 20, text: 'Low', zIndex: 1 },
874
- { type: 'refline', y: 25, zIndex: 10 },
875
- { type: 'range', x1: '2020-01-01', x2: '2021-01-01', zIndex: 5 },
876
- ]);
877
- const scales = computeScales(spec, chartArea, spec.data);
878
- const annotations = computeAnnotations(spec, scales, chartArea, fullStrategy);
879
-
880
- expect(annotations).toHaveLength(3);
881
- // Should be sorted: zIndex 1, 5, 10
882
- expect(annotations[0].zIndex).toBe(1);
883
- expect(annotations[1].zIndex).toBe(5);
884
- expect(annotations[2].zIndex).toBe(10);
885
- });
886
-
887
- it('annotations without zIndex default to 0', () => {
888
- const spec = makeSpec([
889
- { type: 'text', x: '2020-01-01', y: 20, text: 'After', zIndex: 5 },
890
- { type: 'refline', y: 25 }, // no zIndex, defaults to 0
891
- ]);
892
- const scales = computeScales(spec, chartArea, spec.data);
893
- const annotations = computeAnnotations(spec, scales, chartArea, fullStrategy);
894
-
895
- expect(annotations).toHaveLength(2);
896
- // The refline (no zIndex = 0) should come before text (zIndex 5)
897
- expect(annotations[0].type).toBe('refline');
898
- expect(annotations[1].type).toBe('text');
899
- });
900
- });
901
-
902
- describe('range annotation labelOffset', () => {
903
- it('applies labelOffset to range annotation label', () => {
904
- const spec = makeSpec([
905
- {
906
- type: 'range',
907
- x1: '2020-01-01',
908
- x2: '2021-01-01',
909
- label: 'Shifted',
910
- labelOffset: { dx: 20, dy: 10 },
911
- },
912
- ]);
913
- const specNoOffset = makeSpec([
914
- {
915
- type: 'range',
916
- x1: '2020-01-01',
917
- x2: '2021-01-01',
918
- label: 'Not shifted',
919
- },
920
- ]);
921
- const scales = computeScales(spec, chartArea, spec.data);
922
-
923
- const withOffset = computeAnnotations(spec, scales, chartArea, fullStrategy);
924
- const withoutOffset = computeAnnotations(specNoOffset, scales, chartArea, fullStrategy);
925
-
926
- const dx = withOffset[0].label!.x - withoutOffset[0].label!.x;
927
- const dy = withOffset[0].label!.y - withoutOffset[0].label!.y;
928
- expect(dx).toBeCloseTo(20);
929
- expect(dy).toBeCloseTo(10);
930
- });
931
- });
932
-
933
- describe('refline annotation labelOffset', () => {
934
- it('applies labelOffset to refline label', () => {
935
- const spec = makeSpec([
936
- {
937
- type: 'refline',
938
- y: 20,
939
- label: 'Shifted',
940
- labelOffset: { dx: 15, dy: -10 },
941
- },
942
- ]);
943
- const specNoOffset = makeSpec([
944
- {
945
- type: 'refline',
946
- y: 20,
947
- label: 'Not shifted',
948
- },
949
- ]);
950
- const scales = computeScales(spec, chartArea, spec.data);
951
-
952
- const withOffset = computeAnnotations(spec, scales, chartArea, fullStrategy);
953
- const withoutOffset = computeAnnotations(specNoOffset, scales, chartArea, fullStrategy);
954
-
955
- const dx = withOffset[0].label!.x - withoutOffset[0].label!.x;
956
- const dy = withOffset[0].label!.y - withoutOffset[0].label!.y;
957
- expect(dx).toBe(15);
958
- expect(dy).toBe(-10);
959
- });
960
- });
961
-
962
- // -----------------------------------------------------------------
963
- // Refline labelAnchor positioning
964
- // -----------------------------------------------------------------
965
-
966
- describe('refline labelAnchor positioning', () => {
967
- it('horizontal refline: "left" places label at start.x with text-anchor start', () => {
968
- const spec = makeSpec([{ type: 'refline', y: 20, label: 'Left label', labelAnchor: 'left' }]);
969
- const scales = computeScales(spec, chartArea, spec.data);
970
- const annotations = computeAnnotations(spec, scales, chartArea, fullStrategy);
971
-
972
- const label = annotations[0].label!;
973
- // Label x should be near chartArea.x (start of line) + small offset
974
- expect(label.x).toBeCloseTo(chartArea.x + 4, 0);
975
- expect(label.style.textAnchor).toBe('start');
976
- });
977
-
978
- it('horizontal refline: default places label at end.x with text-anchor end', () => {
979
- const spec = makeSpec([{ type: 'refline', y: 20, label: 'Default label' }]);
980
- const scales = computeScales(spec, chartArea, spec.data);
981
- const annotations = computeAnnotations(spec, scales, chartArea, fullStrategy);
982
-
983
- const label = annotations[0].label!;
984
- // Label x should be near chartArea.x + chartArea.width (end of line) - small offset
985
- expect(label.x).toBeCloseTo(chartArea.x + chartArea.width - 4, 0);
986
- expect(label.style.textAnchor).toBe('end');
987
- });
988
-
989
- it('horizontal refline: "bottom" places label below the line', () => {
990
- const specTop = makeSpec([{ type: 'refline', y: 20, label: 'Top', labelAnchor: 'top' }]);
991
- const specBottom = makeSpec([
992
- { type: 'refline', y: 20, label: 'Bottom', labelAnchor: 'bottom' },
993
- ]);
994
- const scales = computeScales(specTop, chartArea, specTop.data);
995
-
996
- const top = computeAnnotations(specTop, scales, chartArea, fullStrategy);
997
- const bottom = computeAnnotations(specBottom, scales, chartArea, fullStrategy);
998
-
999
- // Bottom label should be below top label (larger y value)
1000
- expect(bottom[0].label!.y).toBeGreaterThan(top[0].label!.y);
1001
- });
1002
-
1003
- it('vertical refline: "right" places label with text-anchor end', () => {
1004
- const spec = makeSpec([
1005
- { type: 'refline', x: '2020-06-01', label: 'Right', labelAnchor: 'right' },
1006
- ]);
1007
- const scales = computeScales(spec, chartArea, spec.data);
1008
- const annotations = computeAnnotations(spec, scales, chartArea, fullStrategy);
1009
-
1010
- const label = annotations[0].label!;
1011
- expect(label.style.textAnchor).toBe('end');
1012
- });
1013
-
1014
- it('vertical refline: "bottom" places label near end.y', () => {
1015
- const specTop = makeSpec([
1016
- { type: 'refline', x: '2020-06-01', label: 'Top', labelAnchor: 'top' },
1017
- ]);
1018
- const specBottom = makeSpec([
1019
- { type: 'refline', x: '2020-06-01', label: 'Bottom', labelAnchor: 'bottom' },
1020
- ]);
1021
- const scales = computeScales(specTop, chartArea, specTop.data);
1022
-
1023
- const top = computeAnnotations(specTop, scales, chartArea, fullStrategy);
1024
- const bottom = computeAnnotations(specBottom, scales, chartArea, fullStrategy);
1025
-
1026
- // Bottom label should be further down (near end.y which is chartArea.y + chartArea.height)
1027
- expect(bottom[0].label!.y).toBeGreaterThan(top[0].label!.y);
1028
- });
1029
-
1030
- it('labelOffset still applies on top of anchor positioning', () => {
1031
- const spec = makeSpec([
1032
- {
1033
- type: 'refline',
1034
- y: 20,
1035
- label: 'Offset left',
1036
- labelAnchor: 'left',
1037
- labelOffset: { dx: 10, dy: -5 },
1038
- },
1039
- ]);
1040
- const specNoOffset = makeSpec([
1041
- { type: 'refline', y: 20, label: 'No offset', labelAnchor: 'left' },
1042
- ]);
1043
- const scales = computeScales(spec, chartArea, spec.data);
1044
-
1045
- const withOffset = computeAnnotations(spec, scales, chartArea, fullStrategy);
1046
- const withoutOffset = computeAnnotations(specNoOffset, scales, chartArea, fullStrategy);
1047
-
1048
- const dx = withOffset[0].label!.x - withoutOffset[0].label!.x;
1049
- const dy = withOffset[0].label!.y - withoutOffset[0].label!.y;
1050
- expect(dx).toBe(10);
1051
- expect(dy).toBe(-5);
1052
- });
1053
- });
1054
-
1055
- // -----------------------------------------------------------------
1056
- // Connector origin (ray-box exit with standoff)
1057
- // -----------------------------------------------------------------
1058
-
1059
- describe('connector origin auto-selection', () => {
1060
- // The origin is now a ray-box intersection plus a CONNECTOR_STANDOFF gap,
1061
- // so it sits just OUTSIDE the edge it exits from rather than on it.
1062
- const STANDOFF = 6;
1063
- const INFLATE = 2;
1064
-
1065
- it('connector exits above the label when the data point is above it', () => {
1066
- const spec = makeSpec([
1067
- {
1068
- type: 'text',
1069
- x: '2020-01-01',
1070
- y: 20,
1071
- text: 'Below point',
1072
- anchor: 'bottom',
1073
- offset: { dx: 0, dy: 150 },
1074
- },
1075
- ]);
1076
- const scales = computeScales(spec, chartArea, spec.data);
1077
- const annotations = computeAnnotations(spec, scales, chartArea, fullStrategy);
1078
-
1079
- const ann = annotations[0];
1080
- const connector = ann.label!.connector!;
1081
- const bounds = ann.bounds!;
1082
-
1083
- expect(connector.exit).toBe('vertical');
1084
- // Origin sits above the (inflated) top edge by the standoff gap.
1085
- expect(connector.from.y).toBeCloseTo(bounds.y - INFLATE - STANDOFF, 0);
1086
- });
1087
-
1088
- it('connector exits below the label when the data point is below it', () => {
1089
- const spec = makeSpec([
1090
- {
1091
- type: 'text',
1092
- x: '2020-01-01',
1093
- y: 20,
1094
- text: 'Above point',
1095
- anchor: 'top',
1096
- offset: { dx: 0, dy: -80 },
1097
- },
1098
- ]);
1099
- const scales = computeScales(spec, chartArea, spec.data);
1100
- const annotations = computeAnnotations(spec, scales, chartArea, fullStrategy);
1101
-
1102
- const ann = annotations[0];
1103
- const connector = ann.label!.connector!;
1104
- const bounds = ann.bounds!;
1105
-
1106
- expect(connector.exit).toBe('vertical');
1107
- expect(connector.from.y).toBeCloseTo(bounds.y + bounds.height + INFLATE + STANDOFF, 0);
1108
- });
1109
-
1110
- it('connector exits left of the label when the data point is left of it', () => {
1111
- const spec = makeSpec([
1112
- {
1113
- type: 'text',
1114
- x: '2020-01-01',
1115
- y: 20,
1116
- text: 'Right of point',
1117
- anchor: 'right',
1118
- offset: { dx: 120, dy: 0 },
1119
- },
1120
- ]);
1121
- const scales = computeScales(spec, chartArea, spec.data);
1122
- const annotations = computeAnnotations(spec, scales, chartArea, fullStrategy);
1123
-
1124
- const ann = annotations[0];
1125
- const connector = ann.label!.connector!;
1126
- const bounds = ann.bounds!;
1127
-
1128
- expect(connector.exit).toBe('horizontal');
1129
- // Origin sits left of the (inflated) left edge by the standoff gap. Asserting
1130
- // only `< bounds.x` would pass for an origin anywhere off to the left, which
1131
- // is what a broken ray-box intersection produces.
1132
- expect(connector.from.x).toBeCloseTo(bounds.x - INFLATE - STANDOFF, 1);
1133
- });
1134
-
1135
- it('connector exits right of the label when the data point is right of it', () => {
1136
- const spec = makeSpec([
1137
- {
1138
- type: 'text',
1139
- x: '2021-01-01',
1140
- y: 20,
1141
- text: 'Left of point',
1142
- anchor: 'left',
1143
- offset: { dx: -120, dy: 0 },
1144
- },
1145
- ]);
1146
- const scales = computeScales(spec, chartArea, spec.data);
1147
- const annotations = computeAnnotations(spec, scales, chartArea, fullStrategy);
1148
-
1149
- const ann = annotations[0];
1150
- const connector = ann.label!.connector!;
1151
- const bounds = ann.bounds!;
1152
-
1153
- expect(connector.exit).toBe('horizontal');
1154
- // Mirror of the left-exit case: one standoff gap past the inflated right edge.
1155
- expect(connector.from.x).toBeCloseTo(bounds.x + bounds.width + INFLATE + STANDOFF, 1);
1156
- });
1157
-
1158
- it('the connector origin always sits outside the annotation block', () => {
1159
- const spec = makeSpec([
1160
- {
1161
- type: 'text',
1162
- x: '2020-01-01',
1163
- y: 20,
1164
- text: 'First line\nSecond line',
1165
- anchor: 'bottom',
1166
- offset: { dx: 0, dy: 80 },
1167
- },
1168
- ]);
1169
- const scales = computeScales(spec, chartArea, spec.data);
1170
- const annotations = computeAnnotations(spec, scales, chartArea, fullStrategy);
1171
-
1172
- const ann = annotations[0];
1173
- const { from } = ann.label!.connector!;
1174
- const b = ann.bounds!;
1175
- const inside =
1176
- from.x >= b.x && from.x <= b.x + b.width && from.y >= b.y && from.y <= b.y + b.height;
1177
- expect(inside).toBe(false);
1178
- });
1179
-
1180
- it('connector origin flips sides when the label moves to the other side', () => {
1181
- const specRight = makeSpec([
1182
- {
1183
- type: 'text',
1184
- x: '2020-01-01',
1185
- y: 20,
1186
- text: 'Test label',
1187
- anchor: 'right',
1188
- offset: { dx: 120, dy: 0 },
1189
- },
1190
- ]);
1191
- const specLeft = makeSpec([
1192
- {
1193
- type: 'text',
1194
- x: '2020-01-01',
1195
- y: 20,
1196
- text: 'Test label',
1197
- anchor: 'left',
1198
- offset: { dx: -120, dy: 0 },
1199
- },
1200
- ]);
1201
- const scalesRight = computeScales(specRight, chartArea, specRight.data);
1202
- const scalesLeft = computeScales(specLeft, chartArea, specLeft.data);
1203
-
1204
- const annRight = computeAnnotations(specRight, scalesRight, chartArea, fullStrategy)[0];
1205
- const annLeft = computeAnnotations(specLeft, scalesLeft, chartArea, fullStrategy)[0];
1206
-
1207
- // Label right of the point: exit off the left edge.
1208
- expect(annRight.label!.connector!.from.x).toBeLessThan(annRight.bounds!.x);
1209
- // Label left of the point: exit off the right edge.
1210
- expect(annLeft.label!.connector!.from.x).toBeGreaterThan(
1211
- annLeft.bounds!.x + annLeft.bounds!.width,
1212
- );
1213
- });
1214
-
1215
- it('connectorOffset is still applied on top of the auto-selected origin', () => {
1216
- const specWithOffset = makeSpec([
1217
- {
1218
- type: 'text',
1219
- x: '2020-01-01',
1220
- y: 20,
1221
- text: 'Offset test',
1222
- anchor: 'bottom',
1223
- offset: { dx: 0, dy: 80 },
1224
- connectorOffset: { from: { dx: 10, dy: 5 } },
1225
- },
1226
- ]);
1227
- const specNoOffset = makeSpec([
1228
- {
1229
- type: 'text',
1230
- x: '2020-01-01',
1231
- y: 20,
1232
- text: 'Offset test',
1233
- anchor: 'bottom',
1234
- offset: { dx: 0, dy: 80 },
1235
- },
1236
- ]);
1237
- const scales = computeScales(specWithOffset, chartArea, specWithOffset.data);
1238
-
1239
- const withOffset = computeAnnotations(specWithOffset, scales, chartArea, fullStrategy);
1240
- const withoutOffset = computeAnnotations(specNoOffset, scales, chartArea, fullStrategy);
1241
-
1242
- const fromWith = withOffset[0].label!.connector!.from;
1243
- const fromWithout = withoutOffset[0].label!.connector!.from;
1244
-
1245
- expect(fromWith.x - fromWithout.x).toBeCloseTo(10, 0);
1246
- expect(fromWith.y - fromWithout.y).toBeCloseTo(5, 0);
1247
- });
1248
-
1249
- // Curve connectors used to hardcode a right-edge exit regardless of where the
1250
- // target was; they now use the same ray-box exit as straight connectors.
1251
- it('curve connector exits toward the data point, not always from the right edge', () => {
1252
- const spec = makeSpec([
1253
- {
1254
- type: 'text',
1255
- x: '2020-01-01',
1256
- y: 20,
1257
- text: 'Curve test',
1258
- anchor: 'bottom',
1259
- offset: { dx: 0, dy: 80 },
1260
- connector: 'curve',
1261
- },
1262
- ]);
1263
- const scales = computeScales(spec, chartArea, spec.data);
1264
- const annotations = computeAnnotations(spec, scales, chartArea, fullStrategy);
1265
-
1266
- const ann = annotations[0];
1267
- const connector = ann.label!.connector!;
1268
- const bounds = ann.bounds!;
1269
-
1270
- // Data point is straight above the label, so the curve leaves the top.
1271
- expect(connector.exit).toBe('vertical');
1272
- expect(connector.from.y).toBeLessThan(bounds.y);
1273
- });
1274
- });
1275
-
1276
- // -----------------------------------------------------------------
1277
- // Annotation-to-annotation collision resolution
1278
- // -----------------------------------------------------------------
1279
-
1280
- describe('annotation-to-annotation collision', () => {
1281
- it('nudges second annotation when two overlap at the same data point', () => {
1282
- const spec = makeSpec([
1283
- { type: 'text', x: '2020-01-01', y: 20, text: 'First note' },
1284
- { type: 'text', x: '2020-01-01', y: 20, text: 'Second note' },
1285
- ]);
1286
- const scales = computeScales(spec, chartArea, spec.data);
1287
- const annotations = computeAnnotations(spec, scales, chartArea, fullStrategy);
1288
-
1289
- expect(annotations).toHaveLength(2);
1290
-
1291
- const label1 = annotations[0].label!;
1292
- const label2 = annotations[1].label!;
1293
-
1294
- // Both should be visible
1295
- expect(label1.visible).toBe(true);
1296
- expect(label2.visible).toBe(true);
1297
-
1298
- // Labels should not overlap: their positions should differ
1299
- const samePosition = label1.x === label2.x && label1.y === label2.y;
1300
- expect(samePosition).toBe(false);
1301
- });
1302
-
1303
- it('nudges second annotation when nearby data points produce overlapping labels', () => {
1304
- const spec = makeSpec([
1305
- { type: 'text', x: '2020-01-01', y: 20, text: 'First annotation' },
1306
- { type: 'text', x: '2020-01-01', y: 21, text: 'Second annotation' },
1307
- ]);
1308
- const scales = computeScales(spec, chartArea, spec.data);
1309
- const annotations = computeAnnotations(spec, scales, chartArea, fullStrategy);
1310
-
1311
- expect(annotations).toHaveLength(2);
1312
-
1313
- const label1 = annotations[0].label!;
1314
- const label2 = annotations[1].label!;
1315
-
1316
- // After collision resolution, bounding boxes should not overlap
1317
- // Check that at least one coordinate differs meaningfully
1318
- const dy = Math.abs(label1.y - label2.y);
1319
- const dx = Math.abs(label1.x - label2.x);
1320
- expect(dx + dy).toBeGreaterThan(5);
1321
- });
1322
-
1323
- it('recomputes connector origin after nudging', () => {
1324
- const spec = makeSpec([
1325
- {
1326
- type: 'text',
1327
- x: '2020-01-01',
1328
- y: 20,
1329
- text: 'First note',
1330
- offset: { dx: 0, dy: -60 },
1331
- },
1332
- {
1333
- type: 'text',
1334
- x: '2020-01-01',
1335
- y: 20,
1336
- text: 'Second note',
1337
- offset: { dx: 0, dy: -60 },
1338
- },
1339
- ]);
1340
- const scales = computeScales(spec, chartArea, spec.data);
1341
- const annotations = computeAnnotations(spec, scales, chartArea, fullStrategy);
1342
-
1343
- const nudged = annotations[1];
1344
- const nudgedLabel = nudged.label!;
1345
-
1346
- // The nudged annotation should still have a connector
1347
- expect(nudgedLabel.connector).toBeDefined();
1348
-
1349
- // The origin must sit just outside the nudged block, not the original one.
1350
- const b = nudged.bounds!;
1351
- const { from } = nudgedLabel.connector!;
1352
- const inside =
1353
- from.x >= b.x && from.x <= b.x + b.width && from.y >= b.y && from.y <= b.y + b.height;
1354
- expect(inside).toBe(false);
1355
-
1356
- const distFromBlock = Math.min(
1357
- Math.abs(from.y - b.y),
1358
- Math.abs(from.y - (b.y + b.height)),
1359
- Math.abs(from.x - b.x),
1360
- Math.abs(from.x - (b.x + b.width)),
1361
- );
1362
- // The origin clears the NUDGED block by the standoff gap and no more. The ray
1363
- // leaves the box diagonally, so the CONNECTOR_STANDOFF (6) splits across x and
1364
- // y and the perpendicular clearance lands somewhere in (BOX_INFLATE,
1365
- // BOX_INFLATE + CONNECTOR_STANDOFF] = (2, 8]. A connector rebuilt against the
1366
- // pre-nudge bounds strands the origin tens of pixels off the block, which the
1367
- // old 20px window still admitted.
1368
- expect(distFromBlock).toBeGreaterThan(2);
1369
- expect(distFromBlock).toBeLessThanOrEqual(8);
1370
- });
1371
-
1372
- // The subtitle carries absolute coordinates, so a nudge that only moved the
1373
- // label used to leave the subtitle stranded at the original position.
1374
- it('subtitle moves with a nudged label', () => {
1375
- const spec = makeSpec([
1376
- { type: 'text', x: '2020-01-01', y: 20, text: 'First note', subtitle: 'Context one' },
1377
- { type: 'text', x: '2020-01-01', y: 20, text: 'Second note', subtitle: 'Context two' },
1378
- ]);
1379
- const scales = computeScales(spec, chartArea, spec.data);
1380
- const annotations = computeAnnotations(spec, scales, chartArea, fullStrategy);
1381
-
1382
- const single = computeAnnotations(
1383
- makeSpec([spec.annotations[1]]),
1384
- scales,
1385
- chartArea,
1386
- fullStrategy,
1387
- )[0];
1388
-
1389
- const nudged = annotations[1];
1390
- const dx = nudged.label!.x - single.label!.x;
1391
- const dy = nudged.label!.y - single.label!.y;
1392
- expect(Math.abs(dx) + Math.abs(dy)).toBeGreaterThan(0);
1393
-
1394
- // The subtitle shifted by the same delta as its label.
1395
- expect(nudged.subtitle!.x).toBeCloseTo(single.subtitle!.x + dx, 5);
1396
- expect(nudged.subtitle!.y).toBeCloseTo(single.subtitle!.y + dy, 5);
1397
-
1398
- // And the label/subtitle gap is unchanged.
1399
- expect(nudged.subtitle!.y - nudged.label!.y).toBeCloseTo(
1400
- single.subtitle!.y - single.label!.y,
1401
- 5,
1402
- );
1403
- });
1404
-
1405
- // Auto-placement (compute.ts Pass 2) stamps a new label position from the
1406
- // placement search; the subtitle must land where the search scored it.
1407
- it('subtitle follows an auto-placed label', () => {
1408
- const spec = makeSpec([
1409
- {
1410
- type: 'text',
1411
- x: '2020-01-01',
1412
- y: 20,
1413
- text: 'Auto placed',
1414
- subtitle: 'Its context line',
1415
- },
1416
- ]);
1417
- const scales = computeScales(spec, chartArea, spec.data);
1418
- const annotations = computeAnnotations(spec, {
1419
- scales,
1420
- chartArea,
1421
- strategy: fullStrategy,
1422
- isDark: false,
1423
- obstacles: [],
1424
- svg: { width: 600, height: 400 },
1425
- measure: (text, font) => text.length * font.fontSize * 0.6,
1426
- });
1427
-
1428
- const ann = annotations[0];
1429
- const label = ann.label!;
1430
- const sub = ann.subtitle!;
1431
- const fontSize = label.style.fontSize as number;
1432
- const lineHeight = label.style.lineHeight as number;
1433
-
1434
- expect(sub.x).toBeCloseTo(label.x, 5);
1435
- expect(sub.y).toBeCloseTo(label.y + fontSize * lineHeight * 1 + 2, 5);
1436
- expect(sub.style.textAnchor).toBe(label.style.textAnchor);
1437
- // The stamped bounds cover both lines.
1438
- expect(ann.bounds!.height).toBeGreaterThan(fontSize * lineHeight);
1439
- });
1440
-
1441
- it('resolves three overlapping annotations without any collision', () => {
1442
- const spec = makeSpec([
1443
- { type: 'text', x: '2020-01-01', y: 20, text: 'Note A' },
1444
- { type: 'text', x: '2020-01-01', y: 20, text: 'Note B' },
1445
- { type: 'text', x: '2020-01-01', y: 20, text: 'Note C' },
1446
- ]);
1447
- const scales = computeScales(spec, chartArea, spec.data);
1448
- const annotations = computeAnnotations(spec, scales, chartArea, fullStrategy);
1449
-
1450
- expect(annotations).toHaveLength(3);
1451
-
1452
- // All three should be visible
1453
- for (const ann of annotations) {
1454
- expect(ann.label!.visible).toBe(true);
1455
- }
1456
-
1457
- // All three should have distinct positions
1458
- const positions = annotations.map((a) => `${a.label!.x.toFixed(1)},${a.label!.y.toFixed(1)}`);
1459
- const unique = new Set(positions);
1460
- expect(unique.size).toBe(3);
1461
- });
1462
-
1463
- it('does not nudge annotations that already have distinct positions', () => {
1464
- const spec = makeSpec([
1465
- {
1466
- type: 'text',
1467
- x: '2020-01-01',
1468
- y: 10,
1469
- text: 'Low point',
1470
- anchor: 'bottom',
1471
- offset: { dx: 0, dy: 40 },
1472
- },
1473
- {
1474
- type: 'text',
1475
- x: '2022-01-01',
1476
- y: 40,
1477
- text: 'High point',
1478
- anchor: 'top',
1479
- offset: { dx: 0, dy: -40 },
1480
- },
1481
- ]);
1482
- const scales = computeScales(spec, chartArea, spec.data);
1483
-
1484
- // Compute once to get positions
1485
- const annotations = computeAnnotations(spec, scales, chartArea, fullStrategy);
1486
-
1487
- expect(annotations).toHaveLength(2);
1488
-
1489
- // These annotations are far apart, so positions should match what they'd
1490
- // be without collision resolution (i.e., not nudged)
1491
- const specSingle1 = makeSpec([spec.annotations[0]]);
1492
- const specSingle2 = makeSpec([spec.annotations[1]]);
1493
- const single1 = computeAnnotations(specSingle1, scales, chartArea, fullStrategy);
1494
- const single2 = computeAnnotations(specSingle2, scales, chartArea, fullStrategy);
1495
-
1496
- expect(annotations[0].label!.x).toBeCloseTo(single1[0].label!.x, 1);
1497
- expect(annotations[0].label!.y).toBeCloseTo(single1[0].label!.y, 1);
1498
- expect(annotations[1].label!.x).toBeCloseTo(single2[0].label!.x, 1);
1499
- expect(annotations[1].label!.y).toBeCloseTo(single2[0].label!.y, 1);
1500
- });
1501
- });
1502
-
1503
- // -----------------------------------------------------------------
1504
- // Obstacle avoidance (label bounds as obstacles)
1505
- // -----------------------------------------------------------------
1506
-
1507
- describe('obstacle avoidance', () => {
1508
- it('nudges annotation away from an obstacle rect at the same position', () => {
1509
- const spec = makeSpec([{ type: 'text', x: '2020-01-01', y: 20, text: 'Annotation' }]);
1510
- const scales = computeScales(spec, chartArea, spec.data);
1511
-
1512
- // Place an obstacle rect exactly where the annotation would land
1513
- const withoutObstacles = computeAnnotations(spec, scales, chartArea, fullStrategy);
1514
- const originalLabel = withoutObstacles[0].label!;
1515
-
1516
- const obstacle: Rect = {
1517
- x: originalLabel.x - 5,
1518
- y: originalLabel.y - 5,
1519
- width: 80,
1520
- height: 30,
1521
- };
1522
-
1523
- const withObstacles = computeAnnotations(spec, scales, chartArea, fullStrategy, false, [
1524
- obstacle,
1525
- ]);
1526
-
1527
- expect(withObstacles).toHaveLength(1);
1528
- const nudgedLabel = withObstacles[0].label!;
1529
- expect(nudgedLabel.visible).toBe(true);
1530
-
1531
- // The annotation should have moved away from the obstacle
1532
- const moved = nudgedLabel.x !== originalLabel.x || nudgedLabel.y !== originalLabel.y;
1533
- expect(moved).toBe(true);
1534
- });
1535
-
1536
- it('preserves connector style when nudged away from obstacle', () => {
1537
- // connector: true means "straight line" - obstacle avoidance should not change it
1538
- const spec = makeSpec([
1539
- {
1540
- type: 'text',
1541
- x: '2020-01-01',
1542
- y: 20,
1543
- text: 'Explicit connector',
1544
- connector: true,
1545
- offset: { dx: 0, dy: -60 },
1546
- },
1547
- ]);
1548
- const scales = computeScales(spec, chartArea, spec.data);
1549
-
1550
- const withoutObstacles = computeAnnotations(spec, scales, chartArea, fullStrategy);
1551
- const originalLabel = withoutObstacles[0].label!;
1552
- expect(originalLabel.connector).toBeDefined();
1553
- expect(originalLabel.connector!.style).toBe('straight');
1554
-
1555
- // Place obstacle directly on the annotation to force a nudge
1556
- const obstacle: Rect = {
1557
- x: originalLabel.x - 5,
1558
- y: originalLabel.y - 5,
1559
- width: 80,
1560
- height: 30,
1561
- };
1562
-
1563
- const withObstacles = computeAnnotations(spec, scales, chartArea, fullStrategy, false, [
1564
- obstacle,
1565
- ]);
1566
- const nudgedLabel = withObstacles[0].label!;
1567
- expect(nudgedLabel.connector).toBeDefined();
1568
- expect(nudgedLabel.connector!.style).toBe('straight');
1569
- });
1570
- });
1571
-
1572
- // -----------------------------------------------------------------
1573
- // Boundary clamping (SVG edge overflow prevention)
1574
- // -----------------------------------------------------------------
1575
-
1576
- describe('boundary clamping', () => {
1577
- const svgDimensions = { width: 600, height: 400 };
1578
-
1579
- it('shifts annotation left when it overflows the right SVG edge', () => {
1580
- // Place annotation at the far right of the chart area so the label
1581
- // text extends past the SVG boundary.
1582
- const spec = makeSpec([
1583
- {
1584
- type: 'text',
1585
- x: '2022-01-01',
1586
- y: 40,
1587
- text: 'This is a long annotation label',
1588
- anchor: 'right',
1589
- offset: { dx: 80, dy: 0 },
1590
- },
1591
- ]);
1592
- const scales = computeScales(spec, chartArea, spec.data);
1593
- const annotations = computeAnnotations(
1594
- spec,
1595
- scales,
1596
- chartArea,
1597
- fullStrategy,
1598
- false,
1599
- [],
1600
- svgDimensions,
1601
- );
1602
-
1603
- expect(annotations).toHaveLength(1);
1604
- const label = annotations[0].label!;
1605
- // Use engine-exported bounds instead of re-deriving text width
1606
- expect(label.bounds).toBeDefined();
1607
- expect(label.bounds!.x + label.bounds!.width).toBeLessThanOrEqual(svgDimensions.width);
1608
- });
1609
-
1610
- it('shifts annotation down when it overflows the top SVG edge', () => {
1611
- // Place annotation near the top of the chart and push it upward
1612
- const spec = makeSpec([
1613
- {
1614
- type: 'text',
1615
- x: '2020-01-01',
1616
- y: 40,
1617
- text: 'Top overflow',
1618
- anchor: 'top',
1619
- offset: { dx: 0, dy: -80 },
1620
- },
1621
- ]);
1622
- const scales = computeScales(spec, chartArea, spec.data);
1623
- const annotations = computeAnnotations(
1624
- spec,
1625
- scales,
1626
- chartArea,
1627
- fullStrategy,
1628
- false,
1629
- [],
1630
- svgDimensions,
1631
- );
1632
-
1633
- expect(annotations).toHaveLength(1);
1634
- const label = annotations[0].label!;
1635
- const fontSize = label.style.fontSize ?? 12;
1636
- // The top of the label bounds (y - fontSize) should be >= 0
1637
- expect(label.y - fontSize).toBeGreaterThanOrEqual(0);
1638
- });
1639
-
1640
- it('does not modify annotation that is well within bounds', () => {
1641
- // Place annotation in the center of the chart with an explicit offset
1642
- // so it uses the same explicit placement path with and without SVG dims
1643
- const spec = makeSpec([
1644
- {
1645
- type: 'text',
1646
- x: '2020-06-01',
1647
- y: 25,
1648
- text: 'Centered',
1649
- offset: { dx: 0, dy: -10 },
1650
- },
1651
- ]);
1652
- const scales = computeScales(spec, chartArea, spec.data);
1653
-
1654
- // Compute with and without SVG dimensions to verify no change
1655
- const withClamping = computeAnnotations(
1656
- spec,
1657
- scales,
1658
- chartArea,
1659
- fullStrategy,
1660
- false,
1661
- [],
1662
- svgDimensions,
1663
- );
1664
- const withoutClamping = computeAnnotations(spec, scales, chartArea, fullStrategy, false, []);
1665
-
1666
- expect(withClamping).toHaveLength(1);
1667
- expect(withoutClamping).toHaveLength(1);
1668
- expect(withClamping[0].label!.x).toBe(withoutClamping[0].label!.x);
1669
- expect(withClamping[0].label!.y).toBe(withoutClamping[0].label!.y);
1670
- });
1671
- });
1672
- });