@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,934 +0,0 @@
1
- import type { LayoutStrategy, Rect } from '@opendata-ai/openchart-core';
2
- import { buildD3Formatter } from '@opendata-ai/openchart-core';
3
- import { describe, expect, it } from 'vitest';
4
- import type { NormalizedChartSpec } from '../../../compiler/types';
5
- import { computeScales } from '../../../layout/scales';
6
- import { computeBarMarks } from '../compute';
7
- import { computeBarLabels } from '../labels';
8
-
9
- // ---------------------------------------------------------------------------
10
- // Shared fixtures
11
- // ---------------------------------------------------------------------------
12
-
13
- const chartArea: Rect = { x: 80, y: 20, width: 500, height: 300 };
14
-
15
- // Default container width for tests that don't exercise the narrow-container
16
- // grouped-bar reclaim gate. Wide enough (>= NARROW_VIEWPORT_MAX) that the gate
17
- // stays off, matching the prior default geometry.
18
- const CONTAINER_WIDTH = 800;
19
-
20
- const fullStrategy: LayoutStrategy = {
21
- labelMode: 'all',
22
- legendPosition: 'right',
23
- annotationPosition: 'inline',
24
- axisLabelDensity: 'full',
25
- };
26
-
27
- function makeSimpleBarSpec(): NormalizedChartSpec {
28
- return {
29
- markType: 'bar',
30
- markDef: { type: 'bar' },
31
- data: [
32
- { category: 'Apple', value: 50 },
33
- { category: 'Banana', value: 30 },
34
- { category: 'Cherry', value: 70 },
35
- ],
36
- encoding: {
37
- x: { field: 'value', type: 'quantitative' },
38
- y: { field: 'category', type: 'nominal' },
39
- },
40
- chrome: {},
41
- annotations: [],
42
- responsive: true,
43
- theme: {},
44
- darkMode: 'off',
45
- labels: { density: 'auto', format: '' },
46
- };
47
- }
48
-
49
- function makeGroupedBarSpec(): NormalizedChartSpec {
50
- return {
51
- markType: 'bar',
52
- markDef: { type: 'bar' },
53
- data: [
54
- { category: 'Q1', value: 50, region: 'East' },
55
- { category: 'Q1', value: 40, region: 'West' },
56
- { category: 'Q2', value: 60, region: 'East' },
57
- { category: 'Q2', value: 55, region: 'West' },
58
- { category: 'Q3', value: 45, region: 'East' },
59
- { category: 'Q3', value: 70, region: 'West' },
60
- ],
61
- encoding: {
62
- // Grouped is opt-out since v8 (stacked is now the default): stamp
63
- // stack: null explicitly so this fixture stays grouped.
64
- x: { field: 'value', type: 'quantitative', stack: null },
65
- y: { field: 'category', type: 'nominal' },
66
- color: { field: 'region', type: 'nominal' },
67
- },
68
- chrome: {},
69
- annotations: [],
70
- responsive: true,
71
- theme: {},
72
- darkMode: 'off',
73
- labels: { density: 'auto', format: '' },
74
- };
75
- }
76
-
77
- function makeNegativeBarSpec(): NormalizedChartSpec {
78
- return {
79
- markType: 'bar',
80
- markDef: { type: 'bar' },
81
- data: [
82
- { category: 'Growth', value: 15 },
83
- { category: 'Decline', value: -10 },
84
- { category: 'Stable', value: 2 },
85
- ],
86
- encoding: {
87
- x: { field: 'value', type: 'quantitative' },
88
- y: { field: 'category', type: 'nominal' },
89
- },
90
- chrome: {},
91
- annotations: [],
92
- responsive: true,
93
- theme: {},
94
- darkMode: 'off',
95
- labels: { density: 'auto', format: '' },
96
- };
97
- }
98
-
99
- // ---------------------------------------------------------------------------
100
- // computeBarMarks tests
101
- // ---------------------------------------------------------------------------
102
-
103
- describe('computeBarMarks', () => {
104
- describe('simple bars', () => {
105
- it('produces one RectMark per data row', () => {
106
- const spec = makeSimpleBarSpec();
107
- const scales = computeScales(spec, chartArea, spec.data);
108
- const marks = computeBarMarks(spec, scales, chartArea, fullStrategy, CONTAINER_WIDTH);
109
-
110
- expect(marks).toHaveLength(3);
111
- expect(marks.every((m) => m.type === 'rect')).toBe(true);
112
- });
113
-
114
- it('bars have positive width and height', () => {
115
- const spec = makeSimpleBarSpec();
116
- const scales = computeScales(spec, chartArea, spec.data);
117
- const marks = computeBarMarks(spec, scales, chartArea, fullStrategy, CONTAINER_WIDTH);
118
-
119
- for (const mark of marks) {
120
- expect(mark.width).toBeGreaterThan(0);
121
- expect(mark.height).toBeGreaterThan(0);
122
- }
123
- });
124
-
125
- it('wider bars correspond to larger values', () => {
126
- const spec = makeSimpleBarSpec();
127
- const scales = computeScales(spec, chartArea, spec.data);
128
- const marks = computeBarMarks(spec, scales, chartArea, fullStrategy, CONTAINER_WIDTH);
129
-
130
- // Cherry (70) should be wider than Banana (30)
131
- const cherry = marks.find((m) => m.aria.label.includes('Cherry'))!;
132
- const banana = marks.find((m) => m.aria.label.includes('Banana'))!;
133
- expect(cherry.width).toBeGreaterThan(banana.width);
134
- });
135
-
136
- it('bars have corner radius applied', () => {
137
- const spec = makeSimpleBarSpec();
138
- const scales = computeScales(spec, chartArea, spec.data);
139
- const marks = computeBarMarks(spec, scales, chartArea, fullStrategy, CONTAINER_WIDTH);
140
-
141
- expect(marks[0].cornerRadius).toBe(2);
142
- });
143
-
144
- it('each bar has an aria label with category and value', () => {
145
- const spec = makeSimpleBarSpec();
146
- const scales = computeScales(spec, chartArea, spec.data);
147
- const marks = computeBarMarks(spec, scales, chartArea, fullStrategy, CONTAINER_WIDTH);
148
-
149
- expect(marks[0].aria.label).toContain('Apple');
150
- expect(marks[0].aria.label).toContain('50');
151
- });
152
- });
153
-
154
- describe('stacked bars (stack: zero)', () => {
155
- function makeStackedBarSpec(): NormalizedChartSpec {
156
- const spec = makeGroupedBarSpec();
157
- (spec.encoding.x as { stack?: string }).stack = 'zero';
158
- return spec;
159
- }
160
-
161
- it('produces marks for all data rows', () => {
162
- const spec = makeStackedBarSpec();
163
- const scales = computeScales(spec, chartArea, spec.data);
164
- const marks = computeBarMarks(spec, scales, chartArea, fullStrategy, CONTAINER_WIDTH);
165
-
166
- // 3 categories * 2 groups = 6
167
- expect(marks).toHaveLength(6);
168
- });
169
-
170
- it('segments within a category have different colors', () => {
171
- const spec = makeStackedBarSpec();
172
- const scales = computeScales(spec, chartArea, spec.data);
173
- const marks = computeBarMarks(spec, scales, chartArea, fullStrategy, CONTAINER_WIDTH);
174
-
175
- // Find Q1 bars
176
- const q1Marks = marks.filter((m) => m.aria.label.includes('Q1'));
177
- expect(q1Marks).toHaveLength(2);
178
- expect(q1Marks[0].fill).not.toBe(q1Marks[1].fill);
179
- });
180
-
181
- it('stacked segments share the same y position within a category', () => {
182
- const spec = makeStackedBarSpec();
183
- const scales = computeScales(spec, chartArea, spec.data);
184
- const marks = computeBarMarks(spec, scales, chartArea, fullStrategy, CONTAINER_WIDTH);
185
-
186
- const q1East = marks.find(
187
- (m) => m.aria.label.includes('Q1') && m.aria.label.includes('East'),
188
- )!;
189
- const q1West = marks.find(
190
- (m) => m.aria.label.includes('Q1') && m.aria.label.includes('West'),
191
- )!;
192
-
193
- // Stacked bars share the same y position (full band height)
194
- expect(q1East.y).toBe(q1West.y);
195
- });
196
-
197
- it('stacked segments are placed end-to-end horizontally', () => {
198
- const spec = makeStackedBarSpec();
199
- const scales = computeScales(spec, chartArea, spec.data);
200
- const marks = computeBarMarks(spec, scales, chartArea, fullStrategy, CONTAINER_WIDTH);
201
-
202
- const q1Marks = marks.filter((m) => m.aria.label.includes('Q1'));
203
- // Second segment should start where first ends
204
- const first = q1Marks[0];
205
- const second = q1Marks[1];
206
- expect(second.x).toBeCloseTo(first.x + first.width, 0);
207
- });
208
-
209
- it('stacked bars have zero corner radius', () => {
210
- const spec = makeStackedBarSpec();
211
- const scales = computeScales(spec, chartArea, spec.data);
212
- const marks = computeBarMarks(spec, scales, chartArea, fullStrategy, CONTAINER_WIDTH);
213
-
214
- for (const mark of marks) {
215
- expect(mark.cornerRadius).toBe(0);
216
- }
217
- });
218
- });
219
-
220
- describe('stack: undefined (omitted) stacks by default (v8 VL-aligned default)', () => {
221
- function makeDefaultStackBarSpec(): NormalizedChartSpec {
222
- return {
223
- markType: 'bar',
224
- markDef: { type: 'bar' },
225
- data: [
226
- { category: 'Q1', value: 50, region: 'East' },
227
- { category: 'Q1', value: 40, region: 'West' },
228
- { category: 'Q2', value: 60, region: 'East' },
229
- { category: 'Q2', value: 55, region: 'West' },
230
- ],
231
- encoding: {
232
- x: { field: 'value', type: 'quantitative' },
233
- y: { field: 'category', type: 'nominal' },
234
- color: { field: 'region', type: 'nominal' },
235
- },
236
- chrome: {},
237
- annotations: [],
238
- responsive: true,
239
- theme: {},
240
- darkMode: 'off',
241
- labels: { density: 'auto', format: '' },
242
- };
243
- }
244
-
245
- it('stacks segments end-to-end without an explicit stack property', () => {
246
- const spec = makeDefaultStackBarSpec();
247
- const scales = computeScales(spec, chartArea, spec.data);
248
- const marks = computeBarMarks(spec, scales, chartArea, fullStrategy, CONTAINER_WIDTH);
249
-
250
- const q1Marks = marks.filter((m) => m.aria.label.includes('Q1'));
251
- expect(q1Marks).toHaveLength(2);
252
- expect(q1Marks[0].y).toBe(q1Marks[1].y);
253
- const [first, second] = q1Marks;
254
- expect(second.x).toBeCloseTo(first.x + first.width, 0);
255
- expect(marks.every((m) => m.stackGroup !== undefined)).toBe(true);
256
- });
257
- });
258
-
259
- describe('grouped bars (stack: null)', () => {
260
- it('produces marks for all data rows', () => {
261
- const spec = makeGroupedBarSpec();
262
- const scales = computeScales(spec, chartArea, spec.data);
263
- const marks = computeBarMarks(spec, scales, chartArea, fullStrategy, CONTAINER_WIDTH);
264
-
265
- expect(marks).toHaveLength(6);
266
- });
267
-
268
- it('grouped bars within a category have different y positions', () => {
269
- const spec = makeGroupedBarSpec();
270
- const scales = computeScales(spec, chartArea, spec.data);
271
- const marks = computeBarMarks(spec, scales, chartArea, fullStrategy, CONTAINER_WIDTH);
272
-
273
- const q1East = marks.find(
274
- (m) => m.aria.label.includes('Q1') && m.aria.label.includes('East'),
275
- )!;
276
- const q1West = marks.find(
277
- (m) => m.aria.label.includes('Q1') && m.aria.label.includes('West'),
278
- )!;
279
-
280
- expect(q1East.y).not.toBe(q1West.y);
281
- });
282
-
283
- it('grouped bars all start from baseline (not cumulative)', () => {
284
- const spec = makeGroupedBarSpec();
285
- const scales = computeScales(spec, chartArea, spec.data);
286
- const marks = computeBarMarks(spec, scales, chartArea, fullStrategy, CONTAINER_WIDTH);
287
-
288
- const q1East = marks.find(
289
- (m) => m.aria.label.includes('Q1') && m.aria.label.includes('East'),
290
- )!;
291
- const q1West = marks.find(
292
- (m) => m.aria.label.includes('Q1') && m.aria.label.includes('West'),
293
- )!;
294
-
295
- // Both bars start at the same x position (baseline)
296
- expect(q1East.x).toBe(q1West.x);
297
- });
298
-
299
- it('grouped bars have cornerRadius 2', () => {
300
- const spec = makeGroupedBarSpec();
301
- const scales = computeScales(spec, chartArea, spec.data);
302
- const marks = computeBarMarks(spec, scales, chartArea, fullStrategy, CONTAINER_WIDTH);
303
-
304
- for (const mark of marks) {
305
- expect(mark.cornerRadius).toBe(2);
306
- }
307
- });
308
-
309
- it('grouped bars do not set stackGroup', () => {
310
- const spec = makeGroupedBarSpec();
311
- const scales = computeScales(spec, chartArea, spec.data);
312
- const marks = computeBarMarks(spec, scales, chartArea, fullStrategy, CONTAINER_WIDTH);
313
-
314
- for (const mark of marks) {
315
- expect(mark.stackGroup).toBeUndefined();
316
- }
317
- });
318
-
319
- it('sub-band heights are smaller than full bandwidth (stacked bars use full height)', () => {
320
- const groupedSpec = makeGroupedBarSpec();
321
- const groupedScales = computeScales(groupedSpec, chartArea, groupedSpec.data);
322
- const groupedMarks = computeBarMarks(
323
- groupedSpec,
324
- groupedScales,
325
- chartArea,
326
- fullStrategy,
327
- CONTAINER_WIDTH,
328
- );
329
-
330
- // With 2 groups, each sub-bar should be less than the full bandwidth
331
- const stackedSpec = makeGroupedBarSpec();
332
- (stackedSpec.encoding.x as { stack?: string }).stack = 'zero';
333
- const stackedScales = computeScales(stackedSpec, chartArea, stackedSpec.data);
334
- const stackedMarks = computeBarMarks(
335
- stackedSpec,
336
- stackedScales,
337
- chartArea,
338
- fullStrategy,
339
- CONTAINER_WIDTH,
340
- );
341
-
342
- expect(groupedMarks[0].height).toBeLessThan(stackedMarks[0].height);
343
- expect(groupedMarks[0].height).toBeGreaterThan(0);
344
- });
345
-
346
- it('scale domain covers max individual value, not stacked sum', () => {
347
- const spec = makeGroupedBarSpec();
348
- const scales = computeScales(spec, chartArea, spec.data);
349
-
350
- // Max individual value is 70 (Q3 West), not 115 (Q3 stacked sum)
351
- const xScale = scales.x!.scale;
352
- const domain = xScale.domain() as number[];
353
- // Domain should not extend to the stacked sum (115)
354
- expect(domain[1]).toBeLessThanOrEqual(80); // some nice rounding above 70
355
- });
356
-
357
- // Issue 1: at tight bandwidths on narrow plots, grouped sub-bars fall below
358
- // the readable floor and read as stripes. The engine reclaims reserved
359
- // inter-category whitespace to keep bars >= MIN_GROUPED_BAR_THICKNESS (8px).
360
- it('reclaims band whitespace so narrow-plot sub-bars meet the readable floor', () => {
361
- // 11 categories x 3 series in a short, phone-width plot -> sub-bars would
362
- // be ~7px without the reclaim.
363
- const data = [] as { district: string; source: string; dollars: number }[];
364
- for (let i = 0; i < 11; i++) {
365
- for (const source of ['State', 'Local', 'Federal']) {
366
- data.push({ district: `D${i}`, source, dollars: 100 + i });
367
- }
368
- }
369
- const spec: NormalizedChartSpec = {
370
- markType: 'bar',
371
- markDef: { type: 'bar' },
372
- data,
373
- encoding: {
374
- // Grouped is opt-out since v8 (stacked is now the default): stamp
375
- // stack: null explicitly since this test exercises the
376
- // grouped-bar-only readable-floor reclaim.
377
- x: { field: 'dollars', type: 'quantitative', stack: null },
378
- y: { field: 'district', type: 'nominal' },
379
- color: { field: 'source', type: 'nominal' },
380
- },
381
- chrome: {},
382
- annotations: [],
383
- responsive: true,
384
- theme: {},
385
- darkMode: 'off',
386
- labels: { density: 'auto', format: '' },
387
- };
388
-
389
- // Narrow container (< NARROW_VIEWPORT_MAX): reclaim active. Same plot
390
- // geometry both times; only the container width toggles the gate, proving
391
- // the gate keys off the container, not the derived plot width.
392
- const plotArea: Rect = { x: 120, y: 20, width: 240, height: 380 };
393
- const scales = computeScales(spec, plotArea, spec.data);
394
- const narrowMarks = computeBarMarks(spec, scales, plotArea, fullStrategy, 400);
395
- for (const mark of narrowMarks) {
396
- expect(mark.height).toBeGreaterThanOrEqual(8);
397
- }
398
-
399
- // Wide container (>= NARROW_VIEWPORT_MAX): reclaim gated off, so the same
400
- // tight-height plot is left untouched (bars may be thin, matching the
401
- // pre-fix desktop geometry). Bars must not be *taller* than the narrow
402
- // case, proving the reclaim only applies for narrow containers. A wide
403
- // container with a sub-500px plot (e.g. a large left gutter) is exactly
404
- // the desktop-embed case that must stay pixel-identical.
405
- const wideMarks = computeBarMarks(spec, scales, plotArea, fullStrategy, 700);
406
- const wideMax = Math.max(...wideMarks.map((m) => m.height));
407
- const narrowMin = Math.min(...narrowMarks.map((m) => m.height));
408
- expect(wideMax).toBeLessThan(narrowMin);
409
- });
410
- });
411
-
412
- describe('colored (non-stacked) bars', () => {
413
- it('renders colored bars when each category has one row with color encoding', () => {
414
- const spec: NormalizedChartSpec = {
415
- markType: 'bar',
416
- markDef: { type: 'bar' },
417
- data: [
418
- { category: 'Apple', value: 50, type: 'Fruit' },
419
- { category: 'Banana', value: 30, type: 'Tropical' },
420
- { category: 'Cherry', value: 70, type: 'Berry' },
421
- ],
422
- encoding: {
423
- x: { field: 'value', type: 'quantitative' },
424
- y: { field: 'category', type: 'nominal' },
425
- color: { field: 'type', type: 'nominal' },
426
- },
427
- chrome: {},
428
- annotations: [],
429
- responsive: true,
430
- theme: {},
431
- darkMode: 'off',
432
- labels: { density: 'auto', format: '' },
433
- };
434
- const scales = computeScales(spec, chartArea, spec.data);
435
- const marks = computeBarMarks(spec, scales, chartArea, fullStrategy, CONTAINER_WIDTH);
436
-
437
- expect(marks).toHaveLength(3);
438
- // Each bar should have different colors
439
- const colors = new Set(marks.map((m) => m.fill));
440
- expect(colors.size).toBe(3);
441
- // Non-stacked bars should have corner radius
442
- expect(marks[0].cornerRadius).toBe(2);
443
- // Bars should not be stacked (no stackGroup)
444
- expect(marks[0].stackGroup).toBeUndefined();
445
- });
446
-
447
- it('handles negative values in colored bars', () => {
448
- const spec: NormalizedChartSpec = {
449
- markType: 'bar',
450
- markDef: { type: 'bar' },
451
- data: [
452
- { category: 'Growth', value: 15, status: 'positive' },
453
- { category: 'Decline', value: -10, status: 'negative' },
454
- ],
455
- encoding: {
456
- x: { field: 'value', type: 'quantitative' },
457
- y: { field: 'category', type: 'nominal' },
458
- color: { field: 'status', type: 'nominal' },
459
- },
460
- chrome: {},
461
- annotations: [],
462
- responsive: true,
463
- theme: {},
464
- darkMode: 'off',
465
- labels: { density: 'auto', format: '' },
466
- };
467
- const scales = computeScales(spec, chartArea, spec.data);
468
- const marks = computeBarMarks(spec, scales, chartArea, fullStrategy, CONTAINER_WIDTH);
469
-
470
- expect(marks).toHaveLength(2);
471
- const decline = marks.find((m) => m.aria.label.includes('Decline'))!;
472
- const growth = marks.find((m) => m.aria.label.includes('Growth'))!;
473
- // Negative bar starts to the left of positive bar
474
- expect(decline.x).toBeLessThan(growth.x);
475
- expect(decline.width).toBeGreaterThan(0);
476
- });
477
- });
478
-
479
- describe('negative values', () => {
480
- it('negative bars extend leftward from baseline', () => {
481
- const spec = makeNegativeBarSpec();
482
- const scales = computeScales(spec, chartArea, spec.data);
483
- const marks = computeBarMarks(spec, scales, chartArea, fullStrategy, CONTAINER_WIDTH);
484
-
485
- const decline = marks.find((m) => m.aria.label.includes('Decline'))!;
486
- const growth = marks.find((m) => m.aria.label.includes('Growth'))!;
487
-
488
- // Negative bar should start to the left of where positive bar starts
489
- expect(decline.x).toBeLessThan(growth.x);
490
- });
491
-
492
- it('negative bars still have positive width', () => {
493
- const spec = makeNegativeBarSpec();
494
- const scales = computeScales(spec, chartArea, spec.data);
495
- const marks = computeBarMarks(spec, scales, chartArea, fullStrategy, CONTAINER_WIDTH);
496
-
497
- for (const mark of marks) {
498
- expect(mark.width).toBeGreaterThan(0);
499
- }
500
- });
501
- });
502
-
503
- describe('stacked vs grouped (wage data reproduction)', () => {
504
- // 2 years × 2 firm-size categories — the canonical grouped-bar use case
505
- const wageData = [
506
- { size: '<5 employees', year: '2018', pay: 48200 },
507
- { size: '<5 employees', year: '2022', pay: 56400 },
508
- { size: '5,000+ employees', year: '2018', pay: 62300 },
509
- { size: '5,000+ employees', year: '2022', pay: 74800 },
510
- ];
511
-
512
- function makeWageSpec(stackZero = false): NormalizedChartSpec {
513
- return {
514
- markType: 'bar',
515
- markDef: { type: 'bar' },
516
- data: wageData,
517
- encoding: {
518
- // Grouped is opt-out since v8 (stacked is now the default): stamp
519
- // stack: null explicitly for the grouped case.
520
- x: {
521
- field: 'pay',
522
- type: 'quantitative',
523
- stack: stackZero ? 'zero' : null,
524
- },
525
- y: { field: 'size', type: 'nominal' },
526
- color: { field: 'year', type: 'nominal' },
527
- },
528
- chrome: {},
529
- annotations: [],
530
- responsive: true,
531
- theme: {},
532
- darkMode: 'off',
533
- labels: { density: 'auto', format: '' },
534
- };
535
- }
536
-
537
- it('grouped (stack: null): bars sit at different y positions within each category', () => {
538
- const spec = makeWageSpec(false);
539
- const scales = computeScales(spec, chartArea, spec.data);
540
- const marks = computeBarMarks(spec, scales, chartArea, fullStrategy, CONTAINER_WIDTH);
541
-
542
- // 2 firm sizes × 2 years = 4 bars
543
- expect(marks).toHaveLength(4);
544
-
545
- const smallFirmMarks = marks.filter((m) => m.aria.label.includes('<5'));
546
- expect(smallFirmMarks).toHaveLength(2);
547
- expect(smallFirmMarks[0].y).not.toBe(smallFirmMarks[1].y);
548
- });
549
-
550
- it('grouped (stack: null): scale domain covers max individual value, not stacked sum', () => {
551
- const spec = makeWageSpec(false);
552
- const scales = computeScales(spec, chartArea, spec.data);
553
-
554
- // Max individual pay is 74800. Stacked sum for 5000+ employees = 62300 + 74800 = 137100.
555
- // Grouped bars should NOT extend domain to 137100.
556
- const xScale = scales.x!.scale;
557
- const domain = xScale.domain() as number[];
558
- expect(domain[1]).toBeLessThan(137100);
559
- expect(domain[1]).toBeGreaterThanOrEqual(74800);
560
- });
561
-
562
- it('stacked with stack:zero: segments are contiguous end-to-end within each category', () => {
563
- const spec = makeWageSpec(true);
564
- const scales = computeScales(spec, chartArea, spec.data);
565
- const marks = computeBarMarks(spec, scales, chartArea, fullStrategy, CONTAINER_WIDTH);
566
-
567
- expect(marks).toHaveLength(4);
568
-
569
- // For stacked bars, the second segment starts exactly where the first ends.
570
- const smallFirmMarks = marks
571
- .filter((m) => m.aria.label.includes('<5'))
572
- .sort((a, b) => a.x - b.x);
573
- expect(smallFirmMarks).toHaveLength(2);
574
- expect(smallFirmMarks[1].x).toBeCloseTo(smallFirmMarks[0].x + smallFirmMarks[0].width, 1);
575
- });
576
-
577
- it('stacked with stack:zero: segments share the same y position (stacked on same row)', () => {
578
- const spec = makeWageSpec(true);
579
- const scales = computeScales(spec, chartArea, spec.data);
580
- const marks = computeBarMarks(spec, scales, chartArea, fullStrategy, CONTAINER_WIDTH);
581
-
582
- const smallFirmMarks = marks.filter((m) => m.aria.label.includes('<5'));
583
- expect(smallFirmMarks).toHaveLength(2);
584
- expect(smallFirmMarks[0].y).toBe(smallFirmMarks[1].y);
585
- });
586
-
587
- it('grouped vs stacked: grouped bars each start from baseline', () => {
588
- const stackedSpec = makeWageSpec(true);
589
- const stackedScales = computeScales(stackedSpec, chartArea, stackedSpec.data);
590
- const stackedMarks = computeBarMarks(
591
- stackedSpec,
592
- stackedScales,
593
- chartArea,
594
- fullStrategy,
595
- CONTAINER_WIDTH,
596
- );
597
-
598
- const groupedSpec = makeWageSpec(false);
599
- const groupedScales = computeScales(groupedSpec, chartArea, groupedSpec.data);
600
- const groupedMarks = computeBarMarks(
601
- groupedSpec,
602
- groupedScales,
603
- chartArea,
604
- fullStrategy,
605
- CONTAINER_WIDTH,
606
- );
607
-
608
- expect(groupedMarks).toHaveLength(4);
609
-
610
- // Grouped bars each start from the baseline (same x for both years within a firm size)
611
- const smallFirmGrouped = groupedMarks.filter((m) => m.aria.label.includes('<5'));
612
- expect(smallFirmGrouped[0].x).toBe(smallFirmGrouped[1].x);
613
-
614
- // Stacked bars for the same category have different x positions (end-to-end)
615
- const smallFirmStacked = stackedMarks.filter((m) => m.aria.label.includes('<5'));
616
- expect(smallFirmStacked[0].x).not.toBe(smallFirmStacked[1].x);
617
- });
618
- });
619
-
620
- describe('x-domain excludes zero', () => {
621
- it('simple bars anchor at the domain minimum, not xScale(0)', () => {
622
- const spec = makeSimpleBarSpec();
623
- (spec.encoding.x as { scale?: { domain: number[] } }).scale = { domain: [65, 95] };
624
- const scales = computeScales(spec, chartArea, spec.data);
625
- const marks = computeBarMarks(spec, scales, chartArea, fullStrategy, CONTAINER_WIDTH);
626
-
627
- const xScale = scales.x!.scale as (v: number) => number;
628
- const left = xScale(65);
629
-
630
- // Every bar starts at the plot-area left edge (xScale of the domain min),
631
- // not at the far-left extrapolation of xScale(0).
632
- for (const mark of marks) {
633
- expect(mark.x).toBeCloseTo(left, 5);
634
- // Bars never bleed left of the plot area into the y-axis label gutter.
635
- expect(mark.x).toBeGreaterThanOrEqual(chartArea.x);
636
- }
637
- // Guard against the old behavior: xScale(0) lands well left of the plot area.
638
- expect(xScale(0)).toBeLessThan(chartArea.x);
639
- });
640
-
641
- it('grouped bars anchor at the domain minimum, not xScale(0)', () => {
642
- const spec = makeGroupedBarSpec();
643
- (spec.encoding.x as { scale?: { domain: number[] } }).scale = { domain: [65, 95] };
644
- // Lift values into the [65, 95] window so widths stay positive.
645
- spec.data = spec.data.map((d) => ({ ...d, value: 70 + (d.value as number) / 10 }));
646
- const scales = computeScales(spec, chartArea, spec.data);
647
- const marks = computeBarMarks(spec, scales, chartArea, fullStrategy, CONTAINER_WIDTH);
648
-
649
- const xScale = scales.x!.scale as (v: number) => number;
650
- const left = xScale(65);
651
-
652
- for (const mark of marks) {
653
- expect(mark.x).toBeCloseTo(left, 5);
654
- expect(mark.x).toBeGreaterThanOrEqual(chartArea.x);
655
- }
656
- });
657
- });
658
-
659
- describe('x-domain includes zero (no regression)', () => {
660
- it('simple bars still anchor at xScale(0) for a default [0, max] domain', () => {
661
- const spec = makeSimpleBarSpec();
662
- const scales = computeScales(spec, chartArea, spec.data);
663
- const marks = computeBarMarks(spec, scales, chartArea, fullStrategy, CONTAINER_WIDTH);
664
-
665
- const xScale = scales.x!.scale as (v: number) => number;
666
- for (const mark of marks) {
667
- expect(mark.x).toBeCloseTo(xScale(0), 5);
668
- }
669
- });
670
-
671
- it('diverging bars (domain crosses zero) still anchor at xScale(0)', () => {
672
- const spec = makeNegativeBarSpec();
673
- const scales = computeScales(spec, chartArea, spec.data);
674
- const marks = computeBarMarks(spec, scales, chartArea, fullStrategy, CONTAINER_WIDTH);
675
-
676
- const xScale = scales.x!.scale as (v: number) => number;
677
- const zero = xScale(0);
678
-
679
- const growth = marks.find((m) => m.aria.label.includes('Growth'))!;
680
- const decline = marks.find((m) => m.aria.label.includes('Decline'))!;
681
- // Positive bar starts at zero; negative bar ends at zero.
682
- expect(growth.x).toBeCloseTo(zero, 5);
683
- expect(decline.x + decline.width).toBeCloseTo(zero, 5);
684
- });
685
- });
686
-
687
- describe('edge cases', () => {
688
- it('returns empty array when no x encoding', () => {
689
- const spec: NormalizedChartSpec = {
690
- markType: 'bar',
691
- markDef: { type: 'bar' },
692
- data: [{ category: 'A', value: 10 }],
693
- encoding: {
694
- y: { field: 'category', type: 'nominal' },
695
- },
696
- chrome: {},
697
- annotations: [],
698
- responsive: true,
699
- theme: {},
700
- darkMode: 'off',
701
- labels: { density: 'auto', format: '' },
702
- };
703
- const scales = computeScales(spec, chartArea, spec.data);
704
- const marks = computeBarMarks(spec, scales, chartArea, fullStrategy, CONTAINER_WIDTH);
705
- expect(marks).toHaveLength(0);
706
- });
707
-
708
- it('returns empty array for empty data', () => {
709
- const spec: NormalizedChartSpec = {
710
- markType: 'bar',
711
- markDef: { type: 'bar' },
712
- data: [],
713
- encoding: {
714
- x: { field: 'value', type: 'quantitative' },
715
- y: { field: 'category', type: 'nominal' },
716
- },
717
- chrome: {},
718
- annotations: [],
719
- responsive: true,
720
- theme: {},
721
- darkMode: 'off',
722
- labels: { density: 'auto', format: '' },
723
- };
724
- const scales = computeScales(spec, chartArea, spec.data);
725
- const marks = computeBarMarks(spec, scales, chartArea, fullStrategy, CONTAINER_WIDTH);
726
- expect(marks).toHaveLength(0);
727
- });
728
- });
729
- });
730
-
731
- // ---------------------------------------------------------------------------
732
- // computeBarLabels tests
733
- // ---------------------------------------------------------------------------
734
-
735
- describe('computeBarLabels', () => {
736
- it('produces one label per bar mark', () => {
737
- const spec = makeSimpleBarSpec();
738
- const scales = computeScales(spec, chartArea, spec.data);
739
- const marks = computeBarMarks(spec, scales, chartArea, fullStrategy, CONTAINER_WIDTH);
740
- const labels = computeBarLabels(marks, chartArea);
741
-
742
- expect(labels).toHaveLength(marks.length);
743
- });
744
-
745
- it('labels contain the value text', () => {
746
- const spec = makeSimpleBarSpec();
747
- const scales = computeScales(spec, chartArea, spec.data);
748
- const marks = computeBarMarks(spec, scales, chartArea, fullStrategy, CONTAINER_WIDTH);
749
- const labels = computeBarLabels(marks, chartArea);
750
-
751
- const texts = labels.map((l) => l.text);
752
- expect(texts).toContain('50');
753
- expect(texts).toContain('30');
754
- expect(texts).toContain('70');
755
- });
756
-
757
- it('applies d3 label format string', () => {
758
- const spec = makeSimpleBarSpec();
759
- const scales = computeScales(spec, chartArea, spec.data);
760
- const marks = computeBarMarks(spec, scales, chartArea, fullStrategy, CONTAINER_WIDTH);
761
- const labels = computeBarLabels(marks, chartArea, 'auto', buildD3Formatter('$,.0f'));
762
-
763
- const texts = labels.map((l) => l.text);
764
- expect(texts).toContain('$50');
765
- expect(texts).toContain('$30');
766
- expect(texts).toContain('$70');
767
- });
768
-
769
- it('applies format with literal alpha suffix (e.g. "T")', () => {
770
- const spec: NormalizedChartSpec = {
771
- markType: 'bar',
772
- markDef: { type: 'bar' },
773
- data: [
774
- { company: 'Apple', cap: 3.75 },
775
- { company: 'Meta', cap: 1.63 },
776
- ],
777
- encoding: {
778
- x: { field: 'cap', type: 'quantitative' },
779
- y: { field: 'company', type: 'nominal' },
780
- },
781
- chrome: {},
782
- annotations: [],
783
- responsive: true,
784
- theme: {},
785
- darkMode: 'off',
786
- labels: { density: 'all', format: '$,.2~fT' },
787
- };
788
- const scales = computeScales(spec, chartArea, spec.data);
789
- const marks = computeBarMarks(spec, scales, chartArea, fullStrategy, CONTAINER_WIDTH);
790
- const labels = computeBarLabels(marks, chartArea, 'all', buildD3Formatter('$,.2~fT'));
791
-
792
- const texts = labels.map((l) => l.text);
793
- expect(texts).toContain('$3.75T');
794
- expect(texts).toContain('$1.63T');
795
- });
796
-
797
- it('applies format with non-alpha suffix (e.g. "%")', () => {
798
- const spec = makeSimpleBarSpec();
799
- const scales = computeScales(spec, chartArea, spec.data);
800
- const marks = computeBarMarks(spec, scales, chartArea, fullStrategy, CONTAINER_WIDTH);
801
- const labels = computeBarLabels(marks, chartArea, 'auto', buildD3Formatter('.0f%'));
802
-
803
- const texts = labels.map((l) => l.text);
804
- expect(texts).toContain('50%');
805
- expect(texts).toContain('30%');
806
- expect(texts).toContain('70%');
807
- });
808
-
809
- it('applies fixed label color to outside labels', () => {
810
- // Narrow chart area forces bars < 40px, putting labels outside
811
- const smallArea: Rect = { x: 80, y: 20, width: 30, height: 300 };
812
- const spec: NormalizedChartSpec = {
813
- markType: 'bar',
814
- markDef: { type: 'bar', size: 6, cornerRadius: 'pill' },
815
- data: [
816
- { category: 'A', value: 1 },
817
- { category: 'B', value: 2 },
818
- ],
819
- encoding: {
820
- x: { field: 'value', type: 'quantitative' },
821
- y: { field: 'category', type: 'nominal' },
822
- },
823
- chrome: {},
824
- annotations: [],
825
- responsive: true,
826
- theme: {},
827
- darkMode: 'off',
828
- labels: { density: 'auto', format: '', prefix: '' },
829
- };
830
- const scales = computeScales(spec, smallArea, spec.data);
831
- const marks = computeBarMarks(spec, scales, smallArea, fullStrategy, CONTAINER_WIDTH);
832
- const labels = computeBarLabels(
833
- marks,
834
- smallArea,
835
- 'all',
836
- undefined,
837
- undefined,
838
- undefined,
839
- '#a1a1aa',
840
- );
841
-
842
- // Outside labels should use the fixed color; inside labels use contrast-adjusted colors
843
- const outsideLabels = labels.filter((l) => l.style.textAnchor === 'start');
844
- expect(outsideLabels.length).toBeGreaterThan(0);
845
- for (const label of outsideLabels) {
846
- expect(label.style.fill).toBe('#a1a1aa');
847
- }
848
- });
849
- });
850
-
851
- describe('markDef overrides', () => {
852
- it('markDef.size reduces bar height and centers within band', () => {
853
- const spec = makeSimpleBarSpec();
854
- spec.markDef = { type: 'bar', size: 6 };
855
- const scales = computeScales(spec, chartArea, spec.data);
856
- const marks = computeBarMarks(spec, scales, chartArea, fullStrategy, CONTAINER_WIDTH);
857
-
858
- for (const mark of marks) {
859
- expect(mark.height).toBe(6);
860
- }
861
-
862
- // Bars should be centered: offset = (bandwidth - 6) / 2
863
- const specDefault = makeSimpleBarSpec();
864
- const scalesDefault = computeScales(specDefault, chartArea, specDefault.data);
865
- const marksDefault = computeBarMarks(
866
- specDefault,
867
- scalesDefault,
868
- chartArea,
869
- fullStrategy,
870
- CONTAINER_WIDTH,
871
- );
872
- for (let i = 0; i < marks.length; i++) {
873
- expect(marks[i].y).toBeGreaterThan(marksDefault[i].y);
874
- }
875
- });
876
-
877
- it('markDef.size is capped at bandwidth', () => {
878
- const spec = makeSimpleBarSpec();
879
- spec.markDef = { type: 'bar', size: 9999 };
880
- const scales = computeScales(spec, chartArea, spec.data);
881
- const marks = computeBarMarks(spec, scales, chartArea, fullStrategy, CONTAINER_WIDTH);
882
-
883
- const bandwidth = marks[0].height;
884
- // Should be capped at bandwidth, not 9999
885
- expect(bandwidth).toBeLessThan(9999);
886
- });
887
-
888
- it('markDef.cornerRadius "pill" resolves to half the bar height', () => {
889
- const spec = makeSimpleBarSpec();
890
- spec.markDef = { type: 'bar', size: 6, cornerRadius: 'pill' };
891
- const scales = computeScales(spec, chartArea, spec.data);
892
- const marks = computeBarMarks(spec, scales, chartArea, fullStrategy, CONTAINER_WIDTH);
893
-
894
- for (const mark of marks) {
895
- expect(mark.cornerRadius).toBe(3);
896
- }
897
- });
898
-
899
- it('markDef.cornerRadius as number overrides default', () => {
900
- const spec = makeSimpleBarSpec();
901
- spec.markDef = { type: 'bar', cornerRadius: 8 };
902
- const scales = computeScales(spec, chartArea, spec.data);
903
- const marks = computeBarMarks(spec, scales, chartArea, fullStrategy, CONTAINER_WIDTH);
904
-
905
- for (const mark of marks) {
906
- expect(mark.cornerRadius).toBe(8);
907
- }
908
- });
909
-
910
- it('markDef.size is skipped for stacked bars', () => {
911
- const spec = makeGroupedBarSpec();
912
- spec.markDef = { type: 'bar', size: 6 };
913
- // Enable stacking (default for grouped)
914
- const scales = computeScales(spec, chartArea, spec.data);
915
- const marks = computeBarMarks(spec, scales, chartArea, fullStrategy, CONTAINER_WIDTH);
916
-
917
- const stackedMarks = marks.filter((m) => m.stackGroup !== undefined);
918
- for (const mark of stackedMarks) {
919
- expect(mark.height).not.toBe(6);
920
- }
921
- });
922
-
923
- it('combined size + pill uses adjusted size for radius', () => {
924
- const spec = makeSimpleBarSpec();
925
- spec.markDef = { type: 'bar', size: 10, cornerRadius: 'pill' };
926
- const scales = computeScales(spec, chartArea, spec.data);
927
- const marks = computeBarMarks(spec, scales, chartArea, fullStrategy, CONTAINER_WIDTH);
928
-
929
- for (const mark of marks) {
930
- expect(mark.height).toBe(10);
931
- expect(mark.cornerRadius).toBe(5);
932
- }
933
- });
934
- });