@opendata-ai/openchart-engine 7.11.0 → 8.0.0-rc.1

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 (157) hide show
  1. package/dist/index.d.ts +123 -7
  2. package/dist/index.js +16159 -10024
  3. package/dist/index.js.map +1 -1
  4. package/package.json +11 -4
  5. package/src/__tests__/__snapshots__/compile-snapshot.test.ts.snap +170 -56
  6. package/src/__tests__/annotation-behavior.test.ts +721 -0
  7. package/src/__tests__/axes.test.ts +50 -3
  8. package/src/__tests__/compile-chart.test.ts +30 -0
  9. package/src/__tests__/compile-footnotes.test.ts +277 -0
  10. package/src/__tests__/compile-layer.test.ts +120 -1
  11. package/src/__tests__/helpers/invariants.ts +54 -20
  12. package/src/__tests__/known-bugs.test.ts +455 -2
  13. package/src/__tests__/legend.test.ts +69 -4
  14. package/src/__tests__/ranking-recipes.test.ts +260 -0
  15. package/src/__tests__/scales.test.ts +9 -3
  16. package/src/__tests__/series-search.test.ts +92 -0
  17. package/src/__tests__/spec-sugar.test.ts +801 -0
  18. package/src/__tests__/you-draw-it.test.ts +160 -0
  19. package/src/annotations/__tests__/compute.test.ts +207 -92
  20. package/src/annotations/__tests__/geometry.test.ts +66 -0
  21. package/src/annotations/__tests__/resolve-text.test.ts +527 -27
  22. package/src/annotations/__tests__/rich-text.test.ts +91 -0
  23. package/src/annotations/__tests__/thinning.test.ts +148 -0
  24. package/src/annotations/collisions.ts +130 -84
  25. package/src/annotations/compute.ts +83 -85
  26. package/src/annotations/constants.ts +116 -6
  27. package/src/annotations/geometry.ts +222 -77
  28. package/src/annotations/move.ts +135 -0
  29. package/src/annotations/placement.ts +62 -14
  30. package/src/annotations/resolve-range.ts +2 -0
  31. package/src/annotations/resolve-refline.ts +2 -0
  32. package/src/annotations/resolve-text.ts +312 -111
  33. package/src/annotations/rich-text.ts +65 -0
  34. package/src/annotations/thinning.ts +84 -10
  35. package/src/barlist/compile-barlist.ts +16 -6
  36. package/src/charts/_shared/format-label-value.ts +9 -8
  37. package/src/charts/bar/__tests__/compute.test.ts +57 -10
  38. package/src/charts/bar/__tests__/labels.test.ts +5 -4
  39. package/src/charts/bar/compute.ts +2 -6
  40. package/src/charts/bar/index.ts +8 -1
  41. package/src/charts/bar/labels.ts +3 -3
  42. package/src/charts/beeswarm/__tests__/compute.test.ts +280 -0
  43. package/src/charts/beeswarm/__tests__/dodge.test.ts +148 -0
  44. package/src/charts/beeswarm/compute.ts +203 -0
  45. package/src/charts/beeswarm/dodge.ts +155 -0
  46. package/src/charts/beeswarm/index.ts +32 -0
  47. package/src/charts/builtin.ts +12 -1
  48. package/src/charts/calendar/__tests__/compute.test.ts +417 -0
  49. package/src/charts/calendar/compute.ts +319 -0
  50. package/src/charts/calendar/index.ts +32 -0
  51. package/src/charts/column/__tests__/compute.test.ts +21 -7
  52. package/src/charts/column/__tests__/labels.test.ts +76 -1
  53. package/src/charts/column/compute.ts +2 -6
  54. package/src/charts/column/index.ts +10 -2
  55. package/src/charts/column/labels.ts +95 -21
  56. package/src/charts/dot/index.ts +8 -1
  57. package/src/charts/dot/labels.ts +3 -3
  58. package/src/charts/line/__tests__/compute.test.ts +15 -19
  59. package/src/charts/line/area.ts +8 -23
  60. package/src/charts/line/compute.ts +32 -2
  61. package/src/charts/parliament/__tests__/compute.test.ts +411 -0
  62. package/src/charts/parliament/compute.ts +313 -0
  63. package/src/charts/parliament/index.ts +32 -0
  64. package/src/charts/pie/__tests__/compute.test.ts +103 -0
  65. package/src/charts/pie/compute.ts +63 -8
  66. package/src/charts/pie/labels.ts +1 -0
  67. package/src/charts/range/__tests__/compute.test.ts +486 -0
  68. package/src/charts/range/compute.ts +273 -0
  69. package/src/charts/range/index.ts +82 -0
  70. package/src/charts/range/labels.ts +169 -0
  71. package/src/charts/rect/__tests__/compute.test.ts +120 -0
  72. package/src/charts/rect/index.ts +127 -0
  73. package/src/charts/scatter/compute.ts +7 -21
  74. package/src/charts/text/__tests__/compute.test.ts +307 -0
  75. package/src/charts/text/index.ts +44 -7
  76. package/src/charts/tick/__tests__/compute.test.ts +75 -0
  77. package/src/charts/tick/index.ts +7 -4
  78. package/src/charts/utils.ts +5 -3
  79. package/src/charts/waffle/__tests__/compute.test.ts +305 -0
  80. package/src/charts/waffle/compute.ts +197 -0
  81. package/src/charts/waffle/index.ts +31 -0
  82. package/src/compile/__tests__/color-scale-reverse.test.ts +89 -0
  83. package/src/compile/__tests__/data-clip.test.ts +68 -0
  84. package/src/compile/__tests__/fill-patterns.test.ts +154 -0
  85. package/src/compile/__tests__/size-scale.test.ts +108 -0
  86. package/src/compile/color-scale-range.ts +54 -33
  87. package/src/compile/data-clip.ts +21 -6
  88. package/src/compile/fill-patterns.ts +74 -0
  89. package/src/compile/layer.ts +117 -21
  90. package/src/compile/size-scale.ts +139 -0
  91. package/src/compile/spec-sugar.ts +758 -0
  92. package/src/compile/you-draw-it.ts +123 -0
  93. package/src/compile.ts +310 -103
  94. package/src/compiler/__tests__/a11y-warnings.test.ts +148 -0
  95. package/src/compiler/__tests__/fixtures/vl-parity/axis-null.json +19 -0
  96. package/src/compiler/__tests__/fixtures/vl-parity/color-value.json +20 -0
  97. package/src/compiler/__tests__/fixtures/vl-parity/count-aggregate.json +22 -0
  98. package/src/compiler/__tests__/fixtures/vl-parity/data-values.json +19 -0
  99. package/src/compiler/__tests__/fixtures/vl-parity/layer-axis-null.json +14 -0
  100. package/src/compiler/__tests__/fixtures/vl-parity/layer-count.json +34 -0
  101. package/src/compiler/__tests__/fixtures/vl-parity/layer-data-values.json +22 -0
  102. package/src/compiler/__tests__/fixtures/vl-parity/layer-legend-null.json +41 -0
  103. package/src/compiler/__tests__/fixtures/vl-parity/layer-scheme.json +38 -0
  104. package/src/compiler/__tests__/fixtures/vl-parity/layer-sort.json +28 -0
  105. package/src/compiler/__tests__/fixtures/vl-parity/layer-theta.json +28 -0
  106. package/src/compiler/__tests__/fixtures/vl-parity/layer-title.json +30 -0
  107. package/src/compiler/__tests__/fixtures/vl-parity/layer-value-def.json +29 -0
  108. package/src/compiler/__tests__/fixtures/vl-parity/legend-null.json +32 -0
  109. package/src/compiler/__tests__/fixtures/vl-parity/scheme.json +25 -0
  110. package/src/compiler/__tests__/fixtures/vl-parity/sort-by-value.json +19 -0
  111. package/src/compiler/__tests__/fixtures/vl-parity/theta-arc.json +19 -0
  112. package/src/compiler/__tests__/fixtures/vl-parity/title-subtitle.json +21 -0
  113. package/src/compiler/__tests__/fixtures/vl-parity/width-height.json +23 -0
  114. package/src/compiler/__tests__/normalize.test.ts +65 -1
  115. package/src/compiler/__tests__/validate-did-you-mean.test.ts +86 -0
  116. package/src/compiler/__tests__/validate-near-miss.test.ts +89 -0
  117. package/src/compiler/__tests__/validate.test.ts +156 -0
  118. package/src/compiler/__tests__/vl-parity.test.ts +68 -0
  119. package/src/compiler/a11y-warnings.ts +110 -0
  120. package/src/compiler/normalize.ts +150 -7
  121. package/src/compiler/types.ts +11 -0
  122. package/src/compiler/validate.ts +664 -18
  123. package/src/endpoint-labels/__tests__/compute.test.ts +88 -0
  124. package/src/endpoint-labels/compute.ts +40 -12
  125. package/src/endpoint-labels/format.ts +45 -15
  126. package/src/endpoint-labels/predict.ts +50 -36
  127. package/src/format/__tests__/field-format.test.ts +164 -0
  128. package/src/format/field-format.ts +27 -0
  129. package/src/geo/__tests__/compile-map.test.ts +621 -0
  130. package/src/geo/compile-map.ts +856 -0
  131. package/src/geo/join.ts +115 -0
  132. package/src/geo/projections.ts +45 -0
  133. package/src/geo/types.ts +24 -0
  134. package/src/graphs/__tests__/compile-graph.test.ts +25 -0
  135. package/src/graphs/compile-graph.ts +27 -18
  136. package/src/index.ts +20 -2
  137. package/src/layout/axes/ticks.ts +59 -10
  138. package/src/layout/axes.ts +30 -2
  139. package/src/layout/dimensions.ts +139 -83
  140. package/src/layout/facet.ts +14 -3
  141. package/src/layout/plan.ts +83 -33
  142. package/src/layout/scales.ts +132 -15
  143. package/src/legend/__tests__/continuous.test.ts +318 -0
  144. package/src/legend/__tests__/size.test.ts +348 -0
  145. package/src/legend/__tests__/suppression.test.ts +17 -1
  146. package/src/legend/compute.ts +119 -13
  147. package/src/legend/continuous.ts +327 -0
  148. package/src/legend/size.ts +407 -0
  149. package/src/legend/suppression.ts +8 -7
  150. package/src/sankey/compile-sankey.ts +31 -19
  151. package/src/tables/format-cells.ts +16 -7
  152. package/src/tilemap/__tests__/compile-tilemap.test.ts +39 -0
  153. package/src/tilemap/compile-tilemap.ts +24 -7
  154. package/src/tooltips/__tests__/compute.test.ts +2 -2
  155. package/src/tooltips/compute.ts +422 -41
  156. package/src/transforms/__tests__/conditional.test.ts +18 -0
  157. package/src/transforms/aggregate.ts +2 -1
