@defra/interactive-map 0.0.16-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 (224) hide show
  1. package/assets/images/slot-map.svg +264 -0
  2. package/dist/css/index.css +1 -1
  3. package/dist/esm/im-core.js +1 -1
  4. package/dist/esm/im-shell.js +1 -1
  5. package/dist/umd/im-core.js +1 -1
  6. package/dist/umd/index.js +1 -1
  7. package/docs/api/context.md +53 -7
  8. package/docs/api/map-style-config.md +41 -2
  9. package/docs/api/marker-config.md +53 -11
  10. package/docs/api/slots.md +16 -15
  11. package/docs/api/symbol-config.md +160 -0
  12. package/docs/api/symbol-registry.md +115 -0
  13. package/docs/api.md +25 -22
  14. package/docs/getting-started.md +4 -1
  15. package/docs/plugins/datasets.md +657 -0
  16. package/docs/plugins/interact.md +68 -43
  17. package/docs/plugins/search.md +15 -3
  18. package/docs/plugins.md +1 -1
  19. package/package.json +2 -2
  20. package/plugins/beta/datasets/dist/css/index.css +103 -15
  21. package/plugins/beta/datasets/dist/esm/im-datasets-plugin.js +1 -1
  22. package/plugins/beta/datasets/dist/esm/index.js +1 -1
  23. package/plugins/beta/datasets/dist/umd/im-datasets-plugin.js +1 -1
  24. package/plugins/beta/datasets/dist/umd/index.js +1 -1
  25. package/plugins/beta/datasets/src/DatasetsInit.jsx +29 -9
  26. package/plugins/beta/datasets/src/adapters/maplibre/index.js +18 -0
  27. package/plugins/beta/datasets/src/adapters/maplibre/layerBuilders.js +159 -0
  28. package/plugins/beta/datasets/src/adapters/maplibre/layerIds.js +75 -0
  29. package/plugins/beta/datasets/src/adapters/maplibre/maplibreLayerAdapter.js +440 -0
  30. package/plugins/beta/datasets/src/adapters/maplibre/patternImages.js +27 -0
  31. package/plugins/beta/datasets/src/adapters/maplibre/symbolImages.js +31 -0
  32. package/plugins/beta/datasets/src/api/addDataset.js +2 -8
  33. package/plugins/beta/datasets/src/api/getOpacity.js +17 -0
  34. package/plugins/beta/datasets/src/api/getStyle.js +13 -0
  35. package/plugins/beta/datasets/src/api/removeDataset.js +2 -44
  36. package/plugins/beta/datasets/src/api/setData.js +10 -0
  37. package/plugins/beta/datasets/src/api/setDatasetVisibility.js +37 -0
  38. package/plugins/beta/datasets/src/api/setFeatureVisibility.js +22 -0
  39. package/plugins/beta/datasets/src/api/setOpacity.js +29 -0
  40. package/plugins/beta/datasets/src/api/setStyle.js +22 -0
  41. package/plugins/beta/datasets/src/components/EmptyKey.jsx +7 -0
  42. package/plugins/beta/datasets/src/components/EmptyKey.test.jsx +21 -0
  43. package/plugins/beta/datasets/src/components/KeySvg.jsx +24 -0
  44. package/plugins/beta/datasets/src/components/KeySvgLine.jsx +19 -0
  45. package/plugins/beta/datasets/src/components/KeySvgPattern.jsx +15 -0
  46. package/plugins/beta/datasets/src/components/KeySvgRect.jsx +22 -0
  47. package/plugins/beta/datasets/src/components/KeySvgSymbol.jsx +16 -0
  48. package/plugins/beta/datasets/src/components/svgProperties.js +20 -0
  49. package/plugins/beta/datasets/src/datasets.js +39 -56
  50. package/plugins/beta/datasets/src/defaults.js +44 -8
  51. package/plugins/beta/datasets/src/fetch/createDynamicSource.js +34 -25
  52. package/plugins/beta/datasets/src/fetch/fetchGeoJSON.js +2 -2
  53. package/plugins/beta/datasets/src/index.js +2 -1
  54. package/plugins/beta/datasets/src/manifest.js +25 -17
  55. package/plugins/beta/datasets/src/panels/Key.jsx +51 -51
  56. package/plugins/beta/datasets/src/panels/Key.module.scss +59 -9
  57. package/plugins/beta/datasets/src/panels/Layers.jsx +132 -29
  58. package/plugins/beta/datasets/src/panels/Layers.module.scss +56 -8
  59. package/plugins/beta/datasets/src/reducer.js +134 -9
  60. package/plugins/beta/datasets/src/reducers/keyReducer.js +34 -0
  61. package/plugins/beta/datasets/src/utils/bbox.js +7 -5
  62. package/plugins/beta/datasets/src/utils/filters.js +5 -2
  63. package/plugins/beta/datasets/src/utils/mergeSublayer.js +86 -0
  64. package/plugins/beta/draw-es/dist/esm/im-draw-es-plugin.js +1 -1
  65. package/plugins/beta/draw-es/src/DrawInit.jsx +3 -2
  66. package/plugins/beta/draw-ml/dist/css/index.css +21 -1
  67. package/plugins/beta/draw-ml/dist/esm/im-draw-ml-plugin.js +1 -1
  68. package/plugins/beta/draw-ml/dist/umd/im-draw-ml-plugin.js +1 -1
  69. package/plugins/beta/draw-ml/dist/umd/index.js +1 -1
  70. package/plugins/beta/draw-ml/src/DrawInit.jsx +4 -3
  71. package/plugins/beta/draw-ml/src/draw.scss +0 -7
  72. package/plugins/beta/draw-ml/src/manifest.js +16 -16
  73. package/plugins/beta/frame/dist/esm/im-frame-plugin.js +1 -1
  74. package/plugins/beta/frame/dist/umd/im-frame-plugin.js +1 -1
  75. package/plugins/beta/frame/src/Frame.jsx +5 -5
  76. package/plugins/beta/map-styles/dist/esm/im-map-styles-plugin.js +1 -1
  77. package/plugins/beta/map-styles/dist/umd/im-map-styles-plugin.js +1 -1
  78. package/plugins/beta/map-styles/dist/umd/index.js +1 -1
  79. package/plugins/beta/map-styles/src/MapStyles.jsx +5 -4
  80. package/plugins/beta/map-styles/src/MapStylesInit.jsx +5 -4
  81. package/plugins/beta/map-styles/src/manifest.js +1 -1
  82. package/plugins/beta/scale-bar/dist/css/index.css +1 -1
  83. package/plugins/beta/scale-bar/dist/esm/im-scale-bar-plugin.js +1 -1
  84. package/plugins/beta/scale-bar/dist/umd/im-scale-bar-plugin.js +1 -1
  85. package/plugins/beta/scale-bar/src/index.test.js +3 -3
  86. package/plugins/beta/scale-bar/src/manifest.js +3 -3
  87. package/plugins/beta/scale-bar/src/scaleBar.scss +2 -1
  88. package/plugins/interact/dist/css/index.css +1 -1
  89. package/plugins/interact/dist/esm/im-interact-plugin.js +1 -1
  90. package/plugins/interact/dist/umd/im-interact-plugin.js +1 -1
  91. package/plugins/interact/dist/umd/index.js +1 -1
  92. package/plugins/interact/src/InteractInit.jsx +14 -5
  93. package/plugins/interact/src/InteractInit.test.js +26 -6
  94. package/plugins/interact/src/api/enable.test.js +7 -7
  95. package/plugins/interact/src/defaults.js +4 -6
  96. package/plugins/interact/src/events.js +9 -6
  97. package/plugins/interact/src/events.test.js +28 -4
  98. package/plugins/interact/src/hooks/useHighlightSync.js +3 -3
  99. package/plugins/interact/src/hooks/useHighlightSync.test.js +6 -6
  100. package/plugins/interact/src/hooks/useHoverCursor.js +10 -0
  101. package/plugins/interact/src/hooks/useHoverCursor.test.js +44 -0
  102. package/plugins/interact/src/hooks/useInteractionHandlers.js +111 -69
  103. package/plugins/interact/src/hooks/useInteractionHandlers.test.js +147 -32
  104. package/plugins/interact/src/interact.scss +0 -7
  105. package/plugins/interact/src/manifest.js +14 -18
  106. package/plugins/interact/src/manifest.test.js +3 -1
  107. package/plugins/interact/src/reducer.js +23 -4
  108. package/plugins/interact/src/reducer.test.js +60 -11
  109. package/plugins/interact/src/utils/buildStylesMap.js +17 -4
  110. package/plugins/interact/src/utils/buildStylesMap.test.js +16 -2
  111. package/plugins/interact/src/utils/featureQueries.js +11 -6
  112. package/plugins/interact/src/utils/featureQueries.test.js +8 -1
  113. package/plugins/search/dist/css/index.css +1 -1
  114. package/plugins/search/dist/esm/im-search-plugin.js +1 -1
  115. package/plugins/search/dist/umd/im-search-plugin.js +1 -1
  116. package/plugins/search/src/Search.jsx +3 -1
  117. package/plugins/search/src/components/Form/Form.module.scss +2 -1
  118. package/plugins/search/src/events/fetchSuggestions.js +6 -4
  119. package/plugins/search/src/events/fetchSuggestions.test.js +26 -4
  120. package/plugins/search/src/events/formHandlers.js +3 -3
  121. package/plugins/search/src/events/formHandlers.test.js +1 -1
  122. package/plugins/search/src/events/suggestionHandlers.js +2 -2
  123. package/plugins/search/src/events/suggestionHandlers.test.js +1 -1
  124. package/plugins/search/src/utils/updateMap.js +3 -3
  125. package/plugins/search/src/utils/updateMap.test.js +3 -3
  126. package/providers/maplibre/dist/esm/im-maplibre-provider.js +1 -1
  127. package/providers/maplibre/dist/umd/im-maplibre-framework.js +1 -1
  128. package/providers/maplibre/dist/umd/im-maplibre-framework.js.LICENSE.txt +1 -1
  129. package/providers/maplibre/dist/umd/im-maplibre-provider.js +1 -1
  130. package/providers/maplibre/dist/umd/index.js +1 -1
  131. package/providers/maplibre/src/appEvents.js +7 -0
  132. package/providers/maplibre/src/appEvents.test.js +18 -4
  133. package/providers/maplibre/src/maplibreProvider.js +52 -0
  134. package/providers/maplibre/src/maplibreProvider.test.js +105 -1
  135. package/providers/maplibre/src/utils/highlightFeatures.js +37 -7
  136. package/providers/maplibre/src/utils/highlightFeatures.test.js +153 -95
  137. package/providers/maplibre/src/utils/hoverCursor.js +61 -0
  138. package/providers/maplibre/src/utils/hoverCursor.test.js +130 -0
  139. package/providers/maplibre/src/utils/patternImages.js +70 -0
  140. package/providers/maplibre/src/utils/patternImages.test.js +180 -0
  141. package/providers/maplibre/src/utils/queryFeatures.js +38 -16
  142. package/providers/maplibre/src/utils/queryFeatures.test.js +20 -3
  143. package/providers/maplibre/src/utils/rasteriseToImageData.js +30 -0
  144. package/providers/maplibre/src/utils/rasteriseToImageData.test.js +69 -0
  145. package/providers/maplibre/src/utils/symbolImages.js +147 -0
  146. package/providers/maplibre/src/utils/symbolImages.test.js +248 -0
  147. package/src/App/components/Actions/Actions.jsx +2 -2
  148. package/src/App/components/Actions/Actions.module.scss +0 -7
  149. package/src/App/components/Actions/Actions.test.jsx +1 -1
  150. package/src/App/components/Icon/Icon.jsx +3 -2
  151. package/src/App/components/Icon/Icon.module.scss +4 -0
  152. package/src/App/components/Icon/Icon.test.jsx +43 -4
  153. package/src/App/components/MapButton/MapButton.jsx +42 -17
  154. package/src/App/components/MapButton/MapButton.module.scss +4 -13
  155. package/src/App/components/MapButton/MapButton.test.jsx +27 -3
  156. package/src/App/components/Markers/Markers.jsx +122 -27
  157. package/src/App/components/Markers/Markers.module.scss +0 -10
  158. package/src/App/components/Markers/Markers.test.jsx +246 -0
  159. package/src/App/components/PopupMenu/PopupMenu.jsx +51 -274
  160. package/src/App/components/PopupMenu/PopupMenu.module.scss +14 -7
  161. package/src/App/components/PopupMenu/PopupMenu.test.jsx +70 -1
  162. package/src/App/components/PopupMenu/usePopupMenu.js +258 -0
  163. package/src/App/hooks/useButtonStateEvaluator.js +12 -2
  164. package/src/App/hooks/useButtonStateEvaluator.test.js +38 -4
  165. package/src/App/hooks/useInterfaceAPI.js +6 -0
  166. package/src/App/hooks/useInterfaceAPI.test.js +156 -0
  167. package/src/App/hooks/useLayoutMeasurements.js +84 -18
  168. package/src/App/hooks/useLayoutMeasurements.test.js +124 -17
  169. package/src/App/hooks/useMarkersAPI.js +2 -5
  170. package/src/App/hooks/useMarkersAPI.test.js +4 -4
  171. package/src/App/layout/Layout.jsx +14 -9
  172. package/src/App/layout/Layout.test.jsx +6 -4
  173. package/src/App/layout/layout.module.scss +67 -29
  174. package/src/App/registry/pluginRegistry.js +1 -1
  175. package/src/App/renderer/HtmlElementHost.jsx +2 -1
  176. package/src/App/renderer/HtmlElementHost.test.jsx +7 -7
  177. package/src/App/renderer/mapButtons.js +1 -1
  178. package/src/App/renderer/mapPanels.test.js +2 -2
  179. package/src/App/renderer/slotHelpers.js +2 -2
  180. package/src/App/renderer/slotHelpers.test.js +5 -5
  181. package/src/App/renderer/slots.js +9 -5
  182. package/src/App/store/AppProvider.jsx +3 -1
  183. package/src/App/store/AppProvider.test.jsx +1 -1
  184. package/src/App/store/ServiceProvider.jsx +8 -4
  185. package/src/App/store/appActionsMap.js +16 -0
  186. package/src/App/store/appActionsMap.test.js +27 -0
  187. package/src/App/store/appDispatchMiddleware.js +1 -1
  188. package/src/App/store/appDispatchMiddleware.test.js +2 -2
  189. package/src/App/store/appReducer.js +2 -0
  190. package/src/App/store/mapActionsMap.js +4 -6
  191. package/src/App/store/mapActionsMap.test.js +3 -2
  192. package/src/App/store/mapReducer.js +2 -1
  193. package/src/InteractiveMap/InteractiveMap.js +4 -0
  194. package/src/config/appConfig.js +5 -8
  195. package/src/config/appConfig.test.js +1 -2
  196. package/src/config/defaults.js +0 -2
  197. package/src/config/events.js +28 -0
  198. package/src/config/mapTheme.js +56 -0
  199. package/src/config/patternConfig.js +16 -0
  200. package/src/config/symbolConfig.js +80 -0
  201. package/src/scss/main.scss +1 -0
  202. package/src/scss/settings/_colors.scss +0 -9
  203. package/src/scss/settings/_dimensions.scss +0 -1
  204. package/src/services/patternRegistry.js +40 -0
  205. package/src/services/patternRegistry.test.js +48 -0
  206. package/src/services/symbolRegistry.js +113 -0
  207. package/src/services/symbolRegistry.test.js +262 -0
  208. package/src/types.js +93 -11
  209. package/src/utils/getSafeZoneInset.js +9 -7
  210. package/src/utils/getSafeZoneInset.test.js +10 -10
  211. package/src/utils/patternUtils.js +94 -0
  212. package/src/utils/patternUtils.test.js +160 -0
  213. package/src/utils/symbolUtils.js +85 -0
  214. package/src/utils/symbolUtils.test.js +156 -0
  215. package/webpack.dev.mjs +1 -1
  216. package/docs/api/slot-map.svg +0 -1
  217. package/plugins/beta/datasets/src/api/hideDataset.js +0 -14
  218. package/plugins/beta/datasets/src/api/hideFeatures.js +0 -41
  219. package/plugins/beta/datasets/src/api/showDataset.js +0 -14
  220. package/plugins/beta/datasets/src/api/showFeatures.js +0 -44
  221. package/plugins/beta/datasets/src/handleSetMapStyle.js +0 -54
  222. package/plugins/beta/datasets/src/mapLayers.js +0 -164
  223. /package/src/{utils → services}/logger.js +0 -0
  224. /package/src/{utils → services}/logger.test.js +0 -0
