@defra/interactive-map 0.0.17-alpha → 0.0.18-alpha

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (140) hide show
  1. package/dist/css/index.css +1 -1
  2. package/dist/esm/im-core.js +1 -1
  3. package/dist/esm/im-shell.js +1 -1
  4. package/dist/umd/im-core.js +1 -1
  5. package/dist/umd/index.js +1 -1
  6. package/docs/api/context.md +53 -7
  7. package/docs/api/map-style-config.md +41 -2
  8. package/docs/api/marker-config.md +53 -11
  9. package/docs/api/symbol-config.md +160 -0
  10. package/docs/api/symbol-registry.md +115 -0
  11. package/docs/api.md +22 -19
  12. package/docs/plugins/datasets.md +105 -9
  13. package/docs/plugins/interact.md +68 -43
  14. package/docs/plugins/search.md +15 -3
  15. package/package.json +1 -1
  16. package/plugins/beta/datasets/dist/css/index.css +32 -14
  17. package/plugins/beta/datasets/dist/esm/im-datasets-plugin.js +1 -1
  18. package/plugins/beta/datasets/dist/esm/index.js +1 -1
  19. package/plugins/beta/datasets/dist/umd/im-datasets-plugin.js +1 -1
  20. package/plugins/beta/datasets/dist/umd/index.js +1 -1
  21. package/plugins/beta/datasets/src/DatasetsInit.jsx +9 -4
  22. package/plugins/beta/datasets/src/adapters/maplibre/layerBuilders.js +57 -11
  23. package/plugins/beta/datasets/src/adapters/maplibre/layerIds.js +14 -8
  24. package/plugins/beta/datasets/src/adapters/maplibre/maplibreLayerAdapter.js +155 -53
  25. package/plugins/beta/datasets/src/adapters/maplibre/patternImages.js +27 -0
  26. package/plugins/beta/datasets/src/adapters/maplibre/symbolImages.js +31 -0
  27. package/plugins/beta/datasets/src/api/addDataset.js +1 -1
  28. package/plugins/beta/datasets/src/api/setData.js +4 -2
  29. package/plugins/beta/datasets/src/api/setStyle.js +2 -2
  30. package/plugins/beta/datasets/src/components/EmptyKey.jsx +7 -0
  31. package/plugins/beta/datasets/src/components/EmptyKey.test.jsx +21 -0
  32. package/plugins/beta/datasets/src/components/KeySvg.jsx +24 -0
  33. package/plugins/beta/datasets/src/components/KeySvgLine.jsx +19 -0
  34. package/plugins/beta/datasets/src/components/KeySvgPattern.jsx +15 -0
  35. package/plugins/beta/datasets/src/components/KeySvgRect.jsx +22 -0
  36. package/plugins/beta/datasets/src/components/KeySvgSymbol.jsx +16 -0
  37. package/plugins/beta/datasets/src/components/svgProperties.js +20 -0
  38. package/plugins/beta/datasets/src/datasets.js +13 -4
  39. package/plugins/beta/datasets/src/defaults.js +4 -2
  40. package/plugins/beta/datasets/src/index.js +2 -1
  41. package/plugins/beta/datasets/src/manifest.js +1 -1
  42. package/plugins/beta/datasets/src/panels/Key.jsx +11 -89
  43. package/plugins/beta/datasets/src/panels/Key.module.scss +24 -13
  44. package/plugins/beta/datasets/src/panels/Layers.module.scss +13 -7
  45. package/plugins/beta/datasets/src/reducer.js +6 -0
  46. package/plugins/beta/datasets/src/reducers/keyReducer.js +34 -0
  47. package/plugins/beta/datasets/src/utils/mergeSublayer.js +8 -0
  48. package/plugins/beta/draw-es/dist/esm/im-draw-es-plugin.js +1 -1
  49. package/plugins/beta/draw-es/src/DrawInit.jsx +3 -2
  50. package/plugins/beta/draw-ml/dist/css/index.css +21 -1
  51. package/plugins/beta/draw-ml/dist/esm/im-draw-ml-plugin.js +1 -1
  52. package/plugins/beta/draw-ml/dist/umd/im-draw-ml-plugin.js +1 -1
  53. package/plugins/beta/draw-ml/dist/umd/index.js +1 -1
  54. package/plugins/beta/draw-ml/src/DrawInit.jsx +4 -3
  55. package/plugins/beta/map-styles/dist/esm/im-map-styles-plugin.js +1 -1
  56. package/plugins/beta/map-styles/dist/umd/im-map-styles-plugin.js +1 -1
  57. package/plugins/beta/map-styles/dist/umd/index.js +1 -1
  58. package/plugins/beta/map-styles/src/MapStyles.jsx +5 -4
  59. package/plugins/beta/map-styles/src/MapStylesInit.jsx +5 -4
  60. package/plugins/interact/dist/esm/im-interact-plugin.js +1 -1
  61. package/plugins/interact/dist/umd/im-interact-plugin.js +1 -1
  62. package/plugins/interact/dist/umd/index.js +1 -1
  63. package/plugins/interact/src/InteractInit.jsx +14 -5
  64. package/plugins/interact/src/InteractInit.test.js +26 -6
  65. package/plugins/interact/src/api/enable.test.js +7 -7
  66. package/plugins/interact/src/defaults.js +4 -6
  67. package/plugins/interact/src/events.js +9 -6
  68. package/plugins/interact/src/events.test.js +28 -4
  69. package/plugins/interact/src/hooks/useHighlightSync.js +3 -3
  70. package/plugins/interact/src/hooks/useHighlightSync.test.js +6 -6
  71. package/plugins/interact/src/hooks/useHoverCursor.js +10 -0
  72. package/plugins/interact/src/hooks/useHoverCursor.test.js +44 -0
  73. package/plugins/interact/src/hooks/useInteractionHandlers.js +111 -69
  74. package/plugins/interact/src/hooks/useInteractionHandlers.test.js +147 -32
  75. package/plugins/interact/src/reducer.js +23 -4
  76. package/plugins/interact/src/reducer.test.js +60 -11
  77. package/plugins/interact/src/utils/buildStylesMap.js +17 -4
  78. package/plugins/interact/src/utils/buildStylesMap.test.js +16 -2
  79. package/plugins/interact/src/utils/featureQueries.js +11 -6
  80. package/plugins/interact/src/utils/featureQueries.test.js +8 -1
  81. package/plugins/search/dist/esm/im-search-plugin.js +1 -1
  82. package/plugins/search/dist/umd/im-search-plugin.js +1 -1
  83. package/plugins/search/src/Search.jsx +3 -1
  84. package/plugins/search/src/events/fetchSuggestions.js +6 -4
  85. package/plugins/search/src/events/fetchSuggestions.test.js +26 -4
  86. package/plugins/search/src/events/formHandlers.js +3 -3
  87. package/plugins/search/src/events/formHandlers.test.js +1 -1
  88. package/plugins/search/src/events/suggestionHandlers.js +2 -2
  89. package/plugins/search/src/events/suggestionHandlers.test.js +1 -1
  90. package/plugins/search/src/utils/updateMap.js +3 -3
  91. package/plugins/search/src/utils/updateMap.test.js +3 -3
  92. package/providers/maplibre/dist/esm/im-maplibre-provider.js +1 -1
  93. package/providers/maplibre/dist/umd/im-maplibre-provider.js +1 -1
  94. package/providers/maplibre/dist/umd/index.js +1 -1
  95. package/providers/maplibre/src/appEvents.js +7 -0
  96. package/providers/maplibre/src/appEvents.test.js +18 -4
  97. package/providers/maplibre/src/maplibreProvider.js +52 -0
  98. package/providers/maplibre/src/maplibreProvider.test.js +105 -1
  99. package/providers/maplibre/src/utils/highlightFeatures.js +36 -7
  100. package/providers/maplibre/src/utils/highlightFeatures.test.js +153 -96
  101. package/providers/maplibre/src/utils/hoverCursor.js +61 -0
  102. package/providers/maplibre/src/utils/hoverCursor.test.js +130 -0
  103. package/providers/maplibre/src/utils/patternImages.js +70 -0
  104. package/providers/maplibre/src/utils/patternImages.test.js +180 -0
  105. package/providers/maplibre/src/utils/queryFeatures.js +38 -16
  106. package/providers/maplibre/src/utils/queryFeatures.test.js +20 -3
  107. package/providers/maplibre/src/utils/rasteriseToImageData.js +30 -0
  108. package/providers/maplibre/src/utils/rasteriseToImageData.test.js +69 -0
  109. package/providers/maplibre/src/utils/symbolImages.js +147 -0
  110. package/providers/maplibre/src/utils/symbolImages.test.js +248 -0
  111. package/src/App/components/Markers/Markers.jsx +122 -27
  112. package/src/App/components/Markers/Markers.module.scss +0 -10
  113. package/src/App/components/Markers/Markers.test.jsx +246 -0
  114. package/src/App/hooks/useInterfaceAPI.test.js +156 -0
  115. package/src/App/hooks/useMarkersAPI.js +2 -5
  116. package/src/App/hooks/useMarkersAPI.test.js +4 -4
  117. package/src/App/layout/Layout.jsx +2 -2
  118. package/src/App/layout/Layout.test.jsx +4 -2
  119. package/src/App/store/ServiceProvider.jsx +7 -5
  120. package/src/App/store/mapActionsMap.js +4 -6
  121. package/src/App/store/mapActionsMap.test.js +3 -2
  122. package/src/App/store/mapReducer.js +2 -1
  123. package/src/config/appConfig.js +0 -6
  124. package/src/config/appConfig.test.js +1 -2
  125. package/src/config/defaults.js +0 -2
  126. package/src/config/mapTheme.js +56 -0
  127. package/src/config/patternConfig.js +16 -0
  128. package/src/config/symbolConfig.js +80 -0
  129. package/src/scss/settings/_colors.scss +0 -9
  130. package/src/services/patternRegistry.js +40 -0
  131. package/src/services/patternRegistry.test.js +48 -0
  132. package/src/services/symbolRegistry.js +113 -0
  133. package/src/services/symbolRegistry.test.js +262 -0
  134. package/src/types.js +93 -11
  135. package/src/utils/patternUtils.js +94 -0
  136. package/src/utils/patternUtils.test.js +160 -0
  137. package/src/utils/symbolUtils.js +85 -0
  138. package/src/utils/symbolUtils.test.js +156 -0
  139. package/plugins/beta/datasets/src/adapters/maplibre/patternRegistry.js +0 -48
  140. package/plugins/beta/datasets/src/styles/patterns.js +0 -157
