@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
@@ -15,6 +15,9 @@ import {
15
15
  MARK_ENCODING_RULES,
16
16
  MARK_TYPES,
17
17
  type MarkType,
18
+ resolveSchemeName,
19
+ SEQUENTIAL_PALETTES,
20
+ SUPPORTED_SCHEME_NAMES,
18
21
  type VizSpec,
19
22
  } from '@opendata-ai/openchart-core';
20
23
 
@@ -49,6 +52,379 @@ function isNumeric(value: unknown): boolean {
49
52
  return false;
50
53
  }
51
54
 
55
+ /**
56
+ * Levenshtein edit distance between two strings (case-insensitive). Used to
57
+ * turn a misspelled field reference into a "did you mean" repair hint.
58
+ */
59
+ function editDistance(a: string, b: string): number {
60
+ const s = a.toLowerCase();
61
+ const t = b.toLowerCase();
62
+ const m = s.length;
63
+ const n = t.length;
64
+ if (m === 0) return n;
65
+ if (n === 0) return m;
66
+ // Single-row DP: prev[j] holds distance for the previous source char.
67
+ let prev = Array.from({ length: n + 1 }, (_, j) => j);
68
+ let curr = new Array<number>(n + 1);
69
+ for (let i = 1; i <= m; i++) {
70
+ curr[0] = i;
71
+ for (let j = 1; j <= n; j++) {
72
+ const cost = s[i - 1] === t[j - 1] ? 0 : 1;
73
+ curr[j] = Math.min(prev[j] + 1, curr[j - 1] + 1, prev[j - 1] + cost);
74
+ }
75
+ [prev, curr] = [curr, prev];
76
+ }
77
+ return prev[n];
78
+ }
79
+
80
+ /**
81
+ * Given a misspelled field name and the actual data columns, return the nearest
82
+ * column when it is close enough to be a plausible typo, else undefined.
83
+ *
84
+ * The threshold scales with word length (at most ~40% of the target length, and
85
+ * never more than 3 edits) so short columns don't match unrelated names.
86
+ */
87
+ function nearestColumn(field: string, columns: string[]): string | undefined {
88
+ let best: string | undefined;
89
+ let bestDistance = Number.POSITIVE_INFINITY;
90
+ for (const column of columns) {
91
+ const distance = editDistance(field, column);
92
+ if (distance < bestDistance) {
93
+ bestDistance = distance;
94
+ best = column;
95
+ }
96
+ }
97
+ if (best === undefined) return undefined;
98
+ const threshold = Math.min(3, Math.max(1, Math.floor(best.length * 0.4)));
99
+ return bestDistance <= threshold ? best : undefined;
100
+ }
101
+
102
+ /**
103
+ * Build the "did you mean" clause appended to DATA_FIELD_MISSING suggestions.
104
+ * Returns an empty string when no column is a plausible near-match.
105
+ */
106
+ function didYouMean(field: string, columns: string[]): string {
107
+ const match = nearestColumn(field, columns);
108
+ return match ? ` Did you mean "${match}"?` : '';
109
+ }
110
+
111
+ /**
112
+ * Push an INVALID_VALUE error when a channel carries an unrecognized
113
+ * scale.scheme name. Chart specs are the only family that consumes schemes
114
+ * this way: known names (including VL aliases) resolve via the core palette
115
+ * registry and are expanded to scale.range by the pre-validation sugar pass,
116
+ * so `resolveSchemeName` failing means the name is unknown.
117
+ */
118
+ function checkSchemeName(
119
+ channelObj: Record<string, unknown>,
120
+ channelPath: string,
121
+ errors: ValidationError[],
122
+ ): void {
123
+ const scale = channelObj.scale as Record<string, unknown> | undefined;
124
+ if (scale && typeof scale.scheme === 'string' && !resolveSchemeName(scale.scheme)) {
125
+ errors.push({
126
+ message: `Spec error: ${channelPath}.scale.scheme "${scale.scheme}" is not a supported scheme name`,
127
+ path: `${channelPath}.scale.scheme`,
128
+ code: 'INVALID_VALUE',
129
+ suggestion: `Use one of the supported scheme names: ${[...SUPPORTED_SCHEME_NAMES].join(', ')}. Or provide explicit colors via scale.range.`,
130
+ });
131
+ }
132
+ }
133
+
134
+ /**
135
+ * Push an INVALID_VALUE error when any channel of an encoding block carries a
136
+ * scale.scheme on a spec family whose compile path never reads it. Even a
137
+ * known scheme name is dead config there — silently accepting it would leave
138
+ * the author believing they chose the colors.
139
+ */
140
+ function checkSchemeUnused(
141
+ encoding: Record<string, unknown>,
142
+ errors: ValidationError[],
143
+ familyNote: string,
144
+ suggestion: string,
145
+ ): void {
146
+ for (const [channel, ch] of Object.entries(encoding)) {
147
+ if (!ch || typeof ch !== 'object' || Array.isArray(ch)) continue;
148
+ const scale = (ch as Record<string, unknown>).scale as Record<string, unknown> | undefined;
149
+ if (scale && typeof scale.scheme === 'string') {
150
+ errors.push({
151
+ message: `Spec error: encoding.${channel}.scale.scheme has no effect — ${familyNote}`,
152
+ path: `encoding.${channel}.scale.scheme`,
153
+ code: 'INVALID_VALUE',
154
+ suggestion,
155
+ });
156
+ }
157
+ }
158
+ }
159
+
160
+ /** Palette names the map compile path actually supports (sequential only). */
161
+ const MAP_PALETTE_NAMES = Object.keys(SEQUENTIAL_PALETTES).join(', ');
162
+
163
+ /**
164
+ * Map color channels resolve scale.scheme against SEQUENTIAL_PALETTES with a
165
+ * silent fallback to blue — a typo'd scheme would render the default palette
166
+ * with no signal. Reject names outside the map's supported set here instead.
167
+ * Categorical channels never read schemes at all (colors come from
168
+ * scale.domain/scale.range), so any scheme there is dead config.
169
+ */
170
+ function checkMapSchemeName(
171
+ channelObj: Record<string, unknown>,
172
+ channelPath: string,
173
+ errors: ValidationError[],
174
+ ): void {
175
+ const scale = channelObj.scale as Record<string, unknown> | undefined;
176
+ if (!scale || typeof scale.scheme !== 'string') return;
177
+
178
+ if (channelObj.type === 'nominal' || channelObj.type === 'ordinal') {
179
+ errors.push({
180
+ message: `Spec error: ${channelPath}.scale.scheme has no effect — categorical map colors come from scale.range and scale.domain`,
181
+ path: `${channelPath}.scale.scheme`,
182
+ code: 'INVALID_VALUE',
183
+ suggestion:
184
+ 'Remove scale.scheme. To customize categorical map colors, provide explicit colors via scale.range (with scale.domain for category order).',
185
+ });
186
+ return;
187
+ }
188
+
189
+ if (!(scale.scheme in SEQUENTIAL_PALETTES)) {
190
+ errors.push({
191
+ message: `Spec error: ${channelPath}.scale.scheme "${scale.scheme}" is not a supported map palette`,
192
+ path: `${channelPath}.scale.scheme`,
193
+ code: 'INVALID_VALUE',
194
+ suggestion: `Maps support the sequential palettes: ${MAP_PALETTE_NAMES}. The default is "blue".`,
195
+ });
196
+ }
197
+ }
198
+
199
+ // ---------------------------------------------------------------------------
200
+ // Range mark validation
201
+ // ---------------------------------------------------------------------------
202
+
203
+ const VALID_RANGE_STYLES = new Set(['dumbbell', 'arrow', 'bar']);
204
+
205
+ /**
206
+ * Range marks need an orientation-dependent encoding pair: x + x2 when the
207
+ * category axis is y (horizontal, the common editorial form), or y + y2 when
208
+ * the category axis is x (vertical). The static MARK_ENCODING_RULES table
209
+ * can't express "x2 required only when y is categorical", so this check
210
+ * enforces it with errors naming exactly what to add.
211
+ */
212
+ function validateRangeSpec(spec: Record<string, unknown>, errors: ValidationError[]): void {
213
+ const encoding = spec.encoding as Record<string, Record<string, unknown> | undefined>;
214
+ const data = spec.data as Record<string, unknown>[];
215
+ const x = encoding.x;
216
+ const y = encoding.y;
217
+ // Missing x/y already produced MISSING_FIELD errors from the generic loop.
218
+ if (!x?.field || typeof x.field !== 'string' || !y?.field || typeof y.field !== 'string') {
219
+ return;
220
+ }
221
+
222
+ const xType = (x.type as string | undefined) ?? inferFieldType(data, x.field);
223
+ const yType = (y.type as string | undefined) ?? inferFieldType(data, y.field);
224
+ const xIsCategory = xType === 'nominal' || xType === 'ordinal';
225
+ const yIsCategory = yType === 'nominal' || yType === 'ordinal';
226
+
227
+ if (xIsCategory && yIsCategory) {
228
+ errors.push({
229
+ message:
230
+ 'Spec error: range chart requires a quantitative value axis, but both encoding.x and encoding.y are categorical',
231
+ path: 'encoding',
232
+ code: 'ENCODING_MISMATCH',
233
+ suggestion:
234
+ 'Map the range start to a quantitative channel: x (with x2 for the end) for horizontal ranges, or y (with y2) for vertical ranges',
235
+ });
236
+ return;
237
+ }
238
+ if (!xIsCategory && !yIsCategory) {
239
+ errors.push({
240
+ message:
241
+ 'Spec error: range chart requires a nominal or ordinal category axis, but neither encoding.x nor encoding.y is categorical',
242
+ path: 'encoding',
243
+ code: 'ENCODING_MISMATCH',
244
+ suggestion:
245
+ 'Set encoding.y to a nominal field for horizontal ranges (the common form), or encoding.x for vertical ranges',
246
+ });
247
+ return;
248
+ }
249
+
250
+ const availableColumns = data.length > 0 ? Object.keys(data[0]).join(', ') : '';
251
+ if (yIsCategory) {
252
+ // Horizontal: x is the range start, x2 the end.
253
+ if (!encoding.x2) {
254
+ errors.push({
255
+ message:
256
+ 'Spec error: range chart requires encoding.x2 (the range end) when encoding.y is the category axis, but none was provided',
257
+ path: 'encoding.x2',
258
+ code: 'MISSING_FIELD',
259
+ suggestion: `Add encoding.x2 with the field holding the range's end value, e.g. x2: { field: "endValue" }. Available columns: ${availableColumns}`,
260
+ });
261
+ }
262
+ if (encoding.y2) {
263
+ errors.push({
264
+ message:
265
+ 'Spec error: encoding.y2 is not used on a horizontal range chart (category axis on y); the range end belongs on x2',
266
+ path: 'encoding.y2',
267
+ code: 'INVALID_VALUE',
268
+ suggestion:
269
+ 'Remove encoding.y2, or move the category to encoding.x and the values to y/y2 for a vertical range chart',
270
+ });
271
+ }
272
+ } else {
273
+ // Vertical: y is the range start, y2 the end.
274
+ if (!encoding.y2) {
275
+ errors.push({
276
+ message:
277
+ 'Spec error: range chart requires encoding.y2 (the range end) when encoding.x is the category axis, but none was provided',
278
+ path: 'encoding.y2',
279
+ code: 'MISSING_FIELD',
280
+ suggestion: `Add encoding.y2 with the field holding the range's end value, e.g. y2: { field: "endValue" }. Available columns: ${availableColumns}`,
281
+ });
282
+ }
283
+ if (encoding.x2) {
284
+ errors.push({
285
+ message:
286
+ 'Spec error: encoding.x2 is not used on a vertical range chart (category axis on x); the range end belongs on y2',
287
+ path: 'encoding.x2',
288
+ code: 'INVALID_VALUE',
289
+ suggestion:
290
+ 'Remove encoding.x2, or move the category to encoding.y and the values to x/x2 for a horizontal range chart',
291
+ });
292
+ }
293
+ }
294
+
295
+ // markDef options: style must be one of the known variants.
296
+ const mark = spec.mark;
297
+ if (mark && typeof mark === 'object' && !Array.isArray(mark)) {
298
+ const style = (mark as Record<string, unknown>).style;
299
+ if (style !== undefined && !VALID_RANGE_STYLES.has(style as string)) {
300
+ errors.push({
301
+ message: `Spec error: mark.style "${style}" is not valid for range marks. Must be one of: dumbbell, arrow, bar`,
302
+ path: 'mark.style',
303
+ code: 'INVALID_VALUE',
304
+ suggestion:
305
+ 'Use mark: { type: "range", style: "dumbbell" } (default), "arrow" for directional change, or "bar" for a plain floating range bar',
306
+ });
307
+ }
308
+ }
309
+ }
310
+
311
+ // ---------------------------------------------------------------------------
312
+ // Calendar mark validation
313
+ // ---------------------------------------------------------------------------
314
+
315
+ const VALID_WEEK_STARTS = new Set(['monday', 'sunday']);
316
+
317
+ /**
318
+ * Calendar heatmaps take daily dates on x and a quantitative value on color;
319
+ * the weeks-x-weekdays geometry is computed internally, so there is no y
320
+ * channel. This check enforces the parts the static MARK_ENCODING_RULES
321
+ * table can't express: no y, a temporal x even when the type is inferred,
322
+ * every date parseable, one row per day (daily granularity), and valid
323
+ * mark-level weekStart/cellRadius options.
324
+ */
325
+ function validateCalendarSpec(spec: Record<string, unknown>, errors: ValidationError[]): void {
326
+ const encoding = spec.encoding as Record<string, Record<string, unknown> | undefined>;
327
+ const data = spec.data as Record<string, unknown>[];
328
+
329
+ if (encoding.y) {
330
+ errors.push({
331
+ message:
332
+ 'Spec error: calendar chart does not accept encoding.y (the weeks x weekdays layout is computed from the dates)',
333
+ path: 'encoding.y',
334
+ code: 'ENCODING_MISMATCH',
335
+ suggestion:
336
+ 'Remove encoding.y. A calendar needs only x (temporal, daily dates) and color (quantitative).',
337
+ });
338
+ }
339
+
340
+ // Mark-level options (weekStart / cellRadius) on the MarkDef object form.
341
+ if (spec.mark && typeof spec.mark === 'object') {
342
+ const markDef = spec.mark as Record<string, unknown>;
343
+ if (markDef.weekStart !== undefined && !VALID_WEEK_STARTS.has(markDef.weekStart as string)) {
344
+ errors.push({
345
+ message: `Spec error: mark.weekStart "${markDef.weekStart}" is not a valid week start`,
346
+ path: 'mark.weekStart',
347
+ code: 'INVALID_VALUE',
348
+ suggestion:
349
+ 'Use mark: { type: "calendar", weekStart: "monday" } (default, ISO weeks) or "sunday"',
350
+ });
351
+ }
352
+ if (
353
+ markDef.cellRadius !== undefined &&
354
+ (typeof markDef.cellRadius !== 'number' ||
355
+ !Number.isFinite(markDef.cellRadius) ||
356
+ markDef.cellRadius < 0)
357
+ ) {
358
+ errors.push({
359
+ message: `Spec error: mark.cellRadius must be a non-negative number, got ${JSON.stringify(markDef.cellRadius)}`,
360
+ path: 'mark.cellRadius',
361
+ code: 'INVALID_VALUE',
362
+ suggestion:
363
+ 'Use a pixel radius like cellRadius: 1 (default) or cellRadius: 0 for square corners',
364
+ });
365
+ }
366
+ }
367
+
368
+ const x = encoding.x;
369
+ // Missing x already produced a MISSING_FIELD error from the generic loop.
370
+ if (!x?.field || typeof x.field !== 'string') return;
371
+
372
+ // The generic loop rejects a declared non-temporal type against the
373
+ // encoding rules table; this catches the inferred case (type omitted).
374
+ const xType = (x.type as string | undefined) ?? inferFieldType(data, x.field);
375
+ if (xType !== 'temporal') {
376
+ if (!x.type) {
377
+ errors.push({
378
+ message: `Spec error: calendar chart requires temporal encoding.x, but "${x.field}" was inferred as ${xType}`,
379
+ path: 'encoding.x',
380
+ code: 'ENCODING_MISMATCH',
381
+ suggestion: `Provide daily dates in "${x.field}" (ISO 8601 strings like "2024-01-15" or Date objects) and declare x: { field: "${x.field}", type: "temporal" }`,
382
+ });
383
+ }
384
+ return;
385
+ }
386
+
387
+ // Transforms (e.g. a timeUnit aggregation) reshape the data before the
388
+ // calendar sees it, so per-row date checks only apply to untransformed specs.
389
+ if (Array.isArray(spec.transform) && spec.transform.length > 0) return;
390
+
391
+ // Every date must parse; report the first offender by row index.
392
+ for (let i = 0; i < data.length; i++) {
393
+ const value = data[i][x.field];
394
+ if (value != null && !isParseableDate(value)) {
395
+ errors.push({
396
+ message: `Spec error: encoding.x.field "${x.field}" contains an unparseable date at data[${i}]: ${JSON.stringify(value)}`,
397
+ path: `data[${i}].${x.field}`,
398
+ code: 'INVALID_VALUE',
399
+ suggestion: `Fix data[${i}].${x.field} to a parseable date (ISO 8601 strings like "2024-01-15" or Date objects)`,
400
+ });
401
+ return;
402
+ }
403
+ }
404
+
405
+ // Daily granularity: more than one row on the same UTC day means the data
406
+ // is sub-daily (or duplicated) and cells would silently overwrite.
407
+ const seenDays = new Map<string, number>();
408
+ for (let i = 0; i < data.length; i++) {
409
+ const value = data[i][x.field];
410
+ if (value == null) continue;
411
+ const date = value instanceof Date ? value : new Date(value as string | number);
412
+ if (Number.isNaN(date.getTime())) continue;
413
+ const dayKey = `${date.getUTCFullYear()}-${date.getUTCMonth()}-${date.getUTCDate()}`;
414
+ const firstIndex = seenDays.get(dayKey);
415
+ if (firstIndex !== undefined) {
416
+ errors.push({
417
+ message: `Spec error: calendar chart requires daily granularity, but data[${firstIndex}] and data[${i}] fall on the same day (${JSON.stringify(value)})`,
418
+ path: `data[${i}].${x.field}`,
419
+ code: 'ENCODING_MISMATCH',
420
+ suggestion: `Aggregate to one row per day first, e.g. transform: [{ timeUnit: "yearmonthdate", field: "${x.field}", as: "day" }] plus an aggregate over the value field, or pre-aggregate the data.`,
421
+ });
422
+ return;
423
+ }
424
+ seenDays.set(dayKey, i);
425
+ }
426
+ }
427
+
52
428
  // ---------------------------------------------------------------------------
