@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.
- package/dist/esm/im-core.js +1 -1
- package/dist/umd/im-core.js +1 -1
- package/docs/api.md +19 -0
- package/docs/architecture.md +14 -0
- package/docs/getting-started.md +10 -0
- package/docs/plugins/datasets.md +9 -3
- package/jest.setup.js +8 -0
- package/package.json +8 -3
- package/plugins/datasets/dist/esm/im-datasets-ml-adapter.js +1 -1
- package/plugins/datasets/dist/esm/im-datasets-ol-adapter.js +1 -0
- package/plugins/datasets/dist/esm/im-datasets-plugin.js +1 -1
- package/plugins/datasets/dist/umd/im-datasets-ml-adapter.js +1 -1
- package/plugins/datasets/dist/umd/im-datasets-ol-adapter.js +2 -0
- package/plugins/datasets/dist/umd/im-datasets-ol-adapter.js.LICENSE.txt +1 -0
- package/plugins/datasets/dist/umd/im-datasets-plugin.js +1 -1
- package/plugins/datasets/dist/umd/index.js +1 -1
- package/plugins/datasets/src/adapters/loadLayerAdapter.js +7 -3
- package/plugins/datasets/src/adapters/loadLayerAdapter.test.js +17 -0
- package/plugins/datasets/src/adapters/mapboxStyleLayerAdapter.js +74 -0
- package/plugins/datasets/src/adapters/maplibre/maplibreLayerAdapter.js +14 -79
- package/plugins/datasets/src/adapters/maplibre/maplibreLayerAdapter.test.js +4 -0
- package/plugins/datasets/src/adapters/maplibre/registry/mapLibreDataset.js +3 -89
- package/plugins/datasets/src/adapters/openlayers/canvasPatternStyle.js +111 -0
- package/plugins/datasets/src/adapters/openlayers/canvasPatternStyle.test.js +198 -0
- package/plugins/datasets/src/adapters/openlayers/layerBuilders.js +205 -0
- package/plugins/datasets/src/adapters/openlayers/layerBuilders.test.js +233 -0
- package/plugins/datasets/src/adapters/openlayers/openlayersLayerAdapter.js +276 -0
- package/plugins/datasets/src/adapters/openlayers/openlayersLayerAdapter.test.js +431 -0
- package/plugins/datasets/src/adapters/openlayers/registry/openLayersDataset.js +172 -0
- package/plugins/datasets/src/adapters/openlayers/registry/openLayersDataset.test.js +262 -0
- package/plugins/datasets/src/fetch/createDynamicSource.js +12 -8
- package/plugins/datasets/src/fetch/createDynamicSource.test.js +61 -52
- package/plugins/datasets/src/initialise/DatasetsInit.jsx +13 -1
- package/plugins/datasets/src/initialise/DatasetsInit.test.jsx +20 -0
- package/plugins/datasets/src/initialise/initialiseDatasets.js +1 -1
- package/plugins/datasets/src/initialise/initialiseDatasets.test.js +1 -1
- package/plugins/datasets/src/registry/mapboxStyleDataset.js +108 -0
- package/plugins/datasets/src/registry/mapboxStyleDataset.test.js +240 -0
- package/plugins/datasets/src/utils/bbox.js +5 -7
- package/plugins/datasets/src/utils/bbox.test.js +3 -10
- package/plugins/draw/dist/css/index.css +1 -0
- package/plugins/draw/dist/umd/im-draw-ol-adapter.js +1 -1
- package/plugins/draw/dist/umd/im-draw-plugin.js +1 -1
- package/plugins/draw/src/index.js +2 -0
- package/plugins/draw/src/index.test.js +1 -0
- package/plugins/map-key/dist/esm/im-map-key-plugin.js +1 -1
- package/plugins/map-key/dist/umd/im-map-key-plugin.js +1 -1
- package/providers/beta/openlayers/dist/esm/im-openlayers-provider.js +1 -1
- package/providers/beta/openlayers/dist/umd/im-openlayers-provider.js +1 -1
- package/providers/beta/openlayers/dist/umd/index.js +1 -1
- package/providers/beta/openlayers/src/index.js +16 -0
- package/providers/beta/openlayers/src/openlayersProvider.js +160 -7
- package/providers/beta/openlayers/src/openlayersProvider.test.js +13 -0
- package/providers/beta/openlayers/src/utils/bngProjection.js +9 -0
- package/providers/beta/openlayers/src/utils/filterEvaluator.js +32 -0
- package/providers/beta/openlayers/src/utils/filterEvaluator.test.js +36 -0
- package/providers/beta/openlayers/src/utils/highlightFeatures.js +38 -23
- package/providers/beta/openlayers/src/utils/highlightFeatures.test.js +153 -8
- package/providers/beta/openlayers/src/utils/hoverCursor.js +5 -1
- package/providers/beta/openlayers/src/utils/hoverCursor.test.js +40 -0
- package/providers/beta/openlayers/src/utils/queryFeatures.js +72 -54
- package/providers/beta/openlayers/src/utils/queryFeatures.test.js +143 -1
- package/providers/beta/openlayers/src/utils/symbolImages.js +79 -5
- package/providers/beta/openlayers/src/utils/symbolImages.test.js +111 -1
- package/providers/beta/openlayers/src/utils/vtTileFragments.js +20 -8
- package/providers/beta/openlayers/src/utils/vtTileFragments.test.js +162 -0
- package/providers/maplibre/dist/esm/im-maplibre-provider.js +1 -1
- package/providers/maplibre/dist/umd/im-maplibre-provider.js +1 -1
- package/providers/maplibre/dist/umd/index.js +1 -1
- package/providers/maplibre/src/utils/patternImages.js +4 -44
- package/rollup.esm.mjs +2 -0
- package/src/services/patternRegistry.js +38 -0
- package/src/services/patternRegistry.test.js +61 -0
- package/src/types.js +11 -0
- package/webpack.dev.mjs +1 -0
- /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(...)
|
|
13
|
-
// enumerate real ol/layer instances, and addLayer() to register new ones. Layers
|
|
14
|
-
// (or added during a test) are real ol/layer/Vector or ol/layer/VectorTile instances
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
-
|
|
116
|
-
|
|
117
|
-
|
|
142
|
+
if (mapboxLayer) {
|
|
143
|
+
if (wanted.has(mapboxLayer.id)) {
|
|
144
|
+
pushMapboxLayerResult(results, seenKeys, mapboxLayer, feature)
|
|
145
|
+
}
|
|
118
146
|
return
|
|
119
147
|
}
|
|
120
|
-
|
|
121
|
-
|
|
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
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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
|
/* ------------------------------------------------------------------ */
|