From 97d221d94b4d7581919f14f92970d43cc14d0617 Mon Sep 17 00:00:00 2001 From: Jordon Smith Date: Wed, 20 May 2026 10:36:24 +0100 Subject: [PATCH] OL: fix style type switching and stale event listeners on style change --- providers/beta/openlayers/src/appEvents.js | 19 ++--- .../beta/openlayers/src/appEvents.test.js | 40 ++++++---- providers/beta/openlayers/src/mapEvents.js | 48 ++++++++---- .../beta/openlayers/src/mapEvents.test.js | 53 ++++++++++--- .../beta/openlayers/src/openlayersProvider.js | 26 +------ .../openlayers/src/openlayersProvider.test.js | 42 +++-------- .../beta/openlayers/src/utils/tileLayers.js | 14 ++++ .../openlayers/src/utils/tileLayers.test.js | 75 ++++++++++++++++++- 8 files changed, 208 insertions(+), 109 deletions(-) diff --git a/providers/beta/openlayers/src/appEvents.js b/providers/beta/openlayers/src/appEvents.js index 7e13ce7c..74799c83 100644 --- a/providers/beta/openlayers/src/appEvents.js +++ b/providers/beta/openlayers/src/appEvents.js @@ -1,19 +1,12 @@ -import { createTileSource, createVectorTileLayer, createOGCVectorTileLayer } from './utils/tileLayers.js' +import { createTileSource, createVectorTileLayer, createOGCVectorTileLayer, createMapStyleLayer } from './utils/tileLayers.js' -export { createTileSource, createVectorTileLayer, createOGCVectorTileLayer } +export { createTileSource, createVectorTileLayer, createOGCVectorTileLayer, createMapStyleLayer } -export function attachAppEvents ({ mapProvider, layer, layerType, transformRequest, events, eventBus, map }) { +export function attachAppEvents ({ mapProvider, transformRequest, events, eventBus, map, onBaseSourceChange }) { const handleSetMapStyle = async (mapStyle) => { - if (layerType === 'raster') { - const source = createTileSource(mapStyle.url, transformRequest) - layer.setSource(source) - } else if (mapStyle.type === 'ogc-vt') { - const { layer: newLayer } = await createOGCVectorTileLayer(mapStyle.url, transformRequest, mapStyle) - map.getLayers().setAt(0, newLayer) - } else { - const { layer: newLayer } = await createVectorTileLayer(mapStyle.url, transformRequest, mapStyle) - map.getLayers().setAt(0, newLayer) - } + const { layer, source } = await createMapStyleLayer(mapStyle, transformRequest) + map.getLayers().setAt(0, layer) + onBaseSourceChange(source) eventBus.emit(events.MAP_STYLE_CHANGE, { mapStyleId: mapStyle.id }) } diff --git a/providers/beta/openlayers/src/appEvents.test.js b/providers/beta/openlayers/src/appEvents.test.js index c1c470be..6869be83 100644 --- a/providers/beta/openlayers/src/appEvents.test.js +++ b/providers/beta/openlayers/src/appEvents.test.js @@ -1,14 +1,13 @@ import { attachAppEvents } from './appEvents.js' -import { createTileSource, createVectorTileLayer } from './utils/tileLayers.js' +import { createMapStyleLayer } from './utils/tileLayers.js' +const mockLayerInstance = {} const mockSourceInstance = {} -const mockVectorTileLayerInstance = {} jest.mock('./utils/tileLayers.js', () => ({ __esModule: true, - createTileSource: jest.fn(() => mockSourceInstance), - createVectorTileLayer: jest.fn(async () => ({ layer: mockVectorTileLayerInstance, source: {} })) + createMapStyleLayer: jest.fn(async () => ({ layer: mockLayerInstance, source: mockSourceInstance })) })) const events = { @@ -30,12 +29,14 @@ describe('attachAppEvents', () => { describe('raster', () => { function makeSetup () { jest.clearAllMocks() - const layer = { setSource: jest.fn() } + const setAt = jest.fn() + const map = { getLayers: jest.fn(() => ({ setAt })), setPixelRatio: jest.fn() } + const onBaseSourceChange = jest.fn() const eventBus = { on: jest.fn(), off: jest.fn(), emit: jest.fn() } const mapProvider = makeProvider() - const handles = attachAppEvents({ mapProvider, layer, layerType: 'raster', transformRequest: null, events, eventBus, map: makeMap() }) + const handles = attachAppEvents({ mapProvider, transformRequest: null, events, eventBus, map, onBaseSourceChange }) const handlerFor = (event) => eventBus.on.mock.calls.find(c => c[0] === event)[1] - return { layer, eventBus, handles, handlerFor, mapProvider } + return { map, setAt, onBaseSourceChange, eventBus, handles, handlerFor, mapProvider } } it('subscribes to MAP_SET_STYLE on the event bus', () => { @@ -43,11 +44,18 @@ describe('attachAppEvents', () => { expect(eventBus.on).toHaveBeenCalledWith(events.MAP_SET_STYLE, expect.any(Function)) }) - it('on MAP_SET_STYLE: creates a tile source and sets it on the layer', async () => { - const { layer, handlerFor } = makeSetup() - await handlerFor(events.MAP_SET_STYLE)({ url: 'https://new.tiles.com/{z}/{x}/{y}', id: 'newStyle' }) - expect(createTileSource).toHaveBeenCalledWith('https://new.tiles.com/{z}/{x}/{y}', null) - expect(layer.setSource).toHaveBeenCalledWith(mockSourceInstance) + it('on MAP_SET_STYLE: creates a map style layer and places it at index 0', async () => { + const { setAt, handlerFor } = makeSetup() + const mapStyle = { url: 'https://new.tiles.com/{z}/{x}/{y}', id: 'newStyle', type: 'raster' } + await handlerFor(events.MAP_SET_STYLE)(mapStyle) + expect(createMapStyleLayer).toHaveBeenCalledWith(mapStyle, null) + expect(setAt).toHaveBeenCalledWith(0, mockLayerInstance) + }) + + it('on MAP_SET_STYLE: updates the base source for map events', async () => { + const { onBaseSourceChange, handlerFor } = makeSetup() + await handlerFor(events.MAP_SET_STYLE)({ url: 'https://new.tiles.com/{z}/{x}/{y}', id: 'newStyle', type: 'raster' }) + expect(onBaseSourceChange).toHaveBeenCalledWith(mockSourceInstance) }) it('on MAP_SET_STYLE: emits MAP_STYLE_CHANGE with the new style id', async () => { @@ -72,7 +80,7 @@ describe('attachAppEvents', () => { const map = { getLayers: jest.fn(() => ({ setAt })), setPixelRatio: jest.fn() } const eventBus = { on: jest.fn(), off: jest.fn(), emit: jest.fn() } const mapProvider = makeProvider() - const handles = attachAppEvents({ mapProvider, layer: {}, layerType: 'vector', transformRequest: null, events, eventBus, map }) + const handles = attachAppEvents({ mapProvider, transformRequest: null, events, eventBus, map, onBaseSourceChange: jest.fn() }) const handlerFor = (event) => eventBus.on.mock.calls.find(c => c[0] === event)[1] return { map, setAt, eventBus, handles, handlerFor, mapProvider } } @@ -81,8 +89,8 @@ describe('attachAppEvents', () => { const { setAt, handlerFor } = makeSetup() const mapStyle = { url: 'https://example.com/styles', id: 'newVts' } await handlerFor(events.MAP_SET_STYLE)(mapStyle) - expect(createVectorTileLayer).toHaveBeenCalledWith(mapStyle.url, null, mapStyle) - expect(setAt).toHaveBeenCalledWith(0, mockVectorTileLayerInstance) + expect(createMapStyleLayer).toHaveBeenCalledWith(mapStyle, null) + expect(setAt).toHaveBeenCalledWith(0, mockLayerInstance) }) it('on MAP_SET_STYLE: emits MAP_STYLE_CHANGE with the new style id', async () => { @@ -98,7 +106,7 @@ describe('attachAppEvents', () => { const map = makeMap() const eventBus = { on: jest.fn(), off: jest.fn(), emit: jest.fn() } const mapProvider = makeProvider() - attachAppEvents({ mapProvider, layer: {}, layerType: 'vector', transformRequest: null, events, eventBus, map }) + attachAppEvents({ mapProvider, transformRequest: null, events, eventBus, map, onBaseSourceChange: jest.fn() }) const handlerFor = (event) => eventBus.on.mock.calls.find(c => c[0] === event)[1] return { map, mapProvider, handlerFor } } diff --git a/providers/beta/openlayers/src/mapEvents.js b/providers/beta/openlayers/src/mapEvents.js index db1127e7..59795d81 100644 --- a/providers/beta/openlayers/src/mapEvents.js +++ b/providers/beta/openlayers/src/mapEvents.js @@ -6,17 +6,6 @@ const DEBOUNCE_IDLE_TIME = 500 const MOVE_THROTTLE_TIME = 10 const DRAG_TOLERANCE = 6 -function attachLoadEvents ({ source, map, emit, eventBus, events, on }) { - let loadedEmitted = false - on(source, 'tileloadend', () => { - if (!loadedEmitted) { - loadedEmitted = true - eventBus.emit(events.MAP_LOADED) - } - }) - map.once('rendercomplete', () => emit(events.MAP_FIRST_IDLE)) -} - function attachMoveEvents ({ map, view, emit, eventBus, events, on, debouncers }) { let moving = false @@ -79,6 +68,7 @@ export function attachMapEvents ({ }) { let destroyed = false const listeners = [] + let sourceListeners = [] const debouncers = [] const view = map.getView() @@ -110,21 +100,47 @@ export function attachMapEvents ({ listeners.push([target, type, handler]) } - attachLoadEvents({ source, map, emit, eventBus, events, on }) - attachMoveEvents({ map, view: map.getView(), emit, eventBus, events, on, debouncers }) - attachClickEvents({ map, eventBus, events, on }) + const onSource = (target, type, handler) => { + target.on(type, handler) + sourceListeners.push([target, type, handler]) + } - on(map, 'postrender', () => eventBus.emit(events.MAP_RENDER)) + let loadedEmitted = false + const emitLoaded = () => { + if (!loadedEmitted) { + loadedEmitted = true + eventBus.emit(events.MAP_LOADED) + } + } const emitDataChange = debounce(() => emit(events.MAP_DATA_CHANGE), DEBOUNCE_IDLE_TIME) debouncers.push(emitDataChange) - on(source, 'tileloadend', emitDataChange) + + const setSource = (newSource) => { + if (destroyed) { + return + } + + sourceListeners.forEach(([target, type, handler]) => target.un(type, handler)) + sourceListeners = [] + onSource(newSource, 'tileloadend', emitLoaded) + onSource(newSource, 'tileloadend', emitDataChange) + } + + attachMoveEvents({ map, view: map.getView(), emit, eventBus, events, on, debouncers }) + setSource(source) + map.once('rendercomplete', () => emit(events.MAP_FIRST_IDLE)) + attachClickEvents({ map, eventBus, events, on }) + + on(map, 'postrender', () => eventBus.emit(events.MAP_RENDER)) return { + setSource, remove () { destroyed = true debouncers.forEach(d => d.cancel()) listeners.forEach(([target, type, handler]) => target.un(type, handler)) + sourceListeners.forEach(([target, type, handler]) => target.un(type, handler)) } } } diff --git a/providers/beta/openlayers/src/mapEvents.test.js b/providers/beta/openlayers/src/mapEvents.test.js index 536a6f13..e4d5fda6 100644 --- a/providers/beta/openlayers/src/mapEvents.test.js +++ b/providers/beta/openlayers/src/mapEvents.test.js @@ -35,7 +35,9 @@ function makeTarget () { return { on: jest.fn((type, handler) => { (handlers[type] = handlers[type] || []).push(handler) }), once: jest.fn((type, handler) => { (handlers[type] = handlers[type] || []).push(handler) }), - un: jest.fn(), + un: jest.fn((type, handler) => { + handlers[type] = (handlers[type] || []).filter(h => h !== handler) + }), trigger: (type, event = {}) => (handlers[type] || []).forEach(h => h(event)) } } @@ -104,14 +106,14 @@ describe('attachMapEvents — move events', () => { it('emits MAP_MOVE_END (with state) when debounced callback fires', () => { const { view, eventBus } = setup() view.trigger('change') - mockDebounceFns[0].fn() // emitMoveEnd's inner callback + mockDebounceFns[1].fn() // emitMoveEnd's inner callback expect(eventBus.emit).toHaveBeenCalledWith(events.MAP_MOVE_END, expect.any(Object)) }) it('resets moving flag after MAP_MOVE_END so MAP_MOVE_START can fire again', () => { const { view, eventBus } = setup() view.trigger('change') - mockDebounceFns[0].fn() // resets moving = false + mockDebounceFns[1].fn() // resets moving = false eventBus.emit.mockClear() view.trigger('change') expect(eventBus.emit).toHaveBeenCalledWith(events.MAP_MOVE_START) @@ -168,33 +170,46 @@ describe('attachMapEvents — render and data events', () => { it('emits MAP_DATA_CHANGE (with state) when debounced tileloadend callback fires', () => { const { source, eventBus } = setup() source.trigger('tileloadend') - mockDebounceFns[1].fn() // emitDataChange's inner callback + mockDebounceFns[0].fn() // emitDataChange's inner callback expect(eventBus.emit).toHaveBeenCalledWith(events.MAP_DATA_CHANGE, expect.any(Object)) }) + + it('moves source listeners when source changes', () => { + const { source, handles, eventBus } = setup() + const newSource = makeTarget() + + handles.setSource(newSource) + + source.trigger('tileloadend') + expect(eventBus.emit).not.toHaveBeenCalledWith(events.MAP_LOADED) + + newSource.trigger('tileloadend') + expect(eventBus.emit).toHaveBeenCalledWith(events.MAP_LOADED) + }) }) describe('attachMapEvents — dynamic zoom bounds', () => { it('isAtMaxZoom is updated in emitted event when view max zoom changes', () => { const { view, eventBus } = setup() view.trigger('change') - mockDebounceFns[0].fn() + mockDebounceFns[1].fn() expect(eventBus.emit).toHaveBeenCalledWith(events.MAP_MOVE_END, expect.objectContaining({ isAtMaxZoom: false })) eventBus.emit.mockClear() view.getMaxZoom.mockReturnValue(7) - mockDebounceFns[0].fn() + mockDebounceFns[1].fn() expect(eventBus.emit).toHaveBeenCalledWith(events.MAP_MOVE_END, expect.objectContaining({ isAtMaxZoom: true })) }) it('isAtMinZoom is updated in emitted event when view min zoom changes', () => { const { view, eventBus } = setup() view.trigger('change') - mockDebounceFns[0].fn() + mockDebounceFns[1].fn() expect(eventBus.emit).toHaveBeenCalledWith(events.MAP_MOVE_END, expect.objectContaining({ isAtMinZoom: false })) eventBus.emit.mockClear() view.getMinZoom.mockReturnValue(7) - mockDebounceFns[0].fn() + mockDebounceFns[1].fn() expect(eventBus.emit).toHaveBeenCalledWith(events.MAP_MOVE_END, expect.objectContaining({ isAtMinZoom: true })) }) }) @@ -203,8 +218,8 @@ describe('attachMapEvents — remove', () => { it('calls cancel on all debouncers', () => { const { handles } = setup() handles.remove() - expect(mockDebounceFns[0].wrapper.cancel).toHaveBeenCalled() expect(mockDebounceFns[1].wrapper.cancel).toHaveBeenCalled() + expect(mockDebounceFns[0].wrapper.cancel).toHaveBeenCalled() }) it('calls un on all registered event targets', () => { @@ -215,10 +230,28 @@ describe('attachMapEvents — remove', () => { expect(source.un).toHaveBeenCalled() }) + it('unbinds source listeners when source changes', () => { + const { source, handles } = setup() + handles.setSource(makeTarget()) + expect(source.un).toHaveBeenCalledWith('tileloadend', expect.any(Function)) + }) + + it('does not bind a new source after remove', () => { + const { eventBus, handles } = setup() + const newSource = makeTarget() + + handles.remove() + handles.setSource(newSource) + newSource.trigger('tileloadend') + + expect(newSource.on).not.toHaveBeenCalled() + expect(eventBus.emit).not.toHaveBeenCalledWith(events.MAP_LOADED) + }) + it('prevents state-bearing events from emitting after destroy', () => { const { eventBus, handles } = setup() handles.remove() - mockDebounceFns[0].fn() // emitMoveEnd fires after destroy → getMapState returns null + mockDebounceFns[1].fn() // emitMoveEnd fires after destroy → getMapState returns null expect(eventBus.emit).not.toHaveBeenCalledWith(events.MAP_MOVE_END, expect.anything()) }) }) diff --git a/providers/beta/openlayers/src/openlayersProvider.js b/providers/beta/openlayers/src/openlayersProvider.js index 6acc8234..7a307381 100644 --- a/providers/beta/openlayers/src/openlayersProvider.js +++ b/providers/beta/openlayers/src/openlayersProvider.js @@ -1,13 +1,12 @@ import OlMap from 'ol/Map.js' import View from 'ol/View.js' -import TileLayer from 'ol/layer/Tile.js' import { defaults as defaultInteractions } from 'ol/interaction/defaults.js' import proj4 from 'proj4' import { register } from 'ol/proj/proj4.js' import { supportedShortcuts, DEFAULTS } from './defaults.js' import { getViewResolutionConfig, ZOOM_ALIGNMENT } from './utils/zoom.js' import { attachMapEvents } from './mapEvents.js' -import { attachAppEvents, createTileSource, createVectorTileLayer, createOGCVectorTileLayer } from './appEvents.js' +import { attachAppEvents, createMapStyleLayer } from './appEvents.js' import { getAreaDimensions, getCardinalMove, getExtentFromGeoJSON, getPaddedExtent, isGeometryObscured } from './utils/spatial.js' const CRS = 'EPSG:27700' @@ -42,19 +41,7 @@ export default class OpenLayersProvider { this.mapSize = mapSize const { events, eventBus } = this - let tileLayer, source - if (mapStyle.type === 'raster') { - source = createTileSource(mapStyle.url, transformRequest) - tileLayer = new TileLayer({ source }) - } else if (mapStyle.type === 'ogc-vt') { - const vectorTile = await createOGCVectorTileLayer(mapStyle.url, transformRequest, mapStyle) - tileLayer = vectorTile.layer - source = vectorTile.source - } else { - const vectorTile = await createVectorTileLayer(mapStyle.url, transformRequest, mapStyle) - tileLayer = vectorTile.layer - source = vectorTile.source - } + const { layer: tileLayer, source } = await createMapStyleLayer(mapStyle, transformRequest) const viewResolutions = getViewResolutionConfig(this.zoomAlignment ?? ZOOM_ALIGNMENT.UK) @@ -97,18 +84,15 @@ export default class OpenLayersProvider { this.appEventHandles = attachAppEvents({ mapProvider: this, - layer: tileLayer, - layerType: mapStyle.type ?? 'vector', transformRequest, events, eventBus, - map + map, + onBaseSourceChange: this.mapEventHandles.setSource }) || [] this.map = map this.view = view - this.tileLayer = tileLayer - this.source = source // MAP_READY is synchronous — OL map is immediately interactive after construction eventBus.emit(events.MAP_READY, { @@ -132,8 +116,6 @@ export default class OpenLayersProvider { } this.view = null - this.tileLayer = null - this.source = null } // ========================== diff --git a/providers/beta/openlayers/src/openlayersProvider.test.js b/providers/beta/openlayers/src/openlayersProvider.test.js index 1143d4a4..74afbcf8 100644 --- a/providers/beta/openlayers/src/openlayersProvider.test.js +++ b/providers/beta/openlayers/src/openlayersProvider.test.js @@ -1,7 +1,7 @@ // Mock-prefixed variables are allowed in jest.mock factories by babel-plugin-jest-hoist import OpenLayersProvider from './openlayersProvider.js' import { attachMapEvents } from './mapEvents.js' -import { attachAppEvents, createTileSource, createVectorTileLayer } from './appEvents.js' +import { attachAppEvents, createMapStyleLayer } from './appEvents.js' import { getExtentFromGeoJSON, isGeometryObscured } from './utils/spatial.js' const mockAnimate = jest.fn() @@ -35,14 +35,13 @@ const mockMapInstance = { } const mockSource = {} -const mockTileLayer = { setSource: jest.fn() } const mockVectorTileLayer = {} const mockMapEventHandlesRemove = jest.fn() +const mockSetSource = jest.fn() const mockAppEventHandlesRemove = jest.fn() jest.mock('ol/Map.js', () => ({ __esModule: true, default: jest.fn(() => mockMapInstance) })) jest.mock('ol/View.js', () => ({ __esModule: true, default: jest.fn(() => mockViewInstance) })) -jest.mock('ol/layer/Tile.js', () => ({ __esModule: true, default: jest.fn(() => mockTileLayer) })) jest.mock('ol/interaction/defaults.js', () => ({ __esModule: true, defaults: jest.fn(() => []) })) jest.mock('proj4', () => { const fn = jest.fn() @@ -52,12 +51,11 @@ jest.mock('proj4', () => { jest.mock('ol/proj/proj4.js', () => ({ __esModule: true, register: jest.fn() })) jest.mock('./mapEvents.js', () => ({ __esModule: true, - attachMapEvents: jest.fn(() => ({ remove: mockMapEventHandlesRemove })) + attachMapEvents: jest.fn(() => ({ remove: mockMapEventHandlesRemove, setSource: mockSetSource })) })) jest.mock('./appEvents.js', () => ({ __esModule: true, - createTileSource: jest.fn(() => mockSource), - createVectorTileLayer: jest.fn(async () => ({ layer: mockVectorTileLayer, source: mockSource })), + createMapStyleLayer: jest.fn(async () => ({ layer: mockVectorTileLayer, source: mockSource })), attachAppEvents: jest.fn(() => ({ remove: mockAppEventHandlesRemove })) })) jest.mock('./utils/spatial.js', () => ({ @@ -125,7 +123,7 @@ describe('OpenLayersProvider', () => { it('creates vector tile layer, OL objects, and emits MAP_READY by default', async () => { const { provider, eventBus } = makeProvider() await provider.initMap(defaultInitConfig) - expect(createVectorTileLayer).toHaveBeenCalledWith(defaultInitConfig.mapStyle.url, null, defaultInitConfig.mapStyle) + expect(createMapStyleLayer).toHaveBeenCalledWith(defaultInitConfig.mapStyle, null) expect(attachMapEvents).toHaveBeenCalled() expect(attachAppEvents).toHaveBeenCalled() expect(eventBus.emit).toHaveBeenCalledWith(events.MAP_READY, expect.objectContaining({ @@ -151,43 +149,29 @@ describe('OpenLayersProvider', () => { expect(mockMapOnce).not.toHaveBeenCalled() }) - it('calls createVectorTileLayer and skips createTileSource when mapStyle has no type', async () => { + it('creates the initial layer from the map style', async () => { const { provider } = makeProvider() await provider.initMap(defaultInitConfig) - expect(createVectorTileLayer).toHaveBeenCalled() - expect(createTileSource).not.toHaveBeenCalled() + expect(createMapStyleLayer).toHaveBeenCalledWith(defaultInitConfig.mapStyle, null) }) - it('calls createTileSource and skips createVectorTileLayer when mapStyle.type is "raster"', async () => { + it('creates the initial layer from raster map styles', async () => { const { provider, eventBus } = makeProvider() const rasterConfig = { ...defaultInitConfig, mapStyle: { id: 'myStyle', url: 'https://tiles.example.com/{z}/{x}/{y}', type: 'raster' } } await provider.initMap(rasterConfig) - expect(createTileSource).toHaveBeenCalledWith('https://tiles.example.com/{z}/{x}/{y}', null) - expect(createVectorTileLayer).not.toHaveBeenCalled() + expect(createMapStyleLayer).toHaveBeenCalledWith(rasterConfig.mapStyle, null) expect(eventBus.emit).toHaveBeenCalledWith(events.MAP_READY, expect.objectContaining({ mapStyleId: 'myStyle' })) }) - it('passes layerType "vector" and map to attachAppEvents by default', async () => { + it('passes map and source change handler to attachAppEvents', async () => { const { provider } = makeProvider() await provider.initMap(defaultInitConfig) expect(attachAppEvents).toHaveBeenCalledWith(expect.objectContaining({ - layerType: 'vector', - map: mockMapInstance - })) - }) - - it('passes layerType "raster" to attachAppEvents when mapStyle.type is "raster"', async () => { - const { provider } = makeProvider() - const rasterConfig = { - ...defaultInitConfig, - mapStyle: { id: 'myStyle', url: 'https://tiles.example.com/{z}/{x}/{y}', type: 'raster' } - } - await provider.initMap(rasterConfig) - expect(attachAppEvents).toHaveBeenCalledWith(expect.objectContaining({ - layerType: 'raster' + map: mockMapInstance, + onBaseSourceChange: mockSetSource })) }) @@ -218,8 +202,6 @@ describe('OpenLayersProvider', () => { expect(mockMapSetTarget).toHaveBeenCalledWith(null) expect(provider.map).toBeNull() expect(provider.view).toBeNull() - expect(provider.tileLayer).toBeNull() - expect(provider.source).toBeNull() }) it('does not throw when called before initMap', () => { diff --git a/providers/beta/openlayers/src/utils/tileLayers.js b/providers/beta/openlayers/src/utils/tileLayers.js index 81b85ea3..820b85d2 100644 --- a/providers/beta/openlayers/src/utils/tileLayers.js +++ b/providers/beta/openlayers/src/utils/tileLayers.js @@ -1,5 +1,6 @@ import XYZ from 'ol/source/XYZ.js' import VectorTileSource from 'ol/source/VectorTile.js' +import TileLayer from 'ol/layer/Tile.js' import VectorTileLayer from 'ol/layer/VectorTile.js' import OGCVectorTile from 'ol/source/OGCVectorTile.js' import MVT from 'ol/format/MVT.js' @@ -49,6 +50,19 @@ export function createTileSource (url, transformRequest) { }) } +export async function createMapStyleLayer (mapStyle, transformRequest) { + if (mapStyle.type === 'raster') { + const source = createTileSource(mapStyle.url, transformRequest) + return { layer: new TileLayer({ source }), source } + } + + if (mapStyle.type === 'ogc-vt') { + return createOGCVectorTileLayer(mapStyle.url, transformRequest, mapStyle) + } + + return createVectorTileLayer(mapStyle.url, transformRequest, mapStyle) +} + // Insert extension before any query string to match Mapbox GL sprite convention function resolveSprite (spriteBase, transformRequest) { const queryIdx = spriteBase.indexOf('?') diff --git a/providers/beta/openlayers/src/utils/tileLayers.test.js b/providers/beta/openlayers/src/utils/tileLayers.test.js index 9bf77102..c00d9348 100644 --- a/providers/beta/openlayers/src/utils/tileLayers.test.js +++ b/providers/beta/openlayers/src/utils/tileLayers.test.js @@ -1,21 +1,26 @@ import XYZ from 'ol/source/XYZ.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, createVectorTileLayer } from './tileLayers.js' +import { createTileSource, createMapStyleLayer, createVectorTileLayer } from './tileLayers.js' import { TILE_GRID_RESOLUTIONS, TILE_GRID_ORIGIN, TILE_SIZE } from '../defaults.js' const mockTileGridInstance = {} const mockSourceInstance = {} +const mockTileLayerInstance = {} const mockVectorTileSourceInstance = {} +const mockOGCVectorTileSourceInstance = { supportedMediaTypes: [] } const mockVectorTileLayerInstance = {} -const mockMVTInstance = {} +const mockMVTInstance = { supportedMediaTypes: [] } jest.mock('ol/source/XYZ.js', () => ({ __esModule: true, default: jest.fn(() => mockSourceInstance) })) +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' } })) jest.mock('ol/source/VectorTile.js', () => ({ __esModule: true, default: jest.fn(() => mockVectorTileSourceInstance) })) +jest.mock('ol/source/OGCVectorTile.js', () => ({ __esModule: true, default: jest.fn(() => mockOGCVectorTileSourceInstance) })) jest.mock('ol/layer/VectorTile.js', () => ({ __esModule: true, default: jest.fn(() => mockVectorTileLayerInstance) })) jest.mock('ol/format/MVT.js', () => ({ __esModule: true, default: jest.fn(() => mockMVTInstance) })) jest.mock('ol-mapbox-style', () => ({ __esModule: true, stylefunction: jest.fn(), recordStyleLayer: jest.fn() })) @@ -41,6 +46,38 @@ const mockServiceJson = { const mockSpritesJson = { myIcon: { x: 0, y: 0, width: 16, height: 16, pixelRatio: 1 } } +const mockOGCStyleJson = { + sources: { ngd: { url: 'https://example.com/ogc/tiles' } }, + sprite: 'https://example.com/sprites/sprite', + layers: [] +} + +const mockTilesetJson = { + links: [{ rel: 'http://www.opengis.net/def/rel/ogc/1.0/tiling-scheme', href: 'https://example.com/ogc/tms' }] +} + +const mockTMSJson = { + tileMatrices: [ + { cellSize: 896, pointOfOrigin: [-238375, 1376256], tileHeight: 256, tileWidth: 256 }, + { cellSize: 448, pointOfOrigin: [-238375, 1376256], tileHeight: 256, tileWidth: 256 } + ] +} + +function makeOGCFetchMock (styleJson = mockOGCStyleJson) { + return jest.fn().mockImplementation(url => { + if (url === 'https://example.com/ogc/tiles') { + return Promise.resolve({ json: () => Promise.resolve(mockTilesetJson) }) + } + if (url === 'https://example.com/ogc/tms') { + return Promise.resolve({ json: () => Promise.resolve(mockTMSJson) }) + } + if (url.endsWith('.json') || url.includes('.json?')) { + return Promise.resolve({ json: () => Promise.resolve(mockSpritesJson) }) + } + return Promise.resolve({ json: () => Promise.resolve(styleJson) }) + }) +} + function makeVectorFetchMock (styleJson = mockStyleJson) { return jest.fn().mockImplementation(url => { if (url === styleJson.sources[Object.keys(styleJson.sources)[0]].url) { @@ -146,6 +183,40 @@ describe('tileLoadFunction (via transformRequest)', () => { }) }) +describe('createMapStyleLayer', () => { + const styleUrl = 'https://example.com/styles' + + beforeEach(() => { + jest.clearAllMocks() + global.fetch = makeVectorFetchMock() + }) + + 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 }) + expect(result).toEqual({ layer: mockTileLayerInstance, source: mockSourceInstance }) + }) + + it('creates an OGC vector tile layer and source when mapStyle.type is ogc-vt', async () => { + global.fetch = makeOGCFetchMock() + const result = await createMapStyleLayer({ url: 'https://example.com/ogc-styles', type: 'ogc-vt' }, null) + expect(VectorTileLayer).toHaveBeenCalledWith({ source: mockOGCVectorTileSourceInstance, declutter: true }) + expect(result).toEqual({ layer: mockVectorTileLayerInstance, source: mockOGCVectorTileSourceInstance }) + }) + + it('creates a vector tile layer and source when mapStyle.type is vector', async () => { + const result = await createMapStyleLayer({ url: styleUrl, type: 'vector' }, null) + expect(VectorTileLayer).toHaveBeenCalledWith({ source: mockVectorTileSourceInstance, declutter: true }) + expect(result).toEqual({ layer: mockVectorTileLayerInstance, source: mockVectorTileSourceInstance }) + }) + + 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 }) + expect(result).toEqual({ layer: mockVectorTileLayerInstance, source: mockVectorTileSourceInstance }) + }) +}) + describe('createVectorTileLayer', () => { const styleUrl = 'https://example.com/styles'