@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opendata-ai/openchart-engine",
3
- "version": "8.0.0-rc.2",
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.2",
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. */
@@ -595,6 +595,7 @@ function normalizeMapSpec(spec: MapSpec, _warnings: string[]): NormalizedMapSpec
595
595
  watermark: spec.watermark ?? true,
596
596
  animation: spec.animation,
597
597
  valueFormat: spec.valueFormat,
598
+ points: spec.points,
598
599
  };
599
600
  }
600
601
 
@@ -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
- for (const channel of ['key', 'color'] as const) {
1420
- const ch = encoding[channel] as Record<string, unknown> | undefined;
1421
- if (!ch || typeof ch !== 'object') {
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: `Spec error: map encoding requires "${channel}" channel`,
1424
- path: `encoding.${channel}`,
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: `Add encoding.${channel}, e.g. ${channel}: { field: "${channel === 'key' ? 'id' : 'value'}", type: "${channel === 'key' ? 'nominal' : 'quantitative'}" }`,
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
- if (!ch.field || typeof ch.field !== 'string') {
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: `Spec error: encoding.${channel} must have a "field" string`,
1434
- path: `encoding.${channel}.field`,
1435
- code: 'MISSING_FIELD',
1436
- suggestion: `Add a field name, e.g. ${channel}: { field: "${channel === 'key' ? 'id' : 'value'}" }`,
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
  });