@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.
@@ -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
- 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
- }
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 availableColumns = [...dataColumns].join(', ');
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: "${[...dataColumns][0] ?? 'myField'}", type: "${channel === 'value' ? 'quantitative' : 'nominal'}" }`,
1298
+ suggestion: `Add encoding.${channel} with a field from your data (${availableColumns}). Example: ${channel}: { field: "${columnList[0] ?? 'myField'}", type: "${channel === 'value' ? 'quantitative' : 'nominal'}" }`,
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 availableColumns = [...dataColumns].join(', ');
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: "${[...dataColumns][0] ?? 'myField'}", type: "${channel === 'value' ? 'quantitative' : 'nominal'}" }`,
1420
+ suggestion: `Add encoding.${channel} with a field from your data (${availableColumns}). Example: ${channel}: { field: "${columnList[0] ?? 'myField'}", type: "${channel === 'value' ? 'quantitative' : 'nominal'}" }`,
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 (!('features' in focusObj) || focusObj.features == null) {
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. geo: { focus: { features: "36", padding: 32 } }',
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, or { features: "36", padding: 16 }. Pass null to clear focus.',
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
- for (const channel of ['key', 'color'] as const) {
1420
- const ch = encoding[channel] as Record<string, unknown> | undefined;
1421
- if (!ch || typeof ch !== 'object') {
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: `Spec error: map encoding requires "${channel}" channel`,
1424
- path: `encoding.${channel}`,
1626
+ message: 'Spec error: map encoding requires "key" channel',
1627
+ path: 'encoding.key',
1425
1628
  code: 'MISSING_FIELD',
1426
- suggestion: `Add encoding.${channel}, e.g. ${channel}: { field: "${channel === 'key' ? 'id' : 'value'}", type: "${channel === 'key' ? 'nominal' : 'quantitative'}" }`,
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
- if (!ch.field || typeof ch.field !== 'string') {
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: `Spec error: encoding.${channel} must have a "field" string`,
1434
- path: `encoding.${channel}.field`,
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: `Add a field name, e.g. ${channel}: { field: "${channel === 'key' ? 'id' : 'value'}" }`,
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 availableColumns = [...dataColumns].join(', ');
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: "${[...dataColumns][0] ?? 'myField'}", type: "${channel === 'value' ? 'quantitative' : 'nominal'}" }`,
1777
+ suggestion: `Add encoding.${channel} with a field from your data (${availableColumns}). Example: ${channel}: { field: "${columnList[0] ?? 'myField'}", type: "${channel === 'value' ? 'quantitative' : 'nominal'}" }`,
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"',