@opendata-ai/openchart-engine 8.0.0-rc.2 → 8.0.0-rc.3
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 +374 -25
- 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 +73 -12
- package/src/geo/__tests__/compile-map.test.ts +211 -0
- package/src/geo/compile-map.ts +402 -15
- 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.3",
|
|
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.3",
|
|
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,87 @@ 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 always required
|
|
1422
|
+
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
|
+
});
|
|
1437
|
+
}
|
|
1438
|
+
|
|
1439
|
+
// color is required UNLESS points is present (basemap-only mode)
|
|
1440
|
+
const colorCh = encoding.color as Record<string, unknown> | undefined;
|
|
1441
|
+
if (!hasPoints) {
|
|
1442
|
+
if (!colorCh || typeof colorCh !== 'object') {
|
|
1422
1443
|
errors.push({
|
|
1423
|
-
message:
|
|
1424
|
-
|
|
1444
|
+
message:
|
|
1445
|
+
'Spec error: map encoding requires "color" channel (or add a "points" layer for basemap-only mode)',
|
|
1446
|
+
path: 'encoding.color',
|
|
1425
1447
|
code: 'MISSING_FIELD',
|
|
1426
|
-
suggestion:
|
|
1448
|
+
suggestion: 'Add encoding.color, e.g. color: { field: "value", type: "quantitative" }',
|
|
1449
|
+
});
|
|
1450
|
+
} else if (!colorCh.field || typeof colorCh.field !== 'string') {
|
|
1451
|
+
errors.push({
|
|
1452
|
+
message: 'Spec error: encoding.color must have a "field" string',
|
|
1453
|
+
path: 'encoding.color.field',
|
|
1454
|
+
code: 'MISSING_FIELD',
|
|
1455
|
+
suggestion: 'Add a field name, e.g. color: { field: "value" }',
|
|
1427
1456
|
});
|
|
1428
|
-
continue;
|
|
1429
1457
|
}
|
|
1458
|
+
} else if (
|
|
1459
|
+
colorCh &&
|
|
1460
|
+
typeof colorCh === 'object' &&
|
|
1461
|
+
(!colorCh.field || typeof colorCh.field !== 'string')
|
|
1462
|
+
) {
|
|
1463
|
+
errors.push({
|
|
1464
|
+
message: 'Spec error: encoding.color must have a "field" string when provided',
|
|
1465
|
+
path: 'encoding.color.field',
|
|
1466
|
+
code: 'MISSING_FIELD',
|
|
1467
|
+
suggestion: 'Add a field name, e.g. color: { field: "value" }',
|
|
1468
|
+
});
|
|
1469
|
+
}
|
|
1430
1470
|
|
|
1431
|
-
|
|
1471
|
+
// Validate points layer if present
|
|
1472
|
+
if (hasPoints) {
|
|
1473
|
+
const points = spec.points as Record<string, unknown>;
|
|
1474
|
+
if (!Array.isArray(points.data)) {
|
|
1432
1475
|
errors.push({
|
|
1433
|
-
message:
|
|
1434
|
-
path:
|
|
1435
|
-
code: '
|
|
1436
|
-
suggestion:
|
|
1476
|
+
message: 'Spec error: points.data must be an array of data rows',
|
|
1477
|
+
path: 'points.data',
|
|
1478
|
+
code: 'INVALID_TYPE',
|
|
1479
|
+
suggestion: 'Provide data as an array of objects with lat/lng fields',
|
|
1437
1480
|
});
|
|
1438
1481
|
}
|
|
1482
|
+
for (const ch of ['longitude', 'latitude'] as const) {
|
|
1483
|
+
const enc = points[ch] as Record<string, unknown> | undefined;
|
|
1484
|
+
if (!enc || typeof enc !== 'object') {
|
|
1485
|
+
errors.push({
|
|
1486
|
+
message: `Spec error: points requires "${ch}" encoding channel`,
|
|
1487
|
+
path: `points.${ch}`,
|
|
1488
|
+
code: 'MISSING_FIELD',
|
|
1489
|
+
suggestion: `Add points.${ch}, e.g. ${ch}: { field: "${ch === 'longitude' ? 'lon' : 'lat'}", type: "quantitative" }`,
|
|
1490
|
+
});
|
|
1491
|
+
} else if (!enc.field || typeof enc.field !== 'string') {
|
|
1492
|
+
errors.push({
|
|
1493
|
+
message: `Spec error: points.${ch} must have a "field" string`,
|
|
1494
|
+
path: `points.${ch}.field`,
|
|
1495
|
+
code: 'MISSING_FIELD',
|
|
1496
|
+
suggestion: `Add a field name, e.g. ${ch}: { field: "${ch === 'longitude' ? 'lon' : 'lat'}" }`,
|
|
1497
|
+
});
|
|
1498
|
+
}
|
|
1499
|
+
}
|
|
1439
1500
|
}
|
|
1440
1501
|
}
|
|
1441
1502
|
|
|
@@ -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
|
});
|