@@ -1,4 +1,5 @@
1
1
  export function attachAppEvents ({
2
+ mapProvider,
2
3
  map,
3
4
  events,
4
5
  eventBus
@@ -16,13 +17,19 @@ export function attachAppEvents ({
16
17
  map.setPixelRatio(pixelRatio)
17
18
  }
18
19
 
20
+ const handleSizeChange = ({ mapSize }) => {
21
+ mapProvider.mapSize = mapSize
22
+ }
23
+
19
24
  eventBus.on(events.MAP_SET_STYLE, handleSetMapStyle)
20
25
  eventBus.on(events.MAP_SET_PIXEL_RATIO, handleSetPixelRatio)
26
+ eventBus.on(events.MAP_SIZE_CHANGE, handleSizeChange)
21
27
 
22
28
  return {
23
29
  remove () {
24
30
  eventBus.off(events.MAP_SET_STYLE, handleSetMapStyle)
25
31
  eventBus.off(events.MAP_SET_PIXEL_RATIO, handleSetPixelRatio)
32
+ eventBus.off(events.MAP_SIZE_CHANGE, handleSizeChange)
26
33
  }
27
34
  }
28
35
  }
@@ -1,7 +1,7 @@
1
1
  import { attachAppEvents } from './appEvents.js'
2
2
 
3
3
  describe('attachAppEvents', () => {
4
- let map, eventBus, events
4
+ let map, mapProvider, eventBus, events
5
5
 
6
6
  beforeEach(() => {
7
7
  map = {
@@ -9,6 +9,7 @@ describe('attachAppEvents', () => {
9
9
  setPixelRatio: jest.fn(),
10
10
  once: jest.fn()
11
11
  }
12
+ mapProvider = { mapSize: null }
12
13
  eventBus = {
13
14
  on: jest.fn(),
14
15
  off: jest.fn(),
@@ -17,16 +18,18 @@ describe('attachAppEvents', () => {
17
18
  events = {
18
19
  MAP_SET_STYLE: 'map:set-style',
19
20
  MAP_SET_PIXEL_RATIO: 'map:set-pixel-ratio',
20
- MAP_STYLE_CHANGE: 'map:stylechange'
21
+ MAP_STYLE_CHANGE: 'map:stylechange',
22
+ MAP_SIZE_CHANGE: 'map:size-change'
21
23
  }
22
24
  })
23
25
 
24
26
  it('attaches handlers and triggers correct map methods', () => {
25
- const controller = attachAppEvents({ map, events, eventBus })
27
+ const controller = attachAppEvents({ mapProvider, map, events, eventBus })
26
28
 
27
29
  // Verify eventBus.on was called with correct handlers
28
30
  expect(eventBus.on).toHaveBeenCalledWith(events.MAP_SET_STYLE, expect.any(Function))
29
31
  expect(eventBus.on).toHaveBeenCalledWith(events.MAP_SET_PIXEL_RATIO, expect.any(Function))
32
+ expect(eventBus.on).toHaveBeenCalledWith(events.MAP_SIZE_CHANGE, expect.any(Function))
30
33
 
31
34
  // Extract the attached handlers
32
35
  const styleHandler = eventBus.on.mock.calls.find(c => c[0] === events.MAP_SET_STYLE)[1]
@@ -45,9 +48,20 @@ describe('attachAppEvents', () => {
45
48
  styleLoadCallback()
46
49
  expect(eventBus.emit).toHaveBeenCalledWith(events.MAP_STYLE_CHANGE, { mapStyleId: 'outdoor' })
47
50
 
48
- // Verify remove detaches handlers
51
+ // Verify remove detaches all handlers
49
52
  controller.remove()
50
53
  expect(eventBus.off).toHaveBeenCalledWith(events.MAP_SET_STYLE, styleHandler)
51
54
  expect(eventBus.off).toHaveBeenCalledWith(events.MAP_SET_PIXEL_RATIO, pixelHandler)
55
+ const sizeHandler = eventBus.on.mock.calls.find(c => c[0] === events.MAP_SIZE_CHANGE)[1]
56
+ expect(eventBus.off).toHaveBeenCalledWith(events.MAP_SIZE_CHANGE, sizeHandler)
57
+ })
58
+
59
+ it('updates mapProvider.mapSize when MAP_SIZE_CHANGE fires', () => {
60
+ attachAppEvents({ mapProvider, map, events, eventBus })
61
+
62
+ const sizeHandler = eventBus.on.mock.calls.find(c => c[0] === events.MAP_SIZE_CHANGE)[1]
63
+ sizeHandler({ mapSize: 'large' })
64
+
65
+ expect(mapProvider.mapSize).toBe('large')
52
66
  })
53
67
  })
@@ -4,6 +4,7 @@
4
4
  */
5
5
 
6
6
  import { DEFAULTS, supportedShortcuts } from './defaults.js'
7
+ import { scaleFactor } from '../../../src/config/appConfig.js'
7
8
  import { cleanCanvas, applyPreventDefaultFix } from './utils/maplibreFixes.js'
8
9
  import { attachMapEvents } from './mapEvents.js'
9
10
  import { attachAppEvents } from './appEvents.js'
@@ -11,6 +12,9 @@ import { getAreaDimensions, getCardinalMove, getBboxFromGeoJSON, isGeometryObscu
11
12
  import { createMapLabelNavigator } from './utils/labels.js'
12
13
  import { updateHighlightedFeatures } from './utils/highlightFeatures.js'
13
14
  import { queryFeatures } from './utils/queryFeatures.js'
15
+ import { setupHoverCursor } from './utils/hoverCursor.js'
16
+ import { registerSymbols } from './utils/symbolImages.js'
17
+ import { registerPatterns } from './utils/patternImages.js'
14
18
 
15
19
  /**
16
20
  * MapLibre GL JS implementation of the MapProvider interface.
@@ -113,6 +117,7 @@ export default class MapLibreProvider {
113
117
 
114
118
  /** Destroy the map and clean up resources. */
115
119
  destroyMap () {
120
+ this.setHoverCursor([])
116
121
  this.mapEvents?.remove()
117
122
  this.appEvents?.remove()
118
123
 
@@ -122,6 +127,19 @@ export default class MapLibreProvider {
122
127
  this.map.remove()
123
128
  }
124
129
 
130
+ /**
131
+ * Set pointer cursor on the map canvas when hovering over any of the given layer IDs.
132
+ * Call with an empty array to remove all hover cursor listeners.
133
+ *
134
+ * @param {string[]} layerIds
135
+ */
136
+ setHoverCursor (layerIds) {
137
+ if (!this.map) {
138
+ return
139
+ }
140
+ this._onHoverMove = setupHoverCursor(this.map, layerIds, this._onHoverMove)
141
+ }
142
+
125
143
  // ==========================
126
144
  // Side-effects
127
145
  // ==========================
@@ -283,6 +301,40 @@ export default class MapLibreProvider {
283
301
  return queryFeatures(this.map, point, options)
284
302
  }
285
303
 
304
+ /**
305
+ * Rasterise and register symbol images for the given pre-resolved symbol configs.
306
+ * Delegates to the shared symbol image utility so any plugin's MapLibre adapter can
307
+ * register symbols without importing provider internals directly.
308
+ *
309
+ * The pixel ratio is computed as device pixel ratio × map size scale factor so symbols
310
+ * are rasterised at the correct resolution for the current device DPI and map size.
311
+ *
312
+ * @param {Object[]} symbolConfigs - Flat list of datasets/merged-sublayers with a symbol config.
313
+ * Callers are responsible for sublayer merging before passing configs here.
314
+ * @param {Object} mapStyle - Current map style config (provides id, selectedColor, haloColor)
315
+ * @param {Object} symbolRegistry
316
+ * @returns {Promise<void>}
317
+ */
318
+ async registerSymbols (symbolConfigs, mapStyle, symbolRegistry) {
319
+ const pixelRatio = (this.map.getPixelRatio() || 1) * (scaleFactor[this.mapSize] || 1)
320
+ return registerSymbols(this.map, symbolConfigs, mapStyle, symbolRegistry, pixelRatio)
321
+ }
322
+
323
+ /**
324
+ * Rasterise and register pattern images for the given pre-resolved pattern configs.
325
+ * Delegates to the shared pattern image utility so any plugin's MapLibre adapter can
326
+ * register patterns without importing provider internals directly.
327
+ *
328
+ * @param {Object[]} patternConfigs - Flat list of datasets/merged-sublayers with a pattern config.
329
+ * Callers are responsible for sublayer merging before passing configs here.
330
+ * @param {string} mapStyleId
331
+ * @param {Object} patternRegistry
332
+ * @returns {Promise<void>}
333
+ */
334
+ async registerPatterns (patternConfigs, mapStyleId, patternRegistry) {
335
+ return registerPatterns(this.map, patternConfigs, mapStyleId, patternRegistry)
336
+ }
337
+
286
338
  // ==========================
287
339
  // Spatial helpers
288
340
  // ==========================
@@ -4,6 +4,8 @@ import { attachAppEvents } from './appEvents.js'
4
4
  import { createMapLabelNavigator } from './utils/labels.js'
5
5
  import { updateHighlightedFeatures } from './utils/highlightFeatures.js'
6
6
  import { queryFeatures } from './utils/queryFeatures.js'
7
+ import { registerSymbols } from './utils/symbolImages.js'
8
+ import { registerPatterns } from './utils/patternImages.js'
7
9
  import { getAreaDimensions, getCardinalMove, getResolution, getPaddedBounds, isGeometryObscured } from './utils/spatial.js'
8
10
 
9
11
  jest.mock('./defaults.js', () => ({
@@ -33,6 +35,8 @@ jest.mock('./utils/labels.js', () => ({
33
35
  }))
34
36
  jest.mock('./utils/highlightFeatures.js', () => ({ updateHighlightedFeatures: jest.fn(() => []) }))
35
37
  jest.mock('./utils/queryFeatures.js', () => ({ queryFeatures: jest.fn(() => []) }))
38
+ jest.mock('./utils/symbolImages.js', () => ({ registerSymbols: jest.fn(() => Promise.resolve()) }))
39
+ jest.mock('./utils/patternImages.js', () => ({ registerPatterns: jest.fn(() => Promise.resolve()) }))
36
40
 
37
41
  describe('MapLibreProvider', () => {
38
42
  let map, eventBus, maplibreModule, loadCallback
@@ -53,7 +57,11 @@ describe('MapLibreProvider', () => {
53
57
  unproject: jest.fn(() => ({ lng: 1, lat: 2 })),
54
58
  getCenter: jest.fn(() => ({ lng: 1.2345678, lat: 2.3456789 })),
55
59
  getZoom: jest.fn(() => 10),
56
- getBounds: jest.fn(() => ({ toArray: jest.fn(() => [[0, 0], [1, 1]]) }))
60
+ getBounds: jest.fn(() => ({ toArray: jest.fn(() => [[0, 0], [1, 1]]) })),
61
+ getPixelRatio: jest.fn(() => 1),
62
+ getCanvas: jest.fn(() => ({ style: {} })),
63
+ getLayer: jest.fn(() => true),
64
+ queryRenderedFeatures: jest.fn(() => [])
57
65
  }
58
66
  eventBus = { emit: jest.fn() }
59
67
  maplibreModule = { Map: jest.fn(() => map), LngLatBounds: jest.fn() }
@@ -220,6 +228,102 @@ describe('MapLibreProvider', () => {
220
228
  })
221
229
  })
222
230
 
231
+ test('registerSymbols delegates to utility with map instance', async () => {
232
+ const p = makeProvider()
233
+ await doInitMap(p)
234
+ const configs = [{ symbol: 'pin' }]
235
+ const mapStyle = { id: 'test', selectedColor: '#0b0c0c' }
236
+ const registry = {}
237
+ await p.registerSymbols(configs, mapStyle, registry)
238
+ expect(registerSymbols).toHaveBeenCalledWith(map, configs, mapStyle, registry, expect.any(Number))
239
+ })
240
+
241
+ test('registerSymbols computes pixelRatio from getPixelRatio and mapSize scale factor', async () => {
242
+ const p = makeProvider()
243
+ await doInitMap(p)
244
+ map.getPixelRatio.mockReturnValue(2)
245
+ p.mapSize = 'medium' // scaleFactor['medium'] = 1.5
246
+ const registry = {}
247
+ await p.registerSymbols([], { id: 'test' }, registry)
248
+ expect(registerSymbols).toHaveBeenCalledWith(map, [], { id: 'test' }, registry, 3) // 2 * 1.5
249
+ })
250
+
251
+ test('registerSymbols falls back to pixelRatio 1 when getPixelRatio returns 0', async () => {
252
+ const p = makeProvider()
253
+ await doInitMap(p)
254
+ map.getPixelRatio.mockReturnValue(0)
255
+ p.mapSize = 'small' // scaleFactor['small'] = 1
256
+ const registry = {}
257
+ await p.registerSymbols([], { id: 'test' }, registry)
258
+ expect(registerSymbols).toHaveBeenCalledWith(map, [], { id: 'test' }, registry, 1) // (0 || 1) * 1
259
+ })
260
+
261
+ test('registerPatterns delegates to utility with map instance', async () => {
262
+ const p = makeProvider()
263
+ await doInitMap(p)
264
+ const configs = [{ fillPattern: 'dot' }]
265
+ const registry = {}
266
+ await p.registerPatterns(configs, 'test', registry)
267
+ expect(registerPatterns).toHaveBeenCalledWith(map, configs, 'test', registry)
268
+ })
269
+
270
+ describe('setHoverCursor', () => {
271
+ test('registers mousemove handler on the map when layerIds provided', async () => {
272
+ const p = makeProvider()
273
+ await doInitMap(p)
274
+ p.setHoverCursor(['layer-a'])
275
+ expect(map.on).toHaveBeenCalledWith('mousemove', expect.any(Function))
276
+ })
277
+
278
+ test('sets cursor to pointer when queryRenderedFeatures returns a hit', async () => {
279
+ const canvas = { style: {} }
280
+ map.getCanvas.mockReturnValue(canvas)
281
+ map.queryRenderedFeatures.mockReturnValue([{ id: 'f1' }])
282
+
283
+ const p = makeProvider()
284
+ await doInitMap(p)
285
+ p.setHoverCursor(['layer-a'])
286
+
287
+ const moveHandler = map.on.mock.calls.find(([e]) => e === 'mousemove')?.[1]
288
+ moveHandler({ point: { x: 10, y: 20 } })
289
+
290
+ expect(canvas.style.cursor).toBe('pointer')
291
+ })
292
+
293
+ test('clears cursor when queryRenderedFeatures returns no hit', async () => {
294
+ const canvas = { style: { cursor: 'pointer' } }
295
+ map.getCanvas.mockReturnValue(canvas)
296
+ map.queryRenderedFeatures.mockReturnValue([])
297
+
298
+ const p = makeProvider()
299
+ await doInitMap(p)
300
+ p.setHoverCursor(['layer-a'])
301
+
302
+ const moveHandler = map.on.mock.calls.find(([e]) => e === 'mousemove')?.[1]
303
+ moveHandler({ point: { x: 10, y: 20 } })
304
+
305
+ expect(canvas.style.cursor).toBe('')
306
+ })
307
+
308
+ test('removes mousemove handler and clears cursor when called with empty array', async () => {
309
+ const canvas = { style: { cursor: 'pointer' } }
310
+ map.getCanvas.mockReturnValue(canvas)
311
+
312
+ const p = makeProvider()
313
+ await doInitMap(p)
314
+ p.setHoverCursor(['layer-a'])
315
+ p.setHoverCursor([])
316
+
317
+ expect(map.off).toHaveBeenCalledWith('mousemove', expect.any(Function))
318
+ expect(canvas.style.cursor).toBe('')
319
+ })
320
+
321
+ test('does nothing when map is not initialised', () => {
322
+ const p = makeProvider()
323
+ expect(() => p.setHoverCursor(['layer-a'])).not.toThrow()
324
+ })
325
+ })
326
+
223
327
  test('label methods return null without labelNavigator; delegate when set', async () => {
224
328
  const p = makeProvider()
225
329
  await doInitMap(p)
@@ -36,7 +36,7 @@ const cleanupStaleSources = (map, previousSources, currentSources) => {
36
36
  previousSources.forEach(src => {
37
37
  if (!currentSources.has(src)) {
38
38
  const base = `highlight-${src}`
39
- const layers = [`${base}-fill`, `${base}-line`]
39
+ const layers = [`${base}-fill`, `${base}-line`, `${base}-symbol`]
40
40
  layers.forEach(id => {
41
41
  if (map.getLayer(id)) {
42
42
  map.setFilter(id, ['==', 'id', ''])
@@ -63,6 +63,25 @@ const applyHighlightLayer = (map, id, type, sourceId, srcLayer, paint, filter) =
63
63
  map.moveLayer(id)
64
64
  }
65
65
 
66
+ const applySymbolHighlightLayer = (map, id, sourceId, srcLayer, originalLayerId, imageId, filter) => {
67
+ if (!map.getLayer(id)) {
68
+ map.addLayer({
69
+ id,
70
+ type: 'symbol',
71
+ source: sourceId,
72
+ ...(srcLayer && { 'source-layer': srcLayer }),
73
+ layout: {
74
+ 'icon-image': imageId,
75
+ 'icon-anchor': map.getLayoutProperty(originalLayerId, 'icon-anchor') ?? 'center',
76
+ 'icon-allow-overlap': true
77
+ }
78
+ })
79
+ }
80
+ map.setLayoutProperty(id, 'icon-image', imageId)
81
+ map.setFilter(id, filter)
82
+ map.moveLayer(id)
83
+ }
84
+
66
85
  const calculateBounds = (LngLatBounds, renderedFeatures) => {
67
86
  if (!renderedFeatures.length) {
68
87
  return null
@@ -78,9 +97,11 @@ const calculateBounds = (LngLatBounds, renderedFeatures) => {
78
97
  return [bounds.getWest(), bounds.getSouth(), bounds.getEast(), bounds.getNorth()]
79
98
  }
80
99
 
100
+ const getSelectedImageId = (map, imageId) => map._symbolImageMap?.[imageId] ?? null
101
+
81
102
  /**
82
103
  * Update highlighted features using pure filters.
83
- * Supports fill + line geometry, multi-source, cleanup, and bounds.
104
+ * Supports fill, line and symbol geometry, multi-source, cleanup, and bounds.
84
105
  */
85
106
  export function updateHighlightedFeatures ({ LngLatBounds, map, selectedFeatures, stylesMap }) {
86
107
  if (!map) {
@@ -106,21 +127,21 @@ export function updateHighlightedFeatures ({ LngLatBounds, map, selectedFeatures
106
127
  const geom = hasFillGeometry ? 'fill' : baseLayer.type
107
128
  const base = `highlight-${sourceId}`
108
129
 
109
- const { stroke, strokeWidth, fill } = stylesMap[layerId]
110
- // Use ['id'] for feature.id, ['get', idProperty] for properties
111
130
  const idExpression = idProperty ? ['get', idProperty] : ['id']
112
131
  const filter = ['in', idExpression, ['literal', [...ids]]]
113
- const fillFilter = ['in', idExpression, ['literal', [...fillIds]]]
114
-
115
- const linePaint = { 'line-color': stroke, 'line-width': strokeWidth }
116
132
 
117
133
  if (geom === 'fill') {
134
+ const { stroke, strokeWidth, fill } = stylesMap[layerId]
135
+ const fillFilter = ['in', idExpression, ['literal', [...fillIds]]]
136
+ const linePaint = { 'line-color': stroke, 'line-width': strokeWidth }
118
137
  // Only apply fill highlight to polygon features, not to any co-selected line features
119
138
  applyHighlightLayer(map, `${base}-fill`, 'fill', sourceId, srcLayer, { 'fill-color': fill }, fillFilter)
120
139
  applyHighlightLayer(map, `${base}-line`, 'line', sourceId, srcLayer, linePaint, filter)
121
140
  }
122
141
 
123
142
  if (geom === 'line') {
143
+ const { stroke, strokeWidth } = stylesMap[layerId]
144
+ const linePaint = { 'line-color': stroke, 'line-width': strokeWidth }
124
145
  // Clear any fill highlight from a previous polygon selection on the same source
125
146
  if (map.getLayer(`${base}-fill`)) {
126
147
  map.setFilter(`${base}-fill`, ['==', 'id', ''])
@@ -128,6 +149,14 @@ export function updateHighlightedFeatures ({ LngLatBounds, map, selectedFeatures
128
149
  applyHighlightLayer(map, `${base}-line`, 'line', sourceId, srcLayer, linePaint, filter)
129
150
  }
130
151
 
152
+ if (geom === 'symbol') {
153
+ const imageId = map.getLayoutProperty(layerId, 'icon-image')
154
+ const selectedImageId = getSelectedImageId(map, imageId)
155
+ if (selectedImageId) {
156
+ applySymbolHighlightLayer(map, `${base}-symbol`, sourceId, srcLayer, layerId, selectedImageId, filter)
157
+ }
158
+ }
159
+
131
160
  // Bounds only from rendered tiles
132
161
  renderedFeatures.push(
133
162
  ...map