@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
@@ -5,20 +5,18 @@ import OlFeature from 'ol/Feature.js'
5
5
  import Point from 'ol/geom/Point.js'
6
6
  import Icon from 'ol/style/Icon.js'
7
7
  import { updateHighlightedFeatures } from './highlightFeatures.js'
8
- import { getOrCreateSymbolImage, clearSymbolImageCache } from './symbolImages.js'
8
+ import { getOrCreateSymbolImage, getCachedSymbolImage, clearSymbolImageCache, registerSymbol } from './symbolImages.js'
9
9
 
10
10
  const HIGHLIGHT_MARKER = '_highlight'
11
11
 
12
- // Mirrors the map interface this module actually calls: getLayers().forEach(...) to find/
13
- // enumerate real ol/layer instances, and addLayer() to register new ones. Layers passed in
14
- // (or added during a test) are real ol/layer/Vector or ol/layer/VectorTile instances, same as
15
- // the rest of this codebase's OL tests use real ol classes over mocks for anything the
16
- // module under test actually instantiates or type-checks (instanceof).
12
+ // Mirrors the map interface this module actually calls: getLayers().forEach(...)/getArray()
13
+ // to find/enumerate real ol/layer instances, and addLayer() to register new ones. Layers
14
+ // passed in (or added during a test) are real ol/layer/Vector or ol/layer/VectorTile instances.
17
15
  const createFakeMap = (layers = []) => {
18
16
  const list = [...layers]
19
17
  return {
20
18
  _layers: list,
21
- getLayers: () => ({ forEach: (cb) => list.forEach(cb) }),
19
+ getLayers: () => ({ forEach: (cb) => list.forEach(cb), getArray: () => list }),
22
20
  addLayer: jest.fn((l) => list.push(l))
23
21
  }
24
22
  }
@@ -166,13 +164,160 @@ describe('updateHighlightedFeatures', () => {
166
164
  expect(getHighlightLayer(map).getSource().getFeatures()).toHaveLength(0)
167
165
  })
168
166
  })
167
+
168
+ describe('symbol-styled points (dataset)', () => {
169
+ const LAYER_ID = 'historic-monuments-prehistoric'
170
+
171
+ // A dataset symbol point has no symbolSelectedImageId/symbolActiveImageId of its own (see
172
+ // symbolProperties above) — the layer itself is tagged with the one base imageId every
173
+ // feature shares instead (see layerBuilders.js's createDatasetLayer / OpenLayersDataset.
174
+ // symbolMeta), and the variant is resolved from that via symbolImages.js's reverse-map.
175
+ const datasetSymbolLayer = (features, symbolMeta) => {
176
+ const source = new VectorSource()
177
+ features.forEach(([id, properties]) => {
178
+ const f = new OlFeature({ geometry: new Point([1, 2]), ...properties })
179
+ f.setId(id)
180
+ source.addFeature(f)
181
+ })
182
+ const layer = new VectorLayer({ source })
183
+ layer.set('layerId', LAYER_ID)
184
+ layer.set('layerType', 'vector')
185
+ if (symbolMeta) {
186
+ layer.set('symbolMeta', symbolMeta)
187
+ }
188
+ return layer
189
+ }
190
+
191
+ const registerDatasetSymbol = async () => {
192
+ const symbolRegistry = {
193
+ getSymbolImageId: jest.fn((style, mapStyle, active) => (active ? 'ds-symbol-active' : 'ds-symbol-normal')),
194
+ rasteriseSymbolImage: jest.fn(async (style, mapStyle, variant) => ({
195
+ imageId: variant === 'active' ? 'ds-symbol-active' : variant === 'selected' ? 'ds-symbol-selected' : 'ds-symbol-normal',
196
+ imageData: { width: 30, height: 30 }
197
+ }))
198
+ }
199
+ await registerSymbol({ symbol: 'square' }, {}, symbolRegistry, 3)
200
+ }
201
+
202
+ test('renders the selected-variant Icon for a dataset symbol point', async () => {
203
+ await registerDatasetSymbol()
204
+ const selectedCanvas = getCachedSymbolImage('ds-symbol-selected')
205
+ const map = createFakeMap([datasetSymbolLayer([['f1', {}]], { imageId: 'ds-symbol-normal', anchor: [0.5, 1] })])
206
+
207
+ updateHighlightedFeatures(map, [{ layerId: LAYER_ID, featureId: 'f1', geometry: { type: 'Point', coordinates: [1, 2] } }], [], {})
208
+ const [hlFeature] = getHighlightLayer(map).getSource().getFeatures()
209
+ const [style] = hlFeature.getStyle()
210
+ expect(style.getImage()).toBeInstanceOf(Icon)
211
+ expect(style.getImage().getImage(1)).toBe(selectedCanvas)
212
+ })
213
+
214
+ test('renders the active-variant Icon for the keyboard-cursor item', async () => {
215
+ await registerDatasetSymbol()
216
+ const activeCanvas = getCachedSymbolImage('ds-symbol-active')
217
+ const map = createFakeMap([datasetSymbolLayer([['f1', {}]], { imageId: 'ds-symbol-normal', anchor: [0.5, 1] })])
218
+
219
+ updateHighlightedFeatures(map, [], [{ layerId: LAYER_ID, featureId: 'f1', geometry: { type: 'Point', coordinates: [1, 2] } }], {})
220
+ const [hlFeature] = getHighlightLayer(map).getSource().getFeatures()
221
+ const [style] = hlFeature.getStyle()
222
+ expect(style.getImage().getImage(1)).toBe(activeCanvas)
223
+ })
224
+
225
+ test('falls back to no highlight when the layer has no symbolMeta (not a symbol dataset)', () => {
226
+ const map = createFakeMap([datasetSymbolLayer([['f1', {}]])])
227
+ updateHighlightedFeatures(map, [{ layerId: LAYER_ID, featureId: 'f1', geometry: { type: 'Point', coordinates: [1, 2] } }], [], {})
228
+ expect(getHighlightLayer(map).getSource().getFeatures()).toHaveLength(0)
229
+ })
230
+
231
+ test('falls back to no highlight when the variant has not been rasterised/cached yet', () => {
232
+ const map = createFakeMap([datasetSymbolLayer([['f1', {}]], { imageId: 'ds-symbol-never-registered', anchor: [0.5, 1] })])
233
+ updateHighlightedFeatures(map, [{ layerId: LAYER_ID, featureId: 'f1', geometry: { type: 'Point', coordinates: [1, 2] } }], [], {})
234
+ expect(getHighlightLayer(map).getSource().getFeatures()).toHaveLength(0)
235
+ })
236
+ })
169
237
  })
