@defra/interactive-map 0.0.22-alpha → 0.0.23-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 (190) 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/button-definition.md +39 -0
  7. package/docs/api/context.md +4 -4
  8. package/docs/api/map-style-config.md +11 -2
  9. package/docs/api/marker-config.md +38 -7
  10. package/docs/api/panel-definition.md +7 -3
  11. package/docs/api/symbol-config.md +10 -26
  12. package/docs/api/symbol-registry.md +28 -13
  13. package/docs/api.md +24 -2
  14. package/docs/assets/css/{docusaurus.css → docusaurus.scss} +18 -12
  15. package/docs/assets/images/toggle-marker-label.jpg +0 -0
  16. package/docs/examples/index.mdx +12 -0
  17. package/docs/examples/toggle-marker-label.mdx +89 -0
  18. package/docs/plugins/interact.md +4 -11
  19. package/docusaurus.config.cjs +2 -1
  20. package/package.json +8 -8
  21. package/plugins/beta/datasets/dist/adapters/maplibre/esm/im-datasets-ml-adapter.js +1 -1
  22. package/plugins/beta/datasets/dist/css/index.css +2 -2
  23. package/plugins/beta/datasets/dist/esm/im-datasets-plugin.js +1 -1
  24. package/plugins/beta/datasets/dist/umd/maplibre/im-datasets-maplibre-adapter.js +1 -1
  25. package/plugins/beta/datasets/dist/umd/maplibre/im-datasets-plugin.js +1 -1
  26. package/plugins/beta/datasets/dist/umd/maplibre/index.js +1 -1
  27. package/plugins/beta/datasets/src/adapters/maplibre/layerBuilders.js +5 -5
  28. package/plugins/beta/datasets/src/adapters/maplibre/maplibreLayerAdapter.js +24 -29
  29. package/plugins/beta/datasets/src/adapters/maplibre/patternImages.js +2 -2
  30. package/plugins/beta/datasets/src/adapters/maplibre/symbolImages.js +3 -3
  31. package/plugins/beta/datasets/src/components/KeySvg.jsx +2 -2
  32. package/plugins/beta/datasets/src/components/KeySvgPattern.jsx +1 -2
  33. package/plugins/beta/datasets/src/components/svgProperties.js +2 -2
  34. package/plugins/beta/datasets/src/panels/Key.module.scss +2 -2
  35. package/plugins/beta/draw-es/dist/esm/im-draw-es-plugin.js +1 -1
  36. package/plugins/beta/draw-ml/dist/esm/im-draw-ml-plugin.js +1 -1
  37. package/plugins/beta/draw-ml/dist/umd/im-draw-ml-plugin.js +1 -1
  38. package/plugins/beta/map-styles/dist/esm/im-map-styles-plugin.js +1 -1
  39. package/plugins/beta/map-styles/dist/umd/im-map-styles-plugin.js +1 -1
  40. package/plugins/beta/map-styles/dist/umd/index.js +1 -1
  41. package/plugins/beta/use-location/dist/esm/im-use-location-plugin.js +1 -1
  42. package/plugins/beta/use-location/dist/umd/im-use-location-plugin.js +1 -1
  43. package/plugins/beta/use-location/src/manifest.js +1 -0
  44. package/plugins/interact/dist/esm/im-interact-plugin.js +1 -1
  45. package/plugins/interact/dist/umd/im-interact-plugin.js +1 -1
  46. package/plugins/interact/dist/umd/index.js +1 -1
  47. package/plugins/interact/src/InteractInit.jsx +23 -55
  48. package/plugins/interact/src/InteractInit.test.js +58 -80
  49. package/plugins/interact/src/defaults.js +1 -3
  50. package/plugins/interact/src/hooks/useAttachEvents.js +46 -0
  51. package/plugins/interact/src/hooks/useAttachEvents.test.js +65 -0
  52. package/plugins/interact/src/hooks/useCrossHairVisibility.js +47 -0
  53. package/plugins/interact/src/hooks/useCrossHairVisibility.test.js +54 -0
  54. package/plugins/interact/src/hooks/useHighlightSync.js +30 -8
  55. package/plugins/interact/src/hooks/useHighlightSync.test.js +127 -80
  56. package/plugins/interact/src/hooks/useInteractionHandlers.js +18 -17
  57. package/plugins/interact/src/hooks/useInteractionHandlers.test.js +22 -61
  58. package/plugins/interact/src/hooks/useMapItemList.js +187 -0
  59. package/plugins/interact/src/hooks/useMapItemList.test.js +584 -0
  60. package/plugins/interact/src/index.js +0 -2
  61. package/plugins/interact/src/index.test.js +0 -2
  62. package/plugins/interact/src/manifest.js +37 -6
  63. package/plugins/interact/src/reducer.js +8 -4
  64. package/plugins/interact/src/reducer.test.js +18 -3
  65. package/plugins/interact/src/utils/buildStylesMap.js +21 -10
  66. package/plugins/interact/src/utils/buildStylesMap.test.js +29 -29
  67. package/plugins/interact/src/utils/spatial.js +0 -15
  68. package/plugins/interact/src/utils/spatial.test.js +1 -13
  69. package/plugins/search/dist/css/index.css +1 -1
  70. package/plugins/search/dist/esm/im-search-plugin.js +1 -1
  71. package/plugins/search/dist/umd/im-search-plugin.js +1 -1
  72. package/plugins/search/src/components/CloseButton/CloseButton.module.scss +2 -0
  73. package/plugins/search/src/components/OpenButton/OpenButton.jsx +1 -1
  74. package/plugins/search/src/components/OpenButton/OpenButton.module.scss +3 -0
  75. package/plugins/search/src/search.scss +1 -0
  76. package/providers/beta/esri/dist/esm/im-esri-provider.js +1 -1
  77. package/providers/beta/esri/src/esriProvider.js +4 -0
  78. package/providers/maplibre/dist/esm/im-maplibre-provider.js +1 -1
  79. package/providers/maplibre/dist/esm/index.js +1 -1
  80. package/providers/maplibre/dist/umd/im-maplibre-provider.js +1 -1
  81. package/providers/maplibre/dist/umd/index.js +1 -1
  82. package/providers/maplibre/src/index.js +1 -1
  83. package/providers/maplibre/src/index.test.js +9 -14
  84. package/providers/maplibre/src/mapEvents.js +11 -5
  85. package/providers/maplibre/src/mapEvents.test.js +14 -3
  86. package/providers/maplibre/src/maplibreProvider.js +18 -11
  87. package/providers/maplibre/src/maplibreProvider.test.js +43 -36
  88. package/providers/maplibre/src/utils/highlightFeatures.js +102 -57
  89. package/providers/maplibre/src/utils/highlightFeatures.test.js +162 -51
  90. package/providers/maplibre/src/utils/maplibreFixes.js +10 -0
  91. package/providers/maplibre/src/utils/maplibreFixes.test.js +31 -0
  92. package/providers/maplibre/src/utils/patternImages.js +10 -12
  93. package/providers/maplibre/src/utils/patternImages.test.js +145 -167
  94. package/providers/maplibre/src/utils/rasteriseToImageData.js +1 -4
  95. package/providers/maplibre/src/utils/rasteriseToImageData.test.js +23 -24
  96. package/providers/maplibre/src/utils/symbolImages.js +23 -86
  97. package/providers/maplibre/src/utils/symbolImages.test.js +60 -105
  98. package/src/App/components/Actions/Actions.module.scss +1 -0
  99. package/src/App/components/Actions/Actions.test.jsx +17 -4
  100. package/src/App/components/Hints/Hints.jsx +52 -0
  101. package/src/App/components/Hints/Hints.module.scss +37 -0
  102. package/src/App/components/Hints/Hints.test.jsx +104 -0
  103. package/src/App/components/KeyboardHelp/KeyboardHelp.jsx +57 -19
  104. package/src/App/components/KeyboardHelp/KeyboardHelp.module.scss +10 -0
  105. package/src/App/components/KeyboardHelp/KeyboardHelp.test.jsx +140 -31
  106. package/src/App/components/Markers/LabelMarker.jsx +14 -0
  107. package/src/App/components/Markers/LabelMarker.test.jsx +36 -0
  108. package/src/App/components/Markers/Markers.jsx +44 -75
  109. package/src/App/components/Markers/Markers.module.scss +58 -0
  110. package/src/App/components/Markers/Markers.test.jsx +90 -113
  111. package/src/App/components/Markers/SymbolLabelMarker.jsx +43 -0
  112. package/src/App/components/Markers/SymbolLabelMarker.test.jsx +56 -0
  113. package/src/App/components/Markers/SymbolMarker.jsx +31 -0
  114. package/src/App/components/Markers/SymbolMarker.test.jsx +72 -0
  115. package/src/App/components/Panel/Panel.jsx +1 -1
  116. package/src/App/components/Panel/Panel.module.scss +1 -0
  117. package/src/App/components/PopupMenu/PopupMenu.test.jsx +117 -1
  118. package/src/App/components/PopupMenu/usePopupMenu.js +132 -85
  119. package/src/App/components/Tabs/Tabs.jsx +68 -0
  120. package/src/App/components/Tabs/Tabs.module.scss +57 -0
  121. package/src/App/components/Tabs/Tabs.test.jsx +121 -0
  122. package/src/App/components/Tooltip/Tooltip.module.scss +1 -1
  123. package/src/App/components/Viewport/Features.jsx +35 -0
  124. package/src/App/components/Viewport/Features.module.scss +31 -0
  125. package/src/App/components/Viewport/Features.test.jsx +124 -0
  126. package/src/App/components/Viewport/MapStatus.jsx +0 -4
  127. package/src/App/components/Viewport/Viewport.jsx +35 -29
  128. package/src/App/components/Viewport/Viewport.module.scss +7 -21
  129. package/src/App/components/Viewport/Viewport.test.jsx +108 -65
  130. package/src/App/controls/keyboardActions.js +9 -3
  131. package/src/App/controls/keyboardActions.test.js +122 -87
  132. package/src/App/controls/keyboardShortcuts.js +1 -14
  133. package/src/App/hooks/useFeatureFocus.js +199 -0
  134. package/src/App/hooks/useFeatureFocus.test.js +635 -0
  135. package/src/App/hooks/useFeatureItems.js +30 -0
  136. package/src/App/hooks/useFeatureItems.test.js +97 -0
  137. package/src/App/hooks/useFocusVisible.js +4 -7
  138. package/src/App/hooks/useFocusVisible.test.js +6 -6
  139. package/src/App/hooks/useKeyboardHint.js +9 -34
  140. package/src/App/hooks/useKeyboardHint.test.js +34 -84
  141. package/src/App/hooks/useKeyboardShortcuts.js +8 -5
  142. package/src/App/hooks/useKeyboardShortcuts.test.js +16 -12
  143. package/src/App/hooks/useLayoutMeasurements.js +10 -4
  144. package/src/App/hooks/useLayoutMeasurements.test.js +30 -0
  145. package/src/App/hooks/useMarkerCursor.js +33 -0
  146. package/src/App/hooks/useMarkerCursor.test.js +87 -0
  147. package/src/App/hooks/useMarkersAPI.js +32 -9
  148. package/src/App/hooks/useMarkersAPI.test.js +162 -94
  149. package/src/App/layout/Layout.jsx +2 -0
  150. package/src/App/layout/Layout.test.jsx +3 -0
  151. package/src/App/registry/keyboardShortcutRegistry.js +3 -2
  152. package/src/App/registry/keyboardShortcutRegistry.test.js +6 -8
  153. package/src/App/renderer/mapButtons.js +5 -1
  154. package/src/App/renderer/mapButtons.test.js +37 -1
  155. package/src/App/store/ServiceProvider.jsx +4 -1
  156. package/src/App/store/ServiceProvider.test.jsx +11 -0
  157. package/src/App/store/appActionsMap.js +5 -1
  158. package/src/App/store/appActionsMap.test.js +13 -0
  159. package/src/App/store/appReducer.js +1 -0
  160. package/src/App/store/mapActionsMap.js +2 -1
  161. package/src/InteractiveMap/InteractiveMap.js +12 -0
  162. package/src/InteractiveMap/InteractiveMap.test.js +2 -0
  163. package/src/config/appConfig.js +3 -1
  164. package/src/config/appConfig.test.js +4 -0
  165. package/src/config/defaults.js +1 -1
  166. package/src/config/events.js +8 -0
  167. package/src/config/mapTheme.js +8 -4
  168. package/src/config/symbolConfig.js +23 -16
  169. package/src/scss/main.scss +3 -0
  170. package/src/scss/tools/_border-focus.scss +0 -1
  171. package/src/services/eventBus.js +15 -0
  172. package/src/services/eventBus.test.js +11 -0
  173. package/src/services/hints.js +47 -0
  174. package/src/services/hints.test.js +110 -0
  175. package/src/services/patternRegistry.js +69 -1
  176. package/src/services/patternRegistry.test.js +112 -1
  177. package/src/services/reverseGeocode.js +4 -0
  178. package/src/services/reverseGeocode.test.js +11 -1
  179. package/src/services/symbolRegistry.js +152 -17
  180. package/src/services/symbolRegistry.test.js +141 -31
  181. package/src/types.js +71 -14
  182. package/src/utils/constrainKeyboardFocus.js +5 -5
  183. package/src/utils/detectInterfaceType.js +27 -5
  184. package/src/utils/detectInterfaceType.test.js +29 -3
  185. package/src/utils/patternUtils.js +4 -67
  186. package/src/utils/patternUtils.test.js +1 -123
  187. package/src/utils/symbolUtils.js +11 -13
  188. package/src/utils/symbolUtils.test.js +18 -26
  189. package/plugins/interact/dist/css/index.css +0 -1
  190. package/plugins/interact/src/interact.scss +0 -24
