@defra/interactive-map 0.0.17-alpha → 0.0.19-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 (185) hide show
  1. package/assets/css/docusaurus.css +58 -34
  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/panel-definition.md +16 -0
  11. package/docs/api/symbol-config.md +160 -0
  12. package/docs/api/symbol-registry.md +115 -0
  13. package/docs/api.md +50 -23
  14. package/docs/assets/basic-map.jpg +0 -0
  15. package/docs/assets/button-first.jpg +0 -0
  16. package/docs/assets/maker-panel.jpg +0 -0
  17. package/docs/examples/add-marker-with-panel.mdx +59 -0
  18. package/docs/examples/basic-map.mdx +24 -0
  19. package/docs/examples/button-map.mdx +24 -0
  20. package/docs/examples/index.mdx +49 -0
  21. package/docs/index.mdx +1 -1
  22. package/docs/plugins/datasets.md +105 -9
  23. package/docs/plugins/interact.md +100 -44
  24. package/docs/plugins/search.md +15 -3
  25. package/docs/plugins.md +1 -1
  26. package/docusaurus.config.cjs +9 -1
  27. package/package.json +1 -1
  28. package/plugins/beta/datasets/dist/css/index.css +32 -14
  29. package/plugins/beta/datasets/dist/esm/im-datasets-plugin.js +1 -1
  30. package/plugins/beta/datasets/dist/esm/index.js +1 -1
  31. package/plugins/beta/datasets/dist/umd/im-datasets-plugin.js +1 -1
  32. package/plugins/beta/datasets/dist/umd/index.js +1 -1
  33. package/plugins/beta/datasets/src/DatasetsInit.jsx +9 -4
  34. package/plugins/beta/datasets/src/adapters/maplibre/layerBuilders.js +57 -11
  35. package/plugins/beta/datasets/src/adapters/maplibre/layerIds.js +14 -8
  36. package/plugins/beta/datasets/src/adapters/maplibre/maplibreLayerAdapter.js +155 -53
  37. package/plugins/beta/datasets/src/adapters/maplibre/patternImages.js +27 -0
  38. package/plugins/beta/datasets/src/adapters/maplibre/symbolImages.js +31 -0
  39. package/plugins/beta/datasets/src/api/addDataset.js +1 -1
  40. package/plugins/beta/datasets/src/api/setData.js +4 -2
  41. package/plugins/beta/datasets/src/api/setStyle.js +2 -2
  42. package/plugins/beta/datasets/src/components/EmptyKey.jsx +7 -0
  43. package/plugins/beta/datasets/src/components/EmptyKey.test.jsx +21 -0
  44. package/plugins/beta/datasets/src/components/KeySvg.jsx +24 -0
  45. package/plugins/beta/datasets/src/components/KeySvgLine.jsx +19 -0
  46. package/plugins/beta/datasets/src/components/KeySvgPattern.jsx +15 -0
  47. package/plugins/beta/datasets/src/components/KeySvgRect.jsx +22 -0
  48. package/plugins/beta/datasets/src/components/KeySvgSymbol.jsx +16 -0
  49. package/plugins/beta/datasets/src/components/svgProperties.js +20 -0
  50. package/plugins/beta/datasets/src/datasets.js +13 -4
  51. package/plugins/beta/datasets/src/defaults.js +4 -2
  52. package/plugins/beta/datasets/src/index.js +2 -1
  53. package/plugins/beta/datasets/src/manifest.js +1 -1
  54. package/plugins/beta/datasets/src/panels/Key.jsx +11 -89
  55. package/plugins/beta/datasets/src/panels/Key.module.scss +24 -13
  56. package/plugins/beta/datasets/src/panels/Layers.module.scss +13 -7
  57. package/plugins/beta/datasets/src/reducer.js +6 -0
  58. package/plugins/beta/datasets/src/reducers/keyReducer.js +34 -0
  59. package/plugins/beta/datasets/src/utils/mergeSublayer.js +8 -0
  60. package/plugins/beta/draw-es/dist/esm/im-draw-es-plugin.js +1 -1
  61. package/plugins/beta/draw-es/src/DrawInit.jsx +3 -2
  62. package/plugins/beta/draw-ml/dist/css/index.css +3 -0
  63. package/plugins/beta/draw-ml/dist/esm/im-draw-ml-plugin.js +1 -1
  64. package/plugins/beta/draw-ml/dist/umd/im-draw-ml-plugin.js +1 -1
  65. package/plugins/beta/draw-ml/dist/umd/index.js +1 -1
  66. package/plugins/beta/draw-ml/src/DrawInit.jsx +4 -3
  67. package/plugins/beta/map-styles/dist/esm/im-map-styles-plugin.js +1 -1
  68. package/plugins/beta/map-styles/dist/umd/im-map-styles-plugin.js +1 -1
  69. package/plugins/beta/map-styles/dist/umd/index.js +1 -1
  70. package/plugins/beta/map-styles/src/MapStyles.jsx +5 -4
  71. package/plugins/beta/map-styles/src/MapStylesInit.jsx +5 -4
  72. package/plugins/beta/scale-bar/dist/css/index.css +1 -1
  73. package/plugins/beta/scale-bar/src/scaleBar.scss +1 -0
  74. package/plugins/interact/dist/esm/im-interact-plugin.js +1 -1
  75. package/plugins/interact/dist/umd/im-interact-plugin.js +1 -1
  76. package/plugins/interact/dist/umd/index.js +1 -1
  77. package/plugins/interact/src/InteractInit.jsx +19 -8
  78. package/plugins/interact/src/InteractInit.test.js +26 -6
  79. package/plugins/interact/src/api/clear.js +1 -1
  80. package/plugins/interact/src/api/enable.test.js +7 -7
  81. package/plugins/interact/src/api/selectMarker.js +14 -0
  82. package/plugins/interact/src/api/selectMarker.test.js +25 -0
  83. package/plugins/interact/src/api/unselectMarker.js +14 -0
  84. package/plugins/interact/src/api/unselectMarker.test.js +14 -0
  85. package/plugins/interact/src/defaults.js +4 -6
  86. package/plugins/interact/src/events.js +27 -36
  87. package/plugins/interact/src/events.test.js +119 -90
  88. package/plugins/interact/src/hooks/useHighlightSync.js +3 -3
  89. package/plugins/interact/src/hooks/useHighlightSync.test.js +6 -6
  90. package/plugins/interact/src/hooks/useHoverCursor.js +10 -0
  91. package/plugins/interact/src/hooks/useHoverCursor.test.js +44 -0
  92. package/plugins/interact/src/hooks/useInteractionHandlers.js +111 -69
  93. package/plugins/interact/src/hooks/useInteractionHandlers.test.js +147 -32
  94. package/plugins/interact/src/manifest.js +10 -2
  95. package/plugins/interact/src/reducer.js +59 -5
  96. package/plugins/interact/src/reducer.test.js +100 -12
  97. package/plugins/interact/src/utils/buildStylesMap.js +17 -4
  98. package/plugins/interact/src/utils/buildStylesMap.test.js +16 -2
  99. package/plugins/interact/src/utils/featureQueries.js +11 -6
  100. package/plugins/interact/src/utils/featureQueries.test.js +8 -1
  101. package/plugins/interact/src/utils/interactionModes.js +12 -0
  102. package/plugins/search/dist/esm/im-search-plugin.js +1 -1
  103. package/plugins/search/dist/umd/im-search-plugin.js +1 -1
  104. package/plugins/search/src/Search.jsx +3 -1
  105. package/plugins/search/src/events/fetchSuggestions.js +6 -4
  106. package/plugins/search/src/events/fetchSuggestions.test.js +26 -4
  107. package/plugins/search/src/events/formHandlers.js +3 -3
  108. package/plugins/search/src/events/formHandlers.test.js +1 -1
  109. package/plugins/search/src/events/suggestionHandlers.js +2 -2
  110. package/plugins/search/src/events/suggestionHandlers.test.js +1 -1
  111. package/plugins/search/src/utils/updateMap.js +3 -3
  112. package/plugins/search/src/utils/updateMap.test.js +3 -3
  113. package/providers/maplibre/dist/esm/im-maplibre-provider.js +1 -1
  114. package/providers/maplibre/dist/umd/im-maplibre-provider.js +1 -1
  115. package/providers/maplibre/dist/umd/index.js +1 -1
  116. package/providers/maplibre/src/appEvents.js +7 -0
  117. package/providers/maplibre/src/appEvents.test.js +18 -4
  118. package/providers/maplibre/src/maplibreProvider.js +52 -0
  119. package/providers/maplibre/src/maplibreProvider.test.js +105 -1
  120. package/providers/maplibre/src/utils/highlightFeatures.js +36 -7
  121. package/providers/maplibre/src/utils/highlightFeatures.test.js +153 -96
  122. package/providers/maplibre/src/utils/hoverCursor.js +61 -0
  123. package/providers/maplibre/src/utils/hoverCursor.test.js +130 -0
  124. package/providers/maplibre/src/utils/patternImages.js +70 -0
  125. package/providers/maplibre/src/utils/patternImages.test.js +180 -0
  126. package/providers/maplibre/src/utils/queryFeatures.js +38 -16
  127. package/providers/maplibre/src/utils/queryFeatures.test.js +20 -3
  128. package/providers/maplibre/src/utils/rasteriseToImageData.js +30 -0
  129. package/providers/maplibre/src/utils/rasteriseToImageData.test.js +69 -0
  130. package/providers/maplibre/src/utils/symbolImages.js +147 -0
  131. package/providers/maplibre/src/utils/symbolImages.test.js +248 -0
  132. package/src/App/components/Markers/Markers.jsx +122 -27
  133. package/src/App/components/Markers/Markers.module.scss +0 -10
  134. package/src/App/components/Markers/Markers.test.jsx +246 -0
  135. package/src/App/components/Panel/Panel.jsx +6 -6
  136. package/src/App/components/Panel/Panel.test.jsx +37 -0
  137. package/src/App/components/Viewport/Viewport.jsx +5 -15
  138. package/src/App/components/Viewport/Viewport.module.scss +2 -0
  139. package/src/App/components/Viewport/Viewport.test.jsx +16 -33
  140. package/src/App/hooks/useInterfaceAPI.js +7 -7
  141. package/src/App/hooks/useInterfaceAPI.test.js +162 -0
  142. package/src/App/hooks/useLayoutMeasurements.js +64 -72
  143. package/src/App/hooks/useMarkersAPI.js +2 -5
  144. package/src/App/hooks/useMarkersAPI.test.js +4 -4
  145. package/src/App/layout/Layout.jsx +3 -3
  146. package/src/App/layout/Layout.test.jsx +4 -2
  147. package/src/App/layout/layout.module.scss +1 -8
  148. package/src/App/renderer/HtmlElementHost.jsx +10 -5
  149. package/src/App/renderer/mapPanels.js +2 -1
  150. package/src/App/store/ServiceProvider.jsx +7 -5
  151. package/src/App/store/appActionsMap.js +4 -4
  152. package/src/App/store/appActionsMap.test.js +10 -0
  153. package/src/App/store/mapActionsMap.js +4 -6
  154. package/src/App/store/mapActionsMap.test.js +3 -2
  155. package/src/App/store/mapReducer.js +2 -1
  156. package/src/InteractiveMap/InteractiveMap.js +59 -11
  157. package/src/InteractiveMap/InteractiveMap.test.js +126 -4
  158. package/src/InteractiveMap/domStateManager.js +18 -6
  159. package/src/InteractiveMap/domStateManager.test.js +21 -0
  160. package/src/InteractiveMap/historyManager.js +28 -16
  161. package/src/InteractiveMap/historyManager.test.js +17 -0
  162. package/src/config/appConfig.js +2 -7
  163. package/src/config/appConfig.test.js +4 -15
  164. package/src/config/defaults.js +2 -3
  165. package/src/config/events.js +20 -21
  166. package/src/config/mapTheme.js +56 -0
  167. package/src/config/patternConfig.js +16 -0
  168. package/src/config/symbolConfig.js +80 -0
  169. package/src/scss/settings/_colors.scss +0 -9
  170. package/src/services/closeApp.js +1 -10
  171. package/src/services/closeApp.test.js +3 -43
  172. package/src/services/patternRegistry.js +40 -0
  173. package/src/services/patternRegistry.test.js +48 -0
  174. package/src/services/symbolRegistry.js +113 -0
  175. package/src/services/symbolRegistry.test.js +262 -0
  176. package/src/types.js +99 -12
  177. package/src/utils/mapStateSync.js +48 -10
  178. package/src/utils/mapStateSync.test.js +29 -9
  179. package/src/utils/patternUtils.js +94 -0
  180. package/src/utils/patternUtils.test.js +160 -0
  181. package/src/utils/symbolUtils.js +85 -0
  182. package/src/utils/symbolUtils.test.js +156 -0
  183. package/docs/examples.mdx +0 -70
  184. package/plugins/beta/datasets/src/adapters/maplibre/patternRegistry.js +0 -48
  185. package/plugins/beta/datasets/src/styles/patterns.js +0 -157
