@opendata-ai/openchart-engine 8.0.0-rc.5 → 8.0.0-rc.7

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"',
@@ -296,6 +296,55 @@ describe('compileMap', () => {
296
296
  expect(layout.continuousLegend).toBeNull();
297
297
  });
298
298
 
299
+ it('categorical legend defaults to top and sits above the map area', () => {
300
+ const layout = compileMap(
301
+ {
302
+ type: 'map',
303
+ geo: { features: MINI_TOPO, projection: 'mercator' },
304
+ data: [
305
+ { fips: '06', category: 'west' },
306
+ { fips: '48', category: 'south' },
307
+ ],
308
+ encoding: {
309
+ key: { field: 'fips', type: 'nominal' },
310
+ color: { field: 'category', type: 'nominal' },
311
+ },
312
+ },
313
+ DEFAULT_OPTIONS,
314
+ );
315
+
316
+ const legend = layout.categoricalLegend!;
317
+ expect(legend.position).toBe('top');
318
+ // The map area shifts down to make room for a top legend; the legend row
319
+ // must land above it, not inside it (the old bottom-anchored legend
320
+ // overlapped the map because the two disagreed about the layout).
321
+ expect(legend.bounds.y + legend.bounds.height).toBeLessThanOrEqual(layout.area.y);
322
+ });
323
+
324
+ it('honors position: bottom for categorical legend', () => {
325
+ const layout = compileMap(
326
+ {
327
+ type: 'map',
328
+ geo: { features: MINI_TOPO, projection: 'mercator' },
329
+ data: [
330
+ { fips: '06', category: 'west' },
331
+ { fips: '48', category: 'south' },
332
+ ],
333
+ encoding: {
334
+ key: { field: 'fips', type: 'nominal' },
335
+ color: { field: 'category', type: 'nominal' },
336
+ },
337
+ legend: { position: 'bottom' },
338
+ },
339
+ DEFAULT_OPTIONS,
340
+ );
341
+
342
+ const legend = layout.categoricalLegend!;
343
+ expect(legend.position).toBe('bottom');
344
+ // Bottom legend starts at or below the map area's bottom edge.
345
+ expect(legend.bounds.y).toBeGreaterThanOrEqual(layout.area.y + layout.area.height);
346
+ });
347
+
299
348
  it('assigns neutral fill to features without data', () => {
300
349
  const layout = compileMap(
301
350
  {
@@ -360,6 +360,7 @@ export function compileMap(spec: unknown, options: CompileOptions): MapLayout {
360
360
  fullArea,
361
361
  mapAreaHeight,
362
362
  legendReserveGap,
363
+ legendPosition,
363
364
  showLegend,
364
365
  droppedFeatures,
365
366
  });
@@ -1010,6 +1011,7 @@ interface CategoricalOptions {
1010
1011
  fullArea: { x: number; y: number; width: number; height: number };
1011
1012
  mapAreaHeight: number;
1012
1013
  legendReserveGap: number;
1014
+ legendPosition: 'top' | 'bottom';
1013
1015
  showLegend: boolean;
1014
1016
  droppedFeatures: string[];
1015
1017
  }
@@ -1031,6 +1033,7 @@ function buildCategoricalMarks(opts: CategoricalOptions): {
1031
1033
  fullArea,
1032
1034
  mapAreaHeight,
1033
1035
  legendReserveGap,
1036
+ legendPosition,
1034
1037
  showLegend,
1035
1038
  droppedFeatures,
1036
1039
  } = opts;
@@ -1142,8 +1145,15 @@ function buildCategoricalMarks(opts: CategoricalOptions): {
1142
1145
  shape: 'square' as const,
1143
1146
  }));
1144
1147
 
1148
+ // Honor legend.position like the continuous path does. The map drawing
1149
+ // area (mapAreaY in compileMap) already shifts down for top legends, so
1150
+ // the legend row here must agree with it — a bottom-anchored legend with
1151
+ // a top-shifted map is how legends used to land on top of Alaska.
1145
1152
  const legendX = fullArea.x;
1146
- const legendY = fullArea.y + mapAreaHeight + legendReserveGap;
1153
+ const legendY =
1154
+ legendPosition === 'bottom'
1155
+ ? fullArea.y + mapAreaHeight + legendReserveGap
1156
+ : fullArea.y + legendReserveGap;
1147
1157
  const legendWidth = fullArea.width;
1148
1158
  const swatchSize = 10;
1149
1159
  const swatchGap = 6;
@@ -1151,7 +1161,7 @@ function buildCategoricalMarks(opts: CategoricalOptions): {
1151
1161
 
1152
1162
  legend = {
1153
1163
  type: 'categorical',
1154
- position: 'bottom',
1164
+ position: legendPosition,
1155
1165
  bounds: { x: legendX, y: legendY, width: legendWidth, height: swatchSize + 6 },
1156
1166
  labelStyle,
1157
1167
  entries,
@@ -1207,13 +1217,20 @@ function resolveFocus(
1207
1217
  let maxX = -Infinity;
1208
1218
  let maxY = -Infinity;
1209
1219
  for (const p of matched) {
1210
- minX = Math.min(minX, p.cx - p.r);
1211
- minY = Math.min(minY, p.cy - p.r);
1212
- maxX = Math.max(maxX, p.cx + p.r);
1213
- 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);
1214
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);
1215
1232
  return {
1216
- target: { x: minX, y: minY, width: maxX - minX, height: maxY - minY, padding },
1233
+ target: { x: minX, y: minY, width: w, height: h, padding },
1217
1234
  // No feature ids: focus-dim is a feature concept, so a points focus
1218
1235
  // dims no features (there are no "other" features to mute).
1219
1236
  ids: [],