@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
|
@@ -1,18 +1,36 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The OL "last mile" for symbolRegistry's rasterised symbol images: turns the ImageData
|
|
3
|
-
* rasteriseSymbolImage() produces into something
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
3
|
+
* rasteriseSymbolImage() produces into something OL can render, cached by the same imageId
|
|
4
|
+
* symbolRegistry computes. OL has no map-level image registry like MapLibre's map.addImage,
|
|
5
|
+
* so this cache lives at the module level instead.
|
|
6
|
+
*
|
|
7
|
+
* Two consumers need two different shapes from the same ImageData: highlightFeatures.js builds
|
|
8
|
+
* its own ol/style/Icon directly, so it wants a raw <canvas> (getOrCreateSymbolImage/
|
|
9
|
+
* getCachedSymbolImage); OpenLayersDataset.flatStyle's icon-src needs a data URI string instead.
|
|
8
10
|
*/
|
|
9
11
|
|
|
12
|
+
// Fixed oversampling ratio, unrelated to the map's actual pixelRatio — icon-scale genuinely
|
|
13
|
+
// decouples a symbol's on-screen size from its native pixel detail, so one fixed high-detail
|
|
14
|
+
// raster stays crisp at any pixelRatio with no re-rasterisation needed on resize.
|
|
15
|
+
export const SYMBOL_RASTER_PIXEL_RATIO = 3
|
|
16
|
+
|
|
10
17
|
const imageCache = new Map() // imageId → HTMLCanvasElement
|
|
18
|
+
const dataUriCache = new Map() // imageId → data URI string
|
|
19
|
+
const activeImageMap = new Map() // normalId → activeId
|
|
20
|
+
const selectedImageMap = new Map() // normalId → selectedId
|
|
11
21
|
|
|
12
22
|
/** Synchronous lookup for style functions (which must be synchronous) — undefined until
|
|
13
23
|
* getOrCreateSymbolImage() has resolved it at least once. */
|
|
14
24
|
export const getCachedSymbolImage = (imageId) => imageCache.get(imageId)
|
|
15
25
|
|
|
26
|
+
/**
|
|
27
|
+
* Reverse-map a symbol's normal (base) imageId to its active/selected variant, mirroring
|
|
28
|
+
* MapLibre's own active/selected image maps. Used by highlightFeatures.js to highlight a
|
|
29
|
+
* dataset symbol point, which (unlike a drawn point) has no active/selected id of its own.
|
|
30
|
+
*/
|
|
31
|
+
export const getActiveSymbolImageId = (normalId) => activeImageMap.get(normalId) ?? null
|
|
32
|
+
export const getSelectedSymbolImageId = (normalId) => selectedImageMap.get(normalId) ?? null
|
|
33
|
+
|
|
16
34
|
/**
|
|
17
35
|
* Rasterise-once-cache-forever: draws the given ImageData onto a canvas the first time
|
|
18
36
|
* imageId is seen, and returns the cached canvas on every subsequent call.
|
|
@@ -34,7 +52,63 @@ export const getOrCreateSymbolImage = (imageId, imageData) => {
|
|
|
34
52
|
return canvas
|
|
35
53
|
}
|
|
36
54
|
|
|
55
|
+
/** Synchronous lookup for OpenLayersDataset.flatStyle (a synchronous getter) — undefined
|
|
56
|
+
* until registerSymbol() has resolved it at least once. */
|
|
57
|
+
export const getCachedSymbolDataUri = (imageId) => dataUriCache.get(imageId)
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Rasterise-once-cache-forever: resolves and caches one symbol's data URI for flatStyle's
|
|
61
|
+
* icon-src, plus its active/selected variants so highlightFeatures.js can highlight a dataset
|
|
62
|
+
* symbol point the same way it already does for a drawn one.
|
|
63
|
+
* @param {Object} style - Dataset style with symbol/symbolSvgContent properties
|
|
64
|
+
* @param {Object} mapStyle - Current map style config
|
|
65
|
+
* @param {Object} symbolRegistry
|
|
66
|
+
* @param {number} pixelRatio - the map's actual current pixelRatio
|
|
67
|
+
* @returns {Promise<void>}
|
|
68
|
+
*/
|
|
69
|
+
export const registerSymbol = async (style, mapStyle, symbolRegistry, pixelRatio) => {
|
|
70
|
+
const normalId = symbolRegistry.getSymbolImageId(style, mapStyle, false, pixelRatio)
|
|
71
|
+
if (!normalId) {
|
|
72
|
+
return
|
|
73
|
+
}
|
|
74
|
+
const activeId = symbolRegistry.getSymbolImageId(style, mapStyle, true, pixelRatio)
|
|
75
|
+
if (activeId) {
|
|
76
|
+
activeImageMap.set(normalId, activeId)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
await Promise.all(['normal', 'active', 'selected'].map(async (variant) => {
|
|
80
|
+
const imageId = variant === 'active' ? activeId : normalId
|
|
81
|
+
if (variant !== 'selected' && (!imageId || imageCache.has(imageId))) {
|
|
82
|
+
return
|
|
83
|
+
}
|
|
84
|
+
const result = await symbolRegistry.rasteriseSymbolImage(style, mapStyle, variant, pixelRatio)
|
|
85
|
+
if (!result) {
|
|
86
|
+
return
|
|
87
|
+
}
|
|
88
|
+
if (variant === 'selected') {
|
|
89
|
+
selectedImageMap.set(normalId, result.imageId)
|
|
90
|
+
}
|
|
91
|
+
if (!imageCache.has(result.imageId)) {
|
|
92
|
+
const canvas = getOrCreateSymbolImage(result.imageId, result.imageData)
|
|
93
|
+
if (variant === 'normal') {
|
|
94
|
+
dataUriCache.set(result.imageId, canvas.toDataURL())
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}))
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Register symbol data URIs for the given pre-resolved style configs, in parallel. */
|
|
101
|
+
export const registerSymbols = async (styleArray, mapStyle, symbolRegistry, pixelRatio) => {
|
|
102
|
+
if (!styleArray.length) {
|
|
103
|
+
return
|
|
104
|
+
}
|
|
105
|
+
await Promise.all(styleArray.map(style => registerSymbol(style, mapStyle, symbolRegistry, pixelRatio)))
|
|
106
|
+
}
|
|
107
|
+
|
|
37
108
|
/** Mainly for testing — clears every cached image. */
|
|
38
109
|
export const clearSymbolImageCache = () => {
|
|
39
110
|
imageCache.clear()
|
|
111
|
+
dataUriCache.clear()
|
|
112
|
+
activeImageMap.clear()
|
|
113
|
+
selectedImageMap.clear()
|
|
40
114
|
}
|
|
@@ -1,7 +1,17 @@
|
|
|
1
|
-
import { getCachedSymbolImage, getOrCreateSymbolImage, clearSymbolImageCache } from './symbolImages.js'
|
|
1
|
+
import { getCachedSymbolImage, getOrCreateSymbolImage, clearSymbolImageCache, registerSymbol, registerSymbols, getCachedSymbolDataUri, getActiveSymbolImageId, getSelectedSymbolImageId } from './symbolImages.js'
|
|
2
|
+
|
|
3
|
+
const MAP_STYLE = { id: 'outdoor' }
|
|
2
4
|
|
|
3
5
|
const imageData = (width, height) => ({ width, height })
|
|
4
6
|
|
|
7
|
+
const makeSymbolRegistry = () => ({
|
|
8
|
+
getSymbolImageId: jest.fn((style, mapStyle, active, pixelRatio) => `symbol-${style.symbol}-${pixelRatio}x${active ? '-active' : ''}`),
|
|
9
|
+
rasteriseSymbolImage: jest.fn(async (style, mapStyle, variant, pixelRatio) => ({
|
|
10
|
+
imageId: `symbol-${style.symbol}-${pixelRatio}x${variant === 'active' ? '-active' : variant === 'selected' ? '-selected' : ''}`,
|
|
11
|
+
imageData: imageData(20, 20)
|
|
12
|
+
}))
|
|
13
|
+
})
|
|
14
|
+
|
|
5
15
|
beforeEach(() => {
|
|
6
16
|
clearSymbolImageCache()
|
|
7
17
|
// Memoized per-canvas so `canvas.getContext()` returns the same mock object on repeat
|
|
@@ -11,6 +21,9 @@ beforeEach(() => {
|
|
|
11
21
|
this._ctx ??= { putImageData: jest.fn() }
|
|
12
22
|
return this._ctx
|
|
13
23
|
})
|
|
24
|
+
HTMLCanvasElement.prototype.toDataURL = jest.fn(function () {
|
|
25
|
+
return `data:image/png;base64,mock-${this.width}x${this.height}`
|
|
26
|
+
})
|
|
14
27
|
})
|
|
15
28
|
|
|
16
29
|
describe('getCachedSymbolImage', () => {
|
|
@@ -55,4 +68,101 @@ describe('clearSymbolImageCache', () => {
|
|
|
55
68
|
const second = getOrCreateSymbolImage('symbol-f', imageData(10, 10))
|
|
56
69
|
expect(second).not.toBe(first)
|
|
57
70
|
})
|
|
71
|
+
|
|
72
|
+
it('also clears the data URI cache', async () => {
|
|
73
|
+
const symbolRegistry = makeSymbolRegistry()
|
|
74
|
+
await registerSymbol({ symbol: 'pin' }, MAP_STYLE, symbolRegistry, 1)
|
|
75
|
+
clearSymbolImageCache()
|
|
76
|
+
expect(getCachedSymbolDataUri('symbol-pin-1x')).toBeUndefined()
|
|
77
|
+
})
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
describe('registerSymbol', () => {
|
|
81
|
+
it('caches a data URI built from the rasterised ImageData', async () => {
|
|
82
|
+
const symbolRegistry = makeSymbolRegistry()
|
|
83
|
+
await registerSymbol({ symbol: 'pin' }, MAP_STYLE, symbolRegistry, 1)
|
|
84
|
+
expect(getCachedSymbolDataUri('symbol-pin-1x')).toBe('data:image/png;base64,mock-20x20')
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
it('rasterises at the given pixelRatio, not a fixed constant', async () => {
|
|
88
|
+
const symbolRegistry = makeSymbolRegistry()
|
|
89
|
+
await registerSymbol({ symbol: 'pin' }, MAP_STYLE, symbolRegistry, 3)
|
|
90
|
+
expect(symbolRegistry.rasteriseSymbolImage).toHaveBeenCalledWith({ symbol: 'pin' }, MAP_STYLE, 'normal', 3)
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
it('does nothing when rasteriseSymbolImage returns null', async () => {
|
|
94
|
+
const symbolRegistry = { ...makeSymbolRegistry(), rasteriseSymbolImage: jest.fn().mockResolvedValue(null) }
|
|
95
|
+
await registerSymbol({ symbol: 'unknown' }, MAP_STYLE, symbolRegistry, 1)
|
|
96
|
+
expect(getCachedSymbolDataUri('symbol-unknown-1x')).toBeUndefined()
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
it('does nothing when getSymbolImageId returns null', async () => {
|
|
100
|
+
const symbolRegistry = { ...makeSymbolRegistry(), getSymbolImageId: jest.fn(() => null) }
|
|
101
|
+
await registerSymbol({ symbol: 'unknown' }, MAP_STYLE, symbolRegistry, 1)
|
|
102
|
+
expect(symbolRegistry.rasteriseSymbolImage).not.toHaveBeenCalled()
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
it('skips re-rasterising normal/active once cached for the same imageId — selected always re-rasterises', async () => {
|
|
106
|
+
const symbolRegistry = makeSymbolRegistry()
|
|
107
|
+
await registerSymbol({ symbol: 'pin' }, MAP_STYLE, symbolRegistry, 1)
|
|
108
|
+
expect(symbolRegistry.rasteriseSymbolImage).toHaveBeenCalledTimes(3) // normal + active + selected
|
|
109
|
+
await registerSymbol({ symbol: 'pin' }, MAP_STYLE, symbolRegistry, 1)
|
|
110
|
+
expect(symbolRegistry.rasteriseSymbolImage).toHaveBeenCalledTimes(4) // + selected only
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
it('reuses the same cached canvas getOrCreateSymbolImage builds', async () => {
|
|
114
|
+
const symbolRegistry = makeSymbolRegistry()
|
|
115
|
+
await registerSymbol({ symbol: 'pin' }, MAP_STYLE, symbolRegistry, 1)
|
|
116
|
+
expect(getCachedSymbolImage('symbol-pin-1x')).toBeInstanceOf(HTMLCanvasElement)
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
it('registers the active variant, resolvable via getActiveSymbolImageId', async () => {
|
|
120
|
+
const symbolRegistry = makeSymbolRegistry()
|
|
121
|
+
await registerSymbol({ symbol: 'pin' }, MAP_STYLE, symbolRegistry, 1)
|
|
122
|
+
expect(getActiveSymbolImageId('symbol-pin-1x')).toBe('symbol-pin-1x-active')
|
|
123
|
+
expect(getCachedSymbolImage('symbol-pin-1x-active')).toBeInstanceOf(HTMLCanvasElement)
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
it('registers the selected variant, resolvable via getSelectedSymbolImageId', async () => {
|
|
127
|
+
const symbolRegistry = makeSymbolRegistry()
|
|
128
|
+
await registerSymbol({ symbol: 'pin' }, MAP_STYLE, symbolRegistry, 1)
|
|
129
|
+
expect(getSelectedSymbolImageId('symbol-pin-1x')).toBe('symbol-pin-1x-selected')
|
|
130
|
+
expect(getCachedSymbolImage('symbol-pin-1x-selected')).toBeInstanceOf(HTMLCanvasElement)
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
it('does not register an active variant when getSymbolImageId returns null for active', async () => {
|
|
134
|
+
const symbolRegistry = makeSymbolRegistry()
|
|
135
|
+
symbolRegistry.getSymbolImageId.mockImplementation((style, mapStyle, active) => (active ? null : 'symbol-pin-1x'))
|
|
136
|
+
await registerSymbol({ symbol: 'pin' }, MAP_STYLE, symbolRegistry, 1)
|
|
137
|
+
expect(getActiveSymbolImageId('symbol-pin-1x')).toBeNull()
|
|
138
|
+
})
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
describe('getActiveSymbolImageId / getSelectedSymbolImageId', () => {
|
|
142
|
+
it('returns null for an imageId that has never been registered', () => {
|
|
143
|
+
expect(getActiveSymbolImageId('symbol-unseen')).toBeNull()
|
|
144
|
+
expect(getSelectedSymbolImageId('symbol-unseen')).toBeNull()
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
it('is cleared by clearSymbolImageCache', async () => {
|
|
148
|
+
const symbolRegistry = makeSymbolRegistry()
|
|
149
|
+
await registerSymbol({ symbol: 'pin' }, MAP_STYLE, symbolRegistry, 1)
|
|
150
|
+
clearSymbolImageCache()
|
|
151
|
+
expect(getActiveSymbolImageId('symbol-pin-1x')).toBeNull()
|
|
152
|
+
expect(getSelectedSymbolImageId('symbol-pin-1x')).toBeNull()
|
|
153
|
+
})
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
describe('registerSymbols', () => {
|
|
157
|
+
it('registers every config in parallel', async () => {
|
|
158
|
+
const symbolRegistry = makeSymbolRegistry()
|
|
159
|
+
await registerSymbols([{ symbol: 'pin' }, { symbol: 'flag' }], MAP_STYLE, symbolRegistry, 1)
|
|
160
|
+
expect(symbolRegistry.rasteriseSymbolImage).toHaveBeenCalledTimes(6) // 2 symbols × normal/active/selected
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
it('is a no-op for an empty array', async () => {
|
|
164
|
+
const symbolRegistry = makeSymbolRegistry()
|
|
165
|
+
await registerSymbols([], MAP_STYLE, symbolRegistry, 1)
|
|
166
|
+
expect(symbolRegistry.rasteriseSymbolImage).not.toHaveBeenCalled()
|
|
167
|
+
})
|
|
58
168
|
})
|
|
@@ -1,5 +1,9 @@
|
|
|
1
|
-
import VectorTileLayer from 'ol/layer/VectorTile.js'
|
|
2
1
|
import TileState from 'ol/TileState.js'
|
|
2
|
+
import GeoJSON from 'ol/format/GeoJSON.js'
|
|
3
|
+
|
|
4
|
+
const CRS = 'EPSG:27700'
|
|
5
|
+
const geoJsonFormat = new GeoJSON({ dataProjection: CRS, featureProjection: CRS })
|
|
6
|
+
|
|
3
7
|
const toPairs = (flat, start, end) => {
|
|
4
8
|
const coords = []
|
|
5
9
|
for (let i = start; i < end; i += 2) {
|
|
@@ -47,17 +51,24 @@ export const renderFeatureToGeoJSON = (feature) => {
|
|
|
47
51
|
return { type, coordinates: toPairs(flat, 0, flat.length) }
|
|
48
52
|
}
|
|
49
53
|
|
|
54
|
+
// draw-ol's basemap MVT tiles are RenderFeatures (no getGeometry()); tiles-backed datasets use
|
|
55
|
+
// real ol/Feature instances instead — branch on which shape this actually is.
|
|
56
|
+
const fragmentToGeoJSON = (feature) =>
|
|
57
|
+
feature.getGeometry ? geoJsonFormat.writeGeometryObject(feature.getGeometry()) : renderFeatureToGeoJSON(feature)
|
|
58
|
+
|
|
50
59
|
/**
|
|
51
|
-
* Collects all loaded VT tile fragments for a feature across all
|
|
52
|
-
* OL clips VT features at tile boundaries, so a single logical feature appears as
|
|
53
|
-
* multiple
|
|
54
|
-
*
|
|
60
|
+
* Collects all loaded VT tile fragments for a feature across all VectorTile/WebGLVectorTile
|
|
61
|
+
* layers. OL clips VT features at tile boundaries, so a single logical feature appears as
|
|
62
|
+
* multiple feature instances (one per tile). This gathers them all so callers can work with
|
|
63
|
+
* the full feature geometry rather than a single clipped fragment.
|
|
55
64
|
*/
|
|
56
65
|
export const collectTileFragments = (map, layerId, featureId, idProperty) => {
|
|
57
66
|
const fragments = []
|
|
58
67
|
|
|
59
68
|
map.getLayers().forEach(mapLayer => {
|
|
60
|
-
|
|
69
|
+
// Tagged with a 'layerType' property rather than checked via `instanceof VectorTileLayer`,
|
|
70
|
+
// since a UMD consumer's independently-bundled ol copy would never match this one's class.
|
|
71
|
+
if (mapLayer.get('layerType') !== 'vectorTile') {
|
|
61
72
|
return
|
|
62
73
|
}
|
|
63
74
|
const source = mapLayer.getSource()
|
|
@@ -70,14 +81,15 @@ export const collectTileFragments = (map, layerId, featureId, idProperty) => {
|
|
|
70
81
|
return
|
|
71
82
|
}
|
|
72
83
|
tile.getFeatures().forEach(feature => {
|
|
73
|
-
|
|
84
|
+
const styleLayerId = feature.get('mapbox-layer')?.id ?? mapLayer.get('layerId')
|
|
85
|
+
if (styleLayerId !== layerId) {
|
|
74
86
|
return
|
|
75
87
|
}
|
|
76
88
|
const fid = idProperty ? feature.get(idProperty) : feature.getId()
|
|
77
89
|
if (String(fid) !== String(featureId)) {
|
|
78
90
|
return
|
|
79
91
|
}
|
|
80
|
-
fragments.push(
|
|
92
|
+
fragments.push(fragmentToGeoJSON(feature))
|
|
81
93
|
})
|
|
82
94
|
})
|
|
83
95
|
})
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import TileState from 'ol/TileState.js'
|
|
2
|
+
import { renderFeatureToGeoJSON, collectTileFragments } from './vtTileFragments.js'
|
|
3
|
+
|
|
4
|
+
jest.mock('ol/TileState.js', () => ({ __esModule: true, default: { LOADED: 'loaded', LOADING: 'loading' } }))
|
|
5
|
+
jest.mock('ol/format/GeoJSON.js', () => ({
|
|
6
|
+
__esModule: true,
|
|
7
|
+
default: jest.fn(() => ({
|
|
8
|
+
writeGeometryObject: jest.fn(geom => ({ type: 'mock-real-feature', geom }))
|
|
9
|
+
}))
|
|
10
|
+
}))
|
|
11
|
+
|
|
12
|
+
describe('renderFeatureToGeoJSON', () => {
|
|
13
|
+
const makeRenderFeature = (type, flat, extra = {}) => ({
|
|
14
|
+
getType: () => type,
|
|
15
|
+
getFlatCoordinates: () => flat,
|
|
16
|
+
...extra
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
it('converts a Point', () => {
|
|
20
|
+
const feature = makeRenderFeature('Point', [1, 2])
|
|
21
|
+
expect(renderFeatureToGeoJSON(feature)).toEqual({ type: 'Point', coordinates: [1, 2] })
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it('converts a LineString', () => {
|
|
25
|
+
const feature = makeRenderFeature('LineString', [0, 0, 1, 1, 2, 2])
|
|
26
|
+
expect(renderFeatureToGeoJSON(feature)).toEqual({ type: 'LineString', coordinates: [[0, 0], [1, 1], [2, 2]] })
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it('converts a Polygon using getEnds for ring boundaries', () => {
|
|
30
|
+
const flat = [0, 0, 1, 0, 1, 1, 0, 0]
|
|
31
|
+
const feature = makeRenderFeature('Polygon', flat, { getEnds: () => [8] })
|
|
32
|
+
expect(renderFeatureToGeoJSON(feature)).toEqual({
|
|
33
|
+
type: 'Polygon',
|
|
34
|
+
coordinates: [[[0, 0], [1, 0], [1, 1], [0, 0]]]
|
|
35
|
+
})
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('converts a Polygon with a hole (two rings)', () => {
|
|
39
|
+
const outer = [0, 0, 4, 0, 4, 4, 0, 0]
|
|
40
|
+
const hole = [1, 1, 2, 1, 2, 2, 1, 1]
|
|
41
|
+
const flat = [...outer, ...hole]
|
|
42
|
+
const feature = makeRenderFeature('Polygon', flat, { getEnds: () => [8, 16] })
|
|
43
|
+
const result = renderFeatureToGeoJSON(feature)
|
|
44
|
+
expect(result.coordinates).toHaveLength(2)
|
|
45
|
+
expect(result.coordinates[1]).toEqual([[1, 1], [2, 1], [2, 2], [1, 1]])
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it('converts a MultiPolygon using getEndss for polygon/ring boundaries', () => {
|
|
49
|
+
const poly1 = [0, 0, 1, 0, 1, 1, 0, 0]
|
|
50
|
+
const poly2 = [2, 2, 3, 2, 3, 3, 2, 2]
|
|
51
|
+
const flat = [...poly1, ...poly2]
|
|
52
|
+
const feature = makeRenderFeature('MultiPolygon', flat, { getEndss: () => [[8], [16]] })
|
|
53
|
+
const result = renderFeatureToGeoJSON(feature)
|
|
54
|
+
expect(result.coordinates).toEqual([
|
|
55
|
+
[[[0, 0], [1, 0], [1, 1], [0, 0]]],
|
|
56
|
+
[[[2, 2], [3, 2], [3, 3], [2, 2]]]
|
|
57
|
+
])
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('falls back to a flat pairs conversion for MultiPoint/MultiLineString', () => {
|
|
61
|
+
const feature = makeRenderFeature('MultiPoint', [0, 0, 1, 1])
|
|
62
|
+
expect(renderFeatureToGeoJSON(feature)).toEqual({ type: 'MultiPoint', coordinates: [[0, 0], [1, 1]] })
|
|
63
|
+
})
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
describe('collectTileFragments', () => {
|
|
67
|
+
const makeTile = (features, state = TileState.LOADED) => ({
|
|
68
|
+
getState: () => state,
|
|
69
|
+
getFeatures: () => features
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
const makeVtLayer = (layerId, sourceTiles) => ({
|
|
73
|
+
get: (key) => {
|
|
74
|
+
if (key === 'layerType') return 'vectorTile'
|
|
75
|
+
if (key === 'layerId') return layerId
|
|
76
|
+
return undefined
|
|
77
|
+
},
|
|
78
|
+
getSource: () => ({ sourceTiles_: sourceTiles })
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
const makeMap = (layers) => ({ getLayers: () => ({ forEach: cb => layers.forEach(cb) }) })
|
|
82
|
+
|
|
83
|
+
// draw-ol's basemap MVT convention: a RenderFeature with a 'mapbox-layer' object.
|
|
84
|
+
const makeMapboxFeature = (styleLayerId, id, flat = [1, 2]) => ({
|
|
85
|
+
get: (key) => key === 'mapbox-layer' ? { id: styleLayerId } : undefined,
|
|
86
|
+
getId: () => id,
|
|
87
|
+
getType: () => 'Point',
|
|
88
|
+
getFlatCoordinates: () => flat
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
// datasets-plugin tiles-backed convention: a real ol/Feature, no 'mapbox-layer', identified
|
|
92
|
+
// purely by which OL *layer* it came from (mapLayer.get('layerId')).
|
|
93
|
+
const makeDatasetFeature = (id) => ({
|
|
94
|
+
get: () => undefined,
|
|
95
|
+
getId: () => id,
|
|
96
|
+
getGeometry: () => ({ type: 'Point', coordinates: [3, 4] })
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
it('returns fragments for a matching mapbox-layer (basemap MVT) feature', () => {
|
|
100
|
+
const feature = makeMapboxFeature('roads', 1)
|
|
101
|
+
const layer = makeVtLayer(undefined, { a: makeTile([feature]) })
|
|
102
|
+
const result = collectTileFragments(makeMap([layer]), 'roads', 1)
|
|
103
|
+
expect(result).toEqual([{ type: 'Point', coordinates: [1, 2] }])
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
it('returns fragments for a matching datasets-plugin tiles-backed feature, via the layer\'s own layerId', () => {
|
|
107
|
+
const feature = makeDatasetFeature('f1')
|
|
108
|
+
const layer = makeVtLayer('existing-fields', { a: makeTile([feature]) })
|
|
109
|
+
const result = collectTileFragments(makeMap([layer]), 'existing-fields', 'f1')
|
|
110
|
+
expect(result).toEqual([{ type: 'mock-real-feature', geom: { type: 'Point', coordinates: [3, 4] } }])
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
it('does not call the RenderFeature-only conversion for a real ol/Feature', () => {
|
|
114
|
+
// A real ol/Feature has no getFlatCoordinates/getType — calling renderFeatureToGeoJSON on
|
|
115
|
+
// it would throw. This proves collectTileFragments takes the getGeometry() branch instead.
|
|
116
|
+
const feature = makeDatasetFeature('f1')
|
|
117
|
+
const layer = makeVtLayer('existing-fields', { a: makeTile([feature]) })
|
|
118
|
+
expect(() => collectTileFragments(makeMap([layer]), 'existing-fields', 'f1')).not.toThrow()
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
it('matches a dataset feature using idProperty when given', () => {
|
|
122
|
+
const feature = { get: (k) => k === 'ref' ? 'abc' : undefined, getGeometry: () => ({ type: 'Point', coordinates: [0, 0] }) }
|
|
123
|
+
const layer = makeVtLayer('existing-fields', { a: makeTile([feature]) })
|
|
124
|
+
const result = collectTileFragments(makeMap([layer]), 'existing-fields', 'abc', 'ref')
|
|
125
|
+
expect(result).toHaveLength(1)
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
it('skips a feature whose styleLayerId does not match', () => {
|
|
129
|
+
const feature = makeDatasetFeature('f1')
|
|
130
|
+
const layer = makeVtLayer('hedge-control', { a: makeTile([feature]) })
|
|
131
|
+
expect(collectTileFragments(makeMap([layer]), 'existing-fields', 'f1')).toEqual([])
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
it('skips a feature whose id does not match', () => {
|
|
135
|
+
const feature = makeDatasetFeature('other-id')
|
|
136
|
+
const layer = makeVtLayer('existing-fields', { a: makeTile([feature]) })
|
|
137
|
+
expect(collectTileFragments(makeMap([layer]), 'existing-fields', 'f1')).toEqual([])
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
it('skips tiles that are not loaded', () => {
|
|
141
|
+
const feature = makeDatasetFeature('f1')
|
|
142
|
+
const layer = makeVtLayer('existing-fields', { a: makeTile([feature], TileState.LOADING) })
|
|
143
|
+
expect(collectTileFragments(makeMap([layer]), 'existing-fields', 'f1')).toEqual([])
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
it('skips layers with no loaded tiles yet', () => {
|
|
147
|
+
const layer = makeVtLayer('existing-fields', undefined)
|
|
148
|
+
expect(collectTileFragments(makeMap([layer]), 'existing-fields', 'f1')).toEqual([])
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
it('skips layers that are not vectorTile-tagged', () => {
|
|
152
|
+
const otherLayer = { get: () => 'vector', getSource: () => ({}) }
|
|
153
|
+
expect(collectTileFragments(makeMap([otherLayer]), 'existing-fields', 'f1')).toEqual([])
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
it('collects fragments split across multiple loaded tiles', () => {
|
|
157
|
+
const feature1 = makeDatasetFeature('f1')
|
|
158
|
+
const feature2 = makeDatasetFeature('f1')
|
|
159
|
+
const layer = makeVtLayer('existing-fields', { a: makeTile([feature1]), b: makeTile([feature2]) })
|
|
160
|
+
expect(collectTileFragments(makeMap([layer]), 'existing-fields', 'f1')).toHaveLength(2)
|
|
161
|
+
})
|
|
162
|
+
})
|