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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (224) hide show
  1. package/assets/images/slot-map.svg +264 -0
  2. package/dist/css/index.css +1 -1
  3. package/dist/esm/im-core.js +1 -1
  4. package/dist/esm/im-shell.js +1 -1
  5. package/dist/umd/im-core.js +1 -1
  6. package/dist/umd/index.js +1 -1
  7. package/docs/api/context.md +53 -7
  8. package/docs/api/map-style-config.md +41 -2
  9. package/docs/api/marker-config.md +53 -11
  10. package/docs/api/slots.md +16 -15
  11. package/docs/api/symbol-config.md +160 -0
  12. package/docs/api/symbol-registry.md +115 -0
  13. package/docs/api.md +25 -22
  14. package/docs/getting-started.md +4 -1
  15. package/docs/plugins/datasets.md +657 -0
  16. package/docs/plugins/interact.md +68 -43
  17. package/docs/plugins/search.md +15 -3
  18. package/docs/plugins.md +1 -1
  19. package/package.json +2 -2
  20. package/plugins/beta/datasets/dist/css/index.css +103 -15
  21. package/plugins/beta/datasets/dist/esm/im-datasets-plugin.js +1 -1
  22. package/plugins/beta/datasets/dist/esm/index.js +1 -1
  23. package/plugins/beta/datasets/dist/umd/im-datasets-plugin.js +1 -1
  24. package/plugins/beta/datasets/dist/umd/index.js +1 -1
  25. package/plugins/beta/datasets/src/DatasetsInit.jsx +29 -9
  26. package/plugins/beta/datasets/src/adapters/maplibre/index.js +18 -0
  27. package/plugins/beta/datasets/src/adapters/maplibre/layerBuilders.js +159 -0
  28. package/plugins/beta/datasets/src/adapters/maplibre/layerIds.js +75 -0
  29. package/plugins/beta/datasets/src/adapters/maplibre/maplibreLayerAdapter.js +440 -0
  30. package/plugins/beta/datasets/src/adapters/maplibre/patternImages.js +27 -0
  31. package/plugins/beta/datasets/src/adapters/maplibre/symbolImages.js +31 -0
  32. package/plugins/beta/datasets/src/api/addDataset.js +2 -8
  33. package/plugins/beta/datasets/src/api/getOpacity.js +17 -0
  34. package/plugins/beta/datasets/src/api/getStyle.js +13 -0
  35. package/plugins/beta/datasets/src/api/removeDataset.js +2 -44
  36. package/plugins/beta/datasets/src/api/setData.js +10 -0
  37. package/plugins/beta/datasets/src/api/setDatasetVisibility.js +37 -0
  38. package/plugins/beta/datasets/src/api/setFeatureVisibility.js +22 -0
  39. package/plugins/beta/datasets/src/api/setOpacity.js +29 -0
  40. package/plugins/beta/datasets/src/api/setStyle.js +22 -0
  41. package/plugins/beta/datasets/src/components/EmptyKey.jsx +7 -0
  42. package/plugins/beta/datasets/src/components/EmptyKey.test.jsx +21 -0
  43. package/plugins/beta/datasets/src/components/KeySvg.jsx +24 -0
  44. package/plugins/beta/datasets/src/components/KeySvgLine.jsx +19 -0
  45. package/plugins/beta/datasets/src/components/KeySvgPattern.jsx +15 -0
  46. package/plugins/beta/datasets/src/components/KeySvgRect.jsx +22 -0
  47. package/plugins/beta/datasets/src/components/KeySvgSymbol.jsx +16 -0
  48. package/plugins/beta/datasets/src/components/svgProperties.js +20 -0
  49. package/plugins/beta/datasets/src/datasets.js +39 -56
  50. package/plugins/beta/datasets/src/defaults.js +44 -8
  51. package/plugins/beta/datasets/src/fetch/createDynamicSource.js +34 -25
  52. package/plugins/beta/datasets/src/fetch/fetchGeoJSON.js +2 -2
  53. package/plugins/beta/datasets/src/index.js +2 -1
  54. package/plugins/beta/datasets/src/manifest.js +25 -17
  55. package/plugins/beta/datasets/src/panels/Key.jsx +51 -51
  56. package/plugins/beta/datasets/src/panels/Key.module.scss +59 -9
  57. package/plugins/beta/datasets/src/panels/Layers.jsx +132 -29
  58. package/plugins/beta/datasets/src/panels/Layers.module.scss +56 -8
  59. package/plugins/beta/datasets/src/reducer.js +134 -9
  60. package/plugins/beta/datasets/src/reducers/keyReducer.js +34 -0
  61. package/plugins/beta/datasets/src/utils/bbox.js +7 -5
  62. package/plugins/beta/datasets/src/utils/filters.js +5 -2
  63. package/plugins/beta/datasets/src/utils/mergeSublayer.js +86 -0
  64. package/plugins/beta/draw-es/dist/esm/im-draw-es-plugin.js +1 -1
  65. package/plugins/beta/draw-es/src/DrawInit.jsx +3 -2
  66. package/plugins/beta/draw-ml/dist/css/index.css +21 -1
  67. package/plugins/beta/draw-ml/dist/esm/im-draw-ml-plugin.js +1 -1
  68. package/plugins/beta/draw-ml/dist/umd/im-draw-ml-plugin.js +1 -1
  69. package/plugins/beta/draw-ml/dist/umd/index.js +1 -1
  70. package/plugins/beta/draw-ml/src/DrawInit.jsx +4 -3
  71. package/plugins/beta/draw-ml/src/draw.scss +0 -7
  72. package/plugins/beta/draw-ml/src/manifest.js +16 -16
  73. package/plugins/beta/frame/dist/esm/im-frame-plugin.js +1 -1
  74. package/plugins/beta/frame/dist/umd/im-frame-plugin.js +1 -1
  75. package/plugins/beta/frame/src/Frame.jsx +5 -5
  76. package/plugins/beta/map-styles/dist/esm/im-map-styles-plugin.js +1 -1
  77. package/plugins/beta/map-styles/dist/umd/im-map-styles-plugin.js +1 -1
  78. package/plugins/beta/map-styles/dist/umd/index.js +1 -1
  79. package/plugins/beta/map-styles/src/MapStyles.jsx +5 -4
  80. package/plugins/beta/map-styles/src/MapStylesInit.jsx +5 -4
  81. package/plugins/beta/map-styles/src/manifest.js +1 -1
  82. package/plugins/beta/scale-bar/dist/css/index.css +1 -1
  83. package/plugins/beta/scale-bar/dist/esm/im-scale-bar-plugin.js +1 -1
  84. package/plugins/beta/scale-bar/dist/umd/im-scale-bar-plugin.js +1 -1
  85. package/plugins/beta/scale-bar/src/index.test.js +3 -3
  86. package/plugins/beta/scale-bar/src/manifest.js +3 -3
  87. package/plugins/beta/scale-bar/src/scaleBar.scss +2 -1
  88. package/plugins/interact/dist/css/index.css +1 -1
  89. package/plugins/interact/dist/esm/im-interact-plugin.js +1 -1
  90. package/plugins/interact/dist/umd/im-interact-plugin.js +1 -1
  91. package/plugins/interact/dist/umd/index.js +1 -1
  92. package/plugins/interact/src/InteractInit.jsx +14 -5
  93. package/plugins/interact/src/InteractInit.test.js +26 -6
  94. package/plugins/interact/src/api/enable.test.js +7 -7
  95. package/plugins/interact/src/defaults.js +4 -6
  96. package/plugins/interact/src/events.js +9 -6
  97. package/plugins/interact/src/events.test.js +28 -4
  98. package/plugins/interact/src/hooks/useHighlightSync.js +3 -3
  99. package/plugins/interact/src/hooks/useHighlightSync.test.js +6 -6
  100. package/plugins/interact/src/hooks/useHoverCursor.js +10 -0
  101. package/plugins/interact/src/hooks/useHoverCursor.test.js +44 -0
  102. package/plugins/interact/src/hooks/useInteractionHandlers.js +111 -69
  103. package/plugins/interact/src/hooks/useInteractionHandlers.test.js +147 -32
  104. package/plugins/interact/src/interact.scss +0 -7
  105. package/plugins/interact/src/manifest.js +14 -18
  106. package/plugins/interact/src/manifest.test.js +3 -1
  107. package/plugins/interact/src/reducer.js +23 -4
  108. package/plugins/interact/src/reducer.test.js +60 -11
  109. package/plugins/interact/src/utils/buildStylesMap.js +17 -4
  110. package/plugins/interact/src/utils/buildStylesMap.test.js +16 -2
  111. package/plugins/interact/src/utils/featureQueries.js +11 -6
  112. package/plugins/interact/src/utils/featureQueries.test.js +8 -1
  113. package/plugins/search/dist/css/index.css +1 -1
  114. package/plugins/search/dist/esm/im-search-plugin.js +1 -1
  115. package/plugins/search/dist/umd/im-search-plugin.js +1 -1
  116. package/plugins/search/src/Search.jsx +3 -1
  117. package/plugins/search/src/components/Form/Form.module.scss +2 -1
  118. package/plugins/search/src/events/fetchSuggestions.js +6 -4
  119. package/plugins/search/src/events/fetchSuggestions.test.js +26 -4
  120. package/plugins/search/src/events/formHandlers.js +3 -3
  121. package/plugins/search/src/events/formHandlers.test.js +1 -1
  122. package/plugins/search/src/events/suggestionHandlers.js +2 -2
  123. package/plugins/search/src/events/suggestionHandlers.test.js +1 -1
  124. package/plugins/search/src/utils/updateMap.js +3 -3
  125. package/plugins/search/src/utils/updateMap.test.js +3 -3
  126. package/providers/maplibre/dist/esm/im-maplibre-provider.js +1 -1
  127. package/providers/maplibre/dist/umd/im-maplibre-framework.js +1 -1
  128. package/providers/maplibre/dist/umd/im-maplibre-framework.js.LICENSE.txt +1 -1
  129. package/providers/maplibre/dist/umd/im-maplibre-provider.js +1 -1
  130. package/providers/maplibre/dist/umd/index.js +1 -1
  131. package/providers/maplibre/src/appEvents.js +7 -0
  132. package/providers/maplibre/src/appEvents.test.js +18 -4
  133. package/providers/maplibre/src/maplibreProvider.js +52 -0
  134. package/providers/maplibre/src/maplibreProvider.test.js +105 -1
  135. package/providers/maplibre/src/utils/highlightFeatures.js +37 -7
  136. package/providers/maplibre/src/utils/highlightFeatures.test.js +153 -95
  137. package/providers/maplibre/src/utils/hoverCursor.js +61 -0
  138. package/providers/maplibre/src/utils/hoverCursor.test.js +130 -0
  139. package/providers/maplibre/src/utils/patternImages.js +70 -0
  140. package/providers/maplibre/src/utils/patternImages.test.js +180 -0
  141. package/providers/maplibre/src/utils/queryFeatures.js +38 -16
  142. package/providers/maplibre/src/utils/queryFeatures.test.js +20 -3
  143. package/providers/maplibre/src/utils/rasteriseToImageData.js +30 -0
  144. package/providers/maplibre/src/utils/rasteriseToImageData.test.js +69 -0
  145. package/providers/maplibre/src/utils/symbolImages.js +147 -0
  146. package/providers/maplibre/src/utils/symbolImages.test.js +248 -0
  147. package/src/App/components/Actions/Actions.jsx +2 -2
  148. package/src/App/components/Actions/Actions.module.scss +0 -7
  149. package/src/App/components/Actions/Actions.test.jsx +1 -1
  150. package/src/App/components/Icon/Icon.jsx +3 -2
  151. package/src/App/components/Icon/Icon.module.scss +4 -0
  152. package/src/App/components/Icon/Icon.test.jsx +43 -4
  153. package/src/App/components/MapButton/MapButton.jsx +42 -17
  154. package/src/App/components/MapButton/MapButton.module.scss +4 -13
  155. package/src/App/components/MapButton/MapButton.test.jsx +27 -3
  156. package/src/App/components/Markers/Markers.jsx +122 -27
  157. package/src/App/components/Markers/Markers.module.scss +0 -10
  158. package/src/App/components/Markers/Markers.test.jsx +246 -0
  159. package/src/App/components/PopupMenu/PopupMenu.jsx +51 -274
  160. package/src/App/components/PopupMenu/PopupMenu.module.scss +14 -7
  161. package/src/App/components/PopupMenu/PopupMenu.test.jsx +70 -1
  162. package/src/App/components/PopupMenu/usePopupMenu.js +258 -0
  163. package/src/App/hooks/useButtonStateEvaluator.js +12 -2
  164. package/src/App/hooks/useButtonStateEvaluator.test.js +38 -4
  165. package/src/App/hooks/useInterfaceAPI.js +6 -0
  166. package/src/App/hooks/useInterfaceAPI.test.js +156 -0
  167. package/src/App/hooks/useLayoutMeasurements.js +84 -18
  168. package/src/App/hooks/useLayoutMeasurements.test.js +124 -17
  169. package/src/App/hooks/useMarkersAPI.js +2 -5
  170. package/src/App/hooks/useMarkersAPI.test.js +4 -4
  171. package/src/App/layout/Layout.jsx +14 -9
  172. package/src/App/layout/Layout.test.jsx +6 -4
  173. package/src/App/layout/layout.module.scss +67 -29
  174. package/src/App/registry/pluginRegistry.js +1 -1
  175. package/src/App/renderer/HtmlElementHost.jsx +2 -1
  176. package/src/App/renderer/HtmlElementHost.test.jsx +7 -7
  177. package/src/App/renderer/mapButtons.js +1 -1
  178. package/src/App/renderer/mapPanels.test.js +2 -2
  179. package/src/App/renderer/slotHelpers.js +2 -2
  180. package/src/App/renderer/slotHelpers.test.js +5 -5
  181. package/src/App/renderer/slots.js +9 -5
  182. package/src/App/store/AppProvider.jsx +3 -1
  183. package/src/App/store/AppProvider.test.jsx +1 -1
  184. package/src/App/store/ServiceProvider.jsx +8 -4
  185. package/src/App/store/appActionsMap.js +16 -0
  186. package/src/App/store/appActionsMap.test.js +27 -0
  187. package/src/App/store/appDispatchMiddleware.js +1 -1
  188. package/src/App/store/appDispatchMiddleware.test.js +2 -2
  189. package/src/App/store/appReducer.js +2 -0
  190. package/src/App/store/mapActionsMap.js +4 -6
  191. package/src/App/store/mapActionsMap.test.js +3 -2
  192. package/src/App/store/mapReducer.js +2 -1
  193. package/src/InteractiveMap/InteractiveMap.js +4 -0
  194. package/src/config/appConfig.js +5 -8
  195. package/src/config/appConfig.test.js +1 -2
  196. package/src/config/defaults.js +0 -2
  197. package/src/config/events.js +28 -0
  198. package/src/config/mapTheme.js +56 -0
  199. package/src/config/patternConfig.js +16 -0
  200. package/src/config/symbolConfig.js +80 -0
  201. package/src/scss/main.scss +1 -0
  202. package/src/scss/settings/_colors.scss +0 -9
  203. package/src/scss/settings/_dimensions.scss +0 -1
  204. package/src/services/patternRegistry.js +40 -0
  205. package/src/services/patternRegistry.test.js +48 -0
  206. package/src/services/symbolRegistry.js +113 -0
  207. package/src/services/symbolRegistry.test.js +262 -0
  208. package/src/types.js +93 -11
  209. package/src/utils/getSafeZoneInset.js +9 -7
  210. package/src/utils/getSafeZoneInset.test.js +10 -10
  211. package/src/utils/patternUtils.js +94 -0
  212. package/src/utils/patternUtils.test.js +160 -0
  213. package/src/utils/symbolUtils.js +85 -0
  214. package/src/utils/symbolUtils.test.js +156 -0
  215. package/webpack.dev.mjs +1 -1
  216. package/docs/api/slot-map.svg +0 -1
  217. package/plugins/beta/datasets/src/api/hideDataset.js +0 -14
  218. package/plugins/beta/datasets/src/api/hideFeatures.js +0 -41
  219. package/plugins/beta/datasets/src/api/showDataset.js +0 -14
  220. package/plugins/beta/datasets/src/api/showFeatures.js +0 -44
  221. package/plugins/beta/datasets/src/handleSetMapStyle.js +0 -54
  222. package/plugins/beta/datasets/src/mapLayers.js +0 -164
  223. /package/src/{utils → services}/logger.js +0 -0
  224. /package/src/{utils → services}/logger.test.js +0 -0