@@ -0,0 +1,260 @@
1
+ /**
2
+ * Integration tests for the slope + bump chart recipes (plan 14).
3
+ *
4
+ * These are the exact specs published on docs/ranking-and-change.md and
5
+ * pinned as visual fixtures in examples/src/testing/fixtures-ranking.stories.tsx.
6
+ * They use only public spec surface: if a test here needs a private flag to
7
+ * pass, the recipe is broken.
8
+ */
9
+
10
+ import type { ChartSpec, LineMark, PointMark } from '@opendata-ai/openchart-core';
11
+ import { describe, expect, it } from 'vitest';
12
+
13
+ import { compileChart } from '../compile';
14
+
15
+ // ---------------------------------------------------------------------------
16
+ // Recipe specs (keep byte-identical to docs/ranking-and-change.md)
17
+ // ---------------------------------------------------------------------------
18
+
19
+ const slopeMarketShareSpec: ChartSpec = {
20
+ mark: 'line',
21
+ data: [
22
+ { year: '2019', brand: 'Samsung', share: 0.216 },
23
+ { year: '2024', brand: 'Samsung', share: 0.19 },
24
+ { year: '2019', brand: 'Huawei', share: 0.176 },
25
+ { year: '2024', brand: 'Huawei', share: 0.043 },
26
+ { year: '2019', brand: 'Apple', share: 0.139 },
27
+ { year: '2024', brand: 'Apple', share: 0.187 },
28
+ { year: '2019', brand: 'Xiaomi', share: 0.092 },
29
+ { year: '2024', brand: 'Xiaomi', share: 0.136 },
30
+ { year: '2019', brand: 'Oppo', share: 0.083 },
31
+ { year: '2024', brand: 'Oppo', share: 0.087 },
32
+ ],
33
+ encoding: {
34
+ x: { field: 'year', type: 'ordinal' },
35
+ y: {
36
+ field: 'share',
37
+ type: 'quantitative',
38
+ axis: false,
39
+ scale: { zero: false },
40
+ },
41
+ color: { field: 'brand', type: 'nominal' },
42
+ },
43
+ endpointLabels: {
44
+ ends: 'both',
45
+ content: 'label value',
46
+ format: '.0%',
47
+ },
48
+ legend: { show: false },
49
+ chrome: {
50
+ title: 'Apple and Xiaomi Split What Huawei Lost',
51
+ subtitle: 'Share of global smartphone shipments, 2019 vs 2024',
52
+ source: 'Source: IDC Worldwide Quarterly Mobile Phone Tracker',
53
+ },
54
+ };
55
+
56
+ const bumpConstructorsSpec: ChartSpec = {
57
+ mark: { type: 'line', interpolate: 'monotone', point: true },
58
+ data: [
59
+ { season: '2019', team: 'Mercedes', position: 1 },
60
+ { season: '2020', team: 'Mercedes', position: 1 },
61
+ { season: '2021', team: 'Mercedes', position: 1 },
62
+ { season: '2022', team: 'Mercedes', position: 3 },
63
+ { season: '2023', team: 'Mercedes', position: 2 },
64
+ { season: '2024', team: 'Mercedes', position: 4 },
65
+ { season: '2019', team: 'Red Bull', position: 3 },
66
+ { season: '2020', team: 'Red Bull', position: 2 },
67
+ { season: '2021', team: 'Red Bull', position: 2 },
68
+ { season: '2022', team: 'Red Bull', position: 1 },
69
+ { season: '2023', team: 'Red Bull', position: 1 },
70
+ { season: '2024', team: 'Red Bull', position: 3 },
71
+ { season: '2019', team: 'Ferrari', position: 2 },
72
+ { season: '2020', team: 'Ferrari', position: 6 },
73
+ { season: '2021', team: 'Ferrari', position: 3 },
74
+ { season: '2022', team: 'Ferrari', position: 2 },
75
+ { season: '2023', team: 'Ferrari', position: 3 },
76
+ { season: '2024', team: 'Ferrari', position: 2 },
77
+ { season: '2019', team: 'McLaren', position: 4 },
78
+ { season: '2020', team: 'McLaren', position: 3 },
79
+ { season: '2021', team: 'McLaren', position: 4 },
80
+ { season: '2022', team: 'McLaren', position: 5 },
81
+ { season: '2023', team: 'McLaren', position: 4 },
82
+ { season: '2024', team: 'McLaren', position: 1 },
83
+ { season: '2019', team: 'Alpine', position: 5 },
84
+ { season: '2020', team: 'Alpine', position: 5 },
85
+ { season: '2021', team: 'Alpine', position: 5 },
86
+ { season: '2022', team: 'Alpine', position: 4 },
87
+ { season: '2023', team: 'Alpine', position: 6 },
88
+ { season: '2024', team: 'Alpine', position: 6 },
89
+ ],
90
+ encoding: {
91
+ x: { field: 'season', type: 'ordinal' },
92
+ y: {
93
+ field: 'position',
94
+ type: 'quantitative',
95
+ scale: { reverse: true, zero: false },
96
+ axis: { values: [1, 2, 3, 4, 5, 6], format: 'ordinal' },
97
+ },
98
+ color: { field: 'team', type: 'nominal' },
99
+ },
100
+ endpointLabels: {
101
+ ends: 'both',
102
+ content: 'label',
103
+ showMarker: false,
104
+ },
105
+ legend: { show: false },
106
+ chrome: {
107
+ title: 'McLaren Went From Midfield to Champions',
108
+ subtitle: 'Formula 1 constructors championship, final position by season',
109
+ source: 'Source: FIA official standings',
110
+ },
111
+ };
112
+
113
+ // ---------------------------------------------------------------------------
114
+ // Slope recipe
115
+ // ---------------------------------------------------------------------------
116
+
117
+ describe('slope chart recipe', () => {
118
+ it('renders both-end labels for all five series', () => {
119
+ const layout = compileChart(slopeMarketShareSpec, { width: 640, height: 420 });
120
+
121
+ expect(layout.endpointLabels?.entries).toHaveLength(5);
122
+ expect(layout.endpointLabels?.leading).toHaveLength(5);
123
+ });
124
+
125
+ it("composes 'name value' on one line at both ends", () => {
126
+ const layout = compileChart(slopeMarketShareSpec, { width: 640, height: 420 });
127
+
128
+ const left = layout.endpointLabels!.leading!.find((e) => e.seriesKey === 'Samsung');
129
+ expect(left!.labelLines[0]).toBe('Samsung 22%');
130
+ expect(left!.value).toBe('');
131
+
132
+ const right = layout.endpointLabels!.entries.find((e) => e.seriesKey === 'Samsung');
133
+ expect(right!.labelLines[0]).toBe('Samsung 19%');
134
+ expect(right!.value).toBe('');
135
+ });
136
+
137
+ it('renders no y axis and no gridlines', () => {
138
+ const layout = compileChart(slopeMarketShareSpec, { width: 640, height: 420 });
139
+
140
+ expect(layout.axes.y).toBeUndefined();
141
+ });
142
+
143
+ it('orders labels by value at each end (Huawei falls from 2nd to last)', () => {
144
+ const layout = compileChart(slopeMarketShareSpec, { width: 640, height: 420 });
145
+
146
+ const byY = (a: { labelY: number }, b: { labelY: number }) => a.labelY - b.labelY;
147
+ const leftOrder = [...layout.endpointLabels!.leading!].sort(byY).map((e) => e.seriesKey);
148
+ expect(leftOrder).toEqual(['Samsung', 'Huawei', 'Apple', 'Xiaomi', 'Oppo']);
149
+
150
+ const rightOrder = [...layout.endpointLabels!.entries].sort(byY).map((e) => e.seriesKey);
151
+ expect(rightOrder).toEqual(['Samsung', 'Apple', 'Xiaomi', 'Oppo', 'Huawei']);
152
+ });
153
+
154
+ it('keeps both-end labels and a positive plot area at 320px (compact breakpoint)', () => {
155
+ const layout = compileChart(slopeMarketShareSpec, { width: 320, height: 360 });
156
+
157
+ expect(layout.endpointLabels?.entries).toHaveLength(5);
158
+ expect(layout.endpointLabels?.leading).toHaveLength(5);
159
+ expect(layout.area.width).toBeGreaterThan(0);
160
+ // Both label columns fit inside the 320px canvas.
161
+ expect(layout.endpointLabels!.leadingBounds!.x).toBeGreaterThanOrEqual(0);
162
+ expect(
163
+ layout.endpointLabels!.bounds.x + layout.endpointLabels!.bounds.width,
164
+ ).toBeLessThanOrEqual(320);
165
+ });
166
+ });
167
+
168
+ // ---------------------------------------------------------------------------
169
+ // Bump recipe
170
+ // ---------------------------------------------------------------------------
171
+
172
+ describe('bump chart recipe', () => {
173
+ it('puts rank 1 at the top via scale.reverse', () => {
174
+ const layout = compileChart(bumpConstructorsSpec, { width: 640, height: 460 });
175
+
176
+ const ticks = layout.axes.y!.ticks;
177
+ const first = ticks.find((t) => t.value === 1);
178
+ const last = ticks.find((t) => t.value === 6);
179
+ expect(first).toBeDefined();
180
+ expect(last).toBeDefined();
181
+ expect(first!.position).toBeLessThan(last!.position);
182
+ });
183
+
184
+ it('labels rank ticks as ordinals, 1st through 6th top to bottom', () => {
185
+ const layout = compileChart(bumpConstructorsSpec, { width: 640, height: 460 });
186
+
187
+ const labels = [...layout.axes.y!.ticks]
188
+ .sort((a, b) => a.position - b.position)
189
+ .map((t) => t.label);
190
+ expect(labels).toEqual(['1st', '2nd', '3rd', '4th', '5th', '6th']);
191
+ });
192
+
193
+ it('draws a point marker at every step of every series', () => {
194
+ const layout = compileChart(bumpConstructorsSpec, { width: 640, height: 460 });
195
+
196
+ const points = layout.marks.filter(
197
+ (m): m is PointMark => m.type === 'point' && m.opacity !== 0,
198
+ );
199
+ // 5 teams x 6 seasons.
200
+ expect(points).toHaveLength(30);
201
+ });
202
+
203
+ it('orders both-end name labels by rank at each end', () => {
204
+ const layout = compileChart(bumpConstructorsSpec, { width: 640, height: 460 });
205
+
206
+ const byY = (a: { labelY: number }, b: { labelY: number }) => a.labelY - b.labelY;
207
+ // 2019: Mercedes 1st, Ferrari 2nd, Red Bull 3rd, McLaren 4th, Alpine 5th.
208
+ const leftOrder = [...layout.endpointLabels!.leading!].sort(byY).map((e) => e.seriesKey);
209
+ expect(leftOrder).toEqual(['Mercedes', 'Ferrari', 'Red Bull', 'McLaren', 'Alpine']);
210
+ // 2024: McLaren 1st, Ferrari 2nd, Red Bull 3rd, Mercedes 4th, Alpine 6th.
211
+ const rightOrder = [...layout.endpointLabels!.entries].sort(byY).map((e) => e.seriesKey);
212
+ expect(rightOrder).toEqual(['McLaren', 'Ferrari', 'Red Bull', 'Mercedes', 'Alpine']);
213
+ });
214
+
215
+ it('renders name-only labels (no value line)', () => {
216
+ const layout = compileChart(bumpConstructorsSpec, { width: 640, height: 460 });
217
+
218
+ for (const entry of layout.endpointLabels!.entries) {
219
+ expect(entry.labelLines).toEqual([entry.seriesKey]);
220
+ expect(entry.value).toBe('');
221
+ }
222
+ });
223
+
224
+ it('maps tied ranks to the same y (ties share a row)', () => {
225
+ const tieSpec: ChartSpec = {
226
+ ...bumpConstructorsSpec,
227
+ data: [
228
+ { season: '2023', team: 'A', position: 1 },
229
+ { season: '2024', team: 'A', position: 2 },
230
+ { season: '2023', team: 'B', position: 2 },
231
+ { season: '2024', team: 'B', position: 2 },
232
+ { season: '2023', team: 'C', position: 3 },
233
+ { season: '2024', team: 'C', position: 1 },
234
+ ],
235
+ encoding: {
236
+ ...bumpConstructorsSpec.encoding,
237
+ y: {
238
+ field: 'position',
239
+ type: 'quantitative',
240
+ scale: { reverse: true, zero: false },
241
+ axis: { values: [1, 2, 3], format: 'ordinal' },
242
+ },
243
+ },
244
+ };
245
+ const layout = compileChart(tieSpec, { width: 640, height: 400 });
246
+
247
+ const lines = layout.marks.filter(
248
+ (m): m is LineMark => m.type === 'line' && m.seriesKey != null,
249
+ );
250
+ const lastY = (key: string) => {
251
+ const mark = lines.find((m) => m.seriesKey === key)!;
252
+ const pts = mark.dataPoints ?? [];
253
+ return pts[pts.length - 1]!.y;
254
+ };
255
+ // A and B tie for 2nd in 2024: identical y.
256
+ expect(lastY('A')).toBeCloseTo(lastY('B'), 6);
257
+ // C is 1st, above the tied pair (smaller y = higher rank).
258
+ expect(lastY('C')).toBeLessThan(lastY('A'));
259
+ });
260
+ });
@@ -62,15 +62,21 @@ describe('computeScales', () => {
62
62
  expect(scales.y).toBeDefined();
63
63
  expect(scales.y!.type).toBe('linear');
64
64
 
65
- // Y is inverted (SVG coordinates)
65
+ // Y is inverted (SVG coordinates); line charts default to zero: false
66
66
  const domain = scales.y!.scale.domain();
67
- expect(domain[0]).toBeLessThanOrEqual(0); // Should include 0
67
+ expect(domain[0]).toBeLessThanOrEqual(10);
68
68
  expect(domain[1]).toBeGreaterThanOrEqual(50);
69
69
  });
