@opendata-ai/openchart-engine 8.0.0-rc.6 → 8.0.0-rc.8

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) {
@@ -1022,7 +1099,7 @@ function validateGraphSpec(spec: Record<string, unknown>, errors: ValidationErro
1022
1099
  message: `Spec error: edges[${i}].source "${edge.source}" does not reference an existing node id`,
1023
1100
  path: `edges[${i}].source`,
1024
1101
  code: 'DATA_FIELD_MISSING',
1025
- suggestion: `Use one of the existing node ids: ${[...nodeIds].slice(0, 5).join(', ')}${nodeIds.size > 5 ? '...' : ''}`,
1102
+ suggestion: `Use one of the existing node ids: ${[...nodeIds].slice(0, 5).join(', ')}${nodeIds.size > 5 ? '...' : ''}.${didYouMean(edge.source, [...nodeIds])}`,
1026
1103
  });
1027
1104
  }
1028
1105
 
@@ -1038,7 +1115,7 @@ function validateGraphSpec(spec: Record<string, unknown>, errors: ValidationErro
1038
1115
  message: `Spec error: edges[${i}].target "${edge.target}" does not reference an existing node id`,
1039
1116
  path: `edges[${i}].target`,
1040
1117
  code: 'DATA_FIELD_MISSING',
1041
- suggestion: `Use one of the existing node ids: ${[...nodeIds].slice(0, 5).join(', ')}${nodeIds.size > 5 ? '...' : ''}`,
1118
+ suggestion: `Use one of the existing node ids: ${[...nodeIds].slice(0, 5).join(', ')}${nodeIds.size > 5 ? '...' : ''}.${didYouMean(edge.target, [...nodeIds])}`,
1042
1119
  });
1043
1120
  }
1044
1121
  }
@@ -1059,7 +1136,7 @@ function validateGraphSpec(spec: Record<string, unknown>, errors: ValidationErro
1059
1136
  message: `Spec error: encoding.${channel}.field "${ch.field}" does not exist on nodes. Available fields: ${[...nodeFields].join(', ')}`,
1060
1137
  path: `encoding.${channel}.field`,
1061
1138
  code: 'DATA_FIELD_MISSING',
1062
- suggestion: `Use one of the node fields: ${[...nodeFields].join(', ')}`,
1139
+ suggestion: `Use one of the node fields: ${[...nodeFields].join(', ')}.${didYouMean(ch.field, [...nodeFields])}`,
1063
1140
  });
1064
1141
  }
1065
1142
  }
@@ -1072,10 +1149,18 @@ function validateGraphSpec(spec: Record<string, unknown>, errors: ValidationErro
1072
1149
  message: `Spec error: encoding.${channel}.field "${ch.field}" does not exist on edges. Available fields: ${[...edgeFields].join(', ')}`,
1073
1150
  path: `encoding.${channel}.field`,
1074
1151
  code: 'DATA_FIELD_MISSING',
1075
- suggestion: `Use one of the edge fields: ${[...edgeFields].join(', ')}`,
1152
+ suggestion: `Use one of the edge fields: ${[...edgeFields].join(', ')}.${didYouMean(ch.field, [...edgeFields])}`,
1076
1153
  });
1077
1154
  }
1078
1155
  }
1156
+
1157
+ // Graph never reads scale.scheme: colors come from scale.range/scale.domain
1158
+ checkSchemeUnused(
1159
+ encoding,
1160
+ errors,
1161
+ 'graph colors come from scale.range and scale.domain',
1162
+ 'Remove scale.scheme. To customize graph colors, provide explicit colors via scale.range.',
1163
+ );
1079
1164
  }
1080
1165
 
1081
1166
  // Validate layout type if specified
@@ -1147,7 +1232,8 @@ function validateSankeySpec(spec: Record<string, unknown>, errors: ValidationErr
1147
1232
 
1148
1233
  const encoding = spec.encoding as Record<string, unknown>;
1149
1234
  const dataColumns = new Set(Object.keys(firstRow as Record<string, unknown>));
1150
- const availableColumns = [...dataColumns].join(', ');
1235
+ const columnList = [...dataColumns];
1236
+ const availableColumns = columnList.join(', ');
1151
1237
 
1152
1238
  // Required channels
1153
1239
  for (const channel of ['source', 'target', 'value'] as const) {
@@ -1157,7 +1243,7 @@ function validateSankeySpec(spec: Record<string, unknown>, errors: ValidationErr
1157
1243
  message: `Spec error: sankey encoding requires "${channel}" channel`,
1158
1244
  path: `encoding.${channel}`,
1159
1245
  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'}" }`,
1246
+ suggestion: `Add encoding.${channel} with a field from your data (${availableColumns}). Example: ${channel}: { field: "${columnList[0] ?? 'myField'}", type: "${channel === 'value' ? 'quantitative' : 'nominal'}" }`,
1161
1247
  });
1162
1248
  continue;
1163
1249
  }