53
429
  // Chart validation
54
430
  // ---------------------------------------------------------------------------
@@ -59,6 +435,20 @@ function validateChartSpec(spec: Record<string, unknown>, errors: ValidationErro
59
435
 
60
436
  // Check data
61
437
  if (!Array.isArray(spec.data)) {
438
+ // Near-miss: the VL `data: { url }` form. Fetching is out of scope for a
439
+ // rendering library, so reject it with a pointed message. The `{ values }`
440
+ // form is unwrapped by the pre-validation sugar expansion and never
441
+ // reaches this check on the compile path.
442
+ if (spec.data && typeof spec.data === 'object' && 'url' in (spec.data as object)) {
443
+ errors.push({
444
+ message: 'Spec error: data.url is not supported (openchart does not fetch remote data)',
445
+ path: 'data.url',
446
+ code: 'INVALID_VALUE',
447
+ suggestion:
448
+ 'Fetch the data in your application and provide the rows inline: data: [...] or data: { values: [...] }',
449
+ });
450
+ return;
451
+ }
62
452
  errors.push({
63
453
  message: 'Spec error: "data" must be an array',
64
454
  path: 'data',
@@ -109,31 +499,128 @@ function validateChartSpec(spec: Record<string, unknown>, errors: ValidationErro
109
499
  const rules = MARK_ENCODING_RULES[markType as MarkType];
110
500
  const encoding = spec.encoding as Record<string, unknown>;
111
501
  const dataColumns = new Set(Object.keys(firstRow as Record<string, unknown>));
112
- const availableColumns = [...dataColumns].join(', ');
113
-
114
- // Validate required channels
502
+ const columnList = [...dataColumns];
503
+ const availableColumns = columnList.join(', ');
504
+
505
+ // Validate required channels. Arc's canonical channel is theta; waffle and
506
+ // parliament still key on y with theta as an alias. For backward compat,
507
+ // validateSpec (a public entry point that callers may invoke on un-expanded
508
+ // specs) accepts y as satisfying a theta requirement and vice versa on
509
+ // theta-capable marks.
510
+ const THETA_MARKS = new Set(['arc', 'waffle', 'parliament']);
511
+ const isThetaMark = THETA_MARKS.has(markType as string);
115
512
  for (const [channel, rule] of Object.entries(rules)) {
116
- if (rule.required && !encoding[channel]) {
513
+ if (!rule.required) continue;
514
+ const satisfiedByAlias =
515
+ isThetaMark && ((channel === 'theta' && encoding.y) || (channel === 'y' && encoding.theta));
516
+ if (!encoding[channel] && !satisfiedByAlias) {
117
517
  const allowedTypes = rule.allowedTypes.join(' or ');
518
+ const suggestedChannel = channel === 'y' && isThetaMark ? 'theta' : channel;
118
519
  errors.push({
119
- message: `Spec error: ${markType} chart requires encoding.${channel} but none was provided`,
120
- path: `encoding.${channel}`,
520
+ message: `Spec error: ${markType} chart requires encoding.${suggestedChannel} but none was provided`,
521
+ path: `encoding.${suggestedChannel}`,
522
+ code: 'MISSING_FIELD',
523
+ suggestion: `Add encoding.${suggestedChannel} with a field from your data (${availableColumns}) and type (${allowedTypes}). Example: ${suggestedChannel}: { field: "${[...dataColumns][0] ?? 'myField'}", type: "${rule.allowedTypes[0]}" }`,
524
+ });
525
+ }
526
+ }
527
+
528
+ // Beeswarm axis combination: the channel rules mark both axes optional, so
529
+ // enforce the shape here. Exactly one positional channel is the quantitative
530
+ // value axis; the other, when present, is the nominal/ordinal lane channel.
531
+ // Only declared types are checked; omitted types are inferred later.
532
+ if (markType === 'beeswarm') {
533
+ const xChannel = encoding.x as Record<string, unknown> | undefined;
534
+ const yChannel = encoding.y as Record<string, unknown> | undefined;
535
+ const xType = xChannel?.type as string | undefined;
536
+ const yType = yChannel?.type as string | undefined;
537
+ if (!xChannel && !yChannel) {
538
+ errors.push({
539
+ message:
540
+ 'Spec error: beeswarm chart requires a quantitative encoding.x or encoding.y (the value axis)',
541
+ path: 'encoding',
121
542
  code: 'MISSING_FIELD',
122
- suggestion: `Add encoding.${channel} with a field from your data (${availableColumns}) and type (${allowedTypes}). Example: ${channel}: { field: "${[...dataColumns][0] ?? 'myField'}", type: "${rule.allowedTypes[0]}" }`,
543
+ suggestion: `Add the value axis, e.g. x: { field: "${[...dataColumns][0] ?? 'myField'}", type: "quantitative" }. Optionally add a nominal channel on the other axis for grouped swarms.`,
544
+ });
545
+ } else if (xType === 'quantitative' && yType === 'quantitative') {
546
+ errors.push({
547
+ message:
548
+ 'Spec error: beeswarm chart accepts only one quantitative axis (the value axis), but both encoding.x and encoding.y are quantitative',
549
+ path: 'encoding',
550
+ code: 'ENCODING_MISMATCH',
551
+ suggestion:
552
+ 'Keep the value axis quantitative and change the other channel to nominal/ordinal (grouped swarm lanes), or remove it for a single swarm. For two quantitative axes use mark: "point".',
553
+ });
554
+ } else if (
555
+ xType !== 'quantitative' &&
556
+ yType !== 'quantitative' &&
557
+ (!xChannel || !!xType) &&
558
+ (!yChannel || !!yType)
559
+ ) {
560
+ errors.push({
561
+ message:
562
+ 'Spec error: beeswarm chart requires one quantitative axis (the value axis), but neither encoding.x nor encoding.y is quantitative',
563
+ path: 'encoding',
564
+ code: 'ENCODING_MISMATCH',
565
+ suggestion:
566
+ 'Set the value axis to type: "quantitative" (x for a horizontal swarm, y for a vertical one).',
123
567
  });
124
568
  }
125
569
  }
126
570
 
571
+ // Range marks: orientation-dependent x2/y2 requirement + mark.style options
572
+ if (markType === 'range') {
573
+ validateRangeSpec(spec, errors);
574
+ }
575
+
576
+ // Calendar marks: temporal daily x, no y, parseable dates, mark options
577
+ if (markType === 'calendar') {
578
+ validateCalendarSpec(spec, errors);
579
+ }
580
+
581
+ // Near-miss: VL's string expression form of calculate. A restricted string
582
+ // grammar is deliberately not supported (decision: structured form only);
583
+ // point authors at the structured equivalent instead.
584
+ if (Array.isArray(spec.transform)) {
585
+ const transforms = spec.transform as Record<string, unknown>[];
586
+ for (let i = 0; i < transforms.length; i++) {
587
+ const t = transforms[i];
588
+ if (t && typeof t === 'object' && typeof t.calculate === 'string') {
589
+ errors.push({
590
+ message: `Spec error: transform[${i}].calculate must be a structured expression object, not a string`,
591
+ path: `transform[${i}].calculate`,
592
+ code: 'INVALID_TYPE',
593
+ suggestion:
594
+ 'Use the structured form for single operations, e.g. calculate: { op: "/", field: "a", field2: "b" } with as: "ratio". For running or grouped computations use the window or aggregate transforms.',
595
+ });
596
+ }
597
+ }
598
+ }
599
+
127
600
  // Collect fields that transforms will create, so we don't reject them
128
601
  const transformFields = new Set<string>();
129
602
  if (Array.isArray(spec.transform)) {
130
603
  for (const t of spec.transform as Record<string, unknown>[]) {
604
+ // filter/bin/calculate/timeUnit/fold write their output(s) to `as`.
131
605
  if (typeof t.as === 'string') transformFields.add(t.as);
132
606
  if (Array.isArray(t.as)) {
133
607
  for (const f of t.as) {
134
608
  if (typeof f === 'string') transformFields.add(f);
135
609
  }
136
610
  }
611
+ // aggregate/window carry their output field names inside their op arrays,
612
+ // and aggregate preserves its groupby fields on the output rows.
613
+ const opList = (t.aggregate ?? t.window) as Record<string, unknown>[] | undefined;
614
+ if (Array.isArray(opList)) {
615
+ for (const op of opList) {
616
+ if (op && typeof op.as === 'string') transformFields.add(op.as);
617
+ }
618
+ }
619
+ if (Array.isArray(t.groupby)) {
620
+ for (const g of t.groupby) {
621
+ if (typeof g === 'string') transformFields.add(g);
622
+ }
623
+ }
137
624
  }
138
625
  }
139
626
 
@@ -160,7 +647,7 @@ function validateChartSpec(spec: Record<string, unknown>, errors: ValidationErro
160
647
  message: `Spec error: encoding.tooltip[${i}].field "${elem.field}" does not exist in data. Available columns: ${availableColumns}`,
161
648
  path: `encoding.tooltip[${i}].field`,
162
649
  code: 'DATA_FIELD_MISSING',
163
- suggestion: `Use one of the available data columns: ${availableColumns}`,
650
+ suggestion: `Use one of the available data columns: ${availableColumns}.${didYouMean(elem.field, columnList)}`,
164
651
  });
165
652
  }
166
653
  if (elem.type && !VALID_FIELD_TYPES.has(elem.type as string)) {
@@ -198,7 +685,7 @@ function validateChartSpec(spec: Record<string, unknown>, errors: ValidationErro
198
685
  message: `Spec error: encoding.${channel}.field "${channelObj.field}" does not exist in data. Available columns: ${availableColumns}`,
199
686
  path: `encoding.${channel}.field`,
200
687
  code: 'DATA_FIELD_MISSING',
201
- suggestion: `Use one of the available data columns: ${availableColumns}`,
688
+ suggestion: `Use one of the available data columns: ${availableColumns}.${didYouMean(channelObj.field, columnList)}`,
202
689
  });
203
690
  }
204
691
 
@@ -212,6 +699,11 @@ function validateChartSpec(spec: Record<string, unknown>, errors: ValidationErro
212
699
  });
213
700
  }
214
701
 
702
+ // Check scale.scheme names. Known names (including VL aliases) are
703
+ // resolved to palette ranges by the pre-validation sugar expansion, so a
704
+ // scheme surviving to this point on the compile path is an unknown name.
705
+ checkSchemeName(channelObj, `encoding.${channel}`, errors);
706
+
215
707
  // Check field type is allowed for this channel
216
708
  if (channelRule && channelObj.type && channelRule.allowedTypes.length > 0) {
217
709
  if (!channelRule.allowedTypes.includes(channelObj.type as FieldType)) {
@@ -324,7 +816,7 @@ function validateChartSpec(spec: Record<string, unknown>, errors: ValidationErro
324
816
  message: `Spec error: encoding.facet.field "${facet.field}" does not exist in data. Available columns: ${availableColumns}`,
325
817
  path: 'encoding.facet.field',
326
818
  code: 'DATA_FIELD_MISSING',
327
- suggestion: `Use one of the available data columns: ${availableColumns}`,
819
+ suggestion: `Use one of the available data columns: ${availableColumns}.${didYouMean(facet.field as string, columnList)}`,
328
820
  });
329
821
  }
330
822
  if (!facet.type || (facet.type !== 'nominal' && facet.type !== 'ordinal')) {
@@ -348,6 +840,58 @@ function validateChartSpec(spec: Record<string, unknown>, errors: ValidationErro
348
840
  }
349
841
  }
350
842
 
843
+ // Validate row/column facet channels
844
+ for (const channel of ['row', 'column'] as const) {
845
+ const ch = (encoding as Record<string, unknown>)[channel];
846
+ if (ch && typeof ch === 'object') {
847
+ const facetCh = ch as Record<string, unknown>;
848
+ if (!facetCh.field || typeof facetCh.field !== 'string') {
849
+ errors.push({
850
+ message: `Spec error: encoding.${channel}.field is required and must be a non-empty string`,
851
+ path: `encoding.${channel}.field`,
852
+ code: 'MISSING_FIELD',
853
+ suggestion: `Add a field property to encoding.${channel} (e.g. { field: "category", type: "nominal" })`,
854
+ });
855
+ } else if (
856
+ !dataColumns.has(facetCh.field as string) &&
857
+ !transformFields.has(facetCh.field as string)
858
+ ) {
859
+ errors.push({
860
+ message: `Spec error: encoding.${channel}.field "${facetCh.field}" does not exist in data. Available columns: ${availableColumns}`,
861
+ path: `encoding.${channel}.field`,
862
+ code: 'DATA_FIELD_MISSING',
863
+ suggestion: `Use one of the available data columns: ${availableColumns}.${didYouMean(facetCh.field as string, columnList)}`,
864
+ });
865
+ }
866
+ if (!facetCh.type || (facetCh.type !== 'nominal' && facetCh.type !== 'ordinal')) {
867
+ errors.push({
868
+ message: `Spec error: encoding.${channel}.type must be "nominal" or "ordinal"${facetCh.type ? `, got "${facetCh.type}"` : ''}`,
869
+ path: `encoding.${channel}.type`,
870
+ code: facetCh.type ? 'INVALID_VALUE' : 'MISSING_FIELD',
871
+ suggestion: `Set encoding.${channel}.type to "nominal" or "ordinal"`,
872
+ });
873
+ }
874
+ if (encoding.facet && typeof encoding.facet === 'object') {
875
+ errors.push({
876
+ message: `Spec error: encoding.${channel} and encoding.facet cannot be used together`,
877
+ path: `encoding.${channel}`,
878
+ code: 'INVALID_VALUE',
879
+ suggestion: `Use either encoding.${channel} or encoding.facet, not both`,
880
+ });
881
+ }
882
+ const otherChannel = channel === 'row' ? 'column' : 'row';
883
+ if ((encoding as Record<string, unknown>)[otherChannel]) {
884
+ errors.push({
885
+ message:
886
+ 'Spec error: encoding.row and encoding.column cannot be used together (cross-product faceting is not yet supported)',
887
+ path: `encoding.${channel}`,
888
+ code: 'INVALID_VALUE',
889
+ suggestion: 'Use encoding.facet with columns for grid layout, or use only row or column',
890
+ });
891
+ }
892
+ }
893
+ }
894
+
351
895
  // Validate darkMode if provided
352
896
  if (spec.darkMode !== undefined && !VALID_DARK_MODES.has(spec.darkMode as string)) {
353
897
  errors.push({
@@ -358,6 +902,60 @@ function validateChartSpec(spec: Record<string, unknown>, errors: ValidationErro
358
902
  'Use one of: "auto" (system preference), "force" (always dark), or "off" (always light)',
359
903
  });
360
904
  }
905
+
906
+ // Validate youDrawIt: line marks only, single-series only, "from" required.
907
+ if (spec.youDrawIt !== undefined && spec.youDrawIt !== false) {
908
+ if (markType !== 'line') {
909
+ errors.push({
910
+ message: `Spec error: youDrawIt is only supported on line charts, but mark is "${markType}"`,
911
+ path: 'youDrawIt',
912
+ code: 'ENCODING_MISMATCH',
913
+ suggestion: 'Remove youDrawIt or change mark to "line".',
914
+ });
915
+ } else {
916
+ const config = spec.youDrawIt as Record<string, unknown>;
917
+ if (
918
+ typeof config !== 'object' ||
919
+ Array.isArray(config) ||
920
+ (config.from !== 0 && !config.from)
921
+ ) {
922
+ errors.push({
923
+ message: 'Spec error: youDrawIt.from is required (the x value where drawing starts)',
924
+ path: 'youDrawIt.from',
925
+ code: 'MISSING_FIELD',
926
+ suggestion:
927
+ 'Set youDrawIt.from to an x value from your data, e.g. youDrawIt: { from: "2010" }',
928
+ });
929
+ } else if (typeof config.from !== 'string' && typeof config.from !== 'number') {
930
+ errors.push({
931
+ message: 'Spec error: youDrawIt.from must be a string or number',
932
+ path: 'youDrawIt.from',
933
+ code: 'INVALID_TYPE',
934
+ suggestion: 'Provide the x value as a string or number matching your data.',
935
+ });
936
+ }
937
+
938
+ const data = Array.isArray(spec.data) ? (spec.data as Record<string, unknown>[]) : [];
939
+ const colorCh = encoding?.color as Record<string, unknown> | undefined;
940
+ if (colorCh && typeof colorCh === 'object' && typeof colorCh.field === 'string') {
941
+ const colorType =
942
+ (colorCh.type as string | undefined) ?? inferFieldType(data, colorCh.field);
943
+ if (colorType !== 'quantitative') {
944
+ const distinct = new Set(data.map((row) => row[colorCh.field as string]));
945
+ if (distinct.size > 1) {
946
+ errors.push({
947
+ message:
948
+ 'Spec error: youDrawIt only supports single-series line charts, but encoding.color has multiple distinct values',
949
+ path: 'youDrawIt',
950
+ code: 'ENCODING_MISMATCH',
951
+ suggestion:
952
+ 'Remove encoding.color (or filter the data to one series) to use youDrawIt.',
953
+ });
954
+ }
955
+ }
956
+ }
957
+ }
958
+ }
361
959
  }
362
960
 
363
961
  // ---------------------------------------------------------------------------
@@ -410,7 +1008,8 @@ function validateTableSpec(spec: Record<string, unknown>, errors: ValidationErro
410
1008
  }
411
1009
 
412
1010
  const dataColumns = new Set(Object.keys(firstRow as Record<string, unknown>));
413
- const availableColumns = [...dataColumns].join(', ');
1011
+ const columnList = [...dataColumns];
1012
+ const availableColumns = columnList.join(', ');
414
1013
  const columns = spec.columns as Record<string, unknown>[];
415
1014
 
416
1015
  for (let i = 0; i < columns.length; i++) {
@@ -442,7 +1041,7 @@ function validateTableSpec(spec: Record<string, unknown>, errors: ValidationErro
442
1041
  message: `Spec error: columns[${i}].key "${col.key}" does not exist in data. Available columns: ${availableColumns}`,
443
1042
  path: `columns[${i}].key`,
444
1043
  code: 'DATA_FIELD_MISSING',
445
- suggestion: `Use one of the available data columns: ${availableColumns}`,
1044
+ suggestion: `Use one of the available data columns: ${availableColumns}.${didYouMean(col.key as string, columnList)}`,
446
1045
  });
447
1046
  }