70
70
 
71
- it('includes zero in quantitative domain by default', () => {
71
+ it('line charts exclude zero by default (zero: false)', () => {
72
72
  const scales = computeScales(lineSpec, chartArea, lineSpec.data);
73
73
  const domain = scales.y!.scale.domain();
74
+ expect(domain[0]).toBeGreaterThan(0);
75
+ });
76
+
77
+ it('bar charts include zero by default', () => {
78
+ const scales = computeScales(barSpec, chartArea, barSpec.data);
79
+ const domain = scales.x!.scale.domain();
74
80
  expect(domain[0]).toBe(0);
75
81
  });
76
82
 
@@ -0,0 +1,92 @@
1
+ /**
2
+ * seriesSearch compilation: band reservation and resolved content.
3
+ *
4
+ * The engine reserves an empty SVG band below chrome (and the metric bar)
5
+ * and exposes its rect + the searchable values on layout.seriesSearch; the
6
+ * vanilla adapter overlays the DOM combobox on that rect.
7
+ */
8
+
9
+ import { describe, expect, it, vi } from 'vitest';
10
+ import { compileChart } from '../compile';
11
+
12
+ const searchSpec = {
13
+ mark: 'line' as const,
14
+ data: [
15
+ { date: '2020-01-01', value: 10, country: 'US' },
16
+ { date: '2021-01-01', value: 40, country: 'US' },
17
+ { date: '2020-01-01', value: 15, country: 'UK' },
18
+ { date: '2021-01-01', value: 35, country: 'UK' },
19
+ { date: '2020-01-01', value: 22, country: 'France' },
20
+ { date: '2021-01-01', value: 28, country: 'France' },
21
+ ],
22
+ encoding: {
23
+ x: { field: 'date', type: 'temporal' as const },
24
+ y: { field: 'value', type: 'quantitative' as const },
25
+ color: { field: 'country', type: 'nominal' as const },
26
+ },
27
+ chrome: { title: 'Find your country' },
28
+ };
29
+
30
+ describe('compileChart seriesSearch', () => {
31
+ it('omits the band when seriesSearch is not set', () => {
32
+ const layout = compileChart(searchSpec, { width: 600, height: 400 });
33
+ expect(layout.seriesSearch).toBeUndefined();
34
+ });
35
+
36
+ it('reserves a band below chrome and shifts the chart area down', () => {
37
+ const base = compileChart(searchSpec, { width: 600, height: 400 });
38
+ const layout = compileChart({ ...searchSpec, seriesSearch: true }, { width: 600, height: 400 });
39
+
40
+ const band = layout.seriesSearch;
41
+ expect(band).toBeDefined();
42
+ expect(band!.height).toBeGreaterThan(0);
43
+ expect(band!.width).toBeGreaterThan(0);
44
+ // Band sits below the top chrome block and above the chart area
45
+ expect(band!.y).toBeGreaterThanOrEqual(layout.chrome.topHeight);
46
+ expect(band!.y + band!.height).toBeLessThanOrEqual(layout.area.y);
47
+ // The reservation pushes the plot down relative to the no-search layout
48
+ expect(layout.area.y).toBeGreaterThan(base.area.y);
49
+ });
50
+
51
+ it('exposes the distinct color values and resolved placeholder', () => {
52
+ const layout = compileChart(
53
+ { ...searchSpec, seriesSearch: { placeholder: 'Find a country' } },
54
+ { width: 600, height: 400 },
55
+ );
56
+ expect(layout.seriesSearch?.values).toEqual(['US', 'UK', 'France']);
57
+ expect(layout.seriesSearch?.placeholder).toBe('Find a country');
58
+
59
+ const defaulted = compileChart(
60
+ { ...searchSpec, seriesSearch: true },
61
+ { width: 600, height: 400 },
62
+ );
63
+ expect(defaulted.seriesSearch?.placeholder).toBe('Find a series');
64
+ });
65
+
66
+ it('warns and disables when there is no categorical color encoding', () => {
67
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
68
+ try {
69
+ const layout = compileChart(
70
+ {
71
+ mark: 'line' as const,
72
+ data: [
73
+ { date: '2020-01-01', value: 10 },
74
+ { date: '2021-01-01', value: 40 },
75
+ ],
76
+ encoding: {
77
+ x: { field: 'date', type: 'temporal' as const },
78
+ y: { field: 'value', type: 'quantitative' as const },
79
+ },
80
+ seriesSearch: true,
81
+ },
82
+ { width: 600, height: 400 },
83
+ );
84
+ expect(layout.seriesSearch).toBeUndefined();
85
+ expect(warn).toHaveBeenCalledWith(
86
+ expect.stringContaining('seriesSearch requires a categorical color encoding'),
87
+ );
88
+ } finally {
89
+ warn.mockRestore();
90
+ }
91
+ });
92
+ });