@@ -1,133 +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
- moveLayer: jest.fn(),
20
- setFilter: jest.fn(),
21
- setPaintProperty: jest.fn(),
22
- queryRenderedFeatures: jest.fn()
23
- }
41
+ map = makeMap({ _highlightedSources: new Set(['stale']) })
24
42
  })
25
43
 
26
44
  test('All branches', () => {
27
- // Coverage for Line 93: Null map check
28
45
  expect(updateHighlightedFeatures({ map: null })).toBeNull()
29
46
 
30
- map.getLayer.mockImplementation((id) => {
31
- if (id.includes('stale')) return true // Coverage for Line 49
32
- if (id === 'l1') return { source: 's1', type: 'fill' }
33
- if (id === 'l2') return { source: 's2', type: 'line' }
34
- if (id === 'highlight-s2-fill') return true // Coverage for Line 124
35
- 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
36
53
  })
37
54
 
38
- const selectedFeatures = [
39
- // Coverage for Lines 37-40: Polygon & MultiPolygon checks
40
- { featureId: 1, layerId: 'l1', geometry: { type: 'Polygon' } },
41
- { featureId: 2, layerId: 'l1', geometry: { type: 'MultiPolygon' } },
42
- // Coverage for Line 13: Invalid layer
43
- { featureId: 3, layerId: 'invalid' },
44
- // Coverage for Line 116: idProperty exists
45
- { featureId: 4, layerId: 'l2', idProperty: 'customId', geometry: { type: 'Point' } }
46
- ]
47
-
48
- const stylesMap = {
49
- l1: { stroke: 'red', fill: 'blue' },
50
- l2: { stroke: 'green' }
51
- }
52
-
53
- // Coverage for Lines 78-80: Recursive coordinate handling (numbers vs arrays)
55
+ const coordMax = 10
56
+ const coordMid = 5
54
57
  map.queryRenderedFeatures.mockReturnValue([
55
- { id: 1, geometry: { coordinates: [10, 10] } }, // Simple point
56
- { 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]] } }
57
60
  ])