448
1047
 
@@ -552,7 +1151,7 @@ function validateGraphSpec(spec: Record<string, unknown>, errors: ValidationErro
552
1151
  message: `Spec error: edges[${i}].source "${edge.source}" does not reference an existing node id`,
553
1152
  path: `edges[${i}].source`,
554
1153
  code: 'DATA_FIELD_MISSING',
555
- suggestion: `Use one of the existing node ids: ${[...nodeIds].slice(0, 5).join(', ')}${nodeIds.size > 5 ? '...' : ''}`,
1154
+ suggestion: `Use one of the existing node ids: ${[...nodeIds].slice(0, 5).join(', ')}${nodeIds.size > 5 ? '...' : ''}.${didYouMean(edge.source, [...nodeIds])}`,
556
1155
  });
557
1156
  }
558
1157
 
@@ -568,7 +1167,7 @@ function validateGraphSpec(spec: Record<string, unknown>, errors: ValidationErro
568
1167
  message: `Spec error: edges[${i}].target "${edge.target}" does not reference an existing node id`,
569
1168
  path: `edges[${i}].target`,
570
1169
  code: 'DATA_FIELD_MISSING',
571
- suggestion: `Use one of the existing node ids: ${[...nodeIds].slice(0, 5).join(', ')}${nodeIds.size > 5 ? '...' : ''}`,
1170
+ suggestion: `Use one of the existing node ids: ${[...nodeIds].slice(0, 5).join(', ')}${nodeIds.size > 5 ? '...' : ''}.${didYouMean(edge.target, [...nodeIds])}`,
572
1171
  });
