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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (271) hide show
  1. package/dist/index.d.ts +149 -12
  2. package/dist/index.js +804 -155
  3. package/dist/index.js.map +1 -1
  4. package/package.json +3 -4
  5. package/src/__test-fixtures__/specs.ts +0 -182
  6. package/src/__tests__/__snapshots__/compile-snapshot.test.ts.snap +0 -2401
  7. package/src/__tests__/annotation-behavior.test.ts +0 -852
  8. package/src/__tests__/axes.test.ts +0 -1579
  9. package/src/__tests__/compile-animation.test.ts +0 -161
  10. package/src/__tests__/compile-chart.test.ts +0 -1303
  11. package/src/__tests__/compile-footnotes.test.ts +0 -277
  12. package/src/__tests__/compile-layer.test.ts +0 -969
  13. package/src/__tests__/compile-snapshot.test.ts +0 -156
  14. package/src/__tests__/compound-labels.test.ts +0 -147
  15. package/src/__tests__/dimensions.test.ts +0 -918
  16. package/src/__tests__/encoding-sugar.test.ts +0 -390
  17. package/src/__tests__/facet.test.ts +0 -215
  18. package/src/__tests__/helpers/invariants.ts +0 -299
  19. package/src/__tests__/known-bugs.test.ts +0 -566
  20. package/src/__tests__/layout-fuzz.test.ts +0 -358
  21. package/src/__tests__/layout-invariants.test.ts +0 -535
  22. package/src/__tests__/legend.test.ts +0 -721
  23. package/src/__tests__/mark-keys.test.ts +0 -294
  24. package/src/__tests__/ranking-recipes.test.ts +0 -260
  25. package/src/__tests__/rotation.test.ts +0 -83
  26. package/src/__tests__/scales.test.ts +0 -323
  27. package/src/__tests__/series-search.test.ts +0 -92
  28. package/src/__tests__/spec-sugar.test.ts +0 -801
  29. package/src/__tests__/you-draw-it.test.ts +0 -160
  30. package/src/annotations/__tests__/compute.test.ts +0 -1672
  31. package/src/annotations/__tests__/geometry.test.ts +0 -66
  32. package/src/annotations/__tests__/placement-fuzz.test.ts +0 -256
  33. package/src/annotations/__tests__/placement-invariants.test.ts +0 -345
  34. package/src/annotations/__tests__/placement.test.ts +0 -316
  35. package/src/annotations/__tests__/resolve-text.test.ts +0 -788
  36. package/src/annotations/__tests__/rich-text.test.ts +0 -91
  37. package/src/annotations/__tests__/thinning.test.ts +0 -285
  38. package/src/annotations/collisions.ts +0 -326
  39. package/src/annotations/compute.ts +0 -333
  40. package/src/annotations/constants.ts +0 -162
  41. package/src/annotations/geometry.ts +0 -376
  42. package/src/annotations/move.ts +0 -135
  43. package/src/annotations/placement.ts +0 -636
  44. package/src/annotations/position.ts +0 -131
  45. package/src/annotations/resolve-range.ts +0 -117
  46. package/src/annotations/resolve-refline.ts +0 -157
  47. package/src/annotations/resolve-text.ts +0 -545
  48. package/src/annotations/rich-text.ts +0 -65
  49. package/src/annotations/thinning.ts +0 -190
  50. package/src/barlist/__tests__/compile-barlist.test.ts +0 -200
  51. package/src/barlist/compile-barlist.ts +0 -391
  52. package/src/barlist/types.ts +0 -28
  53. package/src/charts/__tests__/post-process.test.ts +0 -237
  54. package/src/charts/__tests__/registry.test.ts +0 -116
  55. package/src/charts/__tests__/utils.test.ts +0 -195
  56. package/src/charts/_shared/__tests__/density-filter.test.ts +0 -32
  57. package/src/charts/_shared/density-filter.ts +0 -26
  58. package/src/charts/_shared/format-label-value.ts +0 -16
  59. package/src/charts/bar/__tests__/compute.test.ts +0 -934
  60. package/src/charts/bar/__tests__/gradient-orient.test.ts +0 -127
  61. package/src/charts/bar/__tests__/labels.test.ts +0 -292
  62. package/src/charts/bar/compute.ts +0 -539
  63. package/src/charts/bar/index.ts +0 -60
  64. package/src/charts/bar/labels.ts +0 -311
  65. package/src/charts/beeswarm/__tests__/compute.test.ts +0 -280
  66. package/src/charts/beeswarm/__tests__/dodge.test.ts +0 -148
  67. package/src/charts/beeswarm/compute.ts +0 -203
  68. package/src/charts/beeswarm/dodge.ts +0 -155
  69. package/src/charts/beeswarm/index.ts +0 -32
  70. package/src/charts/builtin.ts +0 -75
  71. package/src/charts/calendar/__tests__/compute.test.ts +0 -417
  72. package/src/charts/calendar/compute.ts +0 -319
  73. package/src/charts/calendar/index.ts +0 -32
  74. package/src/charts/column/__tests__/compute.test.ts +0 -540
  75. package/src/charts/column/__tests__/labels.test.ts +0 -179
  76. package/src/charts/column/compute.ts +0 -493
  77. package/src/charts/column/index.ts +0 -53
  78. package/src/charts/column/labels.ts +0 -229
  79. package/src/charts/dot/__tests__/compute.test.ts +0 -380
  80. package/src/charts/dot/__tests__/labels.test.ts +0 -98
  81. package/src/charts/dot/compute.ts +0 -285
  82. package/src/charts/dot/index.ts +0 -62
  83. package/src/charts/dot/labels.ts +0 -121
  84. package/src/charts/line/__tests__/compute.test.ts +0 -1194
  85. package/src/charts/line/__tests__/curves.test.ts +0 -51
  86. package/src/charts/line/__tests__/labels.test.ts +0 -94
  87. package/src/charts/line/__tests__/mark-options.test.ts +0 -175
  88. package/src/charts/line/area.ts +0 -484
  89. package/src/charts/line/compute.ts +0 -356
  90. package/src/charts/line/curves.ts +0 -85
  91. package/src/charts/line/index.ts +0 -176
  92. package/src/charts/line/labels.ts +0 -177
  93. package/src/charts/parliament/__tests__/compute.test.ts +0 -411
  94. package/src/charts/parliament/compute.ts +0 -313
  95. package/src/charts/parliament/index.ts +0 -32
  96. package/src/charts/pie/__tests__/compute.test.ts +0 -384
  97. package/src/charts/pie/__tests__/labels.test.ts +0 -132
  98. package/src/charts/pie/compute.ts +0 -324
  99. package/src/charts/pie/index.ts +0 -49
  100. package/src/charts/pie/labels.ts +0 -143
  101. package/src/charts/post-process.ts +0 -229
  102. package/src/charts/range/__tests__/compute.test.ts +0 -486
  103. package/src/charts/range/compute.ts +0 -273
  104. package/src/charts/range/index.ts +0 -82
  105. package/src/charts/range/labels.ts +0 -169
  106. package/src/charts/rect/__tests__/compute.test.ts +0 -120
  107. package/src/charts/rect/index.ts +0 -127
  108. package/src/charts/registry.ts +0 -72
  109. package/src/charts/rule/index.ts +0 -139
  110. package/src/charts/scatter/__tests__/compute.test.ts +0 -488
  111. package/src/charts/scatter/__tests__/trendline.test.ts +0 -191
  112. package/src/charts/scatter/compute.ts +0 -192
  113. package/src/charts/scatter/index.ts +0 -46
  114. package/src/charts/scatter/trendline.ts +0 -100
  115. package/src/charts/text/__tests__/compute.test.ts +0 -307
  116. package/src/charts/text/index.ts +0 -141
  117. package/src/charts/tick/__tests__/compute.test.ts +0 -75
  118. package/src/charts/tick/index.ts +0 -99
  119. package/src/charts/utils.ts +0 -170
  120. package/src/charts/waffle/__tests__/compute.test.ts +0 -305
  121. package/src/charts/waffle/compute.ts +0 -197
  122. package/src/charts/waffle/index.ts +0 -31
  123. package/src/compile/__tests__/color-scale-range.test.ts +0 -158
  124. package/src/compile/__tests__/color-scale-reverse.test.ts +0 -89
  125. package/src/compile/__tests__/data-clip.test.ts +0 -127
  126. package/src/compile/__tests__/fill-patterns.test.ts +0 -154
  127. package/src/compile/__tests__/size-scale.test.ts +0 -108
  128. package/src/compile/__tests__/watermark-obstacle.test.ts +0 -93
  129. package/src/compile/color-scale-range.ts +0 -101
  130. package/src/compile/data-clip.ts +0 -48
  131. package/src/compile/fill-patterns.ts +0 -74
  132. package/src/compile/layer.ts +0 -630
  133. package/src/compile/size-scale.ts +0 -141
  134. package/src/compile/spec-sugar.ts +0 -758
  135. package/src/compile/watermark-obstacle.ts +0 -54
  136. package/src/compile/you-draw-it.ts +0 -123
  137. package/src/compile.ts +0 -1503
  138. package/src/compiler/__tests__/a11y-warnings.test.ts +0 -148
  139. package/src/compiler/__tests__/animation.test.ts +0 -177
  140. package/src/compiler/__tests__/compile.test.ts +0 -87
  141. package/src/compiler/__tests__/fixtures/vl-parity/axis-null.json +0 -19
  142. package/src/compiler/__tests__/fixtures/vl-parity/color-value.json +0 -20
  143. package/src/compiler/__tests__/fixtures/vl-parity/count-aggregate.json +0 -22
  144. package/src/compiler/__tests__/fixtures/vl-parity/data-values.json +0 -19
  145. package/src/compiler/__tests__/fixtures/vl-parity/layer-axis-null.json +0 -14
  146. package/src/compiler/__tests__/fixtures/vl-parity/layer-count.json +0 -34
  147. package/src/compiler/__tests__/fixtures/vl-parity/layer-data-values.json +0 -22
  148. package/src/compiler/__tests__/fixtures/vl-parity/layer-legend-null.json +0 -41
  149. package/src/compiler/__tests__/fixtures/vl-parity/layer-scheme.json +0 -38
  150. package/src/compiler/__tests__/fixtures/vl-parity/layer-sort.json +0 -28
  151. package/src/compiler/__tests__/fixtures/vl-parity/layer-theta.json +0 -28
  152. package/src/compiler/__tests__/fixtures/vl-parity/layer-title.json +0 -30
  153. package/src/compiler/__tests__/fixtures/vl-parity/layer-value-def.json +0 -29
  154. package/src/compiler/__tests__/fixtures/vl-parity/legend-null.json +0 -32
  155. package/src/compiler/__tests__/fixtures/vl-parity/scheme.json +0 -25
  156. package/src/compiler/__tests__/fixtures/vl-parity/sort-by-value.json +0 -19
  157. package/src/compiler/__tests__/fixtures/vl-parity/theta-arc.json +0 -19
  158. package/src/compiler/__tests__/fixtures/vl-parity/title-subtitle.json +0 -21
  159. package/src/compiler/__tests__/fixtures/vl-parity/width-height.json +0 -23
  160. package/src/compiler/__tests__/keys.test.ts +0 -83
  161. package/src/compiler/__tests__/normalize-highlight.test.ts +0 -103
  162. package/src/compiler/__tests__/normalize.test.ts +0 -399
  163. package/src/compiler/__tests__/sparkline-defaults.test.ts +0 -153
  164. package/src/compiler/__tests__/validate-did-you-mean.test.ts +0 -170
  165. package/src/compiler/__tests__/validate-highlight.test.ts +0 -112
  166. package/src/compiler/__tests__/validate-near-miss.test.ts +0 -251
  167. package/src/compiler/__tests__/validate.test.ts +0 -629
  168. package/src/compiler/__tests__/vl-parity.test.ts +0 -68
  169. package/src/compiler/a11y-warnings.ts +0 -110
  170. package/src/compiler/animation.ts +0 -142
  171. package/src/compiler/index.ts +0 -47
  172. package/src/compiler/keys.ts +0 -55
  173. package/src/compiler/normalize.ts +0 -734
  174. package/src/compiler/sparkline-defaults.ts +0 -138
  175. package/src/compiler/types.ts +0 -241
  176. package/src/compiler/validate.ts +0 -2041
  177. package/src/endpoint-labels/__tests__/compute.test.ts +0 -587
  178. package/src/endpoint-labels/compute.ts +0 -621
  179. package/src/endpoint-labels/constants.ts +0 -54
  180. package/src/endpoint-labels/format.ts +0 -60
  181. package/src/endpoint-labels/predict.ts +0 -139
  182. package/src/format/__tests__/field-format.test.ts +0 -164
  183. package/src/format/field-format.ts +0 -27
  184. package/src/geo/__tests__/compile-map.test.ts +0 -1128
  185. package/src/geo/compile-map.ts +0 -1376
  186. package/src/geo/join.ts +0 -115
  187. package/src/geo/projections.ts +0 -65
  188. package/src/geo/types.ts +0 -26
  189. package/src/graphs/__tests__/community.test.ts +0 -228
  190. package/src/graphs/__tests__/compile-graph.test.ts +0 -396
  191. package/src/graphs/__tests__/encoding.test.ts +0 -415
  192. package/src/graphs/community.ts +0 -92
  193. package/src/graphs/compile-graph.ts +0 -329
  194. package/src/graphs/encoding.ts +0 -356
  195. package/src/graphs/types.ts +0 -106
  196. package/src/index.ts +0 -177
  197. package/src/layout/__tests__/metrics.test.ts +0 -97
  198. package/src/layout/__tests__/scales-highlight.test.ts +0 -192
  199. package/src/layout/axes/rotation.ts +0 -152
  200. package/src/layout/axes/thinning.ts +0 -98
  201. package/src/layout/axes/ticks.ts +0 -409
  202. package/src/layout/axes.ts +0 -757
  203. package/src/layout/dimensions.ts +0 -912
  204. package/src/layout/facet.ts +0 -91
  205. package/src/layout/gridlines.ts +0 -84
  206. package/src/layout/metrics.ts +0 -132
  207. package/src/layout/plan.ts +0 -631
  208. package/src/layout/scales.ts +0 -1053
  209. package/src/layout/shared.ts +0 -44
  210. package/src/legend/__tests__/continuous.test.ts +0 -318
  211. package/src/legend/__tests__/redundancy.test.ts +0 -108
  212. package/src/legend/__tests__/size.test.ts +0 -348
  213. package/src/legend/__tests__/suppression.test.ts +0 -382
  214. package/src/legend/compute.ts +0 -748
  215. package/src/legend/continuous.ts +0 -327
  216. package/src/legend/size.ts +0 -407
  217. package/src/legend/suppression.ts +0 -226
  218. package/src/legend/wrap.ts +0 -117
  219. package/src/sankey/__tests__/compile-sankey.test.ts +0 -458
  220. package/src/sankey/__tests__/layout.test.ts +0 -165
  221. package/src/sankey/__tests__/node-label-wrap.test.ts +0 -114
  222. package/src/sankey/__tests__/node-sort.test.ts +0 -45
  223. package/src/sankey/compile-sankey.ts +0 -706
  224. package/src/sankey/layout.ts +0 -184
  225. package/src/sankey/types.ts +0 -41
  226. package/src/tables/__tests__/bar-column.test.ts +0 -147
  227. package/src/tables/__tests__/category-colors.test.ts +0 -230
  228. package/src/tables/__tests__/compile-table.test.ts +0 -236
  229. package/src/tables/__tests__/format-cells.test.ts +0 -143
  230. package/src/tables/__tests__/heatmap.test.ts +0 -148
  231. package/src/tables/__tests__/pagination.test.ts +0 -78
  232. package/src/tables/__tests__/search.test.ts +0 -94
  233. package/src/tables/__tests__/sort.test.ts +0 -107
  234. package/src/tables/__tests__/sparkline.test.ts +0 -122
  235. package/src/tables/bar-column.ts +0 -94
  236. package/src/tables/category-colors.ts +0 -78
  237. package/src/tables/compile-table.ts +0 -427
  238. package/src/tables/format-cells.ts +0 -115
  239. package/src/tables/heatmap.ts +0 -141
  240. package/src/tables/pagination.ts +0 -46
  241. package/src/tables/search.ts +0 -66
  242. package/src/tables/sort.ts +0 -69
  243. package/src/tables/sparkline.ts +0 -113
  244. package/src/tables/utils.ts +0 -16
  245. package/src/tilemap/__tests__/compile-tilemap.test.ts +0 -576
  246. package/src/tilemap/compile-tilemap.ts +0 -755
  247. package/src/tilemap/layout.ts +0 -172
  248. package/src/tilemap/types.ts +0 -33
  249. package/src/tooltips/__tests__/compute.test.ts +0 -582
  250. package/src/tooltips/compute.ts +0 -700
  251. package/src/transforms/__tests__/aggregate.test.ts +0 -159
  252. package/src/transforms/__tests__/bin.test.ts +0 -88
  253. package/src/transforms/__tests__/calculate.test.ts +0 -146
  254. package/src/transforms/__tests__/conditional.test.ts +0 -127
  255. package/src/transforms/__tests__/filter-relative.test.ts +0 -202
  256. package/src/transforms/__tests__/filter.test.ts +0 -59
  257. package/src/transforms/__tests__/fold.test.ts +0 -79
  258. package/src/transforms/__tests__/index.test.ts +0 -93
  259. package/src/transforms/__tests__/predicates.test.ts +0 -193
  260. package/src/transforms/__tests__/timeunit.test.ts +0 -129
  261. package/src/transforms/__tests__/window.test.ts +0 -286
  262. package/src/transforms/aggregate.ts +0 -131
  263. package/src/transforms/bin.ts +0 -87
  264. package/src/transforms/calculate.ts +0 -60
  265. package/src/transforms/conditional.ts +0 -46
  266. package/src/transforms/filter.ts +0 -122
  267. package/src/transforms/fold.ts +0 -49
  268. package/src/transforms/index.ts +0 -60
  269. package/src/transforms/predicates.ts +0 -123
  270. package/src/transforms/timeunit.ts +0 -88
  271. package/src/transforms/window.ts +0 -185
