From 6f65e68a6c40e1ca063597937f1c1dd2a9e9d854 Mon Sep 17 00:00:00 2001 From: Alexian Masson <12852383+AlexianMasson@users.noreply.github.com> Date: Thu, 13 Nov 2025 09:44:59 +0100 Subject: [PATCH 1/3] feat: add createContextFromMap function to extract MapContext from OpenLayers maps Implements the inverse operation of createMapFromContext, enabling extraction of MapContext configuration from existing OpenLayers map instances (+ extractLayerModel + extractViewModel). --- .../openlayers/lib/map/create-context.test.ts | 534 ++++++++++++++++++ packages/openlayers/lib/map/create-context.ts | 264 +++++++++ packages/openlayers/lib/map/index.ts | 1 + 3 files changed, 799 insertions(+) create mode 100644 packages/openlayers/lib/map/create-context.test.ts create mode 100644 packages/openlayers/lib/map/create-context.ts diff --git a/packages/openlayers/lib/map/create-context.test.ts b/packages/openlayers/lib/map/create-context.test.ts new file mode 100644 index 0000000..e368faf --- /dev/null +++ b/packages/openlayers/lib/map/create-context.test.ts @@ -0,0 +1,534 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import Map from "ol/Map"; +import View from "ol/View"; +import { + MapContext, + MapContextLayer, + MapContextLayerXyz, + MapContextLayerWms, + MapContextLayerWfs, + MapContextLayerGeojson, + MapContextLayerWmts, +} from "@geospatial-sdk/core"; +import { + MAP_CTX_LAYER_XYZ_FIXTURE, + MAP_CTX_LAYER_WMS_FIXTURE, + MAP_CTX_LAYER_GEOJSON_FIXTURE, + MAP_CTX_LAYER_GEOJSON_REMOTE_FIXTURE, + MAP_CTX_LAYER_WFS_FIXTURE, + MAP_CTX_LAYER_WMTS_FIXTURE, + MAP_CTX_LAYER_MVT_FIXTURE, + MAP_CTX_LAYER_OGCAPI_FIXTURE, + MAP_CTX_VIEW_FIXTURE, + MAP_CTX_FIXTURE, +} from "@geospatial-sdk/core/fixtures/map-context.fixtures"; +import { createLayer, createMapFromContext, createView } from "./create-map"; +import { createContextFromMap } from "./create-context"; + +// Mock the WFS endpoint to make it resolve immediately +vi.mock("@camptocamp/ogc-client", async () => { + const actual = await vi.importActual("@camptocamp/ogc-client"); + return { + ...actual, + WfsEndpoint: class MockWfsEndpoint { + constructor(public url: string) {} + async isReady() { + // Return resolved promise to make the endpoint immediately ready + return Promise.resolve(this); + } + getSingleFeatureTypeName() { + return null; + } + getFeatureUrl(typeName: string, options: any) { + return `${this.url}?service=WFS&version=1.1.0&request=GetFeature&outputFormat=application%2Fjson&typename=${typeName}&srsname=${options.outputCrs}&bbox=${options.extent.join("%2C")}&maxFeatures=${options.maxFeatures}`; + } + }, + }; +}); + +describe("createContextFromMap", () => { + describe("#extractLayerModel", () => { + describe("XYZ", () => { + let layerModel: MapContextLayer; + let extractedLayerModel: MapContextLayer; + + beforeEach(async () => { + layerModel = MAP_CTX_LAYER_XYZ_FIXTURE; + const layer = await createLayer(layerModel); + const map = new Map({}); + map.addLayer(layer); + const context = createContextFromMap(map); + extractedLayerModel = context.layers[0]; + }); + + it("extracts the correct layer type", () => { + expect(extractedLayerModel.type).toBe("xyz"); + }); + + it("extracts the correct url", () => { + // OpenLayers expands the URL template, so we check for the first URL + const url = (extractedLayerModel as MapContextLayerXyz).url; + expect(url).toContain("tile.openstreetmap.org"); + expect(url).toContain("/{z}/{x}/{y}.png"); + }); + + it("extracts layer properties", () => { + expect(extractedLayerModel.visibility).toBe(true); + expect(extractedLayerModel.opacity).toBe(1); + }); + }); + + describe("XYZ with custom properties", () => { + let layerModel: MapContextLayer; + let extractedLayerModel: MapContextLayer; + + beforeEach(async () => { + layerModel = { + ...MAP_CTX_LAYER_XYZ_FIXTURE, + visibility: false, + opacity: 0.7, + label: "Test Layer", + attributions: "Test Attribution", + }; + const layer = await createLayer(layerModel); + const map = new Map({}); + map.addLayer(layer); + const context = createContextFromMap(map); + extractedLayerModel = context.layers[0]; + }); + + it("extracts custom visibility", () => { + expect(extractedLayerModel.visibility).toBe(false); + }); + + it("extracts custom opacity", () => { + expect(extractedLayerModel.opacity).toBe(0.7); + }); + + it("extracts custom label", () => { + expect(extractedLayerModel.label).toBe("Test Layer"); + }); + + it("extracts custom attributions", () => { + expect(extractedLayerModel.attributions).toBe("Test Attribution"); + }); + }); + + describe("WMS", () => { + let layerModel: MapContextLayer; + let extractedLayerModel: MapContextLayer; + + beforeEach(async () => { + layerModel = MAP_CTX_LAYER_WMS_FIXTURE; + const layer = await createLayer(layerModel); + const map = new Map({}); + map.addLayer(layer); + const context = createContextFromMap(map); + extractedLayerModel = context.layers[0]; + }); + + it("extracts the correct layer type", () => { + expect(extractedLayerModel.type).toBe("wms"); + }); + + it("extracts the correct url", () => { + expect((extractedLayerModel as MapContextLayerWms).url).toBe( + "https://www.geograndest.fr/geoserver/region-grand-est/ows", + ); + }); + + it("extracts the correct layer name", () => { + // The name is stored without the 'ms:' prefix after processing + expect((extractedLayerModel as MapContextLayerWms).name).toBe( + "commune_actuelle_3857", + ); + }); + + it("extracts the style", () => { + expect((extractedLayerModel as MapContextLayerWms).style).toBe("default"); + }); + + it("extracts layer properties", () => { + expect(extractedLayerModel.visibility).toBe(false); + expect(extractedLayerModel.opacity).toBe(0.5); + expect(extractedLayerModel.label).toBe("Communes"); + }); + + it("extracts attributions", () => { + expect(extractedLayerModel.attributions).toBe("camptocamp"); + }); + }); + + describe("WFS", () => { + let layerModel: MapContextLayer; + let extractedLayerModel: MapContextLayer; + + beforeEach(async () => { + layerModel = MAP_CTX_LAYER_WFS_FIXTURE; + const layer = await createLayer(layerModel); + const map = new Map({}); + map.addLayer(layer); + // Wait for the promise chain to complete (mock resolves immediately but .then is async) + await vi.waitFor(() => { + const source = layer.getSource(); + if (!source) { + throw new Error("Source not ready yet"); + } + }, { timeout: 1000, interval: 10 }); + const context = createContextFromMap(map); + extractedLayerModel = context.layers[0]; + }); + + it("extracts the correct layer type", () => { + expect(extractedLayerModel?.type).toBe("wfs"); + }); + + it("extracts the base url", () => { + expect((extractedLayerModel as MapContextLayerWfs)?.url).toBe( + "https://www.geograndest.fr/geoserver/region-grand-est/ows", + ); + }); + + it("extracts layer properties", () => { + expect(extractedLayerModel?.visibility).toBe(true); + expect(extractedLayerModel?.opacity).toBe(0.5); + expect(extractedLayerModel?.label).toBe("Communes"); + }); + + it("extracts attributions", () => { + expect(extractedLayerModel?.attributions).toBe("camptocamp"); + }); + }); + + describe("GEOJSON with inline data", () => { + let layerModel: MapContextLayer; + let extractedLayerModel: MapContextLayer; + + beforeEach(async () => { + layerModel = MAP_CTX_LAYER_GEOJSON_FIXTURE; + const layer = await createLayer(layerModel); + const map = new Map({}); + map.addLayer(layer); + const context = createContextFromMap(map); + extractedLayerModel = context.layers[0]; + }); + + it("extracts the correct layer type", () => { + expect(extractedLayerModel.type).toBe("geojson"); + }); + + it("extracts the feature data", () => { + const geojsonModel = extractedLayerModel as MapContextLayerGeojson; + expect(geojsonModel.data).toBeDefined(); + const data = geojsonModel.data as any; + expect(data.type).toBe("FeatureCollection"); + expect(data.features).toBeDefined(); + expect(data.features.length).toBeGreaterThan(0); + }); + + it("extracts layer properties", () => { + expect(extractedLayerModel.visibility).toBe(true); + expect(extractedLayerModel.opacity).toBe(0.8); + expect(extractedLayerModel.label).toBe("Regions"); + }); + }); + + describe("GEOJSON with remote url", () => { + let layerModel: MapContextLayer; + let extractedLayerModel: MapContextLayer; + + beforeEach(async () => { + layerModel = MAP_CTX_LAYER_GEOJSON_REMOTE_FIXTURE; + const layer = await createLayer(layerModel); + const map = new Map({}); + map.addLayer(layer); + const context = createContextFromMap(map); + extractedLayerModel = context.layers[0]; + }); + + it("extracts the correct layer type", () => { + expect(extractedLayerModel.type).toBe("geojson"); + }); + + it("extracts the url", () => { + // The fixture uses a different URL format + const url = (extractedLayerModel as MapContextLayerGeojson).url; + expect(url).toBeDefined(); + expect(typeof url).toBe("string"); + }); + + it("does not have inline data", () => { + expect((extractedLayerModel as MapContextLayerGeojson).data).toBeUndefined(); + }); + + it("extracts layer properties", () => { + expect(extractedLayerModel.visibility).toBe(true); + expect(extractedLayerModel.opacity).toBe(1); + }); + }); + + describe("WMTS", () => { + let layerModel: MapContextLayer; + let extractedLayerModel: MapContextLayer; + + beforeEach(async () => { + layerModel = MAP_CTX_LAYER_WMTS_FIXTURE; + const layer = await createLayer(layerModel); + const map = new Map({}); + map.addLayer(layer); + // Wait for async source to be ready + await new Promise((resolve) => setTimeout(resolve, 100)); + const context = createContextFromMap(map); + extractedLayerModel = context.layers[0]; + }); + + it("extracts the correct layer type", () => { + expect(extractedLayerModel?.type).toBe("wmts"); + }); + + it("extracts the url", () => { + const url = (extractedLayerModel as MapContextLayerWmts)?.url; + expect(url).toBeDefined(); + expect(url).toContain("services.geo.sg.ch"); + }); + + it("extracts the layer name", () => { + expect((extractedLayerModel as MapContextLayerWmts)?.name).toBeDefined(); + }); + + it("extracts layer properties", () => { + expect(extractedLayerModel?.visibility).toBe(true); + expect(extractedLayerModel?.opacity).toBe(1); + }); + }); + + describe("MVT (Vector Tiles)", () => { + let layerModel: MapContextLayer; + let extractedLayerModel: MapContextLayer; + + beforeEach(async () => { + layerModel = MAP_CTX_LAYER_MVT_FIXTURE; + const layer = await createLayer(layerModel); + const map = new Map({}); + map.addLayer(layer); + const context = createContextFromMap(map); + extractedLayerModel = context.layers[0]; + }); + + it("extracts the correct layer type", () => { + expect(extractedLayerModel.type).toBe("xyz"); + }); + + it("extracts the tile format", () => { + expect((extractedLayerModel as MapContextLayerXyz).tileFormat).toBe( + "application/vnd.mapbox-vector-tile", + ); + }); + + it("extracts the url", () => { + expect((extractedLayerModel as MapContextLayerXyz).url).toBe( + "https://data.geopf.fr/tms/1.0.0/PLAN.IGN/{z}/{x}/{y}.pbf", + ); + }); + + it("extracts layer properties", () => { + expect(extractedLayerModel.visibility).toBe(true); + expect(extractedLayerModel.opacity).toBe(1); + }); + }); + + describe("OGCAPI", () => { + let layerModel: MapContextLayer; + let extractedLayerModel: MapContextLayer; + + beforeEach(async () => { + layerModel = MAP_CTX_LAYER_OGCAPI_FIXTURE; + const layer = await createLayer(layerModel); + const map = new Map({}); + map.addLayer(layer); + const context = createContextFromMap(map); + extractedLayerModel = context.layers[0]; + }); + + it("extracts the correct layer type", () => { + expect(extractedLayerModel.type).toBe("geojson"); + }); + + it("extracts the url", () => { + expect((extractedLayerModel as MapContextLayerGeojson).url).toBe( + "https://demo.ldproxy.net/zoomstack/collections/airports/items?f=json", + ); + }); + + it("extracts layer properties", () => { + expect(extractedLayerModel.visibility).toBe(true); + expect(extractedLayerModel.opacity).toBe(1); + }); + }); + }); + + describe("#extractViewModel", () => { + describe("from center and zoom", () => { + let viewModel: any; + let extractedView: any; + + beforeEach(async () => { + viewModel = MAP_CTX_VIEW_FIXTURE; + const map = new Map({}); + map.setSize([100, 100]); + const view = createView(viewModel, map); + map.setView(view); + const context = createContextFromMap(map); + extractedView = context.view; + }); + + it("extracts a view", () => { + expect(extractedView).toBeTruthy(); + }); + + it("extracts the center", () => { + expect(extractedView.center).toBeDefined(); + expect(extractedView.center).toHaveLength(2); + // Compare with some tolerance due to projection transformations + const viewFixture = MAP_CTX_VIEW_FIXTURE as any; + expect(extractedView.center[0]).toBeCloseTo(viewFixture.center[0], 5); + expect(extractedView.center[1]).toBeCloseTo(viewFixture.center[1], 5); + }); + + it("extracts the zoom", () => { + const viewFixture = MAP_CTX_VIEW_FIXTURE as any; + expect(extractedView.zoom).toBe(viewFixture.zoom); + }); + }); + + describe("with null view", () => { + let extractedView: any; + + beforeEach(() => { + const map = new Map({}); + const view = createView(null, map); + map.setView(view); + const context = createContextFromMap(map); + extractedView = context.view; + }); + + it("extracts a view with default values", () => { + expect(extractedView).toBeTruthy(); + expect(extractedView.center).toEqual([0, 0]); + expect(extractedView.zoom).toBe(0); + }); + }); + }); + + describe("#createContextFromMap", () => { + describe("full map context", () => { + let originalContext: MapContext; + let extractedContext: MapContext; + + beforeEach(async () => { + originalContext = MAP_CTX_FIXTURE; + const map = await createMapFromContext(originalContext); + extractedContext = createContextFromMap(map); + }); + + it("extracts the correct number of layers", () => { + expect(extractedContext.layers).toHaveLength( + originalContext.layers.length, + ); + }); + + it("extracts all layer types correctly", () => { + expect(extractedContext.layers[0].type).toBe( + originalContext.layers[0].type, + ); + expect(extractedContext.layers[1].type).toBe( + originalContext.layers[1].type, + ); + expect(extractedContext.layers[2].type).toBe( + originalContext.layers[2].type, + ); + }); + + it("extracts the view", () => { + expect(extractedContext.view).toBeTruthy(); + expect((extractedContext.view as any)?.center).toBeDefined(); + expect((extractedContext.view as any)?.zoom).toBe((originalContext.view as any)?.zoom); + }); + }); + + describe("map with multiple layers of different types", () => { + let extractedContext: MapContext; + + beforeEach(async () => { + const map = new Map({}); + const xyzLayer = await createLayer(MAP_CTX_LAYER_XYZ_FIXTURE); + const wmsLayer = await createLayer(MAP_CTX_LAYER_WMS_FIXTURE); + const geojsonLayer = await createLayer(MAP_CTX_LAYER_GEOJSON_FIXTURE); + map.addLayer(xyzLayer); + map.addLayer(wmsLayer); + map.addLayer(geojsonLayer); + map.setView(createView(MAP_CTX_VIEW_FIXTURE, map)); + extractedContext = createContextFromMap(map); + }); + + it("extracts all layers in correct order", () => { + expect(extractedContext.layers).toHaveLength(3); + expect(extractedContext.layers[0].type).toBe("xyz"); + expect(extractedContext.layers[1].type).toBe("wms"); + expect(extractedContext.layers[2].type).toBe("geojson"); + }); + + it("extracts the view", () => { + expect(extractedContext.view).toBeTruthy(); + }); + }); + + describe("empty map", () => { + let extractedContext: MapContext; + + beforeEach(() => { + const map = new Map({}); + map.setView(new View({ center: [0, 0], zoom: 1 })); + extractedContext = createContextFromMap(map); + }); + + it("returns empty layers array", () => { + expect(extractedContext.layers).toEqual([]); + }); + + it("extracts the view", () => { + expect(extractedContext.view).toBeTruthy(); + expect((extractedContext.view as any)?.center).toEqual([0, 0]); + expect((extractedContext.view as any)?.zoom).toBe(1); + }); + }); + + describe("roundtrip test", () => { + it("should produce equivalent context after roundtrip", async () => { + const originalContext = MAP_CTX_FIXTURE; + const map = await createMapFromContext(originalContext); + const extractedContext = createContextFromMap(map); + const map2 = await createMapFromContext(extractedContext); + const extractedContext2 = createContextFromMap(map2); + + // Compare layer types + expect(extractedContext2.layers.map((l) => l.type)).toEqual( + extractedContext.layers.map((l) => l.type), + ); + + // Compare view zoom + expect((extractedContext2.view as any)?.zoom).toBe((extractedContext.view as any)?.zoom); + + // Compare view center with tolerance + expect((extractedContext2.view as any)?.center[0]).toBeCloseTo( + (extractedContext.view as any)?.center[0] || 0, + 5, + ); + expect((extractedContext2.view as any)?.center[1]).toBeCloseTo( + (extractedContext.view as any)?.center[1] || 0, + 5, + ); + }); + }); + }); +}); diff --git a/packages/openlayers/lib/map/create-context.ts b/packages/openlayers/lib/map/create-context.ts new file mode 100644 index 0000000..6930d7d --- /dev/null +++ b/packages/openlayers/lib/map/create-context.ts @@ -0,0 +1,264 @@ +import Map from "ol/Map"; +import { MapContext, MapContextLayer, MapContextLayerXyz, MapContextView } from "@geospatial-sdk/core"; +import { toLonLat, get as getProjection } from "ol/proj"; +import Layer from "ol/layer/Layer"; +import TileLayer from "ol/layer/Tile"; +import VectorLayer from "ol/layer/Vector"; +import VectorTileLayer from "ol/layer/VectorTile"; +import XYZ from "ol/source/XYZ"; +import TileWMS from "ol/source/TileWMS"; +import VectorSource from "ol/source/Vector"; +import VectorTile from "ol/source/VectorTile"; +import WMTS from "ol/source/WMTS"; +import OGCMapTile from "ol/source/OGCMapTile"; +import OGCVectorTile from "ol/source/OGCVectorTile"; +import GeoJSON from "ol/format/GeoJSON"; + +const GEOJSON = new GeoJSON(); + +/** + * Extracts layer model information from an OpenLayers layer + * @param layer + */ +function extractLayerModel(layer: Layer): MapContextLayer | null { + const source = layer.getSource(); + + if (!source) { + return null; + } + + // Common properties + const attributionsFn = source.getAttributions(); + let attributionsString: string | undefined = undefined; + + if (attributionsFn) { + // @ts-expect-error- OpenLayers AttributionLike can be called without arguments + const attributionsResult = attributionsFn(); + if (attributionsResult) { + attributionsString = Array.isArray(attributionsResult) + ? attributionsResult.join(", ") + : attributionsResult; + } + } + + const baseProperties = { + visibility: layer.getVisible(), + opacity: layer.getOpacity(), + label: layer.get("label"), + ...(attributionsString && { attributions: attributionsString }), + }; + + // Vector tile layers (MVT) + if (layer instanceof VectorTileLayer && source instanceof VectorTile) { + const url = source.getUrls()?.[0]; + if (!url) { + return null; + } + return { + type: "xyz", + url, + tileFormat: "application/vnd.mapbox-vector-tile", + ...baseProperties, + }; + } + + // XYZ layers + if (layer instanceof TileLayer && source instanceof XYZ) { + const url = source.getUrls()?.[0]; + + if (!url) { + return null; + } + + return { + type: "xyz", + url, + ...baseProperties, + }; + } + + // WMS layers + if (layer instanceof TileLayer && source instanceof TileWMS) { + const params = source.getParams(); + const url = source.getUrls()?.[0]; + + if (!url || !params.LAYERS) { + return null; + } + + return { + type: "wms", + url, + name: params.LAYERS, + ...(params.STYLES && { style: params.STYLES }), + ...baseProperties, + }; + } + + // WMTS layers + if (layer instanceof TileLayer && source instanceof WMTS) { + const url = source.getUrls()?.[0]; + const layerName = source.getLayer(); + + if (!url || !layerName) { + return null; + } + + return { + type: "wmts", + url, + name: layerName, + ...baseProperties, + }; + } + + // OGC API - Map Tiles + if (layer instanceof TileLayer && source instanceof OGCMapTile) { + const url = source.getUrls()?.[0]; + if (!url) { + return null; + } + return { + type: "ogcapi", + url, + collection: "", + useTiles: "map", + ...baseProperties, + }; + } + + // OGC API - Vector Tiles + if (layer instanceof VectorTileLayer && source instanceof OGCVectorTile) { + const url = source.getUrls()?.[0]; + if (!url) { + return null; + } + return { + type: "ogcapi", + url, + collection: "", + useTiles: "vector", + ...baseProperties, + }; + } + + // Vector layers (GeoJSON, WFS) + if (layer instanceof VectorLayer && source instanceof VectorSource) { + const getStyle = layer.getStyle(); + let style: string | undefined = undefined; + if (getStyle && typeof getStyle === "string") { + style = getStyle; + } + else { + style = undefined; + } + + const url = source.getUrl(); + + // WFS layers have a function URL, not a string + if (url && typeof url === "function") { + // Call the function with dummy parameters to get the actual URL + const dummyExtent: [number, number, number, number] = [0, 0, 1, 1]; + const dummyResolution = 1; + const dummyProjection = getProjection("EPSG:3857")!; + const urlString = url(dummyExtent, dummyResolution, dummyProjection); + + // Extract the base URL (before the ?) + const baseUrl = urlString.split("?")[0]; + + return { + type: "wfs", + url: baseUrl, + featureType: "", + style, + ...baseProperties, + }; + } + + if (url && typeof url === "string") { + // Check if it's a WFS layer by looking at the URL + if (url.includes("wfs") || url.includes("WFS")) { + return { + type: "wfs", + url: url.split("?")[0], + featureType: "", + style, + ...baseProperties, + }; + } + // Otherwise, treat as GeoJSON + return { + type: "geojson", + url, + style, + ...baseProperties, + }; + } + + // GeoJSON with inline data + const features = source.getFeatures(); + if (features.length > 0) { + const featureCollection = GEOJSON.writeFeaturesObject(features, { + featureProjection: "EPSG:3857", + dataProjection: "EPSG:4326", + }); + return { + type: "geojson", + data: featureCollection, + ...baseProperties, + }; + } + else { + return null; + } + } + + return null; +} + +/** + * Extracts view information from an OpenLayers map + * @param map + */ +function extractViewModel(map: Map): MapContextView | null { + const view = map.getView(); + if (!view) { + return null; + } + + const center = view.getCenter(); + const zoom = view.getZoom(); + + if (!center || zoom === undefined) { + return null; + } + + const centerLonLat = toLonLat(center, view.getProjection()); + + return { + center: centerLonLat as [number, number], + zoom, + }; +} + +/** + * Create a MapContext from an OpenLayers map + * @param map + */ +export function createContextFromMap(map: Map): MapContext { + const layers: MapContextLayer[] = []; + + map.getLayers().forEach((layer) => { + const layerModel = extractLayerModel(layer as Layer); + if (layerModel) { + layers.push(layerModel); + } + }); + + const view = extractViewModel(map); + + return { + layers, + view, + }; +} diff --git a/packages/openlayers/lib/map/index.ts b/packages/openlayers/lib/map/index.ts index c09ab91..c28cfaa 100644 --- a/packages/openlayers/lib/map/index.ts +++ b/packages/openlayers/lib/map/index.ts @@ -1,4 +1,5 @@ export * from "./styles"; export { createMapFromContext, resetMapFromContext } from "./create-map"; +export { createContextFromMap } from "./create-context"; export { applyContextDiffToMap } from "./apply-context-diff"; export { listen } from "./register-events"; From 76f7c94ced75bcf32cd046df7f38665dfc108b1a Mon Sep 17 00:00:00 2001 From: Alexian Masson <12852383+AlexianMasson@users.noreply.github.com> Date: Thu, 13 Nov 2025 12:44:42 +0100 Subject: [PATCH 2/3] formatting --- .../openlayers/lib/map/create-context.test.ts | 35 ++++++++++++------ packages/openlayers/lib/map/create-context.ts | 36 ++++++++++--------- 2 files changed, 43 insertions(+), 28 deletions(-) diff --git a/packages/openlayers/lib/map/create-context.test.ts b/packages/openlayers/lib/map/create-context.test.ts index e368faf..49fdf0c 100644 --- a/packages/openlayers/lib/map/create-context.test.ts +++ b/packages/openlayers/lib/map/create-context.test.ts @@ -145,7 +145,9 @@ describe("createContextFromMap", () => { }); it("extracts the style", () => { - expect((extractedLayerModel as MapContextLayerWms).style).toBe("default"); + expect((extractedLayerModel as MapContextLayerWms).style).toBe( + "default", + ); }); it("extracts layer properties", () => { @@ -169,12 +171,15 @@ describe("createContextFromMap", () => { const map = new Map({}); map.addLayer(layer); // Wait for the promise chain to complete (mock resolves immediately but .then is async) - await vi.waitFor(() => { - const source = layer.getSource(); - if (!source) { - throw new Error("Source not ready yet"); - } - }, { timeout: 1000, interval: 10 }); + await vi.waitFor( + () => { + const source = layer.getSource(); + if (!source) { + throw new Error("Source not ready yet"); + } + }, + { timeout: 1000, interval: 10 }, + ); const context = createContextFromMap(map); extractedLayerModel = context.layers[0]; }); @@ -258,7 +263,9 @@ describe("createContextFromMap", () => { }); it("does not have inline data", () => { - expect((extractedLayerModel as MapContextLayerGeojson).data).toBeUndefined(); + expect( + (extractedLayerModel as MapContextLayerGeojson).data, + ).toBeUndefined(); }); it("extracts layer properties", () => { @@ -293,7 +300,9 @@ describe("createContextFromMap", () => { }); it("extracts the layer name", () => { - expect((extractedLayerModel as MapContextLayerWmts)?.name).toBeDefined(); + expect( + (extractedLayerModel as MapContextLayerWmts)?.name, + ).toBeDefined(); }); it("extracts layer properties", () => { @@ -452,7 +461,9 @@ describe("createContextFromMap", () => { it("extracts the view", () => { expect(extractedContext.view).toBeTruthy(); expect((extractedContext.view as any)?.center).toBeDefined(); - expect((extractedContext.view as any)?.zoom).toBe((originalContext.view as any)?.zoom); + expect((extractedContext.view as any)?.zoom).toBe( + (originalContext.view as any)?.zoom, + ); }); }); @@ -517,7 +528,9 @@ describe("createContextFromMap", () => { ); // Compare view zoom - expect((extractedContext2.view as any)?.zoom).toBe((extractedContext.view as any)?.zoom); + expect((extractedContext2.view as any)?.zoom).toBe( + (extractedContext.view as any)?.zoom, + ); // Compare view center with tolerance expect((extractedContext2.view as any)?.center[0]).toBeCloseTo( diff --git a/packages/openlayers/lib/map/create-context.ts b/packages/openlayers/lib/map/create-context.ts index 6930d7d..80aa2b9 100644 --- a/packages/openlayers/lib/map/create-context.ts +++ b/packages/openlayers/lib/map/create-context.ts @@ -1,5 +1,9 @@ import Map from "ol/Map"; -import { MapContext, MapContextLayer, MapContextLayerXyz, MapContextView } from "@geospatial-sdk/core"; +import { + MapContext, + MapContextLayer, + MapContextView, +} from "@geospatial-sdk/core"; import { toLonLat, get as getProjection } from "ol/proj"; import Layer from "ol/layer/Layer"; import TileLayer from "ol/layer/Tile"; @@ -22,7 +26,7 @@ const GEOJSON = new GeoJSON(); */ function extractLayerModel(layer: Layer): MapContextLayer | null { const source = layer.getSource(); - + if (!source) { return null; } @@ -30,12 +34,12 @@ function extractLayerModel(layer: Layer): MapContextLayer | null { // Common properties const attributionsFn = source.getAttributions(); let attributionsString: string | undefined = undefined; - + if (attributionsFn) { // @ts-expect-error- OpenLayers AttributionLike can be called without arguments const attributionsResult = attributionsFn(); if (attributionsResult) { - attributionsString = Array.isArray(attributionsResult) + attributionsString = Array.isArray(attributionsResult) ? attributionsResult.join(", ") : attributionsResult; } @@ -143,18 +147,17 @@ function extractLayerModel(layer: Layer): MapContextLayer | null { } // Vector layers (GeoJSON, WFS) - if (layer instanceof VectorLayer && source instanceof VectorSource) { + if (layer instanceof VectorLayer && source instanceof VectorSource) { const getStyle = layer.getStyle(); let style: string | undefined = undefined; if (getStyle && typeof getStyle === "string") { style = getStyle; - } - else { - style = undefined; + } else { + style = undefined; } const url = source.getUrl(); - + // WFS layers have a function URL, not a string if (url && typeof url === "function") { // Call the function with dummy parameters to get the actual URL @@ -162,10 +165,10 @@ function extractLayerModel(layer: Layer): MapContextLayer | null { const dummyResolution = 1; const dummyProjection = getProjection("EPSG:3857")!; const urlString = url(dummyExtent, dummyResolution, dummyProjection); - + // Extract the base URL (before the ?) const baseUrl = urlString.split("?")[0]; - + return { type: "wfs", url: baseUrl, @@ -174,7 +177,7 @@ function extractLayerModel(layer: Layer): MapContextLayer | null { ...baseProperties, }; } - + if (url && typeof url === "string") { // Check if it's a WFS layer by looking at the URL if (url.includes("wfs") || url.includes("WFS")) { @@ -207,8 +210,7 @@ function extractLayerModel(layer: Layer): MapContextLayer | null { data: featureCollection, ...baseProperties, }; - } - else { + } else { return null; } } @@ -234,7 +236,7 @@ function extractViewModel(map: Map): MapContextView | null { } const centerLonLat = toLonLat(center, view.getProjection()); - + return { center: centerLonLat as [number, number], zoom, @@ -247,11 +249,11 @@ function extractViewModel(map: Map): MapContextView | null { */ export function createContextFromMap(map: Map): MapContext { const layers: MapContextLayer[] = []; - + map.getLayers().forEach((layer) => { const layerModel = extractLayerModel(layer as Layer); if (layerModel) { - layers.push(layerModel); + layers.push(layerModel); } }); From 8dad2ae443ace0024db17fc2e6e2007d1c6cff99 Mon Sep 17 00:00:00 2001 From: Alexian Masson <12852383+AlexianMasson@users.noreply.github.com> Date: Thu, 13 Nov 2025 17:27:11 +0100 Subject: [PATCH 3/3] chore: rename createContext to readContext --- packages/openlayers/lib/map/index.ts | 2 +- ...e-context.test.ts => read-context.test.ts} | 38 +++++++++---------- .../{create-context.ts => read-context.ts} | 2 +- 3 files changed, 21 insertions(+), 21 deletions(-) rename packages/openlayers/lib/map/{create-context.test.ts => read-context.test.ts} (94%) rename packages/openlayers/lib/map/{create-context.ts => read-context.ts} (99%) diff --git a/packages/openlayers/lib/map/index.ts b/packages/openlayers/lib/map/index.ts index c28cfaa..f9d4e60 100644 --- a/packages/openlayers/lib/map/index.ts +++ b/packages/openlayers/lib/map/index.ts @@ -1,5 +1,5 @@ export * from "./styles"; export { createMapFromContext, resetMapFromContext } from "./create-map"; -export { createContextFromMap } from "./create-context"; +export { readContextFromMap } from "./read-context"; export { applyContextDiffToMap } from "./apply-context-diff"; export { listen } from "./register-events"; diff --git a/packages/openlayers/lib/map/create-context.test.ts b/packages/openlayers/lib/map/read-context.test.ts similarity index 94% rename from packages/openlayers/lib/map/create-context.test.ts rename to packages/openlayers/lib/map/read-context.test.ts index 49fdf0c..1b2d82b 100644 --- a/packages/openlayers/lib/map/create-context.test.ts +++ b/packages/openlayers/lib/map/read-context.test.ts @@ -23,7 +23,7 @@ import { MAP_CTX_FIXTURE, } from "@geospatial-sdk/core/fixtures/map-context.fixtures"; import { createLayer, createMapFromContext, createView } from "./create-map"; -import { createContextFromMap } from "./create-context"; +import { readContextFromMap } from "./read-context"; // Mock the WFS endpoint to make it resolve immediately vi.mock("@camptocamp/ogc-client", async () => { @@ -46,7 +46,7 @@ vi.mock("@camptocamp/ogc-client", async () => { }; }); -describe("createContextFromMap", () => { +describe("readContextFromMap", () => { describe("#extractLayerModel", () => { describe("XYZ", () => { let layerModel: MapContextLayer; @@ -57,7 +57,7 @@ describe("createContextFromMap", () => { const layer = await createLayer(layerModel); const map = new Map({}); map.addLayer(layer); - const context = createContextFromMap(map); + const context = readContextFromMap(map); extractedLayerModel = context.layers[0]; }); @@ -93,7 +93,7 @@ describe("createContextFromMap", () => { const layer = await createLayer(layerModel); const map = new Map({}); map.addLayer(layer); - const context = createContextFromMap(map); + const context = readContextFromMap(map); extractedLayerModel = context.layers[0]; }); @@ -123,7 +123,7 @@ describe("createContextFromMap", () => { const layer = await createLayer(layerModel); const map = new Map({}); map.addLayer(layer); - const context = createContextFromMap(map); + const context = readContextFromMap(map); extractedLayerModel = context.layers[0]; }); @@ -180,7 +180,7 @@ describe("createContextFromMap", () => { }, { timeout: 1000, interval: 10 }, ); - const context = createContextFromMap(map); + const context = readContextFromMap(map); extractedLayerModel = context.layers[0]; }); @@ -214,7 +214,7 @@ describe("createContextFromMap", () => { const layer = await createLayer(layerModel); const map = new Map({}); map.addLayer(layer); - const context = createContextFromMap(map); + const context = readContextFromMap(map); extractedLayerModel = context.layers[0]; }); @@ -247,7 +247,7 @@ describe("createContextFromMap", () => { const layer = await createLayer(layerModel); const map = new Map({}); map.addLayer(layer); - const context = createContextFromMap(map); + const context = readContextFromMap(map); extractedLayerModel = context.layers[0]; }); @@ -285,7 +285,7 @@ describe("createContextFromMap", () => { map.addLayer(layer); // Wait for async source to be ready await new Promise((resolve) => setTimeout(resolve, 100)); - const context = createContextFromMap(map); + const context = readContextFromMap(map); extractedLayerModel = context.layers[0]; }); @@ -320,7 +320,7 @@ describe("createContextFromMap", () => { const layer = await createLayer(layerModel); const map = new Map({}); map.addLayer(layer); - const context = createContextFromMap(map); + const context = readContextFromMap(map); extractedLayerModel = context.layers[0]; }); @@ -355,7 +355,7 @@ describe("createContextFromMap", () => { const layer = await createLayer(layerModel); const map = new Map({}); map.addLayer(layer); - const context = createContextFromMap(map); + const context = readContextFromMap(map); extractedLayerModel = context.layers[0]; }); @@ -387,7 +387,7 @@ describe("createContextFromMap", () => { map.setSize([100, 100]); const view = createView(viewModel, map); map.setView(view); - const context = createContextFromMap(map); + const context = readContextFromMap(map); extractedView = context.view; }); @@ -417,7 +417,7 @@ describe("createContextFromMap", () => { const map = new Map({}); const view = createView(null, map); map.setView(view); - const context = createContextFromMap(map); + const context = readContextFromMap(map); extractedView = context.view; }); @@ -429,7 +429,7 @@ describe("createContextFromMap", () => { }); }); - describe("#createContextFromMap", () => { + describe("#readContextFromMap", () => { describe("full map context", () => { let originalContext: MapContext; let extractedContext: MapContext; @@ -437,7 +437,7 @@ describe("createContextFromMap", () => { beforeEach(async () => { originalContext = MAP_CTX_FIXTURE; const map = await createMapFromContext(originalContext); - extractedContext = createContextFromMap(map); + extractedContext = readContextFromMap(map); }); it("extracts the correct number of layers", () => { @@ -479,7 +479,7 @@ describe("createContextFromMap", () => { map.addLayer(wmsLayer); map.addLayer(geojsonLayer); map.setView(createView(MAP_CTX_VIEW_FIXTURE, map)); - extractedContext = createContextFromMap(map); + extractedContext = readContextFromMap(map); }); it("extracts all layers in correct order", () => { @@ -500,7 +500,7 @@ describe("createContextFromMap", () => { beforeEach(() => { const map = new Map({}); map.setView(new View({ center: [0, 0], zoom: 1 })); - extractedContext = createContextFromMap(map); + extractedContext = readContextFromMap(map); }); it("returns empty layers array", () => { @@ -518,9 +518,9 @@ describe("createContextFromMap", () => { it("should produce equivalent context after roundtrip", async () => { const originalContext = MAP_CTX_FIXTURE; const map = await createMapFromContext(originalContext); - const extractedContext = createContextFromMap(map); + const extractedContext = readContextFromMap(map); const map2 = await createMapFromContext(extractedContext); - const extractedContext2 = createContextFromMap(map2); + const extractedContext2 = readContextFromMap(map2); // Compare layer types expect(extractedContext2.layers.map((l) => l.type)).toEqual( diff --git a/packages/openlayers/lib/map/create-context.ts b/packages/openlayers/lib/map/read-context.ts similarity index 99% rename from packages/openlayers/lib/map/create-context.ts rename to packages/openlayers/lib/map/read-context.ts index 80aa2b9..c1cbfd2 100644 --- a/packages/openlayers/lib/map/create-context.ts +++ b/packages/openlayers/lib/map/read-context.ts @@ -247,7 +247,7 @@ function extractViewModel(map: Map): MapContextView | null { * Create a MapContext from an OpenLayers map * @param map */ -export function createContextFromMap(map: Map): MapContext { +export function readContextFromMap(map: Map): MapContext { const layers: MapContextLayer[] = []; map.getLayers().forEach((layer) => {