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

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 (160) hide show
  1. package/dist/index.d.ts +124 -14
  2. package/dist/index.js +16254 -9417
  3. package/dist/index.js.map +1 -1
  4. package/package.json +11 -4
  5. package/src/__tests__/__snapshots__/compile-snapshot.test.ts.snap +232 -56
  6. package/src/__tests__/annotation-behavior.test.ts +852 -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__/facet.test.ts +144 -0
  12. package/src/__tests__/helpers/invariants.ts +54 -20
  13. package/src/__tests__/known-bugs.test.ts +455 -2
  14. package/src/__tests__/legend.test.ts +69 -4
  15. package/src/__tests__/ranking-recipes.test.ts +260 -0
  16. package/src/__tests__/scales.test.ts +9 -3
  17. package/src/__tests__/series-search.test.ts +92 -0
  18. package/src/__tests__/spec-sugar.test.ts +801 -0
  19. package/src/__tests__/you-draw-it.test.ts +160 -0
  20. package/src/annotations/__tests__/compute.test.ts +207 -92
  21. package/src/annotations/__tests__/geometry.test.ts +66 -0
  22. package/src/annotations/__tests__/resolve-text.test.ts +527 -27
  23. package/src/annotations/__tests__/rich-text.test.ts +91 -0
  24. package/src/annotations/__tests__/thinning.test.ts +148 -0
  25. package/src/annotations/collisions.ts +130 -84
  26. package/src/annotations/compute.ts +83 -85
  27. package/src/annotations/constants.ts +116 -6
  28. package/src/annotations/geometry.ts +228 -77
  29. package/src/annotations/move.ts +135 -0
  30. package/src/annotations/placement.ts +62 -14
  31. package/src/annotations/resolve-range.ts +2 -0
  32. package/src/annotations/resolve-refline.ts +2 -0
  33. package/src/annotations/resolve-text.ts +318 -111
  34. package/src/annotations/rich-text.ts +65 -0
  35. package/src/annotations/thinning.ts +84 -10
  36. package/src/barlist/compile-barlist.ts +16 -6
  37. package/src/charts/_shared/format-label-value.ts +9 -8
  38. package/src/charts/bar/__tests__/compute.test.ts +57 -10
  39. package/src/charts/bar/__tests__/labels.test.ts +5 -4
  40. package/src/charts/bar/compute.ts +2 -6
  41. package/src/charts/bar/index.ts +8 -1
  42. package/src/charts/bar/labels.ts +3 -3
  43. package/src/charts/beeswarm/__tests__/compute.test.ts +280 -0
  44. package/src/charts/beeswarm/__tests__/dodge.test.ts +148 -0
  45. package/src/charts/beeswarm/compute.ts +203 -0
  46. package/src/charts/beeswarm/dodge.ts +155 -0
  47. package/src/charts/beeswarm/index.ts +32 -0
  48. package/src/charts/builtin.ts +12 -1
  49. package/src/charts/calendar/__tests__/compute.test.ts +417 -0
  50. package/src/charts/calendar/compute.ts +319 -0
  51. package/src/charts/calendar/index.ts +32 -0
  52. package/src/charts/column/__tests__/compute.test.ts +21 -7
  53. package/src/charts/column/__tests__/labels.test.ts +76 -1
  54. package/src/charts/column/compute.ts +2 -6
  55. package/src/charts/column/index.ts +10 -2
  56. package/src/charts/column/labels.ts +95 -21
  57. package/src/charts/dot/index.ts +8 -1
  58. package/src/charts/dot/labels.ts +3 -3
  59. package/src/charts/line/__tests__/compute.test.ts +15 -19
  60. package/src/charts/line/area.ts +8 -23
  61. package/src/charts/line/compute.ts +32 -2
  62. package/src/charts/parliament/__tests__/compute.test.ts +411 -0
  63. package/src/charts/parliament/compute.ts +313 -0
  64. package/src/charts/parliament/index.ts +32 -0
  65. package/src/charts/pie/__tests__/compute.test.ts +103 -0
  66. package/src/charts/pie/compute.ts +63 -8
  67. package/src/charts/pie/labels.ts +1 -0
  68. package/src/charts/range/__tests__/compute.test.ts +486 -0
  69. package/src/charts/range/compute.ts +273 -0
  70. package/src/charts/range/index.ts +82 -0
  71. package/src/charts/range/labels.ts +169 -0
  72. package/src/charts/rect/__tests__/compute.test.ts +120 -0
  73. package/src/charts/rect/index.ts +127 -0
  74. package/src/charts/scatter/compute.ts +7 -21
  75. package/src/charts/text/__tests__/compute.test.ts +307 -0
  76. package/src/charts/text/index.ts +44 -7
  77. package/src/charts/tick/__tests__/compute.test.ts +75 -0
  78. package/src/charts/tick/index.ts +7 -4
  79. package/src/charts/utils.ts +5 -3
  80. package/src/charts/waffle/__tests__/compute.test.ts +305 -0
  81. package/src/charts/waffle/compute.ts +197 -0
  82. package/src/charts/waffle/index.ts +31 -0
  83. package/src/compile/__tests__/color-scale-reverse.test.ts +89 -0
  84. package/src/compile/__tests__/data-clip.test.ts +68 -0
  85. package/src/compile/__tests__/fill-patterns.test.ts +154 -0
  86. package/src/compile/__tests__/size-scale.test.ts +108 -0
  87. package/src/compile/color-scale-range.ts +54 -33
  88. package/src/compile/data-clip.ts +21 -6
  89. package/src/compile/fill-patterns.ts +74 -0
  90. package/src/compile/layer.ts +121 -21
  91. package/src/compile/size-scale.ts +141 -0
  92. package/src/compile/spec-sugar.ts +767 -0
  93. package/src/compile/you-draw-it.ts +123 -0
  94. package/src/compile.ts +415 -110
  95. package/src/compiler/__tests__/a11y-warnings.test.ts +148 -0
  96. package/src/compiler/__tests__/fixtures/vl-parity/axis-null.json +19 -0
  97. package/src/compiler/__tests__/fixtures/vl-parity/color-value.json +20 -0
  98. package/src/compiler/__tests__/fixtures/vl-parity/count-aggregate.json +22 -0
  99. package/src/compiler/__tests__/fixtures/vl-parity/data-values.json +19 -0
  100. package/src/compiler/__tests__/fixtures/vl-parity/layer-axis-null.json +14 -0
  101. package/src/compiler/__tests__/fixtures/vl-parity/layer-count.json +34 -0
  102. package/src/compiler/__tests__/fixtures/vl-parity/layer-data-values.json +22 -0
  103. package/src/compiler/__tests__/fixtures/vl-parity/layer-legend-null.json +41 -0
  104. package/src/compiler/__tests__/fixtures/vl-parity/layer-scheme.json +38 -0
  105. package/src/compiler/__tests__/fixtures/vl-parity/layer-sort.json +28 -0
  106. package/src/compiler/__tests__/fixtures/vl-parity/layer-theta.json +28 -0
  107. package/src/compiler/__tests__/fixtures/vl-parity/layer-title.json +30 -0
  108. package/src/compiler/__tests__/fixtures/vl-parity/layer-value-def.json +29 -0
  109. package/src/compiler/__tests__/fixtures/vl-parity/legend-null.json +32 -0
  110. package/src/compiler/__tests__/fixtures/vl-parity/scheme.json +25 -0
  111. package/src/compiler/__tests__/fixtures/vl-parity/sort-by-value.json +19 -0
  112. package/src/compiler/__tests__/fixtures/vl-parity/theta-arc.json +19 -0
  113. package/src/compiler/__tests__/fixtures/vl-parity/title-subtitle.json +21 -0
  114. package/src/compiler/__tests__/fixtures/vl-parity/width-height.json +23 -0
  115. package/src/compiler/__tests__/keys.test.ts +3 -9
  116. package/src/compiler/__tests__/normalize.test.ts +65 -1
  117. package/src/compiler/__tests__/validate-did-you-mean.test.ts +170 -0
  118. package/src/compiler/__tests__/validate-near-miss.test.ts +251 -0
  119. package/src/compiler/__tests__/validate.test.ts +258 -0
  120. package/src/compiler/__tests__/vl-parity.test.ts +68 -0
  121. package/src/compiler/a11y-warnings.ts +110 -0
  122. package/src/compiler/keys.ts +5 -4
  123. package/src/compiler/normalize.ts +151 -7
  124. package/src/compiler/types.ts +11 -0
  125. package/src/compiler/validate.ts +961 -32
  126. package/src/endpoint-labels/__tests__/compute.test.ts +88 -0
  127. package/src/endpoint-labels/compute.ts +40 -12
  128. package/src/endpoint-labels/format.ts +45 -15
  129. package/src/endpoint-labels/predict.ts +50 -36
  130. package/src/format/__tests__/field-format.test.ts +164 -0
  131. package/src/format/field-format.ts +27 -0
  132. package/src/geo/__tests__/compile-map.test.ts +1128 -0
  133. package/src/geo/compile-map.ts +1376 -0
  134. package/src/geo/join.ts +115 -0
  135. package/src/geo/projections.ts +65 -0
  136. package/src/geo/types.ts +26 -0
  137. package/src/graphs/__tests__/compile-graph.test.ts +25 -0
  138. package/src/graphs/compile-graph.ts +27 -18
  139. package/src/index.ts +20 -2
  140. package/src/layout/axes/ticks.ts +59 -10
  141. package/src/layout/axes.ts +35 -6
  142. package/src/layout/dimensions.ts +139 -83
  143. package/src/layout/facet.ts +14 -3
  144. package/src/layout/plan.ts +83 -33
  145. package/src/layout/scales.ts +132 -15
  146. package/src/legend/__tests__/continuous.test.ts +318 -0
  147. package/src/legend/__tests__/size.test.ts +348 -0
  148. package/src/legend/__tests__/suppression.test.ts +17 -1
  149. package/src/legend/compute.ts +123 -16
  150. package/src/legend/continuous.ts +327 -0
  151. package/src/legend/size.ts +407 -0
  152. package/src/legend/suppression.ts +8 -7
  153. package/src/sankey/compile-sankey.ts +31 -19
  154. package/src/tables/format-cells.ts +16 -7
  155. package/src/tilemap/__tests__/compile-tilemap.test.ts +39 -0
  156. package/src/tilemap/compile-tilemap.ts +24 -7
  157. package/src/tooltips/__tests__/compute.test.ts +2 -2
  158. package/src/tooltips/compute.ts +422 -41
  159. package/src/transforms/__tests__/conditional.test.ts +18 -0
  160. package/src/transforms/aggregate.ts +2 -1
