From 980ea298e500554a3c7aaa8374fd9ebc3b7e6658 Mon Sep 17 00:00:00 2001 From: Jordon Smith Date: Fri, 3 Jul 2026 09:05:13 +0100 Subject: [PATCH] OL: WMS tile layer support for map style switcher --- demo/js/draw-ol.js | 4 +- demo/js/gep.js | 4 +- demo/js/mapStyles.js | 17 +++++- docs/api/map-style-config.md | 30 +++++++++-- .../beta/openlayers/src/utils/tileLayers.js | 31 ++++++++++- .../openlayers/src/utils/tileLayers.test.js | 54 ++++++++++++++++++- src/types.js | 15 ++++-- webpack.dev.mjs | 1 + 8 files changed, 141 insertions(+), 15 deletions(-) diff --git a/demo/js/draw-ol.js b/demo/js/draw-ol.js index e7a769e3..69c64fdd 100644 --- a/demo/js/draw-ol.js +++ b/demo/js/draw-ol.js @@ -1,6 +1,6 @@ // InteractiveMap with OpenLayers provider and draw-ol plugin import InteractiveMap from '../../src/index.js' -import { vtsMapStyles27700, ngdMapStyles27700 } from './mapStyles.js' +import { vtsMapStyles27700, ngdMapStyles27700, apgbAerialStyle } from './mapStyles.js' import { transformGeocodeRequest, transformVtsRequest27700 } from './auth.js' // Providers import openLayersProvider from '/providers/beta/openlayers/src/index.js' @@ -54,7 +54,7 @@ const interactiveMap = new InteractiveMap('map', { // readMapText: true, plugins: [ mapStylesPlugin({ - mapStyles: vtsMapStyles27700 // ngdMapStyles27700 + mapStyles: [...vtsMapStyles27700, apgbAerialStyle] // ngdMapStyles27700 }), searchPlugin({ transformRequest: transformGeocodeRequest, diff --git a/demo/js/gep.js b/demo/js/gep.js index e03ef086..53c2461b 100644 --- a/demo/js/gep.js +++ b/demo/js/gep.js @@ -1,5 +1,5 @@ import InteractiveMap from '../../src/index.js' -import { vtsMapStyles27700 } from './mapStyles.js' +import { vtsMapStyles27700, apgbAerialStyle } from './mapStyles.js' import { transformGeocodeRequest, transformVtsRequest27700 } from './auth.js' import '/plugins/beta/datasets/src/datasets.scss' // in a separate repo: import '@defra/interactive-map/plugins/datasets/css' // Providers @@ -37,7 +37,7 @@ const interactiveMap = new InteractiveMap('map', { containerHeight: '650px', plugins: [ mapStylesPlugin({ - mapStyles: vtsMapStyles27700 + mapStyles: [...vtsMapStyles27700, apgbAerialStyle] }), scaleBarPlugin({ units: 'metric' diff --git a/demo/js/mapStyles.js b/demo/js/mapStyles.js index 5ddd8638..22c4bef8 100755 --- a/demo/js/mapStyles.js +++ b/demo/js/mapStyles.js @@ -7,6 +7,7 @@ const OS_ATTRIBUTION = `Contains OS data ${String.fromCodePoint(COPYRIGHT_SYMBOL const OUTDOOR_THUMBNAIL = '/assets/images/outdoor-map-thumb.jpg' const DARK_THUMBNAIL = '/assets/images/dark-map-thumb.jpg' const BW_THUMBNAIL = '/assets/images/black-and-white-map-thumb.jpg' +const AERIAL_THUMBNAIL = '/assets/images/aerial-map-thumb.jpg' const BW_ID = 'black-and-white' const BW_LABEL = 'Black/White' @@ -79,7 +80,7 @@ const vtsMapStyles3857 = [{ label: 'Aerial', mapColorScheme: 'dark', url: process.env.AERIAL_URL, - thumbnail: '/assets/images/aerial-map-thumb.jpg', + thumbnail: AERIAL_THUMBNAIL, logoAltText: OS_LOGO_ALT, attribution: 'Test' }] @@ -116,6 +117,17 @@ const vtsMapStyles27700 = [{ attribution: 'Test' }] +const apgbAerialStyle = { + id: 'apgb-aerial-125mm', + label: 'Aerial 12.5cm', + type: 'wms', + url: process.env.APGB_WMS_URL, + params: { LAYERS: 'APGB_Latest_UK_125mm', BGCOLOR: '0x1E3448', TRANSPARENT: false }, + thumbnail: AERIAL_THUMBNAIL, + mapColorScheme: 'dark', + attribution: `${String.fromCodePoint(COPYRIGHT_SYMBOL)} Getmapping Plc and Bluesky International Limited ${(new Date()).getFullYear()}` +} + const ngdMapStyles27700 = [{ id: 'outdoor', label: 'Outdoor', @@ -184,5 +196,6 @@ export { vtsMapStyles3857, vtsMapStyles27700, ngdMapStyles27700, - mapsRasterStyles27700 + mapsRasterStyles27700, + apgbAerialStyle } diff --git a/docs/api/map-style-config.md b/docs/api/map-style-config.md index 2fe51782..34e7565b 100644 --- a/docs/api/map-style-config.md +++ b/docs/api/map-style-config.md @@ -14,14 +14,16 @@ Unique identifier for the style. Used to reference the style programmatically. --- ### `type` -**Type:** `'raster' | 'ogc-vt'` +**Type:** `'vector' | 'raster' | 'wms' | 'ogc-vt'` > [!NOTE] > This property is only relevant when using the **OpenLayers provider**. The ESRI and MapLibre providers always use the standard Mapbox GL vector tile format and ignore this property. -Allows the OpenLayers provider to support raster, standard vector tile, and OGC API - Tiles basemaps. When omitted, OpenLayers uses the standard Mapbox GL vector tile path — the same format ESRI and MapLibre always use. +Allows the OpenLayers provider to support raster, WMS, standard vector tile, and OGC API - Tiles basemaps. When omitted, OpenLayers uses the standard Mapbox GL vector tile path. +- `'vector'` — standard Mapbox GL vector tile path. `url` should point to a Mapbox GL style document. - `'raster'` — XYZ raster tile source. `url` should be a tile URL template with `{x}`, `{y}`, `{z}` placeholders. +- `'wms'` — WMS raster tile source. `url` should be the WMS service endpoint and `params` should provide the WMS request parameters. - `'ogc-vt'` — OGC API - Tiles vector tile source. `url` should point to an OGC style endpoint that returns a Mapbox GL style document. --- @@ -70,7 +72,7 @@ URL that returns a Mapbox GL style document (Mapbox Style Specification). ``` > [!NOTE] -> The **OpenLayers provider** supports two additional URL forms via the `type` property. +> The **OpenLayers provider** supports three additional URL forms via the `type` property. > > ```js > // type 'ogc-vt' — OS NGD OGC API - Tiles, Outdoor (EPSG:27700) @@ -84,10 +86,32 @@ URL that returns a Mapbox GL style document (Mapbox Style Specification). > type: 'raster', > url: 'https://api.os.uk/maps/raster/v1/zxy/Outdoor_27700/{z}/{x}/{y}.png?key=YOUR_API_KEY' > } +> +> // type 'wms' — APGB aerial imagery via Getmapping WMS (EPSG:27700) +> { +> type: 'wms', +> url: 'https://www.getmapping.com/GmWMS/YOUR_MEMBER_GUID/ApgbBng.wmsx', +> params: { LAYERS: 'APGB_Latest_UK_125mm' } +> } > ``` --- +### `params` +**Type:** `Object` + +WMS request parameters. Passed directly to the OpenLayers `TileWMS` source when `type` is `'wms'`. Most WMS GetMap requests should include `LAYERS`. + +```js +{ + type: 'wms', + url: 'https://www.getmapping.com/GmWMS/YOUR_MEMBER_GUID/ApgbBng.wmsx', + params: { LAYERS: 'APGB_Latest_UK_125mm' } +} +``` + +--- + ### `label` **Type:** `string` diff --git a/providers/beta/openlayers/src/utils/tileLayers.js b/providers/beta/openlayers/src/utils/tileLayers.js index 820b85d2..39893595 100644 --- a/providers/beta/openlayers/src/utils/tileLayers.js +++ b/providers/beta/openlayers/src/utils/tileLayers.js @@ -1,4 +1,5 @@ import XYZ from 'ol/source/XYZ.js' +import TileWMS from 'ol/source/TileWMS.js' import VectorTileSource from 'ol/source/VectorTile.js' import TileLayer from 'ol/layer/Tile.js' import VectorTileLayer from 'ol/layer/VectorTile.js' @@ -14,6 +15,7 @@ import { TILE_GRID_RESOLUTIONS, TILE_GRID_ORIGIN, TILE_SIZE } from '../defaults. recordStyleLayer(true) const CRS = 'EPSG:27700' +const SUPPORTED_MAP_STYLE_TYPES = ['vector', 'raster', 'wms', 'ogc-vt'] export function fetchWithTransform (url, resourceType, transformRequest) { const result = transformRequest ? (transformRequest(url, resourceType) || {}) : {} @@ -30,12 +32,28 @@ const createTileLoadFunction = (transformRequest) => (tile, src) => { .catch(() => tile.setState(TileState.ERROR)) } -export function createTileSource (url, transformRequest) { - const tileGrid = new TileGrid({ +function createTileGrid () { + return new TileGrid({ resolutions: TILE_GRID_RESOLUTIONS, origin: TILE_GRID_ORIGIN, tileSize: TILE_SIZE }) +} + +export function createWMSTileSource (url, params, transformRequest) { + const tileGrid = createTileGrid() + + return new TileWMS({ + url, + params, + projection: CRS, + tileGrid, + tileLoadFunction: transformRequest ? createTileLoadFunction(transformRequest) : undefined + }) +} + +export function createTileSource (url, transformRequest) { + const tileGrid = createTileGrid() const tileUrlFunction = ([z, x, y]) => url .replace('{z}', z) @@ -51,6 +69,15 @@ export function createTileSource (url, transformRequest) { } export async function createMapStyleLayer (mapStyle, transformRequest) { + if (mapStyle.type && !SUPPORTED_MAP_STYLE_TYPES.includes(mapStyle.type)) { + throw new Error(`Unsupported map style type: '${mapStyle.type}'`) + } + + if (mapStyle.type === 'wms') { + const source = createWMSTileSource(mapStyle.url, mapStyle.params || {}, transformRequest) + return { layer: new TileLayer({ source }), source } + } + if (mapStyle.type === 'raster') { const source = createTileSource(mapStyle.url, transformRequest) return { layer: new TileLayer({ source }), source } diff --git a/providers/beta/openlayers/src/utils/tileLayers.test.js b/providers/beta/openlayers/src/utils/tileLayers.test.js index c00d9348..a4f4206b 100644 --- a/providers/beta/openlayers/src/utils/tileLayers.test.js +++ b/providers/beta/openlayers/src/utils/tileLayers.test.js @@ -1,14 +1,16 @@ import XYZ from 'ol/source/XYZ.js' +import TileWMS from 'ol/source/TileWMS.js' import TileGrid from 'ol/tilegrid/TileGrid.js' import TileLayer from 'ol/layer/Tile.js' import VectorTileSource from 'ol/source/VectorTile.js' import VectorTileLayer from 'ol/layer/VectorTile.js' import { stylefunction } from 'ol-mapbox-style' -import { createTileSource, createMapStyleLayer, createVectorTileLayer } from './tileLayers.js' +import { createTileSource, createWMSTileSource, createMapStyleLayer, createVectorTileLayer } from './tileLayers.js' import { TILE_GRID_RESOLUTIONS, TILE_GRID_ORIGIN, TILE_SIZE } from '../defaults.js' const mockTileGridInstance = {} const mockSourceInstance = {} +const mockWMSSourceInstance = {} const mockTileLayerInstance = {} const mockVectorTileSourceInstance = {} const mockOGCVectorTileSourceInstance = { supportedMediaTypes: [] } @@ -16,6 +18,7 @@ const mockVectorTileLayerInstance = {} const mockMVTInstance = { supportedMediaTypes: [] } jest.mock('ol/source/XYZ.js', () => ({ __esModule: true, default: jest.fn(() => mockSourceInstance) })) +jest.mock('ol/source/TileWMS.js', () => ({ __esModule: true, default: jest.fn(() => mockWMSSourceInstance) })) jest.mock('ol/layer/Tile.js', () => ({ __esModule: true, default: jest.fn(() => mockTileLayerInstance) })) jest.mock('ol/tilegrid/TileGrid.js', () => ({ __esModule: true, default: jest.fn(() => mockTileGridInstance) })) jest.mock('ol/TileState.js', () => ({ __esModule: true, default: { ERROR: 'error' } })) @@ -126,6 +129,43 @@ describe('createTileSource', () => { }) }) +describe('createWMSTileSource', () => { + const wmsParams = { LAYERS: 'MyLayer', FORMAT: 'image/jpeg' } + + beforeEach(() => jest.clearAllMocks()) + + it('creates TileGrid with correct OS tile grid config', () => { + createWMSTileSource('https://wms.example.com/wms', wmsParams, null) + expect(TileGrid).toHaveBeenCalledWith({ + resolutions: TILE_GRID_RESOLUTIONS, + origin: TILE_GRID_ORIGIN, + tileSize: TILE_SIZE + }) + }) + + it('creates TileWMS source with EPSG:27700 projection and params', () => { + createWMSTileSource('https://wms.example.com/wms', wmsParams, null) + expect(TileWMS).toHaveBeenCalledWith(expect.objectContaining({ + url: 'https://wms.example.com/wms', + params: wmsParams, + projection: 'EPSG:27700', + tileGrid: mockTileGridInstance + })) + }) + + it('does not set tileLoadFunction when transformRequest is null', () => { + createWMSTileSource('https://wms.example.com/wms', wmsParams, null) + const { tileLoadFunction } = TileWMS.mock.calls[0][0] + expect(tileLoadFunction).toBeUndefined() + }) + + it('sets tileLoadFunction when transformRequest is provided', () => { + createWMSTileSource('https://wms.example.com/wms', wmsParams, jest.fn()) + const { tileLoadFunction } = TileWMS.mock.calls[0][0] + expect(typeof tileLoadFunction).toBe('function') + }) +}) + describe('tileLoadFunction (via transformRequest)', () => { const url = 'https://tiles.example.com/7/3/5' const mockImg = { src: null } @@ -191,6 +231,13 @@ describe('createMapStyleLayer', () => { global.fetch = makeVectorFetchMock() }) + it('creates a WMS tile layer and source when mapStyle.type is wms', async () => { + const params = { LAYERS: 'MyLayer' } + const result = await createMapStyleLayer({ url: 'https://wms.example.com/wms', type: 'wms', params }, null) + expect(TileLayer).toHaveBeenCalledWith({ source: mockWMSSourceInstance }) + expect(result).toEqual({ layer: mockTileLayerInstance, source: mockWMSSourceInstance }) + }) + it('creates a raster tile layer and source when mapStyle.type is raster', async () => { const result = await createMapStyleLayer({ url: 'https://tiles.example.com/{z}/{x}/{y}', type: 'raster' }, null) expect(TileLayer).toHaveBeenCalledWith({ source: mockSourceInstance }) @@ -210,6 +257,11 @@ describe('createMapStyleLayer', () => { expect(result).toEqual({ layer: mockVectorTileLayerInstance, source: mockVectorTileSourceInstance }) }) + it('throws when mapStyle.type is unsupported', async () => { + await expect(createMapStyleLayer({ url: styleUrl, type: 'unsupported' }, null)) + .rejects.toThrow("Unsupported map style type: 'unsupported'") + }) + it('creates a vector tile layer and source when mapStyle.type is missing', async () => { const result = await createMapStyleLayer({ url: styleUrl }, null) expect(VectorTileLayer).toHaveBeenCalledWith({ source: mockVectorTileSourceInstance, declutter: true }) diff --git a/src/types.js b/src/types.js index bf80d7d1..3d499ca6 100644 --- a/src/types.js +++ b/src/types.js @@ -403,18 +403,27 @@ * Falls back to `#0b0c0c` (light) or `#ffffff` (dark). * Injected as the `--map-overlay-foreground-color` CSS custom property. * - * @property {'raster' | 'ogc-vt'} [type] + * @property {'vector' | 'raster' | 'wms' | 'ogc-vt'} [type] * Tile layer type. Controls how the map provider constructs the basemap source. Omit (or leave * undefined) for the default Mapbox GL vector tile path. The OpenLayers provider supports all - * three types, enabling raster, standard vector tile, and OGC vector tile basemaps: + * four types, enabling raster, WMS, standard vector tile, and OGC vector tile basemaps: * - `'raster'` — XYZ raster tile source; `url` should be a tile URL template. + * - `'vector'` — Mapbox GL vector tile path; `url` should point to a Mapbox GL style document. + * - `'wms'` — WMS raster tile source; `url` should be the WMS service endpoint and `params` + * should provide the WMS request parameters. + * **Currently only supported by the OpenLayers provider.** * - `'ogc-vt'` — OGC API - Tiles vector tile source; `url` should point to an OGC style endpoint * that returns a Mapbox GL style document. **Currently only supported by the OpenLayers provider.** * * @property {string} url * URL for the style. For the default vector tile path and `'ogc-vt'`, this should be a URL that * returns a Mapbox GL style document (Mapbox Style Specification). For `'raster'`, this should - * be an XYZ tile URL template with `{x}`, `{y}`, `{z}` placeholders. + * be an XYZ tile URL template with `{x}`, `{y}`, `{z}` placeholders. For `'wms'`, this should + * be the WMS service endpoint (the GetMap URL). + * + * @property {Object} [params] + * WMS request parameters. Passed directly to the OpenLayers `TileWMS` source when `type` is `'wms'`. + * Most WMS GetMap requests should include `LAYERS`. Example: `{ LAYERS: 'MyLayer', FORMAT: 'image/jpeg' }`. */ /** diff --git a/webpack.dev.mjs b/webpack.dev.mjs index 9cd29d0f..d315afa6 100755 --- a/webpack.dev.mjs +++ b/webpack.dev.mjs @@ -87,6 +87,7 @@ export default { NGD_BLACK_AND_WHITE_URL_27700: JSON.stringify(process.env.NGD_BLACK_AND_WHITE_URL_27700), // Aerial photography AERIAL_URL: JSON.stringify(process.env.AERIAL_URL), + APGB_WMS_URL: JSON.stringify(process.env.APGB_WMS_URL), // OS Maps API (27700 raster) MAPS_OUTDOOR_URL: JSON.stringify(process.env.MAPS_OUTDOOR_URL), MAPS_ROAD_URL: JSON.stringify(process.env.MAPS_ROAD_URL),