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