@opendata-ai/openchart-engine 8.0.0-rc.1 → 8.0.0-rc.10

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.
@@ -296,6 +296,55 @@ describe('compileMap', () => {
296
296
  expect(layout.continuousLegend).toBeNull();
297
297
  });
298
298
 
299
+ it('categorical legend defaults to top and sits above the map area', () => {
300
+ const layout = compileMap(
301
+ {
302
+ type: 'map',
303
+ geo: { features: MINI_TOPO, projection: 'mercator' },
304
+ data: [
305
+ { fips: '06', category: 'west' },
306
+ { fips: '48', category: 'south' },
307
+ ],
308
+ encoding: {
309
+ key: { field: 'fips', type: 'nominal' },
310
+ color: { field: 'category', type: 'nominal' },
311
+ },
312
+ },
313
+ DEFAULT_OPTIONS,
314
+ );
315
+
316
+ const legend = layout.categoricalLegend!;
317
+ expect(legend.position).toBe('top');
318
+ // The map area shifts down to make room for a top legend; the legend row
319
+ // must land above it, not inside it (the old bottom-anchored legend
320
+ // overlapped the map because the two disagreed about the layout).
321
+ expect(legend.bounds.y + legend.bounds.height).toBeLessThanOrEqual(layout.area.y);
322
+ });
323
+
324
+ it('honors position: bottom for categorical legend', () => {
325
+ const layout = compileMap(
326
+ {
327
+ type: 'map',
328
+ geo: { features: MINI_TOPO, projection: 'mercator' },
329
+ data: [
330
+ { fips: '06', category: 'west' },
331
+ { fips: '48', category: 'south' },
332
+ ],
333
+ encoding: {
334
+ key: { field: 'fips', type: 'nominal' },
335
+ color: { field: 'category', type: 'nominal' },
336
+ },
337
+ legend: { position: 'bottom' },
338
+ },
339
+ DEFAULT_OPTIONS,
340
+ );
341
+
342
+ const legend = layout.categoricalLegend!;
343
+ expect(legend.position).toBe('bottom');
344
+ // Bottom legend starts at or below the map area's bottom edge.
345
+ expect(legend.bounds.y).toBeGreaterThanOrEqual(layout.area.y + layout.area.height);
346
+ });
347
+
299
348
  it('assigns neutral fill to features without data', () => {
300
349
  const layout = compileMap(
301
350
  {
@@ -582,6 +631,129 @@ describe('compileMap', () => {
582
631
  expect(layout.focus).toBeNull();
583
632
  });
584
633
 
634
+ it('geo.focus { points: true } fits the point cluster, not any feature', () => {
635
+ const layout = compileMap(
636
+ {
637
+ type: 'map',
638
+ geo: {
639
+ features: MINI_TOPO,
640
+ projection: 'mercator',
641
+ focus: { points: true, padding: 4 },
642
+ },
643
+ data: [],
644
+ encoding: { key: { field: 'id', type: 'nominal' } },
645
+ points: {
646
+ data: [
647
+ { lat: 34, lon: -118, name: 'LA' },
648
+ { lat: 40.7, lon: -74, name: 'NYC' },
649
+ ],
650
+ longitude: { field: 'lon', type: 'quantitative' },
651
+ latitude: { field: 'lat', type: 'quantitative' },
652
+ key: { field: 'name', type: 'nominal' },
653
+ },
654
+ },
655
+ DEFAULT_OPTIONS,
656
+ );
657
+
658
+ expect(layout.focus).not.toBeNull();
659
+ expect(layout.focus!.target.padding).toBe(4);
660
+ // No feature ids: an all-points focus dims no features.
661
+ expect(layout.focus!.ids).toEqual([]);
662
+ // The target spans the two points (plus their radii), which sit far apart,
663
+ // so it has real width and height.
664
+ expect(layout.focus!.target.width).toBeGreaterThan(0);
665
+ expect(layout.focus!.target.height).toBeGreaterThan(0);
666
+ // The fitted cluster must be smaller than the full map extent.
667
+ expect(layout.focus!.target.width).toBeLessThan(layout.mapSize.width);
668
+ });
669
+
670
+ it('geo.focus { points: { field, value } } fits only the matching subset', () => {
671
+ const opts = {
672
+ data: [
673
+ { lat: 34, lon: -118, name: 'LA', rating: 'A' },
674
+ { lat: 40.7, lon: -74, name: 'NYC', rating: 'F' },
675
+ ],
676
+ longitude: { field: 'lon', type: 'quantitative' as const },
677
+ latitude: { field: 'lat', type: 'quantitative' as const },
678
+ key: { field: 'name', type: 'nominal' as const },
679
+ };
680
+ const all = compileMap(
681
+ {
682
+ type: 'map',
683
+ geo: { features: MINI_TOPO, projection: 'mercator', focus: { points: true } },
684
+ data: [],
685
+ encoding: { key: { field: 'id', type: 'nominal' } },
686
+ points: opts,
687
+ },
688
+ DEFAULT_OPTIONS,
689
+ );
690
+ const subset = compileMap(
691
+ {
692
+ type: 'map',
693
+ geo: {
694
+ features: MINI_TOPO,
695
+ projection: 'mercator',
696
+ focus: { points: { field: 'rating', value: 'A' } },
697
+ },
698
+ data: [],
699
+ encoding: { key: { field: 'id', type: 'nominal' } },
700
+ points: opts,
701
+ },
702
+ DEFAULT_OPTIONS,
703
+ );
704
+
705
+ expect(subset.focus).not.toBeNull();
706
+ // One point vs two: the subset target is strictly smaller than the all-points target.
707
+ expect(subset.focus!.target.width).toBeLessThan(all.focus!.target.width);
708
+ });
709
+
710
+ it('geo.focus { points: { field, value } } with no match emits FOCUS_UNMATCHED', () => {
711
+ const layout = compileMap(
712
+ {
713
+ type: 'map',
714
+ geo: {
715
+ features: MINI_TOPO,
716
+ projection: 'mercator',
717
+ focus: { points: { field: 'rating', value: 'Z' } },
718
+ },
719
+ data: [],
720
+ encoding: { key: { field: 'id', type: 'nominal' } },
721
+ points: {
722
+ data: [{ lat: 34, lon: -118, name: 'LA', rating: 'A' }],
723
+ longitude: { field: 'lon', type: 'quantitative' },
724
+ latitude: { field: 'lat', type: 'quantitative' },
725
+ key: { field: 'name', type: 'nominal' },
726
+ },
727
+ },
728
+ DEFAULT_OPTIONS,
729
+ );
730
+
731
+ expect(layout.focus).toBeNull();
732
+ expect(layout.warnings.some((w) => w.code === 'FOCUS_UNMATCHED')).toBe(true);
733
+ });
734
+
735
+ it('geo.focus { points: true } with no points emits FOCUS_UNMATCHED and no focus', () => {
736
+ const layout = compileMap(
737
+ {
738
+ type: 'map',
739
+ geo: {
740
+ features: MINI_TOPO,
741
+ projection: 'mercator',
742
+ focus: { points: true },
743
+ },
744
+ data: [{ fips: '06', value: 10 }],
745
+ encoding: {
746
+ key: { field: 'fips', type: 'nominal' },
747
+ color: { field: 'value', type: 'quantitative' },
748
+ },
749
+ },
750
+ DEFAULT_OPTIONS,
751
+ );
752
+
753
+ expect(layout.focus).toBeNull();
754
+ expect(layout.warnings.some((w) => w.code === 'FOCUS_UNMATCHED')).toBe(true);
755
+ });
756
+
585
757
  it('unknown focus id emits FOCUS_UNMATCHED warning', () => {
586
758
  const layout = compileMap(
587
759
  {
@@ -618,4 +790,339 @@ describe('compileMap', () => {
618
790
 
619
791
  expect(layout.focus).toBeNull();
620
792
  });
793
+
794
+ // ---------------------------------------------------------------------------
795
+ // Point layer
796
+ // ---------------------------------------------------------------------------
797
+
798
+ describe('point layer', () => {
799
+ it('compiles points through projection', () => {
800
+ const layout = compileMap(
801
+ {
802
+ type: 'map',
803
+ geo: { features: MINI_TOPO, projection: 'mercator' },
804
+ data: [],
805
+ encoding: { key: { field: 'id', type: 'nominal' } },
806
+ points: {
807
+ data: [
808
+ { lat: 34, lon: -118, name: 'LA' },
809
+ { lat: 40.7, lon: -74, name: 'NYC' },
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
+ expect(layout.pointMarks).toHaveLength(2);
820
+ for (const pm of layout.pointMarks) {
821
+ expect(Number.isFinite(pm.cx)).toBe(true);
822
+ expect(Number.isFinite(pm.cy)).toBe(true);
823
+ }
824
+ });
825
+
826
+ it('drops null-projecting points with warning', () => {
827
+ const layout = compileMap(
828
+ {
829
+ type: 'map',
830
+ geo: { features: MINI_TOPO, projection: 'albersUsa' },
831
+ data: [],
832
+ encoding: { key: { field: 'id', type: 'nominal' } },
833
+ points: {
834
+ data: [{ lat: 0, lon: 0, name: 'Pacific' }],
835
+ longitude: { field: 'lon', type: 'quantitative' },
836
+ latitude: { field: 'lat', type: 'quantitative' },
837
+ key: { field: 'name', type: 'nominal' },
838
+ },
839
+ },
840
+ DEFAULT_OPTIONS,
841
+ );
842
+
843
+ expect(layout.pointMarks).toHaveLength(0);
844
+ const warning = layout.warnings.find((w) => w.code === 'POINT_NULL_PROJECTION');
845
+ expect(warning).toBeDefined();
846
+ });
847
+
848
+ it('builds sqrt size scale for points', () => {
849
+ const layout = compileMap(
850
+ {
851
+ type: 'map',
852
+ geo: { features: MINI_TOPO, projection: 'mercator' },
853
+ data: [],
854
+ encoding: { key: { field: 'id', type: 'nominal' } },
855
+ points: {
856
+ data: [
857
+ { lat: 34, lon: -118, name: 'A', value: 10 },
858
+ { lat: 40.7, lon: -74, name: 'B', value: 1000 },
859
+ ],
860
+ longitude: { field: 'lon', type: 'quantitative' },
861
+ latitude: { field: 'lat', type: 'quantitative' },
862
+ size: { field: 'value', type: 'quantitative' },
863
+ key: { field: 'name', type: 'nominal' },
864
+ },
865
+ },
866
+ DEFAULT_OPTIONS,
867
+ );
868
+
869
+ expect(layout.pointMarks).toHaveLength(2);
870
+ const radii = layout.pointMarks.map((pm) => pm.r);
871
+ expect(radii[0]).not.toBe(radii[1]);
872
+ // Map point size scale range is [3, 20]
873
+ for (const r of radii) {
874
+ expect(r).toBeGreaterThanOrEqual(3);
875
+ expect(r).toBeLessThanOrEqual(20);
876
+ }
877
+ });
878
+
879
+ it('builds independent categorical color scale', () => {
880
+ const layout = compileMap(
881
+ {
882
+ type: 'map',
883
+ geo: { features: MINI_TOPO, projection: 'mercator' },
884
+ data: [],
885
+ encoding: { key: { field: 'id', type: 'nominal' } },
886
+ points: {
887
+ data: [
888
+ { lat: 34, lon: -118, name: 'A', cat: 'x' },
889
+ { lat: 40.7, lon: -74, name: 'B', cat: 'y' },
890
+ { lat: 37, lon: -100, name: 'C', cat: 'z' },
891
+ ],
892
+ longitude: { field: 'lon', type: 'quantitative' },
893
+ latitude: { field: 'lat', type: 'quantitative' },
894
+ color: { field: 'cat', type: 'nominal' },
895
+ key: { field: 'name', type: 'nominal' },
896
+ },
897
+ },
898
+ DEFAULT_OPTIONS,
899
+ );
900
+
901
+ expect(layout.pointMarks).toHaveLength(3);
902
+ const fills = layout.pointMarks.map((pm) => pm.fill);
903
+ // 3 different categories should produce 3 different colors
904
+ expect(new Set(fills).size).toBe(3);
905
+ });
906
+
907
+ it('basemap-only: neutral fill when no encoding.color', () => {
908
+ const layout = compileMap(
909
+ {
910
+ type: 'map',
911
+ geo: { features: MINI_TOPO, projection: 'mercator' },
912
+ data: [],
913
+ encoding: { key: { field: 'id', type: 'nominal' } },
914
+ points: {
915
+ data: [{ lat: 34, lon: -118, name: 'LA' }],
916
+ longitude: { field: 'lon', type: 'quantitative' },
917
+ latitude: { field: 'lat', type: 'quantitative' },
918
+ key: { field: 'name', type: 'nominal' },
919
+ },
920
+ },
921
+ DEFAULT_OPTIONS,
922
+ );
923
+
924
+ // Features should all have the neutral fill (#e8e8e8 in light mode)
925
+ for (const f of layout.features) {
926
+ expect(f.fill).toBe('#e8e8e8');
927
+ }
928
+ });
929
+
930
+ it('generates point tooltips with point: prefix', () => {
931
+ const layout = compileMap(
932
+ {
933
+ type: 'map',
934
+ geo: { features: MINI_TOPO, projection: 'mercator' },
935
+ data: [],
936
+ encoding: { key: { field: 'id', type: 'nominal' } },
937
+ points: {
938
+ data: [
939
+ { lat: 34, lon: -118, name: 'LA', value: 100 },
940
+ { lat: 40.7, lon: -74, name: 'NYC', value: 200 },
941
+ ],
942
+ longitude: { field: 'lon', type: 'quantitative' },
943
+ latitude: { field: 'lat', type: 'quantitative' },
944
+ tooltip: [
945
+ { field: 'name', type: 'nominal', title: 'City' },
946
+ { field: 'value', type: 'quantitative', title: 'Pop' },
947
+ ],
948
+ key: { field: 'name', type: 'nominal' },
949
+ },
950
+ },
951
+ DEFAULT_OPTIONS,
952
+ );
953
+
954
+ const pointKeys = [...layout.tooltipDescriptors.keys()].filter((k) => k.startsWith('point:'));
955
+ expect(pointKeys).toHaveLength(2);
956
+ expect(pointKeys).toContain('point:LA');
957
+ expect(pointKeys).toContain('point:NYC');
958
+
959
+ const laTooltip = layout.tooltipDescriptors.get('point:LA');
960
+ expect(laTooltip).toBeDefined();
961
+ expect(laTooltip!.fields).toHaveLength(2);
962
+ expect(laTooltip!.fields[0].label).toBe('City');
963
+ });
964
+
965
+ it('handles points alongside choropleth', () => {
966
+ const layout = compileMap(
967
+ {
968
+ type: 'map',
969
+ geo: { features: MINI_TOPO, projection: 'mercator' },
970
+ data: [
971
+ { fips: '06', value: 10 },
972
+ { fips: '48', value: 20 },
973
+ ],
974
+ encoding: {
975
+ key: { field: 'fips', type: 'nominal' },
976
+ color: { field: 'value', type: 'quantitative' },
977
+ },
978
+ points: {
979
+ data: [
980
+ { lat: 34, lon: -118, name: 'LA', pop: 400 },
981
+ { lat: 31, lon: -100, name: 'TX City', pop: 200 },
982
+ ],
983
+ longitude: { field: 'lon', type: 'quantitative' },
984
+ latitude: { field: 'lat', type: 'quantitative' },
985
+ key: { field: 'name', type: 'nominal' },
986
+ },
987
+ },
988
+ DEFAULT_OPTIONS,
989
+ );
990
+
991
+ // Choropleth features should have color-encoded fills
992
+ const matchedFeature = layout.features.find((f) => f.id === '06');
993
+ expect(matchedFeature).toBeDefined();
994
+ expect(matchedFeature!.fill).not.toBe('#e8e8e8');
995
+
996
+ // Point marks should also be present
997
+ expect(layout.pointMarks).toHaveLength(2);
998
+ for (const pm of layout.pointMarks) {
999
+ expect(Number.isFinite(pm.cx)).toBe(true);
1000
+ expect(Number.isFinite(pm.cy)).toBe(true);
1001
+ }
1002
+ });
1003
+
1004
+ it('encoding.color legend: null suppresses the choropleth legend reserve (basemap-only)', () => {
1005
+ const base = {
1006
+ type: 'map',
1007
+ geo: { features: MINI_TOPO, projection: 'mercator' },
1008
+ data: [] as Array<Record<string, unknown>>,
1009
+ points: {
1010
+ data: [{ lat: 34, lon: -118, name: 'LA', v: 10 }],
1011
+ longitude: { field: 'lon', type: 'quantitative' },
1012
+ latitude: { field: 'lat', type: 'quantitative' },
1013
+ color: { field: 'v', type: 'quantitative' },
1014
+ key: { field: 'name', type: 'nominal' },
1015
+ },
1016
+ theme: { spacing: { padding: 0 } },
1017
+ };
1018
+ const withLegendNull = compileMap(
1019
+ {
1020
+ ...base,
1021
+ encoding: {
1022
+ key: { field: 'id', type: 'nominal' },
1023
+ color: { field: 'id', type: 'nominal', legend: null },
1024
+ },
1025
+ },
1026
+ DEFAULT_OPTIONS,
1027
+ );
1028
+ const withoutLegendNull = compileMap(
1029
+ {
1030
+ ...base,
1031
+ encoding: {
1032
+ key: { field: 'id', type: 'nominal' },
1033
+ color: { field: 'id', type: 'nominal' },
1034
+ },
1035
+ },
1036
+ DEFAULT_OPTIONS,
1037
+ );
1038
+
1039
+ // legend: null frees the phantom choropleth swatch row, so the map
1040
+ // area is taller than the spec that reserves it.
1041
+ expect(withLegendNull.categoricalLegend).toBeNull();
1042
+ expect(withLegendNull.area.height).toBeGreaterThan(withoutLegendNull.area.height);
1043
+ });
1044
+
1045
+ it("legend position 'top-left' overlays the point legend inside the map area", () => {
1046
+ const spec = {
1047
+ type: 'map',
1048
+ geo: { features: MINI_TOPO, projection: 'mercator' },
1049
+ data: [] as Array<Record<string, unknown>>,
1050
+ encoding: {
1051
+ key: { field: 'id', type: 'nominal' },
1052
+ color: { field: 'id', type: 'nominal', legend: null },
1053
+ },
1054
+ points: {
1055
+ data: [
1056
+ { lat: 34, lon: -118, name: 'LA', v: 10 },
1057
+ { lat: 40.7, lon: -74, name: 'NYC', v: 90 },
1058
+ ],
1059
+ longitude: { field: 'lon', type: 'quantitative' },
1060
+ latitude: { field: 'lat', type: 'quantitative' },
1061
+ color: { field: 'v', type: 'quantitative' },
1062
+ key: { field: 'name', type: 'nominal' },
1063
+ },
1064
+ theme: { spacing: { padding: 0 } },
1065
+ watermark: false,
1066
+ };
1067
+ const overlay = compileMap(
1068
+ { ...spec, legend: { show: true, position: 'top-left' } },
1069
+ DEFAULT_OPTIONS,
1070
+ );
1071
+ const bottom = compileMap({ ...spec, legend: { show: true } }, DEFAULT_OPTIONS);
1072
+
1073
+ // Overlay reserves no figure height: map area fills the full frame.
1074
+ expect(overlay.area.height).toBeGreaterThan(bottom.area.height);
1075
+ expect(overlay.area.height).toBe(DEFAULT_OPTIONS.height);
1076
+
1077
+ // Legend floats inside the map area's top-left corner, inset from the
1078
+ // edges, and is marked as an overlay for the renderer's backdrop.
1079
+ const legend = overlay.pointContinuousLegend!;
1080
+ expect(legend.position).toBe('top-left');
1081
+ expect(legend.bounds.x).toBeGreaterThan(overlay.area.x);
1082
+ expect(legend.bounds.y).toBeGreaterThan(overlay.area.y);
1083
+ expect(legend.bounds.y + legend.bounds.height).toBeLessThan(
1084
+ overlay.area.y + overlay.area.height,
1085
+ );
1086
+
1087
+ // Default (no position) keeps the bottom-row placement.
1088
+ expect(bottom.pointContinuousLegend!.position).toBe('bottom');
1089
+ expect(bottom.pointContinuousLegend!.bounds.y).toBeGreaterThan(
1090
+ overlay.pointContinuousLegend!.bounds.y,
1091
+ );
1092
+ });
1093
+
1094
+ it("legend position 'top-left' overlays a categorical point legend too", () => {
1095
+ const layout = compileMap(
1096
+ {
1097
+ type: 'map',
1098
+ geo: { features: MINI_TOPO, projection: 'mercator' },
1099
+ data: [],
1100
+ encoding: {
1101
+ key: { field: 'id', type: 'nominal' },
1102
+ color: { field: 'id', type: 'nominal', legend: null },
1103
+ },
1104
+ points: {
1105
+ data: [
1106
+ { lat: 34, lon: -118, name: 'LA', cat: 'x' },
1107
+ { lat: 40.7, lon: -74, name: 'NYC', cat: 'y' },
1108
+ ],
1109
+ longitude: { field: 'lon', type: 'quantitative' },
1110
+ latitude: { field: 'lat', type: 'quantitative' },
1111
+ color: { field: 'cat', type: 'nominal' },
1112
+ key: { field: 'name', type: 'nominal' },
1113
+ },
1114
+ legend: { show: true, position: 'top-left' },
1115
+ theme: { spacing: { padding: 0 } },
1116
+ watermark: false,
1117
+ },
1118
+ DEFAULT_OPTIONS,
1119
+ );
1120
+
1121
+ expect(layout.area.height).toBe(DEFAULT_OPTIONS.height);
1122
+ const legend = layout.pointCategoricalLegend!;
1123
+ expect(legend.position).toBe('top-left');
1124
+ expect(legend.bounds.x).toBeGreaterThan(layout.area.x);
1125
+ expect(legend.bounds.y).toBeGreaterThan(layout.area.y);
1126
+ });
1127
+ });
621
1128
  });