@@ -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', ''])
@@ -60,6 +60,26 @@ const applyHighlightLayer = (map, id, type, sourceId, srcLayer, paint, filter) =
60
60
  map.setPaintProperty(id, prop, value)
61
61
  })
62
62
  map.setFilter(id, filter)
63
+ map.moveLayer(id)
64
+ }
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)
63
83
  }
64
84
 
65
85
  const calculateBounds = (LngLatBounds, renderedFeatures) => {
@@ -77,9 +97,11 @@ const calculateBounds = (LngLatBounds, renderedFeatures) => {
77
97
  return [bounds.getWest(), bounds.getSouth(), bounds.getEast(), bounds.getNorth()]
78
98
  }
79
99
 
100
+ const getSelectedImageId = (map, imageId) => map._symbolImageMap?.[imageId] ?? null
101
+
80
102
  /**
81
103
  * Update highlighted features using pure filters.
82
- * Supports fill + line geometry, multi-source, cleanup, and bounds.
104
+ * Supports fill, line and symbol geometry, multi-source, cleanup, and bounds.
83
105
  */
84
106
  export function updateHighlightedFeatures ({ LngLatBounds, map, selectedFeatures, stylesMap }) {
85
107
  if (!map) {
@@ -105,21 +127,21 @@ export function updateHighlightedFeatures ({ LngLatBounds, map, selectedFeatures
105
127
  const geom = hasFillGeometry ? 'fill' : baseLayer.type
106
128
  const base = `highlight-${sourceId}`
107
129
 
108
- const { stroke, strokeWidth, fill } = stylesMap[layerId]
109
- // Use ['id'] for feature.id, ['get', idProperty] for properties
110
130
  const idExpression = idProperty ? ['get', idProperty] : ['id']
111
131
  const filter = ['in', idExpression, ['literal', [...ids]]]
112
- const fillFilter = ['in', idExpression, ['literal', [...fillIds]]]
113
-
114
- const linePaint = { 'line-color': stroke, 'line-width': strokeWidth }
115
132
 
116
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 }
117
137
  // Only apply fill highlight to polygon features, not to any co-selected line features
118
138
  applyHighlightLayer(map, `${base}-fill`, 'fill', sourceId, srcLayer, { 'fill-color': fill }, fillFilter)
119
139
  applyHighlightLayer(map, `${base}-line`, 'line', sourceId, srcLayer, linePaint, filter)
120
140
  }
121
141
 
122
142
  if (geom === 'line') {
143
+ const { stroke, strokeWidth } = stylesMap[layerId]
144
+ const linePaint = { 'line-color': stroke, 'line-width': strokeWidth }
123
145
  // Clear any fill highlight from a previous polygon selection on the same source
124
146
  if (map.getLayer(`${base}-fill`)) {
125
147
  map.setFilter(`${base}-fill`, ['==', 'id', ''])
@@ -127,6 +149,14 @@ export function updateHighlightedFeatures ({ LngLatBounds, map, selectedFeatures
127
149
  applyHighlightLayer(map, `${base}-line`, 'line', sourceId, srcLayer, linePaint, filter)
128
150
  }
129
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
+
130
160
  // Bounds only from rendered tiles
131
161
  renderedFeatures.push(
132
162
  ...map
@@ -1,132 +1,190 @@
1
1
  import { updateHighlightedFeatures } from './highlightFeatures.js'
2
2
 
3
- describe('Highlighting Utils', () => {
3
+ function lngLatBounds () {
4
+ this.coords = []
5
+ this.extend = (c) => this.coords.push(c)
6
+ this.getWest = () => Math.min(...this.coords.map(c => c[0]))
7
+ this.getSouth = () => Math.min(...this.coords.map(c => c[1]))
8
+ this.getEast = () => Math.max(...this.coords.map(c => c[0]))
9
+ this.getNorth = () => Math.max(...this.coords.map(c => c[1]))
10
+ }
11
+
12
+ const EMPTY_FILTER = ['==', 'id', '']
13
+ const STALE_SYMBOL_LAYER = 'highlight-stale-symbol'
14
+
15
+ const makeMap = (overrides = {}) => ({
16
+ _highlightedSources: new Set(),
17
+ getLayer: jest.fn(),
18
+ addLayer: jest.fn(),
19
+ moveLayer: jest.fn(),
20
+ setFilter: jest.fn(),
21
+ setPaintProperty: jest.fn(),
22
+ setLayoutProperty: jest.fn(),
23
+ getLayoutProperty: jest.fn(),
24
+ queryRenderedFeatures: jest.fn().mockReturnValue([]),
25
+ ...overrides
26
+ })
27
+
28
+ describe('Highlighting Utils — fill and line', () => {
4
29
  let map
5
- const LngLatBounds = function () {
6
- this.coords = []
7
- this.extend = (c) => this.coords.push(c)
8
- this.getWest = () => Math.min(...this.coords.map(c => c[0]))
9
- this.getSouth = () => Math.min(...this.coords.map(c => c[1]))
10
- this.getEast = () => Math.max(...this.coords.map(c => c[0]))
11
- this.getNorth = () => Math.max(...this.coords.map(c => c[1]))
12
- }
30
+
31
+ const ALL_BRANCHES_FEATURES = [
32
+ { featureId: 1, layerId: 'l1', geometry: { type: 'Polygon' } },
33
+ { featureId: 2, layerId: 'l1', geometry: { type: 'MultiPolygon' } },
34
+ { featureId: 3, layerId: 'invalid' },
35
+ { featureId: 4, layerId: 'l2', idProperty: 'customId', geometry: { type: 'Point' } }
36
+ ]
37
+
38
+ const ALL_BRANCHES_STYLES = { l1: { stroke: 'red', fill: 'blue' }, l2: { stroke: 'green' } }
13
39
 
14
40
  beforeEach(() => {
15
- map = {
16
- _highlightedSources: new Set(['stale']),
17
- getLayer: jest.fn(),
18
- addLayer: jest.fn(),
19
- setFilter: jest.fn(),
20
- setPaintProperty: jest.fn(),
21
- queryRenderedFeatures: jest.fn()
22
- }
41
+ map = makeMap({ _highlightedSources: new Set(['stale']) })
23
42
  })
24
43
 
25
44
  test('All branches', () => {
26
- // Coverage for Line 93: Null map check
27
45
  expect(updateHighlightedFeatures({ map: null })).toBeNull()
28
46
 
29
- map.getLayer.mockImplementation((id) => {
30
- if (id.includes('stale')) return true // Coverage for Line 49
31
- if (id === 'l1') return { source: 's1', type: 'fill' }
32
- if (id === 'l2') return { source: 's2', type: 'line' }
33
- if (id === 'highlight-s2-fill') return true // Coverage for Line 124
34
- return null // Coverage for Line 13
47
+ map.getLayer.mockImplementation((id) => { // NOSONAR
48
+ if (id.includes('stale')) { return {} }
49
+ if (id === 'l1') { return { source: 's1', type: 'fill' } }
50
+ if (id === 'l2') { return { source: 's2', type: 'line' } }
51
+ if (id === 'highlight-s2-fill') { return {} }
52
+ return null
35
53
  })
36
54
 
37
- const selectedFeatures = [
38
- // Coverage for Lines 37-40: Polygon & MultiPolygon checks
39
- { featureId: 1, layerId: 'l1', geometry: { type: 'Polygon' } },
40
- { featureId: 2, layerId: 'l1', geometry: { type: 'MultiPolygon' } },
41
- // Coverage for Line 13: Invalid layer
42
- { featureId: 3, layerId: 'invalid' },
43
- // Coverage for Line 116: idProperty exists
44
- { featureId: 4, layerId: 'l2', idProperty: 'customId', geometry: { type: 'Point' } }
45
- ]
46
-
47
- const stylesMap = {
48
- l1: { stroke: 'red', fill: 'blue' },
49
- l2: { stroke: 'green' }
50
- }
51
-
52
- // Coverage for Lines 78-80: Recursive coordinate handling (numbers vs arrays)
55
+ const coordMax = 10
56
+ const coordMid = 5
53
57
  map.queryRenderedFeatures.mockReturnValue([
54
- { id: 1, geometry: { coordinates: [10, 10] } }, // Simple point
55
- { id: 2, properties: { customId: 4 }, geometry: { coordinates: [[0, 0], [5, 5]] } } // Nested
58
+ { id: 1, geometry: { coordinates: [coordMax, coordMax] } },
59
+ { id: 2, properties: { customId: 4 }, geometry: { coordinates: [[0, 0], [coordMid, coordMid]] } }
56
60
  ])
57
61
 
58
- const bounds = updateHighlightedFeatures({ LngLatBounds, map, selectedFeatures, stylesMap })
59
-
60
- // Line 13 verify: map.getLayer returned null and function returned early
61
- // Line 49-50 verify: Stale sources filtered out
62
- expect(map.setFilter).toHaveBeenCalledWith('highlight-stale-fill', ['==', 'id', ''])
62
+ const bounds = updateHighlightedFeatures({ LngLatBounds: lngLatBounds, map, selectedFeatures: ALL_BRANCHES_FEATURES, stylesMap: ALL_BRANCHES_STYLES })
63
63
 
64
- // Line 124 verify: Clear fill highlight when switching to line geometry
65
- expect(map.setFilter).toHaveBeenCalledWith('highlight-s2-fill', ['==', 'id', ''])
66
-
67
- // Line 116 verify: Using ['get', idProperty]
64
+ expect(map.setFilter).toHaveBeenCalledWith('highlight-stale-fill', EMPTY_FILTER)
65
+ expect(map.setFilter).toHaveBeenCalledWith(STALE_SYMBOL_LAYER, EMPTY_FILTER)
66
+ expect(map.setFilter).toHaveBeenCalledWith('highlight-s2-fill', EMPTY_FILTER)
68
67
  expect(map.setFilter).toHaveBeenCalledWith('highlight-s2-line', expect.arrayContaining([['get', 'customId']]))
69
-
70
- // Line 80-82 verify: Recursive LngLatBounds logic
71
68
  expect(bounds).toEqual([0, 0, 10, 10])
72
69
  })
73
70
 
74
- test('undefined _highlightedSources falls back to empty set; line geom skips absent fill layer', () => {
75
- // line 93: || new Set() fallback; line 124 false: no pre-existing fill to clear
76
- map._highlightedSources = undefined
77
- map.getLayer.mockImplementation(id => id === 'l1' ? { source: 's1', type: 'line' } : null)
78
- map.queryRenderedFeatures.mockReturnValue([])
79
- updateHighlightedFeatures({
80
- LngLatBounds,
81
- map,
82
- selectedFeatures: [{ featureId: 1, layerId: 'l1' }],
83
- stylesMap: { l1: { stroke: 'red' } }
84
- })
71
+ test('null _highlightedSources falls back to empty set; line geom skips absent fill layer', () => {
72
+ map._highlightedSources = null
73
+ map.getLayer.mockImplementation(id => id === 'l1' ? { source: 's1', type: 'line' } : null) // NOSONAR
74
+ updateHighlightedFeatures({ LngLatBounds: lngLatBounds, map, selectedFeatures: [{ featureId: 1, layerId: 'l1' }], stylesMap: { l1: { stroke: 'red' } } })
85
75
  expect(map.setFilter).not.toHaveBeenCalledWith('highlight-s1-fill', expect.anything())
86
76
  })
87
77
 
88
78
  test('persistent source skips cleanup; missing stale layers skip setFilter', () => {
89
- // line 37 false: src IS in currentSources; line 41 false: getLayer returns null for stale layers
90
79
  map._highlightedSources = new Set(['stale', 's1'])
91
- map.getLayer.mockImplementation(id => id === 'l1' ? { source: 's1', type: 'line' } : null)
92
- map.queryRenderedFeatures.mockReturnValue([])
93
- updateHighlightedFeatures({
94
- LngLatBounds,
95
- map,
96
- selectedFeatures: [{ featureId: 1, layerId: 'l1' }],
97
- stylesMap: { l1: { stroke: 'red' } }
98
- })
80
+ map.getLayer.mockImplementation(id => id === 'l1' ? { source: 's1', type: 'line' } : null) // NOSONAR
81
+ updateHighlightedFeatures({ LngLatBounds: lngLatBounds, map, selectedFeatures: [{ featureId: 1, layerId: 'l1' }], stylesMap: { l1: { stroke: 'red' } } })
99
82
  expect(map.setFilter).not.toHaveBeenCalledWith(expect.stringContaining('stale'), expect.anything())
100
83
  })
84
+ })
85
+
86
+ describe('Highlighting Utils — layer management', () => {
87
+ let map
88
+
89
+ beforeEach(() => {
90
+ map = makeMap({ _highlightedSources: new Set(['stale']) })
91
+ })
101
92
 
102
93
  test('reuses existing highlight layer; new layer spreads sourceLayer', () => {
103
- // line 50 false: getLayer truthy skip addLayer for s1
104
- // line 55: srcLayer truthy 'source-layer' spread in addLayer for s2
105
- map.getLayer.mockImplementation(id => {
106
- if (id === 'l1') return { source: 's1', type: 'line' }
107
- if (id === 'l2') return { source: 's2', type: 'line', sourceLayer: 'tiles' }
108
- if (id === 'highlight-s1-line') return true
94
+ map.getLayer.mockImplementation(id => { // NOSONAR
95
+ if (id === 'l1') { return { source: 's1', type: 'line' } }
96
+ if (id === 'l2') { return { source: 's2', type: 'line', sourceLayer: 'tiles' } }
97
+ if (id === 'highlight-s1-line') { return {} }
109
98
  return null
110
99
  })
111
- map.queryRenderedFeatures.mockReturnValue([])
112
- updateHighlightedFeatures({
113
- LngLatBounds,
114
- map,
115
- selectedFeatures: [
116
- { featureId: 1, layerId: 'l1' },
117
- { featureId: 2, layerId: 'l2' }
118
- ],
119
- stylesMap: { l1: { stroke: 'blue' }, l2: { stroke: 'green' } }
120
- })
100
+ updateHighlightedFeatures({ LngLatBounds: lngLatBounds, map, selectedFeatures: [{ featureId: 1, layerId: 'l1' }, { featureId: 2, layerId: 'l2' }], stylesMap: { l1: { stroke: 'blue' }, l2: { stroke: 'green' } } })
121
101
  expect(map.addLayer).toHaveBeenCalledTimes(1)
122
102
  expect(map.addLayer).toHaveBeenCalledWith(expect.objectContaining({ 'source-layer': 'tiles' }))
123
103
  })
124
104
 
125
- test('Empty features coverage', () => {
126
- // Coverage for Line 72: empty renderedFeatures
127
- map.getLayer.mockReturnValue({ source: 's1', type: 'line' })
128
- map.queryRenderedFeatures.mockReturnValue([])
129
- const res = updateHighlightedFeatures({ LngLatBounds, map, selectedFeatures: [], stylesMap: {} })
130
- expect(res).toBeNull()
105
+ test('returns null when no rendered features match', () => {
106
+ expect(updateHighlightedFeatures({ LngLatBounds: lngLatBounds, map, selectedFeatures: [], stylesMap: {} })).toBeNull()
107
+ })
108
+ })
109
+
110
+ describe('Highlighting Utils — symbol layers', () => {
111
+ const SYMBOL_IMAGE = 'symbol-abc123'
112
+ const SELECTED_IMAGE = 'symbol-sel-abc123'
113
+ const HIGHLIGHT_LAYER = 'highlight-s1-symbol'
114
+ const ICON_IMAGE = 'icon-image'
115
+ const ICON_ANCHOR = 'icon-anchor'
116
+ const POINT_FEATURE = { featureId: 1, layerId: 'l1', geometry: { type: 'Point' } }
117
+
118
+ let map
119
+
120
+ beforeEach(() => {
121
+ map = makeMap()
122
+ map._symbolImageMap = { [SYMBOL_IMAGE]: SELECTED_IMAGE }
123
+ map.getLayer.mockImplementation(id => id === 'l1' ? { source: 's1', type: 'symbol' } : null) // NOSONAR
124
+ map.getLayoutProperty.mockReturnValue(SYMBOL_IMAGE)
125
+ })
126
+
127
+ const run = (selectedFeatures = [POINT_FEATURE]) =>
128
+ updateHighlightedFeatures({ LngLatBounds: lngLatBounds, map, selectedFeatures, stylesMap: { l1: {} } })
129
+
130
+ test('creates symbol highlight layer with selected image variant', () => {
131
+ run()
132
+ expect(map.addLayer).toHaveBeenCalledWith(expect.objectContaining({
133
+ id: HIGHLIGHT_LAYER,
134
+ type: 'symbol',
135
+ layout: expect.objectContaining({ [ICON_IMAGE]: SELECTED_IMAGE })
136
+ }))
137
+ expect(map.setLayoutProperty).toHaveBeenCalledWith(HIGHLIGHT_LAYER, ICON_IMAGE, SELECTED_IMAGE)
138
+ })
139
+
140
+ test('reads icon-anchor from original layer', () => {
141
+ map.getLayoutProperty.mockImplementation((_id, prop) => { // NOSONAR
142
+ if (prop === ICON_IMAGE) { return SYMBOL_IMAGE }
143
+ if (prop === ICON_ANCHOR) { return 'bottom' }
144
+ return null
145
+ })
146
+ run()
147
+ expect(map.addLayer).toHaveBeenCalledWith(expect.objectContaining({
148
+ layout: expect.objectContaining({ [ICON_ANCHOR]: 'bottom' })
149
+ }))
150
+ })
151
+
152
+ test('falls back to center anchor when icon-anchor is not set on original layer', () => {
153
+ map.getLayoutProperty.mockImplementation((_id, prop) => prop === ICON_IMAGE ? SYMBOL_IMAGE : null) // NOSONAR
154
+ run()
155
+ expect(map.addLayer).toHaveBeenCalledWith(expect.objectContaining({
156
+ layout: expect.objectContaining({ [ICON_ANCHOR]: 'center' })
157
+ }))
158
+ })
159
+
160
+ test('spreads source-layer into symbol highlight layer for vector tile source', () => {
161
+ map.getLayer.mockImplementation(id => id === 'l1' ? { source: 's1', type: 'symbol', sourceLayer: 'points' } : null) // NOSONAR
162
+ run()
163
+ expect(map.addLayer).toHaveBeenCalledWith(expect.objectContaining({ 'source-layer': 'points' }))
164
+ })
165
+
166
+ test('reuses existing symbol highlight layer without re-adding', () => {
167
+ map.getLayer.mockImplementation(id => { // NOSONAR
168
+ if (id === 'l1') { return { source: 's1', type: 'symbol' } }
169
+ if (id === HIGHLIGHT_LAYER) { return { source: 's1', type: 'symbol' } }
170
+ return null
171
+ })
172
+ run()
173
+ expect(map.addLayer).not.toHaveBeenCalled()
174
+ expect(map.setLayoutProperty).toHaveBeenCalledWith(HIGHLIGHT_LAYER, ICON_IMAGE, SELECTED_IMAGE)
175
+ })
176
+
177
+ test('skips highlight when icon-image has no entry in _symbolImageMap', () => {
178
+ map.getLayoutProperty.mockReturnValue('symbol-abc123')
179
+ map._symbolImageMap = {} // no mapping registered
180
+ run()
181
+ expect(map.addLayer).not.toHaveBeenCalled()
182
+ })
183
+
184
+ test('cleans up stale symbol highlight layer', () => {
185
+ map._highlightedSources = new Set(['stale'])
186
+ map.getLayer.mockImplementation(id => id === STALE_SYMBOL_LAYER ? { type: 'symbol' } : null) // NOSONAR
187
+ run([])
188
+ expect(map.setFilter).toHaveBeenCalledWith(STALE_SYMBOL_LAYER, EMPTY_FILTER)
131
189
  })
132
190
  })
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Attaches a mousemove listener that changes the map cursor to a pointer when
3
+ * hovering over any of the specified layers.
4
+ *
5
+ * Line layers use a 10px tolerance bbox. Stroke layers that have a companion
6
+ * fill layer are skipped — the fill handles hover. Fill and symbol layers use
7
+ * exact point hit-testing.
8
+ *
9
+ * @param {Object} map - MapLibre map instance
10
+ * @param {string[]} layerIds - Layer IDs to watch
11
+ * @param {Function|null} prevHandler - Previous mousemove handler to remove
12
+ * @returns {Function|null} The new handler, or null if layerIds is empty
13
+ */
14
+ const splitLayers = (map, layerIds) => {
15
+ const lineLayers = []
16
+ const otherLayers = []
17
+ for (const id of layerIds) {
18
+ const type = map.getLayer(id).type
19
+ if (type === 'line') {
20
+ const fillId = id.endsWith('-stroke') ? id.slice(0, -7) : null // NOSONAR
21
+ const hasFillCompanion = fillId !== null && layerIds.includes(fillId)
22
+ if (!hasFillCompanion) {
23
+ lineLayers.push(id)
24
+ }
25
+ } else {
26
+ otherLayers.push(id)
27
+ }
28
+ }
29
+ return { lineLayers, otherLayers }
30
+ }
31
+
32
+ export const setupHoverCursor = (map, layerIds, prevHandler) => {
33
+ const canvas = map.getCanvas()
34
+
35
+ if (prevHandler) {
36
+ map.off('mousemove', prevHandler)
37
+ }
38
+
39
+ if (!layerIds?.length) {
40
+ canvas.style.cursor = ''
41
+ return null
42
+ }
43
+
44
+ const handler = (e) => {
45
+ const existingLayers = layerIds.filter(id => map.getLayer(id))
46
+ if (existingLayers.length === 0) {
47
+ canvas.style.cursor = ''
48
+ return
49
+ }
50
+
51
+ const { lineLayers, otherLayers } = splitLayers(map, existingLayers)
52
+ const { x, y } = e.point
53
+ const bbox = [[x - 10, y - 10], [x + 10, y + 10]]
54
+ const lineHit = lineLayers.length > 0 && map.queryRenderedFeatures(bbox, { layers: lineLayers }).length > 0
55
+ const otherHit = otherLayers.length > 0 && map.queryRenderedFeatures(e.point, { layers: otherLayers }).length > 0
56
+ canvas.style.cursor = (lineHit || otherHit) ? 'pointer' : ''
57
+ }
58
+
59
+ map.on('mousemove', handler)
60
+ return handler
61
+ }