@@ -62,11 +62,11 @@ describe('dedupeKeys', () => {
62
62
  });
63
63
 
64
64
  it('suffixes duplicates with occurrence index', () => {
65
- expect(dedupeKeys(['a', 'a', 'a'])).toEqual(['a\x010', 'a\x011', 'a\x012']);
65
+ expect(dedupeKeys(['a', 'a', 'a'])).toEqual(['a⸱0', 'a⸱1', 'a⸱2']);
66
66
  });
67
67
 
68
68
  it('only suffixes keys that appear more than once', () => {
69
- expect(dedupeKeys(['a', 'b', 'a'])).toEqual(['a\x010', 'b', 'a\x011']);
69
+ expect(dedupeKeys(['a', 'b', 'a'])).toEqual(['a⸱0', 'b', 'a⸱1']);
70
70
  });
71
71
 
72
72
  it('handles empty array', () => {
@@ -78,12 +78,6 @@ describe('dedupeKeys', () => {
78
78
  });
79
79
 
80
80
  it('handles multiple duplicate groups', () => {
81
- expect(dedupeKeys(['x', 'y', 'x', 'y', 'z'])).toEqual([
82
- 'x\x010',
83
- 'y\x010',
84
- 'x\x011',
85
- 'y\x011',
86
- 'z',
87
- ]);
81
+ expect(dedupeKeys(['x', 'y', 'x', 'y', 'z'])).toEqual(['x⸱0', 'y⸱0', 'x⸱1', 'y⸱1', 'z']);
88
82
  });
89
83
  });
