@defra/interactive-map 0.0.50-alpha → 0.0.51-apha

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 (76) hide show
  1. package/dist/esm/im-core.js +1 -1
  2. package/dist/umd/im-core.js +1 -1
  3. package/docs/api.md +19 -0
  4. package/docs/architecture.md +14 -0
  5. package/docs/getting-started.md +10 -0
  6. package/docs/plugins/datasets.md +9 -3
  7. package/jest.setup.js +8 -0
  8. package/package.json +8 -3
  9. package/plugins/datasets/dist/esm/im-datasets-ml-adapter.js +1 -1
  10. package/plugins/datasets/dist/esm/im-datasets-ol-adapter.js +1 -0
  11. package/plugins/datasets/dist/esm/im-datasets-plugin.js +1 -1
  12. package/plugins/datasets/dist/umd/im-datasets-ml-adapter.js +1 -1
  13. package/plugins/datasets/dist/umd/im-datasets-ol-adapter.js +2 -0
  14. package/plugins/datasets/dist/umd/im-datasets-ol-adapter.js.LICENSE.txt +1 -0
  15. package/plugins/datasets/dist/umd/im-datasets-plugin.js +1 -1
  16. package/plugins/datasets/dist/umd/index.js +1 -1
  17. package/plugins/datasets/src/adapters/loadLayerAdapter.js +7 -3
  18. package/plugins/datasets/src/adapters/loadLayerAdapter.test.js +17 -0
  19. package/plugins/datasets/src/adapters/mapboxStyleLayerAdapter.js +74 -0
  20. package/plugins/datasets/src/adapters/maplibre/maplibreLayerAdapter.js +14 -79
  21. package/plugins/datasets/src/adapters/maplibre/maplibreLayerAdapter.test.js +4 -0
  22. package/plugins/datasets/src/adapters/maplibre/registry/mapLibreDataset.js +3 -89
  23. package/plugins/datasets/src/adapters/openlayers/canvasPatternStyle.js +111 -0
  24. package/plugins/datasets/src/adapters/openlayers/canvasPatternStyle.test.js +198 -0
  25. package/plugins/datasets/src/adapters/openlayers/layerBuilders.js +205 -0
  26. package/plugins/datasets/src/adapters/openlayers/layerBuilders.test.js +233 -0
  27. package/plugins/datasets/src/adapters/openlayers/openlayersLayerAdapter.js +276 -0
  28. package/plugins/datasets/src/adapters/openlayers/openlayersLayerAdapter.test.js +431 -0
  29. package/plugins/datasets/src/adapters/openlayers/registry/openLayersDataset.js +172 -0
  30. package/plugins/datasets/src/adapters/openlayers/registry/openLayersDataset.test.js +262 -0
  31. package/plugins/datasets/src/fetch/createDynamicSource.js +12 -8
  32. package/plugins/datasets/src/fetch/createDynamicSource.test.js +61 -52
  33. package/plugins/datasets/src/initialise/DatasetsInit.jsx +13 -1
  34. package/plugins/datasets/src/initialise/DatasetsInit.test.jsx +20 -0
  35. package/plugins/datasets/src/initialise/initialiseDatasets.js +1 -1
  36. package/plugins/datasets/src/initialise/initialiseDatasets.test.js +1 -1
  37. package/plugins/datasets/src/registry/mapboxStyleDataset.js +108 -0
  38. package/plugins/datasets/src/registry/mapboxStyleDataset.test.js +240 -0
  39. package/plugins/datasets/src/utils/bbox.js +5 -7
  40. package/plugins/datasets/src/utils/bbox.test.js +3 -10
  41. package/plugins/draw/dist/css/index.css +1 -0
  42. package/plugins/draw/dist/umd/im-draw-ol-adapter.js +1 -1
  43. package/plugins/draw/dist/umd/im-draw-plugin.js +1 -1
  44. package/plugins/draw/src/index.js +2 -0
  45. package/plugins/draw/src/index.test.js +1 -0
  46. package/plugins/map-key/dist/esm/im-map-key-plugin.js +1 -1
  47. package/plugins/map-key/dist/umd/im-map-key-plugin.js +1 -1
  48. package/providers/beta/openlayers/dist/esm/im-openlayers-provider.js +1 -1
  49. package/providers/beta/openlayers/dist/umd/im-openlayers-provider.js +1 -1
  50. package/providers/beta/openlayers/dist/umd/index.js +1 -1
  51. package/providers/beta/openlayers/src/index.js +16 -0
  52. package/providers/beta/openlayers/src/openlayersProvider.js +160 -7
  53. package/providers/beta/openlayers/src/openlayersProvider.test.js +13 -0
  54. package/providers/beta/openlayers/src/utils/bngProjection.js +9 -0
  55. package/providers/beta/openlayers/src/utils/filterEvaluator.js +32 -0
  56. package/providers/beta/openlayers/src/utils/filterEvaluator.test.js +36 -0
  57. package/providers/beta/openlayers/src/utils/highlightFeatures.js +38 -23
  58. package/providers/beta/openlayers/src/utils/highlightFeatures.test.js +153 -8
  59. package/providers/beta/openlayers/src/utils/hoverCursor.js +5 -1
  60. package/providers/beta/openlayers/src/utils/hoverCursor.test.js +40 -0
  61. package/providers/beta/openlayers/src/utils/queryFeatures.js +72 -54
  62. package/providers/beta/openlayers/src/utils/queryFeatures.test.js +143 -1
  63. package/providers/beta/openlayers/src/utils/symbolImages.js +79 -5
  64. package/providers/beta/openlayers/src/utils/symbolImages.test.js +111 -1
  65. package/providers/beta/openlayers/src/utils/vtTileFragments.js +20 -8
  66. package/providers/beta/openlayers/src/utils/vtTileFragments.test.js +162 -0
  67. package/providers/maplibre/dist/esm/im-maplibre-provider.js +1 -1
  68. package/providers/maplibre/dist/umd/im-maplibre-provider.js +1 -1
  69. package/providers/maplibre/dist/umd/index.js +1 -1
  70. package/providers/maplibre/src/utils/patternImages.js +4 -44
  71. package/rollup.esm.mjs +2 -0
  72. package/src/services/patternRegistry.js +38 -0
  73. package/src/services/patternRegistry.test.js +61 -0
  74. package/src/types.js +11 -0
  75. package/webpack.dev.mjs +1 -0
  76. /package/plugins/datasets/dist/esm/{esriLayerAdapter.js → im-datasets-esri-adapter.js} +0 -0
