@opendata-ai/openchart-engine 8.0.0-rc.1 → 8.0.0-rc.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +2 -8
- package/dist/index.js +793 -91
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/__snapshots__/compile-snapshot.test.ts.snap +62 -0
- package/src/__tests__/annotation-behavior.test.ts +131 -0
- package/src/__tests__/facet.test.ts +144 -0
- package/src/annotations/geometry.ts +7 -1
- package/src/annotations/resolve-text.ts +6 -0
- package/src/compile/layer.ts +4 -0
- package/src/compile/size-scale.ts +2 -0
- package/src/compile/spec-sugar.ts +10 -1
- package/src/compile.ts +106 -8
- package/src/compiler/__tests__/keys.test.ts +3 -9
- package/src/compiler/__tests__/validate-did-you-mean.test.ts +84 -0
- package/src/compiler/__tests__/validate-near-miss.test.ts +162 -0
- package/src/compiler/__tests__/validate.test.ts +102 -0
- package/src/compiler/keys.ts +5 -4
- package/src/compiler/normalize.ts +1 -0
- package/src/compiler/validate.ts +326 -43
- package/src/geo/__tests__/compile-map.test.ts +507 -0
- package/src/geo/compile-map.ts +555 -35
- package/src/geo/projections.ts +21 -1
- package/src/geo/types.ts +2 -0
- package/src/layout/axes.ts +5 -4
- package/src/legend/compute.ts +4 -3
package/src/compiler/validate.ts
CHANGED
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
MARK_TYPES,
|
|
17
17
|
type MarkType,
|
|
18
18
|
resolveSchemeName,
|
|
19
|
+
SEQUENTIAL_PALETTES,
|
|
19
20
|
SUPPORTED_SCHEME_NAMES,
|
|
20
21
|
type VizSpec,
|
|
21
22
|
} from '@opendata-ai/openchart-core';
|
|
@@ -107,6 +108,94 @@ function didYouMean(field: string, columns: string[]): string {
|
|
|
107
108
|
return match ? ` Did you mean "${match}"?` : '';
|
|
108
109
|
}
|
|
109
110
|
|
|
111
|
+
/**
|
|
112
|
+
* Push an INVALID_VALUE error when a channel carries an unrecognized
|
|
113
|
+
* scale.scheme name. Chart specs are the only family that consumes schemes
|
|
114
|
+
* this way: known names (including VL aliases) resolve via the core palette
|
|
115
|
+
* registry and are expanded to scale.range by the pre-validation sugar pass,
|
|
116
|
+
* so `resolveSchemeName` failing means the name is unknown.
|
|
117
|
+
*/
|
|
118
|
+
function checkSchemeName(
|
|
119
|
+
channelObj: Record<string, unknown>,
|
|
120
|
+
channelPath: string,
|
|
121
|
+
errors: ValidationError[],
|
|
122
|
+
): void {
|
|
123
|
+
const scale = channelObj.scale as Record<string, unknown> | undefined;
|
|
124
|
+
if (scale && typeof scale.scheme === 'string' && !resolveSchemeName(scale.scheme)) {
|
|
125
|
+
errors.push({
|
|
126
|
+
message: `Spec error: ${channelPath}.scale.scheme "${scale.scheme}" is not a supported scheme name`,
|
|
127
|
+
path: `${channelPath}.scale.scheme`,
|
|
128
|
+
code: 'INVALID_VALUE',
|
|
129
|
+
suggestion: `Use one of the supported scheme names: ${[...SUPPORTED_SCHEME_NAMES].join(', ')}. Or provide explicit colors via scale.range.`,
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Push an INVALID_VALUE error when any channel of an encoding block carries a
|
|
136
|
+
* scale.scheme on a spec family whose compile path never reads it. Even a
|
|
137
|
+
* known scheme name is dead config there — silently accepting it would leave
|
|
138
|
+
* the author believing they chose the colors.
|
|
139
|
+
*/
|
|
140
|
+
function checkSchemeUnused(
|
|
141
|
+
encoding: Record<string, unknown>,
|
|
142
|
+
errors: ValidationError[],
|
|
143
|
+
familyNote: string,
|
|
144
|
+
suggestion: string,
|
|
145
|
+
): void {
|
|
146
|
+
for (const [channel, ch] of Object.entries(encoding)) {
|
|
147
|
+
if (!ch || typeof ch !== 'object' || Array.isArray(ch)) continue;
|
|
148
|
+
const scale = (ch as Record<string, unknown>).scale as Record<string, unknown> | undefined;
|
|
149
|
+
if (scale && typeof scale.scheme === 'string') {
|
|
150
|
+
errors.push({
|
|
151
|
+
message: `Spec error: encoding.${channel}.scale.scheme has no effect — ${familyNote}`,
|
|
152
|
+
path: `encoding.${channel}.scale.scheme`,
|
|
153
|
+
code: 'INVALID_VALUE',
|
|
154
|
+
suggestion,
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Palette names the map compile path actually supports (sequential only). */
|
|
161
|
+
const MAP_PALETTE_NAMES = Object.keys(SEQUENTIAL_PALETTES).join(', ');
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Map color channels resolve scale.scheme against SEQUENTIAL_PALETTES with a
|
|
165
|
+
* silent fallback to blue — a typo'd scheme would render the default palette
|
|
166
|
+
* with no signal. Reject names outside the map's supported set here instead.
|
|
167
|
+
* Categorical channels never read schemes at all (colors come from
|
|
168
|
+
* scale.domain/scale.range), so any scheme there is dead config.
|
|
169
|
+
*/
|
|
170
|
+
function checkMapSchemeName(
|
|
171
|
+
channelObj: Record<string, unknown>,
|
|
172
|
+
channelPath: string,
|
|
173
|
+
errors: ValidationError[],
|
|
174
|
+
): void {
|
|
175
|
+
const scale = channelObj.scale as Record<string, unknown> | undefined;
|
|
176
|
+
if (!scale || typeof scale.scheme !== 'string') return;
|
|
177
|
+
|
|
178
|
+
if (channelObj.type === 'nominal' || channelObj.type === 'ordinal') {
|
|
179
|
+
errors.push({
|
|
180
|
+
message: `Spec error: ${channelPath}.scale.scheme has no effect — categorical map colors come from scale.range and scale.domain`,
|
|
181
|
+
path: `${channelPath}.scale.scheme`,
|
|
182
|
+
code: 'INVALID_VALUE',
|
|
183
|
+
suggestion:
|
|
184
|
+
'Remove scale.scheme. To customize categorical map colors, provide explicit colors via scale.range (with scale.domain for category order).',
|
|
185
|
+
});
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
if (!(scale.scheme in SEQUENTIAL_PALETTES)) {
|
|
190
|
+
errors.push({
|
|
191
|
+
message: `Spec error: ${channelPath}.scale.scheme "${scale.scheme}" is not a supported map palette`,
|
|
192
|
+
path: `${channelPath}.scale.scheme`,
|
|
193
|
+
code: 'INVALID_VALUE',
|
|
194
|
+
suggestion: `Maps support the sequential palettes: ${MAP_PALETTE_NAMES}. The default is "blue".`,
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
110
199
|
// ---------------------------------------------------------------------------
|
|
111
200
|
// Range mark validation
|
|
112
201
|
// ---------------------------------------------------------------------------
|
|
@@ -613,19 +702,7 @@ function validateChartSpec(spec: Record<string, unknown>, errors: ValidationErro
|
|
|
613
702
|
// Check scale.scheme names. Known names (including VL aliases) are
|
|
614
703
|
// resolved to palette ranges by the pre-validation sugar expansion, so a
|
|
615
704
|
// scheme surviving to this point on the compile path is an unknown name.
|
|
616
|
-
|
|
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
|
-
}
|
|
705
|
+
checkSchemeName(channelObj, `encoding.${channel}`, errors);
|
|
629
706
|
|
|
630
707
|
// Check field type is allowed for this channel
|
|
631
708
|
if (channelRule && channelObj.type && channelRule.allowedTypes.length > 0) {
|
|
@@ -763,6 +840,58 @@ function validateChartSpec(spec: Record<string, unknown>, errors: ValidationErro
|
|
|
763
840
|
}
|
|
764
841
|
}
|
|
765
842
|
|
|
843
|
+
// Validate row/column facet channels
|
|
844
|
+
for (const channel of ['row', 'column'] as const) {
|
|
845
|
+
const ch = (encoding as Record<string, unknown>)[channel];
|
|
846
|
+
if (ch && typeof ch === 'object') {
|
|
847
|
+
const facetCh = ch as Record<string, unknown>;
|
|
848
|
+
if (!facetCh.field || typeof facetCh.field !== 'string') {
|
|
849
|
+
errors.push({
|
|
850
|
+
message: `Spec error: encoding.${channel}.field is required and must be a non-empty string`,
|
|
851
|
+
path: `encoding.${channel}.field`,
|
|
852
|
+
code: 'MISSING_FIELD',
|
|
853
|
+
suggestion: `Add a field property to encoding.${channel} (e.g. { field: "category", type: "nominal" })`,
|
|
854
|
+
});
|
|
855
|
+
} else if (
|
|
856
|
+
!dataColumns.has(facetCh.field as string) &&
|
|
857
|
+
!transformFields.has(facetCh.field as string)
|
|
858
|
+
) {
|
|
859
|
+
errors.push({
|
|
860
|
+
message: `Spec error: encoding.${channel}.field "${facetCh.field}" does not exist in data. Available columns: ${availableColumns}`,
|
|
861
|
+
path: `encoding.${channel}.field`,
|
|
862
|
+
code: 'DATA_FIELD_MISSING',
|
|
863
|
+
suggestion: `Use one of the available data columns: ${availableColumns}.${didYouMean(facetCh.field as string, columnList)}`,
|
|
864
|
+
});
|
|
865
|
+
}
|
|
866
|
+
if (!facetCh.type || (facetCh.type !== 'nominal' && facetCh.type !== 'ordinal')) {
|
|
867
|
+
errors.push({
|
|
868
|
+
message: `Spec error: encoding.${channel}.type must be "nominal" or "ordinal"${facetCh.type ? `, got "${facetCh.type}"` : ''}`,
|
|
869
|
+
path: `encoding.${channel}.type`,
|
|
870
|
+
code: facetCh.type ? 'INVALID_VALUE' : 'MISSING_FIELD',
|
|
871
|
+
suggestion: `Set encoding.${channel}.type to "nominal" or "ordinal"`,
|
|
872
|
+
});
|
|
873
|
+
}
|
|
874
|
+
if (encoding.facet && typeof encoding.facet === 'object') {
|
|
875
|
+
errors.push({
|
|
876
|
+
message: `Spec error: encoding.${channel} and encoding.facet cannot be used together`,
|
|
877
|
+
path: `encoding.${channel}`,
|
|
878
|
+
code: 'INVALID_VALUE',
|
|
879
|
+
suggestion: `Use either encoding.${channel} or encoding.facet, not both`,
|
|
880
|
+
});
|
|
881
|
+
}
|
|
882
|
+
const otherChannel = channel === 'row' ? 'column' : 'row';
|
|
883
|
+
if ((encoding as Record<string, unknown>)[otherChannel]) {
|
|
884
|
+
errors.push({
|
|
885
|
+
message:
|
|
886
|
+
'Spec error: encoding.row and encoding.column cannot be used together (cross-product faceting is not yet supported)',
|
|
887
|
+
path: `encoding.${channel}`,
|
|
888
|
+
code: 'INVALID_VALUE',
|
|
889
|
+
suggestion: 'Use encoding.facet with columns for grid layout, or use only row or column',
|
|
890
|
+
});
|
|
891
|
+
}
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
|
|
766
895
|
// Validate darkMode if provided
|
|
767
896
|
if (spec.darkMode !== undefined && !VALID_DARK_MODES.has(spec.darkMode as string)) {
|
|
768
897
|
errors.push({
|
|
@@ -1022,7 +1151,7 @@ function validateGraphSpec(spec: Record<string, unknown>, errors: ValidationErro
|
|
|
1022
1151
|
message: `Spec error: edges[${i}].source "${edge.source}" does not reference an existing node id`,
|
|
1023
1152
|
path: `edges[${i}].source`,
|
|
1024
1153
|
code: 'DATA_FIELD_MISSING',
|
|
1025
|
-
suggestion: `Use one of the existing node ids: ${[...nodeIds].slice(0, 5).join(', ')}${nodeIds.size > 5 ? '...' : ''}`,
|
|
1154
|
+
suggestion: `Use one of the existing node ids: ${[...nodeIds].slice(0, 5).join(', ')}${nodeIds.size > 5 ? '...' : ''}.${didYouMean(edge.source, [...nodeIds])}`,
|
|
1026
1155
|
});
|
|
1027
1156
|
}
|
|
1028
1157
|
|
|
@@ -1038,7 +1167,7 @@ function validateGraphSpec(spec: Record<string, unknown>, errors: ValidationErro
|
|
|
1038
1167
|
message: `Spec error: edges[${i}].target "${edge.target}" does not reference an existing node id`,
|
|
1039
1168
|
path: `edges[${i}].target`,
|
|
1040
1169
|
code: 'DATA_FIELD_MISSING',
|
|
1041
|
-
suggestion: `Use one of the existing node ids: ${[...nodeIds].slice(0, 5).join(', ')}${nodeIds.size > 5 ? '...' : ''}`,
|
|
1170
|
+
suggestion: `Use one of the existing node ids: ${[...nodeIds].slice(0, 5).join(', ')}${nodeIds.size > 5 ? '...' : ''}.${didYouMean(edge.target, [...nodeIds])}`,
|
|
1042
1171
|
});
|
|
1043
1172
|
}
|
|
1044
1173
|
}
|
|
@@ -1059,7 +1188,7 @@ function validateGraphSpec(spec: Record<string, unknown>, errors: ValidationErro
|
|
|
1059
1188
|
message: `Spec error: encoding.${channel}.field "${ch.field}" does not exist on nodes. Available fields: ${[...nodeFields].join(', ')}`,
|
|
1060
1189
|
path: `encoding.${channel}.field`,
|
|
1061
1190
|
code: 'DATA_FIELD_MISSING',
|
|
1062
|
-
suggestion: `Use one of the node fields: ${[...nodeFields].join(', ')}`,
|
|
1191
|
+
suggestion: `Use one of the node fields: ${[...nodeFields].join(', ')}.${didYouMean(ch.field, [...nodeFields])}`,
|
|
1063
1192
|
});
|
|
1064
1193
|
}
|
|
1065
1194
|
}
|
|
@@ -1072,10 +1201,18 @@ function validateGraphSpec(spec: Record<string, unknown>, errors: ValidationErro
|
|
|
1072
1201
|
message: `Spec error: encoding.${channel}.field "${ch.field}" does not exist on edges. Available fields: ${[...edgeFields].join(', ')}`,
|
|
1073
1202
|
path: `encoding.${channel}.field`,
|
|
1074
1203
|
code: 'DATA_FIELD_MISSING',
|
|
1075
|
-
suggestion: `Use one of the edge fields: ${[...edgeFields].join(', ')}`,
|
|
1204
|
+
suggestion: `Use one of the edge fields: ${[...edgeFields].join(', ')}.${didYouMean(ch.field, [...edgeFields])}`,
|
|
1076
1205
|
});
|
|
1077
1206
|
}
|
|
1078
1207
|
}
|
|
1208
|
+
|
|
1209
|
+
// Graph never reads scale.scheme: colors come from scale.range/scale.domain
|
|
1210
|
+
checkSchemeUnused(
|
|
1211
|
+
encoding,
|
|
1212
|
+
errors,
|
|
1213
|
+
'graph colors come from scale.range and scale.domain',
|
|
1214
|
+
'Remove scale.scheme. To customize graph colors, provide explicit colors via scale.range.',
|
|
1215
|
+
);
|
|
1079
1216
|
}
|
|
1080
1217
|
|
|
1081
1218
|
// Validate layout type if specified
|
|
@@ -1147,7 +1284,8 @@ function validateSankeySpec(spec: Record<string, unknown>, errors: ValidationErr
|
|
|
1147
1284
|
|
|
1148
1285
|
const encoding = spec.encoding as Record<string, unknown>;
|
|
1149
1286
|
const dataColumns = new Set(Object.keys(firstRow as Record<string, unknown>));
|
|
1150
|
-
const
|
|
1287
|
+
const columnList = [...dataColumns];
|
|
1288
|
+
const availableColumns = columnList.join(', ');
|
|
1151
1289
|
|
|
1152
1290
|
// Required channels
|
|
1153
1291
|
for (const channel of ['source', 'target', 'value'] as const) {
|
|
@@ -1157,7 +1295,7 @@ function validateSankeySpec(spec: Record<string, unknown>, errors: ValidationErr
|
|
|
1157
1295
|
message: `Spec error: sankey encoding requires "${channel}" channel`,
|
|
1158
1296
|
path: `encoding.${channel}`,
|
|
1159
1297
|
code: 'MISSING_FIELD',
|
|
1160
|
-
suggestion: `Add encoding.${channel} with a field from your data (${availableColumns}). Example: ${channel}: { field: "${[
|
|
1298
|
+
suggestion: `Add encoding.${channel} with a field from your data (${availableColumns}). Example: ${channel}: { field: "${columnList[0] ?? 'myField'}", type: "${channel === 'value' ? 'quantitative' : 'nominal'}" }`,
|
|
1161
1299
|
});
|
|
1162
1300
|
continue;
|
|
1163
1301
|
}
|
|
@@ -1177,11 +1315,19 @@ function validateSankeySpec(spec: Record<string, unknown>, errors: ValidationErr
|
|
|
1177
1315
|
message: `Spec error: encoding.${channel}.field "${ch.field}" does not exist in data. Available columns: ${availableColumns}`,
|
|
1178
1316
|
path: `encoding.${channel}.field`,
|
|
1179
1317
|
code: 'DATA_FIELD_MISSING',
|
|
1180
|
-
suggestion: `Use one of the available data columns: ${availableColumns}`,
|
|
1318
|
+
suggestion: `Use one of the available data columns: ${availableColumns}.${didYouMean(ch.field as string, columnList)}`,
|
|
1181
1319
|
});
|
|
1182
1320
|
}
|
|
1183
1321
|
}
|
|
1184
1322
|
|
|
1323
|
+
// Sankey never reads scale.scheme: node colors cycle theme.colors.categorical
|
|
1324
|
+
checkSchemeUnused(
|
|
1325
|
+
encoding,
|
|
1326
|
+
errors,
|
|
1327
|
+
'sankey node colors cycle the theme categorical palette',
|
|
1328
|
+
'Remove scale.scheme. To customize sankey colors, set theme.colors.categorical.',
|
|
1329
|
+
);
|
|
1330
|
+
|
|
1185
1331
|
// Validate darkMode if provided
|
|
1186
1332
|
if (spec.darkMode !== undefined && !VALID_DARK_MODES.has(spec.darkMode as string)) {
|
|
1187
1333
|
errors.push({
|
|
@@ -1260,7 +1406,8 @@ function validateTileMapSpec(spec: Record<string, unknown>, errors: ValidationEr
|
|
|
1260
1406
|
|
|
1261
1407
|
const encoding = spec.encoding as Record<string, unknown>;
|
|
1262
1408
|
const dataColumns = new Set(Object.keys(firstRow as Record<string, unknown>));
|
|
1263
|
-
const
|
|
1409
|
+
const columnList = [...dataColumns];
|
|
1410
|
+
const availableColumns = columnList.join(', ');
|
|
1264
1411
|
|
|
1265
1412
|
// Required channels
|
|
1266
1413
|
for (const channel of ['state', 'value'] as const) {
|
|
@@ -1270,7 +1417,7 @@ function validateTileMapSpec(spec: Record<string, unknown>, errors: ValidationEr
|
|
|
1270
1417
|
message: `Spec error: tilemap encoding requires "${channel}" channel`,
|
|
1271
1418
|
path: `encoding.${channel}`,
|
|
1272
1419
|
code: 'MISSING_FIELD',
|
|
1273
|
-
suggestion: `Add encoding.${channel} with a field from your data (${availableColumns}). Example: ${channel}: { field: "${[
|
|
1420
|
+
suggestion: `Add encoding.${channel} with a field from your data (${availableColumns}). Example: ${channel}: { field: "${columnList[0] ?? 'myField'}", type: "${channel === 'value' ? 'quantitative' : 'nominal'}" }`,
|
|
1274
1421
|
});
|
|
1275
1422
|
continue;
|
|
1276
1423
|
}
|
|
@@ -1290,12 +1437,23 @@ function validateTileMapSpec(spec: Record<string, unknown>, errors: ValidationEr
|
|
|
1290
1437
|
message: `Spec error: encoding.${channel}.field "${ch.field}" does not exist in data. Available columns: ${availableColumns}`,
|
|
1291
1438
|
path: `encoding.${channel}.field`,
|
|
1292
1439
|
code: 'DATA_FIELD_MISSING',
|
|
1293
|
-
suggestion: `Use one of the available data columns: ${availableColumns}`,
|
|
1440
|
+
suggestion: `Use one of the available data columns: ${availableColumns}.${didYouMean(ch.field as string, columnList)}`,
|
|
1294
1441
|
});
|
|
1295
1442
|
}
|
|
1296
1443
|
}
|
|
1297
1444
|
}
|
|
1298
1445
|
|
|
1446
|
+
// Tilemap never reads scale.scheme: fills come from the top-level palette
|
|
1447
|
+
// prop (encoding may be present in both record-map and array-data modes)
|
|
1448
|
+
if (spec.encoding && typeof spec.encoding === 'object') {
|
|
1449
|
+
checkSchemeUnused(
|
|
1450
|
+
spec.encoding as Record<string, unknown>,
|
|
1451
|
+
errors,
|
|
1452
|
+
'tilemap fills come from the top-level palette property',
|
|
1453
|
+
`Remove scale.scheme and set the top-level palette property instead, e.g. palette: "green". Supported: ${MAP_PALETTE_NAMES}.`,
|
|
1454
|
+
);
|
|
1455
|
+
}
|
|
1456
|
+
|
|
1299
1457
|
// Validate darkMode if provided
|
|
1300
1458
|
if (spec.darkMode !== undefined && !VALID_DARK_MODES.has(spec.darkMode as string)) {
|
|
1301
1459
|
errors.push({
|
|
@@ -1346,6 +1504,19 @@ function validateMapSpec(spec: Record<string, unknown>, errors: ValidationError[
|
|
|
1346
1504
|
return;
|
|
1347
1505
|
}
|
|
1348
1506
|
|
|
1507
|
+
// Map color channels resolve scale.scheme against the sequential palettes
|
|
1508
|
+
// with a silent blue fallback; reject unsupported names here instead.
|
|
1509
|
+
const mapEncoding = spec.encoding as Record<string, unknown>;
|
|
1510
|
+
if (mapEncoding.color && typeof mapEncoding.color === 'object') {
|
|
1511
|
+
checkMapSchemeName(mapEncoding.color as Record<string, unknown>, 'encoding.color', errors);
|
|
1512
|
+
}
|
|
1513
|
+
if (spec.points && typeof spec.points === 'object') {
|
|
1514
|
+
const pointsLayer = spec.points as Record<string, unknown>;
|
|
1515
|
+
if (pointsLayer.color && typeof pointsLayer.color === 'object') {
|
|
1516
|
+
checkMapSchemeName(pointsLayer.color as Record<string, unknown>, 'points.color', errors);
|
|
1517
|
+
}
|
|
1518
|
+
}
|
|
1519
|
+
|
|
1349
1520
|
// Validate geo.focus if present
|
|
1350
1521
|
if ('focus' in geo && geo.focus !== undefined && geo.focus !== null) {
|
|
1351
1522
|
const focus = geo.focus;
|
|
@@ -1364,13 +1535,40 @@ function validateMapSpec(spec: Record<string, unknown>, errors: ValidationError[
|
|
|
1364
1535
|
}
|
|
1365
1536
|
} else if (typeof focus === 'object') {
|
|
1366
1537
|
const focusObj = focus as Record<string, unknown>;
|
|
1367
|
-
if (
|
|
1538
|
+
if ('points' in focusObj) {
|
|
1539
|
+
// Points form: { points: true | { field, value }, padding? } fits the
|
|
1540
|
+
// point layer's cluster (all points, or a matching subset).
|
|
1541
|
+
const pts = focusObj.points;
|
|
1542
|
+
const isSubset =
|
|
1543
|
+
typeof pts === 'object' &&
|
|
1544
|
+
pts !== null &&
|
|
1545
|
+
typeof (pts as Record<string, unknown>).field === 'string' &&
|
|
1546
|
+
'value' in (pts as Record<string, unknown>);
|
|
1547
|
+
if (pts !== true && !isSubset) {
|
|
1548
|
+
errors.push({
|
|
1549
|
+
message:
|
|
1550
|
+
'Spec error: geo.focus.points must be true or { field: string, value: string | number }',
|
|
1551
|
+
path: 'geo.focus.points',
|
|
1552
|
+
code: 'INVALID_TYPE',
|
|
1553
|
+
suggestion:
|
|
1554
|
+
'Use { points: true } to fit all points, or { points: { field: "rating", value: "F" } } to fit a subset.',
|
|
1555
|
+
});
|
|
1556
|
+
}
|
|
1557
|
+
if (focusObj.padding !== undefined && typeof focusObj.padding !== 'number') {
|
|
1558
|
+
errors.push({
|
|
1559
|
+
message: 'Spec error: geo.focus.padding must be a number',
|
|
1560
|
+
path: 'geo.focus.padding',
|
|
1561
|
+
code: 'INVALID_TYPE',
|
|
1562
|
+
suggestion: 'Provide padding as a number in map-local units, e.g. padding: 16',
|
|
1563
|
+
});
|
|
1564
|
+
}
|
|
1565
|
+
} else if (!('features' in focusObj) || focusObj.features == null) {
|
|
1368
1566
|
errors.push({
|
|
1369
|
-
message: 'Spec error: geo.focus object must have a "features" property',
|
|
1567
|
+
message: 'Spec error: geo.focus object must have a "features" or "points" property',
|
|
1370
1568
|
path: 'geo.focus.features',
|
|
1371
1569
|
code: 'MISSING_FIELD',
|
|
1372
1570
|
suggestion:
|
|
1373
|
-
'Provide feature id(s) to focus on, e.g.
|
|
1571
|
+
'Provide feature id(s) to focus on, e.g. { features: "36", padding: 32 }, or { points: true } to fit the point cluster.',
|
|
1374
1572
|
});
|
|
1375
1573
|
} else {
|
|
1376
1574
|
const feats = focusObj.features;
|
|
@@ -1406,36 +1604,112 @@ function validateMapSpec(spec: Record<string, unknown>, errors: ValidationError[
|
|
|
1406
1604
|
} else {
|
|
1407
1605
|
errors.push({
|
|
1408
1606
|
message:
|
|
1409
|
-
'Spec error: geo.focus must be a string, number, array of ids, or object with { features, padding? }',
|
|
1607
|
+
'Spec error: geo.focus must be a string, number, array of ids, or object with { features, padding? } or { points: true, padding? }',
|
|
1410
1608
|
path: 'geo.focus',
|
|
1411
1609
|
code: 'INVALID_TYPE',
|
|
1412
1610
|
suggestion:
|
|
1413
|
-
'Use a feature id, array of ids,
|
|
1611
|
+
'Use a feature id, array of ids, { features: "36", padding: 16 }, or { points: true }. Pass null to clear focus.',
|
|
1414
1612
|
});
|
|
1415
1613
|
}
|
|
1416
1614
|
}
|
|
1417
1615
|
|
|
1418
1616
|
const encoding = spec.encoding as Record<string, unknown>;
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1617
|
+
const hasPoints = spec.points && typeof spec.points === 'object' && !Array.isArray(spec.points);
|
|
1618
|
+
|
|
1619
|
+
// key is required unless basemap-only mode (points present + empty/no choropleth data)
|
|
1620
|
+
const specData = spec.data as unknown[] | undefined;
|
|
1621
|
+
const isBasemapOnly = hasPoints && (!specData || specData.length === 0);
|
|
1622
|
+
const keyCh = encoding.key as Record<string, unknown> | undefined;
|
|
1623
|
+
if (!isBasemapOnly) {
|
|
1624
|
+
if (!keyCh || typeof keyCh !== 'object') {
|
|
1422
1625
|
errors.push({
|
|
1423
|
-
message:
|
|
1424
|
-
path:
|
|
1626
|
+
message: 'Spec error: map encoding requires "key" channel',
|
|
1627
|
+
path: 'encoding.key',
|
|
1425
1628
|
code: 'MISSING_FIELD',
|
|
1426
|
-
suggestion:
|
|
1629
|
+
suggestion: 'Add encoding.key, e.g. key: { field: "id", type: "nominal" }',
|
|
1630
|
+
});
|
|
1631
|
+
} else if (!keyCh.field || typeof keyCh.field !== 'string') {
|
|
1632
|
+
errors.push({
|
|
1633
|
+
message: 'Spec error: encoding.key must have a "field" string',
|
|
1634
|
+
path: 'encoding.key.field',
|
|
1635
|
+
code: 'MISSING_FIELD',
|
|
1636
|
+
suggestion: 'Add a field name, e.g. key: { field: "id" }',
|
|
1427
1637
|
});
|
|
1428
|
-
continue;
|
|
1429
1638
|
}
|
|
1639
|
+
}
|
|
1430
1640
|
|
|
1431
|
-
|
|
1641
|
+
// color is required UNLESS points is present (basemap-only mode)
|
|
1642
|
+
const colorCh = encoding.color as Record<string, unknown> | undefined;
|
|
1643
|
+
if (!hasPoints) {
|
|
1644
|
+
if (!colorCh || typeof colorCh !== 'object') {
|
|
1432
1645
|
errors.push({
|
|
1433
|
-
message:
|
|
1434
|
-
|
|
1646
|
+
message:
|
|
1647
|
+
'Spec error: map encoding requires "color" channel (or add a "points" layer for basemap-only mode)',
|
|
1648
|
+
path: 'encoding.color',
|
|
1435
1649
|
code: 'MISSING_FIELD',
|
|
1436
|
-
suggestion:
|
|
1650
|
+
suggestion: 'Add encoding.color, e.g. color: { field: "value", type: "quantitative" }',
|
|
1651
|
+
});
|
|
1652
|
+
} else if (!colorCh.field || typeof colorCh.field !== 'string') {
|
|
1653
|
+
errors.push({
|
|
1654
|
+
message: 'Spec error: encoding.color must have a "field" string',
|
|
1655
|
+
path: 'encoding.color.field',
|
|
1656
|
+
code: 'MISSING_FIELD',
|
|
1657
|
+
suggestion: 'Add a field name, e.g. color: { field: "value" }',
|
|
1658
|
+
});
|
|
1659
|
+
}
|
|
1660
|
+
} else if (
|
|
1661
|
+
colorCh &&
|
|
1662
|
+
typeof colorCh === 'object' &&
|
|
1663
|
+
(!colorCh.field || typeof colorCh.field !== 'string')
|
|
1664
|
+
) {
|
|
1665
|
+
errors.push({
|
|
1666
|
+
message: 'Spec error: encoding.color must have a "field" string when provided',
|
|
1667
|
+
path: 'encoding.color.field',
|
|
1668
|
+
code: 'MISSING_FIELD',
|
|
1669
|
+
suggestion: 'Add a field name, e.g. color: { field: "value" }',
|
|
1670
|
+
});
|
|
1671
|
+
}
|
|
1672
|
+
|
|
1673
|
+
// Validate points layer if present
|
|
1674
|
+
if (hasPoints) {
|
|
1675
|
+
const points = spec.points as Record<string, unknown>;
|
|
1676
|
+
if (!Array.isArray(points.data)) {
|
|
1677
|
+
errors.push({
|
|
1678
|
+
message: 'Spec error: points.data must be an array of data rows',
|
|
1679
|
+
path: 'points.data',
|
|
1680
|
+
code: 'INVALID_TYPE',
|
|
1681
|
+
suggestion: 'Provide data as an array of objects with lat/lng fields',
|
|
1437
1682
|
});
|
|
1438
1683
|
}
|
|
1684
|
+
for (const ch of ['longitude', 'latitude'] as const) {
|
|
1685
|
+
const enc = points[ch] as Record<string, unknown> | undefined;
|
|
1686
|
+
if (!enc || typeof enc !== 'object') {
|
|
1687
|
+
errors.push({
|
|
1688
|
+
message: `Spec error: points requires "${ch}" encoding channel`,
|
|
1689
|
+
path: `points.${ch}`,
|
|
1690
|
+
code: 'MISSING_FIELD',
|
|
1691
|
+
suggestion: `Add points.${ch}, e.g. ${ch}: { field: "${ch === 'longitude' ? 'lon' : 'lat'}", type: "quantitative" }`,
|
|
1692
|
+
});
|
|
1693
|
+
} else if (!enc.field || typeof enc.field !== 'string') {
|
|
1694
|
+
errors.push({
|
|
1695
|
+
message: `Spec error: points.${ch} must have a "field" string`,
|
|
1696
|
+
path: `points.${ch}.field`,
|
|
1697
|
+
code: 'MISSING_FIELD',
|
|
1698
|
+
suggestion: `Add a field name, e.g. ${ch}: { field: "${ch === 'longitude' ? 'lon' : 'lat'}" }`,
|
|
1699
|
+
});
|
|
1700
|
+
}
|
|
1701
|
+
}
|
|
1702
|
+
for (const ch of ['size', 'color', 'key'] as const) {
|
|
1703
|
+
const enc = points[ch] as Record<string, unknown> | undefined;
|
|
1704
|
+
if (enc && typeof enc === 'object' && (!enc.field || typeof enc.field !== 'string')) {
|
|
1705
|
+
errors.push({
|
|
1706
|
+
message: `Spec error: points.${ch} must have a "field" string when provided`,
|
|
1707
|
+
path: `points.${ch}.field`,
|
|
1708
|
+
code: 'MISSING_FIELD',
|
|
1709
|
+
suggestion: `Add a field name, e.g. ${ch}: { field: "myField" }`,
|
|
1710
|
+
});
|
|
1711
|
+
}
|
|
1712
|
+
}
|
|
1439
1713
|
}
|
|
1440
1714
|
}
|
|
1441
1715
|
|
|
@@ -1490,7 +1764,8 @@ function validateBarListSpec(spec: Record<string, unknown>, errors: ValidationEr
|
|
|
1490
1764
|
|
|
1491
1765
|
const encoding = spec.encoding as Record<string, unknown>;
|
|
1492
1766
|
const dataColumns = new Set(Object.keys(firstRow as Record<string, unknown>));
|
|
1493
|
-
const
|
|
1767
|
+
const columnList = [...dataColumns];
|
|
1768
|
+
const availableColumns = columnList.join(', ');
|
|
1494
1769
|
|
|
1495
1770
|
for (const channel of ['label', 'value'] as const) {
|
|
1496
1771
|
const ch = encoding[channel] as Record<string, unknown> | undefined;
|
|
@@ -1499,7 +1774,7 @@ function validateBarListSpec(spec: Record<string, unknown>, errors: ValidationEr
|
|
|
1499
1774
|
message: `Spec error: barlist encoding requires "${channel}" channel`,
|
|
1500
1775
|
path: `encoding.${channel}`,
|
|
1501
1776
|
code: 'MISSING_FIELD',
|
|
1502
|
-
suggestion: `Add encoding.${channel} with a field from your data (${availableColumns}). Example: ${channel}: { field: "${[
|
|
1777
|
+
suggestion: `Add encoding.${channel} with a field from your data (${availableColumns}). Example: ${channel}: { field: "${columnList[0] ?? 'myField'}", type: "${channel === 'value' ? 'quantitative' : 'nominal'}" }`,
|
|
1503
1778
|
});
|
|
1504
1779
|
continue;
|
|
1505
1780
|
}
|
|
@@ -1519,7 +1794,7 @@ function validateBarListSpec(spec: Record<string, unknown>, errors: ValidationEr
|
|
|
1519
1794
|
message: `Spec error: encoding.${channel}.field "${ch.field}" does not exist in data. Available columns: ${availableColumns}`,
|
|
1520
1795
|
path: `encoding.${channel}.field`,
|
|
1521
1796
|
code: 'DATA_FIELD_MISSING',
|
|
1522
|
-
suggestion: `Use one of the available data columns: ${availableColumns}`,
|
|
1797
|
+
suggestion: `Use one of the available data columns: ${availableColumns}.${didYouMean(ch.field as string, columnList)}`,
|
|
1523
1798
|
});
|
|
1524
1799
|
}
|
|
1525
1800
|
}
|
|
@@ -1534,11 +1809,19 @@ function validateBarListSpec(spec: Record<string, unknown>, errors: ValidationEr
|
|
|
1534
1809
|
message: `Spec error: encoding.${channel}.field "${field}" does not exist in data. Available columns: ${availableColumns}`,
|
|
1535
1810
|
path: `encoding.${channel}.field`,
|
|
1536
1811
|
code: 'DATA_FIELD_MISSING',
|
|
1537
|
-
suggestion: `Use one of the available data columns: ${availableColumns}`,
|
|
1812
|
+
suggestion: `Use one of the available data columns: ${availableColumns}.${didYouMean(field, columnList)}`,
|
|
1538
1813
|
});
|
|
1539
1814
|
}
|
|
1540
1815
|
}
|
|
1541
1816
|
|
|
1817
|
+
// Barlist never reads scale.scheme: bars cycle the built-in barlist palette
|
|
1818
|
+
checkSchemeUnused(
|
|
1819
|
+
encoding,
|
|
1820
|
+
errors,
|
|
1821
|
+
'bar list colors cycle the built-in barlist palette',
|
|
1822
|
+
'Remove scale.scheme. Bar list colors are not configurable via encoding; they cycle the built-in palette.',
|
|
1823
|
+
);
|
|
1824
|
+
|
|
1542
1825
|
if (spec.darkMode !== undefined && !VALID_DARK_MODES.has(spec.darkMode as string)) {
|
|
1543
1826
|
errors.push({
|
|
1544
1827
|
message: 'Spec error: darkMode must be "auto", "force", or "off"',
|