@@ -2,6 +2,7 @@ import type {
2
2
  ChartSpec,
3
3
  GraphSpec,
4
4
  LayerSpec,
5
+ MapSpec,
5
6
  RangeAnnotation,
6
7
  RefLineAnnotation,
7
8
  SankeySpec,
@@ -9,6 +10,7 @@ import type {
9
10
  TextAnnotation,
10
11
  } from '@opendata-ai/openchart-core';
11
12
  import { describe, expect, it } from 'vitest';
13
+ import type { NormalizedMapSpec } from '../../geo/types';
12
14
  import type { NormalizedSankeySpec } from '../../sankey/types';
13
15
  import { normalizeSpec } from '../normalize';
14
16
  import type { NormalizedChartSpec, NormalizedGraphSpec, NormalizedTableSpec } from '../types';
@@ -195,12 +197,33 @@ describe('normalizeSpec', () => {
195
197
  expect(refline.style).toBe('dashed');
196
198
  expect(refline.strokeWidth).toBe(1);
197
199
 
200
+ // Normalize must NOT stamp typography onto text annotations. The
201
+ // annotation layer owns fontSize (DEFAULT_ANNOTATION_FONT_SIZE) and
202
+ // fontWeight (the lede rule, which promotes a subtitle-bearing primary
203
+ // line to bold only when the author left the weight unset). Defaulting
204
+ // them here makes `fontWeight` always defined, so the lede rule can
205
+ // never fire and the size default can never apply.
198
206
  const text = result.annotations[1] as TextAnnotation;
199
- expect(text.fontSize).toBe(12);
207
+ expect(text.fontSize).toBeUndefined();
208
+ expect(text.fontWeight).toBeUndefined();
209
+ expect(text.opacity).toBe(1);
200
210
 
201
211
  const range = result.annotations[2] as RangeAnnotation;
202
212
  expect(range.opacity).toBe(0.1);
203
213
  });
214
+
215
+ it('preserves author-set annotation typography through normalization', () => {
216
+ const spec: ChartSpec = {
217
+ ...lineSpec,
218
+ annotations: [
219
+ { type: 'text', x: '2020-01-01', y: 10, text: 'Start', fontSize: 18, fontWeight: 300 },
220
+ ],
221
+ };
222
+
223
+ const text = (normalizeSpec(spec) as NormalizedChartSpec).annotations[0] as TextAnnotation;
224
+ expect(text.fontSize).toBe(18);
225
+ expect(text.fontWeight).toBe(300);
226
+ });
204
227
  });