573
1172
  }
574
1173
  }
@@ -589,7 +1188,7 @@ function validateGraphSpec(spec: Record<string, unknown>, errors: ValidationErro
589
1188
  message: `Spec error: encoding.${channel}.field "${ch.field}" does not exist on nodes. Available fields: ${[...nodeFields].join(', ')}`,
590
1189
  path: `encoding.${channel}.field`,
591
1190
  code: 'DATA_FIELD_MISSING',
592
- suggestion: `Use one of the node fields: ${[...nodeFields].join(', ')}`,
1191
+ suggestion: `Use one of the node fields: ${[...nodeFields].join(', ')}.${didYouMean(ch.field, [...nodeFields])}`,
593
1192
  });
594
1193
  }
595
1194
  }
@@ -602,10 +1201,18 @@ function validateGraphSpec(spec: Record<string, unknown>, errors: ValidationErro
602
1201
  message: `Spec error: encoding.${channel}.field "${ch.field}" does not exist on edges. Available fields: ${[...edgeFields].join(', ')}`,
603
1202
  path: `encoding.${channel}.field`,
604
1203
  code: 'DATA_FIELD_MISSING',
605
- suggestion: `Use one of the edge fields: ${[...edgeFields].join(', ')}`,
1204
+ suggestion: `Use one of the edge fields: ${[...edgeFields].join(', ')}.${didYouMean(ch.field, [...edgeFields])}`,
606
1205
  });
