@opendata-ai/openchart-engine 6.11.0 → 6.13.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 (45) hide show
  1. package/dist/index.d.ts +7 -0
  2. package/dist/index.js +944 -629
  3. package/dist/index.js.map +1 -1
  4. package/package.json +2 -2
  5. package/src/__test-fixtures__/specs.ts +3 -0
  6. package/src/__tests__/axes.test.ts +12 -30
  7. package/src/__tests__/compile-chart.test.ts +4 -4
  8. package/src/__tests__/dimensions.test.ts +2 -2
  9. package/src/__tests__/encoding-sugar.test.ts +389 -0
  10. package/src/annotations/collisions.ts +268 -0
  11. package/src/annotations/compute.ts +9 -912
  12. package/src/annotations/constants.ts +32 -0
  13. package/src/annotations/geometry.ts +167 -0
  14. package/src/annotations/position.ts +95 -0
  15. package/src/annotations/resolve-range.ts +98 -0
  16. package/src/annotations/resolve-refline.ts +148 -0
  17. package/src/annotations/resolve-text.ts +134 -0
  18. package/src/charts/__tests__/post-process.test.ts +258 -0
  19. package/src/charts/bar/__tests__/labels.test.ts +31 -0
  20. package/src/charts/bar/compute.ts +27 -6
  21. package/src/charts/bar/labels.ts +7 -1
  22. package/src/charts/column/__tests__/compute.test.ts +99 -0
  23. package/src/charts/column/compute.ts +27 -6
  24. package/src/charts/line/area.ts +19 -2
  25. package/src/charts/post-process.ts +215 -0
  26. package/src/compile.ts +113 -169
  27. package/src/compiler/__tests__/normalize.test.ts +110 -0
  28. package/src/compiler/normalize.ts +22 -3
  29. package/src/compiler/types.ts +4 -0
  30. package/src/graphs/compile-graph.ts +8 -0
  31. package/src/graphs/types.ts +2 -0
  32. package/src/layout/axes.ts +10 -13
  33. package/src/layout/dimensions.ts +6 -3
  34. package/src/layout/scales.ts +106 -29
  35. package/src/legend/compute.ts +3 -1
  36. package/src/sankey/compile-sankey.ts +12 -2
  37. package/src/sankey/types.ts +1 -0
  38. package/src/tables/compile-table.ts +5 -0
  39. package/src/tooltips/__tests__/compute.test.ts +188 -0
  40. package/src/tooltips/compute.ts +25 -11
  41. package/src/transforms/__tests__/aggregate.test.ts +159 -0
  42. package/src/transforms/__tests__/fold.test.ts +79 -0
  43. package/src/transforms/aggregate.ts +130 -0
  44. package/src/transforms/fold.ts +49 -0
  45. package/src/transforms/index.ts +8 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opendata-ai/openchart-engine",
3
- "version": "6.11.0",
3
+ "version": "6.13.0",
4
4
  "description": "Headless compiler for openchart: spec validation, data compilation, scales, and layout",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Riley Hilliard",
@@ -45,7 +45,7 @@
45
45
  "typecheck": "tsc --noEmit"
46
46
  },
47
47
  "dependencies": {
48
- "@opendata-ai/openchart-core": "6.11.0",
48
+ "@opendata-ai/openchart-core": "6.13.0",
49
49
  "d3-array": "^3.2.0",
50
50
  "d3-format": "^3.1.2",
51
51
  "d3-interpolate": "^3.0.0",
@@ -73,6 +73,7 @@ export function makeLineSpec(): NormalizedChartSpec {
73
73
  labels: { density: 'auto', format: '', prefix: '' },
74
74
  hiddenSeries: [],
75
75
  seriesStyles: {},
76
+ watermark: true,
76
77
  };
77
78
  }
78
79
 
@@ -102,6 +103,7 @@ export function makeBarSpec(): NormalizedChartSpec {
102
103
  labels: { density: 'auto', format: '', prefix: '' },
103
104
  hiddenSeries: [],
104
105
  seriesStyles: {},
106
+ watermark: true,
105
107
  };
