@opendata-ai/openchart-engine 8.0.0-rc.2 → 8.0.0-rc.21
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.
- package/dist/index.d.ts +140 -2
- package/dist/index.js +1403 -210
- package/dist/index.js.map +1 -1
- package/package.json +3 -4
- package/src/__test-fixtures__/specs.ts +0 -182
- package/src/__tests__/__snapshots__/compile-snapshot.test.ts.snap +0 -2339
- package/src/__tests__/annotation-behavior.test.ts +0 -721
- package/src/__tests__/axes.test.ts +0 -1579
- package/src/__tests__/compile-animation.test.ts +0 -161
- package/src/__tests__/compile-chart.test.ts +0 -1303
- package/src/__tests__/compile-footnotes.test.ts +0 -277
- package/src/__tests__/compile-layer.test.ts +0 -969
- package/src/__tests__/compile-snapshot.test.ts +0 -156
- package/src/__tests__/compound-labels.test.ts +0 -147
- package/src/__tests__/dimensions.test.ts +0 -918
- package/src/__tests__/encoding-sugar.test.ts +0 -390
- package/src/__tests__/facet.test.ts +0 -215
- package/src/__tests__/helpers/invariants.ts +0 -299
- package/src/__tests__/known-bugs.test.ts +0 -566
- package/src/__tests__/layout-fuzz.test.ts +0 -358
- package/src/__tests__/layout-invariants.test.ts +0 -535
- package/src/__tests__/legend.test.ts +0 -721
- package/src/__tests__/mark-keys.test.ts +0 -294
- package/src/__tests__/ranking-recipes.test.ts +0 -260
- package/src/__tests__/rotation.test.ts +0 -83
- package/src/__tests__/scales.test.ts +0 -323
- package/src/__tests__/series-search.test.ts +0 -92
- package/src/__tests__/spec-sugar.test.ts +0 -801
- package/src/__tests__/you-draw-it.test.ts +0 -160
- package/src/annotations/__tests__/compute.test.ts +0 -1672
- package/src/annotations/__tests__/geometry.test.ts +0 -66
- package/src/annotations/__tests__/placement-fuzz.test.ts +0 -256
- package/src/annotations/__tests__/placement-invariants.test.ts +0 -345
- package/src/annotations/__tests__/placement.test.ts +0 -316
- package/src/annotations/__tests__/resolve-text.test.ts +0 -788
- package/src/annotations/__tests__/rich-text.test.ts +0 -91
- package/src/annotations/__tests__/thinning.test.ts +0 -285
- package/src/annotations/collisions.ts +0 -326
- package/src/annotations/compute.ts +0 -333
- package/src/annotations/constants.ts +0 -162
- package/src/annotations/geometry.ts +0 -370
- package/src/annotations/move.ts +0 -135
- package/src/annotations/placement.ts +0 -636
- package/src/annotations/position.ts +0 -131
- package/src/annotations/resolve-range.ts +0 -117
- package/src/annotations/resolve-refline.ts +0 -157
- package/src/annotations/resolve-text.ts +0 -539
- package/src/annotations/rich-text.ts +0 -65
- package/src/annotations/thinning.ts +0 -190
- package/src/barlist/__tests__/compile-barlist.test.ts +0 -200
- package/src/barlist/compile-barlist.ts +0 -391
- package/src/barlist/types.ts +0 -28
- package/src/charts/__tests__/post-process.test.ts +0 -237
- package/src/charts/__tests__/registry.test.ts +0 -116
- package/src/charts/__tests__/utils.test.ts +0 -195
- package/src/charts/_shared/__tests__/density-filter.test.ts +0 -32
- package/src/charts/_shared/density-filter.ts +0 -26
- package/src/charts/_shared/format-label-value.ts +0 -16
- package/src/charts/bar/__tests__/compute.test.ts +0 -934
- package/src/charts/bar/__tests__/gradient-orient.test.ts +0 -127
- package/src/charts/bar/__tests__/labels.test.ts +0 -292
- package/src/charts/bar/compute.ts +0 -539
- package/src/charts/bar/index.ts +0 -60
- package/src/charts/bar/labels.ts +0 -311
- package/src/charts/beeswarm/__tests__/compute.test.ts +0 -280
- package/src/charts/beeswarm/__tests__/dodge.test.ts +0 -148
- package/src/charts/beeswarm/compute.ts +0 -203
- package/src/charts/beeswarm/dodge.ts +0 -155
- package/src/charts/beeswarm/index.ts +0 -32
- package/src/charts/builtin.ts +0 -75
- package/src/charts/calendar/__tests__/compute.test.ts +0 -417
- package/src/charts/calendar/compute.ts +0 -319
- package/src/charts/calendar/index.ts +0 -32
- package/src/charts/column/__tests__/compute.test.ts +0 -540
- package/src/charts/column/__tests__/labels.test.ts +0 -179
- package/src/charts/column/compute.ts +0 -493
- package/src/charts/column/index.ts +0 -53
- package/src/charts/column/labels.ts +0 -229
- package/src/charts/dot/__tests__/compute.test.ts +0 -380
- package/src/charts/dot/__tests__/labels.test.ts +0 -98
- package/src/charts/dot/compute.ts +0 -285
- package/src/charts/dot/index.ts +0 -62
- package/src/charts/dot/labels.ts +0 -121
- package/src/charts/line/__tests__/compute.test.ts +0 -1194
- package/src/charts/line/__tests__/curves.test.ts +0 -51
- package/src/charts/line/__tests__/labels.test.ts +0 -94
- package/src/charts/line/__tests__/mark-options.test.ts +0 -175
- package/src/charts/line/area.ts +0 -484
- package/src/charts/line/compute.ts +0 -356
- package/src/charts/line/curves.ts +0 -85
- package/src/charts/line/index.ts +0 -176
- package/src/charts/line/labels.ts +0 -177
- package/src/charts/parliament/__tests__/compute.test.ts +0 -411
- package/src/charts/parliament/compute.ts +0 -313
- package/src/charts/parliament/index.ts +0 -32
- package/src/charts/pie/__tests__/compute.test.ts +0 -384
- package/src/charts/pie/__tests__/labels.test.ts +0 -132
- package/src/charts/pie/compute.ts +0 -324
- package/src/charts/pie/index.ts +0 -49
- package/src/charts/pie/labels.ts +0 -143
- package/src/charts/post-process.ts +0 -229
- package/src/charts/range/__tests__/compute.test.ts +0 -486
- package/src/charts/range/compute.ts +0 -273
- package/src/charts/range/index.ts +0 -82
- package/src/charts/range/labels.ts +0 -169
- package/src/charts/rect/__tests__/compute.test.ts +0 -120
- package/src/charts/rect/index.ts +0 -127
- package/src/charts/registry.ts +0 -72
- package/src/charts/rule/index.ts +0 -139
- package/src/charts/scatter/__tests__/compute.test.ts +0 -488
- package/src/charts/scatter/__tests__/trendline.test.ts +0 -191
- package/src/charts/scatter/compute.ts +0 -192
- package/src/charts/scatter/index.ts +0 -46
- package/src/charts/scatter/trendline.ts +0 -100
- package/src/charts/text/__tests__/compute.test.ts +0 -307
- package/src/charts/text/index.ts +0 -141
- package/src/charts/tick/__tests__/compute.test.ts +0 -75
- package/src/charts/tick/index.ts +0 -99
- package/src/charts/utils.ts +0 -170
- package/src/charts/waffle/__tests__/compute.test.ts +0 -305
- package/src/charts/waffle/compute.ts +0 -197
- package/src/charts/waffle/index.ts +0 -31
- package/src/compile/__tests__/color-scale-range.test.ts +0 -158
- package/src/compile/__tests__/color-scale-reverse.test.ts +0 -89
- package/src/compile/__tests__/data-clip.test.ts +0 -127
- package/src/compile/__tests__/fill-patterns.test.ts +0 -154
- package/src/compile/__tests__/size-scale.test.ts +0 -108
- package/src/compile/__tests__/watermark-obstacle.test.ts +0 -93
- package/src/compile/color-scale-range.ts +0 -101
- package/src/compile/data-clip.ts +0 -48
- package/src/compile/fill-patterns.ts +0 -74
- package/src/compile/layer.ts +0 -626
- package/src/compile/size-scale.ts +0 -139
- package/src/compile/spec-sugar.ts +0 -758
- package/src/compile/watermark-obstacle.ts +0 -54
- package/src/compile/you-draw-it.ts +0 -123
- package/src/compile.ts +0 -1450
- package/src/compiler/__tests__/a11y-warnings.test.ts +0 -148
- package/src/compiler/__tests__/animation.test.ts +0 -177
- package/src/compiler/__tests__/compile.test.ts +0 -87
- package/src/compiler/__tests__/fixtures/vl-parity/axis-null.json +0 -19
- package/src/compiler/__tests__/fixtures/vl-parity/color-value.json +0 -20
- package/src/compiler/__tests__/fixtures/vl-parity/count-aggregate.json +0 -22
- package/src/compiler/__tests__/fixtures/vl-parity/data-values.json +0 -19
- package/src/compiler/__tests__/fixtures/vl-parity/layer-axis-null.json +0 -14
- package/src/compiler/__tests__/fixtures/vl-parity/layer-count.json +0 -34
- package/src/compiler/__tests__/fixtures/vl-parity/layer-data-values.json +0 -22
- package/src/compiler/__tests__/fixtures/vl-parity/layer-legend-null.json +0 -41
- package/src/compiler/__tests__/fixtures/vl-parity/layer-scheme.json +0 -38
- package/src/compiler/__tests__/fixtures/vl-parity/layer-sort.json +0 -28
- package/src/compiler/__tests__/fixtures/vl-parity/layer-theta.json +0 -28
- package/src/compiler/__tests__/fixtures/vl-parity/layer-title.json +0 -30
- package/src/compiler/__tests__/fixtures/vl-parity/layer-value-def.json +0 -29
- package/src/compiler/__tests__/fixtures/vl-parity/legend-null.json +0 -32
- package/src/compiler/__tests__/fixtures/vl-parity/scheme.json +0 -25
- package/src/compiler/__tests__/fixtures/vl-parity/sort-by-value.json +0 -19
- package/src/compiler/__tests__/fixtures/vl-parity/theta-arc.json +0 -19
- package/src/compiler/__tests__/fixtures/vl-parity/title-subtitle.json +0 -21
- package/src/compiler/__tests__/fixtures/vl-parity/width-height.json +0 -23
- package/src/compiler/__tests__/keys.test.ts +0 -83
- package/src/compiler/__tests__/normalize-highlight.test.ts +0 -103
- package/src/compiler/__tests__/normalize.test.ts +0 -399
- package/src/compiler/__tests__/sparkline-defaults.test.ts +0 -153
- package/src/compiler/__tests__/validate-did-you-mean.test.ts +0 -86
- package/src/compiler/__tests__/validate-highlight.test.ts +0 -112
- package/src/compiler/__tests__/validate-near-miss.test.ts +0 -89
- package/src/compiler/__tests__/validate.test.ts +0 -629
- package/src/compiler/__tests__/vl-parity.test.ts +0 -68
- package/src/compiler/a11y-warnings.ts +0 -110
- package/src/compiler/animation.ts +0 -142
- package/src/compiler/index.ts +0 -47
- package/src/compiler/keys.ts +0 -55
- package/src/compiler/normalize.ts +0 -733
- package/src/compiler/sparkline-defaults.ts +0 -138
- package/src/compiler/types.ts +0 -241
- package/src/compiler/validate.ts +0 -1810
- package/src/endpoint-labels/__tests__/compute.test.ts +0 -587
- package/src/endpoint-labels/compute.ts +0 -621
- package/src/endpoint-labels/constants.ts +0 -54
- package/src/endpoint-labels/format.ts +0 -60
- package/src/endpoint-labels/predict.ts +0 -139
- package/src/format/__tests__/field-format.test.ts +0 -164
- package/src/format/field-format.ts +0 -27
- package/src/geo/__tests__/compile-map.test.ts +0 -621
- package/src/geo/compile-map.ts +0 -856
- package/src/geo/join.ts +0 -115
- package/src/geo/projections.ts +0 -45
- package/src/geo/types.ts +0 -24
- package/src/graphs/__tests__/community.test.ts +0 -228
- package/src/graphs/__tests__/compile-graph.test.ts +0 -396
- package/src/graphs/__tests__/encoding.test.ts +0 -415
- package/src/graphs/community.ts +0 -92
- package/src/graphs/compile-graph.ts +0 -329
- package/src/graphs/encoding.ts +0 -356
- package/src/graphs/types.ts +0 -106
- package/src/index.ts +0 -177
- package/src/layout/__tests__/metrics.test.ts +0 -97
- package/src/layout/__tests__/scales-highlight.test.ts +0 -192
- package/src/layout/axes/rotation.ts +0 -152
- package/src/layout/axes/thinning.ts +0 -98
- package/src/layout/axes/ticks.ts +0 -409
- package/src/layout/axes.ts +0 -757
- package/src/layout/dimensions.ts +0 -912
- package/src/layout/facet.ts +0 -91
- package/src/layout/gridlines.ts +0 -84
- package/src/layout/metrics.ts +0 -132
- package/src/layout/plan.ts +0 -631
- package/src/layout/scales.ts +0 -1053
- package/src/layout/shared.ts +0 -44
- package/src/legend/__tests__/continuous.test.ts +0 -318
- package/src/legend/__tests__/redundancy.test.ts +0 -108
- package/src/legend/__tests__/size.test.ts +0 -348
- package/src/legend/__tests__/suppression.test.ts +0 -382
- package/src/legend/compute.ts +0 -747
- package/src/legend/continuous.ts +0 -327
- package/src/legend/size.ts +0 -407
- package/src/legend/suppression.ts +0 -226
- package/src/legend/wrap.ts +0 -117
- package/src/sankey/__tests__/compile-sankey.test.ts +0 -458
- package/src/sankey/__tests__/layout.test.ts +0 -165
- package/src/sankey/__tests__/node-label-wrap.test.ts +0 -114
- package/src/sankey/__tests__/node-sort.test.ts +0 -45
- package/src/sankey/compile-sankey.ts +0 -706
- package/src/sankey/layout.ts +0 -184
- package/src/sankey/types.ts +0 -41
- package/src/tables/__tests__/bar-column.test.ts +0 -147
- package/src/tables/__tests__/category-colors.test.ts +0 -230
- package/src/tables/__tests__/compile-table.test.ts +0 -236
- package/src/tables/__tests__/format-cells.test.ts +0 -143
- package/src/tables/__tests__/heatmap.test.ts +0 -148
- package/src/tables/__tests__/pagination.test.ts +0 -78
- package/src/tables/__tests__/search.test.ts +0 -94
- package/src/tables/__tests__/sort.test.ts +0 -107
- package/src/tables/__tests__/sparkline.test.ts +0 -122
- package/src/tables/bar-column.ts +0 -94
- package/src/tables/category-colors.ts +0 -78
- package/src/tables/compile-table.ts +0 -427
- package/src/tables/format-cells.ts +0 -115
- package/src/tables/heatmap.ts +0 -141
- package/src/tables/pagination.ts +0 -46
- package/src/tables/search.ts +0 -66
- package/src/tables/sort.ts +0 -69
- package/src/tables/sparkline.ts +0 -113
- package/src/tables/utils.ts +0 -16
- package/src/tilemap/__tests__/compile-tilemap.test.ts +0 -576
- package/src/tilemap/compile-tilemap.ts +0 -755
- package/src/tilemap/layout.ts +0 -172
- package/src/tilemap/types.ts +0 -33
- package/src/tooltips/__tests__/compute.test.ts +0 -582
- package/src/tooltips/compute.ts +0 -700
- package/src/transforms/__tests__/aggregate.test.ts +0 -159
- package/src/transforms/__tests__/bin.test.ts +0 -88
- package/src/transforms/__tests__/calculate.test.ts +0 -146
- package/src/transforms/__tests__/conditional.test.ts +0 -127
- package/src/transforms/__tests__/filter-relative.test.ts +0 -202
- package/src/transforms/__tests__/filter.test.ts +0 -59
- package/src/transforms/__tests__/fold.test.ts +0 -79
- package/src/transforms/__tests__/index.test.ts +0 -93
- package/src/transforms/__tests__/predicates.test.ts +0 -193
- package/src/transforms/__tests__/timeunit.test.ts +0 -129
- package/src/transforms/__tests__/window.test.ts +0 -286
- package/src/transforms/aggregate.ts +0 -131
- package/src/transforms/bin.ts +0 -87
- package/src/transforms/calculate.ts +0 -60
- package/src/transforms/conditional.ts +0 -46
- package/src/transforms/filter.ts +0 -122
- package/src/transforms/fold.ts +0 -49
- package/src/transforms/index.ts +0 -60
- package/src/transforms/predicates.ts +0 -123
- package/src/transforms/timeunit.ts +0 -88
- package/src/transforms/window.ts +0 -185
package/src/compiler/validate.ts
DELETED
|
@@ -1,1810 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Runtime spec validation.
|
|
3
|
-
*
|
|
4
|
-
* TypeScript catches compile-time errors for specs written in code.
|
|
5
|
-
* This module catches runtime errors for specs coming from JSON, APIs,
|
|
6
|
-
* or Claude-generated output where the TypeScript compiler can't help.
|
|
7
|
-
*
|
|
8
|
-
* Every error includes a machine-readable code and an actionable suggestion
|
|
9
|
-
* so consumers (and LLMs) can fix issues programmatically.
|
|
10
|
-
*/
|
|
11
|
-
|
|
12
|
-
import {
|
|
13
|
-
type FieldType,
|
|
14
|
-
inferFieldType,
|
|
15
|
-
MARK_ENCODING_RULES,
|
|
16
|
-
MARK_TYPES,
|
|
17
|
-
type MarkType,
|
|
18
|
-
resolveSchemeName,
|
|
19
|
-
SUPPORTED_SCHEME_NAMES,
|
|
20
|
-
type VizSpec,
|
|
21
|
-
} from '@opendata-ai/openchart-core';
|
|
22
|
-
|
|
23
|
-
import type { ValidationError, ValidationResult } from './types';
|
|
24
|
-
|
|
25
|
-
// ---------------------------------------------------------------------------
|
|
26
|
-
// Helpers
|
|
27
|
-
// ---------------------------------------------------------------------------
|
|
28
|
-
|
|
29
|
-
const VALID_FIELD_TYPES = new Set<string>(['quantitative', 'temporal', 'nominal', 'ordinal']);
|
|
30
|
-
|
|
31
|
-
const VALID_DARK_MODES = new Set<string>(['auto', 'force', 'off']);
|
|
32
|
-
|
|
33
|
-
/** Check if a value looks like a parseable date. */
|
|
34
|
-
function isParseableDate(value: unknown): boolean {
|
|
35
|
-
if (value instanceof Date) return !Number.isNaN(value.getTime());
|
|
36
|
-
if (typeof value === 'string') {
|
|
37
|
-
const d = new Date(value);
|
|
38
|
-
return !Number.isNaN(d.getTime());
|
|
39
|
-
}
|
|
40
|
-
if (typeof value === 'number') return true;
|
|
41
|
-
return false;
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
/** Check if a value is numeric. */
|
|
45
|
-
function isNumeric(value: unknown): boolean {
|
|
46
|
-
if (typeof value === 'number') return Number.isFinite(value);
|
|
47
|
-
if (typeof value === 'string') {
|
|
48
|
-
const n = Number(value);
|
|
49
|
-
return !Number.isNaN(n) && Number.isFinite(n);
|
|
50
|
-
}
|
|
51
|
-
return false;
|
|
52
|
-
}
|
|
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
|
-
|
|
339
|
-
// ---------------------------------------------------------------------------
|
|
340
|
-
// Chart validation
|
|
341
|
-
// ---------------------------------------------------------------------------
|
|
342
|
-
|
|
343
|
-
function validateChartSpec(spec: Record<string, unknown>, errors: ValidationError[]): void {
|
|
344
|
-
const markType =
|
|
345
|
-
typeof spec.mark === 'string' ? spec.mark : (spec.mark as Record<string, unknown>)?.type;
|
|
346
|
-
|
|
347
|
-
// Check data
|
|
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
|
-
}
|
|
363
|
-
errors.push({
|
|
364
|
-
message: 'Spec error: "data" must be an array',
|
|
365
|
-
path: 'data',
|
|
366
|
-
code: 'INVALID_TYPE',
|
|
367
|
-
suggestion: 'Provide data as an array of objects, e.g. data: [{ x: 1, y: 2 }]',
|
|
368
|
-
});
|
|
369
|
-
return; // Can't validate further without data
|
|
370
|
-
}
|
|
371
|
-
|
|
372
|
-
if (spec.data.length === 0) {
|
|
373
|
-
errors.push({
|
|
374
|
-
message: 'Spec error: "data" must be a non-empty array',
|
|
375
|
-
path: 'data',
|
|
376
|
-
code: 'EMPTY_DATA',
|
|
377
|
-
suggestion: 'Add at least one data row, e.g. data: [{ x: 1, y: 2 }]',
|
|
378
|
-
});
|
|
379
|
-
return;
|
|
380
|
-
}
|
|
381
|
-
|
|
382
|
-
// Validate data entries are objects
|
|
383
|
-
const firstRow = spec.data[0] as unknown;
|
|
384
|
-
if (typeof firstRow !== 'object' || firstRow === null || Array.isArray(firstRow)) {
|
|
385
|
-
errors.push({
|
|
386
|
-
message: 'Spec error: each item in "data" must be a plain object',
|
|
387
|
-
path: 'data[0]',
|
|
388
|
-
code: 'INVALID_TYPE',
|
|
389
|
-
suggestion:
|
|
390
|
-
'Each data item should be an object with key-value pairs, e.g. { name: "Alice", value: 10 }',
|
|
391
|
-
});
|
|
392
|
-
return;
|
|
393
|
-
}
|
|
394
|
-
|
|
395
|
-
// Check encoding exists
|
|
396
|
-
if (!spec.encoding || typeof spec.encoding !== 'object') {
|
|
397
|
-
const rules = MARK_ENCODING_RULES[markType as MarkType];
|
|
398
|
-
const requiredChannels = Object.entries(rules)
|
|
399
|
-
.filter(([, rule]) => rule.required)
|
|
400
|
-
.map(([ch]) => ch);
|
|
401
|
-
errors.push({
|
|
402
|
-
message: `Spec error: ${markType} chart requires an "encoding" object`,
|
|
403
|
-
path: 'encoding',
|
|
404
|
-
code: 'MISSING_FIELD',
|
|
405
|
-
suggestion: `Add an encoding object with required channels: ${requiredChannels.join(', ')}. Example: encoding: { ${requiredChannels.map((ch) => `${ch}: { field: "...", type: "..." }`).join(', ')} }`,
|
|
406
|
-
});
|
|
407
|
-
return;
|
|
408
|
-
}
|
|
409
|
-
|
|
410
|
-
const rules = MARK_ENCODING_RULES[markType as MarkType];
|
|
411
|
-
const encoding = spec.encoding as Record<string, unknown>;
|
|
412
|
-
const dataColumns = new Set(Object.keys(firstRow as Record<string, unknown>));
|
|
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);
|
|
423
|
-
for (const [channel, rule] of Object.entries(rules)) {
|
|
424
|
-
if (!rule.required) continue;
|
|
425
|
-
const satisfiedByAlias =
|
|
426
|
-
isThetaMark && ((channel === 'theta' && encoding.y) || (channel === 'y' && encoding.theta));
|
|
427
|
-
if (!encoding[channel] && !satisfiedByAlias) {
|
|
428
|
-
const allowedTypes = rule.allowedTypes.join(' or ');
|
|
429
|
-
const suggestedChannel = channel === 'y' && isThetaMark ? 'theta' : channel;
|
|
430
|
-
errors.push({
|
|
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',
|
|
453
|
-
code: 'MISSING_FIELD',
|
|
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).',
|
|
478
|
-
});
|
|
479
|
-
}
|
|
480
|
-
}
|
|
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
|
-
|
|
511
|
-
// Collect fields that transforms will create, so we don't reject them
|
|
512
|
-
const transformFields = new Set<string>();
|
|
513
|
-
if (Array.isArray(spec.transform)) {
|
|
514
|
-
for (const t of spec.transform as Record<string, unknown>[]) {
|
|
515
|
-
// filter/bin/calculate/timeUnit/fold write their output(s) to `as`.
|
|
516
|
-
if (typeof t.as === 'string') transformFields.add(t.as);
|
|
517
|
-
if (Array.isArray(t.as)) {
|
|
518
|
-
for (const f of t.as) {
|
|
519
|
-
if (typeof f === 'string') transformFields.add(f);
|
|
520
|
-
}
|
|
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
|
-
}
|
|
535
|
-
}
|
|
536
|
-
}
|
|
537
|
-
|
|
538
|
-
// Validate provided channels
|
|
539
|
-
for (const [channel, channelSpec] of Object.entries(encoding)) {
|
|
540
|
-
if (!channelSpec || typeof channelSpec !== 'object') continue;
|
|
541
|
-
|
|
542
|
-
// Tooltip can be an array of encoding channels
|
|
543
|
-
if (channel === 'tooltip' && Array.isArray(channelSpec)) {
|
|
544
|
-
for (let i = 0; i < channelSpec.length; i++) {
|
|
545
|
-
const elem = channelSpec[i] as Record<string, unknown> | null;
|
|
546
|
-
if (!elem || typeof elem !== 'object') continue;
|
|
547
|
-
if (!elem.field || typeof elem.field !== 'string') {
|
|
548
|
-
errors.push({
|
|
549
|
-
message: `Spec error: encoding.tooltip[${i}] must have a "field" string`,
|
|
550
|
-
path: `encoding.tooltip[${i}].field`,
|
|
551
|
-
code: 'MISSING_FIELD',
|
|
552
|
-
suggestion: `Add a field name from your data columns: ${availableColumns}`,
|
|
553
|
-
});
|
|
554
|
-
continue;
|
|
555
|
-
}
|
|
556
|
-
if (!dataColumns.has(elem.field) && !transformFields.has(elem.field)) {
|
|
557
|
-
errors.push({
|
|
558
|
-
message: `Spec error: encoding.tooltip[${i}].field "${elem.field}" does not exist in data. Available columns: ${availableColumns}`,
|
|
559
|
-
path: `encoding.tooltip[${i}].field`,
|
|
560
|
-
code: 'DATA_FIELD_MISSING',
|
|
561
|
-
suggestion: `Use one of the available data columns: ${availableColumns}.${didYouMean(elem.field, columnList)}`,
|
|
562
|
-
});
|
|
563
|
-
}
|
|
564
|
-
if (elem.type && !VALID_FIELD_TYPES.has(elem.type as string)) {
|
|
565
|
-
errors.push({
|
|
566
|
-
message: `Spec error: encoding.tooltip[${i}].type "${elem.type}" is not valid. Must be one of: ${[...VALID_FIELD_TYPES].join(', ')}`,
|
|
567
|
-
path: `encoding.tooltip[${i}].type`,
|
|
568
|
-
code: 'INVALID_VALUE',
|
|
569
|
-
suggestion: `Use one of: ${[...VALID_FIELD_TYPES].join(', ')}`,
|
|
570
|
-
});
|
|
571
|
-
}
|
|
572
|
-
}
|
|
573
|
-
continue;
|
|
574
|
-
}
|
|
575
|
-
|
|
576
|
-
const channelObj = channelSpec as Record<string, unknown>;
|
|
577
|
-
const channelRule = rules[channel as keyof typeof rules];
|
|
578
|
-
|
|
579
|
-
// Skip ConditionalValueDef channels (they have 'condition' instead of 'field')
|
|
580
|
-
if ('condition' in channelObj) continue;
|
|
581
|
-
|
|
582
|
-
// Check field exists
|
|
583
|
-
if (!channelObj.field || typeof channelObj.field !== 'string') {
|
|
584
|
-
errors.push({
|
|
585
|
-
message: `Spec error: encoding.${channel} must have a "field" string`,
|
|
586
|
-
path: `encoding.${channel}.field`,
|
|
587
|
-
code: 'MISSING_FIELD',
|
|
588
|
-
suggestion: `For constant colors, use mark.fill (e.g., mark: { type: "bar", fill: "#1b7fa3" }) instead of encoding.${channel}. Encoding channels require a data field: ${availableColumns}`,
|
|
589
|
-
});
|
|
590
|
-
continue;
|
|
591
|
-
}
|
|
592
|
-
|
|
593
|
-
// Check field references a column in data (or will be created by a transform)
|
|
594
|
-
if (!dataColumns.has(channelObj.field) && !transformFields.has(channelObj.field)) {
|
|
595
|
-
errors.push({
|
|
596
|
-
message: `Spec error: encoding.${channel}.field "${channelObj.field}" does not exist in data. Available columns: ${availableColumns}`,
|
|
597
|
-
path: `encoding.${channel}.field`,
|
|
598
|
-
code: 'DATA_FIELD_MISSING',
|
|
599
|
-
suggestion: `Use one of the available data columns: ${availableColumns}.${didYouMean(channelObj.field, columnList)}`,
|
|
600
|
-
});
|
|
601
|
-
}
|
|
602
|
-
|
|
603
|
-
// Check field type is valid
|
|
604
|
-
if (channelObj.type && !VALID_FIELD_TYPES.has(channelObj.type as string)) {
|
|
605
|
-
errors.push({
|
|
606
|
-
message: `Spec error: encoding.${channel}.type "${channelObj.type}" is not valid. Must be one of: ${[...VALID_FIELD_TYPES].join(', ')}`,
|
|
607
|
-
path: `encoding.${channel}.type`,
|
|
608
|
-
code: 'INVALID_VALUE',
|
|
609
|
-
suggestion: `Use one of: ${[...VALID_FIELD_TYPES].join(', ')}`,
|
|
610
|
-
});
|
|
611
|
-
}
|
|
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
|
-
|
|
630
|
-
// Check field type is allowed for this channel
|
|
631
|
-
if (channelRule && channelObj.type && channelRule.allowedTypes.length > 0) {
|
|
632
|
-
if (!channelRule.allowedTypes.includes(channelObj.type as FieldType)) {
|
|
633
|
-
errors.push({
|
|
634
|
-
message: `Spec error: encoding.${channel} for ${markType} chart does not accept type "${channelObj.type}". Allowed types: ${channelRule.allowedTypes.join(', ')}`,
|
|
635
|
-
path: `encoding.${channel}.type`,
|
|
636
|
-
code: 'ENCODING_MISMATCH',
|
|
637
|
-
suggestion: `Change encoding.${channel}.type to one of: ${channelRule.allowedTypes.join(', ')}`,
|
|
638
|
-
});
|
|
639
|
-
}
|
|
640
|
-
}
|
|
641
|
-
|
|
642
|
-
// Check field values match declared type
|
|
643
|
-
if (channelObj.type && channelObj.field && dataColumns.has(channelObj.field as string)) {
|
|
644
|
-
const data = spec.data as Record<string, unknown>[];
|
|
645
|
-
const fieldName = channelObj.field as string;
|
|
646
|
-
const fieldType = channelObj.type as string;
|
|
647
|
-
// Sample up to 5 values for type checking
|
|
648
|
-
const sampleSize = Math.min(5, data.length);
|
|
649
|
-
|
|
650
|
-
if (fieldType === 'temporal') {
|
|
651
|
-
let nonDateCount = 0;
|
|
652
|
-
for (let i = 0; i < sampleSize; i++) {
|
|
653
|
-
const val = data[i][fieldName];
|
|
654
|
-
if (val != null && !isParseableDate(val)) {
|
|
655
|
-
nonDateCount++;
|
|
656
|
-
}
|
|
657
|
-
}
|
|
658
|
-
if (nonDateCount > 0) {
|
|
659
|
-
errors.push({
|
|
660
|
-
message: `Spec error: encoding.${channel}.field "${fieldName}" is declared as temporal but contains non-date values`,
|
|
661
|
-
path: `encoding.${channel}`,
|
|
662
|
-
code: 'ENCODING_MISMATCH',
|
|
663
|
-
suggestion: `Either change the type to "nominal" or ensure "${fieldName}" values are parseable dates (ISO 8601 strings like "2024-01-15" or Date objects)`,
|
|
664
|
-
});
|
|
665
|
-
}
|
|
666
|
-
}
|
|
667
|
-
|
|
668
|
-
if (fieldType === 'quantitative') {
|
|
669
|
-
let nonNumericCount = 0;
|
|
670
|
-
for (let i = 0; i < sampleSize; i++) {
|
|
671
|
-
const val = data[i][fieldName];
|
|
672
|
-
if (val != null && !isNumeric(val)) {
|
|
673
|
-
nonNumericCount++;
|
|
674
|
-
}
|
|
675
|
-
}
|
|
676
|
-
if (nonNumericCount > 0) {
|
|
677
|
-
errors.push({
|
|
678
|
-
message: `Spec error: encoding.${channel}.field "${fieldName}" is declared as quantitative but contains non-numeric values`,
|
|
679
|
-
path: `encoding.${channel}`,
|
|
680
|
-
code: 'ENCODING_MISMATCH',
|
|
681
|
-
suggestion: `Either change the type to "nominal" or ensure "${fieldName}" values are numbers`,
|
|
682
|
-
});
|
|
683
|
-
}
|
|
684
|
-
}
|
|
685
|
-
}
|
|
686
|
-
}
|
|
687
|
-
|
|
688
|
-
// Validate highlight on the color channel
|
|
689
|
-
if (encoding) {
|
|
690
|
-
const colorCh = (encoding as Record<string, Record<string, unknown> | undefined>).color;
|
|
691
|
-
if (colorCh && colorCh.highlight != null) {
|
|
692
|
-
const hlVal = colorCh.highlight;
|
|
693
|
-
const isValidType =
|
|
694
|
-
typeof hlVal === 'string' ||
|
|
695
|
-
(Array.isArray(hlVal) && hlVal.every((v: unknown) => typeof v === 'string'));
|
|
696
|
-
if (!isValidType) {
|
|
697
|
-
errors.push({
|
|
698
|
-
message: 'Spec error: encoding.color.highlight must be a string or array of strings',
|
|
699
|
-
path: 'encoding.color.highlight',
|
|
700
|
-
code: 'INVALID_TYPE',
|
|
701
|
-
suggestion: 'Provide a series name (string) or an array of series names.',
|
|
702
|
-
});
|
|
703
|
-
} else {
|
|
704
|
-
const colorType =
|
|
705
|
-
(colorCh.type as string | undefined) ??
|
|
706
|
-
(colorCh.field && Array.isArray(spec.data) && spec.data.length > 0
|
|
707
|
-
? inferFieldType(spec.data as Record<string, unknown>[], colorCh.field as string)
|
|
708
|
-
: undefined);
|
|
709
|
-
if (colorType === 'quantitative') {
|
|
710
|
-
errors.push({
|
|
711
|
-
message:
|
|
712
|
-
'Spec error: encoding.color.highlight is not supported on quantitative color channels',
|
|
713
|
-
path: 'encoding.color.highlight',
|
|
714
|
-
code: 'INVALID_VALUE',
|
|
715
|
-
suggestion:
|
|
716
|
-
'Highlight works with nominal/ordinal color (categorical series). Remove highlight or change encoding.color.type to nominal.',
|
|
717
|
-
});
|
|
718
|
-
}
|
|
719
|
-
}
|
|
720
|
-
}
|
|
721
|
-
}
|
|
722
|
-
|
|
723
|
-
// Validate facet channel if provided
|
|
724
|
-
if (encoding.facet && typeof encoding.facet === 'object') {
|
|
725
|
-
const facet = encoding.facet as Record<string, unknown>;
|
|
726
|
-
if (!facet.field || typeof facet.field !== 'string') {
|
|
727
|
-
errors.push({
|
|
728
|
-
message: 'Spec error: encoding.facet.field is required and must be a non-empty string',
|
|
729
|
-
path: 'encoding.facet.field',
|
|
730
|
-
code: 'MISSING_FIELD',
|
|
731
|
-
suggestion:
|
|
732
|
-
'Add a field property to encoding.facet (e.g. { field: "category", type: "nominal" })',
|
|
733
|
-
});
|
|
734
|
-
} else if (
|
|
735
|
-
!dataColumns.has(facet.field as string) &&
|
|
736
|
-
!transformFields.has(facet.field as string)
|
|
737
|
-
) {
|
|
738
|
-
errors.push({
|
|
739
|
-
message: `Spec error: encoding.facet.field "${facet.field}" does not exist in data. Available columns: ${availableColumns}`,
|
|
740
|
-
path: 'encoding.facet.field',
|
|
741
|
-
code: 'DATA_FIELD_MISSING',
|
|
742
|
-
suggestion: `Use one of the available data columns: ${availableColumns}.${didYouMean(facet.field as string, columnList)}`,
|
|
743
|
-
});
|
|
744
|
-
}
|
|
745
|
-
if (!facet.type || (facet.type !== 'nominal' && facet.type !== 'ordinal')) {
|
|
746
|
-
errors.push({
|
|
747
|
-
message: `Spec error: encoding.facet.type must be "nominal" or "ordinal"${facet.type ? `, got "${facet.type}"` : ''}`,
|
|
748
|
-
path: 'encoding.facet.type',
|
|
749
|
-
code: facet.type ? 'INVALID_VALUE' : 'MISSING_FIELD',
|
|
750
|
-
suggestion: 'Set encoding.facet.type to "nominal" or "ordinal"',
|
|
751
|
-
});
|
|
752
|
-
}
|
|
753
|
-
if (facet.columns !== undefined) {
|
|
754
|
-
const cols = Number(facet.columns);
|
|
755
|
-
if (!Number.isInteger(cols) || cols < 1) {
|
|
756
|
-
errors.push({
|
|
757
|
-
message: 'Spec error: encoding.facet.columns must be a positive integer',
|
|
758
|
-
path: 'encoding.facet.columns',
|
|
759
|
-
code: 'INVALID_VALUE',
|
|
760
|
-
suggestion: 'Set columns to a positive integer (e.g. 3)',
|
|
761
|
-
});
|
|
762
|
-
}
|
|
763
|
-
}
|
|
764
|
-
}
|
|
765
|
-
|
|
766
|
-
// Validate darkMode if provided
|
|
767
|
-
if (spec.darkMode !== undefined && !VALID_DARK_MODES.has(spec.darkMode as string)) {
|
|
768
|
-
errors.push({
|
|
769
|
-
message: `Spec error: darkMode must be "auto", "force", or "off"`,
|
|
770
|
-
path: 'darkMode',
|
|
771
|
-
code: 'INVALID_VALUE',
|
|
772
|
-
suggestion:
|
|
773
|
-
'Use one of: "auto" (system preference), "force" (always dark), or "off" (always light)',
|
|
774
|
-
});
|
|
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
|
-
}
|
|
830
|
-
}
|
|
831
|
-
|
|
832
|
-
// ---------------------------------------------------------------------------
|
|
833
|
-
// Table validation
|
|
834
|
-
// ---------------------------------------------------------------------------
|
|
835
|
-
|
|
836
|
-
function validateTableSpec(spec: Record<string, unknown>, errors: ValidationError[]): void {
|
|
837
|
-
if (!Array.isArray(spec.data)) {
|
|
838
|
-
errors.push({
|
|
839
|
-
message: 'Spec error: "data" must be an array',
|
|
840
|
-
path: 'data',
|
|
841
|
-
code: 'INVALID_TYPE',
|
|
842
|
-
suggestion: 'Provide data as an array of objects, e.g. data: [{ name: "Alice", age: 30 }]',
|
|
843
|
-
});
|
|
844
|
-
return;
|
|
845
|
-
}
|
|
846
|
-
|
|
847
|
-
if (spec.data.length === 0) {
|
|
848
|
-
errors.push({
|
|
849
|
-
message: 'Spec error: "data" must be a non-empty array',
|
|
850
|
-
path: 'data',
|
|
851
|
-
code: 'EMPTY_DATA',
|
|
852
|
-
suggestion: 'Add at least one data row to the data array',
|
|
853
|
-
});
|
|
854
|
-
return;
|
|
855
|
-
}
|
|
856
|
-
|
|
857
|
-
if (!Array.isArray(spec.columns)) {
|
|
858
|
-
errors.push({
|
|
859
|
-
message: 'Spec error: table spec requires a "columns" array',
|
|
860
|
-
path: 'columns',
|
|
861
|
-
code: 'MISSING_FIELD',
|
|
862
|
-
suggestion:
|
|
863
|
-
'Add a columns array defining which data fields to display, e.g. columns: [{ key: "name" }, { key: "age" }]',
|
|
864
|
-
});
|
|
865
|
-
return;
|
|
866
|
-
}
|
|
867
|
-
|
|
868
|
-
const data = spec.data as Record<string, unknown>[];
|
|
869
|
-
const firstRow = data[0];
|
|
870
|
-
if (typeof firstRow !== 'object' || firstRow === null || Array.isArray(firstRow)) {
|
|
871
|
-
errors.push({
|
|
872
|
-
message: 'Spec error: each item in "data" must be a plain object',
|
|
873
|
-
path: 'data[0]',
|
|
874
|
-
code: 'INVALID_TYPE',
|
|
875
|
-
suggestion:
|
|
876
|
-
'Each data item should be an object with key-value pairs, e.g. { name: "Alice", age: 30 }',
|
|
877
|
-
});
|
|
878
|
-
return;
|
|
879
|
-
}
|
|
880
|
-
|
|
881
|
-
const dataColumns = new Set(Object.keys(firstRow as Record<string, unknown>));
|
|
882
|
-
const columnList = [...dataColumns];
|
|
883
|
-
const availableColumns = columnList.join(', ');
|
|
884
|
-
const columns = spec.columns as Record<string, unknown>[];
|
|
885
|
-
|
|
886
|
-
for (let i = 0; i < columns.length; i++) {
|
|
887
|
-
const col = columns[i];
|
|
888
|
-
if (!col || typeof col !== 'object') {
|
|
889
|
-
errors.push({
|
|
890
|
-
message: `Spec error: columns[${i}] must be an object`,
|
|
891
|
-
path: `columns[${i}]`,
|
|
892
|
-
code: 'INVALID_TYPE',
|
|
893
|
-
suggestion: 'Each column entry should be an object, e.g. { key: "fieldName" }',
|
|
894
|
-
});
|
|
895
|
-
continue;
|
|
896
|
-
}
|
|
897
|
-
|
|
898
|
-
// Check key exists
|
|
899
|
-
if (!col.key || typeof col.key !== 'string') {
|
|
900
|
-
errors.push({
|
|
901
|
-
message: `Spec error: columns[${i}] must have a "key" string`,
|
|
902
|
-
path: `columns[${i}].key`,
|
|
903
|
-
code: 'MISSING_FIELD',
|
|
904
|
-
suggestion: `Add a key referencing a data field. Available columns: ${availableColumns}`,
|
|
905
|
-
});
|
|
906
|
-
continue;
|
|
907
|
-
}
|
|
908
|
-
|
|
909
|
-
// Check key references a field in data
|
|
910
|
-
if (!dataColumns.has(col.key as string)) {
|
|
911
|
-
errors.push({
|
|
912
|
-
message: `Spec error: columns[${i}].key "${col.key}" does not exist in data. Available columns: ${availableColumns}`,
|
|
913
|
-
path: `columns[${i}].key`,
|
|
914
|
-
code: 'DATA_FIELD_MISSING',
|
|
915
|
-
suggestion: `Use one of the available data columns: ${availableColumns}.${didYouMean(col.key as string, columnList)}`,
|
|
916
|
-
});
|
|
917
|
-
}
|
|
918
|
-
|
|
919
|
-
// Check at most one visual enhancement
|
|
920
|
-
const visuals = ['heatmap', 'bar', 'sparkline', 'image', 'flag', 'categoryColors'].filter(
|
|
921
|
-
(v) => col[v] != null && col[v] !== false,
|
|
922
|
-
);
|
|
923
|
-
if (visuals.length > 1) {
|
|
924
|
-
errors.push({
|
|
925
|
-
message: `Spec error: columns[${i}] has multiple visual features (${visuals.join(', ')}). Only one is allowed per column.`,
|
|
926
|
-
path: `columns[${i}]`,
|
|
927
|
-
code: 'INVALID_VALUE',
|
|
928
|
-
suggestion: `Keep only one visual feature per column. Remove all but one of: ${visuals.join(', ')}`,
|
|
929
|
-
});
|
|
930
|
-
}
|
|
931
|
-
}
|
|
932
|
-
}
|
|
933
|
-
|
|
934
|
-
// ---------------------------------------------------------------------------
|
|
935
|
-
// Graph validation
|
|
936
|
-
// ---------------------------------------------------------------------------
|
|
937
|
-
|
|
938
|
-
function validateGraphSpec(spec: Record<string, unknown>, errors: ValidationError[]): void {
|
|
939
|
-
// Validate nodes array exists and is non-empty
|
|
940
|
-
if (!Array.isArray(spec.nodes)) {
|
|
941
|
-
errors.push({
|
|
942
|
-
message: 'Spec error: graph spec requires a "nodes" array',
|
|
943
|
-
path: 'nodes',
|
|
944
|
-
code: 'MISSING_FIELD',
|
|
945
|
-
suggestion:
|
|
946
|
-
'Add a nodes array with objects that have an "id" field, e.g. nodes: [{ id: "a" }, { id: "b" }]',
|
|
947
|
-
});
|
|
948
|
-
return; // Can't validate further without nodes
|
|
949
|
-
}
|
|
950
|
-
|
|
951
|
-
if (spec.nodes.length === 0) {
|
|
952
|
-
errors.push({
|
|
953
|
-
message: 'Spec error: "nodes" must be a non-empty array',
|
|
954
|
-
path: 'nodes',
|
|
955
|
-
code: 'EMPTY_DATA',
|
|
956
|
-
suggestion: 'Add at least one node, e.g. nodes: [{ id: "a" }]',
|
|
957
|
-
});
|
|
958
|
-
return;
|
|
959
|
-
}
|
|
960
|
-
|
|
961
|
-
// Validate each node has a string id
|
|
962
|
-
const nodeIds = new Set<string>();
|
|
963
|
-
const nodes = spec.nodes as Record<string, unknown>[];
|
|
964
|
-
for (let i = 0; i < nodes.length; i++) {
|
|
965
|
-
const node = nodes[i];
|
|
966
|
-
if (!node || typeof node !== 'object') {
|
|
967
|
-
errors.push({
|
|
968
|
-
message: `Spec error: nodes[${i}] must be an object`,
|
|
969
|
-
path: `nodes[${i}]`,
|
|
970
|
-
code: 'INVALID_TYPE',
|
|
971
|
-
suggestion: 'Each node must be an object with at least an "id" field, e.g. { id: "a" }',
|
|
972
|
-
});
|
|
973
|
-
continue;
|
|
974
|
-
}
|
|
975
|
-
if (typeof node.id !== 'string' || node.id === '') {
|
|
976
|
-
errors.push({
|
|
977
|
-
message: `Spec error: nodes[${i}] must have a non-empty string "id" field`,
|
|
978
|
-
path: `nodes[${i}].id`,
|
|
979
|
-
code: 'MISSING_FIELD',
|
|
980
|
-
suggestion: 'Add a string id to the node, e.g. { id: "node1" }',
|
|
981
|
-
});
|
|
982
|
-
} else {
|
|
983
|
-
nodeIds.add(node.id);
|
|
984
|
-
}
|
|
985
|
-
}
|
|
986
|
-
|
|
987
|
-
// Validate edges array exists
|
|
988
|
-
if (!Array.isArray(spec.edges)) {
|
|
989
|
-
errors.push({
|
|
990
|
-
message: 'Spec error: graph spec requires an "edges" array',
|
|
991
|
-
path: 'edges',
|
|
992
|
-
code: 'MISSING_FIELD',
|
|
993
|
-
suggestion: 'Add an edges array (can be empty), e.g. edges: [{ source: "a", target: "b" }]',
|
|
994
|
-
});
|
|
995
|
-
return;
|
|
996
|
-
}
|
|
997
|
-
|
|
998
|
-
// Validate each edge has string source and target that reference existing nodes
|
|
999
|
-
const edges = spec.edges as Record<string, unknown>[];
|
|
1000
|
-
for (let i = 0; i < edges.length; i++) {
|
|
1001
|
-
const edge = edges[i];
|
|
1002
|
-
if (!edge || typeof edge !== 'object') {
|
|
1003
|
-
errors.push({
|
|
1004
|
-
message: `Spec error: edges[${i}] must be an object`,
|
|
1005
|
-
path: `edges[${i}]`,
|
|
1006
|
-
code: 'INVALID_TYPE',
|
|
1007
|
-
suggestion:
|
|
1008
|
-
'Each edge must be an object with "source" and "target" fields, e.g. { source: "a", target: "b" }',
|
|
1009
|
-
});
|
|
1010
|
-
continue;
|
|
1011
|
-
}
|
|
1012
|
-
|
|
1013
|
-
if (typeof edge.source !== 'string' || edge.source === '') {
|
|
1014
|
-
errors.push({
|
|
1015
|
-
message: `Spec error: edges[${i}] must have a non-empty string "source" field`,
|
|
1016
|
-
path: `edges[${i}].source`,
|
|
1017
|
-
code: 'MISSING_FIELD',
|
|
1018
|
-
suggestion: 'Add a source node id, e.g. { source: "a", target: "b" }',
|
|
1019
|
-
});
|
|
1020
|
-
} else if (nodeIds.size > 0 && !nodeIds.has(edge.source)) {
|
|
1021
|
-
errors.push({
|
|
1022
|
-
message: `Spec error: edges[${i}].source "${edge.source}" does not reference an existing node id`,
|
|
1023
|
-
path: `edges[${i}].source`,
|
|
1024
|
-
code: 'DATA_FIELD_MISSING',
|
|
1025
|
-
suggestion: `Use one of the existing node ids: ${[...nodeIds].slice(0, 5).join(', ')}${nodeIds.size > 5 ? '...' : ''}`,
|
|
1026
|
-
});
|
|
1027
|
-
}
|
|
1028
|
-
|
|
1029
|
-
if (typeof edge.target !== 'string' || edge.target === '') {
|
|
1030
|
-
errors.push({
|
|
1031
|
-
message: `Spec error: edges[${i}] must have a non-empty string "target" field`,
|
|
1032
|
-
path: `edges[${i}].target`,
|
|
1033
|
-
code: 'MISSING_FIELD',
|
|
1034
|
-
suggestion: 'Add a target node id, e.g. { source: "a", target: "b" }',
|
|
1035
|
-
});
|
|
1036
|
-
} else if (nodeIds.size > 0 && !nodeIds.has(edge.target)) {
|
|
1037
|
-
errors.push({
|
|
1038
|
-
message: `Spec error: edges[${i}].target "${edge.target}" does not reference an existing node id`,
|
|
1039
|
-
path: `edges[${i}].target`,
|
|
1040
|
-
code: 'DATA_FIELD_MISSING',
|
|
1041
|
-
suggestion: `Use one of the existing node ids: ${[...nodeIds].slice(0, 5).join(', ')}${nodeIds.size > 5 ? '...' : ''}`,
|
|
1042
|
-
});
|
|
1043
|
-
}
|
|
1044
|
-
}
|
|
1045
|
-
|
|
1046
|
-
// Validate encoding fields exist on at least the first node/edge
|
|
1047
|
-
if (spec.encoding && typeof spec.encoding === 'object') {
|
|
1048
|
-
const encoding = spec.encoding as Record<string, unknown>;
|
|
1049
|
-
const firstNode = nodes[0] as Record<string, unknown>;
|
|
1050
|
-
const firstEdge = edges.length > 0 ? (edges[0] as Record<string, unknown>) : null;
|
|
1051
|
-
const nodeFields = firstNode ? new Set(Object.keys(firstNode)) : new Set<string>();
|
|
1052
|
-
const edgeFields = firstEdge ? new Set(Object.keys(firstEdge)) : new Set<string>();
|
|
1053
|
-
|
|
1054
|
-
const nodeChannels = ['nodeColor', 'nodeSize', 'nodeLabel'] as const;
|
|
1055
|
-
for (const channel of nodeChannels) {
|
|
1056
|
-
const ch = encoding[channel] as Record<string, unknown> | undefined;
|
|
1057
|
-
if (ch?.field && typeof ch.field === 'string' && !nodeFields.has(ch.field)) {
|
|
1058
|
-
errors.push({
|
|
1059
|
-
message: `Spec error: encoding.${channel}.field "${ch.field}" does not exist on nodes. Available fields: ${[...nodeFields].join(', ')}`,
|
|
1060
|
-
path: `encoding.${channel}.field`,
|
|
1061
|
-
code: 'DATA_FIELD_MISSING',
|
|
1062
|
-
suggestion: `Use one of the node fields: ${[...nodeFields].join(', ')}`,
|
|
1063
|
-
});
|
|
1064
|
-
}
|
|
1065
|
-
}
|
|
1066
|
-
|
|
1067
|
-
const edgeChannels = ['edgeColor', 'edgeWidth'] as const;
|
|
1068
|
-
for (const channel of edgeChannels) {
|
|
1069
|
-
const ch = encoding[channel] as Record<string, unknown> | undefined;
|
|
1070
|
-
if (ch?.field && typeof ch.field === 'string' && firstEdge && !edgeFields.has(ch.field)) {
|
|
1071
|
-
errors.push({
|
|
1072
|
-
message: `Spec error: encoding.${channel}.field "${ch.field}" does not exist on edges. Available fields: ${[...edgeFields].join(', ')}`,
|
|
1073
|
-
path: `encoding.${channel}.field`,
|
|
1074
|
-
code: 'DATA_FIELD_MISSING',
|
|
1075
|
-
suggestion: `Use one of the edge fields: ${[...edgeFields].join(', ')}`,
|
|
1076
|
-
});
|
|
1077
|
-
}
|
|
1078
|
-
}
|
|
1079
|
-
}
|
|
1080
|
-
|
|
1081
|
-
// Validate layout type if specified
|
|
1082
|
-
if (spec.layout && typeof spec.layout === 'object') {
|
|
1083
|
-
const layout = spec.layout as Record<string, unknown>;
|
|
1084
|
-
if (layout.type && layout.type !== 'force') {
|
|
1085
|
-
errors.push({
|
|
1086
|
-
message: `Spec error: layout.type "${layout.type}" is not supported. Only "force" is currently supported`,
|
|
1087
|
-
path: 'layout.type',
|
|
1088
|
-
code: 'INVALID_VALUE',
|
|
1089
|
-
suggestion:
|
|
1090
|
-
'Use layout.type: "force" or omit the layout field to use the default force layout',
|
|
1091
|
-
});
|
|
1092
|
-
}
|
|
1093
|
-
}
|
|
1094
|
-
}
|
|
1095
|
-
|
|
1096
|
-
// ---------------------------------------------------------------------------
|
|
1097
|
-
// Sankey validation
|
|
1098
|
-
// ---------------------------------------------------------------------------
|
|
1099
|
-
|
|
1100
|
-
function validateSankeySpec(spec: Record<string, unknown>, errors: ValidationError[]): void {
|
|
1101
|
-
// Validate data
|
|
1102
|
-
if (!Array.isArray(spec.data)) {
|
|
1103
|
-
errors.push({
|
|
1104
|
-
message: 'Spec error: sankey spec requires a "data" array',
|
|
1105
|
-
path: 'data',
|
|
1106
|
-
code: 'INVALID_TYPE',
|
|
1107
|
-
suggestion:
|
|
1108
|
-
'Provide data as an array of objects, e.g. data: [{ source: "A", target: "B", value: 10 }]',
|
|
1109
|
-
});
|
|
1110
|
-
return;
|
|
1111
|
-
}
|
|
1112
|
-
|
|
1113
|
-
if (spec.data.length === 0) {
|
|
1114
|
-
errors.push({
|
|
1115
|
-
message: 'Spec error: "data" must be a non-empty array',
|
|
1116
|
-
path: 'data',
|
|
1117
|
-
code: 'EMPTY_DATA',
|
|
1118
|
-
suggestion: 'Add at least one data row, e.g. data: [{ source: "A", target: "B", value: 10 }]',
|
|
1119
|
-
});
|
|
1120
|
-
return;
|
|
1121
|
-
}
|
|
1122
|
-
|
|
1123
|
-
const firstRow = spec.data[0] as unknown;
|
|
1124
|
-
if (typeof firstRow !== 'object' || firstRow === null || Array.isArray(firstRow)) {
|
|
1125
|
-
errors.push({
|
|
1126
|
-
message: 'Spec error: each item in "data" must be a plain object',
|
|
1127
|
-
path: 'data[0]',
|
|
1128
|
-
code: 'INVALID_TYPE',
|
|
1129
|
-
suggestion:
|
|
1130
|
-
'Each data item should be an object, e.g. { source: "A", target: "B", value: 10 }',
|
|
1131
|
-
});
|
|
1132
|
-
return;
|
|
1133
|
-
}
|
|
1134
|
-
|
|
1135
|
-
// Validate encoding
|
|
1136
|
-
if (!spec.encoding || typeof spec.encoding !== 'object') {
|
|
1137
|
-
errors.push({
|
|
1138
|
-
message:
|
|
1139
|
-
'Spec error: sankey spec requires an "encoding" object with source, target, and value channels',
|
|
1140
|
-
path: 'encoding',
|
|
1141
|
-
code: 'MISSING_FIELD',
|
|
1142
|
-
suggestion:
|
|
1143
|
-
'Add an encoding object, e.g. encoding: { source: { field: "source", type: "nominal" }, target: { field: "target", type: "nominal" }, value: { field: "value", type: "quantitative" } }',
|
|
1144
|
-
});
|
|
1145
|
-
return;
|
|
1146
|
-
}
|
|
1147
|
-
|
|
1148
|
-
const encoding = spec.encoding as Record<string, unknown>;
|
|
1149
|
-
const dataColumns = new Set(Object.keys(firstRow as Record<string, unknown>));
|
|
1150
|
-
const availableColumns = [...dataColumns].join(', ');
|
|
1151
|
-
|
|
1152
|
-
// Required channels
|
|
1153
|
-
for (const channel of ['source', 'target', 'value'] as const) {
|
|
1154
|
-
const ch = encoding[channel] as Record<string, unknown> | undefined;
|
|
1155
|
-
if (!ch || typeof ch !== 'object') {
|
|
1156
|
-
errors.push({
|
|
1157
|
-
message: `Spec error: sankey encoding requires "${channel}" channel`,
|
|
1158
|
-
path: `encoding.${channel}`,
|
|
1159
|
-
code: 'MISSING_FIELD',
|
|
1160
|
-
suggestion: `Add encoding.${channel} with a field from your data (${availableColumns}). Example: ${channel}: { field: "${[...dataColumns][0] ?? 'myField'}", type: "${channel === 'value' ? 'quantitative' : 'nominal'}" }`,
|
|
1161
|
-
});
|
|
1162
|
-
continue;
|
|
1163
|
-
}
|
|
1164
|
-
|
|
1165
|
-
if (!ch.field || typeof ch.field !== 'string') {
|
|
1166
|
-
errors.push({
|
|
1167
|
-
message: `Spec error: encoding.${channel} must have a "field" string`,
|
|
1168
|
-
path: `encoding.${channel}.field`,
|
|
1169
|
-
code: 'MISSING_FIELD',
|
|
1170
|
-
suggestion: `Add a field name from your data columns: ${availableColumns}`,
|
|
1171
|
-
});
|
|
1172
|
-
continue;
|
|
1173
|
-
}
|
|
1174
|
-
|
|
1175
|
-
if (!dataColumns.has(ch.field as string)) {
|
|
1176
|
-
errors.push({
|
|
1177
|
-
message: `Spec error: encoding.${channel}.field "${ch.field}" does not exist in data. Available columns: ${availableColumns}`,
|
|
1178
|
-
path: `encoding.${channel}.field`,
|
|
1179
|
-
code: 'DATA_FIELD_MISSING',
|
|
1180
|
-
suggestion: `Use one of the available data columns: ${availableColumns}`,
|
|
1181
|
-
});
|
|
1182
|
-
}
|
|
1183
|
-
}
|
|
1184
|
-
|
|
1185
|
-
// Validate darkMode if provided
|
|
1186
|
-
if (spec.darkMode !== undefined && !VALID_DARK_MODES.has(spec.darkMode as string)) {
|
|
1187
|
-
errors.push({
|
|
1188
|
-
message: 'Spec error: darkMode must be "auto", "force", or "off"',
|
|
1189
|
-
path: 'darkMode',
|
|
1190
|
-
code: 'INVALID_VALUE',
|
|
1191
|
-
suggestion:
|
|
1192
|
-
'Use one of: "auto" (system preference), "force" (always dark), or "off" (always light)',
|
|
1193
|
-
});
|
|
1194
|
-
}
|
|
1195
|
-
}
|
|
1196
|
-
|
|
1197
|
-
// ---------------------------------------------------------------------------
|
|
1198
|
-
// TileMap validation
|
|
1199
|
-
// ---------------------------------------------------------------------------
|
|
1200
|
-
|
|
1201
|
-
function validateTileMapSpec(spec: Record<string, unknown>, errors: ValidationError[]): void {
|
|
1202
|
-
// Validate data (can be record or array)
|
|
1203
|
-
if (!spec.data || typeof spec.data !== 'object') {
|
|
1204
|
-
errors.push({
|
|
1205
|
-
message: 'Spec error: tilemap spec requires a "data" field (record or array)',
|
|
1206
|
-
path: 'data',
|
|
1207
|
-
code: 'INVALID_TYPE',
|
|
1208
|
-
suggestion:
|
|
1209
|
-
'Provide data as either a record mapping state codes to values (e.g. { "CA": 12000, "TX": 8500 }) or an array of objects with state and value fields',
|
|
1210
|
-
});
|
|
1211
|
-
return;
|
|
1212
|
-
}
|
|
1213
|
-
|
|
1214
|
-
// If data is an object (record), validate it has at least one entry
|
|
1215
|
-
if (!Array.isArray(spec.data) && Object.keys(spec.data as Record<string, unknown>).length === 0) {
|
|
1216
|
-
errors.push({
|
|
1217
|
-
message: 'Spec error: "data" must have at least one entry',
|
|
1218
|
-
path: 'data',
|
|
1219
|
-
code: 'EMPTY_DATA',
|
|
1220
|
-
suggestion: 'Add at least one state-value pair, e.g. { "CA": 12000 }',
|
|
1221
|
-
});
|
|
1222
|
-
return;
|
|
1223
|
-
}
|
|
1224
|
-
|
|
1225
|
-
// If data is an array, validate it's non-empty
|
|
1226
|
-
if (Array.isArray(spec.data)) {
|
|
1227
|
-
if (spec.data.length === 0) {
|
|
1228
|
-
errors.push({
|
|
1229
|
-
message: 'Spec error: "data" array must be non-empty',
|
|
1230
|
-
path: 'data',
|
|
1231
|
-
code: 'EMPTY_DATA',
|
|
1232
|
-
suggestion: 'Add at least one data row',
|
|
1233
|
-
});
|
|
1234
|
-
return;
|
|
1235
|
-
}
|
|
1236
|
-
|
|
1237
|
-
const firstRow = spec.data[0] as unknown;
|
|
1238
|
-
if (typeof firstRow !== 'object' || firstRow === null || Array.isArray(firstRow)) {
|
|
1239
|
-
errors.push({
|
|
1240
|
-
message: 'Spec error: each item in "data" must be a plain object',
|
|
1241
|
-
path: 'data[0]',
|
|
1242
|
-
code: 'INVALID_TYPE',
|
|
1243
|
-
suggestion: 'Each data item should be an object, e.g. { state: "CA", value: 12000 }',
|
|
1244
|
-
});
|
|
1245
|
-
return;
|
|
1246
|
-
}
|
|
1247
|
-
|
|
1248
|
-
// If data is array, encoding is required
|
|
1249
|
-
if (!spec.encoding || typeof spec.encoding !== 'object') {
|
|
1250
|
-
errors.push({
|
|
1251
|
-
message:
|
|
1252
|
-
'Spec error: tilemap spec with array data requires an "encoding" object with state and value channels',
|
|
1253
|
-
path: 'encoding',
|
|
1254
|
-
code: 'MISSING_FIELD',
|
|
1255
|
-
suggestion:
|
|
1256
|
-
'Add an encoding object, e.g. encoding: { state: { field: "state", type: "nominal" }, value: { field: "value", type: "quantitative" } }',
|
|
1257
|
-
});
|
|
1258
|
-
return;
|
|
1259
|
-
}
|
|
1260
|
-
|
|
1261
|
-
const encoding = spec.encoding as Record<string, unknown>;
|
|
1262
|
-
const dataColumns = new Set(Object.keys(firstRow as Record<string, unknown>));
|
|
1263
|
-
const availableColumns = [...dataColumns].join(', ');
|
|
1264
|
-
|
|
1265
|
-
// Required channels
|
|
1266
|
-
for (const channel of ['state', 'value'] as const) {
|
|
1267
|
-
const ch = encoding[channel] as Record<string, unknown> | undefined;
|
|
1268
|
-
if (!ch || typeof ch !== 'object') {
|
|
1269
|
-
errors.push({
|
|
1270
|
-
message: `Spec error: tilemap encoding requires "${channel}" channel`,
|
|
1271
|
-
path: `encoding.${channel}`,
|
|
1272
|
-
code: 'MISSING_FIELD',
|
|
1273
|
-
suggestion: `Add encoding.${channel} with a field from your data (${availableColumns}). Example: ${channel}: { field: "${[...dataColumns][0] ?? 'myField'}", type: "${channel === 'value' ? 'quantitative' : 'nominal'}" }`,
|
|
1274
|
-
});
|
|
1275
|
-
continue;
|
|
1276
|
-
}
|
|
1277
|
-
|
|
1278
|
-
if (!ch.field || typeof ch.field !== 'string') {
|
|
1279
|
-
errors.push({
|
|
1280
|
-
message: `Spec error: encoding.${channel} must have a "field" string`,
|
|
1281
|
-
path: `encoding.${channel}.field`,
|
|
1282
|
-
code: 'MISSING_FIELD',
|
|
1283
|
-
suggestion: `Add a field name from your data columns: ${availableColumns}`,
|
|
1284
|
-
});
|
|
1285
|
-
continue;
|
|
1286
|
-
}
|
|
1287
|
-
|
|
1288
|
-
if (!dataColumns.has(ch.field as string)) {
|
|
1289
|
-
errors.push({
|
|
1290
|
-
message: `Spec error: encoding.${channel}.field "${ch.field}" does not exist in data. Available columns: ${availableColumns}`,
|
|
1291
|
-
path: `encoding.${channel}.field`,
|
|
1292
|
-
code: 'DATA_FIELD_MISSING',
|
|
1293
|
-
suggestion: `Use one of the available data columns: ${availableColumns}`,
|
|
1294
|
-
});
|
|
1295
|
-
}
|
|
1296
|
-
}
|
|
1297
|
-
}
|
|
1298
|
-
|
|
1299
|
-
// Validate darkMode if provided
|
|
1300
|
-
if (spec.darkMode !== undefined && !VALID_DARK_MODES.has(spec.darkMode as string)) {
|
|
1301
|
-
errors.push({
|
|
1302
|
-
message: 'Spec error: darkMode must be "auto", "force", or "off"',
|
|
1303
|
-
path: 'darkMode',
|
|
1304
|
-
code: 'INVALID_VALUE',
|
|
1305
|
-
suggestion:
|
|
1306
|
-
'Use one of: "auto" (system preference), "force" (always dark), or "off" (always light)',
|
|
1307
|
-
});
|
|
1308
|
-
}
|
|
1309
|
-
}
|
|
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
|
-
|
|
1442
|
-
// ---------------------------------------------------------------------------
|
|
1443
|
-
// BarList validation
|
|
1444
|
-
// ---------------------------------------------------------------------------
|
|
1445
|
-
|
|
1446
|
-
function validateBarListSpec(spec: Record<string, unknown>, errors: ValidationError[]): void {
|
|
1447
|
-
if (!Array.isArray(spec.data)) {
|
|
1448
|
-
errors.push({
|
|
1449
|
-
message: 'Spec error: barlist spec requires a "data" array',
|
|
1450
|
-
path: 'data',
|
|
1451
|
-
code: 'INVALID_TYPE',
|
|
1452
|
-
suggestion:
|
|
1453
|
-
'Provide data as an array of objects, e.g. data: [{ label: "Category A", value: 42 }]',
|
|
1454
|
-
});
|
|
1455
|
-
return;
|
|
1456
|
-
}
|
|
1457
|
-
|
|
1458
|
-
if (spec.data.length === 0) {
|
|
1459
|
-
errors.push({
|
|
1460
|
-
message: 'Spec error: "data" array must be non-empty',
|
|
1461
|
-
path: 'data',
|
|
1462
|
-
code: 'EMPTY_DATA',
|
|
1463
|
-
suggestion: 'Add at least one data row',
|
|
1464
|
-
});
|
|
1465
|
-
return;
|
|
1466
|
-
}
|
|
1467
|
-
|
|
1468
|
-
const firstRow = spec.data[0] as unknown;
|
|
1469
|
-
if (typeof firstRow !== 'object' || firstRow === null || Array.isArray(firstRow)) {
|
|
1470
|
-
errors.push({
|
|
1471
|
-
message: 'Spec error: each item in "data" must be a plain object',
|
|
1472
|
-
path: 'data[0]',
|
|
1473
|
-
code: 'INVALID_TYPE',
|
|
1474
|
-
suggestion: 'Each data item should be an object, e.g. { label: "Category A", value: 42 }',
|
|
1475
|
-
});
|
|
1476
|
-
return;
|
|
1477
|
-
}
|
|
1478
|
-
|
|
1479
|
-
if (!spec.encoding || typeof spec.encoding !== 'object') {
|
|
1480
|
-
errors.push({
|
|
1481
|
-
message:
|
|
1482
|
-
'Spec error: barlist spec requires an "encoding" object with label and value channels',
|
|
1483
|
-
path: 'encoding',
|
|
1484
|
-
code: 'MISSING_FIELD',
|
|
1485
|
-
suggestion:
|
|
1486
|
-
'Add an encoding object, e.g. encoding: { label: { field: "name", type: "nominal" }, value: { field: "count", type: "quantitative" } }',
|
|
1487
|
-
});
|
|
1488
|
-
return;
|
|
1489
|
-
}
|
|
1490
|
-
|
|
1491
|
-
const encoding = spec.encoding as Record<string, unknown>;
|
|
1492
|
-
const dataColumns = new Set(Object.keys(firstRow as Record<string, unknown>));
|
|
1493
|
-
const availableColumns = [...dataColumns].join(', ');
|
|
1494
|
-
|
|
1495
|
-
for (const channel of ['label', 'value'] as const) {
|
|
1496
|
-
const ch = encoding[channel] as Record<string, unknown> | undefined;
|
|
1497
|
-
if (!ch || typeof ch !== 'object') {
|
|
1498
|
-
errors.push({
|
|
1499
|
-
message: `Spec error: barlist encoding requires "${channel}" channel`,
|
|
1500
|
-
path: `encoding.${channel}`,
|
|
1501
|
-
code: 'MISSING_FIELD',
|
|
1502
|
-
suggestion: `Add encoding.${channel} with a field from your data (${availableColumns}). Example: ${channel}: { field: "${[...dataColumns][0] ?? 'myField'}", type: "${channel === 'value' ? 'quantitative' : 'nominal'}" }`,
|
|
1503
|
-
});
|
|
1504
|
-
continue;
|
|
1505
|
-
}
|
|
1506
|
-
|
|
1507
|
-
if (!ch.field || typeof ch.field !== 'string') {
|
|
1508
|
-
errors.push({
|
|
1509
|
-
message: `Spec error: encoding.${channel} must have a "field" string`,
|
|
1510
|
-
path: `encoding.${channel}.field`,
|
|
1511
|
-
code: 'MISSING_FIELD',
|
|
1512
|
-
suggestion: `Add a field name from your data columns: ${availableColumns}`,
|
|
1513
|
-
});
|
|
1514
|
-
continue;
|
|
1515
|
-
}
|
|
1516
|
-
|
|
1517
|
-
if (!dataColumns.has(ch.field as string)) {
|
|
1518
|
-
errors.push({
|
|
1519
|
-
message: `Spec error: encoding.${channel}.field "${ch.field}" does not exist in data. Available columns: ${availableColumns}`,
|
|
1520
|
-
path: `encoding.${channel}.field`,
|
|
1521
|
-
code: 'DATA_FIELD_MISSING',
|
|
1522
|
-
suggestion: `Use one of the available data columns: ${availableColumns}`,
|
|
1523
|
-
});
|
|
1524
|
-
}
|
|
1525
|
-
}
|
|
1526
|
-
|
|
1527
|
-
// Validate optional encoding channels
|
|
1528
|
-
for (const channel of ['subtitle', 'color', 'tooltip'] as const) {
|
|
1529
|
-
const ch = encoding[channel] as Record<string, unknown> | undefined;
|
|
1530
|
-
if (!ch) continue;
|
|
1531
|
-
const field = ch.field;
|
|
1532
|
-
if (field && typeof field === 'string' && !dataColumns.has(field)) {
|
|
1533
|
-
errors.push({
|
|
1534
|
-
message: `Spec error: encoding.${channel}.field "${field}" does not exist in data. Available columns: ${availableColumns}`,
|
|
1535
|
-
path: `encoding.${channel}.field`,
|
|
1536
|
-
code: 'DATA_FIELD_MISSING',
|
|
1537
|
-
suggestion: `Use one of the available data columns: ${availableColumns}`,
|
|
1538
|
-
});
|
|
1539
|
-
}
|
|
1540
|
-
}
|
|
1541
|
-
|
|
1542
|
-
if (spec.darkMode !== undefined && !VALID_DARK_MODES.has(spec.darkMode as string)) {
|
|
1543
|
-
errors.push({
|
|
1544
|
-
message: 'Spec error: darkMode must be "auto", "force", or "off"',
|
|
1545
|
-
path: 'darkMode',
|
|
1546
|
-
code: 'INVALID_VALUE',
|
|
1547
|
-
suggestion:
|
|
1548
|
-
'Use one of: "auto" (system preference), "force" (always dark), or "off" (always light)',
|
|
1549
|
-
});
|
|
1550
|
-
}
|
|
1551
|
-
}
|
|
1552
|
-
|
|
1553
|
-
// ---------------------------------------------------------------------------
|
|
1554
|
-
// Layer validation
|
|
1555
|
-
// ---------------------------------------------------------------------------
|
|
1556
|
-
|
|
1557
|
-
function validateLayerSpec(spec: Record<string, unknown>, errors: ValidationError[]): void {
|
|
1558
|
-
const layer = spec.layer as unknown[];
|
|
1559
|
-
|
|
1560
|
-
if (layer.length === 0) {
|
|
1561
|
-
errors.push({
|
|
1562
|
-
message: 'Spec error: "layer" must be a non-empty array',
|
|
1563
|
-
path: 'layer',
|
|
1564
|
-
code: 'EMPTY_DATA',
|
|
1565
|
-
suggestion: 'Add at least one layer with a mark and encoding',
|
|
1566
|
-
});
|
|
1567
|
-
return;
|
|
1568
|
-
}
|
|
1569
|
-
|
|
1570
|
-
for (let i = 0; i < layer.length; i++) {
|
|
1571
|
-
const child = layer[i];
|
|
1572
|
-
if (!child || typeof child !== 'object' || Array.isArray(child)) {
|
|
1573
|
-
errors.push({
|
|
1574
|
-
message: `Spec error: layer[${i}] must be an object`,
|
|
1575
|
-
path: `layer[${i}]`,
|
|
1576
|
-
code: 'INVALID_TYPE',
|
|
1577
|
-
suggestion:
|
|
1578
|
-
'Each layer must be a chart spec (with mark) or a nested layer spec (with layer)',
|
|
1579
|
-
});
|
|
1580
|
-
continue;
|
|
1581
|
-
}
|
|
1582
|
-
|
|
1583
|
-
const childObj = child as Record<string, unknown>;
|
|
1584
|
-
const isNestedLayer = 'layer' in childObj && Array.isArray(childObj.layer);
|
|
1585
|
-
const isChildChart = 'mark' in childObj;
|
|
1586
|
-
|
|
1587
|
-
if (!isNestedLayer && !isChildChart) {
|
|
1588
|
-
errors.push({
|
|
1589
|
-
message: `Spec error: layer[${i}] must have a "mark" field or a "layer" array`,
|
|
1590
|
-
path: `layer[${i}]`,
|
|
1591
|
-
code: 'MISSING_FIELD',
|
|
1592
|
-
suggestion:
|
|
1593
|
-
'Each layer must be a chart spec (with mark + encoding) or a nested layer spec (with layer array)',
|
|
1594
|
-
});
|
|
1595
|
-
continue;
|
|
1596
|
-
}
|
|
1597
|
-
|
|
1598
|
-
if (isNestedLayer) {
|
|
1599
|
-
validateLayerSpec(childObj, errors);
|
|
1600
|
-
} else if (isChildChart) {
|
|
1601
|
-
// Validate mark type
|
|
1602
|
-
const mark = childObj.mark;
|
|
1603
|
-
let markValue: string | undefined;
|
|
1604
|
-
if (typeof mark === 'string') {
|
|
1605
|
-
markValue = mark;
|
|
1606
|
-
} else if (mark && typeof mark === 'object' && !Array.isArray(mark)) {
|
|
1607
|
-
markValue = (mark as Record<string, unknown>).type as string | undefined;
|
|
1608
|
-
}
|
|
1609
|
-
|
|
1610
|
-
if (!markValue || !MARK_TYPES.has(markValue)) {
|
|
1611
|
-
errors.push({
|
|
1612
|
-
message: `Spec error: layer[${i}].mark "${markValue ?? String(mark)}" is not a valid mark type`,
|
|
1613
|
-
path: `layer[${i}].mark`,
|
|
1614
|
-
code: 'INVALID_VALUE',
|
|
1615
|
-
suggestion: `Change mark to one of: ${[...MARK_TYPES].join(', ')}`,
|
|
1616
|
-
});
|
|
1617
|
-
continue;
|
|
1618
|
-
}
|
|
1619
|
-
|
|
1620
|
-
// Child layers can inherit data and encoding from parent, so only validate
|
|
1621
|
-
// if the child has its own data (or the parent provides shared data).
|
|
1622
|
-
const hasOwnData = Array.isArray(childObj.data) && (childObj.data as unknown[]).length > 0;
|
|
1623
|
-
const parentHasData = Array.isArray(spec.data) && (spec.data as unknown[]).length > 0;
|
|
1624
|
-
|
|
1625
|
-
if (hasOwnData || parentHasData) {
|
|
1626
|
-
// Build a merged spec for validation purposes
|
|
1627
|
-
const mergedForValidation = { ...childObj };
|
|
1628
|
-
if (!hasOwnData && parentHasData) {
|
|
1629
|
-
mergedForValidation.data = spec.data;
|
|
1630
|
-
}
|
|
1631
|
-
// Merge encoding: parent fields are inherited unless child overrides
|
|
1632
|
-
if (spec.encoding && typeof spec.encoding === 'object') {
|
|
1633
|
-
mergedForValidation.encoding = {
|
|
1634
|
-
...(spec.encoding as Record<string, unknown>),
|
|
1635
|
-
...((childObj.encoding as Record<string, unknown>) ?? {}),
|
|
1636
|
-
};
|
|
1637
|
-
}
|
|
1638
|
-
if (mergedForValidation.data && mergedForValidation.encoding) {
|
|
1639
|
-
validateChartSpec(mergedForValidation, errors);
|
|
1640
|
-
}
|
|
1641
|
-
}
|
|
1642
|
-
}
|
|
1643
|
-
}
|
|
1644
|
-
}
|
|
1645
|
-
|
|
1646
|
-
// ---------------------------------------------------------------------------
|
|
1647
|
-
// Public API
|
|
1648
|
-
// ---------------------------------------------------------------------------
|
|
1649
|
-
|
|
1650
|
-
/**
|
|
1651
|
-
* Validate a spec at runtime.
|
|
1652
|
-
*
|
|
1653
|
-
* Checks structure, required fields, encoding rules, data shape, and
|
|
1654
|
-
* field type compatibility. Returns structured errors with machine-readable
|
|
1655
|
-
* codes and actionable suggestions for each problem found.
|
|
1656
|
-
*/
|
|
1657
|
-
export function validateSpec(spec: unknown): ValidationResult {
|
|
1658
|
-
const errors: ValidationError[] = [];
|
|
1659
|
-
|
|
1660
|
-
// Basic shape check
|
|
1661
|
-
if (!spec || typeof spec !== 'object' || Array.isArray(spec)) {
|
|
1662
|
-
return {
|
|
1663
|
-
valid: false,
|
|
1664
|
-
errors: [
|
|
1665
|
-
{
|
|
1666
|
-
message: 'Spec error: spec must be a non-null object',
|
|
1667
|
-
code: 'INVALID_TYPE',
|
|
1668
|
-
suggestion:
|
|
1669
|
-
'Pass a spec object with at least a "mark" field for charts, e.g. { mark: "line", data: [...], encoding: {...} }',
|
|
1670
|
-
},
|
|
1671
|
-
],
|
|
1672
|
-
normalized: null,
|
|
1673
|
-
};
|
|
1674
|
-
}
|
|
1675
|
-
|
|
1676
|
-
const obj = spec as Record<string, unknown>;
|
|
1677
|
-
|
|
1678
|
-
// Determine spec type via structural discrimination:
|
|
1679
|
-
// - Layer specs have a 'layer' array
|
|
1680
|
-
// - Chart specs have a 'mark' field (string or object with type property)
|
|
1681
|
-
// - Table specs have type: 'table'
|
|
1682
|
-
// - Graph specs have type: 'graph'
|
|
1683
|
-
// - Sankey specs have type: 'sankey'
|
|
1684
|
-
// - TileMap specs have type: 'tilemap'
|
|
1685
|
-
const hasLayer = 'layer' in obj && Array.isArray(obj.layer);
|
|
1686
|
-
const hasMark = 'mark' in obj;
|
|
1687
|
-
const isTable = obj.type === 'table';
|
|
1688
|
-
const isGraph = obj.type === 'graph';
|
|
1689
|
-
const isSankey = obj.type === 'sankey';
|
|
1690
|
-
const isTileMap = obj.type === 'tilemap';
|
|
1691
|
-
const isMap = obj.type === 'map';
|
|
1692
|
-
const isBarList = obj.type === 'barlist';
|
|
1693
|
-
const isLayer =
|
|
1694
|
-
hasLayer && !isTable && !isGraph && !isSankey && !isTileMap && !isMap && !isBarList;
|
|
1695
|
-
const isChart =
|
|
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
|
-
}
|
|
1740
|
-
return {
|
|
1741
|
-
valid: false,
|
|
1742
|
-
errors: [
|
|
1743
|
-
{
|
|
1744
|
-
message:
|
|
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',
|
|
1746
|
-
path: 'mark',
|
|
1747
|
-
code: 'MISSING_FIELD',
|
|
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(', ')}`,
|
|
1749
|
-
},
|
|
1750
|
-
],
|
|
1751
|
-
normalized: null,
|
|
1752
|
-
};
|
|
1753
|
-
}
|
|
1754
|
-
|
|
1755
|
-
// For layer specs, validate each child layer recursively
|
|
1756
|
-
if (isLayer) {
|
|
1757
|
-
validateLayerSpec(obj, errors);
|
|
1758
|
-
}
|
|
1759
|
-
|
|
1760
|
-
// For chart specs, validate the mark field
|
|
1761
|
-
if (isChart) {
|
|
1762
|
-
const mark = obj.mark;
|
|
1763
|
-
let markValue: string | undefined;
|
|
1764
|
-
|
|
1765
|
-
if (typeof mark === 'string') {
|
|
1766
|
-
markValue = mark;
|
|
1767
|
-
} else if (mark && typeof mark === 'object' && !Array.isArray(mark)) {
|
|
1768
|
-
markValue = (mark as Record<string, unknown>).type as string | undefined;
|
|
1769
|
-
}
|
|
1770
|
-
|
|
1771
|
-
if (!markValue || !MARK_TYPES.has(markValue)) {
|
|
1772
|
-
return {
|
|
1773
|
-
valid: false,
|
|
1774
|
-
errors: [
|
|
1775
|
-
{
|
|
1776
|
-
message: `Spec error: "${markValue ?? String(mark)}" is not a valid mark type. Valid mark types: ${[...MARK_TYPES].join(', ')}`,
|
|
1777
|
-
path: 'mark',
|
|
1778
|
-
code: 'INVALID_VALUE',
|
|
1779
|
-
suggestion: `Change mark to one of: ${[...MARK_TYPES].join(', ')}`,
|
|
1780
|
-
},
|
|
1781
|
-
],
|
|
1782
|
-
normalized: null,
|
|
1783
|
-
};
|
|
1784
|
-
}
|
|
1785
|
-
|
|
1786
|
-
validateChartSpec(obj, errors);
|
|
1787
|
-
} else if (isTable) {
|
|
1788
|
-
validateTableSpec(obj, errors);
|
|
1789
|
-
} else if (isGraph) {
|
|
1790
|
-
validateGraphSpec(obj, errors);
|
|
1791
|
-
} else if (isSankey) {
|
|
1792
|
-
validateSankeySpec(obj, errors);
|
|
1793
|
-
} else if (isTileMap) {
|
|
1794
|
-
validateTileMapSpec(obj, errors);
|
|
1795
|
-
} else if (isMap) {
|
|
1796
|
-
validateMapSpec(obj, errors);
|
|
1797
|
-
} else if (isBarList) {
|
|
1798
|
-
validateBarListSpec(obj, errors);
|
|
1799
|
-
}
|
|
1800
|
-
|
|
1801
|
-
if (errors.length > 0) {
|
|
1802
|
-
return { valid: false, errors, normalized: null };
|
|
1803
|
-
}
|
|
1804
|
-
|
|
1805
|
-
return {
|
|
1806
|
-
valid: true,
|
|
1807
|
-
errors: [],
|
|
1808
|
-
normalized: spec as VizSpec,
|
|
1809
|
-
};
|
|
1810
|
-
}
|