@geospatial-sdk/core 0.0.5-dev.0.sha-7ea9c6a → 0.0.5-dev.0.sha-9c03c7f

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.
Files changed (46) hide show
  1. package/dist/constant/projections.d.ts +8 -0
  2. package/dist/constant/projections.d.ts.map +1 -1
  3. package/dist/constant/projections.js +8 -0
  4. package/dist/model/events.d.ts +3 -3
  5. package/dist/model/events.d.ts.map +1 -1
  6. package/dist/model/events.js +2 -3
  7. package/dist/model/map-context-diff.d.ts +7 -1
  8. package/dist/model/map-context-diff.d.ts.map +1 -1
  9. package/dist/model/map-context.d.ts +15 -5
  10. package/dist/model/map-context.d.ts.map +1 -1
  11. package/dist/model/resolved-map-state.d.ts +1 -1
  12. package/dist/model/resolved-map-state.d.ts.map +1 -1
  13. package/dist/utils/coordinates.d.ts +52 -0
  14. package/dist/utils/coordinates.d.ts.map +1 -0
  15. package/dist/utils/coordinates.js +91 -0
  16. package/dist/utils/crs.d.ts +30 -0
  17. package/dist/utils/crs.d.ts.map +1 -0
  18. package/dist/utils/crs.js +83 -0
  19. package/dist/utils/geojson.d.ts +16 -0
  20. package/dist/utils/geojson.d.ts.map +1 -0
  21. package/dist/utils/geojson.js +118 -0
  22. package/dist/utils/index.d.ts +3 -0
  23. package/dist/utils/index.d.ts.map +1 -1
  24. package/dist/utils/index.js +3 -0
  25. package/dist/utils/map-context-diff.d.ts.map +1 -1
  26. package/dist/utils/map-context-diff.js +10 -0
  27. package/dist/utils/view.d.ts +2 -2
  28. package/dist/utils/view.d.ts.map +1 -1
  29. package/dist/utils/view.js +16 -29
  30. package/lib/constant/projections.ts +10 -0
  31. package/lib/model/events.ts +4 -4
  32. package/lib/model/map-context-diff.ts +8 -1
  33. package/lib/model/map-context.ts +19 -5
  34. package/lib/model/resolved-map-state.ts +1 -1
  35. package/lib/utils/coordinates.test.ts +172 -0
  36. package/lib/utils/coordinates.ts +157 -0
  37. package/lib/utils/crs.test.ts +128 -0
  38. package/lib/utils/crs.ts +102 -0
  39. package/lib/utils/geojson.test.ts +208 -0
  40. package/lib/utils/geojson.ts +133 -0
  41. package/lib/utils/index.ts +14 -0
  42. package/lib/utils/map-context-diff.test.ts +68 -1
  43. package/lib/utils/map-context-diff.ts +11 -0
  44. package/lib/utils/view.test.ts +23 -0
  45. package/lib/utils/view.ts +19 -44
  46. package/package.json +2 -2
