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

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.4",
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.4",
52
52
  "d3-array": "^3.2.0",
53
53
  "d3-format": "^3.1.2",
54
54
  "d3-interpolate": "^3.0.0",
@@ -1418,22 +1418,26 @@ function validateMapSpec(spec: Record<string, unknown>, errors: ValidationError[
1418
1418
  const encoding = spec.encoding as Record<string, unknown>;
1419
1419
  const hasPoints = spec.points && typeof spec.points === 'object' && !Array.isArray(spec.points);
1420
1420
 
1421
- // key is always required
1421
+ // key is required unless basemap-only mode (points present + empty/no choropleth data)
1422
+ const specData = spec.data as unknown[] | undefined;
1423
+ const isBasemapOnly = hasPoints && (!specData || specData.length === 0);
1422
1424
  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
- });
1425
+ if (!isBasemapOnly) {
1426
+ if (!keyCh || typeof keyCh !== 'object') {
1427
+ errors.push({
1428
+ message: 'Spec error: map encoding requires "key" channel',
1429
+ path: 'encoding.key',
1430
+ code: 'MISSING_FIELD',
1431
+ suggestion: 'Add encoding.key, e.g. key: { field: "id", type: "nominal" }',
1432
+ });
1433
+ } else if (!keyCh.field || typeof keyCh.field !== 'string') {
1434
+ errors.push({
1435
+ message: 'Spec error: encoding.key must have a "field" string',
1436
+ path: 'encoding.key.field',
1437
+ code: 'MISSING_FIELD',
1438
+ suggestion: 'Add a field name, e.g. key: { field: "id" }',
1439
+ });
1440
+ }
1437
1441
  }
1438
1442
 
1439
1443
  // color is required UNLESS points is present (basemap-only mode)
@@ -1497,6 +1501,17 @@ function validateMapSpec(spec: Record<string, unknown>, errors: ValidationError[
1497
1501
  });
1498
1502
  }
1499
1503
  }
1504
+ for (const ch of ['size', 'color', 'key'] as const) {
1505
+ const enc = points[ch] as Record<string, unknown> | undefined;
1506
+ if (enc && typeof enc === 'object' && (!enc.field || typeof enc.field !== 'string')) {
1507
+ errors.push({
1508
+ message: `Spec error: points.${ch} must have a "field" string when provided`,
1509
+ path: `points.${ch}.field`,
1510
+ code: 'MISSING_FIELD',
1511
+ suggestion: `Add a field name, e.g. ${ch}: { field: "myField" }`,
1512
+ });
1513
+ }
1514
+ }
1500
1515
  }
1501
1516
  }
1502
1517
 
@@ -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;
@@ -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[] = [];