205
228
 
206
229
  describe('table spec normalization', () => {
@@ -332,4 +355,45 @@ describe('normalizeSpec', () => {
332
355
  expect(result.watermark).toBe(true);
333
356
  });
334
357
  });
358
+
359
+ describe('map spec normalization', () => {
360
+ const mapSpec: MapSpec = {
361
+ type: 'map',
362
+ geo: {
363
+ features: {
364
+ type: 'Topology',
365
+ objects: { x: { type: 'GeometryCollection', geometries: [] } },
366
+ arcs: [],
367
+ },
368
+ },
369
+ data: [],
370
+ encoding: {
371
+ key: { field: 'id', type: 'nominal' },
372
+ color: { field: 'value', type: 'quantitative' },
373
+ },
374
+ };
375
+
376
+ it('adds focus: null when not specified', () => {
377
+ const result = normalizeSpec(mapSpec) as NormalizedMapSpec;
378
+ expect(result.geo.focus).toBeNull();
379
+ });
380
+
381
+ it('preserves explicit focus value', () => {
382
+ const specWithFocus: MapSpec = {
383
+ ...mapSpec,
384
+ geo: { ...mapSpec.geo, focus: '36' },
385
+ };
386
+ const result = normalizeSpec(specWithFocus) as NormalizedMapSpec;
387
+ expect(result.geo.focus).toBe('36');
388
+ });
389
+
390
+ it('preserves focus: null (explicit clear)', () => {
391
+ const specWithNull: MapSpec = {
392
+ ...mapSpec,
393
+ geo: { ...mapSpec.geo, focus: null },
394
+ };
395
+ const result = normalizeSpec(specWithNull) as NormalizedMapSpec;
396
+ expect(result.geo.focus).toBeNull();
397
+ });
398
+ });
335
399
  });
