@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/dist/index.js +145 -53
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/compiler/validate.ts +62 -20
- package/src/geo/__tests__/compile-map.test.ts +123 -0
- package/src/geo/compile-map.ts +132 -51
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.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.
|
|
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",
|
package/src/compiler/validate.ts
CHANGED
|
@@ -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 (
|
|
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.
|
|
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,
|
|
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
|
|
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 (!
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
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
|
{
|
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;
|
|
@@ -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
|
|
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[] = [];
|
|
@@ -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;
|