@@ -0,0 +1,262 @@
1
+ import { OpenLayersDataset } from './openLayersDataset.js'
2
+ import { datasetRegistry } from '../../../registry/datasetRegistry.js'
3
+ import { symbolRegistry } from '../../../../../../src/services/symbolRegistry.js'
4
+ import { registerSymbol, clearSymbolImageCache, SYMBOL_RASTER_PIXEL_RATIO } from '../../../../../../providers/beta/openlayers/src/utils/symbolImages.js'
5
+ // Use the mock datasetRegistry with the demo datasets attached before each test
6
+ // so we can test Dataset methods that depend on parent/sublayer relationships and styles
7
+ jest.mock('../../../registry/datasetRegistry.js')
8
+
9
+ beforeAll(() => {
10
+ globalThis.Image = class {
11
+ constructor (w, h) { this.width = w; this.height = h; this._src = '' }
12
+ get src () { return this._src }
13
+ set src (val) { this._src = val; this.onload?.() }
14
+ }
15
+ HTMLCanvasElement.prototype.getContext = jest.fn(() => ({
16
+ drawImage: jest.fn(),
17
+ getImageData: jest.fn((_x, _y, w, h) => ({ width: w, height: h })),
18
+ putImageData: jest.fn()
19
+ }))
20
+ HTMLCanvasElement.prototype.toDataURL = jest.fn(() => 'data:image/png;base64,mock')
21
+ })
22
+
23
+ describe('OpenLayersDataset', () => {
24
+ beforeEach(() => {
25
+ datasetRegistry.attachCreateDataset(def => new OpenLayersDataset(def))
26
+ datasetRegistry.mockExtend({
27
+ 'ds-fill-only': { id: 'ds-fill-only', style: { fill: 'blue' } },
28
+ 'ds-stroke-only': { id: 'ds-stroke-only', style: { stroke: 'red', strokeWidth: 2 } },
29
+ 'ds-dashed-stroke': { id: 'ds-dashed-stroke', style: { stroke: 'red', strokeDashArray: [4, 2] } },
30
+ 'ds-transparent-fill': { id: 'ds-transparent-fill', style: { fill: 'transparent' } },
31
+ 'ds-bare': { id: 'ds-bare' },
32
+ 'ds-tiles': { id: 'ds-tiles', tiles: ['https://example.com/{z}/{x}/{y}'], minZoom: 5, maxZoom: 15 },
33
+ 'ds-tiles-no-zoom': { id: 'ds-tiles-no-zoom', tiles: ['https://example.com/{z}/{x}/{y}'] },
34
+ 'ds-static-geojson': { id: 'ds-static-geojson', geojson: { type: 'FeatureCollection', features: [] }, style: { fill: 'green' } },
35
+ 'ds-id-prop': { id: 'ds-id-prop', idProperty: 'ref', geojson: { type: 'FeatureCollection', features: [] } },
36
+ 'ds-hf': { id: 'ds-hf', style: { fill: 'blue' }, hiddenFeatures: [1, 2] },
37
+ 'ds-pattern': { id: 'ds-pattern', style: { fillPattern: 'dot', fillPatternForegroundColor: 'red' } },
38
+ 'ds-symbol': { id: 'ds-symbol', style: { symbol: 'pin' } },
39
+ 'ds-symbol-unknown': { id: 'ds-symbol-unknown', style: { symbol: 'not-a-real-symbol' } },
40
+ 'ds-symbol-anchor': { id: 'ds-symbol-anchor', style: { symbol: 'pin', symbolAnchor: [0.5, 1] } },
41
+ 'ds-symbol-filter': { id: 'ds-symbol-filter', style: { symbol: 'pin' }, filter: ['==', ['get', 'type'], 'a'] }
42
+ })
43
+ clearSymbolImageCache()
44
+ })
45
+
46
+ describe('leafLayerIds', () => {
47
+ it('returns [id] for a dataset with a fill', () => {
48
+ expect(datasetRegistry.getDataset('ds-fill-only').leafLayerIds).toEqual(['ds-fill-only'])
49
+ })
50
+
51
+ it('returns [id] for a dataset with only a stroke', () => {
52
+ expect(datasetRegistry.getDataset('ds-stroke-only').leafLayerIds).toEqual(['ds-stroke-only'])
53
+ })
54
+
55
+ it('returns [] when fill is transparent and there is no stroke or symbol', () => {
56
+ expect(datasetRegistry.getDataset('ds-transparent-fill').leafLayerIds).toEqual([])
57
+ })
58
+
59
+ it('returns [] for a dataset with no fill, stroke, or symbol', () => {
60
+ expect(datasetRegistry.getDataset('ds-bare').leafLayerIds).toEqual([])
61
+ })
62
+
63
+ it('returns [] for a dataset with sublayers', () => {
64
+ const parentDef = { id: 'parent', sublayerIds: ['child'] }
65
+ const childDef = { id: 'child', parentId: 'parent', style: { fill: 'blue' } }
66
+ datasetRegistry.attach({ parent: parentDef, child: childDef })
67
+ expect(datasetRegistry.getDataset('parent').leafLayerIds).toEqual([])
68
+ })
69
+
70
+ it('returns [id] for a dataset with a symbol', () => {
71
+ expect(datasetRegistry.getDataset('ds-symbol').leafLayerIds).toEqual(['ds-symbol'])
72
+ })
73
+ })
74
+
75
+ describe('source', () => {
76
+ it('returns a vector source descriptor for a tile dataset', () => {
77
+ expect(datasetRegistry.getDataset('ds-tiles').source).toEqual({
78
+ type: 'vector',
79
+ tiles: ['https://example.com/{z}/{x}/{y}'],
80
+ minzoom: 5,
81
+ maxzoom: 15
82
+ })
83
+ })
84
+
85
+ it('falls back to 0/22 for minzoom/maxzoom when zoom is not set', () => {
86
+ expect(datasetRegistry.getDataset('ds-tiles-no-zoom').source).toEqual({
87
+ type: 'vector',
88
+ tiles: ['https://example.com/{z}/{x}/{y}'],
89
+ minzoom: 0,
90
+ maxzoom: 22
91
+ })
92
+ })
93
+
94
+ it('returns a geojson source for a static geojson dataset', () => {
95
+ const dataset = datasetRegistry.getDataset('ds-static-geojson')
96
+ expect(dataset.source).toEqual({ type: 'geojson', data: dataset.geojson })
97
+ })
98
+
99
+ it('returns null when there are no tiles and no geojson', () => {
100
+ expect(datasetRegistry.getDataset('ds-bare').source).toBeNull()
101
+ })
102
+ })
103
+
104
+ describe('idStrategy', () => {
105
+ it('returns the idProperty when set', () => {
106
+ expect(datasetRegistry.getDataset('ds-id-prop').idStrategy).toBe('ref')
107
+ })
108
+
109
+ it('returns null when idProperty is not set', () => {
110
+ expect(datasetRegistry.getDataset('ds-bare').idStrategy).toBeNull()
111
+ })
112
+
113
+ it('reads idProperty off dynamicGeoJSON, not the dataset itself, for a dynamic dataset', () => {
114
+ datasetRegistry.mockExtend({
115
+ 'ds-dynamic-id-prop': { id: 'ds-dynamic-id-prop', dynamicGeoJSON: { url: 'https://example.com', idProperty: 'ref' } }
116
+ })
117
+ expect(datasetRegistry.getDataset('ds-dynamic-id-prop').idStrategy).toBe('ref')
118
+ })
119
+
120
+ it('returns null for a dynamic dataset with no idProperty on its dynamicGeoJSON config', () => {
121
+ datasetRegistry.mockExtend({
122
+ 'ds-dynamic-no-id-prop': { id: 'ds-dynamic-no-id-prop', dynamicGeoJSON: { url: 'https://example.com' } }
123
+ })
124
+ expect(datasetRegistry.getDataset('ds-dynamic-no-id-prop').idStrategy).toBeNull()
125
+ })
126
+ })
127
+
128
+ describe('filter (sourceLayer)', () => {
129
+ // mockExtend rebuilds the whole registry from the demo dataset baseline + whatever's given
130
+ // here, so this file's own top-level fixtures (e.g. ds-hf) aren't present in this nested
131
+ // block unless re-declared alongside these — hence ds-geojson-hidden below, rather than
132
+ // reusing ds-hf, to cover the "no sourceLayer" case.
133
+ beforeEach(() => {
134
+ datasetRegistry.mockExtend({
135
+ 'ds-tiles-sourcelayer': { id: 'ds-tiles-sourcelayer', tiles: ['https://example.com/{z}/{x}/{y}'], sourceLayer: 'field_parcels_osgb36', style: { fill: 'blue' } },
136
+ 'ds-tiles-sourcelayer-and-filter': { id: 'ds-tiles-sourcelayer-and-filter', tiles: ['https://example.com/{z}/{x}/{y}'], sourceLayer: 'field_parcels_osgb36', filter: ['==', ['get', 'sbi'], '123'], style: { fill: 'blue' } },
137
+ 'ds-tiles-sourcelayer-hidden': { id: 'ds-tiles-sourcelayer-hidden', tiles: ['https://example.com/{z}/{x}/{y}'], sourceLayer: 'field_parcels_osgb36', hiddenFeatures: [1, 2], style: { fill: 'blue' } },
138
+ 'ds-geojson-hidden': { id: 'ds-geojson-hidden', style: { fill: 'blue' }, hiddenFeatures: [1, 2] }
139
+ })
140
+ })
141
+
142
+ it('is just the sourceLayer check when there is no own filter or hidden features', () => {
143
+ expect(datasetRegistry.getDataset('ds-tiles-sourcelayer').filter).toEqual(['==', ['get', 'layer'], 'field_parcels_osgb36'])
144
+ })
145
+
146
+ it('folds the sourceLayer check into an "all" alongside an own filter', () => {
147
+ expect(datasetRegistry.getDataset('ds-tiles-sourcelayer-and-filter').filter).toEqual([
148
+ 'all',
149
+ ['==', ['get', 'sbi'], '123'],
150
+ ['==', ['get', 'layer'], 'field_parcels_osgb36']
151
+ ])
152
+ })
153
+
154
+ it('folds the sourceLayer check into an "all" alongside a hidden-features filter', () => {
155
+ expect(datasetRegistry.getDataset('ds-tiles-sourcelayer-hidden').filter).toEqual([
156
+ 'all',
157
+ ['!', ['in', ['to-string', ['id']], ['literal', ['1', '2']]]],
158
+ ['==', ['get', 'layer'], 'field_parcels_osgb36']
159
+ ])
160
+ })
161
+
162
+ it('is unaffected for a dataset with no sourceLayer (geojson)', () => {
163
+ expect(datasetRegistry.getDataset('ds-geojson-hidden').filter).toEqual(['!', ['in', ['to-string', ['id']], ['literal', ['1', '2']]]])
164
+ })
165
+ })
166
+
167
+ describe('symbolMeta', () => {
168
+ it('returns null for a dataset with no symbol', () => {
169
+ expect(datasetRegistry.getDataset('ds-fill-only').symbolMeta).toBeNull()
170
+ })
171
+
172
+ it('returns null when the style\'s symbol name has no known symbol definition', () => {
173
+ expect(datasetRegistry.getDataset('ds-symbol-unknown').symbolMeta).toBeNull()
174
+ })
175
+
176
+ it('returns the resolved imageId and anchor for a known symbol, with no registration needed', () => {
177
+ expect(datasetRegistry.getDataset('ds-symbol').symbolMeta).toEqual({
178
+ imageId: expect.any(String),
179
+ anchor: [0.5, 0.9] // pin's own default anchor
180
+ })
181
+ })
182
+
183
+ it('uses the dataset\'s own symbolAnchor over the symbol definition\'s default', () => {
184
+ expect(datasetRegistry.getDataset('ds-symbol-anchor').symbolMeta.anchor).toEqual([0.5, 1])
185
+ })
186
+ })
187
+
188
+ describe('flatStyle', () => {
189
+ it('returns fill-color for a fill-only dataset', () => {
190
+ expect(datasetRegistry.getDataset('ds-fill-only').flatStyle).toEqual({ 'fill-color': 'blue' })
191
+ })
192
+
193
+ it('returns stroke-color and stroke-width, defaulting stroke-width to 1', () => {
194
+ expect(datasetRegistry.getDataset('ds-stroke-only').flatStyle).toEqual({ 'stroke-color': 'red', 'stroke-width': 2 })
195
+ })
196
+
197
+ it('includes stroke-line-dash when strokeDashArray is set', () => {
198
+ expect(datasetRegistry.getDataset('ds-dashed-stroke').flatStyle).toEqual({
199
+ 'stroke-color': 'red',
200
+ 'stroke-width': 1,
201
+ 'stroke-line-dash': [4, 2]
202
+ })
203
+ })
204
+
205
+ it('returns an empty style object for a bare dataset', () => {
206
+ expect(datasetRegistry.getDataset('ds-bare').flatStyle).toEqual({})
207
+ })
208
+
209
+ it('wraps the style in a filter rule when the dataset has hidden features', () => {
210
+ expect(datasetRegistry.getDataset('ds-hf').flatStyle).toEqual([{
211
+ filter: ['!', ['in', ['to-string', ['id']], ['literal', ['1', '2']]]],
212
+ style: { 'fill-color': 'blue' }
213
+ }])
214
+ })
215
+
216
+ describe('pattern fills', () => {
217
+ // A pattern fill is built by canvasPatternStyle.js's crisp CanvasPattern-based style
218
+ // instead (see layerBuilders.js's resolveLayerStyle) — flatStyle never resolves one, and
219
+ // must not fall back to a meaningless fill-color either (see base Dataset.hasFill, which
220
+ // is also true for a pattern dataset).
221
+ it('never sets fill-color or fill-pattern-src for a pattern dataset', () => {
222
+ expect(datasetRegistry.getDataset('ds-pattern').flatStyle).toEqual({})
223
+ })
224
+ })
225
+
226
+ describe('symbols', () => {
227
+ it('has no icon-src before the symbol is registered', () => {
228
+ // registerSymbol is async and hasn't run yet — flatStyle must never block on it.
229
+ expect(datasetRegistry.getDataset('ds-symbol').flatStyle).toEqual({})
230
+ })
231
+
232
+ it('uses icon-src, icon-anchor and icon-scale once the symbol is registered', async () => {
233
+ const dataset = datasetRegistry.getDataset('ds-symbol')
234
+ await registerSymbol(dataset.style, undefined, symbolRegistry, SYMBOL_RASTER_PIXEL_RATIO)
235
+ expect(dataset.flatStyle).toEqual({
236
+ 'icon-src': 'data:image/png;base64,mock',
237
+ 'icon-anchor': [0.5, 0.9], // pin's own default anchor
238
+ 'icon-scale': 1 / SYMBOL_RASTER_PIXEL_RATIO
239
+ })
240
+ })
241
+
242
+ it('uses the dataset\'s own symbolAnchor over the symbol definition\'s default', async () => {
243
+ const dataset = datasetRegistry.getDataset('ds-symbol-anchor')
244
+ await registerSymbol(dataset.style, undefined, symbolRegistry, SYMBOL_RASTER_PIXEL_RATIO)
245
+ expect(dataset.flatStyle['icon-anchor']).toEqual([0.5, 1])
246
+ })
247
+
248
+ it('wraps icon style in a filter rule when the dataset has one', async () => {
249
+ const dataset = datasetRegistry.getDataset('ds-symbol-filter')
250
+ await registerSymbol(dataset.style, undefined, symbolRegistry, SYMBOL_RASTER_PIXEL_RATIO)
251
+ expect(dataset.flatStyle).toEqual([{
252
+ filter: ['==', ['get', 'type'], 'a'],
253
+ style: {
254
+ 'icon-src': 'data:image/png;base64,mock',
255
+ 'icon-anchor': [0.5, 0.9],
256
+ 'icon-scale': 1 / SYMBOL_RASTER_PIXEL_RATIO
257
+ }
258
+ }])
259
+ })
260
+ })
261
+ })
262
+ })
@@ -93,15 +93,15 @@ const mergeFetchedFeatures = (features, data, idProperty) => {
93
93
  * Fetch data for the current viewport and merge it into the feature cache.
94
94
  * Mutates state.fetchedBbox / state.currentController as a side effect.
95
95
  */
96
- const fetchViewportData = async (state, { map, dynamicGeoJSON, onUpdate }) => {
96
+ const fetchViewportData = async (state, { mapProvider, dynamicGeoJSON, onUpdate }) => {
97
97
  const { url: baseUrl, idProperty, transformRequest, maxFeatures, minZoom = 0 } = dynamicGeoJSON
98
98
 
99
- const zoom = map.getZoom()
99
+ const zoom = mapProvider.getZoom()
100
100
  if (zoom < minZoom) {
101
101
  return
102
102
  }
103
103
 
104
- const currentBbox = getBboxArray(map)
104
+ const currentBbox = getBboxArray(mapProvider)
105
105
 
106
106
  // Skip if current viewport is already covered
107
107
  if (state.fetchedBbox && bboxContains(state.fetchedBbox, currentBbox)) {
@@ -141,15 +141,19 @@ const fetchViewportData = async (state, { map, dynamicGeoJSON, onUpdate }) => {
141
141
  }
142
142
  }
143
143
 
144
+ // mapbox-gl has on()/off(), but OL's Observable only has on()/once()/un() — duck-type the
145
+ // removal call rather than adding a map-provider abstraction just for this one method.
146
+ const offMoveEnd = (map, handler) => (map.off || map.un).call(map, 'moveend', handler)
147
+
144
148
  /**
145
149
  * Create a dynamic GeoJSON source that fetches data based on viewport
146
150
  * @param {Object} options
147
151
  * @param {Object} options.dynamicGeoJSON - dynamicGeoJSON config from the registry dataset
148
- * @param {Object} options.map - Map instance
152
+ * @param {Object} options.mapProvider - Map provider instance (e.g. MapLibreProvider/OpenLayersProvider)
149
153
  * @param {Function} options.onUpdate - Callback when source data should be updated
150
154
  * @returns {Object} { destroy, clear, refresh, getFeatureCount, reapply }
151
155
  */
152
- export const createDynamicSource = ({ dynamicGeoJSON, map, onUpdate }) => {
156
+ export const createDynamicSource = ({ dynamicGeoJSON, mapProvider, onUpdate }) => {
153
157
  // state.features: id → { feature, bbox, lastSeenAt }
154
158
  const state = {
155
159
  features: new Map(),
@@ -157,7 +161,7 @@ export const createDynamicSource = ({ dynamicGeoJSON, map, onUpdate }) => {
157
161
  currentController: null
158
162
  }
159
163
 
160
- const fetchData = () => fetchViewportData(state, { map, dynamicGeoJSON, onUpdate })
164
+ const fetchData = () => fetchViewportData(state, { mapProvider, dynamicGeoJSON, onUpdate })
161
165
 
162
166
  // Debounced fetch handler
163
167
  const debouncedFetch = debounce(fetchData, DEBOUNCE_DELAY)
@@ -167,7 +171,7 @@ export const createDynamicSource = ({ dynamicGeoJSON, map, onUpdate }) => {
167
171
  debouncedFetch()
168
172
  }
169
173
 
170
- map.on('moveend', handleMoveEnd)
174
+ mapProvider.map.on('moveend', handleMoveEnd)
171
175
 
172
176
  // Initial fetch
173
177
  fetchData()
@@ -177,7 +181,7 @@ export const createDynamicSource = ({ dynamicGeoJSON, map, onUpdate }) => {
177
181
  * Clean up event listeners and cancel any in-flight request
178
182
  */
179
183
  destroy () {
180
- map.off('moveend', handleMoveEnd)
184
+ offMoveEnd(mapProvider.map, handleMoveEnd)
181
185
  debouncedFetch.cancel()
182
186
  if (state.currentController) {
183
187
  state.currentController.abort()
@@ -9,22 +9,19 @@ jest.mock('./fetchGeoJSON.js', () => ({
9
9
 
10
10
  const DEBOUNCE_DELAY = 200
11
11
 
12
- const makeBounds = ([west, south, east, north]) => ({
13
- getWest: () => west,
14
- getSouth: () => south,
15
- getEast: () => east,
16
- getNorth: () => north
17
- })
18
-
19
- // Minimal map stub: tracks a mutable bbox/zoom and the registered moveend handler
20
- const makeMap = ({ bbox = [-1, -1, 1, 1], zoom = 10 } = {}) => {
12
+ // Minimal mapProvider stub: getBounds()/getZoom() live on the provider (as they do on both
13
+ // MapLibreProvider and OpenLayersProvider), while 'moveend' is subscribed on the raw map —
14
+ // tracks a mutable bbox/zoom and the registered moveend handler.
15
+ const makeMapProvider = ({ bbox = [-1, -1, 1, 1], zoom = 10 } = {}) => {
21
16
  const state = { bbox, zoom }
22
17
  const handlers = {}
23
18
  return {
24
19
  getZoom: jest.fn(() => state.zoom),
25
- getBounds: jest.fn(() => makeBounds(state.bbox)),
26
- on: jest.fn((event, handler) => { handlers[event] = handler }),
27
- off: jest.fn(),
20
+ getBounds: jest.fn(() => state.bbox),
21
+ map: {
22
+ on: jest.fn((event, handler) => { handlers[event] = handler }),
23
+ off: jest.fn()
24
+ },
28
25
  pan (bbox) { state.bbox = bbox },
29
26
  fireMoveEnd () { handlers.moveend?.() }
30
27
  }
@@ -74,7 +71,7 @@ afterEach(() => {
74
71
 
75
72
  describe('createDynamicSource', () => {
76
73
  it('fetches the initial viewport on creation and pushes features to onUpdate', async () => {
77
- const map = makeMap({ bbox: [-1, -1, 1, 1], zoom: 10 })
74
+ const mapProvider = makeMapProvider({ bbox: [-1, -1, 1, 1], zoom: 10 })
78
75
  const onUpdate = jest.fn()
79
76
  const dynamicGeoJSON = makeDynamicGeoJSON({ idProperty: 'featureId' })
80
77
  const feature = makePointFeature('ignored', [0, 0], { featureId: 'abc' })
@@ -82,7 +79,7 @@ describe('createDynamicSource', () => {
82
79
  const featureWithoutIdProperty = makePointFeature('fallback-id', [0, 0])
83
80
  fetchGeoJSON.mockResolvedValueOnce(featureCollection([feature, featureWithoutIdProperty]))
84
81
 
85
- const instance = createDynamicSource({ dynamicGeoJSON, map, onUpdate })
82
+ const instance = createDynamicSource({ dynamicGeoJSON, mapProvider, onUpdate })
86
83
  await flushMicrotasks()
87
84
 
88
85
  expect(fetchGeoJSON).toHaveBeenCalledWith(
@@ -96,14 +93,14 @@ describe('createDynamicSource', () => {
96
93
  })
97
94
 
98
95
  it('falls back to feature.id when no idProperty is configured, and skips features with no resolvable id', async () => {
99
- const map = makeMap()
96
+ const mapProvider = makeMapProvider()
100
97
  const onUpdate = jest.fn()
101
98
  const dynamicGeoJSON = makeDynamicGeoJSON()
102
99
  const withId = makePointFeature('has-id', [0, 0])
103
100
  const withoutId = { type: 'Feature', properties: {}, geometry: { type: 'Point', coordinates: [0, 0] } }
104
101
  fetchGeoJSON.mockResolvedValueOnce(featureCollection([withId, withoutId]))
105
102
 
106
- const instance = createDynamicSource({ dynamicGeoJSON, map, onUpdate })
103
+ const instance = createDynamicSource({ dynamicGeoJSON, mapProvider, onUpdate })
107
104
  await flushMicrotasks()
108
105
 
109
106
  expect(instance.getFeatureCount()).toBe(1)
@@ -111,46 +108,46 @@ describe('createDynamicSource', () => {
111
108
  })
112
109
 
113
110
  it('skips fetching below minZoom, and destroy() is a no-op with no in-flight request', async () => {
114
- const map = makeMap({ zoom: 2 })
111
+ const mapProvider = makeMapProvider({ zoom: 2 })
115
112
  const dynamicGeoJSON = makeDynamicGeoJSON({ minZoom: 5 })
116
113
 
117
- const instance = createDynamicSource({ dynamicGeoJSON, map, onUpdate: jest.fn() })
114
+ const instance = createDynamicSource({ dynamicGeoJSON, mapProvider, onUpdate: jest.fn() })
118
115
  await flushMicrotasks()
119
116
 
120
117
  expect(fetchGeoJSON).not.toHaveBeenCalled()
121
118
  expect(() => instance.destroy()).not.toThrow()
122
- expect(map.off).toHaveBeenCalledWith('moveend', expect.any(Function))
119
+ expect(mapProvider.map.off).toHaveBeenCalledWith('moveend', expect.any(Function))
123
120
  })
124
121
 
125
122
  it('skips re-fetching when the new viewport is already covered by the last fetch', async () => {
126
- const map = makeMap({ bbox: [-1, -1, 1, 1] })
123
+ const mapProvider = makeMapProvider({ bbox: [-1, -1, 1, 1] })
127
124
  const dynamicGeoJSON = makeDynamicGeoJSON()
128
125
  fetchGeoJSON.mockResolvedValueOnce(featureCollection([]))
129
126
 
130
- createDynamicSource({ dynamicGeoJSON, map, onUpdate: jest.fn() })
127
+ createDynamicSource({ dynamicGeoJSON, mapProvider, onUpdate: jest.fn() })
131
128
  await flushMicrotasks()
132
129
 
133
- map.pan([-0.5, -0.5, 0.5, 0.5]) // fully inside the fetched bbox
134
- map.fireMoveEnd()
130
+ mapProvider.pan([-0.5, -0.5, 0.5, 0.5]) // fully inside the fetched bbox
131
+ mapProvider.fireMoveEnd()
135
132
  await advanceDebounce()
136
133
 
137
134
  expect(fetchGeoJSON).toHaveBeenCalledTimes(1)
138
135
  })
139
136
 
140
137
  it('debounces rapid map movement into a single re-fetch', async () => {
141
- const map = makeMap({ bbox: [-1, -1, 1, 1] })
138
+ const mapProvider = makeMapProvider({ bbox: [-1, -1, 1, 1] })
142
139
  const dynamicGeoJSON = makeDynamicGeoJSON()
143
140
  fetchGeoJSON.mockResolvedValue(featureCollection([]))
144
141
 
145
- createDynamicSource({ dynamicGeoJSON, map, onUpdate: jest.fn() })
142
+ createDynamicSource({ dynamicGeoJSON, mapProvider, onUpdate: jest.fn() })
146
143
  await flushMicrotasks()
147
144
 
148
- map.pan([10, 10, 12, 12])
149
- map.fireMoveEnd()
145
+ mapProvider.pan([10, 10, 12, 12])
146
+ mapProvider.fireMoveEnd()
150
147
  jest.advanceTimersByTime(50)
151
- map.fireMoveEnd()
148
+ mapProvider.fireMoveEnd()
152
149
  jest.advanceTimersByTime(50)
153
- map.fireMoveEnd()
150
+ mapProvider.fireMoveEnd()
154
151
  await advanceDebounce()
155
152
 
156
153
  expect(fetchGeoJSON).toHaveBeenCalledTimes(2) // initial + one debounced re-fetch
@@ -159,16 +156,16 @@ describe('createDynamicSource', () => {
159
156
  it('aborts the previous in-flight request when a new viewport is fetched before it resolves', async () => {
160
157
  let resolveFirst
161
158
  fetchGeoJSON.mockImplementationOnce(() => new Promise((resolve) => { resolveFirst = resolve }))
162
- const map = makeMap({ bbox: [-1, -1, 1, 1] })
159
+ const mapProvider = makeMapProvider({ bbox: [-1, -1, 1, 1] })
163
160
 
164
- createDynamicSource({ dynamicGeoJSON: makeDynamicGeoJSON(), map, onUpdate: jest.fn() })
161
+ createDynamicSource({ dynamicGeoJSON: makeDynamicGeoJSON(), mapProvider, onUpdate: jest.fn() })
165
162
  await flushMicrotasks()
166
163
  const firstSignal = fetchGeoJSON.mock.calls[0][3]
167
164
  expect(firstSignal.aborted).toBe(false)
168
165
 
169
166
  fetchGeoJSON.mockResolvedValueOnce(featureCollection([]))
170
- map.pan([10, 10, 12, 12])
171
- map.fireMoveEnd()
167
+ mapProvider.pan([10, 10, 12, 12])
168
+ mapProvider.fireMoveEnd()
172
169
  await advanceDebounce()
173
170
 
174
171
  expect(firstSignal.aborted).toBe(true)
@@ -176,11 +173,11 @@ describe('createDynamicSource', () => {
176
173
  })
177
174
 
178
175
  it('evicts least-recently-seen features once over the maxFeatures threshold, out-of-view first then in-view', async () => {
179
- const map = makeMap({ bbox: [-1, -1, 1, 1] })
176
+ const mapProvider = makeMapProvider({ bbox: [-1, -1, 1, 1] })
180
177
  const dynamicGeoJSON = makeDynamicGeoJSON({ maxFeatures: 1 })
181
178
  fetchGeoJSON.mockResolvedValueOnce(featureCollection([makePointFeature('a', [0, 0])]))
182
179
 
183
- const instance = createDynamicSource({ dynamicGeoJSON, map, onUpdate: jest.fn() })
180
+ const instance = createDynamicSource({ dynamicGeoJSON, mapProvider, onUpdate: jest.fn() })
184
181
  await flushMicrotasks()
185
182
  expect(instance.getFeatureCount()).toBe(1) // under threshold (1 <= 1 * 1.2), no eviction yet
186
183
 
@@ -188,8 +185,8 @@ describe('createDynamicSource', () => {
188
185
  // Evicting the single out-of-view feature ('a') alone reaches the target, so the
189
186
  // in-view eviction pass is skipped.
190
187
  fetchGeoJSON.mockResolvedValueOnce(featureCollection([makePointFeature('b', [10, 10])]))
191
- map.pan([10, 10, 12, 12])
192
- map.fireMoveEnd()
188
+ mapProvider.pan([10, 10, 12, 12])
189
+ mapProvider.fireMoveEnd()
193
190
  await advanceDebounce()
194
191
  expect(instance.getFeatureCount()).toBe(1)
195
192
 
@@ -200,29 +197,29 @@ describe('createDynamicSource', () => {
200
197
  makePointFeature('c', [20, 20]),
201
198
  makePointFeature('d', [21, 21])
202
199
  ]))
203
- map.pan([20, 20, 22, 22])
204
- map.fireMoveEnd()
200
+ mapProvider.pan([20, 20, 22, 22])
201
+ mapProvider.fireMoveEnd()
205
202
  await advanceDebounce()
206
203
  expect(instance.getFeatureCount()).toBe(1)
207
204
  })
208
205
 
209
206
  it('sorts multiple out-of-view features by lastSeenAt when evicting', async () => {
210
- const map = makeMap({ bbox: [-1, -1, 1, 1] })
207
+ const mapProvider = makeMapProvider({ bbox: [-1, -1, 1, 1] })
211
208
  const dynamicGeoJSON = makeDynamicGeoJSON({ maxFeatures: 2 })
212
209
  fetchGeoJSON.mockResolvedValueOnce(featureCollection([
213
210
  makePointFeature('a', [0, 0]),
214
211
  makePointFeature('b', [0.5, 0.5])
215
212
  ]))
216
213
 
217
- const instance = createDynamicSource({ dynamicGeoJSON, map, onUpdate: jest.fn() })
214
+ const instance = createDynamicSource({ dynamicGeoJSON, mapProvider, onUpdate: jest.fn() })
218
215
  await flushMicrotasks()
219
216
  expect(instance.getFeatureCount()).toBe(2) // under threshold (2 <= 2 * 1.2), no eviction yet
220
217
 
221
218
  // New viewport, far from 'a' and 'b': both become out-of-view at once, exercising the
222
219
  // out-of-view sort-by-lastSeenAt comparator (a no-op array has nothing to compare)
223
220
  fetchGeoJSON.mockResolvedValueOnce(featureCollection([makePointFeature('c', [10, 10])]))
224
- map.pan([10, 10, 12, 12])
225
- map.fireMoveEnd()
221
+ mapProvider.pan([10, 10, 12, 12])
222
+ mapProvider.fireMoveEnd()
226
223
  await advanceDebounce()
227
224
 
228
225
  expect(instance.getFeatureCount()).toBe(2) // one of a/b evicted, 'c' kept
@@ -233,7 +230,7 @@ describe('createDynamicSource', () => {
233
230
  fetchGeoJSON.mockRejectedValueOnce(error)
234
231
  const onUpdate = jest.fn()
235
232
 
236
- createDynamicSource({ dynamicGeoJSON: makeDynamicGeoJSON(), map: makeMap(), onUpdate })
233
+ createDynamicSource({ dynamicGeoJSON: makeDynamicGeoJSON(), mapProvider: makeMapProvider(), onUpdate })
237
234
  await flushMicrotasks()
238
235
 
239
236
  expect(console.error).not.toHaveBeenCalled()
@@ -244,7 +241,7 @@ describe('createDynamicSource', () => {
244
241
  const error = new Error('network down')
245
242
  fetchGeoJSON.mockRejectedValueOnce(error)
246
243
 
247
- createDynamicSource({ dynamicGeoJSON: makeDynamicGeoJSON(), map: makeMap(), onUpdate: jest.fn() })
244
+ createDynamicSource({ dynamicGeoJSON: makeDynamicGeoJSON(), mapProvider: makeMapProvider(), onUpdate: jest.fn() })
248
245
  await flushMicrotasks()
249
246
 
250
247
  expect(console.error).toHaveBeenCalledWith(
@@ -255,27 +252,39 @@ describe('createDynamicSource', () => {
255
252
 
256
253
  it('destroy() unregisters moveend, cancels the pending debounce, and aborts the in-flight request', async () => {
257
254
  fetchGeoJSON.mockImplementationOnce(() => new Promise(() => {})) // never resolves
258
- const map = makeMap()
255
+ const mapProvider = makeMapProvider()
259
256
 
260
- const instance = createDynamicSource({ dynamicGeoJSON: makeDynamicGeoJSON(), map, onUpdate: jest.fn() })
257
+ const instance = createDynamicSource({ dynamicGeoJSON: makeDynamicGeoJSON(), mapProvider, onUpdate: jest.fn() })
261
258
  await flushMicrotasks()
262
259
  const signal = fetchGeoJSON.mock.calls[0][3]
263
260
 
264
- map.fireMoveEnd() // schedule a debounced re-fetch
261
+ mapProvider.fireMoveEnd() // schedule a debounced re-fetch
265
262
  instance.destroy()
266
263
 
267
- expect(map.off).toHaveBeenCalledWith('moveend', expect.any(Function))
264
+ expect(mapProvider.map.off).toHaveBeenCalledWith('moveend', expect.any(Function))
268
265
  expect(signal.aborted).toBe(true)
269
266
 
270
267
  await advanceDebounce()
271
268
  expect(fetchGeoJSON).toHaveBeenCalledTimes(1) // debounced call never fired
272
269
  })
273
270
 
271
+ it('unregisters moveend via un() when the raw map has no off() (OpenLayers)', async () => {
272
+ const mapProvider = makeMapProvider()
273
+ mapProvider.map.un = jest.fn()
274
+ delete mapProvider.map.off
275
+
276
+ const instance = createDynamicSource({ dynamicGeoJSON: makeDynamicGeoJSON(), mapProvider, onUpdate: jest.fn() })
277
+ await flushMicrotasks()
278
+ instance.destroy()
279
+
280
+ expect(mapProvider.map.un).toHaveBeenCalledWith('moveend', expect.any(Function))
281
+ })
282
+
274
283
  it('clear() empties the cache and pushes an empty FeatureCollection', async () => {
275
284
  const onUpdate = jest.fn()
276
285
  fetchGeoJSON.mockResolvedValueOnce(featureCollection([makePointFeature('a', [0, 0])]))
277
286
 
278
- const instance = createDynamicSource({ dynamicGeoJSON: makeDynamicGeoJSON(), map: makeMap(), onUpdate })
287
+ const instance = createDynamicSource({ dynamicGeoJSON: makeDynamicGeoJSON(), mapProvider: makeMapProvider(), onUpdate })
279
288
  await flushMicrotasks()
280
289
 
281
290
  instance.clear()
@@ -286,7 +295,7 @@ describe('createDynamicSource', () => {
286
295
 
287
296
  it('refresh() clears the cache and re-fetches the current viewport', async () => {
288
297
  fetchGeoJSON.mockResolvedValueOnce(featureCollection([makePointFeature('a', [0, 0])]))
289
- const instance = createDynamicSource({ dynamicGeoJSON: makeDynamicGeoJSON(), map: makeMap(), onUpdate: jest.fn() })
298
+ const instance = createDynamicSource({ dynamicGeoJSON: makeDynamicGeoJSON(), mapProvider: makeMapProvider(), onUpdate: jest.fn() })
290
299
  await flushMicrotasks()
291
300
 
292
301
  fetchGeoJSON.mockResolvedValueOnce(featureCollection([makePointFeature('b', [0, 0])]))
@@ -302,7 +311,7 @@ describe('createDynamicSource', () => {
302
311
  const feature = makePointFeature('a', [0, 0])
303
312
  fetchGeoJSON.mockResolvedValueOnce(featureCollection([feature]))
304
313
 
305
- const instance = createDynamicSource({ dynamicGeoJSON: makeDynamicGeoJSON(), map: makeMap(), onUpdate })
314
+ const instance = createDynamicSource({ dynamicGeoJSON: makeDynamicGeoJSON(), mapProvider: makeMapProvider(), onUpdate })
306
315
  await flushMicrotasks()
307
316
  onUpdate.mockClear()
308
317
 
@@ -71,7 +71,19 @@ export function DatasetsInit ({ pluginConfig, pluginState, appState, mapState, m
71
71
  useEffect(() => {
72
72
  datasetRegistry.attachMapStyle(mapState.mapStyle)
73
73
  if (layerAdapter?.onMapStyleChange) {
74
- layerAdapter.onMapStyleChange()
74
+ // MapLibre's own map.addImage()/setPaintProperty() calls (made while re-registering
75
+ // symbols/patterns below) naturally re-trigger MAP_DATA_CHANGE via the GL engine's own
76
+ // styledata event — nothing extra needed there. OL has no such generic signal (its
77
+ // MAP_DATA_CHANGE is tied only to the basemap tile source's own tileloadend — see
78
+ // providers/beta/openlayers/src/appEvents.js's comment), so re-registering a symbol on a
79
+ // plain ol/layer/Vector never fires it. Emitting it explicitly once re-registration
80
+ // genuinely finishes lets the interact plugin's settle-window re-apply (armed by
81
+ // MAP_STYLE_CHANGE, see useHighlightSync.js) pick up the new theme's selected/active
82
+ // symbol images, instead of a highlight staying stuck on whatever was cached moments
83
+ // before the switch.
84
+ Promise.resolve(layerAdapter.onMapStyleChange()).then(() => {
85
+ eventBus.emit(EVENTS.MAP_DATA_CHANGE)
86
+ })
75
87
  }
76
88
  },
77
89
  [mapState.mapStyle])