@@ -1177,11 +1263,19 @@ function validateSankeySpec(spec: Record<string, unknown>, errors: ValidationErr
1177
1263
  message: `Spec error: encoding.${channel}.field "${ch.field}" does not exist in data. Available columns: ${availableColumns}`,
1178
1264
  path: `encoding.${channel}.field`,
1179
1265
  code: 'DATA_FIELD_MISSING',
1180
- suggestion: `Use one of the available data columns: ${availableColumns}`,
1266
+ suggestion: `Use one of the available data columns: ${availableColumns}.${didYouMean(ch.field as string, columnList)}`,
1181
1267
  });
1182
1268
  }
1183
1269
  }
1184
1270
 
1271
+ // Sankey never reads scale.scheme: node colors cycle theme.colors.categorical
1272
+ checkSchemeUnused(
1273
+ encoding,
1274
+ errors,
1275
+ 'sankey node colors cycle the theme categorical palette',
1276
+ 'Remove scale.scheme. To customize sankey colors, set theme.colors.categorical.',
1277
+ );
1278
+
1185
1279
  // Validate darkMode if provided
1186
1280
  if (spec.darkMode !== undefined && !VALID_DARK_MODES.has(spec.darkMode as string)) {
1187
1281
  errors.push({
@@ -1260,7 +1354,8 @@ function validateTileMapSpec(spec: Record<string, unknown>, errors: ValidationEr
1260
1354
 
1261
1355
  const encoding = spec.encoding as Record<string, unknown>;
1262
1356
  const dataColumns = new Set(Object.keys(firstRow as Record<string, unknown>));
1263
- const availableColumns = [...dataColumns].join(', ');
1357
+ const columnList = [...dataColumns];
1358
+ const availableColumns = columnList.join(', ');
1264
1359
 
1265
1360
  // Required channels
1266
1361
  for (const channel of ['state', 'value'] as const) {
@@ -1270,7 +1365,7 @@ function validateTileMapSpec(spec: Record<string, unknown>, errors: ValidationEr
1270
1365
  message: `Spec error: tilemap encoding requires "${channel}" channel`,
1271
1366
  path: `encoding.${channel}`,
1272
1367
  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'}" }`,
1368
+ suggestion: `Add encoding.${channel} with a field from your data (${availableColumns}). Example: ${channel}: { field: "${columnList[0] ?? 'myField'}", type: "${channel === 'value' ? 'quantitative' : 'nominal'}" }`,
1274
1369
  });
1275
1370
  continue;
1276
1371
  }
@@ -1290,12 +1385,23 @@ function validateTileMapSpec(spec: Record<string, unknown>, errors: ValidationEr
1290
1385
  message: `Spec error: encoding.${channel}.field "${ch.field}" does not exist in data. Available columns: ${availableColumns}`,
1291
1386
  path: `encoding.${channel}.field`,
1292
1387
  code: 'DATA_FIELD_MISSING',
1293
- suggestion: `Use one of the available data columns: ${availableColumns}`,
1388
+ suggestion: `Use one of the available data columns: ${availableColumns}.${didYouMean(ch.field as string, columnList)}`,
1294
1389
  });
1295
1390
  }
1296
1391
  }
1297
1392
  }
1298
1393
 
1394
+ // Tilemap never reads scale.scheme: fills come from the top-level palette
1395
+ // prop (encoding may be present in both record-map and array-data modes)
1396
+ if (spec.encoding && typeof spec.encoding === 'object') {
1397
+ checkSchemeUnused(
1398
+ spec.encoding as Record<string, unknown>,
1399
+ errors,
1400
+ 'tilemap fills come from the top-level palette property',
1401
+ `Remove scale.scheme and set the top-level palette property instead, e.g. palette: "green". Supported: ${MAP_PALETTE_NAMES}.`,
1402
+ );
1403
+ }
1404
+
1299
1405
  // Validate darkMode if provided