@@ -4,9 +4,10 @@ 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
+ import { addSymbolsToMap } from './utils/symbolImages.js'
8
+ import { addPatternsToMap } from './utils/patternImages.js'
9
9
  import { getAreaDimensions, getCardinalMove, getResolution, getPaddedBounds, isGeometryObscured } from './utils/spatial.js'
10
+ import { symbolRegistry } from '../../../src/services/symbolRegistry.js'
10
11
 
11
12
  jest.mock('./defaults.js', () => ({
12
13
  DEFAULTS: { animationDuration: 400, coordinatePrecision: 7 },
@@ -35,8 +36,8 @@ jest.mock('./utils/labels.js', () => ({
35
36
  }))
36
37
  jest.mock('./utils/highlightFeatures.js', () => ({ updateHighlightedFeatures: jest.fn(() => []) }))
37
38
  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()) }))
39
+ jest.mock('./utils/symbolImages.js', () => ({ addSymbolsToMap: jest.fn(() => Promise.resolve()) }))
40
+ jest.mock('./utils/patternImages.js', () => ({ addPatternsToMap: jest.fn(() => Promise.resolve()) }))
40
41
 
41
42
  describe('MapLibreProvider', () => {
42
43
  let map, eventBus, maplibreModule, loadCallback
@@ -244,69 +245,75 @@ describe('MapLibreProvider', () => {
244
245
  await doInitMap(p)
245
246
  p.getFeaturesAtPoint({ x: 10, y: 20 }, { radius: 5 })
246
247
  expect(queryFeatures).toHaveBeenCalledWith(map, { x: 10, y: 20 }, { radius: 5 })
247
- p.updateHighlightedFeatures(['feat'], { style: 1 })
248
+ p.updateHighlightedFeatures(['feat'], null, { style: 1 })
248
249
  expect(updateHighlightedFeatures).toHaveBeenCalledWith({
249
- LngLatBounds: maplibreModule.LngLatBounds, map, selectedFeatures: ['feat'], stylesMap: { style: 1 }
250
+ LngLatBounds: maplibreModule.LngLatBounds, map, selectedFeatures: ['feat'], activeFeatures: null, stylesMap: { style: 1 }
250
251
  })
251
252
  })
252
253
 
253
- test('registerSymbols delegates to utility with map instance', async () => {
254
+ test('getVisibleFeatures returns empty array when no layers are present on the map', async () => {
255
+ const p = makeProvider()
256
+ await doInitMap(p)
257
+ map.getLayer.mockReturnValue(null)
258
+ expect(p.getVisibleFeatures(['l1', 'l2'])).toEqual([])
259
+ expect(map.queryRenderedFeatures).not.toHaveBeenCalled()
260
+ })
261
+
262
+ test('getVisibleFeatures queries only layers present on the map', async () => {
263
+ const p = makeProvider()
264
+ await doInitMap(p)
265
+ map.getLayer.mockImplementation(id => id === 'l1' ? {} : null) // NOSONAR
266
+ const features = [{ id: 'f1' }]
267
+ map.queryRenderedFeatures.mockReturnValue(features)
268
+ expect(p.getVisibleFeatures(['l1', 'l2'])).toEqual(features)
269
+ expect(map.queryRenderedFeatures).toHaveBeenCalledWith(undefined, { layers: ['l1'] })
270
+ })
271
+
272
+ test('addSymbolsToMap delegates to utility with map instance', async () => {
254
273
  const p = makeProvider()
255
274
  await doInitMap(p)
256
275
  const configs = [{ symbol: 'pin' }]
257
276
  const mapStyle = { id: 'test', selectedColor: '#0b0c0c' }
258
- const registry = {}
259
- await p.registerSymbols(configs, mapStyle, registry)
260
- expect(registerSymbols).toHaveBeenCalledWith(map, configs, mapStyle, registry, expect.any(Number))
277
+ await p.addSymbolsToMap(configs, mapStyle, symbolRegistry)
278
+ expect(addSymbolsToMap).toHaveBeenCalledWith(map, configs, mapStyle, symbolRegistry, expect.any(Number))
261
279
  })
262
280
 
263
- test('registerSymbols computes pixelRatio from getPixelRatio and mapSize scale factor', async () => {
281
+ test('addSymbolsToMap computes pixelRatio from getPixelRatio and mapSize scale factor', async () => {
264
282
  const p = makeProvider()
265
283
  await doInitMap(p)
266
284
  map.getPixelRatio.mockReturnValue(2)
267
- p.mapSize = 'medium' // scaleFactor['medium'] = 1.5
268
- const registry = {}
269
- await p.registerSymbols([], { id: 'test' }, registry)
270
- expect(registerSymbols).toHaveBeenCalledWith(map, [], { id: 'test' }, registry, 3) // 2 * 1.5
285
+ p.mapSize = 'medium'
286
+ await p.addSymbolsToMap([], { id: 'test' }, symbolRegistry)
287
+ expect(addSymbolsToMap).toHaveBeenCalledWith(map, [], { id: 'test' }, symbolRegistry, 2)
271
288
  })
272
289
 
273
- test('registerSymbols falls back to pixelRatio 1 when getPixelRatio returns 0', async () => {
290
+ test('addSymbolsToMap falls back to pixelRatio 1 when getPixelRatio returns 0', async () => {
274
291
  const p = makeProvider()
275
292
  await doInitMap(p)
276
293
  map.getPixelRatio.mockReturnValue(0)
277
294
  p.mapSize = 'small' // scaleFactor['small'] = 1
278
- const registry = {}
279
- await p.registerSymbols([], { id: 'test' }, registry)
280
- expect(registerSymbols).toHaveBeenCalledWith(map, [], { id: 'test' }, registry, 1) // (0 || 1) * 1
295
+ await p.addSymbolsToMap([], { id: 'test' }, symbolRegistry)
296
+ expect(addSymbolsToMap).toHaveBeenCalledWith(map, [], { id: 'test' }, symbolRegistry, 1)
281
297
  })
282
298
 
283
- test('registerPatterns delegates to utility with map instance and pixelRatio', async () => {
299
+ test('addPatternsToMap falls back to pixelRatio 1 when getPixelRatio returns 0', async () => {
284
300
  const p = makeProvider()
285
301
  await doInitMap(p)
302
+ map.getPixelRatio.mockReturnValue(0)
286
303
  const configs = [{ fillPattern: 'dot' }]
287
304
  const registry = {}
288
- await p.registerPatterns(configs, 'test', registry)
289
- expect(registerPatterns).toHaveBeenCalledWith(map, configs, 'test', registry, 1) // getPixelRatio()=1, mapSize unset → 1*1
305
+ await p.addPatternsToMap(configs, 'test', registry)
306
+ expect(addPatternsToMap).toHaveBeenCalledWith(map, configs, 'test', registry, 1)
290
307
  })
291
308
 
292
- test('registerPatterns computes pixelRatio from getPixelRatio and mapSize scale factor', async () => {
309
+ test('addPatternsToMap is called with pixelRatio from getPixelRatio', async () => {
293
310
  const p = makeProvider()
294
311
  await doInitMap(p)
295
312
  map.getPixelRatio.mockReturnValue(2)
296
- p.mapSize = 'medium' // scaleFactor['medium'] = 1.5
297
- const registry = {}
298
- await p.registerPatterns([], 'test', registry)
299
- expect(registerPatterns).toHaveBeenCalledWith(map, [], 'test', registry, 3) // 2 * 1.5
300
- })
301
-
302
- test('registerPatterns falls back to pixelRatio 1 when getPixelRatio returns 0', async () => {
303
- const p = makeProvider()
304
- await doInitMap(p)
305
- map.getPixelRatio.mockReturnValue(0)
306
- p.mapSize = 'small' // scaleFactor['small'] = 1
313
+ p.mapSize = 'medium'
307
314
  const registry = {}
308
- await p.registerPatterns([], 'test', registry)
309
- expect(registerPatterns).toHaveBeenCalledWith(map, [], 'test', registry, 1) // (0 || 1) * 1
315
+ await p.addPatternsToMap([], 'test', registry)
316
+ expect(addPatternsToMap).toHaveBeenCalledWith(map, [], 'test', registry, 2)
310
317
  })
311
318
 
312
319
  describe('setHoverCursor', () => {
@@ -1,3 +1,15 @@
1
+ const ICON_IMAGE = 'icon-image'
2
+
3
+ const ACTIVE_PREFIX = 'active-highlight'
4
+ const ACTIVE_INNER_PREFIX = 'active-highlight-inner'
5
+ const SELECTED_PREFIX = 'selected-highlight'
6
+
7
+ // Inner and selected both use the selected colour/width (black, thin)
8
+ const usesSelectedStyle = (prefix) => prefix === SELECTED_PREFIX || prefix === ACTIVE_INNER_PREFIX
9
+
10
+ const getActiveImageId = (map, imageId) => map._activeSymbolImageMap?.[imageId] ?? null
11
+ const getSelectedImageId = (map, imageId) => map._selectedSymbolImageMap?.[imageId] ?? null
12
+
1
13
  const groupFeaturesBySource = (map, selectedFeatures) => {
2
14
  const featuresBySource = {}
3
15
 
@@ -20,7 +32,7 @@ const groupFeaturesBySource = (map, selectedFeatures) => {
20
32
  }
21
33
  }
22
34
 
23
- // Track whether any selected feature on this source is a polygon
35
+ // Track whether any feature on this source is a polygon
24
36
  if (geometry && (geometry.type === 'Polygon' || geometry.type === 'MultiPolygon')) {
25
37
  featuresBySource[sourceId].hasFillGeometry = true
26
38
  featuresBySource[sourceId].fillIds.add(featureId)
@@ -32,12 +44,11 @@ const groupFeaturesBySource = (map, selectedFeatures) => {
32
44
  return featuresBySource
33
45
  }
34
46
 
35
- const cleanupStaleSources = (map, previousSources, currentSources) => {
47
+ const cleanupStaleSources = (map, previousSources, currentSources, prefix) => {
36
48
  previousSources.forEach(src => {
37
49
  if (!currentSources.has(src)) {
38
- const base = `highlight-${src}`
39
- const layers = [`${base}-fill`, `${base}-line`, `${base}-symbol`]
40
- layers.forEach(id => {
50
+ const base = `${prefix}-${src}`
51
+ ;[`${base}-fill`, `${base}-line`, `${base}-symbol`].forEach(id => {
41
52
  if (map.getLayer(id)) {
42
53
  map.setFilter(id, ['==', 'id', ''])
43
54
  }
@@ -46,6 +57,12 @@ const cleanupStaleSources = (map, previousSources, currentSources) => {
46
57
  })
47
58
  }
48
59
 
60
+ const clearPrefixSources = (map, prefix) => {
61
+ const key = `_${prefix.replaceAll('-', '')}Sources`
62
+ cleanupStaleSources(map, map[key] || new Set(), new Set(), prefix)
63
+ map[key] = new Set()
64
+ }
65
+
49
66
  const applyHighlightLayer = (map, id, type, sourceId, srcLayer, paint, filter) => {
50
67
  if (!map.getLayer(id)) {
51
68
  map.addLayer({
@@ -71,13 +88,13 @@ const applySymbolHighlightLayer = (map, id, sourceId, srcLayer, originalLayerId,
71
88
  source: sourceId,
72
89
  ...(srcLayer && { 'source-layer': srcLayer }),
73
90
  layout: {
74
- 'icon-image': imageId,
91
+ [ICON_IMAGE]: imageId,
75
92
  'icon-anchor': map.getLayoutProperty(originalLayerId, 'icon-anchor') ?? 'center',
76
93
  'icon-allow-overlap': true
77
94
  }
78
95
  })
79
96
  }
80
- map.setLayoutProperty(id, 'icon-image', imageId)
97
+ map.setLayoutProperty(id, ICON_IMAGE, imageId)
81
98
  map.setFilter(id, filter)
82
99
  map.moveLayer(id)
83
100
  }
@@ -97,67 +114,95 @@ const calculateBounds = (LngLatBounds, renderedFeatures) => {
97
114
  return [bounds.getWest(), bounds.getSouth(), bounds.getEast(), bounds.getNorth()]
98
115
  }
99
116
 
100
- const getSelectedImageId = (map, imageId) => map._symbolImageMap?.[imageId] ?? null
101
-
102
- /**
103
- * Update highlighted features using pure filters.
104
- * Supports fill, line and symbol geometry, multi-source, cleanup, and bounds.
105
- */
106
- export function updateHighlightedFeatures ({ LngLatBounds, map, selectedFeatures, stylesMap }) {
107
- if (!map) {
108
- return null
117
+ const applySymbolGeomHighlight = (map, base, sourceId, srcLayer, layerId, filter, getSymbolImageId) => {
118
+ const imageId = map.getLayoutProperty(layerId, ICON_IMAGE)
119
+ const symbolImageId = getSymbolImageId(map, imageId)
120
+ if (symbolImageId) {
121
+ applySymbolHighlightLayer(map, `${base}-symbol`, sourceId, srcLayer, layerId, symbolImageId, filter)
109
122
  }
123
+ }
110
124
 
111
- const featuresBySource = groupFeaturesBySource(map, selectedFeatures)
112
- const renderedFeatures = []
125
+ const applySourceHighlight = (map, sourceId, featuresBySource, stylesMap, prefix, getSymbolImageId) => {
126
+ const { ids, fillIds, idProperty, layerId, hasFillGeometry } = featuresBySource[sourceId]
127
+ const baseLayer = map.getLayer(layerId)
128
+ const srcLayer = baseLayer.sourceLayer
129
+ const geom = hasFillGeometry ? 'fill' : baseLayer.type
130
+ const base = `${prefix}-${sourceId}`
131
+ const { stroke, selectionStroke, strokeWidth, activeStrokeWidth, fill } = stylesMap[layerId]
132
+ const isSelected = prefix === SELECTED_PREFIX
133
+ const selectedStyle = usesSelectedStyle(prefix)
134
+ const lineColor = selectedStyle ? selectionStroke : stroke
135
+ const lineWidth = selectedStyle ? strokeWidth : activeStrokeWidth
136
+ const idExpression = idProperty ? ['get', idProperty] : ['id']
137
+ const filter = ['in', idExpression, ['literal', [...ids]]]
138
+
139
+ if (geom === 'fill') {
140
+ if (isSelected) {
141
+ const fillFilter = ['in', idExpression, ['literal', [...fillIds]]]
142
+ // Only apply fill highlight to polygon features, not to any co-selected line features
143
+ applyHighlightLayer(map, `${base}-fill`, 'fill', sourceId, srcLayer, { 'fill-color': fill }, fillFilter)
144
+ }
145
+ applyHighlightLayer(map, `${base}-line`, 'line', sourceId, srcLayer, { 'line-color': lineColor, 'line-width': lineWidth }, filter)
146
+ }
113
147
 
114
- const currentSources = new Set(Object.keys(featuresBySource))
115
- const previousSources = map._highlightedSources || new Set()
148
+ if (geom === 'line') {
149
+ if (map.getLayer(`${base}-fill`)) {
150
+ // Clear any fill highlight from a previous polygon on the same source
151
+ map.setFilter(`${base}-fill`, ['==', 'id', ''])
152
+ }
153
+ applyHighlightLayer(map, `${base}-line`, 'line', sourceId, srcLayer, { 'line-color': lineColor, 'line-width': lineWidth }, filter)
154
+ }
116
155
 
117
- cleanupStaleSources(map, previousSources, currentSources)
118
- map._highlightedSources = currentSources
156
+ if (geom === 'symbol') {
157
+ applySymbolGeomHighlight(map, base, sourceId, srcLayer, layerId, filter, getSymbolImageId)
158
+ }
159
+ }
119
160
 
120
- // Apply highlights for current sources
121
- currentSources.forEach(sourceId => {
122
- const { ids, fillIds, idProperty, layerId, hasFillGeometry } = featuresBySource[sourceId]
123
- const baseLayer = map.getLayer(layerId)
124
- const srcLayer = baseLayer.sourceLayer
161
+ const applyFeatureHighlights = (map, features, stylesMap, prefix, getSymbolImageId) => {
162
+ const featuresBySource = groupFeaturesBySource(map, features)
163
+ const currentSources = new Set(Object.keys(featuresBySource))
164
+ const storageKey = `_${prefix.replaceAll('-', '')}Sources`
165
+ const previousSources = map[storageKey] || new Set()
125
166
 
126
- // Use the actual feature geometry to determine highlight type
127
- const geom = hasFillGeometry ? 'fill' : baseLayer.type
128
- const base = `highlight-${sourceId}`
167
+ cleanupStaleSources(map, previousSources, currentSources, prefix)
168
+ map[storageKey] = currentSources
169
+ currentSources.forEach(sourceId => applySourceHighlight(map, sourceId, featuresBySource, stylesMap, prefix, getSymbolImageId))
129
170
 
130
- const idExpression = idProperty ? ['get', idProperty] : ['id']
131
- const filter = ['in', idExpression, ['literal', [...ids]]]
171
+ return featuresBySource
172
+ }
132
173
 
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 }
137
- // Only apply fill highlight to polygon features, not to any co-selected line features
138
- applyHighlightLayer(map, `${base}-fill`, 'fill', sourceId, srcLayer, { 'fill-color': fill }, fillFilter)
139
- applyHighlightLayer(map, `${base}-line`, 'line', sourceId, srcLayer, linePaint, filter)
140
- }
174
+ /**
175
+ * Update highlighted features using pure filters.
176
+ * activeFeatures (keyboard cursor) render with the active ring (yellow) plus a selected ring inner (black).
177
+ * selectedFeatures render with the selected ring (black) only.
178
+ * Supports fill, line and symbol geometry, multi-source, cleanup, and bounds.
179
+ */
180
+ export function updateHighlightedFeatures ({ LngLatBounds, map, selectedFeatures, activeFeatures, stylesMap }) {
181
+ if (!map) {
182
+ return null
183
+ }
141
184
 
142
- if (geom === 'line') {
143
- const { stroke, strokeWidth } = stylesMap[layerId]
144
- const linePaint = { 'line-color': stroke, 'line-width': strokeWidth }
145
- // Clear any fill highlight from a previous polygon selection on the same source
146
- if (map.getLayer(`${base}-fill`)) {
147
- map.setFilter(`${base}-fill`, ['==', 'id', ''])
148
- }
149
- applyHighlightLayer(map, `${base}-line`, 'line', sourceId, srcLayer, linePaint, filter)
150
- }
185
+ // Active cursor features — rendered first so selected layers appear on top
186
+ if (activeFeatures?.length) {
187
+ applyFeatureHighlights(map, activeFeatures, stylesMap, ACTIVE_PREFIX, getActiveImageId)
188
+ // Black selected stroke on top of yellow active (mirrors resolveActive for symbols)
189
+ applyFeatureHighlights(map, activeFeatures, stylesMap, ACTIVE_INNER_PREFIX, getSelectedImageId)
190
+ } else {
191
+ clearPrefixSources(map, ACTIVE_PREFIX)
192
+ clearPrefixSources(map, ACTIVE_INNER_PREFIX)
193
+ }
151
194
 
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
- }
195
+ // Selection features
196
+ let featuresBySource = {}
197
+ if (selectedFeatures?.length) {
198
+ featuresBySource = applyFeatureHighlights(map, selectedFeatures, stylesMap, SELECTED_PREFIX, getSelectedImageId)
199
+ } else {
200
+ clearPrefixSources(map, SELECTED_PREFIX)
201
+ }
159
202
 
160
- // Bounds only from rendered tiles
203
+ // Bounds only from selected features
204
+ const renderedFeatures = []
205
+ Object.entries(featuresBySource).forEach(([, { ids, idProperty, layerId }]) => {
161
206
  renderedFeatures.push(
162
207
  ...map
163
208
  .queryRenderedFeatures({ layers: [layerId] })