58
61
 
59
- const bounds = updateHighlightedFeatures({ LngLatBounds, map, selectedFeatures, stylesMap })
60
-
61
- // Line 13 verify: map.getLayer returned null and function returned early
62
- // Line 49-50 verify: Stale sources filtered out
63
- expect(map.setFilter).toHaveBeenCalledWith('highlight-stale-fill', ['==', 'id', ''])
62
+ const bounds = updateHighlightedFeatures({ LngLatBounds: lngLatBounds, map, selectedFeatures: ALL_BRANCHES_FEATURES, stylesMap: ALL_BRANCHES_STYLES })
64
63
 
65
- // Line 124 verify: Clear fill highlight when switching to line geometry
66
- expect(map.setFilter).toHaveBeenCalledWith('highlight-s2-fill', ['==', 'id', ''])
67
-
68
- // 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)
69
67
  expect(map.setFilter).toHaveBeenCalledWith('highlight-s2-line', expect.arrayContaining([['get', 'customId']]))
70
-
71
- // Line 80-82 verify: Recursive LngLatBounds logic
72
68
  expect(bounds).toEqual([0, 0, 10, 10])
73
69
  })
74
70
 
75
- test('undefined _highlightedSources falls back to empty set; line geom skips absent fill layer', () => {
76
- // line 93: || new Set() fallback; line 124 false: no pre-existing fill to clear
77
- map._highlightedSources = undefined
78
- map.getLayer.mockImplementation(id => id === 'l1' ? { source: 's1', type: 'line' } : null)
79
- map.queryRenderedFeatures.mockReturnValue([])
80
- updateHighlightedFeatures({
81
- LngLatBounds,
82
- map,
83
- selectedFeatures: [{ featureId: 1, layerId: 'l1' }],
84
- stylesMap: { l1: { stroke: 'red' } }
85
- })
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' } } })
86
75
  expect(map.setFilter).not.toHaveBeenCalledWith('highlight-s1-fill', expect.anything())
87
76
  })