607
1206
  }
608
1207
  }
1208
+
1209
+ // Graph never reads scale.scheme: colors come from scale.range/scale.domain
1210
+ checkSchemeUnused(
1211
+ encoding,
1212
+ errors,
1213
+ 'graph colors come from scale.range and scale.domain',
1214
+ 'Remove scale.scheme. To customize graph colors, provide explicit colors via scale.range.',
1215
+ );
609
1216
  }
610
1217
 
611
1218
  // Validate layout type if specified
@@ -677,7 +1284,8 @@ function validateSankeySpec(spec: Record<string, unknown>, errors: ValidationErr
677
1284
 
678
1285
  const encoding = spec.encoding as Record<string, unknown>;
679
1286
  const dataColumns = new Set(Object.keys(firstRow as Record<string, unknown>));
680
- const availableColumns = [...dataColumns].join(', ');
1287
+ const columnList = [...dataColumns];
1288
+ const availableColumns = columnList.join(', ');
681
1289
 
682
1290
  // Required channels
683
1291
  for (const channel of ['source', 'target', 'value'] as const) {
@@ -687,7 +1295,7 @@ function validateSankeySpec(spec: Record<string, unknown>, errors: ValidationErr
687
1295
  message: `Spec error: sankey encoding requires "${channel}" channel`,
688
1296
  path: `encoding.${channel}`,
689
1297
  code: 'MISSING_FIELD',
690
- suggestion: `Add encoding.${channel} with a field from your data (${availableColumns}). Example: ${channel}: { field: "${[...dataColumns][0] ?? 'myField'}", type: "${channel === 'value' ? 'quantitative' : 'nominal'}" }`,
1298
+ suggestion: `Add encoding.${channel} with a field from your data (${availableColumns}). Example: ${channel}: { field: "${columnList[0] ?? 'myField'}", type: "${channel === 'value' ? 'quantitative' : 'nominal'}" }`,
691
1299
  });
692
1300
  continue;
693
1301
  }