@@ -0,0 +1,128 @@
1
+ import proj4 from "proj4";
2
+ import {
3
+ ensureContextCrsReady,
4
+ ensureCrsRegistered,
5
+ ensureDiffCrsReady,
6
+ getContextCrsCodes,
7
+ getDiffCrsCodes,
8
+ getViewCrsCodes,
9
+ } from "./crs.js";
10
+ import { MapContextDiff } from "../model/map-context-diff.js";
11
+
12
+ const emptyDiff: MapContextDiff = {
13
+ layersAdded: [],
14
+ layersRemoved: [],
15
+ layersChanged: [],
16
+ layersReordered: [],
17
+ };
18
+
19
+ describe("crs utils", () => {
20
+ afterEach(() => {
21
+ vi.unstubAllGlobals();
22
+ });
23
+
24
+ describe("scanning", () => {
25
+ it("scans a view", () => {
26
+ expect(
27
+ getViewCrsCodes({
28
+ center: [1, 2, "EPSG:2154"],
29
+ zoom: 3,
30
+ projection: "EPSG:3857",
31
+ maxExtent: [0, 0, 1, 1, "EPSG:32631"],
32
+ }),
33
+ ).toEqual(new Set(["EPSG:2154", "EPSG:3857", "EPSG:32631"]));
34
+ });
35
+
36
+ it("scans a context", () => {
37
+ expect(
38
+ getContextCrsCodes({
39
+ layers: [],
40
+ view: { extent: [0, 0, 1, 1, "EPSG:2154"], projection: "EPSG:3857" },
41
+ }),
42
+ ).toEqual(new Set(["EPSG:2154", "EPSG:3857"]));
43
+ expect(getContextCrsCodes({ layers: [], view: null })).toEqual(new Set());
44
+ });
45
+
46
+ it("scans a diff", () => {
47
+ expect(
48
+ getDiffCrsCodes({
49
+ ...emptyDiff,
50
+ viewChanges: {
51
+ center: [0, 0, "EPSG:2154"],
52
+ zoom: 1,
53
+ projection: "EPSG:3857",
54
+ },
55
+ }),
56
+ ).toEqual(new Set(["EPSG:2154", "EPSG:3857"]));
57
+ expect(getDiffCrsCodes(emptyDiff)).toEqual(new Set());
58
+ expect(getDiffCrsCodes({ ...emptyDiff, viewChanges: null })).toEqual(
59
+ new Set(),
60
+ );
61
+ });
62
+ });
63
+
64
+ describe("registration", () => {
65
+ it("does not fetch known CRS", async () => {
66
+ const fetchMock = vi.fn();
67
+ vi.stubGlobal("fetch", fetchMock);
68
+ proj4.defs("EPSG:2154", "+proj=longlat +datum=WGS84 +no_defs");
69
+ await ensureCrsRegistered(
70
+ new Set(["EPSG:3857", "EPSG:4326", "EPSG:2154"]),
71
+ );
72
+ expect(fetchMock).not.toHaveBeenCalled();
73
+ });
74
+
75
+ it("fetches and registers a missing CRS", async () => {
76
+ const fetchMock = vi.fn().mockResolvedValue({
77
+ ok: true,
78
+ text: () => Promise.resolve("+proj=longlat +datum=WGS84 +no_defs"),
79
+ });
80
+ vi.stubGlobal("fetch", fetchMock);
81
+ await ensureCrsRegistered(new Set(["EPSG:9999001"]));
82
+ expect(fetchMock).toHaveBeenCalledWith(
83
+ "https://spatialreference.org/ref/epsg/9999001/ogcwkt/",
84
+ );
85
+ expect(proj4.defs("EPSG:9999001")).toBeTruthy();
86
+ });
87
+
88
+ it("throws if the response is not ok", async () => {
89
+ vi.stubGlobal(
90
+ "fetch",
91
+ vi.fn().mockResolvedValue({ ok: false, status: 404 }),
92
+ );
93
+ await expect(
94
+ ensureCrsRegistered(new Set(["EPSG:9999002"])),
95
+ ).rejects.toThrow(/Could not find a definition for CRS "EPSG:9999002"/);
96
+ });
97
+
98
+ it("throws if the request fails", async () => {
99
+ vi.stubGlobal("fetch", () => Promise.reject(new Error("offline")));
100
+ await expect(
101
+ ensureCrsRegistered(new Set(["EPSG:9999003"])),
102
+ ).rejects.toThrow(/EPSG:9999003.*offline/);
103
+ });
104
+
105
+ it("throws on an invalid code", async () => {
106
+ await expect(ensureCrsRegistered(new Set(["foo"]))).rejects.toThrow(
107
+ /Could not find a definition for CRS "foo"/,
108
+ );
109
+ });
110
+
111
+ it("checks the CRS of a context and of a diff", async () => {
112
+ vi.stubGlobal("fetch", () => Promise.reject(new Error("offline")));
113
+ await expect(
114
+ ensureContextCrsReady({
115
+ layers: [],
116
+ view: { center: [0, 0], zoom: 1, projection: "EPSG:9999004" },
117
+ }),
118
+ ).rejects.toThrow(/EPSG:9999004/);
119
+ await expect(
120
+ ensureDiffCrsReady({
121
+ ...emptyDiff,
122
+ viewChanges: { center: [0, 0], zoom: 1, projection: "EPSG:9999005" },
123
+ }),
124
+ ).rejects.toThrow(/EPSG:9999005/);
125
+ await expect(ensureDiffCrsReady(emptyDiff)).resolves.toBeUndefined();
126
+ });
127
+ });
128
+ });
@@ -0,0 +1,102 @@
1
+ import proj4 from "proj4";
2
+ import { DEFAULT_MAP_VIEW_CRS } from "../constant/projections.js";
3
+ import {
4
+ CrsIdentifier,
5
+ MapContext,
6
+ MapContextView,
7
+ } from "../model/map-context.js";
8
+ import { MapContextDiff } from "../model/map-context-diff.js";
9
+ import { getCrs } from "./coordinates.js";
10
+
11
+ /**
12
+ * Returns the CRS codes referenced by a view (display CRS, center, extent, max extent)
13
+ */
14
+ export function getViewCrsCodes(view: MapContextView): Set<CrsIdentifier> {
15
+ const codes = new Set<CrsIdentifier>([
16
+ view.projection ?? DEFAULT_MAP_VIEW_CRS,
17
+ ]);
18
+ if ("center" in view && view.center) codes.add(getCrs(view.center));
19
+ if ("extent" in view && view.extent) codes.add(getCrs(view.extent));
20
+ if (view.maxExtent) codes.add(getCrs(view.maxExtent));
21
+ return codes;
22
+ }
23
+
24
+ /**
25
+ * Returns all the CRS codes used in a map context
26
+ */
27
+ export function getContextCrsCodes(context: MapContext): Set<CrsIdentifier> {
28
+ // TODO: check for geometries (e.g. GeoJSON)
29
+
30
+ if (!context.view) return new Set();
31
+ return getViewCrsCodes(context.view);
32
+ }
33
+
34
+ /**
35
+ * Returns all the CRS codes used in a map context diff
36
+ */
37
+ export function getDiffCrsCodes(diff: MapContextDiff): Set<CrsIdentifier> {
38
+ // TODO: check for geometries (e.g. GeoJSON)
39
+
40
+ if (!diff.viewChanges) return new Set();
41
+ return getViewCrsCodes(diff.viewChanges);
42
+ }
43
+
44
+ function isRegistered(code: CrsIdentifier): boolean {
45
+ // consider Web Mercator and Geographic as known
46
+ if (code === "EPSG:3857" || code === "EPSG:4326") return true;
47
+ return !!proj4.defs(code);
48
+ }
49
+
50
+ async function registerCrs(code: CrsIdentifier): Promise<void> {
51
+ if (typeof code !== "string" || !code.includes(":")) {
52
+ throw new Error(`Invalid code`);
53
+ }
54
+ const [authority, num] = code.toLowerCase().split(":", 2);
55
+ const response = await fetch(
56
+ `https://spatialreference.org/ref/${authority}/${num}/ogcwkt/`,
57
+ );
58
+ if (!response.ok) {
59
+ throw new Error(
60
+ `Unexpected response from spatialreference.org: ${response.status}`,
61
+ );
62
+ }
63
+ const def = await response.text();
64
+ proj4.defs(code, def);
65
+ }
66
+
67
+ /**
68
+ * Makes sure all given CRS codes are registered, looking up the missing ones.
69
+ * @throws Error if the definition of a CRS cannot be fetched
70
+ */
71
+ export async function ensureCrsRegistered(
72
+ codes: Set<CrsIdentifier>,
73
+ ): Promise<void> {
74
+ const missing = Array.from(codes).filter((code) => !isRegistered(code));
75
+ await Promise.all(
76
+ missing.map(async (code) => {
77
+ try {
78
+ await registerCrs(code);
79
+ } catch (e) {
80
+ throw new Error(
81
+ `Could not find a definition for CRS "${code}": ${(e as Error)?.message ?? e}`,
82
+ );
83
+ }
84
+ }),
85
+ );
86
+ }
87
+
88
+ /**
89
+ * Makes sure all CRS used in a map context are registered
90
+ * @throws Error if the definition of a CRS cannot be fetched
91
+ */
92
+ export function ensureContextCrsReady(context: MapContext): Promise<void> {
93
+ return ensureCrsRegistered(getContextCrsCodes(context));
94
+ }
95
+
96
+ /**
97
+ * Makes sure all CRS used in a map context diff are registered
98
+ * @throws Error if the definition of a CRS cannot be fetched
99
+ */
100
+ export function ensureDiffCrsReady(diff: MapContextDiff): Promise<void> {
101
+ return ensureCrsRegistered(getDiffCrsCodes(diff));
102
+ }
@@ -0,0 +1,208 @@
1
+ import {
2
+ ensureGeojson,
3
+ getExtentFromGeojson,
4
+ getExtentFromGeometry,
5
+ } from "./geojson.js";
6
+ import type {
7
+ Feature,
8
+ FeatureCollection,
9
+ Geometry,
10
+ GeometryCollection,
11
+ LineString,
12
+ MultiLineString,
13
+ MultiPoint,
14
+ MultiPolygon,
15
+ Point,
16
+ Polygon,
17
+ } from "geojson";
18
+
19
+ describe("geojson utils", () => {
20
+ describe("ensureGeojson", () => {
21
+ it("should return the same object if it is a valid GeoJSON", () => {
22
+ const geojson = {
23
+ type: "FeatureCollection",
24
+ features: [],
25
+ } as FeatureCollection;
26
+ expect(ensureGeojson(geojson)).toBe(geojson);
27
+ });
28
+
29
+ it("should parse a GeoJSON string into an object", () => {
30
+ const geojsonString = JSON.stringify({
31
+ type: "FeatureCollection",
32
+ features: [],
33
+ });
34
+ expect(ensureGeojson(geojsonString)).toEqual({
35
+ type: "FeatureCollection",
36
+ features: [],
37
+ });
38
+ });
39
+
40
+ it("should throw if the geojson string is invalid", () => {
41
+ const invalidGeojson = "hello: world";
42
+ expect(() => ensureGeojson(invalidGeojson)).toThrow(
43
+ "Invalid GeoJSON string: SyntaxError: Unexpected token 'h', \"hello: world\" is not valid JSON",
44
+ );
45
+ });
46
+ });
47
+
48
+ describe("getExtentFromGeometry", () => {
49
+ const polygon: Polygon = {
50
+ type: "Polygon",
51
+ coordinates: [
52
+ [
53
+ [1, 1],
54
+ [1, 4],
55
+ [4, 4],
56
+ [4, 1],
57
+ [1, 1],
58
+ ],
59
+ [
60
+ [2, 2],
61
+ [2, 3],
62
+ [130, 3],
63
+ [130, 2],
64
+ [2, 2],
65
+ ],
66
+ ],
67
+ };
68
+ const lineString: LineString = {
69
+ type: "LineString",
70
+ coordinates: [
71
+ [10, 20],
72
+ [20, 20],
73
+ [50, 10],
74
+ [100, -5],
75
+ [-100, -100],
76
+ ],
77
+ };
78
+ const point: Point = {
79
+ type: "Point",
80
+ coordinates: [50, -200],
81
+ };
82
+
83
+ it("computes the bounding box correctly for a Polygon", () => {
84
+ const bbox = getExtentFromGeometry(polygon);
85
+ expect(bbox).toEqual([1, 1, 130, 4]);
86
+ });
87
+ it("computes the bounding box correctly for a LineString", () => {
88
+ const bbox = getExtentFromGeometry(lineString);
89
+ expect(bbox).toEqual([-100, -100, 100, 20]);
90
+ });
91
+ it("computes the bounding box correctly for a Point", () => {
92
+ const bbox = getExtentFromGeometry(point);
93
+ expect(bbox).toEqual([50, -200, 50, -200]);
94
+ });
95
+ it("computes the bounding box correctly for a MultiPolygon", () => {
96
+ const multiPolygon: MultiPolygon = {
97
+ type: "MultiPolygon",
98
+ coordinates: [
99
+ polygon.coordinates,
100
+ [
101
+ [
102
+ [-20, -20],
103
+ [-20, 30],
104
+ [20, 30],
105
+ [20, -20],
106
+ [-20, -20],
107
+ ],
108
+ ],
109
+ ],
110
+ };
111
+ const bbox = getExtentFromGeometry(multiPolygon);
112
+ expect(bbox).toEqual([-20, -20, 130, 30]);
113
+ });
114
+ it("computes the bounding box correctly for a MultiLineString", () => {
115
+ const multiLineString: MultiLineString = {
116
+ type: "MultiLineString",
117
+ coordinates: [
118
+ lineString.coordinates,
119
+ [
120
+ [-2, -2],
121
+ [2, 3],
122
+ [150, 200],
123
+ [100, 10],
124
+ ],
125
+ ],
126
+ };
127
+ const bbox = getExtentFromGeometry(multiLineString);
128
+ expect(bbox).toEqual([-100, -100, 150, 200]);
129
+ });
130
+ it("computes the bounding box correctly for a MultiPoint", () => {
131
+ const multiPoint: MultiPoint = {
132
+ type: "MultiPoint",
133
+ coordinates: [point.coordinates, [200, 300]],
134
+ };
135
+ const bbox = getExtentFromGeometry(multiPoint);
136
+ expect(bbox).toEqual([50, -200, 200, 300]);
137
+ });
138
+ it("computes the bounding box correctly for a GeometryCollection", () => {
139
+ const geom: GeometryCollection = {
140
+ type: "GeometryCollection",
141
+ geometries: [lineString, polygon, point],
142
+ };
143
+ const bbox = getExtentFromGeometry(geom);
144
+ expect(bbox).toEqual([-100, -200, 130, 20]);
145
+ });
146
+ });
147
+
148
+ describe("getExtentFromGeoJSON", () => {
149
+ it("computes the combined extent of a FeatureCollection", () => {
150
+ const geojson: FeatureCollection<Geometry | null> = {
151
+ type: "FeatureCollection",
152
+ features: [
153
+ {
154
+ type: "Feature",
155
+ properties: {},
156
+ geometry: { type: "Point", coordinates: [5, 10] },
157
+ },
158
+ {
159
+ type: "Feature",
160
+ properties: {},
161
+ geometry: { type: "Point", coordinates: [-2, 20] },
162
+ },
163
+ {
164
+ type: "Feature",
165
+ properties: {},
166
+ geometry: null,
167
+ },
168
+ ],
169
+ };
170
+
171
+ expect(getExtentFromGeojson(geojson)).toEqual([-2, 10, 5, 20]);
172
+ });
173
+
174
+ it("computes the extent of a Feature", () => {
175
+ const feature: Feature<Point> = {
176
+ type: "Feature",
177
+ properties: {},
178
+ geometry: { type: "Point", coordinates: [5, 10] },
179
+ };
180
+
181
+ expect(getExtentFromGeojson(feature)).toEqual([5, 10, 5, 10]);
182
+ });
183
+
184
+ it("computes the extent of a Geometry", () => {
185
+ const geometry: Point = {
186
+ type: "Point",
187
+ coordinates: [-2, 20],
188
+ };
189
+
190
+ expect(getExtentFromGeojson(geometry)).toEqual([-2, 20, -2, 20]);
191
+ });
192
+
193
+ it("returns an empty extent for a Feature with no geometry", () => {
194
+ const feature: Feature<null> = {
195
+ type: "Feature",
196
+ properties: {},
197
+ geometry: null,
198
+ };
199
+
200
+ expect(getExtentFromGeojson(feature)).toEqual([
201
+ Infinity,
202
+ Infinity,
203
+ -Infinity,
204
+ -Infinity,
205
+ ]);
206
+ });
207
+ });
208
+ });
@@ -0,0 +1,133 @@
1
+ import type { Feature, FeatureCollection, Geometry, Position } from "geojson";
2
+ import { Extent } from "../model/index.js";
3
+ import { extend } from "./coordinates.js";
4
+
5
+ export function ensureGeojson(
6
+ value: FeatureCollection<Geometry | null> | string,
7
+ ): FeatureCollection<Geometry | null> {
8
+ if (typeof value === "string") {
9
+ try {
10
+ return JSON.parse(value) as FeatureCollection;
11
+ } catch (e) {
12
+ throw new Error(`Invalid GeoJSON string: ${e}`);
13
+ }
14
+ }
15
+ return value;
16
+ }
17
+
18
+ /**
19
+ * This assumes the geometry is in EPSG:4326
20
+ * TODO: support other CRSs
21
+ * @param geometry
22
+ */
23
+ export function getExtentFromGeometry(geometry: Geometry): Extent {
24
+ // use the bounding box if specified in the GeoJSON object
25
+ if (geometry.bbox) {
26
+ return geometry.bbox.length > 5
27
+ ? [
28
+ geometry.bbox[0],
29
+ geometry.bbox[1],
30
+ geometry.bbox[3],
31
+ geometry.bbox[4] as number,
32
+ ]
33
+ : (geometry.bbox as Extent);
34
+ }
35
+
36
+ const coordinatesReducer = (prev: Extent, coords: Position) => {
37
+ if (coords[0] < prev[0]) {
38
+ prev[0] = coords[0];
39
+ }
40
+ if (coords[1] < prev[1]) {
41
+ prev[1] = coords[1];
42
+ }
43
+ if (coords[0] > prev[2]) {
44
+ prev[2] = coords[0];
45
+ }
46
+ if (coords[1] > prev[3]) {
47
+ prev[3] = coords[1];
48
+ }
49
+ return prev;
50
+ };
51
+ const coordinatesArrayReducer = (prev: Extent, coordsArray: Position[]) => {
52
+ const bbox = coordsArray.reduce(coordinatesReducer, [
53
+ Infinity,
54
+ Infinity,
55
+ -Infinity,
56
+ -Infinity,
57
+ ]);
58
+ return extend(prev, bbox);
59
+ };
60
+
61
+ const initialExtent = [Infinity, Infinity, -Infinity, -Infinity] as Extent;
62
+
63
+ switch (geometry.type) {
64
+ case "MultiPolygon":
65
+ return geometry.coordinates.reduce((prev, polygonCoords) => {
66
+ const bbox = polygonCoords.reduce(coordinatesArrayReducer, [
67
+ Infinity,
68
+ Infinity,
69
+ -Infinity,
70
+ -Infinity,
71
+ ]);
72
+ return extend(prev, bbox);
73
+ }, initialExtent);
74
+ case "GeometryCollection":
75
+ return geometry.geometries.reduce((prev, geom) => {
76
+ const bbox = getExtentFromGeometry(geom);
77
+ return extend(prev, bbox);
78
+ }, initialExtent);
79
+ case "Polygon":
80
+ case "MultiLineString":
81
+ return geometry.coordinates.reduce(coordinatesArrayReducer, [
82
+ Infinity,
83
+ Infinity,
84
+ -Infinity,
85
+ -Infinity,
86
+ ]);
87
+ case "LineString":
88
+ case "MultiPoint":
89
+ return geometry.coordinates.reduce<Extent>(coordinatesReducer, [
90
+ Infinity,
91
+ Infinity,
92
+ -Infinity,
93
+ -Infinity,
94
+ ]);
95
+ case "Point":
96
+ return coordinatesReducer(
97
+ [Infinity, Infinity, -Infinity, -Infinity],
98
+ geometry.coordinates,
99
+ );
100
+ }
101
+ }
102
+
103
+ /**
104
+ * This assumes the geometry is in EPSG:4326
105
+ * TODO: support other CRSs
106
+ * @param geojson
107
+ */
108
+ export function getExtentFromGeojson(
109
+ geojson:
110
+ FeatureCollection<Geometry | null> | Feature<Geometry | null> | Geometry,
111
+ ): Extent {
112
+ const empty = [Infinity, Infinity, -Infinity, -Infinity] as Extent;
113
+ if ("type" in geojson) {
114
+ switch (geojson.type) {
115
+ case "FeatureCollection":
116
+ return geojson.features.reduce((prev, feature) => {
117
+ if (feature.geometry) {
118
+ const bbox = getExtentFromGeometry(feature.geometry);
119
+ return extend(prev, bbox);
120
+ }
121
+ return prev;
122
+ }, empty);
123
+ case "Feature":
124
+ if (geojson.geometry) {
125
+ return getExtentFromGeometry(geojson.geometry);
126
+ }
127
+ return empty;
128
+ default:
129
+ return getExtentFromGeometry(geojson);
130
+ }
131
+ }
132
+ return empty;
133
+ }
@@ -6,6 +6,14 @@
6
6
  export * from "./url.js";
