@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/dist/index.js +89 -46
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/compiler/validate.ts +30 -15
- package/src/geo/compile-map.ts +87 -50
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@opendata-ai/openchart-engine",
|
|
3
|
-
"version": "8.0.0-rc.
|
|
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.
|
|
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",
|
package/src/compiler/validate.ts
CHANGED
|
@@ -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
|
|
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 (!
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
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
|
|
package/src/geo/compile-map.ts
CHANGED
|
@@ -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
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
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
|
|
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
|
|
453
|
-
return pointCategoryColors.get(String(val)) ??
|
|
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
|
-
|
|
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
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
? categoricalLegend
|
|
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 =
|
|
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
|
-
|
|
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
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
? categoricalLegend.bounds.height
|
|
611
|
+
const bottomChoroplethH =
|
|
612
|
+
legendPosition === 'bottom'
|
|
613
|
+
? (continuousLegend?.bounds.height ?? categoricalLegend?.bounds.height ?? 0)
|
|
584
614
|
: 0;
|
|
585
|
-
const choroplethLegendGap =
|
|
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
|
|
1038
|
+
// Collect unique categories: honor scale.domain for order, then append any unseen from data
|
|
1011
1039
|
const categories: string[] = [];
|
|
1012
|
-
const
|
|
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],
|
|
1063
|
+
categoryColors.set(categories[i], colorSource[i % colorSource.length]);
|
|
1027
1064
|
}
|
|
1028
1065
|
|
|
1029
1066
|
const marks: MapFeatureMark[] = [];
|