@@ -1,1303 +0,0 @@
1
- import { describe, expect, it } from 'vitest';
2
- import { compileChart, compileGraph, compileTable } from '../compile';
3
-
4
- // ---------------------------------------------------------------------------
5
- // Test data
6
- // ---------------------------------------------------------------------------
7
-
8
- const lineSpec = {
9
- mark: 'line' as const,
10
- data: [
11
- { date: '2020-01-01', value: 10, country: 'US' },
12
- { date: '2021-01-01', value: 40, country: 'US' },
13
- { date: '2020-01-01', value: 15, country: 'UK' },
14
- { date: '2021-01-01', value: 35, country: 'UK' },
15
- ],
16
- encoding: {
17
- x: { field: 'date', type: 'temporal' as const },
18
- y: { field: 'value', type: 'quantitative' as const },
19
- color: { field: 'country', type: 'nominal' as const },
20
- },
21
- chrome: {
22
- title: 'GDP Growth',
23
- subtitle: 'US vs UK over time',
24
- source: 'World Bank',
25
- },
26
- };
27
-
28
- const barSpec = {
29
- mark: 'bar' as const,
30
- data: [
31
- { name: 'A', value: 10 },
32
- { name: 'B', value: 30 },
33
- { name: 'C', value: 20 },
34
- ],
35
- encoding: {
36
- x: { field: 'value', type: 'quantitative' as const },
37
- y: { field: 'name', type: 'nominal' as const },
38
- },
39
- };
40
-
41
- // ---------------------------------------------------------------------------
42
- // Tests
43
- // ---------------------------------------------------------------------------
44
-
45
- describe('compileChart', () => {
46
- it('returns a ChartLayout with all required fields populated', () => {
47
- const layout = compileChart(lineSpec, { width: 600, height: 400 });
48
-
49
- // Chart area has real dimensions within the total viewport
50
- expect(layout.area.width).toBeGreaterThan(0);
51
- expect(layout.area.height).toBeGreaterThan(0);
52
- expect(layout.area.x).toBeGreaterThanOrEqual(0);
53
- expect(layout.area.y).toBeGreaterThanOrEqual(0);
54
-
55
- // Chrome is resolved with positions
56
- expect(layout.chrome.topHeight).toBeGreaterThan(0);
57
- expect(layout.chrome.title?.text).toBe('GDP Growth');
58
-
59
- // Axes have ticks
60
- expect(layout.axes.x?.ticks.length).toBeGreaterThan(0);
61
- expect(layout.axes.y?.ticks.length).toBeGreaterThan(0);
62
-
63
- // Marks were produced by the registered renderer
64
- expect(layout.marks.length).toBeGreaterThan(0);
65
-
66
- // Annotations array exists (empty for this spec since none were specified)
67
- expect(layout.annotations).toEqual([]);
68
-
69
- // Legend is auto-suppressed for line charts with endpoint labels
70
- expect(layout.legend.entries.length).toBe(0);
71
-
72
- // Tooltip descriptors is a Map (may or may not have entries depending on marks)
73
- expect(layout.tooltipDescriptors).toBeInstanceOf(Map);
74
-
75
- // Accessibility metadata is populated
76
- expect(layout.a11y.altText.length).toBeGreaterThan(0);
77
- expect(layout.a11y.role).toBe('img');
78
-
79
- // Theme is resolved with actual color values
80
- expect(layout.theme.colors.background).toBeTruthy();
81
- expect(layout.theme.colors.categorical.length).toBeGreaterThan(0);
82
-
83
- // Dimensions match the input
84
- expect(layout.dimensions).toEqual({ width: 600, height: 400 });
85
- });
86
-
87
- it('has correct total dimensions', () => {
88
- const layout = compileChart(lineSpec, { width: 600, height: 400 });
89
- expect(layout.dimensions.width).toBe(600);
90
- expect(layout.dimensions.height).toBe(400);
91
- });
92
-
93
- it('chart area fits within total dimensions', () => {
94
- const layout = compileChart(lineSpec, { width: 600, height: 400 });
95
- expect(layout.area.x).toBeGreaterThan(0);
96
- expect(layout.area.y).toBeGreaterThan(0);
97
- expect(layout.area.x + layout.area.width).toBeLessThan(600);
98
- expect(layout.area.y + layout.area.height).toBeLessThan(400);
99
- });
100
-
101
- it('chrome title and subtitle are resolved with correct text and positions', () => {
102
- const layout = compileChart(lineSpec, { width: 600, height: 400 });
103
- expect(layout.chrome.topHeight).toBeGreaterThan(0);
104
-
105
- // Title
106
- expect(layout.chrome.title!.text).toBe('GDP Growth');
107
- expect(layout.chrome.title!.x).toBeGreaterThanOrEqual(0);
108
- expect(layout.chrome.title!.y).toBeGreaterThan(0);
109
- expect(layout.chrome.title!.maxWidth).toBeGreaterThan(0);
110
- expect(layout.chrome.title!.style.fontSize).toBeGreaterThan(0);
111
-
112
- // Subtitle
113
- expect(layout.chrome.subtitle!.text).toBe('US vs UK over time');
114
- expect(layout.chrome.subtitle!.y).toBeGreaterThan(layout.chrome.title!.y);
115
- });
116
-
117
- it('bottom chrome source is resolved with text and position', () => {
118
- const layout = compileChart(lineSpec, { width: 600, height: 400 });
119
- expect(layout.chrome.bottomHeight).toBeGreaterThan(0);
120
- expect(layout.chrome.source!.text).toBe('World Bank');
121
- expect(layout.chrome.source!.x).toBeGreaterThanOrEqual(0);
122
- expect(layout.chrome.source!.y).toBeGreaterThan(0);
123
- expect(layout.chrome.source!.style.fontSize).toBeGreaterThan(0);
124
- });
125
-
126
- it('emits bottomAnchorY below the chart area', () => {
127
- const layout = compileChart(lineSpec, { width: 600, height: 400 });
128
- expect(layout.chrome.bottomAnchorY).toBeDefined();
129
- expect(layout.chrome.bottomAnchorY).toBeGreaterThanOrEqual(layout.area.y + layout.area.height);
130
- });
131
-
132
- it('has axes with ticks and valid positions for a line chart', () => {
133
- const layout = compileChart(lineSpec, { width: 600, height: 400 });
134
-
135
- // X axis
136
- const xTicks = layout.axes.x!.ticks;
137
- expect(xTicks.length).toBeGreaterThan(0);
138
- for (const tick of xTicks) {
139
- expect(tick.position).toBeGreaterThanOrEqual(0);
140
- expect(tick.label).toBeTruthy();
141
- }
142
-
143
- // Y axis
144
- const yTicks = layout.axes.y!.ticks;
145
- expect(yTicks.length).toBeGreaterThan(0);
146
- for (const tick of yTicks) {
147
- expect(tick.position).toBeGreaterThanOrEqual(0);
148
- expect(tick.label).toBeTruthy();
149
- }
150
-
151
- // Axes have start and end points
152
- expect(layout.axes.x!.start.x).toBeLessThan(layout.axes.x!.end.x);
153
- expect(layout.axes.y!.start.y).not.toBe(layout.axes.y!.end.y);
154
- });
155
-
156
- it('has a legend when color encoding is present and legend forced on', () => {
157
- const layout = compileChart(
158
- { ...lineSpec, legend: { show: true } },
159
- { width: 600, height: 400 },
160
- );
161
- expect(layout.legend.entries.length).toBeGreaterThan(0);
162
- expect(layout.legend.entries.some((e) => e.label === 'US')).toBe(true);
163
- expect(layout.legend.entries.some((e) => e.label === 'UK')).toBe(true);
164
- });
165
-
166
- it('legend entries have colors and shapes', () => {
167
- const layout = compileChart(lineSpec, { width: 600, height: 400 });
168
- for (const entry of layout.legend.entries) {
169
- expect(entry.color).toBeTruthy();
170
- expect(['circle', 'square', 'line']).toContain(entry.shape);
171
- }
172
- });
173
-
174
- it('legend position is "right" at full width', () => {
175
- const layout = compileChart(lineSpec, { width: 800, height: 400 });
176
- expect(layout.legend.position).toBe('right');
177
- });
178
-
179
- it('legend position is "top" at compact width', () => {
180
- const layout = compileChart(lineSpec, { width: 320, height: 400 });
181
- expect(layout.legend.position).toBe('top');
182
- });
183
-
184
- it('aligns the top legend left edge to the plot area, not the container edge', () => {
185
- // A y-axis title + numeric ticks reserves a left gutter, pushing the plot
186
- // area right of the container padding. The top legend should start at the
187
- // plot's left edge (above the y-axis guide), not flush against the container.
188
- const layout = compileChart(
189
- {
190
- ...lineSpec,
191
- encoding: {
192
- ...lineSpec.encoding,
193
- y: { field: 'value', type: 'quantitative' as const, axis: { title: 'GDP ($B)' } },
194
- },
195
- legend: { position: 'top' as const, show: true },
196
- },
197
- { width: 360, height: 400 },
198
- );
199
- expect(layout.legend.position).toBe('top');
200
- expect(layout.legend.entries.length).toBeGreaterThan(0);
201
- // Plot area is inset from the container by the y-axis gutter.
202
- expect(layout.area.x).toBeGreaterThan(0);
203
- // Legend left edge now matches the plot left edge (within a pixel).
204
- expect(layout.legend.bounds.x).toBeCloseTo(layout.area.x, 0);
205
- // And the legend never paints past the container's right padding edge.
206
- // Default theme spacing.padding is 20.
207
- expect(layout.legend.bounds.x + layout.legend.bounds.width).toBeLessThanOrEqual(360 - 20 + 0.5);
208
- });
209
-
210
- it('produces line marks with dataPoints (no PointMarks by default)', () => {
211
- const layout = compileChart(lineSpec, { width: 600, height: 400 });
212
- expect(layout.marks.length).toBeGreaterThan(0);
213
-
214
- const lineMarks = layout.marks.filter((m) => m.type === 'line');
215
- const pointMarks = layout.marks.filter((m) => m.type === 'point');
216
- expect(lineMarks.length).toBeGreaterThan(0);
217
- // Default: no PointMarks (voronoi overlay handles tooltips)
218
- expect(pointMarks.length).toBe(0);
219
-
220
- // Line marks should have points and dataPoints with valid coordinates
221
- for (const mark of lineMarks) {
222
- if (mark.type === 'line') {
223
- expect(mark.points.length).toBeGreaterThan(0);
224
- expect(mark.stroke).toBeTruthy();
225
- expect(mark.strokeWidth).toBeGreaterThan(0);
226
- expect(mark.dataPoints).toBeDefined();
227
- expect(mark.dataPoints!.length).toBeGreaterThan(0);
228
- }
229
- }
230
- });
231
-
232
- it('produces PointMarks when mark.point is true', () => {
233
- const specWithPoints = { ...lineSpec, mark: { type: 'line' as const, point: true as const } };
234
- const layout = compileChart(specWithPoints, { width: 600, height: 400 });
235
-
236
- const pointMarks = layout.marks.filter((m) => m.type === 'point');
237
- expect(pointMarks.length).toBeGreaterThan(0);
238
- });
239
-
240
- it('produces PointMarks on a multi-series area when mark.point is true', () => {
241
- // Multi-series areas derive their lines from area tops, which bypasses the
242
- // line renderer's point emission. mark.point must still place dots.
243
- const areaSpec = {
244
- ...lineSpec,
245
- mark: { type: 'area' as const, point: true as const },
246
- };
247
- const layout = compileChart(areaSpec, { width: 600, height: 400 });
248
-
249
- const areaMarks = layout.marks.filter((m) => m.type === 'area');
250
- const pointMarks = layout.marks.filter((m) => m.type === 'point');
251
- expect(areaMarks.length).toBe(2); // US + UK
252
- // One point per data row across both series (2 points x 2 series).
253
- expect(pointMarks.length).toBe(4);
254
- for (const p of pointMarks) {
255
- if (p.type === 'point') expect(p.r).toBeGreaterThan(0);
256
- }
257
- });
258
-
259
- it('does not produce PointMarks on a multi-series area by default', () => {
260
- const areaSpec = { ...lineSpec, mark: 'area' as const };
261
- const layout = compileChart(areaSpec, { width: 600, height: 400 });
262
-
263
- const pointMarks = layout.marks.filter((m) => m.type === 'point');
264
- expect(pointMarks.length).toBe(0);
265
- });
266
-
267
- it('includes accessibility metadata with meaningful content', () => {
268
- const layout = compileChart(lineSpec, { width: 600, height: 400 });
269
- expect(layout.a11y.altText).toContain('Line chart');
270
- expect(layout.a11y.altText).toContain('GDP Growth');
271
- expect(layout.a11y.role).toBe('img');
272
- expect(layout.a11y.dataTableFallback.length).toBeGreaterThan(0);
273
- // With marks present, keyboard navigation should be enabled
274
- expect(layout.a11y.keyboardNavigable).toBe(true);
275
- // No author opt-out by default
276
- expect(layout.a11y.hidden).toBeUndefined();
277
- });
278
-
279
- it('a11y.description replaces the auto-generated alt text', () => {
280
- const spec = { ...lineSpec, a11y: { description: 'GDP growth for US and UK, 2020 to 2021.' } };
281
- const layout = compileChart(spec, { width: 600, height: 400 });
282
- expect(layout.a11y.altText).toBe('GDP growth for US and UK, 2020 to 2021.');
283
- });
284
-
285
- it('top-level description (VL alias) replaces the auto-generated alt text', () => {
286
- const spec = { ...lineSpec, description: 'Two-country GDP comparison.' };
287
- const layout = compileChart(spec, { width: 600, height: 400 });
288
- expect(layout.a11y.altText).toBe('Two-country GDP comparison.');
289
- });
290
-
291
- it('a11y.description wins over the top-level description', () => {
292
- const spec = {
293
- ...lineSpec,
294
- description: 'Alias text',
295
- a11y: { description: 'Canonical text' },
296
- };
297
- const layout = compileChart(spec, { width: 600, height: 400 });
298
- expect(layout.a11y.altText).toBe('Canonical text');
299
- });
300
-
301
- it('a11y.hidden flows into the layout metadata', () => {
302
- const spec = { ...lineSpec, a11y: { hidden: true } };
303
- const layout = compileChart(spec, { width: 600, height: 400 });
304
- expect(layout.a11y.hidden).toBe(true);
305
- });
306
-
307
- it('produces different strategies at different widths', () => {
308
- const narrow = compileChart(lineSpec, { width: 320, height: 400 });
309
- const wide = compileChart(lineSpec, { width: 800, height: 400 });
310
-
311
- // At 320px the legend should be on top, at 800px on the right
312
- expect(narrow.legend.position).toBe('top');
313
- expect(wide.legend.position).toBe('right');
314
- });
315
-
316
- it('dark mode adapts the theme colors', () => {
317
- const light = compileChart(lineSpec, { width: 600, height: 400 });
318
- const dark = compileChart(lineSpec, { width: 600, height: 400, darkMode: true });
319
-
320
- expect(light.theme.isDark).toBe(false);
321
- expect(dark.theme.isDark).toBe(true);
322
- // Both modes preserve transparent background — dark mode swaps text/axis/gridline
323
- // colors but keeps transparency so the host surface shows through.
324
- expect(dark.theme.colors.background).toBe('transparent');
325
- expect(light.theme.colors.background).toBe('transparent');
326
- // Dark mode text should be light, light mode text should be dark
327
- expect(dark.theme.colors.text).not.toBe(light.theme.colors.text);
328
- });
329
-
330
- it('compiles a bar chart with rect marks and both axes', () => {
331
- const layout = compileChart(barSpec, { width: 600, height: 400 });
332
-
333
- // Bar charts produce rect marks
334
- expect(layout.marks.length).toBe(3);
335
- for (const mark of layout.marks) {
336
- expect(mark.type).toBe('rect');
337
- if (mark.type === 'rect') {
338
- expect(mark.width).toBeGreaterThan(0);
339
- expect(mark.height).toBeGreaterThan(0);
340
- expect(mark.fill).toBeTruthy();
341
- }
342
- }
343
-
344
- // Both axes should be present with ticks
345
- expect(layout.axes.x!.ticks.length).toBeGreaterThan(0);
346
- expect(layout.axes.y!.ticks.length).toBe(3); // A, B, C
347
- });
348
-
349
- it('bar chart y-axis labels match the nominal field values', () => {
350
- const layout = compileChart(barSpec, { width: 600, height: 400 });
351
- const yLabels = layout.axes.y!.ticks.map((t) => t.label);
352
- expect(yLabels).toContain('A');
353
- expect(yLabels).toContain('B');
354
- expect(yLabels).toContain('C');
355
- });
356
-
357
- it('throws on invalid spec', () => {
358
- expect(() => compileChart({ type: 'bogus' }, { width: 600, height: 400 })).toThrow();
359
- });
360
-
361
- it('throws if a table spec is passed', () => {
362
- expect(() =>
363
- compileChart(
364
- { type: 'table', data: [{ a: 1 }], columns: [{ key: 'a' }] },
365
- { width: 600, height: 400 },
366
- ),
367
- ).toThrow('compileTable');
368
- });
369
-
370
- // ---------------------------------------------------------------------------
371
- // hiddenSeries
372
- // ---------------------------------------------------------------------------
373
-
374
- it('hiddenSeries filters out data for hidden series from marks', () => {
375
- const spec = {
376
- ...lineSpec,
377
- hiddenSeries: ['UK'],
378
- legend: { show: true },
379
- };
380
- const layout = compileChart(spec, { width: 600, height: 400 });
381
-
382
- // With UK hidden, only US marks should be present.
383
- // Line marks carry a series property.
384
- const lineMarks = layout.marks.filter((m) => m.type === 'line');
385
- for (const mark of lineMarks) {
386
- if (mark.type === 'line') {
387
- expect(mark.series).not.toBe('UK');
388
- }
389
- }
390
-
391
- // Legend should still have entries for both series (hidden ones are dimmed, not removed)
392
- expect(layout.legend.entries.length).toBe(2);
393
- expect(layout.legend.entries.some((e) => e.label === 'US')).toBe(true);
394
- expect(layout.legend.entries.some((e) => e.label === 'UK')).toBe(true);
395
- });
396
-
397
- it('hiddenSeries with all series hidden produces no marks', () => {
398
- const spec = {
399
- ...lineSpec,
400
- hiddenSeries: ['US', 'UK'],
401
- };
402
- const layout = compileChart(spec, { width: 600, height: 400 });
403
- // No data left means no marks
404
- expect(layout.marks.length).toBe(0);
405
- });
406
-
407
- it('hiddenSeries with empty array behaves normally', () => {
408
- const spec = {
409
- ...lineSpec,
410
- hiddenSeries: [],
411
- };
412
- const layout = compileChart(spec, { width: 600, height: 400 });
413
- expect(layout.marks.length).toBeGreaterThan(0);
414
- });
415
-
416
- it('hiddenSeries keeps remaining series on their original palette colors', () => {
417
- // Regression: filtering renderData by hiddenSeries used to shrink the
418
- // ordinal color scale's domain, which shifted every visible series down
419
- // one palette index.
420
- type LineLike = { type: string; seriesKey?: string; stroke?: string };
421
- const findStroke = (marks: { type: string }[], series: string) =>
422
- (marks.find((m) => m.type === 'line' && (m as LineLike).seriesKey === series) as LineLike)
423
- ?.stroke;
424
-
425
- const baseline = compileChart({ ...lineSpec, hiddenSeries: [] }, { width: 600, height: 400 });
426
- const baselineUS = findStroke(baseline.marks, 'US');
427
- const baselineUK = findStroke(baseline.marks, 'UK');
428
- expect(baselineUS).toBeTruthy();
429
- expect(baselineUK).toBeTruthy();
430
-
431
- // Hide UK → US should keep its color.
432
- const ukHidden = compileChart(
433
- { ...lineSpec, hiddenSeries: ['UK'] },
434
- { width: 600, height: 400 },
435
- );
436
- expect(findStroke(ukHidden.marks, 'US')).toBe(baselineUS);
437
-
438
- // Hide US → UK should keep its color (reverse direction).
439
- const usHidden = compileChart(
440
- { ...lineSpec, hiddenSeries: ['US'] },
441
- { width: 600, height: 400 },
442
- );
443
- expect(findStroke(usHidden.marks, 'UK')).toBe(baselineUK);
444
- });
445
-
446
- it('hiddenSeries keeps middle-of-N series colors stable in 3-series charts', () => {
447
- // Pre-fix bug shifted EVERY series after the hidden one — a 2-series
448
- // case only proves shift-by-one. A 3-series case where the middle is
449
- // hidden proves the late series doesn't drift either.
450
- type LineLike = { type: string; seriesKey?: string; stroke?: string };
451
- const findStroke = (marks: { type: string }[], series: string) =>
452
- (marks.find((m) => m.type === 'line' && (m as LineLike).seriesKey === series) as LineLike)
453
- ?.stroke;
454
-
455
- const threeSeriesSpec = {
456
- mark: 'line' as const,
457
- data: [
458
- { date: '2020-01-01', value: 10, country: 'US' },
459
- { date: '2021-01-01', value: 40, country: 'US' },
460
- { date: '2020-01-01', value: 15, country: 'UK' },
461
- { date: '2021-01-01', value: 35, country: 'UK' },
462
- { date: '2020-01-01', value: 12, country: 'JP' },
463
- { date: '2021-01-01', value: 38, country: 'JP' },
464
- ],
465
- encoding: {
466
- x: { field: 'date', type: 'temporal' as const },
467
- y: { field: 'value', type: 'quantitative' as const },
468
- color: { field: 'country', type: 'nominal' as const },
469
- },
470
- };
471
-
472
- const baseline = compileChart(threeSeriesSpec, { width: 600, height: 400 });
473
- const baselineUS = findStroke(baseline.marks, 'US');
474
- const baselineJP = findStroke(baseline.marks, 'JP');
475
-
476
- // Hide the middle series.
477
- const ukHidden = compileChart(
478
- { ...threeSeriesSpec, hiddenSeries: ['UK'] },
479
- { width: 600, height: 400 },
480
- );
481
- expect(findStroke(ukHidden.marks, 'US')).toBe(baselineUS); // first stays
482
- expect(findStroke(ukHidden.marks, 'JP')).toBe(baselineJP); // last doesn't shift up
483
- });
484
-
485
- // ---------------------------------------------------------------------------
486
- // scale.clip
487
- // ---------------------------------------------------------------------------
488
-
489
- it('scale.clip filters data rows outside the y-axis domain', () => {
490
- const spec = {
491
- mark: 'point' as const,
492
- data: [
493
- { x: 1, y: 5 },
494
- { x: 2, y: 15 },
495
- { x: 3, y: 25 },
496
- { x: 4, y: 35 },
497
- ],
498
- encoding: {
499
- x: { field: 'x', type: 'quantitative' as const },
500
- y: {
501
- field: 'y',
502
- type: 'quantitative' as const,
503
- scale: { domain: [10, 30] as [number, number], clip: true },
504
- },
505
- },
506
- };
507
- const layout = compileChart(spec, { width: 600, height: 400 });
508
-
509
- // Only y=15 and y=25 should remain (y=5 and y=35 are outside [10,30])
510
- const pointMarks = layout.marks.filter((m) => m.type === 'point');
511
- expect(pointMarks.length).toBe(2);
512
- });
513
-
514
- it('scale.clip filters data rows outside the x-axis domain', () => {
515
- const spec = {
516
- mark: 'point' as const,
517
- data: [
518
- { x: 1, y: 10 },
519
- { x: 5, y: 20 },
520
- { x: 10, y: 30 },
521
- { x: 15, y: 40 },
522
- ],
523
- encoding: {
524
- x: {
525
- field: 'x',
526
- type: 'quantitative' as const,
527
- scale: { domain: [3, 12] as [number, number], clip: true },
528
- },
529
- y: { field: 'y', type: 'quantitative' as const },
530
- },
531
- };
532
- const layout = compileChart(spec, { width: 600, height: 400 });
533
-
534
- // Only x=5 and x=10 should remain
535
- const pointMarks = layout.marks.filter((m) => m.type === 'point');
536
- expect(pointMarks.length).toBe(2);
537
- });
538
-
539
- it('scale.clip=false does not filter data even with domain set', () => {
540
- const spec = {
541
- mark: 'point' as const,
542
- data: [
543
- { x: 1, y: 5 },
544
- { x: 2, y: 15 },
545
- { x: 3, y: 25 },
546
- ],
547
- encoding: {
548
- x: { field: 'x', type: 'quantitative' as const },
549
- y: {
550
- field: 'y',
551
- type: 'quantitative' as const,
552
- scale: { domain: [10, 20] as [number, number], clip: false },
553
- },
554
- },
555
- };
556
- const layout = compileChart(spec, { width: 600, height: 400 });
557
-
558
- // All 3 points should still be present (clip is false)
559
- const pointMarks = layout.marks.filter((m) => m.type === 'point');
560
- expect(pointMarks.length).toBe(3);
561
- });
562
-
563
- // ---------------------------------------------------------------------------
564
- // display field + breakpoint override
565
- // ---------------------------------------------------------------------------
566
-
567
- it('display defaults to "full" when not specified', () => {
568
- const layout = compileChart(lineSpec, { width: 600, height: 400 });
569
- expect(layout.display).toBe('full');
570
- });
571
-
572
- it('display: "sparkline" propagates through to ChartLayout', () => {
573
- const layout = compileChart(
574
- { ...lineSpec, display: 'sparkline' as const },
575
- { width: 400, height: 80 },
576
- );
577
- expect(layout.display).toBe('sparkline');
578
- });
579
-
580
- it('breakpoint override flips display at compact width', () => {
581
- const spec = {
582
- ...lineSpec,
583
- overrides: {
584
- compact: { display: 'sparkline' as const },
585
- },
586
- };
587
-
588
- const compactLayout = compileChart(spec, { width: 320, height: 200 });
589
- expect(compactLayout.display).toBe('sparkline');
590
-
591
- const desktopLayout = compileChart(spec, { width: 1200, height: 600 });
592
- expect(desktopLayout.display).toBe('full');
593
- });
594
-
595
- it('sparkline mode forces watermark off when not user-explicit', () => {
596
- const layout = compileChart(
597
- { ...lineSpec, display: 'sparkline' as const },
598
- { width: 400, height: 80 },
599
- );
600
- expect(layout.watermark).toBe(false);
601
- });
602
-
603
- it('sparkline mode respects explicit watermark: true', () => {
604
- const layout = compileChart(
605
- { ...lineSpec, display: 'sparkline' as const, watermark: true },
606
- { width: 400, height: 80 },
607
- );
608
- expect(layout.watermark).toBe(true);
609
- });
610
-
611
- it('sparkline mode forces crosshair off when not user-explicit', () => {
612
- const layout = compileChart(
613
- { ...lineSpec, display: 'sparkline' as const },
614
- { width: 400, height: 80 },
615
- );
616
- expect(layout.crosshair).toBe(false);
617
- });
618
-
619
- it('sparkline mode respects explicit crosshair: true', () => {
620
- const layout = compileChart(
621
- { ...lineSpec, display: 'sparkline' as const, crosshair: true },
622
- { width: 400, height: 80 },
623
- );
624
- expect(layout.crosshair).toBe(true);
625
- });
626
-
627
- it('crosshair defaults on for line marks in full mode', () => {
628
- const layout = compileChart(lineSpec, { width: 400, height: 300 });
629
- expect(layout.crosshair).toBe(true);
630
- });
631
-
632
- it('crosshair respects explicit crosshair: false on a line chart', () => {
633
- const layout = compileChart({ ...lineSpec, crosshair: false }, { width: 400, height: 300 });
634
- expect(layout.crosshair).toBe(false);
635
- });
636
-
637
- // ---------------------------------------------------------------------------
638
- // Sparkline layout profile (dimensions, axes, legend)
639
- // ---------------------------------------------------------------------------
640
-
641
- it('sparkline produces near-edge-to-edge chart area (margins <= 4px per side)', () => {
642
- const layout = compileChart(
643
- { ...lineSpec, chrome: undefined, display: 'sparkline' as const },
644
- { width: 400, height: 80 },
645
- );
646
-
647
- // No chrome, no axes, no legend by default. Mark area should be tight.
648
- expect(layout.area.x).toBeLessThanOrEqual(4);
649
- expect(layout.area.y).toBeLessThanOrEqual(4);
650
- const rightMargin = 400 - (layout.area.x + layout.area.width);
651
- const bottomMargin = 80 - (layout.area.y + layout.area.height);
652
- expect(rightMargin).toBeLessThanOrEqual(4);
653
- expect(bottomMargin).toBeLessThanOrEqual(4);
654
- });
655
-
656
- it('sparkline returns no axes by default', () => {
657
- const layout = compileChart(
658
- { ...lineSpec, chrome: undefined, display: 'sparkline' as const },
659
- { width: 400, height: 80 },
660
- );
661
- expect(layout.axes.x).toBeUndefined();
662
- expect(layout.axes.y).toBeUndefined();
663
- });
664
-
665
- it('sparkline + explicit encoding.x.axis still reserves x-axis', () => {
666
- const layout = compileChart(
667
- {
668
- ...lineSpec,
669
- chrome: undefined,
670
- display: 'sparkline' as const,
671
- encoding: {
672
- ...lineSpec.encoding,
673
- x: { ...lineSpec.encoding.x, axis: { title: 'date' } },
674
- },
675
- },
676
- { width: 400, height: 200 },
677
- );
678
- expect(layout.axes.x).toBeDefined();
679
- });
680
-
681
- it('sparkline + explicit chrome.title still renders chrome', () => {
682
- const layout = compileChart(
683
- {
684
- ...lineSpec,
685
- chrome: { title: 'Q4 Revenue' },
686
- display: 'sparkline' as const,
687
- },
688
- { width: 400, height: 200 },
689
- );
690
- expect(layout.chrome.title).toBeDefined();
691
- expect(layout.chrome.title?.text).toBe('Q4 Revenue');
692
- });
693
-
694
- it('sparkline hides legend by default even with color encoding', () => {
695
- const layout = compileChart(
696
- {
697
- ...lineSpec,
698
- chrome: undefined,
699
- display: 'sparkline' as const,
700
- },
701
- { width: 400, height: 80 },
702
- );
703
- expect('entries' in layout.legend && layout.legend.entries.length).toBe(0);
704
- });
705
-
706
- it('sparkline + explicit legend.show: true renders legend', () => {
707
- const layout = compileChart(
708
- {
709
- ...lineSpec,
710
- chrome: undefined,
711
- display: 'sparkline' as const,
712
- legend: { show: true },
713
- },
714
- { width: 400, height: 200 },
715
- );
716
- expect('entries' in layout.legend && layout.legend.entries.length).toBeGreaterThan(0);
717
- });
718
-
719
- it('sparkline works at heights as low as 30px', () => {
720
- const layout = compileChart(
721
- { ...lineSpec, chrome: undefined, display: 'sparkline' as const },
722
- { width: 200, height: 30 },
723
- );
724
- expect(layout.area.height).toBeGreaterThan(0);
725
- expect(layout.area.width).toBeGreaterThan(0);
726
- });
727
-
728
- it('chrome: {} does not count as user-explicit chrome (still stripped in sparkline)', () => {
729
- // Empty chrome object is the idiom for "silence defaults" — should not
730
- // opt-in to chrome rendering in sparkline mode.
731
- const layout = compileChart(
732
- { ...lineSpec, chrome: {}, display: 'sparkline' as const },
733
- { width: 400, height: 80 },
734
- );
735
- expect(layout.chrome.title).toBeUndefined();
736
- expect(layout.chrome.topHeight).toBe(0);
737
- });
738
-
739
- // -------------------------------------------------------------------------
740
- // Sparkline visual defaults (trend color, endpoint dot, gradient, bar pill)
741
- // -------------------------------------------------------------------------
742
-
743
- const upTrendData = [
744
- { date: '2026-01-01', value: 10 },
745
- { date: '2026-01-02', value: 12 },
746
- { date: '2026-01-03', value: 14 },
747
- { date: '2026-01-04', value: 18 },
748
- { date: '2026-01-05', value: 22 },
749
- ];
750
-
751
- const downTrendData = [
752
- { date: '2026-01-01', value: 22 },
753
- { date: '2026-01-02', value: 18 },
754
- { date: '2026-01-03', value: 14 },
755
- { date: '2026-01-04', value: 12 },
756
- { date: '2026-01-05', value: 10 },
757
- ];
758
-
759
- const sparkLineSpec = {
760
- mark: 'line' as const,
761
- data: upTrendData,
762
- encoding: {
763
- x: { field: 'date', type: 'temporal' as const },
764
- y: { field: 'value', type: 'quantitative' as const },
765
- },
766
- display: 'sparkline' as const,
767
- };
768
-
769
- it('sparkline line emits a single decorative endpoint dot at the last point', () => {
770
- const layout = compileChart(sparkLineSpec, { width: 200, height: 40 });
771
- const points = layout.marks.filter((m) => m.type === 'point');
772
- expect(points.length).toBe(1);
773
- expect(points[0].aria?.decorative).toBe(true);
774
- });
775
-
776
- it('sparkline line uses the positive theme token for an up-trending series', () => {
777
- const layout = compileChart(sparkLineSpec, { width: 200, height: 40 });
778
- const lineMark = layout.marks.find((m) => m.type === 'line');
779
- expect(lineMark?.stroke).toBe('#16a34a');
780
- });
781
-
782
- it('sparkline line uses the negative theme token for a down-trending series', () => {
783
- const layout = compileChart(
784
- { ...sparkLineSpec, data: downTrendData },
785
- { width: 200, height: 40 },
786
- );
787
- const lineMark = layout.marks.find((m) => m.type === 'line');
788
- expect(lineMark?.stroke).toBe('#dc2626');
789
- });
790
-
791
- it('explicit markDef.stroke wins over the trend default in sparkline mode', () => {
792
- const layout = compileChart(
793
- { ...sparkLineSpec, mark: { type: 'line' as const, stroke: '#ff00ff' } },
794
- { width: 200, height: 40 },
795
- );
796
- const lineMark = layout.marks.find((m) => m.type === 'line');
797
- expect(lineMark?.stroke).toBe('#ff00ff');
798
- });
799
-
800
- it('explicit markDef.point: false suppresses the auto-injected endpoint dot', () => {
801
- const layout = compileChart(
802
- { ...sparkLineSpec, mark: { type: 'line' as const, point: false } },
803
- { width: 200, height: 40 },
804
- );
805
- const points = layout.marks.filter((m) => m.type === 'point');
806
- expect(points.length).toBe(0);
807
- });
808
-
809
- it('sparkline area injects a trend-colored gradient fill by default', () => {
810
- const layout = compileChart(
811
- { ...sparkLineSpec, mark: 'area' as const },
812
- { width: 200, height: 40 },
813
- );
814
- const areaMark = layout.marks.find((m) => m.type === 'area');
815
- // fill should be a gradient object (not a flat string)
816
- expect(typeof areaMark?.fill).toBe('object');
817
- expect((areaMark?.fill as { gradient?: string }).gradient).toBe('linear');
818
- });
819
-
820
- it('sparkline single-series vertical bars use a [min, max] domain', () => {
821
- // With values 100, 105, 110, 120 and zero: false, the y-domain should
822
- // start near 100 — not 0 — so the shortest bar is still visible.
823
- const layout = compileChart(
824
- {
825
- mark: 'bar' as const,
826
- data: [
827
- { cat: 'a', value: 100 },
828
- { cat: 'b', value: 105 },
829
- { cat: 'c', value: 110 },
830
- { cat: 'd', value: 120 },
831
- ],
832
- encoding: {
833
- x: { field: 'cat', type: 'nominal' as const },
834
- y: { field: 'value', type: 'quantitative' as const },
835
- },
836
- display: 'sparkline' as const,
837
- },
838
- { width: 200, height: 40 },
839
- );
840
- // The shortest bar (value 100) should NOT have a height that fills the
841
- // chart down from y=0; with zero:false it sits near the bottom.
842
- const rects = layout.marks.filter((m) => m.type === 'rect');
843
- expect(rects.length).toBe(4);
844
- // Heights should differ meaningfully across the four bars.
845
- const heights = rects.map((r) => (r as { height: number }).height);
846
- const maxH = Math.max(...heights);
847
- const minH = Math.min(...heights);
848
- expect(maxH - minH).toBeGreaterThan(maxH * 0.5);
849
- });
850
-
851
- it('sparkline stacked vertical bars retain the [0, max] baseline', () => {
852
- // Stacked bars MUST baseline at zero — non-zero baselines break stack
853
- // arithmetic. The total bar should equal the category sum.
854
- const layout = compileChart(
855
- {
856
- mark: 'bar' as const,
857
- data: [
858
- { cat: 'a', series: 's1', value: 50 },
859
- { cat: 'a', series: 's2', value: 50 },
860
- { cat: 'b', series: 's1', value: 30 },
861
- { cat: 'b', series: 's2', value: 70 },
862
- ],
863
- encoding: {
864
- x: { field: 'cat', type: 'nominal' as const },
865
- y: { field: 'value', type: 'quantitative' as const, stack: 'zero' as const },
866
- color: { field: 'series', type: 'nominal' as const },
867
- },
868
- display: 'sparkline' as const,
869
- },
870
- { width: 200, height: 80 },
871
- );
872
- const rects = layout.marks.filter((m) => m.type === 'rect');
873
- expect(rects.length).toBe(4);
874
- // For stacked bars, both categories sum to the same total (100), so the
875
- // tallest stacked column should fill close to the full chart area height.
876
- // Verify by checking that at least one bar starts at the chart-area top
877
- // (its y matches chartArea.y within a rounding tolerance).
878
- const minY = Math.min(...rects.map((r) => (r as { y: number }).y));
879
- expect(minY).toBeCloseTo(layout.area.y, -1);
880
- });
881
-
882
- it('sparkline bar gets pill cornerRadius by default', () => {
883
- const layout = compileChart(
884
- {
885
- mark: 'bar' as const,
886
- data: [
887
- { cat: 'a', value: 5 },
888
- { cat: 'b', value: 8 },
889
- { cat: 'c', value: 6 },
890
- ],
891
- encoding: {
892
- x: { field: 'cat', type: 'nominal' as const },
893
- y: { field: 'value', type: 'quantitative' as const },
894
- },
895
- display: 'sparkline' as const,
896
- },
897
- { width: 200, height: 40 },
898
- );
899
- const rects = layout.marks.filter((m) => m.type === 'rect');
900
- expect(rects.length).toBeGreaterThan(0);
901
- // 'pill' translates to rx = barWidth / 2 in column compute. Each rect's
902
- // cornerRadius should equal half the bar's width (or thickness).
903
- const r = rects[0] as { width: number; cornerRadius: number };
904
- expect(r.cornerRadius).toBeCloseTo(r.width / 2, 1);
905
- });
906
-
907
- it('sparkline stacked bars: only top segment per stack gets pill rounding (top corners)', () => {
908
- // Stacked segments below the top stay square so the seams between
909
- // segments are flush. Each topmost segment receives the pill radius
910
- // with `cornerRadiusSides` constrained to the top corners — the
911
- // renderer emits a path with rounded top + square bottom.
912
- const layout = compileChart(
913
- {
914
- mark: 'bar' as const,
915
- data: [
916
- { cat: 'a', series: 's1', value: 50 },
917
- { cat: 'a', series: 's2', value: 50 },
918
- { cat: 'b', series: 's1', value: 30 },
919
- { cat: 'b', series: 's2', value: 70 },
920
- ],
921
- encoding: {
922
- x: { field: 'cat', type: 'nominal' as const },
923
- y: { field: 'value', type: 'quantitative' as const, stack: 'zero' as const },
924
- color: { field: 'series', type: 'nominal' as const },
925
- },
926
- display: 'sparkline' as const,
927
- },
928
- { width: 200, height: 80 },
929
- );
930
- type Rect = {
931
- type: 'rect';
932
- cornerRadius?: number;
933
- cornerRadiusSides?: { tl?: boolean; tr?: boolean; br?: boolean; bl?: boolean };
934
- stackGroup?: string;
935
- y: number;
936
- };
937
- const rects = layout.marks.filter((m): m is Rect => m.type === 'rect') as Rect[];
938
-
939
- // Group by stackGroup, find topmost (smallest y) per group.
940
- const tops = new Map<string, Rect>();
941
- for (const r of rects) {
942
- if (!r.stackGroup) continue;
943
- const cur = tops.get(r.stackGroup);
944
- if (!cur || r.y < cur.y) tops.set(r.stackGroup, r);
945
- }
946
- expect(tops.size).toBe(2);
947
-
948
- for (const r of rects) {
949
- const isTop = tops.get(r.stackGroup ?? '') === r;
950
- if (isTop) {
951
- expect(r.cornerRadius).toBeGreaterThan(0);
952
- expect(r.cornerRadiusSides).toEqual({
953
- tl: true,
954
- tr: true,
955
- br: false,
956
- bl: false,
957
- });
958
- } else {
959
- expect(r.cornerRadius ?? 0).toBe(0);
960
- }
961
- }
962
- });
963
-
964
- // ---------------------------------------------------------------------------
965
- // Explicit-at-any-level wins (precedence matrix)
966
- // ---------------------------------------------------------------------------
967
-
968
- it('top-level animation: true wins even when breakpoint flips to sparkline', () => {
969
- const spec = {
970
- ...lineSpec,
971
- animation: true as const,
972
- overrides: { compact: { display: 'sparkline' as const } },
973
- };
974
- const layout = compileChart(spec, { width: 320, height: 200 });
975
- expect(layout.display).toBe('sparkline');
976
- expect(layout.animation?.enter).toBeDefined();
977
- });
978
-
979
- it('breakpoint chrome wins when top-level is sparkline', () => {
980
- const spec = {
981
- ...lineSpec,
982
- chrome: undefined,
983
- display: 'sparkline' as const,
984
- overrides: { full: { chrome: { title: 'Q4 revenue' } } },
985
- };
986
- const layout = compileChart(spec, { width: 1200, height: 600 });
987
- // At full breakpoint, chrome.title from override should render even
988
- // though display is sparkline at top-level.
989
- expect(layout.chrome.title?.text).toBe('Q4 revenue');
990
- });
991
-
992
- it('top-level display: sparkline + breakpoint display: full restores all defaults', () => {
993
- const spec = {
994
- ...lineSpec,
995
- display: 'sparkline' as const,
996
- overrides: { full: { display: 'full' as const } },
997
- };
998
- const layout = compileChart(spec, { width: 1200, height: 600 });
999
- expect(layout.display).toBe('full');
1000
- // Watermark default is true in full mode.
1001
- expect(layout.watermark).toBe(true);
1002
- });
1003
-
1004
- it('explicit watermark: true in sparkline mode actually paints the watermark', () => {
1005
- const layout = compileChart(
1006
- {
1007
- ...lineSpec,
1008
- chrome: undefined,
1009
- display: 'sparkline' as const,
1010
- watermark: true,
1011
- },
1012
- { width: 400, height: 200 },
1013
- );
1014
- expect(layout.watermark).toBe(true);
1015
- });
1016
-
1017
- it('breakpoint encoding.x.axis opts back into x-axis at that breakpoint', () => {
1018
- const spec = {
1019
- ...lineSpec,
1020
- chrome: undefined,
1021
- display: 'sparkline' as const,
1022
- overrides: {
1023
- full: {
1024
- encoding: {
1025
- x: { field: 'date', type: 'temporal' as const, axis: { title: 'date' } },
1026
- },
1027
- },
1028
- },
1029
- };
1030
- const layoutFull = compileChart(spec, { width: 1200, height: 600 });
1031
- expect(layoutFull.axes.x).toBeDefined();
1032
- const layoutCompact = compileChart(spec, { width: 320, height: 200 });
1033
- expect(layoutCompact.axes.x).toBeUndefined();
1034
- });
1035
-
1036
- // ---------------------------------------------------------------------------
1037
- // Animation duration: sparkline mode bumps the entrance to 1100ms when on
1038
- // ---------------------------------------------------------------------------
1039
-
1040
- it('sparkline + animation: true bumps entrance duration to 1100ms', () => {
1041
- const layout = compileChart(
1042
- { ...lineSpec, display: 'sparkline' as const, animation: true },
1043
- { width: 400, height: 80 },
1044
- );
1045
- expect(layout.animation?.enter).toBeDefined();
1046
- expect(layout.animation?.enter?.duration).toBe(1100);
1047
- });
1048
-
1049
- it('sparkline + animation: { enter: { duration: 500 } } respects user duration', () => {
1050
- const layout = compileChart(
1051
- {
1052
- ...lineSpec,
1053
- display: 'sparkline' as const,
1054
- animation: { enter: { duration: 500 } },
1055
- },
1056
- { width: 400, height: 80 },
1057
- );
1058
- expect(layout.animation?.enter?.duration).toBe(500);
1059
- });
1060
-
1061
- it('full mode + animation: true uses default 500ms (sparkline bump does not leak)', () => {
1062
- const layout = compileChart({ ...lineSpec, animation: true }, { width: 600, height: 400 });
1063
- expect(layout.animation?.enter?.duration).toBe(500);
1064
- });
1065
-
1066
- // ---------------------------------------------------------------------------
1067
- // Breakpoint encoding deep-merge: nested axis state survives an override that
1068
- // only touches one axis property.
1069
- // ---------------------------------------------------------------------------
1070
-
1071
- it('breakpoint encoding.x.axis deep-merges with base axis config', () => {
1072
- const spec = {
1073
- ...lineSpec,
1074
- encoding: {
1075
- ...lineSpec.encoding,
1076
- x: {
1077
- field: 'date',
1078
- type: 'temporal' as const,
1079
- axis: { title: 'date', tickCount: 8, format: '%b' },
1080
- },
1081
- },
1082
- overrides: {
1083
- compact: {
1084
- encoding: {
1085
- x: { axis: { title: 'd' } },
1086
- },
1087
- },
1088
- },
1089
- };
1090
- const layout = compileChart(spec, { width: 320, height: 200 });
1091
- // The compact override only touched axis.title; tickCount and format
1092
- // should survive the deep merge.
1093
- expect(layout.axes.x).toBeDefined();
1094
- expect(layout.axes.x?.label).toBe('d');
1095
- // Tick count flows from base spec — if shallow-merged, tickCount would be lost.
1096
- expect(layout.axes.x?.ticks.length).toBeGreaterThan(0);
1097
- });
1098
- });
1099
-
1100
- describe('compileTable', () => {
1101
- it('returns a valid TableLayout with resolved columns and rows', () => {
1102
- const layout = compileTable(
1103
- {
1104
- type: 'table',
1105
- data: [{ name: 'Alice', age: 30 }],
1106
- columns: [{ key: 'name' }, { key: 'age' }],
1107
- chrome: { title: 'People' },
1108
- },
1109
- { width: 600, height: 400 },
1110
- );
1111
-
1112
- // Chrome title is resolved
1113
- expect(layout.chrome.title!.text).toBe('People');
1114
- expect(layout.chrome.topHeight).toBeGreaterThan(0);
1115
-
1116
- // Columns are resolved with labels and widths
1117
- expect(layout.columns).toHaveLength(2);
1118
- expect(layout.columns[0].key).toBe('name');
1119
- expect(layout.columns[0].label).toBeTruthy();
1120
- expect(layout.columns[0].width).toBeGreaterThan(0);
1121
- expect(layout.columns[1].key).toBe('age');
1122
-
1123
- // Rows contain formatted cell values
1124
- expect(layout.rows).toHaveLength(1);
1125
- expect(layout.rows[0].cells).toHaveLength(2);
1126
- expect(layout.rows[0].cells[0].formattedValue).toBe('Alice');
1127
- expect(layout.rows[0].cells[1].formattedValue).toBe('30');
1128
-
1129
- // Accessibility caption includes the title
1130
- expect(layout.a11y.caption).toContain('People');
1131
-
1132
- // Theme is resolved
1133
- expect(layout.theme.isDark).toBe(false);
1134
- expect(layout.theme.colors.background).toBeTruthy();
1135
- });
1136
-
1137
- it('throws if a chart spec is passed', () => {
1138
- expect(() => compileTable(lineSpec, { width: 600, height: 400 })).toThrow('compileChart');
1139
- });
1140
- });
1141
-
1142
- describe('compileGraph', () => {
1143
- it('returns a valid GraphCompilation for a graph spec', () => {
1144
- const result = compileGraph(
1145
- {
1146
- type: 'graph',
1147
- nodes: [{ id: 'a' }],
1148
- edges: [],
1149
- },
1150
- { width: 600, height: 400 },
1151
- );
1152
- expect(result.nodes).toHaveLength(1);
1153
- expect(result.edges).toHaveLength(0);
1154
- expect(result.dimensions).toEqual({ width: 600, height: 400 });
1155
- });
1156
-
1157
- it('throws for non-graph specs', () => {
1158
- expect(() =>
1159
- compileGraph(
1160
- {
1161
- mark: 'point',
1162
- data: [{ x: 1, y: 2 }],
1163
- encoding: {
1164
- x: { field: 'x', type: 'quantitative' },
1165
- y: { field: 'y', type: 'quantitative' },
1166
- },
1167
- },
1168
- { width: 600, height: 400 },
1169
- ),
1170
- ).toThrow('compileGraph received a non-graph spec');
1171
- });
1172
-
1173
- it('propagates labelAngle through the full compilation pipeline', () => {
1174
- const columnSpec = {
1175
- mark: 'bar' as const,
1176
- data: [
1177
- { state: 'California', pop: 39000000 },
1178
- { state: 'Texas', pop: 29000000 },
1179
- { state: 'Florida', pop: 22000000 },
1180
- { state: 'New York', pop: 20000000 },
1181
- { state: 'Pennsylvania', pop: 13000000 },
1182
- ],
1183
- encoding: {
1184
- x: { field: 'state', type: 'nominal' as const, axis: { labelAngle: -90 } },
1185
- y: { field: 'pop', type: 'quantitative' as const },
1186
- },
1187
- };
1188
-
1189
- const layout = compileChart(columnSpec, { width: 400, height: 300 });
1190
-
1191
- // labelAngle should be propagated to the x-axis layout as tickAngle
1192
- expect(layout.axes.x!.tickAngle).toBe(-90);
1193
- // y-axis should not have a tickAngle
1194
- expect(layout.axes.y!.tickAngle).toBeUndefined();
1195
- });
1196
-
1197
- it('reserves extra bottom margin for rotated x-axis labels', () => {
1198
- const baseColumnSpec = {
1199
- mark: 'bar' as const,
1200
- data: [
1201
- { state: 'California', pop: 39000000 },
1202
- { state: 'Texas', pop: 29000000 },
1203
- { state: 'Massachusetts', pop: 7000000 },
1204
- ],
1205
- encoding: {
1206
- x: { field: 'state', type: 'nominal' as const },
1207
- y: { field: 'pop', type: 'quantitative' as const },
1208
- },
1209
- };
1210
- const rotatedColumnSpec = {
1211
- ...baseColumnSpec,
1212
- encoding: {
1213
- x: { field: 'state', type: 'nominal' as const, axis: { labelAngle: -90 } },
1214
- y: { field: 'pop', type: 'quantitative' as const },
1215
- },
1216
- };
1217
-
1218
- const layoutNormal = compileChart(baseColumnSpec, { width: 400, height: 300 });
1219
- const layoutRotated = compileChart(rotatedColumnSpec, { width: 400, height: 300 });
1220
-
1221
- // Rotated labels should shrink the chart area height
1222
- expect(layoutRotated.area.height).toBeLessThan(layoutNormal.area.height);
1223
- });
1224
-
1225
- it('hides legend when legend.show is false', () => {
1226
- const spec = {
1227
- ...lineSpec,
1228
- legend: { show: false },
1229
- };
1230
- const layout = compileChart(spec, { width: 600, height: 400 });
1231
- expect(layout.legend.entries).toHaveLength(0);
1232
- expect(layout.legend.bounds.width).toBe(0);
1233
- expect(layout.legend.bounds.height).toBe(0);
1234
- });
1235
-
1236
- it('applies compact breakpoint overrides to chrome', () => {
1237
- const spec = {
1238
- ...lineSpec,
1239
- chrome: {
1240
- title: 'Full width title with extra context',
1241
- subtitle: 'Long subtitle with methodology details',
1242
- },
1243
- overrides: {
1244
- compact: {
1245
- chrome: {
1246
- title: 'Short title',
1247
- subtitle: 'Short sub',
1248
- },
1249
- },
1250
- },
1251
- };
1252
-
1253
- // At compact width (< 400), overrides should apply
1254
- const compactLayout = compileChart(spec, { width: 350, height: 400 });
1255
- expect(compactLayout.chrome.title!.text).toBe('Short title');
1256
- expect(compactLayout.chrome.subtitle!.text).toBe('Short sub');
1257
-
1258
- // At full width, base spec should apply
1259
- const fullLayout = compileChart(spec, { width: 800, height: 400 });
1260
- expect(fullLayout.chrome.title!.text).toBe('Full width title with extra context');
1261
- expect(fullLayout.chrome.subtitle!.text).toBe('Long subtitle with methodology details');
1262
- });
1263
-
1264
- it('applies medium breakpoint overrides', () => {
1265
- const spec = {
1266
- ...lineSpec,
1267
- chrome: { title: 'Full title' },
1268
- overrides: {
1269
- medium: {
1270
- chrome: { title: 'Medium title' },
1271
- },
1272
- },
1273
- };
1274
-
1275
- // At medium width (400-700), override should apply
1276
- const mediumLayout = compileChart(spec, { width: 500, height: 400 });
1277
- expect(mediumLayout.chrome.title!.text).toBe('Medium title');
1278
-
1279
- // At full width, base spec should apply
1280
- const fullLayout = compileChart(spec, { width: 800, height: 400 });
1281
- expect(fullLayout.chrome.title!.text).toBe('Full title');
1282
- });
1283
-
1284
- it('applies breakpoint override for legend show', () => {
1285
- const spec = {
1286
- ...lineSpec,
1287
- legend: { show: true },
1288
- overrides: {
1289
- compact: {
1290
- legend: { show: false },
1291
- },
1292
- },
1293
- };
1294
-
1295
- // At compact, legend hidden
1296
- const compactLayout = compileChart(spec, { width: 350, height: 400 });
1297
- expect(compactLayout.legend.entries).toHaveLength(0);
1298
-
1299
- // At full, legend shown
1300
- const fullLayout = compileChart(spec, { width: 800, height: 400 });
1301
- expect(fullLayout.legend.entries.length).toBeGreaterThan(0);
1302
- });
1303
- });