7
7
  export * from "./freeze.js";
8
8
  export * from "./hash.js";
9
+ export {
10
+ getCrs,
11
+ setCrs,
12
+ transform,
13
+ toGeographic,
14
+ equals,
15
+ extend,
16
+ } from "./coordinates.js";
9
17
  export { computeMapContextDiff } from "./map-context-diff.js";
10
18
  export {
11
19
  getLayerPosition,
@@ -18,3 +26,9 @@ export {
18
26
  export { updateLayer } from "./map-context-layer.js";
19
27
  export { createViewFromLayer } from "./view.js";
20
28
  export * from "./style.js";
29
+ export {
30
+ ensureGeojson,
31
+ getExtentFromGeojson,
32
+ getExtentFromGeometry,
33
+ } from "./geojson.js";
34
+ export { ensureContextCrsReady, ensureDiffCrsReady } from "./crs.js";
@@ -10,7 +10,7 @@ import {
10
10
  SAMPLE_LAYER6,
11
11
  SAMPLE_LAYER7,
12
12
  } from "../../fixtures/map-context.fixtures.js";
13
- import { beforeEach, describe, expect, it } from "vitest";
13
+ import { DEFAULT_MAP_VIEW_CRS } from "../constant/projections.js";
14
14
 
15
15
  describe("Context diff utils", () => {
16
16
  describe("computeMapContextDiff", () => {
@@ -330,6 +330,73 @@ describe("Context diff utils", () => {
330
330
  });
331
331
  });
332
332
  });
333
+
334
+ describe("changing projection from default to custom", () => {
335
+ beforeEach(() => {
336
+ contextOld = {
337
+ ...SAMPLE_CONTEXT,
338
+ view: {
339
+ center: [5, 10],
340
+ zoom: 3,
341
+ },
342
+ };
343
+ contextNew = {
344
+ ...SAMPLE_CONTEXT,
345
+ view: {
346
+ center: [5, 10],
347
+ zoom: 3,
348
+ projection: "EPSG:1234",
349
+ },
350
+ };
351
+ });
352
+ it("output the correct diff", () => {
353
+ diff = computeMapContextDiff(contextNew, contextOld);
354
+ expect(diff).toEqual({
355
+ layersAdded: [],
356
+ layersChanged: [],
357
+ layersRemoved: [],
358
+ layersReordered: [],
359
+ viewChanges: {
360
+ center: [5, 10],
361
+ zoom: 3,
362
+ projection: "EPSG:1234",
363
+ },
364
+ });
365
+ });
366
+ });
367
+ describe("changing projection from custom to default", () => {
368
+ beforeEach(() => {
369
+ contextOld = {
370
+ ...SAMPLE_CONTEXT,
371
+ view: {
372
+ center: [5, 10],
373
+ zoom: 3,
374
+ projection: "EPSG:1234",
375
+ },
376
+ };
377
+ contextNew = {
378
+ ...SAMPLE_CONTEXT,
379
+ view: {
380
+ center: [5, 10],
381
+ zoom: 3,
382
+ },
383
+ };
384
+ });
385
+ it("output the correct diff", () => {
386
+ diff = computeMapContextDiff(contextNew, contextOld);
387
+ expect(diff).toEqual({
388
+ layersAdded: [],
389
+ layersChanged: [],
390
+ layersRemoved: [],
391
+ layersReordered: [],
392
+ viewChanges: {
393
+ center: [5, 10],
394
+ zoom: 3,
395
+ projection: DEFAULT_MAP_VIEW_CRS,
396
+ },
397
+ });
398
+ });
399
+ });
333
400
  });