@@ -707,11 +1315,19 @@ function validateSankeySpec(spec: Record<string, unknown>, errors: ValidationErr
707
1315
  message: `Spec error: encoding.${channel}.field "${ch.field}" does not exist in data. Available columns: ${availableColumns}`,
708
1316
  path: `encoding.${channel}.field`,
709
1317
  code: 'DATA_FIELD_MISSING',
710
- suggestion: `Use one of the available data columns: ${availableColumns}`,
1318
+ suggestion: `Use one of the available data columns: ${availableColumns}.${didYouMean(ch.field as string, columnList)}`,
711
1319
  });
712
1320
  }
713
1321
  }
714
1322
 
1323
+ // Sankey never reads scale.scheme: node colors cycle theme.colors.categorical
1324
+ checkSchemeUnused(
1325
+ encoding,
1326
+ errors,
1327
+ 'sankey node colors cycle the theme categorical palette',
1328
+ 'Remove scale.scheme. To customize sankey colors, set theme.colors.categorical.',
1329
+ );
1330
+
715
1331
  // Validate darkMode if provided
716
1332
  if (spec.darkMode !== undefined && !VALID_DARK_MODES.has(spec.darkMode as string)) {
717
1333
  errors.push({
@@ -790,7 +1406,8 @@ function validateTileMapSpec(spec: Record<string, unknown>, errors: ValidationEr
790
1406
 
791
1407
  const encoding = spec.encoding as Record<string, unknown>;
792
1408
  const dataColumns = new Set(Object.keys(firstRow as Record<string, unknown>));
793
- const availableColumns = [...dataColumns].join(', ');
1409
+ const columnList = [...dataColumns];
1410
+ const availableColumns = columnList.join(', ');
794
1411
 
795
1412
  // Required channels
796
1413
  for (const channel of ['state', 'value'] as const) {
@@ -800,7 +1417,7 @@ function validateTileMapSpec(spec: Record<string, unknown>, errors: ValidationEr
800
1417
  message: `Spec error: tilemap encoding requires "${channel}" channel`,
801
1418
  path: `encoding.${channel}`,
802
1419
  code: 'MISSING_FIELD',
803
- suggestion: `Add encoding.${channel} with a field from your data (${availableColumns}). Example: ${channel}: { field: "${[...dataColumns][0] ?? 'myField'}", type: "${channel === 'value' ? 'quantitative' : 'nominal'}" }`,
1420
+ suggestion: `Add encoding.${channel} with a field from your data (${availableColumns}). Example: ${channel}: { field: "${columnList[0] ?? 'myField'}", type: "${channel === 'value' ? 'quantitative' : 'nominal'}" }`,
804
1421
  });
805
1422
  continue;
806
1423
  }
@@ -820,12 +1437,23 @@ function validateTileMapSpec(spec: Record<string, unknown>, errors: ValidationEr
820
1437
  message: `Spec error: encoding.${channel}.field "${ch.field}" does not exist in data. Available columns: ${availableColumns}`,
821
1438
  path: `encoding.${channel}.field`,
822
1439
  code: 'DATA_FIELD_MISSING',
823
- suggestion: `Use one of the available data columns: ${availableColumns}`,
1440
+ suggestion: `Use one of the available data columns: ${availableColumns}.${didYouMean(ch.field as string, columnList)}`,
824
1441
  });
825
1442
  }
826
1443
  }
827
1444
  }
828
1445
 
1446
+ // Tilemap never reads scale.scheme: fills come from the top-level palette
1447
+ // prop (encoding may be present in both record-map and array-data modes)
1448
+ if (spec.encoding && typeof spec.encoding === 'object') {
1449
+ checkSchemeUnused(
1450
+ spec.encoding as Record<string, unknown>,
1451
+ errors,
1452
+ 'tilemap fills come from the top-level palette property',
1453
+ `Remove scale.scheme and set the top-level palette property instead, e.g. palette: "green". Supported: ${MAP_PALETTE_NAMES}.`,
1454
+ );
1455
+ }
1456
+
829
1457
  // Validate darkMode if provided
