@opendata-ai/openchart-engine 8.0.0-rc.4 → 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.4",
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.4",
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
  }
@@ -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
  {
@@ -761,7 +761,7 @@ export function compileMap(spec: unknown, options: CompileOptions): MapLayout {
761
761
  };
762
762
 
763
763
  // 18. Resolve focus
764
- const resolvedFocus = resolveFocus(mapSpec.geo.focus, featureMarks, compileWarnings);
764
+ const resolvedFocus = resolveFocus(mapSpec.geo.focus, featureMarks, pointMarks, compileWarnings);
765
765
 
766
766
  // 19. Animation
767
767
  const resolvedAnimation: ResolvedAnimation | undefined = resolveAnimation(mapSpec.animation);
@@ -1172,10 +1172,54 @@ function buildCategoricalMarks(opts: CategoricalOptions): {
1172
1172
  function resolveFocus(
1173
1173
  focus: MapFocus | null,
1174
1174
  features: MapFeatureMark[],
1175
+ pointMarks: MapPointMark[],
1175
1176
  warnings: CompileWarning[],
1176
1177
  ): MapFocusLayout | null {
1177
1178
  if (focus === null || focus === undefined) return null;
1178
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
+
1179
1223
  // Normalize to { ids, padding }
1180
1224
  let ids: Array<string | number>;
1181
1225
  let padding = 16;