1300
1406
  if (spec.darkMode !== undefined && !VALID_DARK_MODES.has(spec.darkMode as string)) {
1301
1407
  errors.push({
@@ -1346,6 +1452,19 @@ function validateMapSpec(spec: Record<string, unknown>, errors: ValidationError[
1346
1452
  return;
1347
1453
  }
1348
1454
 
1455
+ // Map color channels resolve scale.scheme against the sequential palettes
1456
+ // with a silent blue fallback; reject unsupported names here instead.
1457
+ const mapEncoding = spec.encoding as Record<string, unknown>;
1458
+ if (mapEncoding.color && typeof mapEncoding.color === 'object') {
1459
+ checkMapSchemeName(mapEncoding.color as Record<string, unknown>, 'encoding.color', errors);
1460
+ }
1461
+ if (spec.points && typeof spec.points === 'object') {
1462
+ const pointsLayer = spec.points as Record<string, unknown>;
1463
+ if (pointsLayer.color && typeof pointsLayer.color === 'object') {
1464
+ checkMapSchemeName(pointsLayer.color as Record<string, unknown>, 'points.color', errors);
1465
+ }
1466
+ }
1467
+
1349
1468
  // Validate geo.focus if present
1350
1469
  if ('focus' in geo && geo.focus !== undefined && geo.focus !== null) {
1351
1470
  const focus = geo.focus;
@@ -1593,7 +1712,8 @@ function validateBarListSpec(spec: Record<string, unknown>, errors: ValidationEr
1593
1712
 
1594
1713
  const encoding = spec.encoding as Record<string, unknown>;
1595
1714
  const dataColumns = new Set(Object.keys(firstRow as Record<string, unknown>));
1596
- const availableColumns = [...dataColumns].join(', ');
1715
+ const columnList = [...dataColumns];
1716
+ const availableColumns = columnList.join(', ');
1597
1717
 
1598
1718
  for (const channel of ['label', 'value'] as const) {
1599
1719
  const ch = encoding[channel] as Record<string, unknown> | undefined;
@@ -1602,7 +1722,7 @@ function validateBarListSpec(spec: Record<string, unknown>, errors: ValidationEr
1602
1722
  message: `Spec error: barlist encoding requires "${channel}" channel`,
1603
1723
  path: `encoding.${channel}`,
1604
1724
  code: 'MISSING_FIELD',
1605
- suggestion: `Add encoding.${channel} with a field from your data (${availableColumns}). Example: ${channel}: { field: "${[...dataColumns][0] ?? 'myField'}", type: "${channel === 'value' ? 'quantitative' : 'nominal'}" }`,
1725
+ suggestion: `Add encoding.${channel} with a field from your data (${availableColumns}). Example: ${channel}: { field: "${columnList[0] ?? 'myField'}", type: "${channel === 'value' ? 'quantitative' : 'nominal'}" }`,
1606
1726
  });
1607
1727
  continue;
1608
1728
  }
@@ -1622,7 +1742,7 @@ function validateBarListSpec(spec: Record<string, unknown>, errors: ValidationEr
1622
1742
  message: `Spec error: encoding.${channel}.field "${ch.field}" does not exist in data. Available columns: ${availableColumns}`,
1623
1743
  path: `encoding.${channel}.field`,
1624
1744
  code: 'DATA_FIELD_MISSING',
1625
- suggestion: `Use one of the available data columns: ${availableColumns}`,
1745
+ suggestion: `Use one of the available data columns: ${availableColumns}.${didYouMean(ch.field as string, columnList)}`,
1626
1746
  });
1627
1747
  }
1628
1748
  }
@@ -1637,11 +1757,19 @@ function validateBarListSpec(spec: Record<string, unknown>, errors: ValidationEr
1637
1757
  message: `Spec error: encoding.${channel}.field "${field}" does not exist in data. Available columns: ${availableColumns}`,
1638
1758
  path: `encoding.${channel}.field`,
1639
1759
  code: 'DATA_FIELD_MISSING',
1640
- suggestion: `Use one of the available data columns: ${availableColumns}`,
1760
+ suggestion: `Use one of the available data columns: ${availableColumns}.${didYouMean(field, columnList)}`,
1641
1761
  });
1642
1762
  }
1643
1763
  }
1644
1764
 
1765
+ // Barlist never reads scale.scheme: bars cycle the built-in barlist palette
1766
+ checkSchemeUnused(
1767
+ encoding,
1768
+ errors,
1769
+ 'bar list colors cycle the built-in barlist palette',
1770
+ 'Remove scale.scheme. Bar list colors are not configurable via encoding; they cycle the built-in palette.',
1771
+ );
1772
+
1645
1773
  if (spec.darkMode !== undefined && !VALID_DARK_MODES.has(spec.darkMode as string)) {
1646
1774
  errors.push({
1647
1775
  message: 'Spec error: darkMode must be "auto", "force", or "off"',
@@ -1217,13 +1217,20 @@ function resolveFocus(
1217
1217
  let maxX = -Infinity;
1218
1218
  let maxY = -Infinity;
1219
1219
  for (const p of matched) {
1220
- minX = Math.min(minX, p.cx - p.r);
1221
- minY = Math.min(minY, p.cy - p.r);
1222
- maxX = Math.max(maxX, p.cx + p.r);
1223
- maxY = Math.max(maxY, p.cy + p.r);
1220
+ // Use center points only, not cx ± r: the camera counter-scales point
1221
+ // radii to constant screen size, so the base radius doesn't occupy
1222
+ // projected space after the transform. Including it inflates the
1223
+ // bounding box and produces a much shallower zoom than expected.
1224
+ minX = Math.min(minX, p.cx);
1225
+ minY = Math.min(minY, p.cy);
1226
+ maxX = Math.max(maxX, p.cx);
1227
+ maxY = Math.max(maxY, p.cy);
1224
1228
  }
1229
+ // Guard against degenerate bounding box (single point or coincident points)
1230
+ const w = Math.max(maxX - minX, 1);
1231
+ const h = Math.max(maxY - minY, 1);
1225
1232
  return {
1226
- target: { x: minX, y: minY, width: maxX - minX, height: maxY - minY, padding },
1233
+ target: { x: minX, y: minY, width: w, height: h, padding },
1227
1234
  // No feature ids: focus-dim is a feature concept, so a points focus
1228
1235
  // dims no features (there are no "other" features to mute).
1229
1236
  ids: [],