830
1458
  if (spec.darkMode !== undefined && !VALID_DARK_MODES.has(spec.darkMode as string)) {
831
1459
  errors.push({
@@ -838,6 +1466,253 @@ function validateTileMapSpec(spec: Record<string, unknown>, errors: ValidationEr
838
1466
  }
839
1467
  }
840
1468
 
1469
+ // ---------------------------------------------------------------------------
1470
+ // Map validation
1471
+ // ---------------------------------------------------------------------------
1472
+
1473
+ function validateMapSpec(spec: Record<string, unknown>, errors: ValidationError[]): void {
1474
+ if (!spec.geo || typeof spec.geo !== 'object') {
1475
+ errors.push({
1476
+ message: 'Spec error: map spec requires a "geo" object with a TopoJSON "features" field',
1477
+ path: 'geo',
1478
+ code: 'MISSING_FIELD',
1479
+ suggestion: 'Add a geo object, e.g. geo: { features: topoJsonData, projection: "identity" }',
1480
+ });
1481
+ return;
1482
+ }
1483
+
1484
+ const geo = spec.geo as Record<string, unknown>;
1485
+ if (!geo.features) {
1486
+ errors.push({
1487
+ message: 'Spec error: geo requires a "features" field containing TopoJSON topology',
1488
+ path: 'geo.features',
1489
+ code: 'MISSING_FIELD',
1490
+ suggestion:
1491
+ 'Import a TopoJSON file and pass it as geo.features, e.g. import topo from "us-atlas/states-albers-10m.json"',
1492
+ });
1493
+ return;
1494
+ }
1495
+
1496
+ if (!spec.encoding || typeof spec.encoding !== 'object') {
1497
+ errors.push({
1498
+ message: 'Spec error: map spec requires an "encoding" object with key and color channels',
1499
+ path: 'encoding',
1500
+ code: 'MISSING_FIELD',
1501
+ suggestion:
1502
+ 'Add an encoding object, e.g. encoding: { key: { field: "id", type: "nominal" }, color: { field: "value", type: "quantitative" } }',
1503
+ });
1504
+ return;
1505
+ }
1506
+
1507
+ // Map color channels resolve scale.scheme against the sequential palettes
1508
+ // with a silent blue fallback; reject unsupported names here instead.
1509
+ const mapEncoding = spec.encoding as Record<string, unknown>;
1510
+ if (mapEncoding.color && typeof mapEncoding.color === 'object') {
1511
+ checkMapSchemeName(mapEncoding.color as Record<string, unknown>, 'encoding.color', errors);
1512
+ }
1513
+ if (spec.points && typeof spec.points === 'object') {
1514
+ const pointsLayer = spec.points as Record<string, unknown>;
1515
+ if (pointsLayer.color && typeof pointsLayer.color === 'object') {
1516
+ checkMapSchemeName(pointsLayer.color as Record<string, unknown>, 'points.color', errors);
1517
+ }
1518
+ }
1519
+
1520
+ // Validate geo.focus if present
1521
+ if ('focus' in geo && geo.focus !== undefined && geo.focus !== null) {
1522
+ const focus = geo.focus;
1523
+ if (typeof focus === 'string' || typeof focus === 'number') {
1524
+ // valid: single id
1525
+ } else if (Array.isArray(focus)) {
1526
+ for (let i = 0; i < focus.length; i++) {
1527
+ if (typeof focus[i] !== 'string' && typeof focus[i] !== 'number') {
1528
+ errors.push({
1529
+ message: `Spec error: geo.focus array element [${i}] must be a string or number`,
1530
+ path: `geo.focus[${i}]`,
1531
+ code: 'INVALID_TYPE',
1532
+ suggestion: 'Each element in the focus array should be a feature id (string or number)',
1533
+ });
1534
+ }
1535
+ }
1536
+ } else if (typeof focus === 'object') {
1537
+ const focusObj = focus as Record<string, unknown>;
1538
+ if ('points' in focusObj) {
1539
+ // Points form: { points: true | { field, value }, padding? } fits the
1540
+ // point layer's cluster (all points, or a matching subset).
1541
+ const pts = focusObj.points;
1542
+ const isSubset =
1543
+ typeof pts === 'object' &&
1544
+ pts !== null &&
1545
+ typeof (pts as Record<string, unknown>).field === 'string' &&
1546
+ 'value' in (pts as Record<string, unknown>);
1547
+ if (pts !== true && !isSubset) {
1548
+ errors.push({
1549
+ message:
1550
+ 'Spec error: geo.focus.points must be true or { field: string, value: string | number }',
1551
+ path: 'geo.focus.points',
1552
+ code: 'INVALID_TYPE',
1553
+ suggestion:
1554
+ 'Use { points: true } to fit all points, or { points: { field: "rating", value: "F" } } to fit a subset.',
1555
+ });
1556
+ }
1557
+ if (focusObj.padding !== undefined && typeof focusObj.padding !== 'number') {
1558
+ errors.push({
1559
+ message: 'Spec error: geo.focus.padding must be a number',
1560
+ path: 'geo.focus.padding',
1561
+ code: 'INVALID_TYPE',
1562
+ suggestion: 'Provide padding as a number in map-local units, e.g. padding: 16',
1563
+ });
1564
+ }
1565
+ } else if (!('features' in focusObj) || focusObj.features == null) {
1566
+ errors.push({
1567
+ message: 'Spec error: geo.focus object must have a "features" or "points" property',
1568
+ path: 'geo.focus.features',
1569
+ code: 'MISSING_FIELD',
1570
+ suggestion:
1571
+ 'Provide feature id(s) to focus on, e.g. { features: "36", padding: 32 }, or { points: true } to fit the point cluster.',
1572
+ });
1573
+ } else {
1574
+ const feats = focusObj.features;
1575
+ if (typeof feats !== 'string' && typeof feats !== 'number' && !Array.isArray(feats)) {
1576
+ errors.push({
1577
+ message:
1578
+ 'Spec error: geo.focus.features must be a string, number, or array of strings/numbers',
1579
+ path: 'geo.focus.features',
1580
+ code: 'INVALID_TYPE',
1581
+ suggestion: 'Use a feature id or array of ids, e.g. "36" or ["36", "34"]',
1582
+ });
1583
+ } else if (Array.isArray(feats)) {
1584
+ for (let i = 0; i < feats.length; i++) {
1585
+ if (typeof feats[i] !== 'string' && typeof feats[i] !== 'number') {
1586
+ errors.push({
1587
+ message: `Spec error: geo.focus.features[${i}] must be a string or number`,
1588
+ path: `geo.focus.features[${i}]`,
1589
+ code: 'INVALID_TYPE',
1590
+ suggestion: 'Each element should be a feature id (string or number)',
1591
+ });
1592
+ }
1593
+ }
1594
+ }
1595
+ if (focusObj.padding !== undefined && typeof focusObj.padding !== 'number') {
1596
+ errors.push({
1597
+ message: 'Spec error: geo.focus.padding must be a number',
1598
+ path: 'geo.focus.padding',
1599
+ code: 'INVALID_TYPE',
1600
+ suggestion: 'Provide padding as a number in map-local units, e.g. padding: 32',
1601
+ });
1602
+ }
1603
+ }
1604
+ } else {
1605
+ errors.push({
1606
+ message:
1607
+ 'Spec error: geo.focus must be a string, number, array of ids, or object with { features, padding? } or { points: true, padding? }',
1608
+ path: 'geo.focus',
1609
+ code: 'INVALID_TYPE',
1610
+ suggestion:
1611
+ 'Use a feature id, array of ids, { features: "36", padding: 16 }, or { points: true }. Pass null to clear focus.',
1612
+ });
1613
+ }
1614
+ }
1615
+
1616
+ const encoding = spec.encoding as Record<string, unknown>;
1617
+ const hasPoints = spec.points && typeof spec.points === 'object' && !Array.isArray(spec.points);
1618
+
1619
+ // key is required unless basemap-only mode (points present + empty/no choropleth data)
1620
+ const specData = spec.data as unknown[] | undefined;
1621
+ const isBasemapOnly = hasPoints && (!specData || specData.length === 0);
1622
+ const keyCh = encoding.key as Record<string, unknown> | undefined;
1623
+ if (!isBasemapOnly) {
1624
+ if (!keyCh || typeof keyCh !== 'object') {
1625
+ errors.push({
1626
+ message: 'Spec error: map encoding requires "key" channel',
1627
+ path: 'encoding.key',
1628
+ code: 'MISSING_FIELD',
1629
+ suggestion: 'Add encoding.key, e.g. key: { field: "id", type: "nominal" }',
1630
+ });
1631
+ } else if (!keyCh.field || typeof keyCh.field !== 'string') {
1632
+ errors.push({
1633
+ message: 'Spec error: encoding.key must have a "field" string',
1634
+ path: 'encoding.key.field',
1635
+ code: 'MISSING_FIELD',
1636
+ suggestion: 'Add a field name, e.g. key: { field: "id" }',
1637
+ });
1638
+ }
1639
+ }
1640
+
1641
+ // color is required UNLESS points is present (basemap-only mode)
1642
+ const colorCh = encoding.color as Record<string, unknown> | undefined;
1643
+ if (!hasPoints) {
1644
+ if (!colorCh || typeof colorCh !== 'object') {
1645
+ errors.push({
1646
+ message:
1647
+ 'Spec error: map encoding requires "color" channel (or add a "points" layer for basemap-only mode)',
1648
+ path: 'encoding.color',
1649
+ code: 'MISSING_FIELD',
1650
+ suggestion: 'Add encoding.color, e.g. color: { field: "value", type: "quantitative" }',
1651
+ });
1652
+ } else if (!colorCh.field || typeof colorCh.field !== 'string') {
1653
+ errors.push({
1654
+ message: 'Spec error: encoding.color must have a "field" string',
1655
+ path: 'encoding.color.field',
1656
+ code: 'MISSING_FIELD',
1657
+ suggestion: 'Add a field name, e.g. color: { field: "value" }',
1658
+ });
1659
+ }
1660
+ } else if (
1661
+ colorCh &&
1662
+ typeof colorCh === 'object' &&
1663
+ (!colorCh.field || typeof colorCh.field !== 'string')
1664
+ ) {
1665
+ errors.push({
1666
+ message: 'Spec error: encoding.color must have a "field" string when provided',
1667
+ path: 'encoding.color.field',
1668
+ code: 'MISSING_FIELD',
1669
+ suggestion: 'Add a field name, e.g. color: { field: "value" }',
1670
+ });
1671
+ }
1672
+
1673
+ // Validate points layer if present
1674
+ if (hasPoints) {
1675
+ const points = spec.points as Record<string, unknown>;
1676
+ if (!Array.isArray(points.data)) {
1677
+ errors.push({
1678
+ message: 'Spec error: points.data must be an array of data rows',
1679
+ path: 'points.data',
1680
+ code: 'INVALID_TYPE',
1681
+ suggestion: 'Provide data as an array of objects with lat/lng fields',
1682
+ });
1683
+ }
1684
+ for (const ch of ['longitude', 'latitude'] as const) {
1685
+ const enc = points[ch] as Record<string, unknown> | undefined;
1686
+ if (!enc || typeof enc !== 'object') {
1687
+ errors.push({
1688
+ message: `Spec error: points requires "${ch}" encoding channel`,
1689
+ path: `points.${ch}`,
1690
+ code: 'MISSING_FIELD',
1691
+ suggestion: `Add points.${ch}, e.g. ${ch}: { field: "${ch === 'longitude' ? 'lon' : 'lat'}", type: "quantitative" }`,
1692
+ });
1693
+ } else if (!enc.field || typeof enc.field !== 'string') {
1694
+ errors.push({
1695
+ message: `Spec error: points.${ch} must have a "field" string`,
1696
+ path: `points.${ch}.field`,
1697
+ code: 'MISSING_FIELD',
1698
+ suggestion: `Add a field name, e.g. ${ch}: { field: "${ch === 'longitude' ? 'lon' : 'lat'}" }`,
1699
+ });
1700
+ }
1701
+ }
1702
+ for (const ch of ['size', 'color', 'key'] as const) {
1703
+ const enc = points[ch] as Record<string, unknown> | undefined;
1704
+ if (enc && typeof enc === 'object' && (!enc.field || typeof enc.field !== 'string')) {
1705
+ errors.push({
1706
+ message: `Spec error: points.${ch} must have a "field" string when provided`,
1707
+ path: `points.${ch}.field`,
1708
+ code: 'MISSING_FIELD',
1709
+ suggestion: `Add a field name, e.g. ${ch}: { field: "myField" }`,
1710
+ });
1711
+ }
1712
+ }
1713
+ }
1714
+ }
1715
+
841
1716
  // ---------------------------------------------------------------------------
842
1717
  // BarList validation
843
1718
  // ---------------------------------------------------------------------------
@@ -889,7 +1764,8 @@ function validateBarListSpec(spec: Record<string, unknown>, errors: ValidationEr
889
1764
 
890
1765
  const encoding = spec.encoding as Record<string, unknown>;
891
1766
  const dataColumns = new Set(Object.keys(firstRow as Record<string, unknown>));
892
- const availableColumns = [...dataColumns].join(', ');
1767
+ const columnList = [...dataColumns];
1768
+ const availableColumns = columnList.join(', ');
893
1769
 
894
1770
  for (const channel of ['label', 'value'] as const) {
895
1771
  const ch = encoding[channel] as Record<string, unknown> | undefined;
@@ -898,7 +1774,7 @@ function validateBarListSpec(spec: Record<string, unknown>, errors: ValidationEr
898
1774
  message: `Spec error: barlist encoding requires "${channel}" channel`,
899
1775
  path: `encoding.${channel}`,
900
1776
  code: 'MISSING_FIELD',
901
- suggestion: `Add encoding.${channel} with a field from your data (${availableColumns}). Example: ${channel}: { field: "${[...dataColumns][0] ?? 'myField'}", type: "${channel === 'value' ? 'quantitative' : 'nominal'}" }`,
1777
+ suggestion: `Add encoding.${channel} with a field from your data (${availableColumns}). Example: ${channel}: { field: "${columnList[0] ?? 'myField'}", type: "${channel === 'value' ? 'quantitative' : 'nominal'}" }`,
902
1778
  });
903
1779
  continue;
904
1780
  }
@@ -918,7 +1794,7 @@ function validateBarListSpec(spec: Record<string, unknown>, errors: ValidationEr
918
1794
  message: `Spec error: encoding.${channel}.field "${ch.field}" does not exist in data. Available columns: ${availableColumns}`,
919
1795
  path: `encoding.${channel}.field`,
920
1796
  code: 'DATA_FIELD_MISSING',
921
- suggestion: `Use one of the available data columns: ${availableColumns}`,
1797
+ suggestion: `Use one of the available data columns: ${availableColumns}.${didYouMean(ch.field as string, columnList)}`,
922
1798
  });
923
1799
  }