88
77
 
89
78
  test('persistent source skips cleanup; missing stale layers skip setFilter', () => {
90
- // line 37 false: src IS in currentSources; line 41 false: getLayer returns null for stale layers
91
79
  map._highlightedSources = new Set(['stale', 's1'])
92
- map.getLayer.mockImplementation(id => id === 'l1' ? { source: 's1', type: 'line' } : null)
93
- map.queryRenderedFeatures.mockReturnValue([])
94
- updateHighlightedFeatures({
95
- LngLatBounds,
96
- map,
97
- selectedFeatures: [{ featureId: 1, layerId: 'l1' }],
98
- stylesMap: { l1: { stroke: 'red' } }
99
- })
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' } } })
100
82
  expect(map.setFilter).not.toHaveBeenCalledWith(expect.stringContaining('stale'), expect.anything())
101
83
  })
84
+ })
85
+
86
+ describe('Highlighting Utils — layer management', () => {
87
+ let map
88
+
89
+ beforeEach(() => {
90
+ map = makeMap({ _highlightedSources: new Set(['stale']) })
91
+ })
102
92
 
103
93
  test('reuses existing highlight layer; new layer spreads sourceLayer', () => {
104
- // line 50 false: getLayer truthy skip addLayer for s1
105
- // line 55: srcLayer truthy 'source-layer' spread in addLayer for s2
106
- map.getLayer.mockImplementation(id => {
107
- if (id === 'l1') return { source: 's1', type: 'line' }
108
- if (id === 'l2') return { source: 's2', type: 'line', sourceLayer: 'tiles' }
109
- 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 {} }
110
98
  return null
111
99
  })
112
- map.queryRenderedFeatures.mockReturnValue([])
113
- updateHighlightedFeatures({
114
- LngLatBounds,
115
- map,
116
- selectedFeatures: [
117
- { featureId: 1, layerId: 'l1' },
118
- { featureId: 2, layerId: 'l2' }
119
- ],
120
- stylesMap: { l1: { stroke: 'blue' }, l2: { stroke: 'green' } }
121
- })
100
+ updateHighlightedFeatures({ LngLatBounds: lngLatBounds, map, selectedFeatures: [{ featureId: 1, layerId: 'l1' }, { featureId: 2, layerId: 'l2' }], stylesMap: { l1: { stroke: 'blue' }, l2: { stroke: 'green' } } })
122
101
  expect(map.addLayer).toHaveBeenCalledTimes(1)
123
102
  expect(map.addLayer).toHaveBeenCalledWith(expect.objectContaining({ 'source-layer': 'tiles' }))
124
103
  })
125
104
 
126
- test('Empty features coverage', () => {
127
- // Coverage for Line 72: empty renderedFeatures
128
- map.getLayer.mockReturnValue({ source: 's1', type: 'line' })
129
- map.queryRenderedFeatures.mockReturnValue([])
130
- const res = updateHighlightedFeatures({ LngLatBounds, map, selectedFeatures: [], stylesMap: {} })
131
- 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)
132
189
  })
