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

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opendata-ai/openchart-engine",
3
- "version": "8.0.0-rc.3",
3
+ "version": "8.0.0-rc.5",
4
4
  "description": "Headless compiler for openchart: spec validation, data compilation, scales, and layout",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Riley Hilliard",
@@ -48,7 +48,7 @@
48
48
  "typecheck": "tsc --noEmit"
49
49
  },
50
50
  "dependencies": {
51
- "@opendata-ai/openchart-core": "8.0.0-rc.3",
51
+ "@opendata-ai/openchart-core": "8.0.0-rc.5",
52
52
  "d3-array": "^3.2.0",
53
53
  "d3-format": "^3.1.2",
54
54
  "d3-interpolate": "^3.0.0",
@@ -1364,13 +1364,40 @@ function validateMapSpec(spec: Record<string, unknown>, errors: ValidationError[
1364
1364
  }
1365
1365
  } else if (typeof focus === 'object') {
1366
1366
  const focusObj = focus as Record<string, unknown>;
1367
- if (!('features' in focusObj) || focusObj.features == null) {
1367
+ if ('points' in focusObj) {
1368
+ // Points form: { points: true | { field, value }, padding? } fits the
1369
+ // point layer's cluster (all points, or a matching subset).
1370
+ const pts = focusObj.points;
1371
+ const isSubset =
1372
+ typeof pts === 'object' &&
1373
+ pts !== null &&
1374
+ typeof (pts as Record<string, unknown>).field === 'string' &&
1375
+ 'value' in (pts as Record<string, unknown>);
1376
+ if (pts !== true && !isSubset) {
1377
+ errors.push({
1378
+ message:
1379
+ 'Spec error: geo.focus.points must be true or { field: string, value: string | number }',
1380
+ path: 'geo.focus.points',
1381
+ code: 'INVALID_TYPE',
1382
+ suggestion:
1383
+ 'Use { points: true } to fit all points, or { points: { field: "rating", value: "F" } } to fit a subset.',
1384
+ });
1385
+ }
1386
+ if (focusObj.padding !== undefined && typeof focusObj.padding !== 'number') {
1387
+ errors.push({
1388
+ message: 'Spec error: geo.focus.padding must be a number',
1389
+ path: 'geo.focus.padding',
1390
+ code: 'INVALID_TYPE',
1391
+ suggestion: 'Provide padding as a number in map-local units, e.g. padding: 16',
1392
+ });
1393
+ }
1394
+ } else if (!('features' in focusObj) || focusObj.features == null) {
1368
1395
  errors.push({
1369
- message: 'Spec error: geo.focus object must have a "features" property',
1396
+ message: 'Spec error: geo.focus object must have a "features" or "points" property',
1370
1397
  path: 'geo.focus.features',
1371
1398
  code: 'MISSING_FIELD',
1372
1399
  suggestion:
1373
- 'Provide feature id(s) to focus on, e.g. geo: { focus: { features: "36", padding: 32 } }',
1400
+ 'Provide feature id(s) to focus on, e.g. { features: "36", padding: 32 }, or { points: true } to fit the point cluster.',
1374
1401
  });
1375
1402
  } else {
1376
1403
  const feats = focusObj.features;
@@ -1406,11 +1433,11 @@ function validateMapSpec(spec: Record<string, unknown>, errors: ValidationError[
1406
1433
  } else {
1407
1434
  errors.push({
1408
1435
  message:
1409
- 'Spec error: geo.focus must be a string, number, array of ids, or object with { features, padding? }',
1436
+ 'Spec error: geo.focus must be a string, number, array of ids, or object with { features, padding? } or { points: true, padding? }',
1410
1437
  path: 'geo.focus',
1411
1438
  code: 'INVALID_TYPE',
1412
1439
  suggestion:
1413
- 'Use a feature id, array of ids, or { features: "36", padding: 16 }. Pass null to clear focus.',
1440
+ 'Use a feature id, array of ids, { features: "36", padding: 16 }, or { points: true }. Pass null to clear focus.',
1414
1441
  });
1415
1442
  }
1416
1443
  }
@@ -1418,22 +1445,26 @@ function validateMapSpec(spec: Record<string, unknown>, errors: ValidationError[
1418
1445
  const encoding = spec.encoding as Record<string, unknown>;
1419
1446
  const hasPoints = spec.points && typeof spec.points === 'object' && !Array.isArray(spec.points);
1420
1447
 
1421
- // key is always required
1448
+ // key is required unless basemap-only mode (points present + empty/no choropleth data)
1449
+ const specData = spec.data as unknown[] | undefined;
1450
+ const isBasemapOnly = hasPoints && (!specData || specData.length === 0);
1422
1451
  const keyCh = encoding.key as Record<string, unknown> | undefined;
1423
- if (!keyCh || typeof keyCh !== 'object') {
1424
- errors.push({
1425
- message: 'Spec error: map encoding requires "key" channel',
1426
- path: 'encoding.key',
1427
- code: 'MISSING_FIELD',
1428
- suggestion: 'Add encoding.key, e.g. key: { field: "id", type: "nominal" }',
1429
- });
1430
- } else if (!keyCh.field || typeof keyCh.field !== 'string') {
1431
- errors.push({
1432
- message: 'Spec error: encoding.key must have a "field" string',
1433
- path: 'encoding.key.field',
1434
- code: 'MISSING_FIELD',
1435
- suggestion: 'Add a field name, e.g. key: { field: "id" }',
1436
- });
1452
+ if (!isBasemapOnly) {
1453
+ if (!keyCh || typeof keyCh !== 'object') {
1454
+ errors.push({
1455
+ message: 'Spec error: map encoding requires "key" channel',
1456
+ path: 'encoding.key',
1457
+ code: 'MISSING_FIELD',
1458
+ suggestion: 'Add encoding.key, e.g. key: { field: "id", type: "nominal" }',
1459
+ });
1460
+ } else if (!keyCh.field || typeof keyCh.field !== 'string') {
1461
+ errors.push({
1462
+ message: 'Spec error: encoding.key must have a "field" string',
1463
+ path: 'encoding.key.field',
1464
+ code: 'MISSING_FIELD',
1465
+ suggestion: 'Add a field name, e.g. key: { field: "id" }',
1466
+ });
1467
+ }
1437
1468
  }
1438
1469
 
1439
1470
  // color is required UNLESS points is present (basemap-only mode)
@@ -1497,6 +1528,17 @@ function validateMapSpec(spec: Record<string, unknown>, errors: ValidationError[
1497
1528
  });
1498
1529
  }
1499
1530
  }
1531
+ for (const ch of ['size', 'color', 'key'] as const) {
1532
+ const enc = points[ch] as Record<string, unknown> | undefined;
1533
+ if (enc && typeof enc === 'object' && (!enc.field || typeof enc.field !== 'string')) {
1534
+ errors.push({
1535
+ message: `Spec error: points.${ch} must have a "field" string when provided`,
1536
+ path: `points.${ch}.field`,
1537
+ code: 'MISSING_FIELD',
1538
+ suggestion: `Add a field name, e.g. ${ch}: { field: "myField" }`,
1539
+ });
1540
+ }
1541
+ }
1500
1542
  }
1501
1543
  }
1502
1544
 
@@ -582,6 +582,129 @@ describe('compileMap', () => {
582
582
  expect(layout.focus).toBeNull();
583
583
  });
584
584
 
585
+ it('geo.focus { points: true } fits the point cluster, not any feature', () => {
586
+ const layout = compileMap(
587
+ {
588
+ type: 'map',
589
+ geo: {
590
+ features: MINI_TOPO,
591
+ projection: 'mercator',
592
+ focus: { points: true, padding: 4 },
593
+ },
594
+ data: [],
595
+ encoding: { key: { field: 'id', type: 'nominal' } },
596
+ points: {
597
+ data: [
598
+ { lat: 34, lon: -118, name: 'LA' },
599
+ { lat: 40.7, lon: -74, name: 'NYC' },
600
+ ],
601
+ longitude: { field: 'lon', type: 'quantitative' },
602
+ latitude: { field: 'lat', type: 'quantitative' },
603
+ key: { field: 'name', type: 'nominal' },
604
+ },
605
+ },
606
+ DEFAULT_OPTIONS,
607
+ );
608
+
609
+ expect(layout.focus).not.toBeNull();
610
+ expect(layout.focus!.target.padding).toBe(4);
611
+ // No feature ids: an all-points focus dims no features.
612
+ expect(layout.focus!.ids).toEqual([]);
613
+ // The target spans the two points (plus their radii), which sit far apart,
614
+ // so it has real width and height.
615
+ expect(layout.focus!.target.width).toBeGreaterThan(0);
616
+ expect(layout.focus!.target.height).toBeGreaterThan(0);
617
+ // The fitted cluster must be smaller than the full map extent.
618
+ expect(layout.focus!.target.width).toBeLessThan(layout.mapSize.width);
619
+ });
620
+
621
+ it('geo.focus { points: { field, value } } fits only the matching subset', () => {
622
+ const opts = {
623
+ data: [
624
+ { lat: 34, lon: -118, name: 'LA', rating: 'A' },
625
+ { lat: 40.7, lon: -74, name: 'NYC', rating: 'F' },
626
+ ],
627
+ longitude: { field: 'lon', type: 'quantitative' as const },
628
+ latitude: { field: 'lat', type: 'quantitative' as const },
629
+ key: { field: 'name', type: 'nominal' as const },
630
+ };
631
+ const all = compileMap(
632
+ {
633
+ type: 'map',
634
+ geo: { features: MINI_TOPO, projection: 'mercator', focus: { points: true } },
635
+ data: [],
636
+ encoding: { key: { field: 'id', type: 'nominal' } },
637
+ points: opts,
638
+ },
639
+ DEFAULT_OPTIONS,
640
+ );
641
+ const subset = compileMap(
642
+ {
643
+ type: 'map',
644
+ geo: {
645
+ features: MINI_TOPO,
646
+ projection: 'mercator',
647
+ focus: { points: { field: 'rating', value: 'A' } },
648
+ },
649
+ data: [],
650
+ encoding: { key: { field: 'id', type: 'nominal' } },
651
+ points: opts,
652
+ },
653
+ DEFAULT_OPTIONS,
654
+ );
655
+
656
+ expect(subset.focus).not.toBeNull();
657
+ // One point vs two: the subset target is strictly smaller than the all-points target.
658
+ expect(subset.focus!.target.width).toBeLessThan(all.focus!.target.width);
659
+ });
660
+
661
+ it('geo.focus { points: { field, value } } with no match emits FOCUS_UNMATCHED', () => {
662
+ const layout = compileMap(
663
+ {
664
+ type: 'map',
665
+ geo: {
666
+ features: MINI_TOPO,
667
+ projection: 'mercator',
668
+ focus: { points: { field: 'rating', value: 'Z' } },
669
+ },
670
+ data: [],
671
+ encoding: { key: { field: 'id', type: 'nominal' } },
672
+ points: {
673
+ data: [{ lat: 34, lon: -118, name: 'LA', rating: 'A' }],
674
+ longitude: { field: 'lon', type: 'quantitative' },
675
+ latitude: { field: 'lat', type: 'quantitative' },
676
+ key: { field: 'name', type: 'nominal' },
677
+ },
678
+ },
679
+ DEFAULT_OPTIONS,
680
+ );
681
+
682
+ expect(layout.focus).toBeNull();
683
+ expect(layout.warnings.some((w) => w.code === 'FOCUS_UNMATCHED')).toBe(true);
684
+ });
685
+
686
+ it('geo.focus { points: true } with no points emits FOCUS_UNMATCHED and no focus', () => {
687
+ const layout = compileMap(
688
+ {
689
+ type: 'map',
690
+ geo: {
691
+ features: MINI_TOPO,
692
+ projection: 'mercator',
693
+ focus: { points: true },
694
+ },
695
+ data: [{ fips: '06', value: 10 }],
696
+ encoding: {
697
+ key: { field: 'fips', type: 'nominal' },
698
+ color: { field: 'value', type: 'quantitative' },
699
+ },
700
+ },
701
+ DEFAULT_OPTIONS,
702
+ );
703
+
704
+ expect(layout.focus).toBeNull();
705
+ expect(layout.warnings.some((w) => w.code === 'FOCUS_UNMATCHED')).toBe(true);
706
+ });
707
+
585
708
  it('unknown focus id emits FOCUS_UNMATCHED warning', () => {
586
709
  const layout = compileMap(
587
710
  {
@@ -247,23 +247,27 @@ export function compileMap(spec: unknown, options: CompileOptions): MapLayout {
247
247
  }
248
248
  }
249
249
 
250
- // 11. Join data to features
251
- const featureObjs = geoFeatures.map((f) => ({
252
- id: f.id as string | number,
253
- properties: (f.properties ?? {}) as Record<string, unknown>,
254
- }));
255
- const { joined, warnings: joinWarnings } = joinDataToFeatures(
256
- featureObjs,
257
- mapSpec.data,
258
- mapSpec.encoding.key.field,
259
- mapSpec.geo.idField,
260
- );
261
- // In basemap-only mode (empty data + points layer), all features are intentionally unmatched
262
- const filteredJoinWarnings =
263
- mapSpec.data.length === 0 && mapSpec.points
264
- ? joinWarnings.filter((w) => w.code !== 'UNMATCHED_FEATURES')
265
- : joinWarnings;
266
- compileWarnings.push(...filteredJoinWarnings);
250
+ // 11. Join data to features (skip when no key channel, e.g. basemap-only mode)
251
+ let joined = new Map<string | number, Record<string, unknown>>();
252
+ if (mapSpec.encoding.key) {
253
+ const featureObjs = geoFeatures.map((f) => ({
254
+ id: f.id as string | number,
255
+ properties: (f.properties ?? {}) as Record<string, unknown>,
256
+ }));
257
+ const joinResult = joinDataToFeatures(
258
+ featureObjs,
259
+ mapSpec.data,
260
+ mapSpec.encoding.key.field,
261
+ mapSpec.geo.idField,
262
+ );
263
+ joined = joinResult.joined;
264
+ // In basemap-only mode (empty data + points layer), all features are intentionally unmatched
265
+ const filteredJoinWarnings =
266
+ mapSpec.data.length === 0 && mapSpec.points
267
+ ? joinResult.warnings.filter((w) => w.code !== 'UNMATCHED_FEATURES')
268
+ : joinResult.warnings;
269
+ compileWarnings.push(...filteredJoinWarnings);
270
+ }
267
271
 
268
272
  // 12. Check for null-projecting features (albersUsa drops territories)
269
273
  const droppedFeatures: string[] = [];
@@ -348,6 +352,7 @@ export function compileMap(spec: unknown, options: CompileOptions): MapLayout {
348
352
  pathGen,
349
353
  joined,
350
354
  colorField: colorEncoding.field,
355
+ colorScale: colorEncoding.scale,
351
356
  isDarkMode: !!isDarkMode,
352
357
  neutralFill,
353
358
  neutralStroke,
@@ -432,8 +437,17 @@ export function compileMap(spec: unknown, options: CompileOptions): MapLayout {
432
437
  };
433
438
  }
434
439
  } else {
435
- // nominal / ordinal
436
- const seen = new Set<string>();
440
+ // nominal / ordinal: honor scale.domain (category order) and scale.range (explicit colors)
441
+ const explicitDomain = pts.color.scale?.domain as string[] | undefined;
442
+ const explicitRange = pts.color.scale?.range as string[] | undefined;
443
+
444
+ if (explicitDomain && Array.isArray(explicitDomain)) {
445
+ for (const cat of explicitDomain) {
446
+ pointCategories.push(String(cat));
447
+ }
448
+ }
449
+ // Also pick up any categories in the data that aren't in the explicit domain
450
+ const seen = new Set<string>(pointCategories);
437
451
  for (const row of pts.data) {
438
452
  const raw = row[pts.color.field];
439
453
  if (raw != null) {
@@ -441,22 +455,27 @@ export function compileMap(spec: unknown, options: CompileOptions): MapLayout {
441
455
  if (!seen.has(cat)) {
442
456
  seen.add(cat);
443
457
  pointCategories.push(cat);
444
- pointCategoryColors.set(
445
- cat,
446
- CATEGORICAL_PALETTE[(pointCategories.length - 1) % CATEGORICAL_PALETTE.length],
447
- );
448
458
  }
449
459
  }
450
460
  }
461
+
462
+ const colorSource =
463
+ explicitRange && Array.isArray(explicitRange) ? explicitRange : CATEGORICAL_PALETTE;
464
+ for (let i = 0; i < pointCategories.length; i++) {
465
+ pointCategoryColors.set(pointCategories[i], colorSource[i % colorSource.length]);
466
+ }
467
+
451
468
  pointColorScale = (val: unknown) => {
452
- if (val == null) return CATEGORICAL_PALETTE[0];
453
- return pointCategoryColors.get(String(val)) ?? CATEGORICAL_PALETTE[0];
469
+ if (val == null) return colorSource[0];
470
+ return pointCategoryColors.get(String(val)) ?? colorSource[0];
454
471
  };
455
472
  }
456
473
  }
457
474
 
458
475
  const defaultFill = CATEGORICAL_PALETTE[0];
476
+ const pointOpacity = pts.opacity ?? 0.65;
459
477
  let animIndex = 0;
478
+ const droppedPoints: Array<[number, number]> = [];
460
479
 
461
480
  for (let i = 0; i < pts.data.length; i++) {
462
481
  const row = pts.data[i];
@@ -466,11 +485,7 @@ export function compileMap(spec: unknown, options: CompileOptions): MapLayout {
466
485
 
467
486
  const projected = projection([lon, lat]);
468
487
  if (!projected) {
469
- compileWarnings.push({
470
- code: 'POINT_NULL_PROJECTION',
471
- message: `Point at [${lon}, ${lat}] projects to null and was dropped.`,
472
- context: { lon, lat },
473
- });
488
+ droppedPoints.push([lon, lat]);
474
489
  continue;
475
490
  }
476
491
 
@@ -488,6 +503,7 @@ export function compileMap(spec: unknown, options: CompileOptions): MapLayout {
488
503
  fill,
489
504
  stroke,
490
505
  strokeWidth: 1,
506
+ fillOpacity: pointOpacity,
491
507
  key,
492
508
  data: row as Record<string, unknown>,
493
509
  aria: { role: 'img', label: key },
@@ -495,6 +511,21 @@ export function compileMap(spec: unknown, options: CompileOptions): MapLayout {
495
511
  });
496
512
  }
497
513
 
514
+ if (droppedPoints.length > 0) {
515
+ const sample = droppedPoints
516
+ .slice(0, 3)
517
+ .map(([lo, la]) => `[${lo}, ${la}]`)
518
+ .join(', ');
519
+ compileWarnings.push({
520
+ code: 'POINT_NULL_PROJECTION',
521
+ message:
522
+ `${droppedPoints.length} point(s) project to null and were dropped (e.g. ${sample}). ` +
523
+ 'This is common with albersUsa which excludes territories. ' +
524
+ 'Consider using projection: "mercator" or "equalEarth".',
525
+ context: { count: droppedPoints.length, samples: droppedPoints.slice(0, 5) },
526
+ });
527
+ }
528
+
498
529
  // Point categorical legend
499
530
  if (
500
531
  pts.color &&
@@ -516,19 +547,19 @@ export function compileMap(spec: unknown, options: CompileOptions): MapLayout {
516
547
  shape: 'circle' as const,
517
548
  }));
518
549
 
519
- // Position below the choropleth legend (or at the legend position if none)
520
- const choroplethLegendHeight = continuousLegend
521
- ? continuousLegend.bounds.height
522
- : categoricalLegend
523
- ? categoricalLegend.bounds.height
550
+ // Position below the map. Only offset by choropleth legend height when
551
+ // the choropleth legend is also at the bottom (not 'top').
552
+ const bottomChoroplethHeight =
553
+ legendPosition === 'bottom'
554
+ ? (continuousLegend?.bounds.height ?? categoricalLegend?.bounds.height ?? 0)
524
555
  : 0;
525
- const choroplethLegendGap = choroplethLegendHeight > 0 ? 8 : 0;
556
+ const choroplethLegendGap = bottomChoroplethHeight > 0 ? 8 : 0;
526
557
  const legendX = fullArea.x;
527
558
  const legendY =
528
559
  fullArea.y +
529
560
  mapAreaHeight +
530
561
  legendReserveGap +
531
- choroplethLegendHeight +
562
+ bottomChoroplethHeight +
532
563
  choroplethLegendGap;
533
564
  const legendWidth = fullArea.width;
534
565
  const swatchSize = 10;
@@ -577,19 +608,14 @@ export function compileMap(spec: unknown, options: CompileOptions): MapLayout {
577
608
  lineHeight: 1.3,
578
609
  fontVariant: 'tabular-nums',
579
610
  };
580
- const choroplethLegendHeight = continuousLegend
581
- ? continuousLegend.bounds.height
582
- : categoricalLegend
583
- ? categoricalLegend.bounds.height
611
+ const bottomChoroplethH =
612
+ legendPosition === 'bottom'
613
+ ? (continuousLegend?.bounds.height ?? categoricalLegend?.bounds.height ?? 0)
584
614
  : 0;
585
- const choroplethLegendGap = choroplethLegendHeight > 0 ? 8 : 0;
615
+ const choroplethLegendGap = bottomChoroplethH > 0 ? 8 : 0;
586
616
  const legendX = fullArea.x;
587
617
  const legendY =
588
- fullArea.y +
589
- mapAreaHeight +
590
- legendReserveGap +
591
- choroplethLegendHeight +
592
- choroplethLegendGap;
618
+ fullArea.y + mapAreaHeight + legendReserveGap + bottomChoroplethH + choroplethLegendGap;
593
619
  const ptLabelRowHeight = Math.ceil(theme.fonts.sizes.small * 1.3);
594
620
  const ptLegendHeight =
595
621
  ptContinuousContent.barHeight + CONTINUOUS_LABEL_GAP + ptLabelRowHeight;
@@ -735,7 +761,7 @@ export function compileMap(spec: unknown, options: CompileOptions): MapLayout {
735
761
  };
736
762
 
737
763
  // 18. Resolve focus
738
- const resolvedFocus = resolveFocus(mapSpec.geo.focus, featureMarks, compileWarnings);
764
+ const resolvedFocus = resolveFocus(mapSpec.geo.focus, featureMarks, pointMarks, compileWarnings);
739
765
 
740
766
  // 19. Animation
741
767
  const resolvedAnimation: ResolvedAnimation | undefined = resolveAnimation(mapSpec.animation);
@@ -976,6 +1002,7 @@ interface CategoricalOptions {
976
1002
  pathGen: ReturnType<typeof geoPath>;
977
1003
  joined: Map<string | number, Record<string, unknown>>;
978
1004
  colorField: string;
1005
+ colorScale?: { domain?: unknown; range?: unknown };
979
1006
  isDarkMode: boolean;
980
1007
  neutralFill: string;
981
1008
  neutralStroke: string;
@@ -996,6 +1023,7 @@ function buildCategoricalMarks(opts: CategoricalOptions): {
996
1023
  pathGen,
997
1024
  joined,
998
1025
  colorField,
1026
+ colorScale: scaleConfig,
999
1027
  isDarkMode,
1000
1028
  neutralFill,
1001
1029
  neutralStroke,
@@ -1007,9 +1035,15 @@ function buildCategoricalMarks(opts: CategoricalOptions): {
1007
1035
  droppedFeatures,
1008
1036
  } = opts;
1009
1037
 
1010
- // Collect unique categories in data order
1038
+ // Collect unique categories: honor scale.domain for order, then append any unseen from data
1011
1039
  const categories: string[] = [];
1012
- const seen = new Set<string>();
1040
+ const explicitDomain = scaleConfig?.domain;
1041
+ if (explicitDomain && Array.isArray(explicitDomain)) {
1042
+ for (const cat of explicitDomain) {
1043
+ categories.push(String(cat));
1044
+ }
1045
+ }
1046
+ const seen = new Set<string>(categories);
1013
1047
  for (const row of joined.values()) {
1014
1048
  const raw = row[colorField];
1015
1049
  if (raw != null) {
@@ -1021,9 +1055,12 @@ function buildCategoricalMarks(opts: CategoricalOptions): {
1021
1055
  }
1022
1056
  }
1023
1057
 
1058
+ const explicitRange = scaleConfig?.range;
1059
+ const colorSource =
1060
+ explicitRange && Array.isArray(explicitRange) ? explicitRange : CATEGORICAL_PALETTE;
1024
1061
  const categoryColors = new Map<string, string>();
1025
1062
  for (let i = 0; i < categories.length; i++) {
1026
- categoryColors.set(categories[i], CATEGORICAL_PALETTE[i % CATEGORICAL_PALETTE.length]);
1063
+ categoryColors.set(categories[i], colorSource[i % colorSource.length]);
1027
1064
  }
1028
1065
 
1029
1066
  const marks: MapFeatureMark[] = [];
@@ -1135,10 +1172,54 @@ function buildCategoricalMarks(opts: CategoricalOptions): {
1135
1172
  function resolveFocus(
1136
1173
  focus: MapFocus | null,
1137
1174
  features: MapFeatureMark[],
1175
+ pointMarks: MapPointMark[],
1138
1176
  warnings: CompileWarning[],
1139
1177
  ): MapFocusLayout | null {
1140
1178
  if (focus === null || focus === undefined) return null;
1141
1179
 
1180
+ // Points form fits the union of the point layer's circle bounds (cx +/- r)
1181
+ // rather than any feature. Use it when the points cluster in a small part of a
1182
+ // large feature, so fitting the feature would leave the cluster small and
1183
+ // off-center. `points: true` fits every point; `points: { field, value }`
1184
+ // fits only the matching subset, so a story can pan between sub-clusters.
1185
+ if (typeof focus === 'object' && !Array.isArray(focus) && 'points' in focus) {
1186
+ const filter = focus.points;
1187
+ const matched =
1188
+ filter === true
1189
+ ? pointMarks
1190
+ : pointMarks.filter((p) => p.data[filter.field] === filter.value);
1191
+
1192
+ if (matched.length === 0) {
1193
+ const detail =
1194
+ filter === true
1195
+ ? 'the map has no points to fit'
1196
+ : `no points match { field: "${filter.field}", value: ${JSON.stringify(filter.value)} }`;
1197
+ warnings.push({
1198
+ code: 'FOCUS_UNMATCHED',
1199
+ message: `geo.focus.points is set but ${detail}`,
1200
+ context: {},
1201
+ });
1202
+ return null;
1203
+ }
1204
+ const padding = focus.padding ?? 16;
1205
+ let minX = Infinity;
1206
+ let minY = Infinity;
1207
+ let maxX = -Infinity;
1208
+ let maxY = -Infinity;
1209
+ 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);
1214
+ }
1215
+ return {
1216
+ target: { x: minX, y: minY, width: maxX - minX, height: maxY - minY, padding },
1217
+ // No feature ids: focus-dim is a feature concept, so a points focus
1218
+ // dims no features (there are no "other" features to mute).
1219
+ ids: [],
1220
+ };
1221
+ }
1222
+
1142
1223
  // Normalize to { ids, padding }
1143
1224
  let ids: Array<string | number>;
1144
1225
  let padding = 16;