@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.
- package/dist/index.d.ts +2 -8
- package/dist/index.js +793 -91
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/__snapshots__/compile-snapshot.test.ts.snap +62 -0
- package/src/__tests__/annotation-behavior.test.ts +131 -0
- package/src/__tests__/facet.test.ts +144 -0
- package/src/annotations/geometry.ts +7 -1
- package/src/annotations/resolve-text.ts +6 -0
- package/src/compile/layer.ts +4 -0
- package/src/compile/size-scale.ts +2 -0
- package/src/compile/spec-sugar.ts +10 -1
- package/src/compile.ts +106 -8
- package/src/compiler/__tests__/keys.test.ts +3 -9
- package/src/compiler/__tests__/validate-did-you-mean.test.ts +84 -0
- package/src/compiler/__tests__/validate-near-miss.test.ts +162 -0
- package/src/compiler/__tests__/validate.test.ts +102 -0
- package/src/compiler/keys.ts +5 -4
- package/src/compiler/normalize.ts +1 -0
- package/src/compiler/validate.ts +326 -43
- package/src/geo/__tests__/compile-map.test.ts +507 -0
- package/src/geo/compile-map.ts +555 -35
- package/src/geo/projections.ts +21 -1
- package/src/geo/types.ts +2 -0
- package/src/layout/axes.ts +5 -4
- package/src/legend/compute.ts +4 -3
|
@@ -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
|
});
|