924
1800
  }
@@ -933,11 +1809,19 @@ function validateBarListSpec(spec: Record<string, unknown>, errors: ValidationEr
933
1809
  message: `Spec error: encoding.${channel}.field "${field}" does not exist in data. Available columns: ${availableColumns}`,
934
1810
  path: `encoding.${channel}.field`,
935
1811
  code: 'DATA_FIELD_MISSING',
936
- suggestion: `Use one of the available data columns: ${availableColumns}`,
1812
+ suggestion: `Use one of the available data columns: ${availableColumns}.${didYouMean(field, columnList)}`,
937
1813
  });
938
1814
  }
939
1815
  }
940
1816
 
1817
+ // Barlist never reads scale.scheme: bars cycle the built-in barlist palette
1818
+ checkSchemeUnused(
1819
+ encoding,
1820
+ errors,
1821
+ 'bar list colors cycle the built-in barlist palette',
1822
+ 'Remove scale.scheme. Bar list colors are not configurable via encoding; they cycle the built-in palette.',
1823
+ );
1824
+
941
1825
  if (spec.darkMode !== undefined && !VALID_DARK_MODES.has(spec.darkMode as string)) {
942
1826
  errors.push({
943
1827
  message: 'Spec error: darkMode must be "auto", "force", or "off"',
@@ -1087,21 +1971,64 @@ export function validateSpec(spec: unknown): ValidationResult {
1087
1971
  const isGraph = obj.type === 'graph';
1088
1972
  const isSankey = obj.type === 'sankey';
1089
1973
  const isTileMap = obj.type === 'tilemap';
1974
+ const isMap = obj.type === 'map';
1090
1975
  const isBarList = obj.type === 'barlist';
1091
- const isLayer = hasLayer && !isTable && !isGraph && !isSankey && !isTileMap && !isBarList;
1976
+ const isLayer =
1977
+ hasLayer && !isTable && !isGraph && !isSankey && !isTileMap && !isMap && !isBarList;
1092
1978
  const isChart =
1093
- hasMark && !hasLayer && !isTable && !isGraph && !isSankey && !isTileMap && !isBarList;
1094
-
1095
- if (!isChart && !isTable && !isGraph && !isSankey && !isTileMap && !isBarList && !isLayer) {
1979
+ hasMark && !hasLayer && !isTable && !isGraph && !isSankey && !isTileMap && !isMap && !isBarList;
1980
+
1981
+ if (
1982
+ !isChart &&
1983
+ !isTable &&
1984
+ !isGraph &&
1985
+ !isSankey &&
1986
+ !isTileMap &&
1987
+ !isMap &&
1988
+ !isBarList &&
1989
+ !isLayer
1990
+ ) {
1991
+ // Near-misses for VL composition operators that are unsupported by decision
1992
+ if ('hconcat' in obj || 'vconcat' in obj) {
1993
+ const key = 'hconcat' in obj ? 'hconcat' : 'vconcat';
1994
+ return {
1995
+ valid: false,
1996
+ errors: [
1997
+ {
1998
+ message: `Spec error: "${key}" composition is not supported`,
1999
+ path: key,
2000
+ code: 'INVALID_VALUE',
2001
+ suggestion:
2002
+ 'Render each chart in its own container instead. For small multiples of the same chart, use the facet encoding channel: encoding.facet = { field, type }',
2003
+ },
2004
+ ],
2005
+ normalized: null,
2006
+ };
2007
+ }
2008
+ if ('facet' in obj && 'spec' in obj) {
2009
+ return {
2010
+ valid: false,
2011
+ errors: [
2012
+ {
2013
+ message: 'Spec error: the top-level "facet" operator is not supported',
2014
+ path: 'facet',
2015
+ code: 'INVALID_VALUE',
2016
+ suggestion:
2017
+ 'Use the facet encoding channel on a regular chart spec instead: encoding.facet = { field: "category", type: "nominal" }',
2018
+ },
2019
+ ],
2020
+ normalized: null,
2021
+ };
2022
+ }
1096
2023
  return {
1097
2024
  valid: false,
1098
2025
  errors: [
1099
2026
  {
1100
2027
  message:
1101
- 'Spec error: spec must have a "mark" field for charts, a "layer" array for layered charts, or a "type" field for tables/graphs/sankey/tilemap/barlist',
2028
+ 'Spec error: spec must have a "mark" field for charts, a "layer" array for layered charts, or a "type" field for tables/graphs/sankey/tilemap/map/barlist',
1102
2029
  path: 'mark',
1103
2030
  code: 'MISSING_FIELD',
1104
- suggestion: `Add a "mark" field for charts (e.g. mark: "bar"), a "layer" array for layered charts, or a "type" field (type: "table", type: "graph", type: "sankey", type: "tilemap", or type: "barlist"). Valid mark types: ${[...MARK_TYPES].join(', ')}`,
2031
+ suggestion: `Add a "mark" field for charts (e.g. mark: "bar"), a "layer" array for layered charts, or a "type" field (type: "table", type: "graph", type: "sankey", type: "tilemap", type: "map", or type: "barlist"). Valid mark types: ${[...MARK_TYPES].join(', ')}`,
1105
2032
  },
1106
2033
  ],
1107
2034
  normalized: null,
@@ -1148,6 +2075,8 @@ export function validateSpec(spec: unknown): ValidationResult {
1148
2075
  validateSankeySpec(obj, errors);
1149
2076
  } else if (isTileMap) {
1150
2077
  validateTileMapSpec(obj, errors);
2078
+ } else if (isMap) {
2079
+ validateMapSpec(obj, errors);
1151
2080
  } else if (isBarList) {
1152
2081
  validateBarListSpec(obj, errors);
1153
2082
  }