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

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 (270) hide show
  1. package/dist/index.js +2 -3
  2. package/dist/index.js.map +1 -1
  3. package/package.json +3 -4
  4. package/src/__test-fixtures__/specs.ts +0 -182
  5. package/src/__tests__/__snapshots__/compile-snapshot.test.ts.snap +0 -2401
  6. package/src/__tests__/annotation-behavior.test.ts +0 -852
  7. package/src/__tests__/axes.test.ts +0 -1579
  8. package/src/__tests__/compile-animation.test.ts +0 -161
  9. package/src/__tests__/compile-chart.test.ts +0 -1303
  10. package/src/__tests__/compile-footnotes.test.ts +0 -277
  11. package/src/__tests__/compile-layer.test.ts +0 -969
  12. package/src/__tests__/compile-snapshot.test.ts +0 -156
  13. package/src/__tests__/compound-labels.test.ts +0 -147
  14. package/src/__tests__/dimensions.test.ts +0 -918
  15. package/src/__tests__/encoding-sugar.test.ts +0 -390
  16. package/src/__tests__/facet.test.ts +0 -359
  17. package/src/__tests__/helpers/invariants.ts +0 -299
  18. package/src/__tests__/known-bugs.test.ts +0 -566
  19. package/src/__tests__/layout-fuzz.test.ts +0 -358
  20. package/src/__tests__/layout-invariants.test.ts +0 -535
  21. package/src/__tests__/legend.test.ts +0 -721
  22. package/src/__tests__/mark-keys.test.ts +0 -294
  23. package/src/__tests__/ranking-recipes.test.ts +0 -260
  24. package/src/__tests__/rotation.test.ts +0 -83
  25. package/src/__tests__/scales.test.ts +0 -323
  26. package/src/__tests__/series-search.test.ts +0 -92
  27. package/src/__tests__/spec-sugar.test.ts +0 -801
  28. package/src/__tests__/you-draw-it.test.ts +0 -160
  29. package/src/annotations/__tests__/compute.test.ts +0 -1672
  30. package/src/annotations/__tests__/geometry.test.ts +0 -66
  31. package/src/annotations/__tests__/placement-fuzz.test.ts +0 -256
  32. package/src/annotations/__tests__/placement-invariants.test.ts +0 -345
  33. package/src/annotations/__tests__/placement.test.ts +0 -316
  34. package/src/annotations/__tests__/resolve-text.test.ts +0 -788
  35. package/src/annotations/__tests__/rich-text.test.ts +0 -91
  36. package/src/annotations/__tests__/thinning.test.ts +0 -285
  37. package/src/annotations/collisions.ts +0 -326
  38. package/src/annotations/compute.ts +0 -333
  39. package/src/annotations/constants.ts +0 -162
  40. package/src/annotations/geometry.ts +0 -376
  41. package/src/annotations/move.ts +0 -135
  42. package/src/annotations/placement.ts +0 -636
  43. package/src/annotations/position.ts +0 -131
  44. package/src/annotations/resolve-range.ts +0 -117
  45. package/src/annotations/resolve-refline.ts +0 -157
  46. package/src/annotations/resolve-text.ts +0 -545
  47. package/src/annotations/rich-text.ts +0 -65
  48. package/src/annotations/thinning.ts +0 -190
  49. package/src/barlist/__tests__/compile-barlist.test.ts +0 -200
  50. package/src/barlist/compile-barlist.ts +0 -391
  51. package/src/barlist/types.ts +0 -28
  52. package/src/charts/__tests__/post-process.test.ts +0 -237
  53. package/src/charts/__tests__/registry.test.ts +0 -116
  54. package/src/charts/__tests__/utils.test.ts +0 -195
  55. package/src/charts/_shared/__tests__/density-filter.test.ts +0 -32
  56. package/src/charts/_shared/density-filter.ts +0 -26
  57. package/src/charts/_shared/format-label-value.ts +0 -16
  58. package/src/charts/bar/__tests__/compute.test.ts +0 -934
  59. package/src/charts/bar/__tests__/gradient-orient.test.ts +0 -127
  60. package/src/charts/bar/__tests__/labels.test.ts +0 -292
  61. package/src/charts/bar/compute.ts +0 -539
  62. package/src/charts/bar/index.ts +0 -60
  63. package/src/charts/bar/labels.ts +0 -311
  64. package/src/charts/beeswarm/__tests__/compute.test.ts +0 -280
  65. package/src/charts/beeswarm/__tests__/dodge.test.ts +0 -148
  66. package/src/charts/beeswarm/compute.ts +0 -203
  67. package/src/charts/beeswarm/dodge.ts +0 -155
  68. package/src/charts/beeswarm/index.ts +0 -32
  69. package/src/charts/builtin.ts +0 -75
  70. package/src/charts/calendar/__tests__/compute.test.ts +0 -417
  71. package/src/charts/calendar/compute.ts +0 -319
  72. package/src/charts/calendar/index.ts +0 -32
  73. package/src/charts/column/__tests__/compute.test.ts +0 -540
  74. package/src/charts/column/__tests__/labels.test.ts +0 -179
  75. package/src/charts/column/compute.ts +0 -493
  76. package/src/charts/column/index.ts +0 -53
  77. package/src/charts/column/labels.ts +0 -229
  78. package/src/charts/dot/__tests__/compute.test.ts +0 -380
  79. package/src/charts/dot/__tests__/labels.test.ts +0 -98
  80. package/src/charts/dot/compute.ts +0 -285
  81. package/src/charts/dot/index.ts +0 -62
  82. package/src/charts/dot/labels.ts +0 -121
  83. package/src/charts/line/__tests__/compute.test.ts +0 -1194
  84. package/src/charts/line/__tests__/curves.test.ts +0 -51
  85. package/src/charts/line/__tests__/labels.test.ts +0 -94
  86. package/src/charts/line/__tests__/mark-options.test.ts +0 -175
  87. package/src/charts/line/area.ts +0 -484
  88. package/src/charts/line/compute.ts +0 -356
  89. package/src/charts/line/curves.ts +0 -85
  90. package/src/charts/line/index.ts +0 -176
  91. package/src/charts/line/labels.ts +0 -177
  92. package/src/charts/parliament/__tests__/compute.test.ts +0 -411
  93. package/src/charts/parliament/compute.ts +0 -313
  94. package/src/charts/parliament/index.ts +0 -32
  95. package/src/charts/pie/__tests__/compute.test.ts +0 -384
  96. package/src/charts/pie/__tests__/labels.test.ts +0 -132
  97. package/src/charts/pie/compute.ts +0 -324
  98. package/src/charts/pie/index.ts +0 -49
  99. package/src/charts/pie/labels.ts +0 -143
  100. package/src/charts/post-process.ts +0 -229
  101. package/src/charts/range/__tests__/compute.test.ts +0 -486
  102. package/src/charts/range/compute.ts +0 -273
  103. package/src/charts/range/index.ts +0 -82
  104. package/src/charts/range/labels.ts +0 -169
  105. package/src/charts/rect/__tests__/compute.test.ts +0 -120
  106. package/src/charts/rect/index.ts +0 -127
  107. package/src/charts/registry.ts +0 -72
  108. package/src/charts/rule/index.ts +0 -139
  109. package/src/charts/scatter/__tests__/compute.test.ts +0 -488
  110. package/src/charts/scatter/__tests__/trendline.test.ts +0 -191
  111. package/src/charts/scatter/compute.ts +0 -192
  112. package/src/charts/scatter/index.ts +0 -46
  113. package/src/charts/scatter/trendline.ts +0 -100
  114. package/src/charts/text/__tests__/compute.test.ts +0 -307
  115. package/src/charts/text/index.ts +0 -141
  116. package/src/charts/tick/__tests__/compute.test.ts +0 -75
  117. package/src/charts/tick/index.ts +0 -99
  118. package/src/charts/utils.ts +0 -170
  119. package/src/charts/waffle/__tests__/compute.test.ts +0 -305
  120. package/src/charts/waffle/compute.ts +0 -197
  121. package/src/charts/waffle/index.ts +0 -31
  122. package/src/compile/__tests__/color-scale-range.test.ts +0 -158
  123. package/src/compile/__tests__/color-scale-reverse.test.ts +0 -89
  124. package/src/compile/__tests__/data-clip.test.ts +0 -127
  125. package/src/compile/__tests__/fill-patterns.test.ts +0 -154
  126. package/src/compile/__tests__/size-scale.test.ts +0 -108
  127. package/src/compile/__tests__/watermark-obstacle.test.ts +0 -93
  128. package/src/compile/color-scale-range.ts +0 -101
  129. package/src/compile/data-clip.ts +0 -48
  130. package/src/compile/fill-patterns.ts +0 -74
  131. package/src/compile/layer.ts +0 -630
  132. package/src/compile/size-scale.ts +0 -141
  133. package/src/compile/spec-sugar.ts +0 -767
  134. package/src/compile/watermark-obstacle.ts +0 -54
  135. package/src/compile/you-draw-it.ts +0 -123
  136. package/src/compile.ts +0 -1548
  137. package/src/compiler/__tests__/a11y-warnings.test.ts +0 -148
  138. package/src/compiler/__tests__/animation.test.ts +0 -177
  139. package/src/compiler/__tests__/compile.test.ts +0 -87
  140. package/src/compiler/__tests__/fixtures/vl-parity/axis-null.json +0 -19
  141. package/src/compiler/__tests__/fixtures/vl-parity/color-value.json +0 -20
  142. package/src/compiler/__tests__/fixtures/vl-parity/count-aggregate.json +0 -22
  143. package/src/compiler/__tests__/fixtures/vl-parity/data-values.json +0 -19
  144. package/src/compiler/__tests__/fixtures/vl-parity/layer-axis-null.json +0 -14
  145. package/src/compiler/__tests__/fixtures/vl-parity/layer-count.json +0 -34
  146. package/src/compiler/__tests__/fixtures/vl-parity/layer-data-values.json +0 -22
  147. package/src/compiler/__tests__/fixtures/vl-parity/layer-legend-null.json +0 -41
  148. package/src/compiler/__tests__/fixtures/vl-parity/layer-scheme.json +0 -38
  149. package/src/compiler/__tests__/fixtures/vl-parity/layer-sort.json +0 -28
  150. package/src/compiler/__tests__/fixtures/vl-parity/layer-theta.json +0 -28
  151. package/src/compiler/__tests__/fixtures/vl-parity/layer-title.json +0 -30
  152. package/src/compiler/__tests__/fixtures/vl-parity/layer-value-def.json +0 -29
  153. package/src/compiler/__tests__/fixtures/vl-parity/legend-null.json +0 -32
  154. package/src/compiler/__tests__/fixtures/vl-parity/scheme.json +0 -25
  155. package/src/compiler/__tests__/fixtures/vl-parity/sort-by-value.json +0 -19
  156. package/src/compiler/__tests__/fixtures/vl-parity/theta-arc.json +0 -19
  157. package/src/compiler/__tests__/fixtures/vl-parity/title-subtitle.json +0 -21
  158. package/src/compiler/__tests__/fixtures/vl-parity/width-height.json +0 -23
  159. package/src/compiler/__tests__/keys.test.ts +0 -83
  160. package/src/compiler/__tests__/normalize-highlight.test.ts +0 -103
  161. package/src/compiler/__tests__/normalize.test.ts +0 -399
  162. package/src/compiler/__tests__/sparkline-defaults.test.ts +0 -153
  163. package/src/compiler/__tests__/validate-did-you-mean.test.ts +0 -170
  164. package/src/compiler/__tests__/validate-highlight.test.ts +0 -112
  165. package/src/compiler/__tests__/validate-near-miss.test.ts +0 -251
  166. package/src/compiler/__tests__/validate.test.ts +0 -731
  167. package/src/compiler/__tests__/vl-parity.test.ts +0 -68
  168. package/src/compiler/a11y-warnings.ts +0 -110
  169. package/src/compiler/animation.ts +0 -142
  170. package/src/compiler/index.ts +0 -47
  171. package/src/compiler/keys.ts +0 -55
  172. package/src/compiler/normalize.ts +0 -734
  173. package/src/compiler/sparkline-defaults.ts +0 -138
  174. package/src/compiler/types.ts +0 -241
  175. package/src/compiler/validate.ts +0 -2093
  176. package/src/endpoint-labels/__tests__/compute.test.ts +0 -587
  177. package/src/endpoint-labels/compute.ts +0 -621
  178. package/src/endpoint-labels/constants.ts +0 -54
  179. package/src/endpoint-labels/format.ts +0 -60
  180. package/src/endpoint-labels/predict.ts +0 -139
  181. package/src/format/__tests__/field-format.test.ts +0 -164
  182. package/src/format/field-format.ts +0 -27
  183. package/src/geo/__tests__/compile-map.test.ts +0 -1128
  184. package/src/geo/compile-map.ts +0 -1376
  185. package/src/geo/join.ts +0 -115
  186. package/src/geo/projections.ts +0 -65
  187. package/src/geo/types.ts +0 -26
  188. package/src/graphs/__tests__/community.test.ts +0 -228
  189. package/src/graphs/__tests__/compile-graph.test.ts +0 -396
  190. package/src/graphs/__tests__/encoding.test.ts +0 -415
  191. package/src/graphs/community.ts +0 -92
  192. package/src/graphs/compile-graph.ts +0 -329
  193. package/src/graphs/encoding.ts +0 -356
  194. package/src/graphs/types.ts +0 -106
  195. package/src/index.ts +0 -177
  196. package/src/layout/__tests__/metrics.test.ts +0 -97
  197. package/src/layout/__tests__/scales-highlight.test.ts +0 -192
  198. package/src/layout/axes/rotation.ts +0 -152
  199. package/src/layout/axes/thinning.ts +0 -98
  200. package/src/layout/axes/ticks.ts +0 -409
  201. package/src/layout/axes.ts +0 -757
  202. package/src/layout/dimensions.ts +0 -912
  203. package/src/layout/facet.ts +0 -91
  204. package/src/layout/gridlines.ts +0 -84
  205. package/src/layout/metrics.ts +0 -132
  206. package/src/layout/plan.ts +0 -631
  207. package/src/layout/scales.ts +0 -1053
  208. package/src/layout/shared.ts +0 -44
  209. package/src/legend/__tests__/continuous.test.ts +0 -318
  210. package/src/legend/__tests__/redundancy.test.ts +0 -108
  211. package/src/legend/__tests__/size.test.ts +0 -348
  212. package/src/legend/__tests__/suppression.test.ts +0 -382
  213. package/src/legend/compute.ts +0 -748
  214. package/src/legend/continuous.ts +0 -327
  215. package/src/legend/size.ts +0 -407
  216. package/src/legend/suppression.ts +0 -226
  217. package/src/legend/wrap.ts +0 -117
  218. package/src/sankey/__tests__/compile-sankey.test.ts +0 -458
  219. package/src/sankey/__tests__/layout.test.ts +0 -165
  220. package/src/sankey/__tests__/node-label-wrap.test.ts +0 -114
  221. package/src/sankey/__tests__/node-sort.test.ts +0 -45
  222. package/src/sankey/compile-sankey.ts +0 -706
  223. package/src/sankey/layout.ts +0 -184
  224. package/src/sankey/types.ts +0 -41
  225. package/src/tables/__tests__/bar-column.test.ts +0 -147
  226. package/src/tables/__tests__/category-colors.test.ts +0 -230
  227. package/src/tables/__tests__/compile-table.test.ts +0 -236
  228. package/src/tables/__tests__/format-cells.test.ts +0 -143
  229. package/src/tables/__tests__/heatmap.test.ts +0 -148
  230. package/src/tables/__tests__/pagination.test.ts +0 -78
  231. package/src/tables/__tests__/search.test.ts +0 -94
  232. package/src/tables/__tests__/sort.test.ts +0 -107
  233. package/src/tables/__tests__/sparkline.test.ts +0 -122
  234. package/src/tables/bar-column.ts +0 -94
  235. package/src/tables/category-colors.ts +0 -78
  236. package/src/tables/compile-table.ts +0 -427
  237. package/src/tables/format-cells.ts +0 -115
  238. package/src/tables/heatmap.ts +0 -141
  239. package/src/tables/pagination.ts +0 -46
  240. package/src/tables/search.ts +0 -66
  241. package/src/tables/sort.ts +0 -69
  242. package/src/tables/sparkline.ts +0 -113
  243. package/src/tables/utils.ts +0 -16
  244. package/src/tilemap/__tests__/compile-tilemap.test.ts +0 -576
  245. package/src/tilemap/compile-tilemap.ts +0 -755
  246. package/src/tilemap/layout.ts +0 -172
  247. package/src/tilemap/types.ts +0 -33
  248. package/src/tooltips/__tests__/compute.test.ts +0 -582
  249. package/src/tooltips/compute.ts +0 -700
  250. package/src/transforms/__tests__/aggregate.test.ts +0 -159
  251. package/src/transforms/__tests__/bin.test.ts +0 -88
  252. package/src/transforms/__tests__/calculate.test.ts +0 -146
  253. package/src/transforms/__tests__/conditional.test.ts +0 -127
  254. package/src/transforms/__tests__/filter-relative.test.ts +0 -202
  255. package/src/transforms/__tests__/filter.test.ts +0 -59
  256. package/src/transforms/__tests__/fold.test.ts +0 -79
  257. package/src/transforms/__tests__/index.test.ts +0 -93
  258. package/src/transforms/__tests__/predicates.test.ts +0 -193
  259. package/src/transforms/__tests__/timeunit.test.ts +0 -129
  260. package/src/transforms/__tests__/window.test.ts +0 -286
  261. package/src/transforms/aggregate.ts +0 -131
  262. package/src/transforms/bin.ts +0 -87
  263. package/src/transforms/calculate.ts +0 -60
  264. package/src/transforms/conditional.ts +0 -46
  265. package/src/transforms/filter.ts +0 -122
  266. package/src/transforms/fold.ts +0 -49
  267. package/src/transforms/index.ts +0 -60
  268. package/src/transforms/predicates.ts +0 -123
  269. package/src/transforms/timeunit.ts +0 -88
  270. 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
- });