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

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.6",
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.6",
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
  }
@@ -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
  {
@@ -582,6 +631,129 @@ describe('compileMap', () => {
582
631
  expect(layout.focus).toBeNull();
583
632
  });
584
633
 
634
+ it('geo.focus { points: true } fits the point cluster, not any feature', () => {
635
+ const layout = compileMap(
636
+ {
637
+ type: 'map',
638
+ geo: {
639
+ features: MINI_TOPO,
640
+ projection: 'mercator',
641
+ focus: { points: true, padding: 4 },
642
+ },
643
+ data: [],
644
+ encoding: { key: { field: 'id', type: 'nominal' } },
645
+ points: {
646
+ data: [
647
+ { lat: 34, lon: -118, name: 'LA' },
648
+ { lat: 40.7, lon: -74, name: 'NYC' },
649
+ ],
650
+ longitude: { field: 'lon', type: 'quantitative' },
651
+ latitude: { field: 'lat', type: 'quantitative' },
652
+ key: { field: 'name', type: 'nominal' },
653
+ },
654
+ },
655
+ DEFAULT_OPTIONS,
656
+ );
657
+
658
+ expect(layout.focus).not.toBeNull();
659
+ expect(layout.focus!.target.padding).toBe(4);
660
+ // No feature ids: an all-points focus dims no features.
661
+ expect(layout.focus!.ids).toEqual([]);
662
+ // The target spans the two points (plus their radii), which sit far apart,
663
+ // so it has real width and height.
664
+ expect(layout.focus!.target.width).toBeGreaterThan(0);
665
+ expect(layout.focus!.target.height).toBeGreaterThan(0);
666
+ // The fitted cluster must be smaller than the full map extent.
667
+ expect(layout.focus!.target.width).toBeLessThan(layout.mapSize.width);
668
+ });
669
+
670
+ it('geo.focus { points: { field, value } } fits only the matching subset', () => {
671
+ const opts = {
672
+ data: [
673
+ { lat: 34, lon: -118, name: 'LA', rating: 'A' },
674
+ { lat: 40.7, lon: -74, name: 'NYC', rating: 'F' },
675
+ ],
676
+ longitude: { field: 'lon', type: 'quantitative' as const },
677
+ latitude: { field: 'lat', type: 'quantitative' as const },
678
+ key: { field: 'name', type: 'nominal' as const },
679
+ };
680
+ const all = compileMap(
681
+ {
682
+ type: 'map',
683
+ geo: { features: MINI_TOPO, projection: 'mercator', focus: { points: true } },
684
+ data: [],
685
+ encoding: { key: { field: 'id', type: 'nominal' } },
686
+ points: opts,
687
+ },
688
+ DEFAULT_OPTIONS,
689
+ );
690
+ const subset = compileMap(
691
+ {
692
+ type: 'map',
693
+ geo: {
694
+ features: MINI_TOPO,
695
+ projection: 'mercator',
696
+ focus: { points: { field: 'rating', value: 'A' } },
697
+ },
698
+ data: [],
699
+ encoding: { key: { field: 'id', type: 'nominal' } },
700
+ points: opts,
701
+ },
702
+ DEFAULT_OPTIONS,
703
+ );
704
+
705
+ expect(subset.focus).not.toBeNull();
706
+ // One point vs two: the subset target is strictly smaller than the all-points target.
707
+ expect(subset.focus!.target.width).toBeLessThan(all.focus!.target.width);
708
+ });
709
+
710
+ it('geo.focus { points: { field, value } } with no match emits FOCUS_UNMATCHED', () => {
711
+ const layout = compileMap(
712
+ {
713
+ type: 'map',
714
+ geo: {
715
+ features: MINI_TOPO,
716
+ projection: 'mercator',
717
+ focus: { points: { field: 'rating', value: 'Z' } },
718
+ },
719
+ data: [],
720
+ encoding: { key: { field: 'id', type: 'nominal' } },
721
+ points: {
722
+ data: [{ lat: 34, lon: -118, name: 'LA', rating: 'A' }],
723
+ longitude: { field: 'lon', type: 'quantitative' },
724
+ latitude: { field: 'lat', type: 'quantitative' },
725
+ key: { field: 'name', type: 'nominal' },
726
+ },
727
+ },
728
+ DEFAULT_OPTIONS,
729
+ );
730
+
731
+ expect(layout.focus).toBeNull();
732
+ expect(layout.warnings.some((w) => w.code === 'FOCUS_UNMATCHED')).toBe(true);
733
+ });
734
+
735
+ it('geo.focus { points: true } with no points emits FOCUS_UNMATCHED and no focus', () => {
736
+ const layout = compileMap(
737
+ {
738
+ type: 'map',
739
+ geo: {
740
+ features: MINI_TOPO,
741
+ projection: 'mercator',
742
+ focus: { points: true },
743
+ },
744
+ data: [{ fips: '06', value: 10 }],
745
+ encoding: {
746
+ key: { field: 'fips', type: 'nominal' },
747
+ color: { field: 'value', type: 'quantitative' },
748
+ },
749
+ },
750
+ DEFAULT_OPTIONS,
751
+ );
752
+
753
+ expect(layout.focus).toBeNull();
754
+ expect(layout.warnings.some((w) => w.code === 'FOCUS_UNMATCHED')).toBe(true);
755
+ });
756
+
585
757
  it('unknown focus id emits FOCUS_UNMATCHED warning', () => {
586
758
  const layout = compileMap(
587
759
  {
@@ -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
  });
@@ -761,7 +762,7 @@ export function compileMap(spec: unknown, options: CompileOptions): MapLayout {
761
762
  };
762
763
 
763
764
  // 18. Resolve focus
764
- const resolvedFocus = resolveFocus(mapSpec.geo.focus, featureMarks, compileWarnings);
765
+ const resolvedFocus = resolveFocus(mapSpec.geo.focus, featureMarks, pointMarks, compileWarnings);
765
766
 
766
767
  // 19. Animation
767
768
  const resolvedAnimation: ResolvedAnimation | undefined = resolveAnimation(mapSpec.animation);
@@ -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,
@@ -1172,10 +1182,54 @@ function buildCategoricalMarks(opts: CategoricalOptions): {
1172
1182
  function resolveFocus(
1173
1183
  focus: MapFocus | null,
1174
1184
  features: MapFeatureMark[],
1185
+ pointMarks: MapPointMark[],
1175
1186
  warnings: CompileWarning[],
1176
1187
  ): MapFocusLayout | null {
1177
1188
  if (focus === null || focus === undefined) return null;
1178
1189
 
1190
+ // Points form fits the union of the point layer's circle bounds (cx +/- r)
1191
+ // rather than any feature. Use it when the points cluster in a small part of a
1192
+ // large feature, so fitting the feature would leave the cluster small and
1193
+ // off-center. `points: true` fits every point; `points: { field, value }`
1194
+ // fits only the matching subset, so a story can pan between sub-clusters.
1195
+ if (typeof focus === 'object' && !Array.isArray(focus) && 'points' in focus) {
1196
+ const filter = focus.points;
1197
+ const matched =
1198
+ filter === true
1199
+ ? pointMarks
1200
+ : pointMarks.filter((p) => p.data[filter.field] === filter.value);
1201
+
1202
+ if (matched.length === 0) {
1203
+ const detail =
1204
+ filter === true
1205
+ ? 'the map has no points to fit'
1206
+ : `no points match { field: "${filter.field}", value: ${JSON.stringify(filter.value)} }`;
1207
+ warnings.push({
1208
+ code: 'FOCUS_UNMATCHED',
1209
+ message: `geo.focus.points is set but ${detail}`,
1210
+ context: {},
1211
+ });
1212
+ return null;
1213
+ }
1214
+ const padding = focus.padding ?? 16;
1215
+ let minX = Infinity;
1216
+ let minY = Infinity;
1217
+ let maxX = -Infinity;
1218
+ let maxY = -Infinity;
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);
1224
+ }
1225
+ return {
1226
+ target: { x: minX, y: minY, width: maxX - minX, height: maxY - minY, padding },
1227
+ // No feature ids: focus-dim is a feature concept, so a points focus
1228
+ // dims no features (there are no "other" features to mute).
1229
+ ids: [],
1230
+ };
1231
+ }
1232
+
1179
1233
  // Normalize to { ids, padding }
1180
1234
  let ids: Array<string | number>;
1181
1235
  let padding = 16;