133
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
+ }
@@ -0,0 +1,130 @@
1
+ import { setupHoverCursor } from './hoverCursor.js'
2
+
3
+ const makeMap = (layerTypes = {}, queryResults = []) => ({
4
+ getCanvas: () => ({ style: { cursor: '' } }),
5
+ getLayer: (id) => layerTypes[id] ? { type: layerTypes[id] } : undefined,
6
+ queryRenderedFeatures: jest.fn(() => queryResults),
7
+ on: jest.fn(),
8
+ off: jest.fn()
9
+ })
10
+
11
+ const move = (handler, x = 10, y = 10) =>
12
+ handler({ point: { x, y } })
13
+
14
+ describe('setupHoverCursor', () => {
15
+ /* ------------------------------------------------------------------ */
16
+ /* Setup / teardown */
17
+ /* ------------------------------------------------------------------ */
18
+
19
+ it('returns null and clears cursor when layerIds is empty', () => {
20
+ const map = makeMap()
21
+ const result = setupHoverCursor(map, [], null)
22
+ expect(result).toBeNull()
23
+ expect(map.getCanvas().style.cursor).toBe('')
24
+ expect(map.on).not.toHaveBeenCalled()
25
+ })
26
+
27
+ it('returns null and clears cursor when layerIds is null', () => {
28
+ const map = makeMap()
29
+ const result = setupHoverCursor(map, null, null)
30
+ expect(result).toBeNull()
31
+ expect(map.getCanvas().style.cursor).toBe('')
32
+ })
33
+
34
+ it('removes previous handler before attaching a new one', () => {
35
+ const map = makeMap({ 'layer-a': 'fill' })
36
+ const prev = jest.fn()
37
+ setupHoverCursor(map, ['layer-a'], prev)
38
+ expect(map.off).toHaveBeenCalledWith('mousemove', prev)
39
+ })
40
+
41
+ it('removes previous handler when clearing layers', () => {
42
+ const map = makeMap()
43
+ const prev = jest.fn()
44
+ setupHoverCursor(map, [], prev)
45
+ expect(map.off).toHaveBeenCalledWith('mousemove', prev)
46
+ })
47
+
48
+ it('attaches a mousemove listener and returns the handler', () => {
49
+ const map = makeMap({ 'layer-a': 'fill' })
50
+ const handler = setupHoverCursor(map, ['layer-a'], null)
51
+ expect(typeof handler).toBe('function')
52
+ expect(map.on).toHaveBeenCalledWith('mousemove', handler)
53
+ })
54
+
55
+ /* ------------------------------------------------------------------ */
56
+ /* Mousemove — cursor state */
57
+ /* ------------------------------------------------------------------ */
58
+
59
+ it('sets pointer cursor when a fill layer is hit', () => {
60
+ const canvas = { style: { cursor: '' } }
61
+ const map = { ...makeMap({ 'fill-layer': 'fill' }, [{ id: 'f1' }]), getCanvas: () => canvas }
62
+ const handler = setupHoverCursor(map, ['fill-layer'], null)
63
+ move(handler)
64
+ expect(canvas.style.cursor).toBe('pointer')
65
+ })
66
+
67
+ it('clears cursor when no layers are hit', () => {
68
+ const canvas = { style: { cursor: 'pointer' } }
69
+ const map = { ...makeMap({ 'fill-layer': 'fill' }, []), getCanvas: () => canvas }
70
+ const handler = setupHoverCursor(map, ['fill-layer'], null)
71
+ move(handler)
72
+ expect(canvas.style.cursor).toBe('')
73
+ })
74
+
75
+ it('clears cursor when no registered layers exist on the map', () => {
76
+ const canvas = { style: { cursor: 'pointer' } }
77
+ const map = { ...makeMap({}, []), getCanvas: () => canvas }
78
+ const handler = setupHoverCursor(map, ['missing-layer'], null)
79
+ move(handler)
80
+ expect(canvas.style.cursor).toBe('')
81
+ })
82
+
83
+ /* ------------------------------------------------------------------ */
84
+ /* Line layer tolerance */
85
+ /* ------------------------------------------------------------------ */
86
+
87
+ it('uses bbox query for a pure line layer', () => {
88
+ const map = makeMap({ hedge: 'line' }, [{ id: 'f1' }])
89
+ const handler = setupHoverCursor(map, ['hedge'], null)
90
+ move(handler, 50, 50)
91
+ expect(map.queryRenderedFeatures).toHaveBeenCalledWith(
92
+ [[40, 40], [60, 60]],
93
+ { layers: ['hedge'] }
94
+ )
95
+ })
96
+
97
+ it('sets pointer when line layer is hit via bbox', () => {
98
+ const canvas = { style: { cursor: '' } }
99
+ const map = { ...makeMap({ hedge: 'line' }, [{ id: 'f1' }]), getCanvas: () => canvas }
100
+ const handler = setupHoverCursor(map, ['hedge'], null)
101
+ move(handler)
102
+ expect(canvas.style.cursor).toBe('pointer')
103
+ })
104
+
105
+ /* ------------------------------------------------------------------ */
106
+ /* Stroke + fill companion */
107
+ /* ------------------------------------------------------------------ */
108
+
109
+ it('skips stroke layer when a companion fill layer exists', () => {
110
+ const map = makeMap({ poly: 'fill', 'poly-stroke': 'line' }, [])
111
+ const handler = setupHoverCursor(map, ['poly', 'poly-stroke'], null)
112
+ move(handler)
113
+ // Only the fill layer should be queried (exact point), not the stroke
114
+ expect(map.queryRenderedFeatures).toHaveBeenCalledTimes(1)
115
+ expect(map.queryRenderedFeatures).toHaveBeenCalledWith(
116
+ expect.objectContaining({ x: 10, y: 10 }),
117
+ { layers: ['poly'] }
118
+ )
119
+ })
120
+
121
+ it('does not skip a stroke layer that has no companion fill', () => {
122
+ const map = makeMap({ 'hedge-stroke': 'line' }, [])
123
+ const handler = setupHoverCursor(map, ['hedge-stroke'], null)
124
+ move(handler)
125
+ expect(map.queryRenderedFeatures).toHaveBeenCalledWith(
126
+ expect.any(Array),
127
+ { layers: ['hedge-stroke'] }
128
+ )
129
+ })
130
+ })
@@ -0,0 +1,70 @@
1
+ import { getPatternInnerContent, getPatternImageId, injectColors } from '../../../../src/utils/patternUtils.js'
2
+ import { getValueForStyle } from '../../../../src/utils/getValueForStyle.js'
3
+ import { rasteriseToImageData } from './rasteriseToImageData.js'
4
+
5
+ // Module-level cache: imageId → ImageData. Avoids re-rasterising identical patterns.
6
+ const imageDataCache = new Map()
7
+
8
+ /**
9
+ * Rasterises a dataset's pattern SVG to ImageData, using an in-memory cache
10
+ * to avoid re-rasterising identical patterns.
11
+ *
12
+ * @param {Object} dataset
13
+ * @param {string} mapStyleId
14
+ * @param {Object} patternRegistry
15
+ * @returns {Promise<{ imageId: string, imageData: ImageData }|null>}
16
+ */
17
+ const rasterisePattern = async (dataset, mapStyleId, patternRegistry) => {
18
+ const innerContent = getPatternInnerContent(dataset, patternRegistry)
19
+ if (!innerContent) {
20
+ return null
21
+ }
22
+
23
+ const imageId = getPatternImageId(dataset, mapStyleId, patternRegistry)
24
+ if (!imageId) {
25
+ return null
26
+ }
27
+
28
+ let imageData = imageDataCache.get(imageId)
29
+ if (!imageData) {
30
+ const fg = getValueForStyle(dataset.fillPatternForegroundColor, mapStyleId) || 'black'
31
+ const bg = getValueForStyle(dataset.fillPatternBackgroundColor, mapStyleId) || 'transparent'
32
+ const colored = injectColors(innerContent, fg, bg)
33
+ const bgRect = `<rect width="16" height="16" fill="${bg}"/>`
34
+ // pixelRatio: 2 means the map treats this as an 8×8 logical tile — crisp on retina screens.
35
+ const svgString = `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">${bgRect}${colored}</svg>`
36
+ imageData = await rasteriseToImageData(svgString, 16, 16)
37
+ imageDataCache.set(imageId, imageData)
38
+ }
39
+
40
+ return { imageId, imageData }
41
+ }
42
+
43
+ /**
44
+ * Register pattern images for the given pre-resolved pattern configs.
45
+ * Skips images that are already registered (safe to call on style change).
46
+ * Callers are responsible for sublayer merging before passing configs here
47
+ * (see `getPatternConfigs` in the datasets plugin adapter).
48
+ *
49
+ * @param {Object} map - MapLibre map instance
50
+ * @param {Object[]} patternConfigs - Flat list of datasets/merged-sublayers with a pattern config
51
+ * @param {string} mapStyleId
52
+ * @param {Object} patternRegistry
53
+ * @returns {Promise<void>}
54
+ */
55
+ export const registerPatterns = async (map, patternConfigs, mapStyleId, patternRegistry) => {
56
+ if (!patternConfigs.length) {
57
+ return
58
+ }
59
+
60
+ await Promise.all(patternConfigs.map(async (config) => {
61
+ const imageId = getPatternImageId(config, mapStyleId, patternRegistry)
62
+ if (!imageId || map.hasImage(imageId)) {
63
+ return
64
+ }
65
+ const result = await rasterisePattern(config, mapStyleId, patternRegistry)
66
+ if (result) {
67
+ map.addImage(result.imageId, result.imageData, { pixelRatio: 2 })
68
+ }
69
+ }))
70
+ }