170
238
 
171
- describe('VectorTileLayer style-wrap (smoke test — pre-existing, undocumented path)', () => {
239
+ describe('VectorTileLayer style-wrap', () => {
172
240
  test('does not throw when a VT layer is present alongside a draw VectorLayer', () => {
173
241
  const vt = new VectorTileLayer({})
174
242
  vt.set('layerType', 'vectorTile')
175
243
  const map = createFakeMap([vt, drawLayer()])
176
244
  expect(() => updateHighlightedFeatures(map, [], [], {})).not.toThrow()
177
245
  })
246
+
247
+ const vtStylesMap = { 'existing-fields': { stroke: '#000', selectionStroke: '#111', fill: 'transparent', strokeWidth: 2, activeStrokeWidth: 2 } }
248
+
249
+ // Two different vector-tile producers share the 'vectorTile' layerType tag: draw-ol's
250
+ // basemap MVT tiles (a 'mapbox-layer' object per feature) and the datasets plugin's own
251
+ // tiles-backed datasets (no 'mapbox-layer' at all — the id lives on the OL layer itself, as
252
+ // 'layerId'). This feature has neither a mapbox-layer property nor any live 'get' beyond the
253
+ // default, matching a real datasets-plugin MVT feature.
254
+ const datasetVtFeature = (id) => {
255
+ const feature = new OlFeature({ geometry: new Point([1, 2]) })
256
+ feature.setId(id)
257
+ return feature
258
+ }
259
+
260
+ test('highlights a datasets-plugin tiles-backed feature via the layer\'s own layerId', () => {
261
+ const vt = new VectorTileLayer({ style: { 'fill-color': '#0000ff' } })
262
+ vt.set('layerType', 'vectorTile')
263
+ vt.set('layerId', 'existing-fields')
264
+ const map = createFakeMap([vt])
265
+ const feature = datasetVtFeature('f1')
266
+
267
+ updateHighlightedFeatures(map, [{ layerId: 'existing-fields', featureId: 'f1', geometry: { type: 'Point', coordinates: [1, 2] } }], [], vtStylesMap)
268
+
269
+ const styles = vt.getStyle()(feature, 1)
270
+ expect(Array.isArray(styles)).toBe(true)
271
+ expect(styles.some(s => s.getStroke()?.getColor() === '#111')).toBe(true) // selectionStroke applied
272
+ })
273
+
274
+ test('leaves a non-matching datasets-plugin tiles-backed feature unhighlighted', () => {
275
+ const vt = new VectorTileLayer({ style: { 'fill-color': '#0000ff' } })
276
+ vt.set('layerType', 'vectorTile')
277
+ vt.set('layerId', 'existing-fields')
278
+ const map = createFakeMap([vt])
279
+ const feature = datasetVtFeature('f2') // not the selected f1
280
+
281
+ updateHighlightedFeatures(map, [{ layerId: 'existing-fields', featureId: 'f1', geometry: { type: 'Point', coordinates: [1, 2] } }], [], vtStylesMap)
282
+
283
+ const base = vt._highlightOriginalStyle(feature, 1)
284
+ const styled = vt.getStyle()(feature, 1)
285
+ expect(styled).toEqual(base)
286
+ })
287
+
288
+ test('picks up a restyle made while a selection stays active, instead of wrapping a stale pre-restyle style forever', () => {
289
+ // Simulates a map style/theme switch: _setLayerStyle calls layer.setStyle(...) directly on
290
+ // a dataset layer while a feature on it is still selected (e.g. via useHighlightSync's
291
+ // re-apply after MAP_STYLE_CHANGE) — the highlight wrap must notice its own wrap was
292
+ // replaced and re-capture the new live style, not keep calling the old one underneath.
293
+ const vt = new VectorTileLayer({ style: { 'fill-color': '#0000ff' } })
294
+ vt.set('layerType', 'vectorTile')
295
+ vt.set('layerId', 'existing-fields')
296
+ const map = createFakeMap([vt])
297
+ const selection = [{ layerId: 'existing-fields', featureId: 'f1', geometry: { type: 'Point', coordinates: [1, 2] } }]
298
+
299
+ updateHighlightedFeatures(map, selection, [], vtStylesMap)
300
+
301
+ // Something outside the highlight module (a theme switch) restyles the layer directly.
302
+ vt.setStyle({ 'fill-color': '#ff0000' })
303
+
304
+ updateHighlightedFeatures(map, selection, [], vtStylesMap)
305
+
306
+ const otherFeature = datasetVtFeature('not-selected')
307
+ const [styled] = vt.getStyle()(otherFeature, 1)
308
+ expect(styled.getFill().getColor()).toEqual([255, 0, 0, 1])
309
+ })
310
+
311
+ test('restores the original style function once nothing is selected/active', () => {
312
+ const vt = new VectorTileLayer({ style: { 'fill-color': '#0000ff' } })
313
+ vt.set('layerType', 'vectorTile')
314
+ vt.set('layerId', 'existing-fields')
315
+ const map = createFakeMap([vt])
316
+
317
+ updateHighlightedFeatures(map, [{ layerId: 'existing-fields', featureId: 'f1', geometry: { type: 'Point', coordinates: [1, 2] } }], [], vtStylesMap)
318
+ expect(vt._highlightOriginalStyle).toBeDefined()
319
+
320
+ updateHighlightedFeatures(map, [], [], vtStylesMap)
321
+ expect(vt._highlightOriginalStyle).toBeUndefined()
322
+ })
178
323
  })
@@ -7,7 +7,11 @@ const HIT_TOLERANCE = 8
7
7
  // ol, so a class reference from this bundle never matches an instance built by another.
8
8
  const isInteractiveFeature = (feature, layer, layerSet) => {
9
9
  if (layer.get('layerType') === 'vectorTile') {
10
- const styleLayerId = feature.get('mapbox-layer')?.id
10
+ // Two different vector-tile producers share this tag: draw-ol's basemap MVT tiles (a
11
+ // 'mapbox-layer' object per feature) and the datasets plugin's own tiles-backed datasets
12
+ // (no 'mapbox-layer' — the id lives on the OL layer itself, as 'layerId'). See
13
+ // plugins/datasets/src/adapters/openlayers/layerBuilders.js's createDatasetLayer.
14
+ const styleLayerId = feature.get('mapbox-layer')?.id ?? layer.get('layerId')
11
15
  return Boolean(styleLayerId && layerSet.has(styleLayerId))
12
16
  }
13
17
  if (layer.get('layerType') === 'vector' && !layer.get(HIGHLIGHT_MARKER)) {
@@ -30,6 +30,20 @@ const makeVTLayer = () => {
30
30
  return layer
31
31
  }
32
32
 
33
+ // A datasets-plugin tiles-backed layer: 'vectorTile' layerType, but no 'mapbox-layer' on the
34
+ // feature — its layerId lives on the layer itself instead.
35
+ const makeDatasetVTLayer = (layerId) => {
36
+ const layer = new VectorTileLayer()
37
+ layer.get = (key) => {
38
+ if (key === 'layerType') return 'vectorTile'
39
+ if (key === 'layerId') return layerId
40
+ return undefined
41
+ }
42
+ return layer
43
+ }
44
+
45
+ const makeDatasetVTFeature = () => ({ get: () => undefined })
46
+
33
47
  const makeVectorLayer = (layerId, isHighlight = false) => {
34
48
  const layer = new VectorLayer()
35
49
  layer.get = (key) => {
@@ -154,6 +168,32 @@ describe('setupHoverCursor', () => {
154
168
  expect(map.getViewport().style.cursor).toBe('')
155
169
  })
156
170
 
171
+ /* ------------------------------------------------------------------ */
172
+ /* Feature matching — datasets-plugin tiles-backed VT layer */
173
+ /* ------------------------------------------------------------------ */
174
+
175
+ it('matches a dataset tiles-backed feature via the layer\'s own layerId', () => {
176
+ const map = makeMap()
177
+ const feature = makeDatasetVTFeature()
178
+ const layer = makeDatasetVTLayer('existing-fields')
179
+ map.forEachFeatureAtPixel.mockImplementation((pixel, cb) => cb(feature, layer))
180
+
181
+ const handler = setupHoverCursor(map, ['existing-fields'], null)
182
+ move(handler)
183
+ expect(map.getViewport().style.cursor).toBe('pointer')
184
+ })
185
+
186
+ it('does not match a dataset tiles-backed feature whose layerId is not watched', () => {
187
+ const map = makeMap()
188
+ const feature = makeDatasetVTFeature()
189
+ const layer = makeDatasetVTLayer('existing-fields')
190
+ map.forEachFeatureAtPixel.mockImplementation((pixel, cb) => cb(feature, layer))
191
+
192
+ const handler = setupHoverCursor(map, ['hedge-control'], null)
193
+ move(handler)
194
+ expect(map.getViewport().style.cursor).toBe('')
195
+ })
196
+
157
197
  /* ------------------------------------------------------------------ */
158
198
  /* Feature matching — Vector layer */
159
199
  /* ------------------------------------------------------------------ */
@@ -1,6 +1,7 @@
1
1
  import GeoJSON from 'ol/format/GeoJSON.js'
2
2
  import TileState from 'ol/TileState.js'
3
3
  import { renderFeatureToGeoJSON } from './vtTileFragments.js'
4
+ import { buildFilterEvaluator } from './filterEvaluator.js'
4
5
 
5
6
  const CRS = 'EPSG:27700'
6
7
 
@@ -23,6 +24,57 @@ const getVtFeatureId = (feature) => {
23
24
  return JSON.stringify(props)
24
25
  }
25
26
 
27
+ // Shared result shape for a real ol/Feature identified by an OL layer's own layerId — used by
28
+ // plain 'vector' layers and by tiles-backed datasets, as opposed to draw-ol's basemap MVT tiles
29
+ // (RenderFeatures identified via a 'mapbox-layer' object instead).
30
+ const pushLayerIdResult = (results, seenKeys, layerId, feature) => {
31
+ if (!layerId) {
32
+ return
33
+ }
34
+ // Property-hash fallback so distinct id-less features sharing a source don't collide.
35
+ const key = `${layerId}:${getVtFeatureId(feature)}`
36
+ if (seenKeys.has(key)) {
37
+ return
38
+ }
39
+ seenKeys.add(key)
40
+ results.push({
41
+ id: feature.getId(),
42
+ layer: { id: layerId },
43
+ geometry: geoJsonFormat.writeGeometryObject(feature.getGeometry()),
44
+ properties: feature.getProperties()
45
+ })
46
+ }
47
+
48
+ const pushMapboxLayerResult = (results, seenKeys, mapboxLayer, feature) => {
49
+ const styleLayerId = mapboxLayer?.id
50
+ // background-type layers have no features in MapLibre — skip to match behaviour
51
+ if (!styleLayerId || mapboxLayer?.type === 'background') {
52
+ return
53
+ }
54
+ const key = `${styleLayerId}:${getVtFeatureId(feature)}`
55
+ if (seenKeys.has(key)) {
56
+ return
57
+ }
58
+ seenKeys.add(key)
59
+ results.push({
60
+ id: feature.getId(),
61
+ layer: { id: styleLayerId },
62
+ geometry: renderFeatureToGeoJSON(feature),
63
+ properties: feature.getProperties()
64
+ })
65
+ }
66
+
67
+ // Two different vector-tile producers share the 'vectorTile' layerType tag: draw-ol's basemap
68
+ // MVT tiles (carry a 'mapbox-layer' object) and tiles-backed datasets (carry 'layerId' instead).
69
+ const pushVectorTileResult = (results, seenKeys, layer, feature) => {
70
+ const mapboxLayer = feature.get('mapbox-layer')
71
+ if (mapboxLayer) {
72
+ pushMapboxLayerResult(results, seenKeys, mapboxLayer, feature)
73
+ return
74
+ }
75
+ pushLayerIdResult(results, seenKeys, layer.get('layerId'), feature)
76
+ }
77
+
26
78
  export const queryFeatures = (map, point, options = {}) => {
27
79
  if (!point) {
28
80
  return []
@@ -36,42 +88,11 @@ export const queryFeatures = (map, point, options = {}) => {
36
88
  pixel,
37
89
  (feature, layer) => {
38
90
  if (layer.get('layerType') === 'vectorTile') {
39
- const mapboxLayer = feature.get('mapbox-layer')
40
- const styleLayerId = mapboxLayer?.id
41
- // background-type layers have no features in MapLibre — skip to match behaviour
42
- if (!styleLayerId || mapboxLayer?.type === 'background') {
43
- return
44
- }
45
- const key = `${styleLayerId}:${getVtFeatureId(feature)}`
46
- if (seenKeys.has(key)) {
47
- return
48
- }
49
- seenKeys.add(key)
50
- results.push({
51
- id: feature.getId(),
52
- layer: { id: styleLayerId },
53
- geometry: renderFeatureToGeoJSON(feature),
54
- properties: feature.getProperties()
55
- })
56
- } else if (layer.get('layerType') === 'vector') {
57
- const layerId = layer.get('layerId')
58
- if (!layerId || layer.get('_highlight')) {
59
- return
60
- }
61
- const featureId = feature.getId()
62
- const key = `${layerId}:${featureId}`
63
- if (seenKeys.has(key)) {
64
- return
65
- }
66
- seenKeys.add(key)
67
- results.push({
68
- id: featureId,
69
- layer: { id: layerId },
70
- geometry: geoJsonFormat.writeGeometryObject(feature.getGeometry()),
71
- properties: feature.getProperties()
72
- })
91
+ pushVectorTileResult(results, seenKeys, layer, feature)
92
+ } else if (layer.get('layerType') === 'vector' && !layer.get('_highlight')) {
93
+ pushLayerIdResult(results, seenKeys, layer.get('layerId'), feature)
73
94
  } else {
74
- // other layer types (e.g. TileLayer) — skip
95
+ // other layer types (e.g. TileLayer), or a highlight overlay — skip
75
96
  }
76
97
  },
77
98
  { hitTolerance: radius }
@@ -93,6 +114,10 @@ export const queryFeatures = (map, point, options = {}) => {
93
114
  * separate extent check. A single logical feature can be split across tile boundaries, so
94
115
  * fragments are deduplicated the same way queryFeatures() does above.
95
116
  * VectorLayer features come directly from the source's current-viewport extent.
117
+ *
118
+ * Both branches also apply each layer's own tagged filter (see buildFilterEvaluator), since
119
+ * reading straight off the source/tiles has no other way to isolate a shared source's sibling
120
+ * sublayers.
96
121
  */
97
122
  export const getVisibleFeatures = (map, layerIds) => {
98
123
  const wanted = new Set(layerIds)
@@ -106,28 +131,23 @@ export const getVisibleFeatures = (map, layerIds) => {
106
131
  if (!sourceTiles) {
107
132
  return
108
133
  }
134
+ const layerId = mapLayer.get('layerId')
135
+ const matchesFilter = buildFilterEvaluator(mapLayer.get('filter'))
109
136
  Object.values(sourceTiles).forEach(tile => {
110
137
  if (tile.getState() !== TileState.LOADED) {
111
138
  return
112
139
  }
113
140
  tile.getFeatures().forEach(feature => {
114
141
  const mapboxLayer = feature.get('mapbox-layer')
115
- const styleLayerId = mapboxLayer?.id
116
- // background-type layers have no features in MapLibre — skip to match behaviour
117
- if (!styleLayerId || !wanted.has(styleLayerId) || mapboxLayer?.type === 'background') {
142
+ if (mapboxLayer) {
143
+ if (wanted.has(mapboxLayer.id)) {
144
+ pushMapboxLayerResult(results, seenKeys, mapboxLayer, feature)
145
+ }
118
146
  return
119
147
  }
120
- const key = `${styleLayerId}:${getVtFeatureId(feature)}`
121
- if (seenKeys.has(key)) {
122
- return
148
+ if (wanted.has(layerId) && (!matchesFilter || matchesFilter(feature))) {
149
+ pushLayerIdResult(results, seenKeys, layerId, feature)
123
150
  }
124
- seenKeys.add(key)
125
- results.push({
126
- id: feature.getId(),
127
- layer: { id: styleLayerId },
128
- geometry: renderFeatureToGeoJSON(feature),
129
- properties: feature.getProperties()
130
- })
131
151
  })
132
152
  })
133
153
  } else if (mapLayer.get('layerType') === 'vector') {
@@ -135,13 +155,11 @@ export const getVisibleFeatures = (map, layerIds) => {
135
155
  if (!layerId || !wanted.has(layerId) || mapLayer.get('_highlight')) {
136
156
  return
137
157
  }
158
+ const matchesFilter = buildFilterEvaluator(mapLayer.get('filter'))
138
159
  mapLayer.getSource()?.getFeaturesInExtent(extent).forEach(feature => {
139
- results.push({
140
- id: feature.getId(),
141
- layer: { id: layerId },
142
- geometry: geoJsonFormat.writeGeometryObject(feature.getGeometry()),
143
- properties: feature.getProperties()
144
- })
160
+ if (!matchesFilter || matchesFilter(feature)) {
161
+ pushLayerIdResult(results, seenKeys, layerId, feature)
162
+ }
145
163
  })
146
164
  } else {
147
165
  // other layer types (e.g. TileLayer) — skip
@@ -26,6 +26,23 @@ const makeMap = (hits = []) => ({
26
26
 
27
27
  const makeVTLayer = () => Object.assign(new VectorTileLayer(), { get: (key) => key === 'layerType' ? 'vectorTile' : undefined })
28
28
 
29
+ // A datasets-plugin tiles-backed layer: 'vectorTile' layerType, but a real ol/Feature with no
30
+ // 'mapbox-layer' property, and a layerId set on the layer itself (not the feature).
31
+ const makeDatasetVTLayer = (layerId) => Object.assign(new VectorTileLayer(), {
32
+ get: (key) => {
33
+ if (key === 'layerType') return 'vectorTile'
34
+ if (key === 'layerId') return layerId
35
+ return undefined
36
+ }
37
+ })
38
+
39
+ const makeDatasetVTFeature = (id, geom = { type: 'Point' }, props = {}) => ({
40
+ getId: () => id,
41
+ get: () => undefined, // no 'mapbox-layer'
42
+ getGeometry: () => geom,
43
+ getProperties: () => props
44
+ })
45
+
29
46
  const makeVTFeature = ({ id = undefined, styleLayerId = 'roads', type = 'fill', props = {} } = {}) => ({
30
47
  getId: () => id,
31
48
  get: (key) => {
@@ -50,6 +67,16 @@ const makeVectorFeature = (id = 'f1', geom = { type: 'Point' }, props = {}) => (
50
67
  getProperties: () => props
51
68
  })
52
69
 
70
+ // A feature with no native id, e.g. a plain geojson dataset with no id/idProperty — getId()
71
+ // genuinely returns undefined for these, not a default like makeVectorFeature('f1', ...) would.
72
+ // getType() is real ol/geom/Geometry API — needed here (unlike makeVectorFeature's plain shape
73
+ // object) since these features are used with a filter, and buildFilterEvaluator's context reads it.
74
+ const makeIdlessVectorFeature = (props, geom = { getType: () => 'Point' }) => ({
75
+ getId: () => undefined,
76
+ getGeometry: () => geom,
77
+ getProperties: () => props
78
+ })
79
+
53
80
  describe('queryFeatures', () => {
54
81
  beforeEach(() => jest.clearAllMocks())
55
82
 
@@ -135,6 +162,31 @@ describe('queryFeatures', () => {
135
162
  expect(queryFeatures(map, { x: 0, y: 0 })).toHaveLength(1)
136
163
  })
137
164
 
165
+ /* ------------------------------------------------------------------ */
166
+ /* Datasets-plugin tiles-backed VectorTile layer features */
167
+ /* ------------------------------------------------------------------ */
168
+
169
+ it('returns a result for a dataset tiles-backed feature, keyed by the layer\'s own layerId', () => {
170
+ const feature = makeDatasetVTFeature('f1', { type: 'Point' }, { sbi: 123 })
171
+ const map = makeMap([[feature, makeDatasetVTLayer('existing-fields')]])
172
+ const results = queryFeatures(map, { x: 0, y: 0 })
173
+ expect(results).toHaveLength(1)
174
+ expect(results[0]).toMatchObject({ id: 'f1', layer: { id: 'existing-fields' }, properties: { sbi: 123 } })
175
+ expect(renderFeatureToGeoJSON).not.toHaveBeenCalled()
176
+ })
177
+
178
+ it('skips a dataset tiles-backed feature when the layer has no layerId', () => {
179
+ const feature = makeDatasetVTFeature('f1')
180
+ const map = makeMap([[feature, makeDatasetVTLayer(undefined)]])
181
+ expect(queryFeatures(map, { x: 0, y: 0 })).toEqual([])
182
+ })
183
+
184
+ it('deduplicates dataset tiles-backed features with the same layerId and feature id', () => {
185
+ const feature = makeDatasetVTFeature('f1')
186
+ const map = makeMap([[feature, makeDatasetVTLayer('existing-fields')], [feature, makeDatasetVTLayer('existing-fields')]])
187
+ expect(queryFeatures(map, { x: 0, y: 0 })).toHaveLength(1)
188
+ })
189
+
138
190
  /* ------------------------------------------------------------------ */
139
191
  /* Vector layer features */
140
192
  /* ------------------------------------------------------------------ */
@@ -204,13 +256,14 @@ describe('getVisibleFeatures', () => {
204
256
  getSource: () => ({ sourceTiles_: tiles })
205
257
  })
206
258
 
207
- const makeVectorLayerWithFeatures = (layerId, features, isHighlight = false) => {
259
+ const makeVectorLayerWithFeatures = (layerId, features, isHighlight = false, filter = undefined) => {
208
260
  const source = { getFeaturesInExtent: jest.fn(() => features) }
209
261
  return Object.assign(new VectorLayer(), {
210
262
  get: (key) => {
211
263
  if (key === 'layerType') return 'vector'
212
264
  if (key === 'layerId') return layerId
213
265
  if (key === '_highlight') return isHighlight || undefined
266
+ if (key === 'filter') return filter
214
267
  return undefined
215
268
  },
216
269
  getSource: () => source
@@ -258,6 +311,48 @@ describe('getVisibleFeatures', () => {
258
311
  expect(getVisibleFeatures(makeExtentMap([layer]), ['roads'])).toEqual([])
259
312
  })
260
313
 
314
+ /* ------------------------------------------------------------------ */
315
+ /* Datasets-plugin tiles-backed VectorTile layer features */
316
+ /* ------------------------------------------------------------------ */
317
+
318
+ const makeDatasetVTLayerWithTiles = (layerId, tiles, filter = undefined) => Object.assign(new VectorTileLayer(), {
319
+ get: (key) => {
320
+ if (key === 'layerType') return 'vectorTile'
321
+ if (key === 'layerId') return layerId
322
+ if (key === 'filter') return filter
323
+ return undefined
324
+ },
325
+ getSource: () => ({ sourceTiles_: tiles })
326
+ })
327
+
328
+ it('returns a loaded dataset tiles-backed feature matching the layer\'s own layerId', () => {
329
+ const feature = makeDatasetVTFeature('f1')
330
+ const layer = makeDatasetVTLayerWithTiles('existing-fields', { a: makeTile([feature]) })
331
+ const results = getVisibleFeatures(makeExtentMap([layer]), ['existing-fields'])
332
+ expect(results).toHaveLength(1)
333
+ expect(results[0]).toMatchObject({ id: 'f1', layer: { id: 'existing-fields' } })
334
+ })
335
+
336
+ it('skips a dataset tiles-backed layer whose layerId is not requested', () => {
337
+ const feature = makeDatasetVTFeature('f1')
338
+ const layer = makeDatasetVTLayerWithTiles('existing-fields', { a: makeTile([feature]) })
339
+ expect(getVisibleFeatures(makeExtentMap([layer]), ['hedge-control'])).toEqual([])
340
+ })
341
+
342
+ it('applies the layer\'s own filter when reading a shared VT source (e.g. sibling sourceLayers)', () => {
343
+ const geom = { getType: () => 'Point' }
344
+ const matching = makeDatasetVTFeature('f1', geom, { layer: 'field_parcels_osgb36' })
345
+ const other = makeDatasetVTFeature('f2', geom, { layer: 'hedge_control' })
346
+ const layer = makeDatasetVTLayerWithTiles(
347
+ 'existing-fields',
348
+ { a: makeTile([matching, other]) },
349
+ ['==', ['get', 'layer'], 'field_parcels_osgb36']
350
+ )
351
+ const results = getVisibleFeatures(makeExtentMap([layer]), ['existing-fields'])
352
+ expect(results).toHaveLength(1)
353
+ expect(results[0].id).toBe('f1')
354
+ })
355
+
261
356
  /* ------------------------------------------------------------------ */
262
357
  /* Vector layer features */
263
358
  /* ------------------------------------------------------------------ */
@@ -288,6 +383,53 @@ describe('getVisibleFeatures', () => {
288
383
  expect(getVisibleFeatures(makeExtentMap([layer]), ['draw'])).toEqual([])
289
384
  })
290
385
 
386
+ // Regression coverage for a real bug: sibling sublayers of one dataset (e.g. a symbol
387
+ // dataset's per-category sublayers) share one OL source — reading straight off it, with no
388
+ // rendering step involved, previously ignored each sublayer's own filter entirely, so every
389
+ // sibling reported every feature in the shared source. Combined with id-less features (no
390
+ // id/idProperty configured, common for demo/small datasets) all colliding on the same
391
+ // `${layerId}:undefined` dedup key, this collapsed every sibling down to whichever feature
392
+ // happened to be first in the source, regardless of which one it actually was.
393
+ it('applies each sibling sublayer\'s own filter when reading one shared Vector source', () => {
394
+ const prehistoric = makeIdlessVectorFeature({ category: 'prehistoric', name: 'Prehistoric feature' })
395
+ const roman = makeIdlessVectorFeature({ category: 'roman', name: 'Roman feature' })
396
+ const medieval = makeIdlessVectorFeature({ category: 'medieval', name: 'Medieval feature' })
397
+ const sharedSourceFeatures = [prehistoric, roman, medieval]
398
+
399
+ const layers = ['prehistoric', 'roman', 'medieval'].map(category =>
400
+ makeVectorLayerWithFeatures(
401
+ `historic-monuments-${category}`,
402
+ sharedSourceFeatures,
403
+ false,
404
+ ['==', ['get', 'category'], category]
405
+ )
406
+ )
407
+ const layerIds = layers.map(l => l.get('layerId'))
408
+
409
+ const results = getVisibleFeatures(makeExtentMap(layers), layerIds)
410
+
411
+ expect(results).toHaveLength(3)
412
+ layers.forEach((layer, i) => {
413
+ const layerId = layerIds[i]
414
+ const own = results.find(r => r.layer.id === layerId)
415
+ expect(own?.properties.name).toBe(sharedSourceFeatures[i].getProperties().name)
416
+ })
417
+ })
418
+
419
+ it('does not dedupe distinct id-less features sharing a layer/source', () => {
420
+ const a = makeIdlessVectorFeature({ name: 'A' })
421
+ const b = makeIdlessVectorFeature({ name: 'B' })
422
+ const layer = makeVectorLayerWithFeatures('draw', [a, b])
423
+ const results = getVisibleFeatures(makeExtentMap([layer]), ['draw'])
424
+ expect(results).toHaveLength(2)
425
+ expect(results.map(r => r.properties.name).sort()).toEqual(['A', 'B'])
426
+ })
427
+
428
+ it('a Vector layer with no filter tag still returns every feature in the source (untagged = unfiltered)', () => {
429
+ const layer = makeVectorLayerWithFeatures('draw', [makeVectorFeature('f1'), makeVectorFeature('f2')])
430
+ expect(getVisibleFeatures(makeExtentMap([layer]), ['draw'])).toHaveLength(2)
431
+ })
432
+
291
433
  /* ------------------------------------------------------------------ */
292
434
  /* Mixed / other layers */
293
435
  /* ------------------------------------------------------------------ */