@@ -0,0 +1,170 @@
1
+ /**
2
+ * Levenshtein "did you mean" repair hints for misspelled field references.
3
+ *
4
+ * When a spec references a field that is not a column in the provided data,
5
+ * validation suggests the nearest actual column (by edit distance) so an LLM
6
+ * or author can self-correct a typo in one step. Short/unrelated names must
7
+ * NOT produce a spurious suggestion.
8
+ */
9
+
10
+ import { describe, expect, it } from 'vitest';
11
+ import { validateSpec } from '../validate';
12
+
13
+ const data = [
14
+ { region: 'West', revenue: 100, quarter: '2024-Q1' },
15
+ { region: 'East', revenue: 200, quarter: '2024-Q2' },
16
+ ];
17
+
18
+ /** Pull the suggestion for the field-missing error on the given path. */
19
+ function suggestionFor(result: ReturnType<typeof validateSpec>, path: string): string {
20
+ const error = result.errors.find((e) => e.path === path);
21
+ return error?.suggestion ?? '';
22
+ }
23
+
24
+ describe('did-you-mean field suggestions', () => {
25
+ it('suggests the nearest column for a one-edit typo in an encoding field', () => {
26
+ const result = validateSpec({
27
+ mark: 'bar',
28
+ data,
29
+ encoding: {
30
+ x: { field: 'region', type: 'nominal' },
31
+ y: { field: 'reveune', type: 'quantitative' }, // typo of "revenue"
32
+ },
33
+ });
34
+ expect(result.valid).toBe(false);
35
+ const suggestion = suggestionFor(result, 'encoding.y.field');
36
+ expect(suggestion).toContain('Did you mean "revenue"?');
37
+ // The full available-column list is still present alongside the hint.
38
+ expect(suggestion).toContain('revenue');
39
+ expect(suggestion).toContain('region');
40
+ });
41
+
42
+ it('does not invent a suggestion for a field with no close match', () => {
43
+ const result = validateSpec({
44
+ mark: 'bar',
45
+ data,
46
+ encoding: {
47
+ x: { field: 'region', type: 'nominal' },
48
+ y: { field: 'xyzqwerty', type: 'quantitative' },
49
+ },
50
+ });
51
+ expect(result.valid).toBe(false);
52
+ const suggestion = suggestionFor(result, 'encoding.y.field');
53
+ expect(suggestion).not.toContain('Did you mean');
54
+ // Still lists the real columns so the author can pick the right one.
55
+ expect(suggestion).toContain('revenue');
56
+ });
57
+
58
+ it('suggests the nearest column for a misspelled table column key', () => {
59
+ const result = validateSpec({
60
+ type: 'table',
61
+ data,
62
+ columns: [{ key: 'quater' }], // typo of "quarter"
63
+ });
64
+ expect(result.valid).toBe(false);
65
+ const suggestion = suggestionFor(result, 'columns[0].key');
66
+ expect(suggestion).toContain('Did you mean "quarter"?');
67
+ });
68
+
69
+ it('suggests the nearest column for a misspelled sankey encoding field', () => {
70
+ const result = validateSpec({
71
+ type: 'sankey',
72
+ data: [{ source: 'A', target: 'B', value: 10 }],
73
+ encoding: {
74
+ source: { field: 'sorce', type: 'nominal' }, // typo of "source"
75
+ target: { field: 'target', type: 'nominal' },
76
+ value: { field: 'value', type: 'quantitative' },
77
+ },
78
+ });
79
+ expect(result.valid).toBe(false);
80
+ const suggestion = suggestionFor(result, 'encoding.source.field');
81
+ expect(suggestion).toContain('Did you mean "source"?');
82
+ expect(suggestion).toContain('target');
83
+ });
84
+
85
+ it('suggests the nearest column for a misspelled tilemap encoding field', () => {
86
+ const result = validateSpec({
87
+ type: 'tilemap',
88
+ data: [{ state: 'CA', value: 12000 }],
89
+ encoding: {
90
+ state: { field: 'state', type: 'nominal' },
91
+ value: { field: 'vlaue', type: 'quantitative' }, // typo of "value"
92
+ },
93
+ });
94
+ expect(result.valid).toBe(false);
95
+ const suggestion = suggestionFor(result, 'encoding.value.field');
96
+ expect(suggestion).toContain('Did you mean "value"?');
97
+ });
98
+
99
+ it('suggests the nearest column for a misspelled barlist encoding field', () => {
100
+ const result = validateSpec({
101
+ type: 'barlist',
102
+ data: [{ label: 'A', value: 42 }],
103
+ encoding: {
104
+ label: { field: 'lable', type: 'nominal' }, // typo of "label"
105
+ value: { field: 'value', type: 'quantitative' },
106
+ },
107
+ });
108
+ expect(result.valid).toBe(false);
109
+ const suggestion = suggestionFor(result, 'encoding.label.field');
110
+ expect(suggestion).toContain('Did you mean "label"?');
111
+ });
112
+
113
+ it('suggests the nearest column for a misspelled optional barlist channel field', () => {
114
+ const result = validateSpec({
115
+ type: 'barlist',
116
+ data: [{ label: 'A', value: 42, group: 'x' }],
117
+ encoding: {
118
+ label: { field: 'label', type: 'nominal' },
119
+ value: { field: 'value', type: 'quantitative' },
120
+ color: { field: 'gruop', type: 'nominal' }, // typo of "group"
121
+ },
122
+ });
123
+ expect(result.valid).toBe(false);
124
+ const suggestion = suggestionFor(result, 'encoding.color.field');
125
+ expect(suggestion).toContain('Did you mean "group"?');
126
+ });
127
+
128
+ it('suggests the nearest node field for a misspelled graph encoding field', () => {
129
+ const result = validateSpec({
130
+ type: 'graph',
131
+ nodes: [{ id: 'a', category: 'x' }],
132
+ edges: [],
133
+ encoding: {
134
+ nodeColor: { field: 'catgory', type: 'nominal' }, // typo of "category"
135
+ },
136
+ });
137
+ expect(result.valid).toBe(false);
138
+ const suggestion = suggestionFor(result, 'encoding.nodeColor.field');
139
+ expect(suggestion).toContain('Did you mean "category"?');
140
+ });
141
+
142
+ it('suggests the nearest node id for a misspelled graph edge endpoint', () => {
143
+ const result = validateSpec({
144
+ type: 'graph',
145
+ nodes: [{ id: 'alpha' }, { id: 'beta' }],
146
+ edges: [{ source: 'alpha', target: 'betta' }], // typo of "beta"
147
+ });
148
+ expect(result.valid).toBe(false);
149
+ const suggestion = suggestionFor(result, 'edges[0].target');
150
+ expect(suggestion).toContain('Did you mean "beta"?');
151
+ });
152
+
153
+ it('does not match on a short unrelated field where the edit distance exceeds the threshold', () => {
154
+ const shortData = [
155
+ { a: 1, b: 2 },
156
+ { a: 3, b: 4 },
157
+ ];
158
+ const result = validateSpec({
159
+ mark: 'bar',
160
+ data: shortData,
161
+ encoding: {
162
+ x: { field: 'zzz', type: 'nominal' },
163
+ y: { field: 'a', type: 'quantitative' },
164
+ },
165
+ });
166
+ expect(result.valid).toBe(false);
167
+ const suggestion = suggestionFor(result, 'encoding.x.field');
168
+ expect(suggestion).not.toContain('Did you mean');
169
+ });
170
+ });
@@ -0,0 +1,251 @@
1
+ /**
2
+ * Near-miss validation messages for VL idioms that are unsupported by
3
+ * decision. Each rejection must point the author at the openchart
4
+ * equivalent instead of failing with a generic type error.
5
+ */
6
+
7
+ import { describe, expect, it } from 'vitest';
8
+ import { validateSpec } from '../validate';
9
+
10
+ const encoding = {
11
+ x: { field: 'cat', type: 'nominal' },
12
+ y: { field: 'value', type: 'quantitative' },
13
+ };
14
+ const data = [
15
+ { cat: 'A', value: 10 },
16
+ { cat: 'B', value: 20 },
17
+ ];
18
+
19
+ describe('near-miss validation messages', () => {
20
+ it('rejects data.url with a pointer to inline data', () => {
21
+ const result = validateSpec({ mark: 'bar', data: { url: '/data.json' }, encoding });
22
+ expect(result.valid).toBe(false);
23
+ const error = result.errors.find((e) => e.path === 'data.url');
24
+ expect(error?.message).toContain('does not fetch remote data');
25
+ expect(error?.suggestion).toContain('inline');
26
+ });
27
+
28
+ it('rejects the string form of calculate with the structured equivalent', () => {
29
+ const result = validateSpec({
30
+ mark: 'bar',
31
+ data,
32
+ transform: [{ calculate: 'datum.a / datum.b', as: 'ratio' }],
33
+ encoding,
34
+ });
35
+ expect(result.valid).toBe(false);
36
+ const error = result.errors.find((e) => e.path === 'transform[0].calculate');
37
+ expect(error?.message).toContain('structured expression object');
38
+ expect(error?.suggestion).toContain('calculate: { op:');
39
+ });
40
+
41
+ it('rejects hconcat/vconcat with the side-by-side containers suggestion', () => {
42
+ for (const key of ['hconcat', 'vconcat']) {
43
+ const result = validateSpec({ [key]: [] });
44
+ expect(result.valid).toBe(false);
45
+ const error = result.errors.find((e) => e.path === key);
46
+ expect(error?.message).toContain(`"${key}" composition is not supported`);
47
+ expect(error?.suggestion).toContain('facet');
48
+ }
49
+ });
50
+
51
+ it('rejects the top-level facet operator with the facet channel suggestion', () => {
52
+ const result = validateSpec({
53
+ facet: { field: 'cat', type: 'nominal' },
54
+ spec: { mark: 'bar', encoding },
55
+ });
56
+ expect(result.valid).toBe(false);
57
+ const error = result.errors.find((e) => e.path === 'facet');
58
+ expect(error?.message).toContain('"facet" operator is not supported');
59
+ expect(error?.suggestion).toContain('encoding.facet');
60
+ });
61
+
62
+ it('rejects unknown scheme names with the supported list', () => {
63
+ const result = validateSpec({
64
+ mark: 'bar',
65
+ data,
66
+ encoding: {
67
+ ...encoding,
68
+ color: { field: 'value', type: 'quantitative', scale: { scheme: 'viridis' } },
69
+ },
70
+ });
71
+ expect(result.valid).toBe(false);
72
+ const error = result.errors.find((e) => e.path === 'encoding.color.scale.scheme');
73
+ expect(error?.message).toContain('"viridis" is not a supported scheme name');
74
+ expect(error?.suggestion).toContain('blues');
75
+ expect(error?.suggestion).toContain('scale.range');
76
+ });
77
+
78
+ it('accepts known scheme names (resolved by sugar before validation on the compile path)', () => {
79
+ const result = validateSpec({
80
+ mark: 'bar',
81
+ data,
82
+ encoding: {
83
+ ...encoding,
84
+ color: { field: 'value', type: 'quantitative', scale: { scheme: 'blues' } },
85
+ },
86
+ });
87
+ expect(result.errors.filter((e) => e.path === 'encoding.color.scale.scheme')).toHaveLength(0);
88
+ });
89
+ });
90
+
91
+ describe('scale.scheme on non-chart specs', () => {
92
+ it('rejects any scheme on a sankey color channel as dead config', () => {
93
+ // Sankey never reads scale.scheme (node colors cycle theme.colors.categorical),
94
+ // so even a known name like "blues" must not be silently accepted.
95
+ for (const scheme of ['viridis', 'blues']) {
96
+ const result = validateSpec({
97
+ type: 'sankey',
98
+ data: [{ source: 'A', target: 'B', value: 10 }],
99
+ encoding: {
100
+ source: { field: 'source', type: 'nominal' },
101
+ target: { field: 'target', type: 'nominal' },
102
+ value: { field: 'value', type: 'quantitative' },
103
+ color: { field: 'source', type: 'nominal', scale: { scheme } },
104
+ },
105
+ });
106
+ expect(result.valid).toBe(false);
107
+ const error = result.errors.find((e) => e.path === 'encoding.color.scale.scheme');
108
+ expect(error?.code).toBe('INVALID_VALUE');
109
+ expect(error?.message).toContain('has no effect');
110
+ expect(error?.suggestion).toContain('theme.colors.categorical');
111
+ }
112
+ });
113
+
114
+ it('rejects any scheme on a tilemap value channel, pointing at the palette prop', () => {
115
+ const result = validateSpec({
116
+ type: 'tilemap',
117
+ data: [{ state: 'CA', value: 12000 }],
118
+ encoding: {
119
+ state: { field: 'state', type: 'nominal' },
120
+ value: { field: 'value', type: 'quantitative', scale: { scheme: 'viridis' } },
121
+ },
122
+ });
123
+ expect(result.valid).toBe(false);
124
+ const error = result.errors.find((e) => e.path === 'encoding.value.scale.scheme');
125
+ expect(error?.code).toBe('INVALID_VALUE');
126
+ expect(error?.message).toContain('has no effect');
127
+ expect(error?.suggestion).toContain('palette: "green"');
128
+ });
129
+
130
+ it('rejects any scheme on a barlist color channel as dead config', () => {
131
+ const result = validateSpec({
132
+ type: 'barlist',
133
+ data: [{ label: 'A', value: 42, group: 'x' }],
134
+ encoding: {
135
+ label: { field: 'label', type: 'nominal' },
136
+ value: { field: 'value', type: 'quantitative' },
137
+ color: { field: 'group', type: 'nominal', scale: { scheme: 'viridis' } },
138
+ },
139
+ });
140
+ expect(result.valid).toBe(false);
141
+ const error = result.errors.find((e) => e.path === 'encoding.color.scale.scheme');
142
+ expect(error?.code).toBe('INVALID_VALUE');
143
+ expect(error?.message).toContain('has no effect');
144
+ });
145
+
146
+ it('rejects any scheme on a graph nodeColor channel as dead config', () => {
147
+ // Graph never reads scale.scheme (encoding.ts consumes only
148
+ // scale.range/scale.domain), so even a known name must not pass.
149
+ for (const scheme of ['viridis', 'blues']) {
150
+ const result = validateSpec({
151
+ type: 'graph',
152
+ nodes: [{ id: 'a', category: 'x' }],
153
+ edges: [],
154
+ encoding: {
155
+ nodeColor: { field: 'category', type: 'nominal', scale: { scheme } },
156
+ },
157
+ });
158
+ expect(result.valid).toBe(false);
159
+ const error = result.errors.find((e) => e.path === 'encoding.nodeColor.scale.scheme');
160
+ expect(error?.code).toBe('INVALID_VALUE');
161
+ expect(error?.message).toContain('has no effect');
162
+ expect(error?.suggestion).toContain('scale.range');
163
+ }
164
+ });
165
+ });
166
+
167
+ describe('scale.scheme on map specs', () => {
168
+ // Minimal valid TopoJSON topology with one feature
169
+ const topo = {
170
+ type: 'Topology',
171
+ objects: {
172
+ things: {
173
+ type: 'GeometryCollection',
174
+ geometries: [{ type: 'Polygon', arcs: [[0]], id: '01' }],
175
+ },
176
+ },
177
+ arcs: [
178
+ [
179
+ [0, 0],
180
+ [0, 1],
181
+ [1, 0],
182
+ [-1, -1],
183
+ ],
184
+ ],
185
+ };
186
+
187
+ it('rejects a scheme the map palette set does not support', () => {
188
+ const result = validateSpec({
189
+ type: 'map',
190
+ geo: { features: topo },
191
+ data: [{ id: '01', value: 5 }],
192
+ encoding: {
193
+ key: { field: 'id' },
194
+ color: { field: 'value', type: 'quantitative', scale: { scheme: 'viridis' } },
195
+ },
196
+ });
197
+ expect(result.valid).toBe(false);
198
+ const error = result.errors.find((e) => e.path === 'encoding.color.scale.scheme');
199
+ expect(error?.code).toBe('INVALID_VALUE');
200
+ expect(error?.message).toContain('"viridis" is not a supported map palette');
201
+ expect(error?.suggestion).toContain('green');
202
+ });
203
+
204
+ it('accepts a supported sequential palette on a map color channel', () => {
205
+ const result = validateSpec({
206
+ type: 'map',
207
+ geo: { features: topo },
208
+ data: [{ id: '01', value: 5 }],
209
+ encoding: {
210
+ key: { field: 'id' },
211
+ color: { field: 'value', type: 'quantitative', scale: { scheme: 'green' } },
212
+ },
213
+ });
214
+ expect(result.errors.filter((e) => e.path === 'encoding.color.scale.scheme')).toHaveLength(0);
215
+ });
216
+
217
+ it('rejects any scheme on a categorical map color channel as dead config', () => {
218
+ const result = validateSpec({
219
+ type: 'map',
220
+ geo: { features: topo },
221
+ data: [{ id: '01', region: 'south' }],
222
+ encoding: {
223
+ key: { field: 'id' },
224
+ color: { field: 'region', type: 'nominal', scale: { scheme: 'green' } },
225
+ },
226
+ });
227
+ expect(result.valid).toBe(false);
228
+ const error = result.errors.find((e) => e.path === 'encoding.color.scale.scheme');
229
+ expect(error?.code).toBe('INVALID_VALUE');
230
+ expect(error?.message).toContain('has no effect');
231
+ expect(error?.suggestion).toContain('scale.range');
232
+ });
233
+
234
+ it('rejects an unsupported scheme on the points layer color channel', () => {
235
+ const result = validateSpec({
236
+ type: 'map',
237
+ geo: { features: topo },
238
+ data: [],
239
+ encoding: { key: { field: 'id' } },
240
+ points: {
241
+ data: [{ lat: 30, lon: -97, score: 1 }],
242
+ latitude: { field: 'lat' },
243
+ longitude: { field: 'lon' },
244
+ color: { field: 'score', type: 'quantitative', scale: { scheme: 'magma' } },
245
+ },
246
+ });
247
+ const error = result.errors.find((e) => e.path === 'points.color.scale.scheme');
248
+ expect(error?.code).toBe('INVALID_VALUE');
249
+ expect(error?.message).toContain('"magma" is not a supported map palette');
250
+ });
251
+ });