@@ -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
+ }
@@ -0,0 +1,180 @@
1
+ import { registerPatterns } from './patternImages.js'
2
+
3
+ const OUTDOOR = 'outdoor'
4
+
5
+ const SVG_CONTENT = '<path d="M0 0 L8 8"/>'
6
+
7
+ beforeAll(() => {
8
+ globalThis.URL.createObjectURL = jest.fn(() => 'blob:mock')
9
+ globalThis.URL.revokeObjectURL = jest.fn()
10
+
11
+ HTMLCanvasElement.prototype.getContext = jest.fn(() => ({
12
+ drawImage: jest.fn(),
13
+ getImageData: jest.fn((_x, _y, w, h) => ({ width: w, height: h }))
14
+ }))
15
+
16
+ globalThis.Image = class {
17
+ constructor (w, h) {
18
+ this.width = w
19
+ this.height = h
20
+ this._src = ''
21
+ }
22
+
23
+ get src () { return this._src }
24
+ set src (val) { this._src = val; this.onload?.() }
25
+ }
26
+ })
27
+
28
+ const makeMap = (existingIds = []) => ({
29
+ hasImage: jest.fn((id) => existingIds.includes(id)),
30
+ addImage: jest.fn()
31
+ })
32
+
33
+ const makePatternRegistry = (id = 'stripes', content = SVG_CONTENT) => ({
34
+ get: jest.fn((name) => name === id ? { svgContent: content } : undefined)
35
+ })
36
+
37
+ // ─── registerPatterns ─────────────────────────────────────────────────────────
38
+
39
+ describe('registerPatterns — registration', () => {
40
+ it('returns early and does not touch map for empty configs', async () => {
41
+ const map = makeMap()
42
+ const registry = makePatternRegistry()
43
+ await registerPatterns(map, [], OUTDOOR, registry)
44
+ expect(map.hasImage).not.toHaveBeenCalled()
45
+ expect(map.addImage).not.toHaveBeenCalled()
46
+ })
47
+
48
+ it('calls addImage with pixelRatio 2 for a named pattern', async () => {
49
+ const map = makeMap()
50
+ const registry = makePatternRegistry()
51
+ const config = { fillPattern: 'stripes' }
52
+ await registerPatterns(map, [config], OUTDOOR, registry)
53
+ expect(map.addImage).toHaveBeenCalledTimes(1)
54
+ expect(map.addImage).toHaveBeenCalledWith(
55
+ expect.stringMatching(/^pattern-[a-z0-9]+$/),
56
+ expect.any(Object),
57
+ { pixelRatio: 2 }
58
+ )
59
+ })
60
+
61
+ it('calls addImage for an inline fillPatternSvgContent config', async () => {
62
+ const map = makeMap()
63
+ const registry = makePatternRegistry()
64
+ const config = { fillPatternSvgContent: SVG_CONTENT }
65
+ await registerPatterns(map, [config], OUTDOOR, registry)
66
+ expect(map.addImage).toHaveBeenCalledTimes(1)
67
+ })
68
+
69
+ it('skips addImage when image is already registered', async () => {
70
+ const registry = makePatternRegistry()
71
+ const config = { fillPattern: 'stripes' }
72
+ const { getPatternImageId } = await import('../../../../src/utils/patternUtils.js')
73
+ const existingId = getPatternImageId(config, OUTDOOR, registry)
74
+ const map = makeMap([existingId])
75
+ await registerPatterns(map, [config], OUTDOOR, registry)
76
+ expect(map.addImage).not.toHaveBeenCalled()
77
+ })
78
+
79
+ it('skips config when pattern has no inner content', async () => {
80
+ const map = makeMap()
81
+ const emptyRegistry = { get: jest.fn(() => undefined) }
82
+ await registerPatterns(map, [{ fillPattern: 'unknown' }], OUTDOOR, emptyRegistry)
83
+ expect(map.addImage).not.toHaveBeenCalled()
84
+ })
85
+
86
+ it('skips config when neither fillPattern nor fillPatternSvgContent is set', async () => {
87
+ const map = makeMap()
88
+ const registry = makePatternRegistry()
89
+ await registerPatterns(map, [{ fillColor: '#ff0000' }], OUTDOOR, registry)
90
+ expect(map.addImage).not.toHaveBeenCalled()
91
+ })
92
+
93
+ it('processes multiple configs in parallel', async () => {
94
+ const map = makeMap()
95
+ const registry = {
96
+ get: jest.fn((name) => {
97
+ if (name === 'stripes') { return { svgContent: '<path d="M0 0"/>' } }
98
+ if (name === 'dots') { return { svgContent: '<circle cx="8" cy="8" r="4"/>' } }
99
+ return undefined
100
+ })
101
+ }
102
+ await registerPatterns(map, [{ fillPattern: 'stripes' }, { fillPattern: 'dots' }], OUTDOOR, registry)
103
+ expect(map.addImage).toHaveBeenCalledTimes(2)
104
+ })
105
+ })
106
+
107
+ describe('registerPatterns — color resolution and caching', () => {
108
+ it('applies foreground and background colors when resolving the SVG', async () => {
109
+ const map = makeMap()
110
+ const registry = makePatternRegistry()
111
+ const getContextSpy = HTMLCanvasElement.prototype.getContext
112
+ await registerPatterns(
113
+ map,
114
+ [{ fillPattern: 'stripes', fillPatternForegroundColor: '#aabbcc', fillPatternBackgroundColor: '#112233' }],
115
+ OUTDOOR,
116
+ registry
117
+ )
118
+ expect(map.addImage).toHaveBeenCalledTimes(1)
119
+ expect(getContextSpy).toHaveBeenCalled()
120
+ })
121
+
122
+ it('resolves style-keyed foreground color for the given mapStyleId', async () => {
123
+ const map = makeMap()
124
+ const registry = makePatternRegistry()
125
+ const config = {
126
+ fillPattern: 'stripes',
127
+ fillPatternForegroundColor: { outdoor: '#aabbcc', dark: '#112233' }
128
+ }
129
+ await registerPatterns(map, [config], OUTDOOR, registry)
130
+ const map2 = makeMap()
131
+ await registerPatterns(map2, [config], 'dark', registry)
132
+ const [idOutdoor] = map.addImage.mock.calls[0]
133
+ const [idDark] = map2.addImage.mock.calls[0]
134
+ expect(idOutdoor).not.toBe(idDark)
135
+ })
136
+
137
+ it('uses cached ImageData on second call with identical config', async () => {
138
+ const map = makeMap()
139
+ const registry = makePatternRegistry()
140
+ const config = { fillPattern: 'stripes', fillPatternForegroundColor: '#unique1' }
141
+ await registerPatterns(map, [config], OUTDOOR, registry)
142
+ const { getPatternImageId } = await import('../../../../src/utils/patternUtils.js')
143
+ const imageId = getPatternImageId(config, OUTDOOR, registry)
144
+ const map2 = makeMap([imageId])
145
+ await registerPatterns(map2, [config], OUTDOOR, registry)
146
+ expect(map2.addImage).not.toHaveBeenCalled()
147
+ })
148
+ })
149
+
150
+ describe('registerPatterns — null results', () => {
151
+ it('does not call addImage when innerContent becomes unavailable inside rasterisePattern', async () => {
152
+ // registry.get returns content on the first call (for getPatternImageId in registerPatterns)
153
+ // but undefined on the second call (for getPatternInnerContent inside rasterisePattern)
154
+ const registry = {
155
+ get: jest.fn()
156
+ .mockReturnValueOnce({ svgContent: SVG_CONTENT })
157
+ .mockReturnValueOnce(undefined)
158
+ }
159
+ const map = makeMap()
160
+ await registerPatterns(map, [{ fillPattern: 'stripes' }], OUTDOOR, registry)
161
+ expect(map.addImage).not.toHaveBeenCalled()
162
+ })
163
+
164
+ it('does not call addImage when imageId becomes unavailable inside rasterisePattern', async () => {
165
+ // Three consecutive calls to registry.get:
166
+ // 1. getPatternImageId in registerPatterns → returns content → imageId is truthy
167
+ // 2. getPatternInnerContent directly in rasterisePattern → returns content → passes innerContent guard
168
+ // 3. getPatternInnerContent inside getPatternImageId in rasterisePattern → returns undefined
169
+ // → getPatternImageId returns null → hits the imageId null guard
170
+ const registry = {
171
+ get: jest.fn()
172
+ .mockReturnValueOnce({ svgContent: SVG_CONTENT })
173
+ .mockReturnValueOnce({ svgContent: SVG_CONTENT })
174
+ .mockReturnValueOnce(undefined)
175
+ }
176
+ const map = makeMap()
177
+ await registerPatterns(map, [{ fillPattern: 'stripes' }], OUTDOOR, registry)
178
+ expect(map.addImage).not.toHaveBeenCalled()
179
+ })
180
+ })
@@ -83,12 +83,23 @@ const getMinDistToGeometry = (map, point, geometry) => {
83
83
  */
84
84
  export const queryFeatures = (map, point, options = {}) => {
85
85
  const { radius = 10 } = options
86
- const queryArea = [[point.x - radius, point.y - radius], [point.x + radius, point.y + radius]]
87
- const rawFeatures = map.queryRenderedFeatures(queryArea)
86
+
87
+ const bbox = [[point.x - radius, point.y - radius], [point.x + radius, point.y + radius]]
88
+ const rawFeatures = map.queryRenderedFeatures(bbox)
88
89
  if (rawFeatures.length === 0) {
89
90
  return []
90
91
  }
91
92
 
93
+ // For symbol/point features, tolerance must not apply — selection should only
94
+ // fire when the click lands within the rendered icon bounds. An exact point
95
+ // query uses MapLibre's own icon hit-testing, mirroring the hover cursor behaviour.
96
+ const exactFeatureKeys = new Set(
97
+ map.queryRenderedFeatures([point.x, point.y]).map(f => {
98
+ const rawId = f.id === undefined ? JSON.stringify(f.properties) : f.id
99
+ return `${f.layer?.source}:${rawId}`
100
+ })
101
+ )
102
+
92
103
  // Identify layer visual hierarchy
93
104
  const layerStack = []
94
105
  rawFeatures.forEach(f => {
@@ -97,12 +108,15 @@ export const queryFeatures = (map, point, options = {}) => {
97
108
  }
98
109
  })
99
110
 
100
- // Deduplicate Bottom-Up to favor data layers over highlight layers
111
+ // Deduplicate Bottom-Up to favor data layers over highlight layers.
112
+ // Key includes source ID to prevent collisions between features from different
113
+ // sources that share the same numeric ID (e.g. generateId: true resets per source).
101
114
  const seenIds = new Set()
102
115
  const uniqueFeatures = []
103
116
  for (let i = rawFeatures.length - 1; i >= 0; i--) {
104
117
  const f = rawFeatures[i]
105
- const featureId = f.id === undefined ? JSON.stringify(f.properties) : f.id
118
+ const rawId = f.id === undefined ? JSON.stringify(f.properties) : f.id
119
+ const featureId = `${f.layer?.source}:${rawId}`
106
120
  if (seenIds.has(featureId) === false) {
107
121
  seenIds.add(featureId)
108
122
  uniqueFeatures.push(f)
@@ -112,7 +126,24 @@ export const queryFeatures = (map, point, options = {}) => {
112
126
  const clickLngLat = map.unproject(point)
113
127
  const clickPt = [clickLngLat.lng, clickLngLat.lat]
114
128
 
115
- return uniqueFeatures
129
+ // Discard features where tolerance should not apply:
130
+ // - Polygons: only include if click is geometrically inside
131
+ // - Points/symbols: only include if under the exact click point (respects icon bounds)
132
+ // - Lines: allowed through — tolerance bbox is intentional for them
133
+ const candidates = uniqueFeatures.filter((f) => {
134
+ const type = f.geometry.type
135
+ if (type.includes('Polygon')) {
136
+ const polys = type === 'Polygon' ? [f.geometry.coordinates] : f.geometry.coordinates
137
+ return polys.some((ring) => isPointInPolygon(clickPt, ring[0]))
138
+ }
139
+ if (type === 'Point' || type === 'MultiPoint') {
140
+ const rawId = f.id === undefined ? JSON.stringify(f.properties) : f.id
141
+ return exactFeatureKeys.has(`${f.layer?.source}:${rawId}`)
142
+ }
143
+ return true
144
+ })
145
+
146
+ return candidates
116
147
  .map((f) => {
117
148
  let score = 0
118
149
  const type = f.geometry.type
@@ -122,18 +153,9 @@ export const queryFeatures = (map, point, options = {}) => {
122
153
  const layerRank = layerStack.indexOf(f.layer.id)
123
154
  score += (layerRank * 1000000)
124
155
 
125
- // PRIORITY 2: CONTAINMENT (Polygon Special Treatment)
156
+ // PRIORITY 2: POLYGON BOOST (already filtered to inside-only)
126
157
  if (type.includes('Polygon')) {
127
- const polys = type === 'Polygon' ? [f.geometry.coordinates] : f.geometry.coordinates
128
- const isInside = polys.some((ring) => isPointInPolygon(clickPt, ring[0]))
129
-
130
- if (isInside === true) {
131
- // Massive boost for polygons if we are actually inside them
132
- score -= 500000 // NOSONAR - tolerance used only here
133
- } else {
134
- // If we are outside a polygon, it loses significantly to anything we ARE inside
135
- score += 100000 // NOSONAR - tolerance used only here
136
- }
158
+ score -= 500000 // NOSONAR
137
159
  }
138
160
 
139
161
  // PRIORITY 3: DISTANCE (Final Tie-breaker)
@@ -20,7 +20,7 @@ describe('queryFeatures coverage', () => {
20
20
  { type: 'MultiPoint', coords: [[0, 0], [10, 10]], p: { x: 1, y: 0 } },
21
21
  { type: 'MultiLineString', coords: [[[0, 0], [10, 0]]], p: { x: 5, y: 1 } },
22
22
  { type: 'Polygon', coords: [[[0, 0], [10, 0], [10, 10], [0, 10], [0, 0]]], p: { x: 5, y: 5 } }, // Inside
23
- { type: 'MultiPolygon', coords: [[[[0, 0], [10, 0], [10, 10], [0, 0]]]], p: { x: 20, y: 20 } }, // Outside
23
+ { type: 'MultiPolygon', coords: [[[[0, 0], [10, 0], [10, 10], [0, 0]]]], p: { x: 5, y: 3 } }, // Inside
24
24
  { type: 'Unknown', coords: [], p: { x: 0, y: 0 } }
25
25
  ]
26
26
 
@@ -49,12 +49,29 @@ describe('queryFeatures coverage', () => {
49
49
  expect(result.length).toBe(2)
50
50
  expect(result[0].layer.id).toBe('layer-A') // Sorted by layerStack index
51
51
 
52
- // 4. Hit ray-casting intersect logic (Line 42 branch)
52
+ // 4. Hit ray-casting intersect logic point inside the polygon
53
53
  const polyFeat = {
54
54
  layer: { id: 'L' },
55
55
  geometry: { type: 'Polygon', coordinates: [[[0, 0], [10, 10], [0, 10], [0, 0]]] }
56
56
  }
57
57
  const rayMap = { ...mockMap, queryRenderedFeatures: () => [polyFeat] }
58
- expect(queryFeatures(rayMap, { x: -1, y: 5 }).length).toBe(1)
58
+ expect(queryFeatures(rayMap, { x: 2, y: 8 }).length).toBe(1)
59
+
60
+ // 5. Outside polygon is filtered out (tolerance only applies to lines)
61
+ const outsideMap = { ...mockMap, queryRenderedFeatures: () => [polyFeat] }
62
+ expect(queryFeatures(outsideMap, { x: -1, y: 5 }).length).toBe(0)
63
+
64
+ // 6. Symbol under exact click point is included
65
+ const symbolFeat = { id: 'sym', layer: { id: 'S', source: 'src' }, geometry: { type: 'Point', coordinates: [0, 0] } }
66
+ const symbolMap = { ...mockMap, queryRenderedFeatures: () => [symbolFeat] } // both calls return it
67
+ expect(queryFeatures(symbolMap, { x: 5, y: 5 }).length).toBe(1)
68
+
69
+ // 7. Symbol NOT under exact click point is filtered out
70
+ let call = 0
71
+ const symbolMissMap = {
72
+ ...mockMap,
73
+ queryRenderedFeatures: () => call++ === 0 ? [symbolFeat] : [] // bbox returns it, exact does not
74
+ }
75
+ expect(queryFeatures(symbolMissMap, { x: 5, y: 5 }).length).toBe(0)
59
76
  })
60
77
  })
@@ -0,0 +1,30 @@
1
+ const SVG_ERROR_PREVIEW_LENGTH = 80
2
+
3
+ /**
4
+ * Rasterises an SVG string to an ImageData object via a canvas.
5
+ *
6
+ * @param {string} svgString - Full SVG markup to render
7
+ * @param {number} width - Canvas width in pixels
8
+ * @param {number} height - Canvas height in pixels
9
+ * @returns {Promise<ImageData>}
10
+ */
11
+ export const rasteriseToImageData = (svgString, width, height) =>
12
+ new Promise((resolve, reject) => {
13
+ const blob = new Blob([svgString], { type: 'image/svg+xml' })
14
+ const url = URL.createObjectURL(blob)
15
+ const img = new Image(width, height)
16
+ img.onload = () => {
17
+ const canvas = document.createElement('canvas')
18
+ canvas.width = width
19
+ canvas.height = height
20
+ const ctx = canvas.getContext('2d')
21
+ ctx.drawImage(img, 0, 0, width, height)
22
+ URL.revokeObjectURL(url)
23
+ resolve(ctx.getImageData(0, 0, width, height))
24
+ }
25
+ img.onerror = () => {
26
+ URL.revokeObjectURL(url)
27
+ reject(new Error(`Failed to rasterise SVG: ${svgString.slice(0, SVG_ERROR_PREVIEW_LENGTH)}`))
28
+ }
29
+ img.src = url
30
+ })
@@ -0,0 +1,69 @@
1
+ import { rasteriseToImageData } from './rasteriseToImageData.js'
2
+
3
+ const SVG = '<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32"><circle cx="16" cy="16" r="8"/></svg>'
4
+ const WIDTH = 32
5
+ const HEIGHT = 32
6
+
7
+ // Mirrors SVG_ERROR_PREVIEW_LENGTH in rasteriseToImageData.js
8
+ const ERROR_PREVIEW_LENGTH = 80
9
+ // Length chosen to be well over ERROR_PREVIEW_LENGTH so truncation is exercised
10
+ const LONG_CONTENT_LENGTH = 200
11
+
12
+ beforeAll(() => {
13
+ globalThis.URL.createObjectURL = jest.fn(() => 'blob:mock')
14
+ globalThis.URL.revokeObjectURL = jest.fn()
15
+
16
+ HTMLCanvasElement.prototype.getContext = jest.fn(() => ({
17
+ drawImage: jest.fn(),
18
+ getImageData: jest.fn((_x, _y, w, h) => ({ width: w, height: h }))
19
+ }))
20
+
21
+ globalThis.Image = class {
22
+ constructor (w, h) {
23
+ this.width = w
24
+ this.height = h
25
+ this._src = ''
26
+ }
27
+
28
+ get src () { return this._src }
29
+ set src (val) { this._src = val; this.onload?.() }
30
+ }
31
+ })
32
+
33
+ beforeEach(() => {
34
+ jest.clearAllMocks()
35
+ globalThis.URL.createObjectURL.mockReturnValue('blob:mock')
36
+ })
37
+
38
+ describe('rasteriseToImageData', () => {
39
+ it('resolves with ImageData at the requested dimensions, draws via canvas, and revokes the blob URL', async () => {
40
+ const getContext = HTMLCanvasElement.prototype.getContext
41
+ const result = await rasteriseToImageData(SVG, WIDTH, HEIGHT)
42
+ expect(result).toMatchObject({ width: WIDTH, height: HEIGHT })
43
+ expect(globalThis.URL.createObjectURL).toHaveBeenCalledWith(expect.any(Blob))
44
+ expect(globalThis.URL.revokeObjectURL).toHaveBeenCalledWith('blob:mock')
45
+ const { drawImage, getImageData } = getContext.mock.results[0].value
46
+ expect(drawImage).toHaveBeenCalledWith(expect.any(Object), 0, 0, WIDTH, HEIGHT)
47
+ expect(getImageData).toHaveBeenCalledWith(0, 0, WIDTH, HEIGHT)
48
+ })
49
+
50
+ it('rejects with a truncated SVG preview and revokes the blob URL on error', async () => {
51
+ const originalImage = globalThis.Image
52
+ globalThis.Image = class {
53
+ constructor (w, h) { this.width = w; this.height = h; this._src = '' }
54
+ get src () { return this._src }
55
+ set src (val) { this._src = val; this.onerror?.() }
56
+ }
57
+ try {
58
+ const longSvg = `<svg>${'x'.repeat(LONG_CONTENT_LENGTH)}</svg>`
59
+ const error = await rasteriseToImageData(longSvg, WIDTH, HEIGHT).catch(e => e)
60
+ expect(error.message).toMatch('Failed to rasterise SVG')
61
+ const preview = error.message.replace('Failed to rasterise SVG: ', '')
62
+ expect(preview).toHaveLength(ERROR_PREVIEW_LENGTH)
63
+ expect(preview).toBe(longSvg.slice(0, ERROR_PREVIEW_LENGTH))
64
+ expect(globalThis.URL.revokeObjectURL).toHaveBeenCalledWith('blob:mock')
65
+ } finally {
66
+ globalThis.Image = originalImage
67
+ }
68
+ })
69
+ })