106
108
  }
107
109
 
@@ -133,5 +135,6 @@ export function makeScatterSpec(): NormalizedChartSpec {
133
135
  labels: { density: 'auto', format: '', prefix: '' },
134
136
  hiddenSeries: [],
135
137
  seriesStyles: {},
138
+ watermark: true,
136
139
  };
137
140
  }
@@ -203,10 +203,10 @@ describe('computeAxes', () => {
203
203
  });
204
204
 
205
205
  // -------------------------------------------------------------------------
206
- // tickAngle propagation
206
+ // labelAngle propagation
207
207
  // -------------------------------------------------------------------------
208
208
 
209
- it('propagates tickAngle from encoding to x-axis layout', () => {
209
+ it('propagates labelAngle from encoding to x-axis layout', () => {
210
210
  const specWithAngle: NormalizedChartSpec = {
211
211
  ...lineSpec,
212
212
  markType: 'bar',
@@ -216,7 +216,7 @@ describe('computeAxes', () => {
216
216
  { cat: 'New York', val: 20 },
217
217
  ],
218
218
  encoding: {
219
- x: { field: 'cat', type: 'nominal', axis: { tickAngle: -90 } },
219
+ x: { field: 'cat', type: 'nominal', axis: { labelAngle: -90 } },
220
220
  y: { field: 'val', type: 'quantitative' },
221
221
  },
222
222
  };
@@ -234,12 +234,12 @@ describe('computeAxes', () => {
234
234
  expect(axes.y!.tickAngle).toBeUndefined();
235
235
  });
236
236
 
237
- it('propagates tickAngle to y-axis layout', () => {
237
+ it('propagates labelAngle to y-axis layout', () => {
238
238
  const specWithAngle: NormalizedChartSpec = {
239
239
  ...lineSpec,
240
240
  encoding: {
241
241
  x: { field: 'date', type: 'temporal' },
242
- y: { field: 'value', type: 'quantitative', axis: { tickAngle: -45 } },
242
+ y: { field: 'value', type: 'quantitative', axis: { labelAngle: -45 } },
243
243
  },
244
244
  };
245
245
  const scales = computeScales(specWithAngle, chartArea, specWithAngle.data);
@@ -458,19 +458,16 @@ describe('text-aware tick density', () => {
458
458
  expect(axes.x!.ticks.length).toBe(categories.length);
459
459
  });
460
460
 
461
- it('gridlines survive tick thinning', () => {
461
+ it('y-axis gridlines match ticks so every gridline has a label', () => {
462
462
  // Force thinning by using a measureText that reports wide labels
463
463
  const wideMeasure = () => ({ width: 200, height: 12 });
464
464
  const scales = computeScales(lineSpec, chartArea, lineSpec.data);
465
465
  const axes = computeAxes(scales, chartArea, fullStrategy, theme, wideMeasure);
466
466
 
467
- // Ticks should be thinned (fewer labels) but gridlines should remain at
468
- // all original tick positions
469
- expect(axes.y!.gridlines.length).toBeGreaterThanOrEqual(axes.y!.ticks.length);
470
- // With wide labels forcing thinning, gridlines should outnumber ticks
471
- if (axes.y!.ticks.length < axes.y!.gridlines.length) {
472
- // Gridlines retained positions that ticks lost — the fix is working
473
- expect(axes.y!.gridlines.length).toBeGreaterThan(axes.y!.ticks.length);
467
+ // Y-axis gridlines should always match ticks 1:1 (every gridline gets a label)
468
+ expect(axes.y!.gridlines.length).toBe(axes.y!.ticks.length);
469
+ for (let i = 0; i < axes.y!.ticks.length; i++) {
470
+ expect(axes.y!.gridlines[i].position).toBe(axes.y!.ticks[i].position);
474
471
  }
475
472
  });
476
473
 
@@ -534,28 +531,14 @@ describe('axis config properties', () => {
534
531
  expect(axes.y!.label).toBe('Amount ($)');
535
532
  });
536
533
 
537
- it('falls back to deprecated label when title is not set', () => {
538
- const spec: NormalizedChartSpec = {
539
- ...lineSpec,
540
- encoding: {
541
- x: { field: 'date', type: 'temporal', axis: { label: 'Old Label' } },
542
- y: { field: 'value', type: 'quantitative' },
543
- },
544
- };
545
- const scales = computeScales(spec, chartArea, spec.data);
546
- const axes = computeAxes(scales, chartArea, fullStrategy, theme);
547
-
548
- expect(axes.x!.label).toBe('Old Label');
549
- });
550
-
551
- it('prefers labelAngle over deprecated tickAngle', () => {
534
+ it('propagates labelAngle to layout tickAngle', () => {
552
535
  const spec: NormalizedChartSpec = {
553
536
  ...lineSpec,
554
537
  encoding: {
555
538
  x: {
556
539
  field: 'date',
557
540
  type: 'temporal',
558
- axis: { labelAngle: -30, tickAngle: -90 },
541
+ axis: { labelAngle: -30 },
559
542
  },
560
543
  y: { field: 'value', type: 'quantitative' },
561
544
  },
@@ -563,7 +546,6 @@ describe('axis config properties', () => {
563
546
  const scales = computeScales(spec, chartArea, spec.data);
564
547
  const axes = computeAxes(scales, chartArea, fullStrategy, theme);
565
548
 
566
- // labelAngle takes precedence
567
549
  expect(axes.x!.tickAngle).toBe(-30);
568
550
  });
569
551
 
@@ -469,7 +469,7 @@ describe('compileGraph', () => {
469
469
  ).toThrow('compileGraph received a non-graph spec');
470
470
  });
471
471
 
472
- it('propagates tickAngle through the full compilation pipeline', () => {
472
+ it('propagates labelAngle through the full compilation pipeline', () => {
473
473
  const columnSpec = {
474
474
  mark: 'bar' as const,
475
475
  data: [
@@ -480,14 +480,14 @@ describe('compileGraph', () => {
480
480
  { state: 'Pennsylvania', pop: 13000000 },
481
481
  ],
482
482
  encoding: {
483
- x: { field: 'state', type: 'nominal' as const, axis: { tickAngle: -90 } },
483
+ x: { field: 'state', type: 'nominal' as const, axis: { labelAngle: -90 } },
484
484
  y: { field: 'pop', type: 'quantitative' as const },
485
485
  },
486
486
  };
487
487
 
488
488
  const layout = compileChart(columnSpec, { width: 400, height: 300 });
489
489
 
490
- // tickAngle should be propagated to the x-axis layout
490
+ // labelAngle should be propagated to the x-axis layout as tickAngle
491
491
  expect(layout.axes.x!.tickAngle).toBe(-90);
492
492
  // y-axis should not have a tickAngle
493
493
  expect(layout.axes.y!.tickAngle).toBeUndefined();
@@ -509,7 +509,7 @@ describe('compileGraph', () => {
509
509
  const rotatedColumnSpec = {
510
510
  ...baseColumnSpec,
511
511
  encoding: {
512
- x: { field: 'state', type: 'nominal' as const, axis: { tickAngle: -90 } },
512
+ x: { field: 'state', type: 'nominal' as const, axis: { labelAngle: -90 } },
513
513
  y: { field: 'pop', type: 'quantitative' as const },
514
514
  },
515
515
  };
@@ -161,7 +161,7 @@ describe('computeDimensions', () => {
161
161
  { category: 'Massachusetts', value: 15 },
162
162
  ],
163
163
  encoding: {
164
- x: { field: 'category', type: 'nominal', axis: { tickAngle: -90 } },
164
+ x: { field: 'category', type: 'nominal', axis: { labelAngle: -90 } },
165
165
  y: { field: 'value', type: 'quantitative' },
166
166
  },
167
167
  };
@@ -198,7 +198,7 @@ describe('computeDimensions', () => {
198
198
  const smallAngleSpec: NormalizedChartSpec = {
199
199
  ...baseSpec,
200
200
  encoding: {
201
- x: { field: 'date', type: 'temporal', axis: { tickAngle: 5 } },
201
+ x: { field: 'date', type: 'temporal', axis: { labelAngle: 5 } },
202
202
  y: { field: 'value', type: 'quantitative' },
203
203
  },
204
204
  };
@@ -0,0 +1,389 @@
1
+ /**
2
+ * Tests for encoding-level bin, timeUnit, and sort shorthand expansion.
3
+ *
4
+ * These test the Vega-Lite-aligned encoding sugar that auto-generates
5
+ * transforms (bin, timeUnit) and applies sort to categorical scales.
6
+ */
7
+
8
+ import type { ScaleBand, ScaleOrdinal, ScalePoint } from 'd3-scale';
9
+ import { describe, expect, it } from 'vitest';
10
+ import { expandEncodingSugar } from '../compile';
11
+ import type { NormalizedChartSpec } from '../compiler/types';
12
+ import { computeScales } from '../layout/scales';
13
+
14
+ // ---------------------------------------------------------------------------
15
+ // expandEncodingSugar: bin
16
+ // ---------------------------------------------------------------------------
17
+
18
+ describe('expandEncodingSugar', () => {
19
+ describe('bin expansion', () => {
20
+ it('expands encoding.x.bin: true into a BinTransform', () => {
21
+ const spec = {
22
+ mark: 'bar',
23
+ data: [{ val: 10 }, { val: 20 }],
24
+ encoding: {
25
+ x: { field: 'val', type: 'quantitative', bin: true },
26
+ y: { field: 'count', type: 'quantitative' },
27
+ },
28
+ };
29
+
30
+ const result = expandEncodingSugar(spec);
31
+
32
+ // Should generate a bin transform
33
+ expect(result.transform).toHaveLength(1);
34
+ expect(result.transform).toEqual([{ bin: true, field: 'val', as: 'bin_val' }]);
35
+
36
+ // Encoding field should reference the binned output
37
+ const encoding = result.encoding as Record<string, { field: string; bin?: unknown }>;
38
+ expect(encoding.x.field).toBe('bin_val');
39
+ // bin property should be removed from the encoding channel
40
+ expect(encoding.x.bin).toBeUndefined();
41
+ });
42
+
43
+ it('expands encoding.y.bin with BinParams', () => {
44
+ const spec = {
45
+ mark: 'bar',
46
+ data: [],
47
+ encoding: {
48
+ x: { field: 'cat', type: 'nominal' },
49
+ y: { field: 'score', type: 'quantitative', bin: { maxbins: 20 } },
50
+ },
51
+ };
52
+
53
+ const result = expandEncodingSugar(spec);
54
+
55
+ expect(result.transform).toHaveLength(1);
56
+ expect(result.transform).toEqual([{ bin: { maxbins: 20 }, field: 'score', as: 'bin_score' }]);
57
+
58
+ const encoding = result.encoding as Record<string, { field: string }>;
59
+ expect(encoding.y.field).toBe('bin_score');
60
+ });
61
+
62
+ it('does not expand bin: false', () => {
63
+ const spec = {
64
+ mark: 'bar',
65
+ data: [],
66
+ encoding: {
67
+ x: { field: 'val', type: 'quantitative', bin: false },
68
+ y: { field: 'count', type: 'quantitative' },
69
+ },
70
+ };
71
+
72
+ const result = expandEncodingSugar(spec);
73
+
74
+ // No transform generated, spec returned as-is
75
+ expect(result.transform).toBeUndefined();
76
+ expect(result).toBe(spec);
77
+ });
78
+
79
+ it('prepends generated transforms before existing transforms', () => {
80
+ const spec = {
81
+ mark: 'bar',
82
+ data: [],
83
+ encoding: {
84
+ x: { field: 'val', type: 'quantitative', bin: true },
85
+ y: { field: 'count', type: 'quantitative' },
86
+ },
87
+ transform: [{ filter: { field: 'active', equal: true } }],
88
+ };
89
+
90
+ const result = expandEncodingSugar(spec);
91
+
92
+ expect(result.transform).toHaveLength(2);
93
+ // Generated bin transform comes first
94
+ expect((result.transform as Array<Record<string, unknown>>)[0]).toHaveProperty('bin');
95
+ // User-defined filter comes after
96
+ expect((result.transform as Array<Record<string, unknown>>)[1]).toHaveProperty('filter');
97
+ });
98
+ });
99
+
100
+ // ---------------------------------------------------------------------------
101
+ // expandEncodingSugar: timeUnit
102
+ // ---------------------------------------------------------------------------
103
+
104
+ describe('timeUnit expansion', () => {
105
+ it('expands encoding.x.timeUnit into a TimeUnitTransform', () => {
106
+ const spec = {
107
+ mark: 'line',
108
+ data: [],
109
+ encoding: {
110
+ x: { field: 'date', type: 'temporal', timeUnit: 'yearmonth' },
111
+ y: { field: 'value', type: 'quantitative' },
112
+ },
113
+ };
114
+
115
+ const result = expandEncodingSugar(spec);
116
+
117
+ expect(result.transform).toHaveLength(1);
118
+ expect(result.transform).toEqual([
119
+ { timeUnit: 'yearmonth', field: 'date', as: 'yearmonth_date' },
120
+ ]);
121
+
122
+ const encoding = result.encoding as Record<string, { field: string; timeUnit?: unknown }>;
123
+ expect(encoding.x.field).toBe('yearmonth_date');
124
+ expect(encoding.x.timeUnit).toBeUndefined();
125
+ });
126
+
127
+ it('expands encoding.y.timeUnit: month', () => {
128
+ const spec = {
129
+ mark: 'bar',
130
+ data: [],
131
+ encoding: {
132
+ x: { field: 'cat', type: 'nominal' },
133
+ y: { field: 'timestamp', type: 'temporal', timeUnit: 'month' },
134
+ },
135
+ };
136
+
137
+ const result = expandEncodingSugar(spec);
138
+
139
+ expect(result.transform).toHaveLength(1);
140
+ expect(result.transform).toEqual([
141
+ { timeUnit: 'month', field: 'timestamp', as: 'month_timestamp' },
142
+ ]);
143
+
144
+ const encoding = result.encoding as Record<string, { field: string }>;
145
+ expect(encoding.y.field).toBe('month_timestamp');
146
+ });
147
+ });
148
+
149
+ // ---------------------------------------------------------------------------
150
+ // Combined bin + timeUnit
151
+ // ---------------------------------------------------------------------------
152
+
153
+ describe('combined expansions', () => {
154
+ it('expands both bin on x and timeUnit on y', () => {
155
+ const spec = {
156
+ mark: 'point',
157
+ data: [],
158
+ encoding: {
159
+ x: { field: 'amount', type: 'quantitative', bin: true },
160
+ y: { field: 'created', type: 'temporal', timeUnit: 'year' },
161
+ },
162
+ };
163
+
164
+ const result = expandEncodingSugar(spec);
165
+
166
+ expect(result.transform).toHaveLength(2);
167
+ expect((result.transform as Array<Record<string, unknown>>)[0]).toEqual({
168
+ bin: true,
169
+ field: 'amount',
170
+ as: 'bin_amount',
171
+ });
172
+ expect((result.transform as Array<Record<string, unknown>>)[1]).toEqual({
173
+ timeUnit: 'year',
174
+ field: 'created',
175
+ as: 'year_created',
176
+ });
177
+
178
+ const encoding = result.encoding as Record<string, { field: string }>;
179
+ expect(encoding.x.field).toBe('bin_amount');
180
+ expect(encoding.y.field).toBe('year_created');
181
+ });
182
+
183
+ it('returns spec unchanged when no encoding sugar is present', () => {
184
+ const spec = {
185
+ mark: 'line',
186
+ data: [],
187
+ encoding: {
188
+ x: { field: 'date', type: 'temporal' },
189
+ y: { field: 'value', type: 'quantitative' },
190
+ },
191
+ };
192
+
193
+ const result = expandEncodingSugar(spec);
194
+ expect(result).toBe(spec); // Same reference, no copy needed
195
+ });
196
+
197
+ it('returns spec unchanged when no encoding is present', () => {
198
+ const spec = { mark: 'line', data: [] };
199
+ const result = expandEncodingSugar(spec);
200
+ expect(result).toBe(spec);
201
+ });
202
+ });
203
+ });
204
+
205
+ // ---------------------------------------------------------------------------
206
+ // Sort on categorical scales
207
+ // ---------------------------------------------------------------------------
208
+
209
+ describe('categorical sort', () => {
210
+ const chartArea = { x: 0, y: 0, width: 400, height: 300 };
211
+
212
+ const makeBarSpec = (
213
+ sort: 'ascending' | 'descending' | null | undefined,
214
+ ): NormalizedChartSpec => ({
215
+ markType: 'bar',
216
+ markDef: { type: 'bar' },
217
+ data: [
218
+ { category: 'Banana', count: 10 },
219
+ { category: 'Apple', count: 30 },
220
+ { category: 'Cherry', count: 20 },
221
+ ],
222
+ encoding: {
223
+ x: { field: 'count', type: 'quantitative' },
224
+ y: { field: 'category', type: 'nominal', sort },
225
+ },
226
+ chrome: {},
227
+ annotations: [],
228
+ responsive: true,
229
+ theme: {},
230
+ darkMode: 'off',
231
+ labels: { density: 'auto', format: '', prefix: '' },
232
+ watermark: true,
233
+ hiddenSeries: [],
234
+ seriesStyles: {},
235
+ });
236
+
237
+ it('sorts domain ascending by default (undefined sort)', () => {
238
+ const spec = makeBarSpec(undefined);
239
+ const scales = computeScales(spec, chartArea, spec.data);
240
+ const domain = (scales.y!.scale as ScaleBand<string>).domain();
241
+ expect(domain).toEqual(['Apple', 'Banana', 'Cherry']);
242
+ });
243
+
244
+ it('sorts domain ascending when sort is "ascending"', () => {
245
+ const spec = makeBarSpec('ascending');
246
+ const scales = computeScales(spec, chartArea, spec.data);
247
+ const domain = (scales.y!.scale as ScaleBand<string>).domain();
248
+ expect(domain).toEqual(['Apple', 'Banana', 'Cherry']);
249
+ });
250
+
251
+ it('sorts domain descending when sort is "descending"', () => {
252
+ const spec = makeBarSpec('descending');
253
+ const scales = computeScales(spec, chartArea, spec.data);
254
+ const domain = (scales.y!.scale as ScaleBand<string>).domain();
255
+ expect(domain).toEqual(['Cherry', 'Banana', 'Apple']);
256
+ });
257
+
258
+ it('preserves data order when sort is null', () => {
259
+ const spec = makeBarSpec(null);
260
+ const scales = computeScales(spec, chartArea, spec.data);
261
+ const domain = (scales.y!.scale as ScaleBand<string>).domain();
262
+ // Data order: Banana, Apple, Cherry
263
+ expect(domain).toEqual(['Banana', 'Apple', 'Cherry']);
264
+ });
265
+
266
+ it('applies sort to point scales (nominal x on line chart)', () => {
267
+ const spec: NormalizedChartSpec = {
268
+ markType: 'line',
269
+ markDef: { type: 'line' },
270
+ data: [
271
+ { name: 'Zara', score: 10 },
272
+ { name: 'Alice', score: 20 },
273
+ { name: 'Mike', score: 15 },
274
+ ],
275
+ encoding: {
276
+ x: { field: 'name', type: 'nominal', sort: 'descending' },
277
+ y: { field: 'score', type: 'quantitative' },
278
+ },
279
+ chrome: {},
280
+ annotations: [],
281
+ responsive: true,
282
+ theme: {},
283
+ darkMode: 'off',
284
+ labels: { density: 'auto', format: '', prefix: '' },
285
+ watermark: true,
286
+ hiddenSeries: [],
287
+ seriesStyles: {},
288
+ };
289
+
290
+ const scales = computeScales(spec, chartArea, spec.data);
291
+ const domain = (scales.x!.scale as ScalePoint<string>).domain();
292
+ expect(domain).toEqual(['Zara', 'Mike', 'Alice']);
293
+ });
294
+
295
+ it('applies sort to ordinal color scales', () => {
296
+ const spec: NormalizedChartSpec = {
297
+ markType: 'line',
298
+ markDef: { type: 'line' },
299
+ data: [
300
+ { date: '2020-01-01', value: 10, group: 'Gamma' },
301
+ { date: '2020-01-01', value: 20, group: 'Alpha' },
302
+ { date: '2020-01-01', value: 15, group: 'Beta' },
303
+ ],
304
+ encoding: {
305
+ x: { field: 'date', type: 'temporal' },
306
+ y: { field: 'value', type: 'quantitative' },
307
+ color: { field: 'group', type: 'nominal', sort: 'ascending' },
308
+ },
309
+ chrome: {},
310
+ annotations: [],
311
+ responsive: true,
312
+ theme: {},
313
+ darkMode: 'off',
314
+ labels: { density: 'auto', format: '', prefix: '' },
315
+ watermark: true,
316
+ hiddenSeries: [],
317
+ seriesStyles: {},
318
+ };
319
+
320
+ const scales = computeScales(spec, chartArea, spec.data);
321
+ const domain = (scales.color!.scale as ScaleOrdinal<string, string>).domain();
322
+ expect(domain).toEqual(['Alpha', 'Beta', 'Gamma']);
323
+ });
324
+
325
+ it('sorts numerically when values are numeric strings', () => {
326
+ const spec: NormalizedChartSpec = {
327
+ markType: 'bar',
328
+ markDef: { type: 'bar' },
329
+ data: [
330
+ { category: '10', count: 1 },
331
+ { category: '2', count: 2 },
332
+ { category: '1', count: 3 },
333
+ ],
334
+ encoding: {
335
+ x: { field: 'count', type: 'quantitative' },
336
+ y: { field: 'category', type: 'nominal', sort: 'ascending' },
337
+ },
338
+ chrome: {},
339
+ annotations: [],
340
+ responsive: true,
341
+ theme: {},
342
+ darkMode: 'off',
343
+ labels: { density: 'auto', format: '', prefix: '' },
344
+ watermark: true,
345
+ hiddenSeries: [],
346
+ seriesStyles: {},
347
+ };
348
+
349
+ const scales = computeScales(spec, chartArea, spec.data);
350
+ const domain = (scales.y!.scale as ScaleBand<string>).domain();
351
+ // numeric: true in localeCompare means "1" < "2" < "10"
352
+ expect(domain).toEqual(['1', '2', '10']);
353
+ });
354
+
355
+ it('does not sort when explicit scale.domain is provided', () => {
356
+ const spec: NormalizedChartSpec = {
357
+ markType: 'bar',
358
+ markDef: { type: 'bar' },
359
+ data: [
360
+ { category: 'C', count: 10 },
361
+ { category: 'A', count: 20 },
362
+ { category: 'B', count: 30 },
363
+ ],
364
+ encoding: {
365
+ x: { field: 'count', type: 'quantitative' },
366
+ y: {
367
+ field: 'category',
368
+ type: 'nominal',
369
+ sort: 'ascending',
370
+ scale: { domain: ['C', 'B', 'A'] },
371
+ },
372
+ },
373
+ chrome: {},
374
+ annotations: [],
375
+ responsive: true,
376
+ theme: {},
377
+ darkMode: 'off',
378
+ labels: { density: 'auto', format: '', prefix: '' },
379
+ watermark: true,
380
+ hiddenSeries: [],
381
+ seriesStyles: {},
382
+ };
383
+
384
+ const scales = computeScales(spec, chartArea, spec.data);
385
+ const domain = (scales.y!.scale as ScaleBand<string>).domain();
386
+ // Explicit domain takes precedence over sort
387
+ expect(domain).toEqual(['C', 'B', 'A']);
388
+ });
389
+ });