334
401
 
335
402
  describe("combined changes", () => {
@@ -7,6 +7,7 @@ import {
7
7
  MapContextLayerReordered,
8
8
  } from "../model/index.js";
9
9
  import { isLayerSame, isLayerSameAndUnchanged } from "./map-context-layer.js";
10
+ import { DEFAULT_MAP_VIEW_CRS } from "../constant/projections.js";
10
11
 
11
12
  /**
12
13
  * The following logic is produced by identifying layers in both context
@@ -99,6 +100,16 @@ export function computeMapContextDiff(
99
100
  nextContext.view !== previousContext.view ? nextContext.view : undefined;
100
101
  if (viewChanges !== null && viewChanges !== undefined) {
101
102
  viewChanges = { ...viewChanges }; // copy the view to avoid unexpected mutations
103
+
104
+ // only keep the projection if it changed between the two contexts
105
+ const prevProjection =
106
+ previousContext.view?.projection ?? DEFAULT_MAP_VIEW_CRS;
107
+ const nextProjection = nextContext.view?.projection ?? DEFAULT_MAP_VIEW_CRS;
108
+ if (nextProjection === prevProjection) {
109
+ delete viewChanges.projection;
110
+ } else {
111
+ viewChanges.projection = nextProjection;
112
+ }
102
113
  }
103
114
 
104
115
  return {