@opendata-ai/openchart-engine 8.0.0-rc.2 → 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.d.ts +2 -1
- package/dist/index.js +428 -36
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/compile/size-scale.ts +2 -0
- package/src/compiler/normalize.ts +1 -0
- package/src/compiler/validate.ts +87 -11
- package/src/geo/__tests__/compile-map.test.ts +211 -0
- package/src/geo/compile-map.ts +453 -29
- package/src/geo/types.ts +2 -0
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",
|
|
@@ -45,6 +45,8 @@ export const SIZE_SCALE_DEFAULTS = {
|
|
|
45
45
|
* linear in font size, where a disc reads by its area.
|
|
46
46
|
*/
|
|
47
47
|
text: { curve: 'linear', range: [11, 32] },
|
|
48
|
+
/** Map point dots. sqrt for area-proportional, tighter range than scatter. */
|
|
49
|
+
mapPoint: { curve: 'sqrt', range: [3, 20] },
|
|
48
50
|
} as const satisfies Record<string, { curve: SizeCurve; range: readonly [number, number] }>;
|
|
49
51
|
|
|
50
52
|
/** Size-scale options for a mark, or null when the mark has no size channel. */
|
package/src/compiler/validate.ts
CHANGED
|
@@ -1416,26 +1416,102 @@ function validateMapSpec(spec: Record<string, unknown>, errors: ValidationError[
|
|
|
1416
1416
|
}
|
|
1417
1417
|
|
|
1418
1418
|
const encoding = spec.encoding as Record<string, unknown>;
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1419
|
+
const hasPoints = spec.points && typeof spec.points === 'object' && !Array.isArray(spec.points);
|
|
1420
|
+
|
|
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);
|
|
1424
|
+
const keyCh = encoding.key as Record<string, unknown> | undefined;
|
|
1425
|
+
if (!isBasemapOnly) {
|
|
1426
|
+
if (!keyCh || typeof keyCh !== 'object') {
|
|
1422
1427
|
errors.push({
|
|
1423
|
-
message:
|
|
1424
|
-
path:
|
|
1428
|
+
message: 'Spec error: map encoding requires "key" channel',
|
|
1429
|
+
path: 'encoding.key',
|
|
1425
1430
|
code: 'MISSING_FIELD',
|
|
1426
|
-
suggestion:
|
|
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" }',
|
|
1427
1439
|
});
|
|
1428
|
-
continue;
|
|
1429
1440
|
}
|
|
1441
|
+
}
|
|
1430
1442
|
|
|
1431
|
-
|
|
1443
|
+
// color is required UNLESS points is present (basemap-only mode)
|
|
1444
|
+
const colorCh = encoding.color as Record<string, unknown> | undefined;
|
|
1445
|
+
if (!hasPoints) {
|
|
1446
|
+
if (!colorCh || typeof colorCh !== 'object') {
|
|
1432
1447
|
errors.push({
|
|
1433
|
-
message:
|
|
1434
|
-
|
|
1448
|
+
message:
|
|
1449
|
+
'Spec error: map encoding requires "color" channel (or add a "points" layer for basemap-only mode)',
|
|
1450
|
+
path: 'encoding.color',
|
|
1451
|
+
code: 'MISSING_FIELD',
|
|
1452
|
+
suggestion: 'Add encoding.color, e.g. color: { field: "value", type: "quantitative" }',
|
|
1453
|
+
});
|
|
1454
|
+
} else if (!colorCh.field || typeof colorCh.field !== 'string') {
|
|
1455
|
+
errors.push({
|
|
1456
|
+
message: 'Spec error: encoding.color must have a "field" string',
|
|
1457
|
+
path: 'encoding.color.field',
|
|
1435
1458
|
code: 'MISSING_FIELD',
|
|
1436
|
-
suggestion:
|
|
1459
|
+
suggestion: 'Add a field name, e.g. color: { field: "value" }',
|
|
1460
|
+
});
|
|
1461
|
+
}
|
|
1462
|
+
} else if (
|
|
1463
|
+
colorCh &&
|
|
1464
|
+
typeof colorCh === 'object' &&
|
|
1465
|
+
(!colorCh.field || typeof colorCh.field !== 'string')
|
|
1466
|
+
) {
|
|
1467
|
+
errors.push({
|
|
1468
|
+
message: 'Spec error: encoding.color must have a "field" string when provided',
|
|
1469
|
+
path: 'encoding.color.field',
|
|
1470
|
+
code: 'MISSING_FIELD',
|
|
1471
|
+
suggestion: 'Add a field name, e.g. color: { field: "value" }',
|
|
1472
|
+
});
|
|
1473
|
+
}
|
|
1474
|
+
|
|
1475
|
+
// Validate points layer if present
|
|
1476
|
+
if (hasPoints) {
|
|
1477
|
+
const points = spec.points as Record<string, unknown>;
|
|
1478
|
+
if (!Array.isArray(points.data)) {
|
|
1479
|
+
errors.push({
|
|
1480
|
+
message: 'Spec error: points.data must be an array of data rows',
|
|
1481
|
+
path: 'points.data',
|
|
1482
|
+
code: 'INVALID_TYPE',
|
|
1483
|
+
suggestion: 'Provide data as an array of objects with lat/lng fields',
|
|
1437
1484
|
});
|
|
1438
1485
|
}
|
|
1486
|
+
for (const ch of ['longitude', 'latitude'] as const) {
|
|
1487
|
+
const enc = points[ch] as Record<string, unknown> | undefined;
|
|
1488
|
+
if (!enc || typeof enc !== 'object') {
|
|
1489
|
+
errors.push({
|
|
1490
|
+
message: `Spec error: points requires "${ch}" encoding channel`,
|
|
1491
|
+
path: `points.${ch}`,
|
|
1492
|
+
code: 'MISSING_FIELD',
|
|
1493
|
+
suggestion: `Add points.${ch}, e.g. ${ch}: { field: "${ch === 'longitude' ? 'lon' : 'lat'}", type: "quantitative" }`,
|
|
1494
|
+
});
|
|
1495
|
+
} else if (!enc.field || typeof enc.field !== 'string') {
|
|
1496
|
+
errors.push({
|
|
1497
|
+
message: `Spec error: points.${ch} must have a "field" string`,
|
|
1498
|
+
path: `points.${ch}.field`,
|
|
1499
|
+
code: 'MISSING_FIELD',
|
|
1500
|
+
suggestion: `Add a field name, e.g. ${ch}: { field: "${ch === 'longitude' ? 'lon' : 'lat'}" }`,
|
|
1501
|
+
});
|
|
1502
|
+
}
|
|
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
|
+
}
|
|
1439
1515
|
}
|
|
1440
1516
|
}
|
|
1441
1517
|
|
|
@@ -618,4 +618,215 @@ describe('compileMap', () => {
|
|
|
618
618
|
|
|
619
619
|
expect(layout.focus).toBeNull();
|
|
620
620
|
});
|
|
621
|
+
|
|
622
|
+
// ---------------------------------------------------------------------------
|
|
623
|
+
// Point layer
|
|
624
|
+
// ---------------------------------------------------------------------------
|
|
625
|
+
|
|
626
|
+
describe('point layer', () => {
|
|
627
|
+
it('compiles points through projection', () => {
|
|
628
|
+
const layout = compileMap(
|
|
629
|
+
{
|
|
630
|
+
type: 'map',
|
|
631
|
+
geo: { features: MINI_TOPO, projection: 'mercator' },
|
|
632
|
+
data: [],
|
|
633
|
+
encoding: { key: { field: 'id', type: 'nominal' } },
|
|
634
|
+
points: {
|
|
635
|
+
data: [
|
|
636
|
+
{ lat: 34, lon: -118, name: 'LA' },
|
|
637
|
+
{ lat: 40.7, lon: -74, name: 'NYC' },
|
|
638
|
+
],
|
|
639
|
+
longitude: { field: 'lon', type: 'quantitative' },
|
|
640
|
+
latitude: { field: 'lat', type: 'quantitative' },
|
|
641
|
+
key: { field: 'name', type: 'nominal' },
|
|
642
|
+
},
|
|
643
|
+
},
|
|
644
|
+
DEFAULT_OPTIONS,
|
|
645
|
+
);
|
|
646
|
+
|
|
647
|
+
expect(layout.pointMarks).toHaveLength(2);
|
|
648
|
+
for (const pm of layout.pointMarks) {
|
|
649
|
+
expect(Number.isFinite(pm.cx)).toBe(true);
|
|
650
|
+
expect(Number.isFinite(pm.cy)).toBe(true);
|
|
651
|
+
}
|
|
652
|
+
});
|
|
653
|
+
|
|
654
|
+
it('drops null-projecting points with warning', () => {
|
|
655
|
+
const layout = compileMap(
|
|
656
|
+
{
|
|
657
|
+
type: 'map',
|
|
658
|
+
geo: { features: MINI_TOPO, projection: 'albersUsa' },
|
|
659
|
+
data: [],
|
|
660
|
+
encoding: { key: { field: 'id', type: 'nominal' } },
|
|
661
|
+
points: {
|
|
662
|
+
data: [{ lat: 0, lon: 0, name: 'Pacific' }],
|
|
663
|
+
longitude: { field: 'lon', type: 'quantitative' },
|
|
664
|
+
latitude: { field: 'lat', type: 'quantitative' },
|
|
665
|
+
key: { field: 'name', type: 'nominal' },
|
|
666
|
+
},
|
|
667
|
+
},
|
|
668
|
+
DEFAULT_OPTIONS,
|
|
669
|
+
);
|
|
670
|
+
|
|
671
|
+
expect(layout.pointMarks).toHaveLength(0);
|
|
672
|
+
const warning = layout.warnings.find((w) => w.code === 'POINT_NULL_PROJECTION');
|
|
673
|
+
expect(warning).toBeDefined();
|
|
674
|
+
});
|
|
675
|
+
|
|
676
|
+
it('builds sqrt size scale for points', () => {
|
|
677
|
+
const layout = compileMap(
|
|
678
|
+
{
|
|
679
|
+
type: 'map',
|
|
680
|
+
geo: { features: MINI_TOPO, projection: 'mercator' },
|
|
681
|
+
data: [],
|
|
682
|
+
encoding: { key: { field: 'id', type: 'nominal' } },
|
|
683
|
+
points: {
|
|
684
|
+
data: [
|
|
685
|
+
{ lat: 34, lon: -118, name: 'A', value: 10 },
|
|
686
|
+
{ lat: 40.7, lon: -74, name: 'B', value: 1000 },
|
|
687
|
+
],
|
|
688
|
+
longitude: { field: 'lon', type: 'quantitative' },
|
|
689
|
+
latitude: { field: 'lat', type: 'quantitative' },
|
|
690
|
+
size: { field: 'value', type: 'quantitative' },
|
|
691
|
+
key: { field: 'name', type: 'nominal' },
|
|
692
|
+
},
|
|
693
|
+
},
|
|
694
|
+
DEFAULT_OPTIONS,
|
|
695
|
+
);
|
|
696
|
+
|
|
697
|
+
expect(layout.pointMarks).toHaveLength(2);
|
|
698
|
+
const radii = layout.pointMarks.map((pm) => pm.r);
|
|
699
|
+
expect(radii[0]).not.toBe(radii[1]);
|
|
700
|
+
// Map point size scale range is [3, 20]
|
|
701
|
+
for (const r of radii) {
|
|
702
|
+
expect(r).toBeGreaterThanOrEqual(3);
|
|
703
|
+
expect(r).toBeLessThanOrEqual(20);
|
|
704
|
+
}
|
|
705
|
+
});
|
|
706
|
+
|
|
707
|
+
it('builds independent categorical color scale', () => {
|
|
708
|
+
const layout = compileMap(
|
|
709
|
+
{
|
|
710
|
+
type: 'map',
|
|
711
|
+
geo: { features: MINI_TOPO, projection: 'mercator' },
|
|
712
|
+
data: [],
|
|
713
|
+
encoding: { key: { field: 'id', type: 'nominal' } },
|
|
714
|
+
points: {
|
|
715
|
+
data: [
|
|
716
|
+
{ lat: 34, lon: -118, name: 'A', cat: 'x' },
|
|
717
|
+
{ lat: 40.7, lon: -74, name: 'B', cat: 'y' },
|
|
718
|
+
{ lat: 37, lon: -100, name: 'C', cat: 'z' },
|
|
719
|
+
],
|
|
720
|
+
longitude: { field: 'lon', type: 'quantitative' },
|
|
721
|
+
latitude: { field: 'lat', type: 'quantitative' },
|
|
722
|
+
color: { field: 'cat', type: 'nominal' },
|
|
723
|
+
key: { field: 'name', type: 'nominal' },
|
|
724
|
+
},
|
|
725
|
+
},
|
|
726
|
+
DEFAULT_OPTIONS,
|
|
727
|
+
);
|
|
728
|
+
|
|
729
|
+
expect(layout.pointMarks).toHaveLength(3);
|
|
730
|
+
const fills = layout.pointMarks.map((pm) => pm.fill);
|
|
731
|
+
// 3 different categories should produce 3 different colors
|
|
732
|
+
expect(new Set(fills).size).toBe(3);
|
|
733
|
+
});
|
|
734
|
+
|
|
735
|
+
it('basemap-only: neutral fill when no encoding.color', () => {
|
|
736
|
+
const layout = compileMap(
|
|
737
|
+
{
|
|
738
|
+
type: 'map',
|
|
739
|
+
geo: { features: MINI_TOPO, projection: 'mercator' },
|
|
740
|
+
data: [],
|
|
741
|
+
encoding: { key: { field: 'id', type: 'nominal' } },
|
|
742
|
+
points: {
|
|
743
|
+
data: [{ lat: 34, lon: -118, name: 'LA' }],
|
|
744
|
+
longitude: { field: 'lon', type: 'quantitative' },
|
|
745
|
+
latitude: { field: 'lat', type: 'quantitative' },
|
|
746
|
+
key: { field: 'name', type: 'nominal' },
|
|
747
|
+
},
|
|
748
|
+
},
|
|
749
|
+
DEFAULT_OPTIONS,
|
|
750
|
+
);
|
|
751
|
+
|
|
752
|
+
// Features should all have the neutral fill (#e8e8e8 in light mode)
|
|
753
|
+
for (const f of layout.features) {
|
|
754
|
+
expect(f.fill).toBe('#e8e8e8');
|
|
755
|
+
}
|
|
756
|
+
});
|
|
757
|
+
|
|
758
|
+
it('generates point tooltips with point: prefix', () => {
|
|
759
|
+
const layout = compileMap(
|
|
760
|
+
{
|
|
761
|
+
type: 'map',
|
|
762
|
+
geo: { features: MINI_TOPO, projection: 'mercator' },
|
|
763
|
+
data: [],
|
|
764
|
+
encoding: { key: { field: 'id', type: 'nominal' } },
|
|
765
|
+
points: {
|
|
766
|
+
data: [
|
|
767
|
+
{ lat: 34, lon: -118, name: 'LA', value: 100 },
|
|
768
|
+
{ lat: 40.7, lon: -74, name: 'NYC', value: 200 },
|
|
769
|
+
],
|
|
770
|
+
longitude: { field: 'lon', type: 'quantitative' },
|
|
771
|
+
latitude: { field: 'lat', type: 'quantitative' },
|
|
772
|
+
tooltip: [
|
|
773
|
+
{ field: 'name', type: 'nominal', title: 'City' },
|
|
774
|
+
{ field: 'value', type: 'quantitative', title: 'Pop' },
|
|
775
|
+
],
|
|
776
|
+
key: { field: 'name', type: 'nominal' },
|
|
777
|
+
},
|
|
778
|
+
},
|
|
779
|
+
DEFAULT_OPTIONS,
|
|
780
|
+
);
|
|
781
|
+
|
|
782
|
+
const pointKeys = [...layout.tooltipDescriptors.keys()].filter((k) => k.startsWith('point:'));
|
|
783
|
+
expect(pointKeys).toHaveLength(2);
|
|
784
|
+
expect(pointKeys).toContain('point:LA');
|
|
785
|
+
expect(pointKeys).toContain('point:NYC');
|
|
786
|
+
|
|
787
|
+
const laTooltip = layout.tooltipDescriptors.get('point:LA');
|
|
788
|
+
expect(laTooltip).toBeDefined();
|
|
789
|
+
expect(laTooltip!.fields).toHaveLength(2);
|
|
790
|
+
expect(laTooltip!.fields[0].label).toBe('City');
|
|
791
|
+
});
|
|
792
|
+
|
|
793
|
+
it('handles points alongside choropleth', () => {
|
|
794
|
+
const layout = compileMap(
|
|
795
|
+
{
|
|
796
|
+
type: 'map',
|
|
797
|
+
geo: { features: MINI_TOPO, projection: 'mercator' },
|
|
798
|
+
data: [
|
|
799
|
+
{ fips: '06', value: 10 },
|
|
800
|
+
{ fips: '48', value: 20 },
|
|
801
|
+
],
|
|
802
|
+
encoding: {
|
|
803
|
+
key: { field: 'fips', type: 'nominal' },
|
|
804
|
+
color: { field: 'value', type: 'quantitative' },
|
|
805
|
+
},
|
|
806
|
+
points: {
|
|
807
|
+
data: [
|
|
808
|
+
{ lat: 34, lon: -118, name: 'LA', pop: 400 },
|
|
809
|
+
{ lat: 31, lon: -100, name: 'TX City', pop: 200 },
|
|
810
|
+
],
|
|
811
|
+
longitude: { field: 'lon', type: 'quantitative' },
|
|
812
|
+
latitude: { field: 'lat', type: 'quantitative' },
|
|
813
|
+
key: { field: 'name', type: 'nominal' },
|
|
814
|
+
},
|
|
815
|
+
},
|
|
816
|
+
DEFAULT_OPTIONS,
|
|
817
|
+
);
|
|
818
|
+
|
|
819
|
+
// Choropleth features should have color-encoded fills
|
|
820
|
+
const matchedFeature = layout.features.find((f) => f.id === '06');
|
|
821
|
+
expect(matchedFeature).toBeDefined();
|
|
822
|
+
expect(matchedFeature!.fill).not.toBe('#e8e8e8');
|
|
823
|
+
|
|
824
|
+
// Point marks should also be present
|
|
825
|
+
expect(layout.pointMarks).toHaveLength(2);
|
|
826
|
+
for (const pm of layout.pointMarks) {
|
|
827
|
+
expect(Number.isFinite(pm.cx)).toBe(true);
|
|
828
|
+
expect(Number.isFinite(pm.cy)).toBe(true);
|
|
829
|
+
}
|
|
830
|
+
});
|
|
831
|
+
});
|
|
621
832
|
});
|