@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
@@ -1,19 +1,23 @@
1
1
  import { updateHighlightedFeatures } from './highlightFeatures.js'
2
2
 
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]))
3
+ class LngLatBounds {
4
+ constructor () { this.coords = [] }
5
+ extend (c) { this.coords.push(c) }
6
+ getWest () { return Math.min(...this.coords.map(c => c[0])) }
7
+ getSouth () { return Math.min(...this.coords.map(c => c[1])) }
8
+ getEast () { return Math.max(...this.coords.map(c => c[0])) }
9
+ getNorth () { return Math.max(...this.coords.map(c => c[1])) }
10
10
  }
11
11
 
12
+ const SYMBOL_IMAGE = 'symbol-abc123'
12
13
  const EMPTY_FILTER = ['==', 'id', '']
13
- const STALE_SYMBOL_LAYER = 'highlight-stale-symbol'
14
+ const LINE_COLOR = 'line-color'
15
+ const STALE_SYMBOL_LAYER = 'active-highlight-stale-symbol'
16
+ const SEL_STALE_SYMBOL_LAYER = 'selected-highlight-stale-symbol'
14
17
 
15
18
  const makeMap = (overrides = {}) => ({
16
- _highlightedSources: new Set(),
19
+ _activehighlightSources: new Set(),
20
+ _selectedhighlightSources: new Set(),
17
21
  getLayer: jest.fn(),
18
22
  addLayer: jest.fn(),
19
23
  moveLayer: jest.fn(),
@@ -25,7 +29,9 @@ const makeMap = (overrides = {}) => ({
25
29
  ...overrides
26
30
  })
27
31
 
28
- describe('Highlighting Utils fill and line', () => {
32
+ // ─── Active (cursor) features active-highlight-* layers ────────────────────
33
+
34
+ describe('Highlighting Utils — active (cursor) fill and line', () => {
29
35
  let map
30
36
 
31
37
  const ALL_BRANCHES_FEATURES = [
@@ -38,7 +44,7 @@ describe('Highlighting Utils — fill and line', () => {
38
44
  const ALL_BRANCHES_STYLES = { l1: { stroke: 'red', fill: 'blue' }, l2: { stroke: 'green' } }
39
45
 
40
46
  beforeEach(() => {
41
- map = makeMap({ _highlightedSources: new Set(['stale']) })
47
+ map = makeMap({ _activehighlightSources: new Set(['stale']) })
42
48
  })
43
49
 
44
50
  test('All branches', () => {
@@ -48,69 +54,140 @@ describe('Highlighting Utils — fill and line', () => {
48
54
  if (id.includes('stale')) { return {} }
49
55
  if (id === 'l1') { return { source: 's1', type: 'fill' } }
50
56
  if (id === 'l2') { return { source: 's2', type: 'line' } }
51
- if (id === 'highlight-s2-fill') { return {} }
57
+ if (id === 'active-highlight-s2-fill') { return {} }
52
58
  return null
53
59
  })
54
60
 
55
- const coordMax = 10
56
- const coordMid = 5
57
- map.queryRenderedFeatures.mockReturnValue([
58
- { id: 1, geometry: { coordinates: [coordMax, coordMax] } },
59
- { id: 2, properties: { customId: 4 }, geometry: { coordinates: [[0, 0], [coordMid, coordMid]] } }
60
- ])
61
-
62
- const bounds = updateHighlightedFeatures({ LngLatBounds: lngLatBounds, map, selectedFeatures: ALL_BRANCHES_FEATURES, stylesMap: ALL_BRANCHES_STYLES })
61
+ updateHighlightedFeatures({ LngLatBounds, map, activeFeatures: ALL_BRANCHES_FEATURES, stylesMap: ALL_BRANCHES_STYLES })
63
62
 
64
- expect(map.setFilter).toHaveBeenCalledWith('highlight-stale-fill', EMPTY_FILTER)
63
+ expect(map.setFilter).toHaveBeenCalledWith('active-highlight-stale-fill', EMPTY_FILTER)
65
64
  expect(map.setFilter).toHaveBeenCalledWith(STALE_SYMBOL_LAYER, EMPTY_FILTER)
66
- expect(map.setFilter).toHaveBeenCalledWith('highlight-s2-fill', EMPTY_FILTER)
67
- expect(map.setFilter).toHaveBeenCalledWith('highlight-s2-line', expect.arrayContaining([['get', 'customId']]))
68
- expect(bounds).toEqual([0, 0, 10, 10])
65
+ expect(map.setFilter).toHaveBeenCalledWith('active-highlight-s2-fill', EMPTY_FILTER)
66
+ expect(map.setFilter).toHaveBeenCalledWith('active-highlight-s2-line', expect.arrayContaining([['get', 'customId']]))
69
67
  })
70
68
 
71
- test('null _highlightedSources falls back to empty set; line geom skips absent fill layer', () => {
72
- map._highlightedSources = null
69
+ test('null _activehighlightSources falls back to empty set; line geom skips absent fill layer', () => {
70
+ map._activehighlightSources = null
73
71
  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' } } })
75
- expect(map.setFilter).not.toHaveBeenCalledWith('highlight-s1-fill', expect.anything())
72
+ updateHighlightedFeatures({ LngLatBounds, map, activeFeatures: [{ featureId: 1, layerId: 'l1' }], stylesMap: { l1: { stroke: 'red' } } })
73
+ expect(map.setFilter).not.toHaveBeenCalledWith('active-highlight-s1-fill', expect.anything())
76
74
  })
77
75
 
78
76
  test('persistent source skips cleanup; missing stale layers skip setFilter', () => {
79
- map._highlightedSources = new Set(['stale', 's1'])
77
+ map._activehighlightSources = new Set(['stale', 's1'])
80
78
  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' } } })
79
+ updateHighlightedFeatures({ LngLatBounds, map, activeFeatures: [{ featureId: 1, layerId: 'l1' }], stylesMap: { l1: { stroke: 'red' } } })
82
80
  expect(map.setFilter).not.toHaveBeenCalledWith(expect.stringContaining('stale'), expect.anything())
83
81
  })
82
+
83
+ test('active features get selected-style overlay line on top', () => {
84
+ map.getLayer.mockImplementation(id => id === 'l1' ? { source: 's1', type: 'line' } : null) // NOSONAR
85
+ const stylesMap = { l1: { stroke: 'yellow', selectionStroke: 'black', strokeWidth: 3, activeStrokeWidth: 9 } }
86
+ updateHighlightedFeatures({ LngLatBounds, map, activeFeatures: [{ featureId: 1, layerId: 'l1' }], stylesMap })
87
+
88
+ const activeColorCall = map.setPaintProperty.mock.calls.find(c => c[0] === 'active-highlight-s1-line' && c[1] === LINE_COLOR)
89
+ expect(activeColorCall?.[2]).toBe('yellow')
90
+
91
+ const overlayColorCall = map.setPaintProperty.mock.calls.find(c => c[0] === 'active-highlight-inner-s1-line' && c[1] === LINE_COLOR)
92
+ expect(overlayColorCall?.[2]).toBe('black')
93
+ })
94
+
95
+ test('overlay cleanup when active features cleared', () => {
96
+ map._activehighlightinnerSources = new Set(['stale'])
97
+ map.getLayer.mockImplementation(id => id === 'active-highlight-inner-stale-line' ? {} : null) // NOSONAR
98
+ updateHighlightedFeatures({ LngLatBounds, map, activeFeatures: [], stylesMap: {} })
99
+ expect(map.setFilter).toHaveBeenCalledWith('active-highlight-inner-stale-line', EMPTY_FILTER)
100
+ })
84
101
  })
85
102
 
103
+ // ─── Selected features — selected-highlight-* layers ─────────────────────────
104
+
105
+ describe('Highlighting Utils — selected fill and line', () => {
106
+ let map
107
+
108
+ const SELECTED_FEATURES = [
109
+ { featureId: 1, layerId: 'l1', geometry: { type: 'Polygon' } }
110
+ ]
111
+
112
+ const STYLES = { l1: { stroke: 'red', selectionStroke: '#ffdd00', fill: 'blue', strokeWidth: 3 } }
113
+
114
+ beforeEach(() => {
115
+ map = makeMap({ _selectedhighlightSources: new Set(['stale']) })
116
+ })
117
+
118
+ test('creates selected-highlight-* layers with selectionStroke color', () => {
119
+ map.getLayer.mockImplementation(id => { // NOSONAR
120
+ if (id.includes('stale')) { return {} }
121
+ if (id === 'l1') { return { source: 's1', type: 'fill' } }
122
+ return null
123
+ })
124
+ updateHighlightedFeatures({ LngLatBounds, map, selectedFeatures: SELECTED_FEATURES, stylesMap: STYLES })
125
+ expect(map.setFilter).toHaveBeenCalledWith(SEL_STALE_SYMBOL_LAYER, EMPTY_FILTER)
126
+ expect(map.addLayer).toHaveBeenCalledWith(expect.objectContaining({ id: 'selected-highlight-s1-line' }))
127
+ const linePaintCall = map.setPaintProperty.mock.calls.find(c => c[0] === 'selected-highlight-s1-line' && c[1] === LINE_COLOR)
128
+ expect(linePaintCall).toBeTruthy()
129
+ expect(linePaintCall[2]).toBe('#ffdd00')
130
+ })
131
+
132
+ test('bounds matched using idProperty when set', () => {
133
+ const features = [{ featureId: 'parcel-1', layerId: 'l1', idProperty: 'parcelId', geometry: { type: 'Polygon' } }]
134
+ map.getLayer.mockImplementation(id => id === 'l1' ? { source: 's1', type: 'fill' } : null) // NOSONAR
135
+ map.queryRenderedFeatures.mockReturnValue([
136
+ { properties: { parcelId: 'parcel-1' }, geometry: { coordinates: [[1, 2]] } }
137
+ ])
138
+ const bounds = updateHighlightedFeatures({ LngLatBounds, map, selectedFeatures: features, stylesMap: STYLES })
139
+ expect(bounds).not.toBeNull()
140
+ })
141
+
142
+ test('bounds are calculated from selected features only', () => {
143
+ const features = [
144
+ { featureId: 1, layerId: 'l1', geometry: { type: 'Polygon' } },
145
+ { featureId: 2, layerId: 'l1', geometry: { type: 'Polygon' } }
146
+ ]
147
+ map.getLayer.mockImplementation(id => id === 'l1' ? { source: 's1', type: 'line' } : null) // NOSONAR
148
+ const coordMax = 10
149
+ const coordMid = 5
150
+ map.queryRenderedFeatures.mockReturnValue([
151
+ { id: 1, geometry: { coordinates: [coordMax, coordMax] } },
152
+ { id: 2, geometry: { coordinates: [[0, 0], [coordMid, coordMid]] } }
153
+ ])
154
+ const bounds = updateHighlightedFeatures({ LngLatBounds, map, selectedFeatures: features, stylesMap: STYLES })
155
+ expect(bounds).toEqual([0, 0, 10, 10])
156
+ })
157
+ })
158
+
159
+ // ─── Layer management ─────────────────────────────────────────────────────────
160
+
86
161
  describe('Highlighting Utils — layer management', () => {
87
162
  let map
88
163
 
89
164
  beforeEach(() => {
90
- map = makeMap({ _highlightedSources: new Set(['stale']) })
165
+ map = makeMap({ _activehighlightSources: new Set(['stale']) })
91
166
  })
92
167
 
93
- test('reuses existing highlight layer; new layer spreads sourceLayer', () => {
168
+ test('reuses existing active-highlight layer; new layers spread sourceLayer', () => {
94
169
  map.getLayer.mockImplementation(id => { // NOSONAR
95
170
  if (id === 'l1') { return { source: 's1', type: 'line' } }
96
171
  if (id === 'l2') { return { source: 's2', type: 'line', sourceLayer: 'tiles' } }
97
- if (id === 'highlight-s1-line') { return {} }
172
+ if (id === 'active-highlight-s1-line') { return {} }
98
173
  return null
99
174
  })
100
- updateHighlightedFeatures({ LngLatBounds: lngLatBounds, map, selectedFeatures: [{ featureId: 1, layerId: 'l1' }, { featureId: 2, layerId: 'l2' }], stylesMap: { l1: { stroke: 'blue' }, l2: { stroke: 'green' } } })
101
- expect(map.addLayer).toHaveBeenCalledTimes(1)
175
+ updateHighlightedFeatures({ LngLatBounds, map, activeFeatures: [{ featureId: 1, layerId: 'l1' }, { featureId: 2, layerId: 'l2' }], stylesMap: { l1: { stroke: 'blue' }, l2: { stroke: 'green' } } })
176
+ // active-highlight-s1-line reused; active-highlight-s2-line, active-highlight-inner-s1-line, active-highlight-inner-s2-line are new
177
+ expect(map.addLayer).toHaveBeenCalledTimes(3)
102
178
  expect(map.addLayer).toHaveBeenCalledWith(expect.objectContaining({ 'source-layer': 'tiles' }))
103
179
  })
104
180
 
105
181
  test('returns null when no rendered features match', () => {
106
- expect(updateHighlightedFeatures({ LngLatBounds: lngLatBounds, map, selectedFeatures: [], stylesMap: {} })).toBeNull()
182
+ expect(updateHighlightedFeatures({ LngLatBounds, map, selectedFeatures: [], stylesMap: {} })).toBeNull()
107
183
  })
108
184
  })
109
185
 
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'
186
+ // ─── Symbol layers ────────────────────────────────────────────────────────────
187
+
188
+ describe('Highlighting Utils symbol layers (active cursor)', () => {
189
+ const ACTIVE_IMAGE = 'symbol-act-abc123'
190
+ const HIGHLIGHT_LAYER = 'active-highlight-s1-symbol'
114
191
  const ICON_IMAGE = 'icon-image'
115
192
  const ICON_ANCHOR = 'icon-anchor'
116
193
  const POINT_FEATURE = { featureId: 1, layerId: 'l1', geometry: { type: 'Point' } }
@@ -119,22 +196,22 @@ describe('Highlighting Utils — symbol layers', () => {
119
196
 
120
197
  beforeEach(() => {
121
198
  map = makeMap()
122
- map._symbolImageMap = { [SYMBOL_IMAGE]: SELECTED_IMAGE }
199
+ map._activeSymbolImageMap = { [SYMBOL_IMAGE]: ACTIVE_IMAGE }
123
200
  map.getLayer.mockImplementation(id => id === 'l1' ? { source: 's1', type: 'symbol' } : null) // NOSONAR
124
201
  map.getLayoutProperty.mockReturnValue(SYMBOL_IMAGE)
125
202
  })
126
203
 
127
- const run = (selectedFeatures = [POINT_FEATURE]) =>
128
- updateHighlightedFeatures({ LngLatBounds: lngLatBounds, map, selectedFeatures, stylesMap: { l1: {} } })
204
+ const run = (activeFeatures = [POINT_FEATURE]) =>
205
+ updateHighlightedFeatures({ LngLatBounds, map, activeFeatures, stylesMap: { l1: {} } })
129
206
 
130
- test('creates symbol highlight layer with selected image variant', () => {
207
+ test('creates symbol highlight layer with cursor image variant', () => {
131
208
  run()
132
209
  expect(map.addLayer).toHaveBeenCalledWith(expect.objectContaining({
133
210
  id: HIGHLIGHT_LAYER,
134
211
  type: 'symbol',
135
- layout: expect.objectContaining({ [ICON_IMAGE]: SELECTED_IMAGE })
212
+ layout: expect.objectContaining({ [ICON_IMAGE]: ACTIVE_IMAGE })
136
213
  }))
137
- expect(map.setLayoutProperty).toHaveBeenCalledWith(HIGHLIGHT_LAYER, ICON_IMAGE, SELECTED_IMAGE)
214
+ expect(map.setLayoutProperty).toHaveBeenCalledWith(HIGHLIGHT_LAYER, ICON_IMAGE, ACTIVE_IMAGE)
138
215
  })
139
216
 
140
217
  test('reads icon-anchor from original layer', () => {
@@ -171,20 +248,54 @@ describe('Highlighting Utils — symbol layers', () => {
171
248
  })
172
249
  run()
173
250
  expect(map.addLayer).not.toHaveBeenCalled()
174
- expect(map.setLayoutProperty).toHaveBeenCalledWith(HIGHLIGHT_LAYER, ICON_IMAGE, SELECTED_IMAGE)
251
+ expect(map.setLayoutProperty).toHaveBeenCalledWith(HIGHLIGHT_LAYER, ICON_IMAGE, ACTIVE_IMAGE)
175
252
  })
176
253
 
177
- test('skips highlight when icon-image has no entry in _symbolImageMap', () => {
178
- map.getLayoutProperty.mockReturnValue('symbol-abc123')
179
- map._symbolImageMap = {} // no mapping registered
254
+ test('skips highlight when icon-image has no entry in _activeSymbolImageMap', () => {
255
+ map.getLayoutProperty.mockReturnValue(SYMBOL_IMAGE)
256
+ map._activeSymbolImageMap = {} // no mapping registered
180
257
  run()
181
258
  expect(map.addLayer).not.toHaveBeenCalled()
182
259
  })
183
260
 
184
261
  test('cleans up stale symbol highlight layer', () => {
185
- map._highlightedSources = new Set(['stale'])
262
+ map._activehighlightSources = new Set(['stale'])
186
263
  map.getLayer.mockImplementation(id => id === STALE_SYMBOL_LAYER ? { type: 'symbol' } : null) // NOSONAR
187
264
  run([])
188
265
  expect(map.setFilter).toHaveBeenCalledWith(STALE_SYMBOL_LAYER, EMPTY_FILTER)
189
266
  })
190
267
  })
268
+
269
+ describe('Highlighting Utils — symbol layers (committed selection)', () => {
270
+ const SELECTED_IMAGE = 'symbol-sel-abc123'
271
+ const SEL_HIGHLIGHT_LAYER = 'selected-highlight-s1-symbol'
272
+ const ICON_IMAGE = 'icon-image'
273
+ const POINT_FEATURE = { featureId: 1, layerId: 'l1', geometry: { type: 'Point' } }
274
+
275
+ let map
276
+
277
+ beforeEach(() => {
278
+ map = makeMap()
279
+ map._selectedSymbolImageMap = { [SYMBOL_IMAGE]: SELECTED_IMAGE }
280
+ map.getLayer.mockImplementation(id => id === 'l1' ? { source: 's1', type: 'symbol' } : null) // NOSONAR
281
+ map.getLayoutProperty.mockReturnValue(SYMBOL_IMAGE)
282
+ })
283
+
284
+ const run = (selectedFeatures = [POINT_FEATURE]) =>
285
+ updateHighlightedFeatures({ LngLatBounds, map, selectedFeatures, stylesMap: { l1: {} } })
286
+
287
+ test('creates selected-highlight symbol layer with selected image variant', () => {
288
+ run()
289
+ expect(map.addLayer).toHaveBeenCalledWith(expect.objectContaining({
290
+ id: SEL_HIGHLIGHT_LAYER,
291
+ type: 'symbol',
292
+ layout: expect.objectContaining({ [ICON_IMAGE]: SELECTED_IMAGE })
293
+ }))
294
+ })
295
+
296
+ test('skips highlight when icon-image has no entry in _selectedSymbolImageMap', () => {
297
+ map._selectedSymbolImageMap = {}
298
+ run()
299
+ expect(map.addLayer).not.toHaveBeenCalled()
300
+ })
301
+ })
@@ -5,6 +5,16 @@ export function cleanCanvas (map) {
5
5
  canvas.setAttribute('tabindex', -1) // If removed altogether Chrome can add a focus-visible style
6
6
  canvas.removeAttribute('aria-label')
7
7
  canvas.style.display = 'block'
8
+ // The map container is aria-hidden, but MapLibre's canvas inside it can still
9
+ // receive focus, triggering: "Blocked aria-hidden on an element because its
10
+ // descendant retained focus." Immediately blurring and returning focus to
11
+ // relatedTarget prevents the canvas from holding focus inside an aria-hidden subtree.
12
+ canvas.addEventListener('focus', (event) => { // NOSONAR
13
+ canvas.blur()
14
+ if (event.relatedTarget) {
15
+ event.relatedTarget.focus()
16
+ }
17
+ })
8
18
  }
9
19
 
10
20
  // Fix touch preventDefault console error
@@ -16,6 +16,37 @@ describe('cleanCanvas', () => {
16
16
  expect(canvas.hasAttribute('aria-label')).toBe(false)
17
17
  expect(canvas.style.display).toBe('block')
18
18
  })
19
+
20
+ it('redirects focus back to the previously focused element when canvas receives focus', () => {
21
+ const canvas = document.createElement('canvas')
22
+ const map = { getCanvas: () => canvas }
23
+ const prev = document.createElement('button')
24
+ prev.focus = jest.fn()
25
+ document.body.appendChild(canvas)
26
+
27
+ cleanCanvas(map)
28
+
29
+ const event = new FocusEvent('focus', { relatedTarget: prev })
30
+ const blurSpy = jest.spyOn(canvas, 'blur')
31
+ canvas.dispatchEvent(event)
32
+
33
+ expect(blurSpy).toHaveBeenCalled()
34
+ expect(prev.focus).toHaveBeenCalled()
35
+ })
36
+
37
+ it('blurs canvas without restoring focus when there is no previous element', () => {
38
+ const canvas = document.createElement('canvas')
39
+ const map = { getCanvas: () => canvas }
40
+ document.body.appendChild(canvas)
41
+
42
+ cleanCanvas(map)
43
+
44
+ const event = new FocusEvent('focus', { relatedTarget: null })
45
+ const blurSpy = jest.spyOn(canvas, 'blur')
46
+ canvas.dispatchEvent(event)
47
+
48
+ expect(blurSpy).toHaveBeenCalled()
49
+ })
19
50
  })
20
51
 
21
52
  describe('applyPreventDefaultFix', () => {
@@ -1,4 +1,4 @@
1
- import { getPatternInnerContent, getPatternImageId, injectColors, PATTERN_MIN_PIXEL_RATIO } from '../../../../src/utils/patternUtils.js'
1
+ import { injectColors, getEffectivePixelRatio } from '../../../../src/utils/patternUtils.js'
2
2
  import { getValueForStyle } from '../../../../src/utils/getValueForStyle.js'
3
3
  import { rasteriseToImageData } from './rasteriseToImageData.js'
4
4
 
@@ -16,12 +16,12 @@ const imageDataCache = new Map()
16
16
  * @returns {Promise<{ imageId: string, imageData: ImageData }|null>}
17
17
  */
18
18
  const rasterisePattern = async (dataset, mapStyleId, patternRegistry, pixelRatio) => {
19
- const innerContent = getPatternInnerContent(dataset, patternRegistry)
19
+ const innerContent = patternRegistry.getPatternInnerContent(dataset)
20
20
  if (!innerContent) {
21
21
  return null
22
22
  }
23
23
 
24
- const imageId = getPatternImageId(dataset, mapStyleId, patternRegistry, pixelRatio)
24
+ const imageId = patternRegistry.getPatternImageId(dataset, mapStyleId, pixelRatio)
25
25
  if (!imageId) {
26
26
  return null
27
27
  }
@@ -32,9 +32,7 @@ const rasterisePattern = async (dataset, mapStyleId, patternRegistry, pixelRatio
32
32
  const bg = getValueForStyle(dataset.fillPatternBackgroundColor, mapStyleId) || 'transparent'
33
33
  const colored = injectColors(innerContent, fg, bg)
34
34
  const bgRect = `<rect width="16" height="16" fill="${bg}"/>`
35
- // effectiveRatio floored at PATTERN_MIN_PIXEL_RATIO keeps the canvas at ≥16px physical so
36
- // SVG detail renders crisply. Logical tile size = physicalSize / effectiveRatio = 8px always.
37
- const effectiveRatio = Math.max(PATTERN_MIN_PIXEL_RATIO, pixelRatio)
35
+ const effectiveRatio = getEffectivePixelRatio(pixelRatio)
38
36
  const physicalSize = Math.round(8 * effectiveRatio)
39
37
  const svgString = `<svg xmlns="http://www.w3.org/2000/svg" width="${physicalSize}" height="${physicalSize}" viewBox="0 0 16 16">${bgRect}${colored}</svg>`
40
38
  imageData = await rasteriseToImageData(svgString, physicalSize, physicalSize)
@@ -51,20 +49,20 @@ const rasterisePattern = async (dataset, mapStyleId, patternRegistry, pixelRatio
51
49
  * (see `getPatternConfigs` in the datasets plugin adapter).
52
50
  *
53
51
  * @param {Object} map - MapLibre map instance
54
- * @param {Object[]} patternConfigs - Flat list of datasets/merged-sublayers with a pattern config
52
+ * @param {Object[]} styleArray - Flat list of datasets/merged-sublayers with a pattern config
55
53
  * @param {string} mapStyleId
56
54
  * @param {Object} patternRegistry
57
55
  * @param {number} pixelRatio
58
56
  * @returns {Promise<void>}
59
57
  */
60
- export const registerPatterns = async (map, patternConfigs, mapStyleId, patternRegistry, pixelRatio) => {
61
- if (!patternConfigs.length) {
58
+ export const addPatternsToMap = async (map, styleArray, mapStyleId, patternRegistry, pixelRatio) => {
59
+ if (!styleArray.length) {
62
60
  return
63
61
  }
64
62
 
65
- const effectiveRatio = Math.max(PATTERN_MIN_PIXEL_RATIO, pixelRatio)
66
- await Promise.all(patternConfigs.map(async (config) => {
67
- const imageId = getPatternImageId(config, mapStyleId, patternRegistry, pixelRatio)
63
+ const effectiveRatio = getEffectivePixelRatio(pixelRatio)
64
+ await Promise.all(styleArray.map(async (config) => {
65
+ const imageId = patternRegistry.getPatternImageId(config, mapStyleId, pixelRatio)
68
66
  if (!imageId || map.hasImage(imageId)) {
69
67
  return
70
68
  }