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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (159) hide show
  1. package/dist/index.d.ts +142 -7
  2. package/dist/index.js +16182 -10032
  3. package/dist/index.js.map +1 -1
  4. package/package.json +11 -4
  5. package/src/__tests__/__snapshots__/compile-snapshot.test.ts.snap +170 -56
  6. package/src/__tests__/annotation-behavior.test.ts +721 -0
  7. package/src/__tests__/axes.test.ts +50 -3
  8. package/src/__tests__/compile-chart.test.ts +30 -0
  9. package/src/__tests__/compile-footnotes.test.ts +277 -0
  10. package/src/__tests__/compile-layer.test.ts +120 -1
  11. package/src/__tests__/dimensions.test.ts +49 -4
  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 +120 -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 +222 -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 +312 -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 +117 -21
  91. package/src/compile/size-scale.ts +139 -0
  92. package/src/compile/spec-sugar.ts +758 -0
  93. package/src/compile/you-draw-it.ts +123 -0
  94. package/src/compile.ts +315 -103
  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__/normalize.test.ts +65 -1
  116. package/src/compiler/__tests__/validate-did-you-mean.test.ts +86 -0
  117. package/src/compiler/__tests__/validate-near-miss.test.ts +89 -0
  118. package/src/compiler/__tests__/validate.test.ts +156 -0
  119. package/src/compiler/__tests__/vl-parity.test.ts +68 -0
  120. package/src/compiler/a11y-warnings.ts +110 -0
  121. package/src/compiler/normalize.ts +150 -7
  122. package/src/compiler/types.ts +11 -0
  123. package/src/compiler/validate.ts +664 -18
  124. package/src/endpoint-labels/__tests__/compute.test.ts +88 -0
  125. package/src/endpoint-labels/compute.ts +40 -12
  126. package/src/endpoint-labels/format.ts +45 -15
  127. package/src/endpoint-labels/predict.ts +50 -36
  128. package/src/format/__tests__/field-format.test.ts +164 -0
  129. package/src/format/field-format.ts +27 -0
  130. package/src/geo/__tests__/compile-map.test.ts +621 -0
  131. package/src/geo/compile-map.ts +856 -0
  132. package/src/geo/join.ts +115 -0
  133. package/src/geo/projections.ts +45 -0
  134. package/src/geo/types.ts +24 -0
  135. package/src/graphs/__tests__/compile-graph.test.ts +25 -0
  136. package/src/graphs/compile-graph.ts +27 -18
  137. package/src/index.ts +28 -2
  138. package/src/layout/axes/ticks.ts +59 -10
  139. package/src/layout/axes.ts +30 -2
  140. package/src/layout/dimensions.ts +178 -95
  141. package/src/layout/facet.ts +14 -3
  142. package/src/layout/plan.ts +86 -35
  143. package/src/layout/scales.ts +132 -15
  144. package/src/legend/__tests__/continuous.test.ts +318 -0
  145. package/src/legend/__tests__/size.test.ts +348 -0
  146. package/src/legend/__tests__/suppression.test.ts +17 -1
  147. package/src/legend/compute.ts +126 -14
  148. package/src/legend/continuous.ts +327 -0
  149. package/src/legend/size.ts +407 -0
  150. package/src/legend/suppression.ts +8 -7
  151. package/src/legend/wrap.ts +8 -0
  152. package/src/sankey/compile-sankey.ts +31 -19
  153. package/src/tables/format-cells.ts +16 -7
  154. package/src/tilemap/__tests__/compile-tilemap.test.ts +39 -0
  155. package/src/tilemap/compile-tilemap.ts +24 -7
  156. package/src/tooltips/__tests__/compute.test.ts +2 -2
  157. package/src/tooltips/compute.ts +422 -41
  158. package/src/transforms/__tests__/conditional.test.ts +18 -0
  159. package/src/transforms/aggregate.ts +2 -1
@@ -15,6 +15,8 @@ import {
15
15
  MARK_ENCODING_RULES,
16
16
  MARK_TYPES,
17
17
  type MarkType,
18
+ resolveSchemeName,
19
+ SUPPORTED_SCHEME_NAMES,
18
20
  type VizSpec,
19
21
  } from '@opendata-ai/openchart-core';
20
22
 
@@ -49,6 +51,291 @@ function isNumeric(value: unknown): boolean {
49
51
  return false;
50
52
  }
51
53
 
54
+ /**
55
+ * Levenshtein edit distance between two strings (case-insensitive). Used to
56
+ * turn a misspelled field reference into a "did you mean" repair hint.
57
+ */
58
+ function editDistance(a: string, b: string): number {
59
+ const s = a.toLowerCase();
60
+ const t = b.toLowerCase();
61
+ const m = s.length;
62
+ const n = t.length;
63
+ if (m === 0) return n;
64
+ if (n === 0) return m;
65
+ // Single-row DP: prev[j] holds distance for the previous source char.
66
+ let prev = Array.from({ length: n + 1 }, (_, j) => j);
67
+ let curr = new Array<number>(n + 1);
68
+ for (let i = 1; i <= m; i++) {
69
+ curr[0] = i;
70
+ for (let j = 1; j <= n; j++) {
71
+ const cost = s[i - 1] === t[j - 1] ? 0 : 1;
72
+ curr[j] = Math.min(prev[j] + 1, curr[j - 1] + 1, prev[j - 1] + cost);
73
+ }
74
+ [prev, curr] = [curr, prev];
75
+ }
76
+ return prev[n];
77
+ }
78
+
79
+ /**
80
+ * Given a misspelled field name and the actual data columns, return the nearest
81
+ * column when it is close enough to be a plausible typo, else undefined.
82
+ *
83
+ * The threshold scales with word length (at most ~40% of the target length, and
84
+ * never more than 3 edits) so short columns don't match unrelated names.
85
+ */
86
+ function nearestColumn(field: string, columns: string[]): string | undefined {
87
+ let best: string | undefined;
88
+ let bestDistance = Number.POSITIVE_INFINITY;
89
+ for (const column of columns) {
90
+ const distance = editDistance(field, column);
91
+ if (distance < bestDistance) {
92
+ bestDistance = distance;
93
+ best = column;
94
+ }
95
+ }
96
+ if (best === undefined) return undefined;
97
+ const threshold = Math.min(3, Math.max(1, Math.floor(best.length * 0.4)));
98
+ return bestDistance <= threshold ? best : undefined;
99
+ }
100
+
101
+ /**
102
+ * Build the "did you mean" clause appended to DATA_FIELD_MISSING suggestions.
103
+ * Returns an empty string when no column is a plausible near-match.
104
+ */
105
+ function didYouMean(field: string, columns: string[]): string {
106
+ const match = nearestColumn(field, columns);
107
+ return match ? ` Did you mean "${match}"?` : '';
108
+ }
109
+
110
+ // ---------------------------------------------------------------------------
111
+ // Range mark validation
112
+ // ---------------------------------------------------------------------------
113
+
114
+ const VALID_RANGE_STYLES = new Set(['dumbbell', 'arrow', 'bar']);
115
+
116
+ /**
117
+ * Range marks need an orientation-dependent encoding pair: x + x2 when the
118
+ * category axis is y (horizontal, the common editorial form), or y + y2 when
119
+ * the category axis is x (vertical). The static MARK_ENCODING_RULES table
120
+ * can't express "x2 required only when y is categorical", so this check
121
+ * enforces it with errors naming exactly what to add.
122
+ */
123
+ function validateRangeSpec(spec: Record<string, unknown>, errors: ValidationError[]): void {
124
+ const encoding = spec.encoding as Record<string, Record<string, unknown> | undefined>;
125
+ const data = spec.data as Record<string, unknown>[];
126
+ const x = encoding.x;
127
+ const y = encoding.y;
128
+ // Missing x/y already produced MISSING_FIELD errors from the generic loop.
129
+ if (!x?.field || typeof x.field !== 'string' || !y?.field || typeof y.field !== 'string') {
130
+ return;
131
+ }
132
+
133
+ const xType = (x.type as string | undefined) ?? inferFieldType(data, x.field);
134
+ const yType = (y.type as string | undefined) ?? inferFieldType(data, y.field);
135
+ const xIsCategory = xType === 'nominal' || xType === 'ordinal';
136
+ const yIsCategory = yType === 'nominal' || yType === 'ordinal';
137
+
138
+ if (xIsCategory && yIsCategory) {
139
+ errors.push({
140
+ message:
141
+ 'Spec error: range chart requires a quantitative value axis, but both encoding.x and encoding.y are categorical',
142
+ path: 'encoding',
143
+ code: 'ENCODING_MISMATCH',
144
+ suggestion:
145
+ 'Map the range start to a quantitative channel: x (with x2 for the end) for horizontal ranges, or y (with y2) for vertical ranges',
146
+ });
147
+ return;
148
+ }
149
+ if (!xIsCategory && !yIsCategory) {
150
+ errors.push({
151
+ message:
152
+ 'Spec error: range chart requires a nominal or ordinal category axis, but neither encoding.x nor encoding.y is categorical',
153
+ path: 'encoding',
154
+ code: 'ENCODING_MISMATCH',
155
+ suggestion:
156
+ 'Set encoding.y to a nominal field for horizontal ranges (the common form), or encoding.x for vertical ranges',
157
+ });
158
+ return;
159
+ }
160
+
161
+ const availableColumns = data.length > 0 ? Object.keys(data[0]).join(', ') : '';
162
+ if (yIsCategory) {
163
+ // Horizontal: x is the range start, x2 the end.
164
+ if (!encoding.x2) {
165
+ errors.push({
166
+ message:
167
+ 'Spec error: range chart requires encoding.x2 (the range end) when encoding.y is the category axis, but none was provided',
168
+ path: 'encoding.x2',
169
+ code: 'MISSING_FIELD',
170
+ suggestion: `Add encoding.x2 with the field holding the range's end value, e.g. x2: { field: "endValue" }. Available columns: ${availableColumns}`,
171
+ });
172
+ }
173
+ if (encoding.y2) {
174
+ errors.push({
175
+ message:
176
+ 'Spec error: encoding.y2 is not used on a horizontal range chart (category axis on y); the range end belongs on x2',
177
+ path: 'encoding.y2',
178
+ code: 'INVALID_VALUE',
179
+ suggestion:
180
+ 'Remove encoding.y2, or move the category to encoding.x and the values to y/y2 for a vertical range chart',
181
+ });
182
+ }
183
+ } else {
184
+ // Vertical: y is the range start, y2 the end.
185
+ if (!encoding.y2) {
186
+ errors.push({
187
+ message:
188
+ 'Spec error: range chart requires encoding.y2 (the range end) when encoding.x is the category axis, but none was provided',
189
+ path: 'encoding.y2',
190
+ code: 'MISSING_FIELD',
191
+ suggestion: `Add encoding.y2 with the field holding the range's end value, e.g. y2: { field: "endValue" }. Available columns: ${availableColumns}`,
192
+ });
193
+ }
194
+ if (encoding.x2) {
195
+ errors.push({
196
+ message:
197
+ 'Spec error: encoding.x2 is not used on a vertical range chart (category axis on x); the range end belongs on y2',
198
+ path: 'encoding.x2',
199
+ code: 'INVALID_VALUE',
200
+ suggestion:
201
+ 'Remove encoding.x2, or move the category to encoding.y and the values to x/x2 for a horizontal range chart',
202
+ });
203
+ }
204
+ }
205
+
206
+ // markDef options: style must be one of the known variants.
207
+ const mark = spec.mark;
208
+ if (mark && typeof mark === 'object' && !Array.isArray(mark)) {
209
+ const style = (mark as Record<string, unknown>).style;
210
+ if (style !== undefined && !VALID_RANGE_STYLES.has(style as string)) {
211
+ errors.push({
212
+ message: `Spec error: mark.style "${style}" is not valid for range marks. Must be one of: dumbbell, arrow, bar`,
213
+ path: 'mark.style',
214
+ code: 'INVALID_VALUE',
215
+ suggestion:
216
+ 'Use mark: { type: "range", style: "dumbbell" } (default), "arrow" for directional change, or "bar" for a plain floating range bar',
217
+ });
218
+ }
219
+ }
220
+ }
221
+
222
+ // ---------------------------------------------------------------------------
223
+ // Calendar mark validation
224
+ // ---------------------------------------------------------------------------
225
+
226
+ const VALID_WEEK_STARTS = new Set(['monday', 'sunday']);
227
+
228
+ /**
229
+ * Calendar heatmaps take daily dates on x and a quantitative value on color;
230
+ * the weeks-x-weekdays geometry is computed internally, so there is no y
231
+ * channel. This check enforces the parts the static MARK_ENCODING_RULES
232
+ * table can't express: no y, a temporal x even when the type is inferred,
233
+ * every date parseable, one row per day (daily granularity), and valid
234
+ * mark-level weekStart/cellRadius options.
235
+ */
236
+ function validateCalendarSpec(spec: Record<string, unknown>, errors: ValidationError[]): void {
237
+ const encoding = spec.encoding as Record<string, Record<string, unknown> | undefined>;
238
+ const data = spec.data as Record<string, unknown>[];
239
+
240
+ if (encoding.y) {
241
+ errors.push({
242
+ message:
243
+ 'Spec error: calendar chart does not accept encoding.y (the weeks x weekdays layout is computed from the dates)',
244
+ path: 'encoding.y',
245
+ code: 'ENCODING_MISMATCH',
246
+ suggestion:
247
+ 'Remove encoding.y. A calendar needs only x (temporal, daily dates) and color (quantitative).',
248
+ });
249
+ }
250
+
251
+ // Mark-level options (weekStart / cellRadius) on the MarkDef object form.
252
+ if (spec.mark && typeof spec.mark === 'object') {
253
+ const markDef = spec.mark as Record<string, unknown>;
254
+ if (markDef.weekStart !== undefined && !VALID_WEEK_STARTS.has(markDef.weekStart as string)) {
255
+ errors.push({
256
+ message: `Spec error: mark.weekStart "${markDef.weekStart}" is not a valid week start`,
257
+ path: 'mark.weekStart',
258
+ code: 'INVALID_VALUE',
259
+ suggestion:
260
+ 'Use mark: { type: "calendar", weekStart: "monday" } (default, ISO weeks) or "sunday"',
261
+ });
262
+ }
263
+ if (
264
+ markDef.cellRadius !== undefined &&
265
+ (typeof markDef.cellRadius !== 'number' ||
266
+ !Number.isFinite(markDef.cellRadius) ||
267
+ markDef.cellRadius < 0)
268
+ ) {
269
+ errors.push({
270
+ message: `Spec error: mark.cellRadius must be a non-negative number, got ${JSON.stringify(markDef.cellRadius)}`,
271
+ path: 'mark.cellRadius',
272
+ code: 'INVALID_VALUE',
273
+ suggestion:
274
+ 'Use a pixel radius like cellRadius: 1 (default) or cellRadius: 0 for square corners',
275
+ });
276
+ }
277
+ }
278
+
279
+ const x = encoding.x;
280
+ // Missing x already produced a MISSING_FIELD error from the generic loop.
281
+ if (!x?.field || typeof x.field !== 'string') return;
282
+
283
+ // The generic loop rejects a declared non-temporal type against the
284
+ // encoding rules table; this catches the inferred case (type omitted).
285
+ const xType = (x.type as string | undefined) ?? inferFieldType(data, x.field);
286
+ if (xType !== 'temporal') {
287
+ if (!x.type) {
288
+ errors.push({
289
+ message: `Spec error: calendar chart requires temporal encoding.x, but "${x.field}" was inferred as ${xType}`,
290
+ path: 'encoding.x',
291
+ code: 'ENCODING_MISMATCH',
292
+ 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" }`,
293
+ });
294
+ }
295
+ return;
296
+ }
297
+
298
+ // Transforms (e.g. a timeUnit aggregation) reshape the data before the
299
+ // calendar sees it, so per-row date checks only apply to untransformed specs.
300
+ if (Array.isArray(spec.transform) && spec.transform.length > 0) return;
301
+
302
+ // Every date must parse; report the first offender by row index.
303
+ for (let i = 0; i < data.length; i++) {
304
+ const value = data[i][x.field];
305
+ if (value != null && !isParseableDate(value)) {
306
+ errors.push({
307
+ message: `Spec error: encoding.x.field "${x.field}" contains an unparseable date at data[${i}]: ${JSON.stringify(value)}`,
308
+ path: `data[${i}].${x.field}`,
309
+ code: 'INVALID_VALUE',
310
+ suggestion: `Fix data[${i}].${x.field} to a parseable date (ISO 8601 strings like "2024-01-15" or Date objects)`,
311
+ });
312
+ return;
313
+ }
314
+ }
315
+
316
+ // Daily granularity: more than one row on the same UTC day means the data
317
+ // is sub-daily (or duplicated) and cells would silently overwrite.
318
+ const seenDays = new Map<string, number>();
319
+ for (let i = 0; i < data.length; i++) {
320
+ const value = data[i][x.field];
321
+ if (value == null) continue;
322
+ const date = value instanceof Date ? value : new Date(value as string | number);
323
+ if (Number.isNaN(date.getTime())) continue;
324
+ const dayKey = `${date.getUTCFullYear()}-${date.getUTCMonth()}-${date.getUTCDate()}`;
325
+ const firstIndex = seenDays.get(dayKey);
326
+ if (firstIndex !== undefined) {
327
+ errors.push({
328
+ message: `Spec error: calendar chart requires daily granularity, but data[${firstIndex}] and data[${i}] fall on the same day (${JSON.stringify(value)})`,
329
+ path: `data[${i}].${x.field}`,
330
+ code: 'ENCODING_MISMATCH',
331
+ 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.`,
332
+ });
333
+ return;
334
+ }
335
+ seenDays.set(dayKey, i);
336
+ }
337
+ }
338
+
52
339
  // ---------------------------------------------------------------------------
53
340
  // Chart validation
54
341
  // ---------------------------------------------------------------------------
@@ -59,6 +346,20 @@ function validateChartSpec(spec: Record<string, unknown>, errors: ValidationErro
59
346
 
60
347
  // Check data
61
348
  if (!Array.isArray(spec.data)) {
349
+ // Near-miss: the VL `data: { url }` form. Fetching is out of scope for a
350
+ // rendering library, so reject it with a pointed message. The `{ values }`
351
+ // form is unwrapped by the pre-validation sugar expansion and never
352
+ // reaches this check on the compile path.
353
+ if (spec.data && typeof spec.data === 'object' && 'url' in (spec.data as object)) {
354
+ errors.push({
355
+ message: 'Spec error: data.url is not supported (openchart does not fetch remote data)',
356
+ path: 'data.url',
357
+ code: 'INVALID_VALUE',
358
+ suggestion:
359
+ 'Fetch the data in your application and provide the rows inline: data: [...] or data: { values: [...] }',
360
+ });
361
+ return;
362
+ }
62
363
  errors.push({
63
364
  message: 'Spec error: "data" must be an array',
64
365
  path: 'data',
@@ -109,31 +410,128 @@ function validateChartSpec(spec: Record<string, unknown>, errors: ValidationErro
109
410
  const rules = MARK_ENCODING_RULES[markType as MarkType];
110
411
  const encoding = spec.encoding as Record<string, unknown>;
111
412
  const dataColumns = new Set(Object.keys(firstRow as Record<string, unknown>));
112
- const availableColumns = [...dataColumns].join(', ');
113
-
114
- // Validate required channels
413
+ const columnList = [...dataColumns];
414
+ const availableColumns = columnList.join(', ');
415
+
416
+ // Validate required channels. Arc's canonical channel is theta; waffle and
417
+ // parliament still key on y with theta as an alias. For backward compat,
418
+ // validateSpec (a public entry point that callers may invoke on un-expanded
419
+ // specs) accepts y as satisfying a theta requirement and vice versa on
420
+ // theta-capable marks.
421
+ const THETA_MARKS = new Set(['arc', 'waffle', 'parliament']);
422
+ const isThetaMark = THETA_MARKS.has(markType as string);
115
423
  for (const [channel, rule] of Object.entries(rules)) {
116
- if (rule.required && !encoding[channel]) {
424
+ if (!rule.required) continue;
425
+ const satisfiedByAlias =
426
+ isThetaMark && ((channel === 'theta' && encoding.y) || (channel === 'y' && encoding.theta));
427
+ if (!encoding[channel] && !satisfiedByAlias) {
117
428
  const allowedTypes = rule.allowedTypes.join(' or ');
429
+ const suggestedChannel = channel === 'y' && isThetaMark ? 'theta' : channel;
118
430
  errors.push({
119
- message: `Spec error: ${markType} chart requires encoding.${channel} but none was provided`,
120
- path: `encoding.${channel}`,
431
+ message: `Spec error: ${markType} chart requires encoding.${suggestedChannel} but none was provided`,
432
+ path: `encoding.${suggestedChannel}`,
433
+ code: 'MISSING_FIELD',
434
+ suggestion: `Add encoding.${suggestedChannel} with a field from your data (${availableColumns}) and type (${allowedTypes}). Example: ${suggestedChannel}: { field: "${[...dataColumns][0] ?? 'myField'}", type: "${rule.allowedTypes[0]}" }`,
435
+ });
436
+ }
437
+ }
438
+
439
+ // Beeswarm axis combination: the channel rules mark both axes optional, so
440
+ // enforce the shape here. Exactly one positional channel is the quantitative
441
+ // value axis; the other, when present, is the nominal/ordinal lane channel.
442
+ // Only declared types are checked; omitted types are inferred later.
443
+ if (markType === 'beeswarm') {
444
+ const xChannel = encoding.x as Record<string, unknown> | undefined;
445
+ const yChannel = encoding.y as Record<string, unknown> | undefined;
446
+ const xType = xChannel?.type as string | undefined;
447
+ const yType = yChannel?.type as string | undefined;
448
+ if (!xChannel && !yChannel) {
449
+ errors.push({
450
+ message:
451
+ 'Spec error: beeswarm chart requires a quantitative encoding.x or encoding.y (the value axis)',
452
+ path: 'encoding',
121
453
  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]}" }`,
454
+ 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.`,
455
+ });
456
+ } else if (xType === 'quantitative' && yType === 'quantitative') {
457
+ errors.push({
458
+ message:
459
+ 'Spec error: beeswarm chart accepts only one quantitative axis (the value axis), but both encoding.x and encoding.y are quantitative',
460
+ path: 'encoding',
461
+ code: 'ENCODING_MISMATCH',
462
+ suggestion:
463
+ '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".',
464
+ });
465
+ } else if (
466
+ xType !== 'quantitative' &&
467
+ yType !== 'quantitative' &&
468
+ (!xChannel || !!xType) &&
469
+ (!yChannel || !!yType)
470
+ ) {
471
+ errors.push({
472
+ message:
473
+ 'Spec error: beeswarm chart requires one quantitative axis (the value axis), but neither encoding.x nor encoding.y is quantitative',
474
+ path: 'encoding',
475
+ code: 'ENCODING_MISMATCH',
476
+ suggestion:
477
+ 'Set the value axis to type: "quantitative" (x for a horizontal swarm, y for a vertical one).',
123
478
  });
124
479
  }
125
480
  }
126
481
 
482
+ // Range marks: orientation-dependent x2/y2 requirement + mark.style options
483
+ if (markType === 'range') {
484
+ validateRangeSpec(spec, errors);
485
+ }
486
+
487
+ // Calendar marks: temporal daily x, no y, parseable dates, mark options
488
+ if (markType === 'calendar') {
489
+ validateCalendarSpec(spec, errors);
490
+ }
491
+
492
+ // Near-miss: VL's string expression form of calculate. A restricted string
493
+ // grammar is deliberately not supported (decision: structured form only);
494
+ // point authors at the structured equivalent instead.
495
+ if (Array.isArray(spec.transform)) {
496
+ const transforms = spec.transform as Record<string, unknown>[];
497
+ for (let i = 0; i < transforms.length; i++) {
498
+ const t = transforms[i];
499
+ if (t && typeof t === 'object' && typeof t.calculate === 'string') {
500
+ errors.push({
501
+ message: `Spec error: transform[${i}].calculate must be a structured expression object, not a string`,
502
+ path: `transform[${i}].calculate`,
503
+ code: 'INVALID_TYPE',
504
+ suggestion:
505
+ '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.',
506
+ });
507
+ }
508
+ }
509
+ }
510
+
127
511
  // Collect fields that transforms will create, so we don't reject them
128
512
  const transformFields = new Set<string>();
129
513
  if (Array.isArray(spec.transform)) {
130
514
  for (const t of spec.transform as Record<string, unknown>[]) {
515
+ // filter/bin/calculate/timeUnit/fold write their output(s) to `as`.
131
516
  if (typeof t.as === 'string') transformFields.add(t.as);
132
517
  if (Array.isArray(t.as)) {
133
518
  for (const f of t.as) {
134
519
  if (typeof f === 'string') transformFields.add(f);
135
520
  }
136
521
  }
522
+ // aggregate/window carry their output field names inside their op arrays,
523
+ // and aggregate preserves its groupby fields on the output rows.
524
+ const opList = (t.aggregate ?? t.window) as Record<string, unknown>[] | undefined;
525
+ if (Array.isArray(opList)) {
526
+ for (const op of opList) {
527
+ if (op && typeof op.as === 'string') transformFields.add(op.as);
528
+ }
529
+ }
530
+ if (Array.isArray(t.groupby)) {
531
+ for (const g of t.groupby) {
532
+ if (typeof g === 'string') transformFields.add(g);
533
+ }
534
+ }
137
535
  }
138
536
  }
139
537
 
@@ -160,7 +558,7 @@ function validateChartSpec(spec: Record<string, unknown>, errors: ValidationErro
160
558
  message: `Spec error: encoding.tooltip[${i}].field "${elem.field}" does not exist in data. Available columns: ${availableColumns}`,
161
559
  path: `encoding.tooltip[${i}].field`,
162
560
  code: 'DATA_FIELD_MISSING',
163
- suggestion: `Use one of the available data columns: ${availableColumns}`,
561
+ suggestion: `Use one of the available data columns: ${availableColumns}.${didYouMean(elem.field, columnList)}`,
164
562
  });
165
563
  }
166
564
  if (elem.type && !VALID_FIELD_TYPES.has(elem.type as string)) {
@@ -198,7 +596,7 @@ function validateChartSpec(spec: Record<string, unknown>, errors: ValidationErro
198
596
  message: `Spec error: encoding.${channel}.field "${channelObj.field}" does not exist in data. Available columns: ${availableColumns}`,
199
597
  path: `encoding.${channel}.field`,
200
598
  code: 'DATA_FIELD_MISSING',
201
- suggestion: `Use one of the available data columns: ${availableColumns}`,
599
+ suggestion: `Use one of the available data columns: ${availableColumns}.${didYouMean(channelObj.field, columnList)}`,
202
600
  });
203
601
  }
204
602
 
@@ -212,6 +610,23 @@ function validateChartSpec(spec: Record<string, unknown>, errors: ValidationErro
212
610
  });
213
611
  }
214
612
 
613
+ // Check scale.scheme names. Known names (including VL aliases) are
614
+ // resolved to palette ranges by the pre-validation sugar expansion, so a
615
+ // scheme surviving to this point on the compile path is an unknown name.
616
+ const channelScale = channelObj.scale as Record<string, unknown> | undefined;
617
+ if (
618
+ channelScale &&
619
+ typeof channelScale.scheme === 'string' &&
620
+ !resolveSchemeName(channelScale.scheme)
621
+ ) {
622
+ errors.push({
623
+ message: `Spec error: encoding.${channel}.scale.scheme "${channelScale.scheme}" is not a supported scheme name`,
624
+ path: `encoding.${channel}.scale.scheme`,
625
+ code: 'INVALID_VALUE',
626
+ suggestion: `Use one of the supported scheme names: ${[...SUPPORTED_SCHEME_NAMES].join(', ')}. Or provide explicit colors via scale.range.`,
627
+ });
628
+ }
629
+
215
630
  // Check field type is allowed for this channel
216
631
  if (channelRule && channelObj.type && channelRule.allowedTypes.length > 0) {
217
632
  if (!channelRule.allowedTypes.includes(channelObj.type as FieldType)) {
@@ -324,7 +739,7 @@ function validateChartSpec(spec: Record<string, unknown>, errors: ValidationErro
324
739
  message: `Spec error: encoding.facet.field "${facet.field}" does not exist in data. Available columns: ${availableColumns}`,
325
740
  path: 'encoding.facet.field',
326
741
  code: 'DATA_FIELD_MISSING',
327
- suggestion: `Use one of the available data columns: ${availableColumns}`,
742
+ suggestion: `Use one of the available data columns: ${availableColumns}.${didYouMean(facet.field as string, columnList)}`,
328
743
  });
329
744
  }
330
745
  if (!facet.type || (facet.type !== 'nominal' && facet.type !== 'ordinal')) {
@@ -358,6 +773,60 @@ function validateChartSpec(spec: Record<string, unknown>, errors: ValidationErro
358
773
  'Use one of: "auto" (system preference), "force" (always dark), or "off" (always light)',
359
774
  });
360
775
  }
776
+
777
+ // Validate youDrawIt: line marks only, single-series only, "from" required.
778
+ if (spec.youDrawIt !== undefined && spec.youDrawIt !== false) {
779
+ if (markType !== 'line') {
780
+ errors.push({
781
+ message: `Spec error: youDrawIt is only supported on line charts, but mark is "${markType}"`,
782
+ path: 'youDrawIt',
783
+ code: 'ENCODING_MISMATCH',
784
+ suggestion: 'Remove youDrawIt or change mark to "line".',
785
+ });
786
+ } else {
787
+ const config = spec.youDrawIt as Record<string, unknown>;
788
+ if (
789
+ typeof config !== 'object' ||
790
+ Array.isArray(config) ||
791
+ (config.from !== 0 && !config.from)
792
+ ) {
793
+ errors.push({
794
+ message: 'Spec error: youDrawIt.from is required (the x value where drawing starts)',
795
+ path: 'youDrawIt.from',
796
+ code: 'MISSING_FIELD',
797
+ suggestion:
798
+ 'Set youDrawIt.from to an x value from your data, e.g. youDrawIt: { from: "2010" }',
799
+ });
800
+ } else if (typeof config.from !== 'string' && typeof config.from !== 'number') {
801
+ errors.push({
802
+ message: 'Spec error: youDrawIt.from must be a string or number',
803
+ path: 'youDrawIt.from',
804
+ code: 'INVALID_TYPE',
805
+ suggestion: 'Provide the x value as a string or number matching your data.',
806
+ });
807
+ }
808
+
809
+ const data = Array.isArray(spec.data) ? (spec.data as Record<string, unknown>[]) : [];
810
+ const colorCh = encoding?.color as Record<string, unknown> | undefined;
811
+ if (colorCh && typeof colorCh === 'object' && typeof colorCh.field === 'string') {
812
+ const colorType =
813
+ (colorCh.type as string | undefined) ?? inferFieldType(data, colorCh.field);
814
+ if (colorType !== 'quantitative') {
815
+ const distinct = new Set(data.map((row) => row[colorCh.field as string]));
816
+ if (distinct.size > 1) {
817
+ errors.push({
818
+ message:
819
+ 'Spec error: youDrawIt only supports single-series line charts, but encoding.color has multiple distinct values',
820
+ path: 'youDrawIt',
821
+ code: 'ENCODING_MISMATCH',
822
+ suggestion:
823
+ 'Remove encoding.color (or filter the data to one series) to use youDrawIt.',
824
+ });
825
+ }
826
+ }
827
+ }
828
+ }
829
+ }
361
830
  }
362
831
 
363
832
  // ---------------------------------------------------------------------------
@@ -410,7 +879,8 @@ function validateTableSpec(spec: Record<string, unknown>, errors: ValidationErro
410
879
  }
411
880
 
412
881
  const dataColumns = new Set(Object.keys(firstRow as Record<string, unknown>));
413
- const availableColumns = [...dataColumns].join(', ');
882
+ const columnList = [...dataColumns];
883
+ const availableColumns = columnList.join(', ');
414
884
  const columns = spec.columns as Record<string, unknown>[];
415
885
 
416
886
  for (let i = 0; i < columns.length; i++) {
@@ -442,7 +912,7 @@ function validateTableSpec(spec: Record<string, unknown>, errors: ValidationErro
442
912
  message: `Spec error: columns[${i}].key "${col.key}" does not exist in data. Available columns: ${availableColumns}`,
443
913
  path: `columns[${i}].key`,
444
914
  code: 'DATA_FIELD_MISSING',
445
- suggestion: `Use one of the available data columns: ${availableColumns}`,
915
+ suggestion: `Use one of the available data columns: ${availableColumns}.${didYouMean(col.key as string, columnList)}`,
446
916
  });
447
917
  }
448
918
 
@@ -838,6 +1308,137 @@ function validateTileMapSpec(spec: Record<string, unknown>, errors: ValidationEr
838
1308
  }
839
1309
  }
840
1310
 
1311
+ // ---------------------------------------------------------------------------
1312
+ // Map validation
1313
+ // ---------------------------------------------------------------------------
1314
+
1315
+ function validateMapSpec(spec: Record<string, unknown>, errors: ValidationError[]): void {
1316
+ if (!spec.geo || typeof spec.geo !== 'object') {
1317
+ errors.push({
1318
+ message: 'Spec error: map spec requires a "geo" object with a TopoJSON "features" field',
1319
+ path: 'geo',
1320
+ code: 'MISSING_FIELD',
1321
+ suggestion: 'Add a geo object, e.g. geo: { features: topoJsonData, projection: "identity" }',
1322
+ });
1323
+ return;
1324
+ }
1325
+
1326
+ const geo = spec.geo as Record<string, unknown>;
1327
+ if (!geo.features) {
1328
+ errors.push({
1329
+ message: 'Spec error: geo requires a "features" field containing TopoJSON topology',
1330
+ path: 'geo.features',
1331
+ code: 'MISSING_FIELD',
1332
+ suggestion:
1333
+ 'Import a TopoJSON file and pass it as geo.features, e.g. import topo from "us-atlas/states-albers-10m.json"',
1334
+ });
1335
+ return;
1336
+ }
1337
+
1338
+ if (!spec.encoding || typeof spec.encoding !== 'object') {
1339
+ errors.push({
1340
+ message: 'Spec error: map spec requires an "encoding" object with key and color channels',
1341
+ path: 'encoding',
1342
+ code: 'MISSING_FIELD',
1343
+ suggestion:
1344
+ 'Add an encoding object, e.g. encoding: { key: { field: "id", type: "nominal" }, color: { field: "value", type: "quantitative" } }',
1345
+ });
1346
+ return;
1347
+ }
1348
+
1349
+ // Validate geo.focus if present
1350
+ if ('focus' in geo && geo.focus !== undefined && geo.focus !== null) {
1351
+ const focus = geo.focus;
1352
+ if (typeof focus === 'string' || typeof focus === 'number') {
1353
+ // valid: single id
1354
+ } else if (Array.isArray(focus)) {
1355
+ for (let i = 0; i < focus.length; i++) {
1356
+ if (typeof focus[i] !== 'string' && typeof focus[i] !== 'number') {
1357
+ errors.push({
1358
+ message: `Spec error: geo.focus array element [${i}] must be a string or number`,
1359
+ path: `geo.focus[${i}]`,
1360
+ code: 'INVALID_TYPE',
1361
+ suggestion: 'Each element in the focus array should be a feature id (string or number)',
1362
+ });
1363
+ }
1364
+ }
1365
+ } else if (typeof focus === 'object') {
1366
+ const focusObj = focus as Record<string, unknown>;
1367
+ if (!('features' in focusObj) || focusObj.features == null) {
1368
+ errors.push({
1369
+ message: 'Spec error: geo.focus object must have a "features" property',
1370
+ path: 'geo.focus.features',
1371
+ code: 'MISSING_FIELD',
1372
+ suggestion:
1373
+ 'Provide feature id(s) to focus on, e.g. geo: { focus: { features: "36", padding: 32 } }',
1374
+ });
1375
+ } else {
1376
+ const feats = focusObj.features;
1377
+ if (typeof feats !== 'string' && typeof feats !== 'number' && !Array.isArray(feats)) {
1378
+ errors.push({
1379
+ message:
1380
+ 'Spec error: geo.focus.features must be a string, number, or array of strings/numbers',
1381
+ path: 'geo.focus.features',
1382
+ code: 'INVALID_TYPE',
1383
+ suggestion: 'Use a feature id or array of ids, e.g. "36" or ["36", "34"]',
1384
+ });
1385
+ } else if (Array.isArray(feats)) {
1386
+ for (let i = 0; i < feats.length; i++) {
1387
+ if (typeof feats[i] !== 'string' && typeof feats[i] !== 'number') {
1388
+ errors.push({
1389
+ message: `Spec error: geo.focus.features[${i}] must be a string or number`,
1390
+ path: `geo.focus.features[${i}]`,
1391
+ code: 'INVALID_TYPE',
1392
+ suggestion: 'Each element should be a feature id (string or number)',
1393
+ });
1394
+ }
1395
+ }
1396
+ }
1397
+ if (focusObj.padding !== undefined && typeof focusObj.padding !== 'number') {
1398
+ errors.push({
1399
+ message: 'Spec error: geo.focus.padding must be a number',
1400
+ path: 'geo.focus.padding',
1401
+ code: 'INVALID_TYPE',
1402
+ suggestion: 'Provide padding as a number in map-local units, e.g. padding: 32',
1403
+ });
1404
+ }
1405
+ }
1406
+ } else {
1407
+ errors.push({
1408
+ message:
1409
+ 'Spec error: geo.focus must be a string, number, array of ids, or object with { features, padding? }',
1410
+ path: 'geo.focus',
1411
+ code: 'INVALID_TYPE',
1412
+ suggestion:
1413
+ 'Use a feature id, array of ids, or { features: "36", padding: 16 }. Pass null to clear focus.',
1414
+ });
1415
+ }
1416
+ }
1417
+
1418
+ const encoding = spec.encoding as Record<string, unknown>;
1419
+ for (const channel of ['key', 'color'] as const) {
1420
+ const ch = encoding[channel] as Record<string, unknown> | undefined;
1421
+ if (!ch || typeof ch !== 'object') {
1422
+ errors.push({
1423
+ message: `Spec error: map encoding requires "${channel}" channel`,
1424
+ path: `encoding.${channel}`,
1425
+ code: 'MISSING_FIELD',
1426
+ suggestion: `Add encoding.${channel}, e.g. ${channel}: { field: "${channel === 'key' ? 'id' : 'value'}", type: "${channel === 'key' ? 'nominal' : 'quantitative'}" }`,
1427
+ });
1428
+ continue;
1429
+ }
1430
+
1431
+ if (!ch.field || typeof ch.field !== 'string') {
1432
+ errors.push({
1433
+ message: `Spec error: encoding.${channel} must have a "field" string`,
1434
+ path: `encoding.${channel}.field`,
1435
+ code: 'MISSING_FIELD',
1436
+ suggestion: `Add a field name, e.g. ${channel}: { field: "${channel === 'key' ? 'id' : 'value'}" }`,
1437
+ });
1438
+ }
1439
+ }
1440
+ }
1441
+
841
1442
  // ---------------------------------------------------------------------------
842
1443
  // BarList validation
843
1444
  // ---------------------------------------------------------------------------
@@ -1087,21 +1688,64 @@ export function validateSpec(spec: unknown): ValidationResult {
1087
1688
  const isGraph = obj.type === 'graph';
1088
1689
  const isSankey = obj.type === 'sankey';
1089
1690
  const isTileMap = obj.type === 'tilemap';
1691
+ const isMap = obj.type === 'map';
1090
1692
  const isBarList = obj.type === 'barlist';
1091
- const isLayer = hasLayer && !isTable && !isGraph && !isSankey && !isTileMap && !isBarList;
1693
+ const isLayer =
1694
+ hasLayer && !isTable && !isGraph && !isSankey && !isTileMap && !isMap && !isBarList;
1092
1695
  const isChart =
1093
- hasMark && !hasLayer && !isTable && !isGraph && !isSankey && !isTileMap && !isBarList;
1094
-
1095
- if (!isChart && !isTable && !isGraph && !isSankey && !isTileMap && !isBarList && !isLayer) {
1696
+ hasMark && !hasLayer && !isTable && !isGraph && !isSankey && !isTileMap && !isMap && !isBarList;
1697
+
1698
+ if (
1699
+ !isChart &&
1700
+ !isTable &&
1701
+ !isGraph &&
1702
+ !isSankey &&
1703
+ !isTileMap &&
1704
+ !isMap &&
1705
+ !isBarList &&
1706
+ !isLayer
1707
+ ) {
1708
+ // Near-misses for VL composition operators that are unsupported by decision
1709
+ if ('hconcat' in obj || 'vconcat' in obj) {
1710
+ const key = 'hconcat' in obj ? 'hconcat' : 'vconcat';
1711
+ return {
1712
+ valid: false,
1713
+ errors: [
1714
+ {
1715
+ message: `Spec error: "${key}" composition is not supported`,
1716
+ path: key,
1717
+ code: 'INVALID_VALUE',
1718
+ suggestion:
1719
+ 'Render each chart in its own container instead. For small multiples of the same chart, use the facet encoding channel: encoding.facet = { field, type }',
1720
+ },
1721
+ ],
1722
+ normalized: null,
1723
+ };
1724
+ }
1725
+ if ('facet' in obj && 'spec' in obj) {
1726
+ return {
1727
+ valid: false,
1728
+ errors: [
1729
+ {
1730
+ message: 'Spec error: the top-level "facet" operator is not supported',
1731
+ path: 'facet',
1732
+ code: 'INVALID_VALUE',
1733
+ suggestion:
1734
+ 'Use the facet encoding channel on a regular chart spec instead: encoding.facet = { field: "category", type: "nominal" }',
1735
+ },
1736
+ ],
1737
+ normalized: null,
1738
+ };
1739
+ }
1096
1740
  return {
1097
1741
  valid: false,
1098
1742
  errors: [
1099
1743
  {
1100
1744
  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',
1745
+ '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
1746
  path: 'mark',
1103
1747
  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(', ')}`,
1748
+ 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
1749
  },
1106
1750
  ],
1107
1751
  normalized: null,
@@ -1148,6 +1792,8 @@ export function validateSpec(spec: unknown): ValidationResult {
1148
1792
  validateSankeySpec(obj, errors);
1149
1793
  } else if (isTileMap) {
1150
1794
  validateTileMapSpec(obj, errors);
1795
+ } else if (isMap) {
1796
+ validateMapSpec(obj, errors);
1151
1797
  } else if (isBarList) {
1152
1798
  validateBarListSpec(obj, errors);
1153
1799
  }