@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
@@ -23,7 +23,7 @@ describe('useHighlightSync', () => {
23
23
  },
24
24
  mapStyle: { id: 'default-style' },
25
25
  pluginState: {
26
- dataLayers: [{ layerId: 'layer1' }]
26
+ layers: [{ layerId: 'layer1' }]
27
27
  },
28
28
  selectedFeatures: [],
29
29
  dispatch: jest.fn(),
@@ -93,21 +93,21 @@ describe('useHighlightSync', () => {
93
93
  )
94
94
  })
95
95
 
96
- it('rebuilds styles when dataLayers change', () => {
96
+ it('rebuilds styles when layers change', () => {
97
97
  mockDeps.selectedFeatures = [{ featureId: 'F1' }]
98
98
 
99
99
  const { rerender } = renderHook(
100
- ({ dataLayers }) =>
100
+ ({ layers }) =>
101
101
  useHighlightSync({
102
102
  ...mockDeps,
103
- pluginState: { dataLayers }
103
+ pluginState: { layers }
104
104
  }),
105
- { initialProps: { dataLayers: [{ layerId: 'layer1' }] } }
105
+ { initialProps: { layers: [{ layerId: 'layer1' }] } }
106
106
  )
107
107
 
108
108
  buildStylesMap.mockClear()
109
109
 
110
- rerender({ dataLayers: [{ layerId: 'layer1' }, { layerId: 'layer2' }] })
110
+ rerender({ layers: [{ layerId: 'layer1' }, { layerId: 'layer2' }] })
111
111
 
112
112
  expect(buildStylesMap).toHaveBeenCalled()
113
113
  })
@@ -0,0 +1,10 @@
1
+ import { useEffect } from 'react'
2
+
3
+ export const useHoverCursor = (mapProvider, enabled, interactionModes, layers) => {
4
+ useEffect(() => {
5
+ const canSelect = enabled && interactionModes?.includes('selectFeature')
6
+ const layerIds = canSelect ? layers.map(l => l.layerId) : []
7
+ mapProvider.setHoverCursor?.(layerIds)
8
+ return () => mapProvider.setHoverCursor?.([])
9
+ }, [enabled, interactionModes, layers])
10
+ }
@@ -0,0 +1,44 @@
1
+ import { renderHook } from '@testing-library/react'
2
+ import { useHoverCursor } from './useHoverCursor.js'
3
+
4
+ const makeProvider = () => ({ setHoverCursor: jest.fn() })
5
+
6
+ describe('useHoverCursor', () => {
7
+ const dataLayers = [{ layerId: 'layer-a' }, { layerId: 'layer-b' }]
8
+
9
+ it('calls setHoverCursor with layer IDs when enabled with selectFeature mode', () => {
10
+ const mapProvider = makeProvider()
11
+ renderHook(() => useHoverCursor(mapProvider, true, ['selectFeature'], dataLayers))
12
+ expect(mapProvider.setHoverCursor).toHaveBeenCalledWith(['layer-a', 'layer-b'])
13
+ })
14
+
15
+ it('calls setHoverCursor with layer IDs when selectFeature is combined with other interactionModes', () => {
16
+ const mapProvider = makeProvider()
17
+ renderHook(() => useHoverCursor(mapProvider, true, ['selectMarker', 'selectFeature'], dataLayers))
18
+ expect(mapProvider.setHoverCursor).toHaveBeenCalledWith(['layer-a', 'layer-b'])
19
+ })
20
+
21
+ it('calls setHoverCursor with empty array when disabled', () => {
22
+ const mapProvider = makeProvider()
23
+ renderHook(() => useHoverCursor(mapProvider, false, ['selectFeature'], dataLayers))
24
+ expect(mapProvider.setHoverCursor).toHaveBeenCalledWith([])
25
+ })
26
+
27
+ it('calls setHoverCursor with empty array when selectFeature is not in interactionModes', () => {
28
+ const mapProvider = makeProvider()
29
+ renderHook(() => useHoverCursor(mapProvider, true, ['selectMarker', 'placeMarker'], dataLayers))
30
+ expect(mapProvider.setHoverCursor).toHaveBeenCalledWith([])
31
+ })
32
+
33
+ it('clears cursor on unmount', () => {
34
+ const mapProvider = makeProvider()
35
+ const { unmount } = renderHook(() => useHoverCursor(mapProvider, true, ['selectFeature'], dataLayers))
36
+ mapProvider.setHoverCursor.mockClear()
37
+ unmount()
38
+ expect(mapProvider.setHoverCursor).toHaveBeenCalledWith([])
39
+ })
40
+
41
+ it('does not throw when setHoverCursor is absent', () => {
42
+ expect(() => renderHook(() => useHoverCursor({}, true, ['selectFeature'], dataLayers))).not.toThrow()
43
+ })
44
+ })
@@ -1,8 +1,42 @@
1
1
  import { useCallback, useEffect, useRef } from 'react'
2
2
  import { isContiguousWithAny, canSplitFeatures, areAllContiguous } from '../utils/spatial.js'
3
3
  import { getFeaturesAtPoint, findMatchingFeature, buildLayerConfigMap } from '../utils/featureQueries.js'
4
+ import { scaleFactor } from '../../../../src/config/appConfig.js'
5
+
6
+ /**
7
+ * Returns the id of the first DOM marker whose visual bounds contain the given point.
8
+ *
9
+ * MAP_CLICK point is container-relative; getBoundingClientRect is viewport-relative.
10
+ * We convert by subtracting the parent element's top-left (markers share a parent with
11
+ * the map container, so parentElement.getBoundingClientRect() gives the offset).
12
+ *
13
+ * @param {Object} markers - markers object from mapState (has .items and .markerRefs)
14
+ * @param {{ x: number, y: number }} point - container-relative pixel coordinates
15
+ * @param {number} scale - scaleFactor for the current mapSize (e.g. 1.5 for medium)
16
+ * @returns {string|null}
17
+ */
18
+ const findMarkerAtPoint = (markers, point, scale) => {
19
+ for (const marker of markers.items) {
20
+ const el = markers.markerRefs?.get(marker.id)
21
+ if (!el) {
22
+ continue
23
+ }
24
+ const parent = el.parentElement
25
+ const parentRect = parent ? parent.getBoundingClientRect() : { left: 0, top: 0 }
26
+ const { left, top, right, bottom } = el.getBoundingClientRect()
27
+ const scaledX = point.x * scale
28
+ const scaledY = point.y * scale
29
+ if (
30
+ scaledX >= left - parentRect.left && scaledX <= right - parentRect.left &&
31
+ scaledY >= top - parentRect.top && scaledY <= bottom - parentRect.top
32
+ ) {
33
+ return marker.id
34
+ }
35
+ }
36
+ return null
37
+ }
4
38
 
5
- const useSelectionChangeEmitter = (eventBus, selectedFeatures, selectionBounds) => {
39
+ const useSelectionChangeEmitter = (eventBus, selectedFeatures, selectedMarkers, selectionBounds) => {
6
40
  const lastEmittedSelectionChange = useRef(null)
7
41
 
8
42
  useEffect(() => {
@@ -14,105 +48,113 @@ const useSelectionChangeEmitter = (eventBus, selectedFeatures, selectionBounds)
14
48
 
15
49
  // Skip if selection was already empty and remains empty
16
50
  const prev = lastEmittedSelectionChange.current
17
- const wasEmpty = prev === null || prev.length === 0
18
- if (wasEmpty && selectedFeatures.length === 0) {
51
+ const wasEmpty = prev === null || (prev.features.length === 0 && prev.markers.length === 0)
52
+ if (wasEmpty && selectedFeatures.length === 0 && selectedMarkers.length === 0) {
19
53
  return
20
54
  }
21
55
 
22
56
  eventBus.emit('interact:selectionchange', {
23
57
  selectedFeatures,
58
+ selectedMarkers,
24
59
  selectionBounds,
25
60
  canMerge: areAllContiguous(selectedFeatures),
26
61
  canSplit: canSplitFeatures(selectedFeatures)
27
62
  })
28
63
 
29
- lastEmittedSelectionChange.current = selectedFeatures
30
- }, [selectedFeatures, selectionBounds])
64
+ lastEmittedSelectionChange.current = { features: selectedFeatures, markers: selectedMarkers }
65
+ }, [selectedFeatures, selectedMarkers, selectionBounds])
31
66
  }
32
67
 
33
- export const useInteractionHandlers = ({
34
- mapState,
35
- pluginState,
36
- services,
37
- mapProvider
38
- }) => {
39
- const { markers } = mapState
40
- const { dispatch, dataLayers, interactionMode, multiSelect, contiguous, markerColor, tolerance, selectedFeatures, selectionBounds, deselectOnClickOutside } = pluginState
68
+ /**
69
+ * Core interaction hook. Processes map clicks in fixed priority order:
70
+ * selectMarker → selectFeature → placeMarker (fallback).
71
+ *
72
+ * Which steps are active is controlled by `pluginState.interactionModes`. Steps not
73
+ * present in the array are skipped entirely — e.g. omitting `'selectMarker'` means
74
+ * marker hit-testing is never performed.
75
+ *
76
+ * @param {Object} deps
77
+ * @param {Object} deps.mapState - Map state including markers and mapSize
78
+ * @param {Object} deps.pluginState - Plugin state including interactionModes, layers, etc.
79
+ * @param {Object} deps.services - Services including eventBus
80
+ * @param {Object} deps.mapProvider - Map provider instance for feature queries
81
+ * @returns {{ handleInteraction: Function }}
82
+ */
83
+ export const useInteractionHandlers = ({ mapState, pluginState, services, mapProvider }) => {
84
+ const { markers, mapSize } = mapState
85
+ const { dispatch, layers, interactionModes, multiSelect, contiguous, marker: markerOptions, tolerance, selectedFeatures, selectedMarkers, selectionBounds, deselectOnClickOutside } = pluginState
41
86
  const { eventBus } = services
42
- const layerConfigMap = buildLayerConfigMap(dataLayers)
43
-
44
- const handleInteraction = useCallback(({ point, coords }) => {
45
- const allFeatures = getFeaturesAtPoint(mapProvider, point, { radius: tolerance })
46
- const hasDataLayers = dataLayers.length > 0
47
-
48
- if (pluginState?.debug) {
49
- console.log(`--- Features at ${coords} ---`, allFeatures)
87
+ const layerConfigMap = buildLayerConfigMap(layers)
88
+ const scale = scaleFactor[mapSize] ?? 1
89
+ const processFeatureMatch = useCallback(({ feature, config }) => {
90
+ markers.remove('location')
91
+ const isNewContiguous = contiguous && isContiguousWithAny(feature, selectedFeatures)
92
+ const featureId = feature.properties?.[config.idProperty] ?? feature.id
93
+ if (featureId == null) {
94
+ return
50
95
  }
96
+ dispatch({
97
+ type: 'TOGGLE_SELECTED_FEATURES',
98
+ payload: {
99
+ featureId,
100
+ multiSelect,
101
+ layerId: config.layerId,
102
+ idProperty: config.idProperty,
103
+ properties: feature.properties,
104
+ geometry: feature.geometry,
105
+ replaceAll: contiguous && !isNewContiguous
106
+ }
107
+ })
108
+ }, [markers, contiguous, selectedFeatures, dispatch, multiSelect])
51
109
 
52
- const canMatch = hasDataLayers && (interactionMode === 'select' || interactionMode === 'auto')
53
- const match = canMatch ? findMatchingFeature(allFeatures, layerConfigMap) : null
54
-
55
- // 1. Handle Feature Match
56
- if (match) {
57
- processFeatureMatch(match)
110
+ const processFallback = useCallback(({ coords }) => {
111
+ const canPlace = interactionModes.includes('placeMarker')
112
+ if (!canPlace && !deselectOnClickOutside) {
58
113
  return
59
114
  }
60
-
61
- // 2. Handle Marker Mode (Fallback)
62
- const isMarkerMode = interactionMode === 'marker' || (interactionMode === 'auto' && hasDataLayers)
63
- if (isMarkerMode) {
64
- dispatch({ type: 'CLEAR_SELECTED_FEATURES' })
65
- markers.add('location', coords, { color: markerColor })
115
+ dispatch({ type: 'CLEAR_SELECTED_FEATURES' })
116
+ if (canPlace) {
117
+ markers.add('location', coords, markerOptions)
66
118
  eventBus.emit('interact:markerchange', { coords })
67
- } else if (deselectOnClickOutside) {
68
- dispatch({ type: 'CLEAR_SELECTED_FEATURES' })
69
- } else {
70
- // No action
71
119
  }
120
+ }, [interactionModes, dispatch, markers, markerOptions, eventBus, deselectOnClickOutside])
72
121
 
73
- // Internal helper to keep complexity low
74
- function processFeatureMatch ({ feature, config }) {
75
- markers.remove('location')
76
- const isNewContiguous = contiguous && isContiguousWithAny(feature, selectedFeatures)
77
- const featureId = feature.properties?.[config.idProperty] ?? feature.id
78
-
79
- if (!featureId) {
122
+ const handleInteraction = useCallback(({ point, coords }) => {
123
+ if (interactionModes.includes('selectMarker')) {
124
+ const markerHit = findMarkerAtPoint(markers, point, scale)
125
+ if (markerHit) {
126
+ dispatch({ type: 'TOGGLE_SELECTED_MARKERS', payload: { markerId: markerHit, multiSelect } })
80
127
  return
81
128
  }
129
+ }
82
130
 
83
- dispatch({
84
- type: 'TOGGLE_SELECTED_FEATURES',
85
- payload: {
86
- featureId,
87
- multiSelect,
88
- layerId: config.layerId,
89
- idProperty: config.idProperty,
90
- properties: feature.properties,
91
- geometry: feature.geometry,
92
- replaceAll: contiguous && !isNewContiguous
93
- }
94
- })
131
+ if (interactionModes.includes('selectFeature') && layers.length > 0) {
132
+ const allFeatures = getFeaturesAtPoint(mapProvider, point, { radius: tolerance })
133
+ if (pluginState?.debug) {
134
+ console.log(`--- Features at ${coords} ---`, allFeatures)
135
+ }
136
+ const match = findMatchingFeature(allFeatures, layerConfigMap)
137
+ if (match) {
138
+ processFeatureMatch(match)
139
+ return
140
+ }
95
141
  }
142
+
143
+ processFallback({ coords })
96
144
  }, [
97
145
  mapProvider,
98
- dataLayers,
99
- interactionMode,
146
+ layers,
147
+ interactionModes,
100
148
  multiSelect,
101
- eventBus,
102
149
  dispatch,
103
150
  markers,
104
- contiguous,
105
- selectedFeatures,
106
151
  layerConfigMap,
107
152
  pluginState?.debug,
108
153
  tolerance,
109
- markerColor,
110
- deselectOnClickOutside
154
+ processFeatureMatch,
155
+ processFallback,
156
+ scale
111
157
  ])
112
-
113
- useSelectionChangeEmitter(eventBus, selectedFeatures, selectionBounds)
114
-
115
- return {
116
- handleInteraction
117
- }
158
+ useSelectionChangeEmitter(eventBus, selectedFeatures, selectedMarkers, selectionBounds)
159
+ return { handleInteraction }
118
160
  }
@@ -30,19 +30,27 @@ const click = result =>
30
30
  })
31
31
  )
32
32
 
33
- const setup = (pluginOverrides = {}) => {
33
+ const makeMarkerEl = (rect) => ({
34
+ getBoundingClientRect: () => rect,
35
+ parentElement: {
36
+ getBoundingClientRect: () => ({ left: 0, top: 0 })
37
+ }
38
+ })
39
+
40
+ const setup = (pluginOverrides = {}, markerItems = [], markerRefs = new Map()) => {
34
41
  const deps = {
35
42
  mapState: {
36
- markers: { add: jest.fn(), remove: jest.fn() }
43
+ markers: { add: jest.fn(), remove: jest.fn(), items: markerItems, markerRefs }
37
44
  },
38
45
  pluginState: {
39
46
  dispatch: jest.fn(),
40
- dataLayers: [{ layerId: 'parcels', idProperty: 'parcelId' }],
41
- interactionMode: 'select',
47
+ layers: [{ layerId: 'parcels', idProperty: 'parcelId' }],
48
+ interactionModes: ['selectMarker', 'selectFeature'],
42
49
  multiSelect: false,
43
50
  contiguous: false,
44
- markerColor: 'red',
51
+ marker: { symbol: 'pin', backgroundColor: 'red' },
45
52
  selectedFeatures: [],
53
+ selectedMarkers: [],
46
54
  selectionBounds: null,
47
55
  ...pluginOverrides
48
56
  },
@@ -73,12 +81,94 @@ beforeEach(() => {
73
81
  })
74
82
 
75
83
  /* ------------------------------------------------------------------ */
76
- /* Marker mode */
84
+ /* DOM marker hit detection */
85
+ /* ------------------------------------------------------------------ */
86
+
87
+ describe('DOM marker hit detection', () => {
88
+ it('dispatches TOGGLE_SELECTED_MARKERS when click is within a marker bounds', () => {
89
+ const markerEl = makeMarkerEl({ left: 5, top: 15, right: 15, bottom: 25 })
90
+ const markerRefs = new Map([['marker-1', markerEl]])
91
+ const markerItems = [{ id: 'marker-1', coords: [1, 2] }]
92
+
93
+ const { result, deps } = setup({}, markerItems, markerRefs)
94
+ click(result)
95
+
96
+ expect(deps.pluginState.dispatch).toHaveBeenCalledWith({
97
+ type: 'TOGGLE_SELECTED_MARKERS',
98
+ payload: { markerId: 'marker-1', multiSelect: false }
99
+ })
100
+ })
101
+
102
+ it('markers take precedence over features when both hit', () => {
103
+ const markerEl = makeMarkerEl({ left: 5, top: 15, right: 15, bottom: 25 })
104
+ const markerRefs = new Map([['marker-1', markerEl]])
105
+ const markerItems = [{ id: 'marker-1', coords: [1, 2] }]
106
+
107
+ const { result, deps } = setup({}, markerItems, markerRefs)
108
+ click(result)
109
+
110
+ expect(deps.pluginState.dispatch).not.toHaveBeenCalledWith(
111
+ expect.objectContaining({ type: 'TOGGLE_SELECTED_FEATURES' })
112
+ )
113
+ expect(featureQueries.getFeaturesAtPoint).not.toHaveBeenCalled()
114
+ })
115
+
116
+ it('skips markers with no ref and continues to next', () => {
117
+ // marker in items but not in markerRefs — should not throw, should fall through to features
118
+ const markerItems = [{ id: 'marker-no-ref', coords: [1, 2] }]
119
+ const markerRefs = new Map() // no entry for marker-no-ref
120
+
121
+ const { result, deps } = setup({}, markerItems, markerRefs)
122
+ click(result)
123
+
124
+ expect(featureQueries.getFeaturesAtPoint).toHaveBeenCalled()
125
+ expect(deps.pluginState.dispatch).toHaveBeenCalledWith(
126
+ expect.objectContaining({ type: 'TOGGLE_SELECTED_FEATURES' })
127
+ )
128
+ })
129
+
130
+ it('uses zero offset when marker element has no parentElement', () => {
131
+ // parentElement is null — fallback parentRect { left: 0, top: 0 } should be used
132
+ const markerEl = {
133
+ getBoundingClientRect: () => ({ left: 5, top: 15, right: 15, bottom: 25 }),
134
+ parentElement: null
135
+ }
136
+ const markerRefs = new Map([['marker-1', markerEl]])
137
+ const markerItems = [{ id: 'marker-1', coords: [1, 2] }]
138
+
139
+ const { result, deps } = setup({}, markerItems, markerRefs)
140
+ click(result)
141
+
142
+ // click point { x: 10, y: 20 } is within [5,15,15,25] with zero parent offset
143
+ expect(deps.pluginState.dispatch).toHaveBeenCalledWith({
144
+ type: 'TOGGLE_SELECTED_MARKERS',
145
+ payload: { markerId: 'marker-1', multiSelect: false }
146
+ })
147
+ })
148
+
149
+ it('falls through to feature selection when click misses all markers', () => {
150
+ // marker bounds don't include the click point { x: 10, y: 20 }
151
+ const markerEl = makeMarkerEl({ left: 50, top: 50, right: 80, bottom: 80 })
152
+ const markerRefs = new Map([['marker-1', markerEl]])
153
+ const markerItems = [{ id: 'marker-1', coords: [1, 2] }]
154
+
155
+ const { result, deps } = setup({}, markerItems, markerRefs)
156
+ click(result)
157
+
158
+ expect(featureQueries.getFeaturesAtPoint).toHaveBeenCalled()
159
+ expect(deps.pluginState.dispatch).toHaveBeenCalledWith(
160
+ expect.objectContaining({ type: 'TOGGLE_SELECTED_FEATURES' })
161
+ )
162
+ })
163
+ })
164
+
165
+ /* ------------------------------------------------------------------ */
166
+ /* placeMarker mode */
77
167
  /* ------------------------------------------------------------------ */
78
168
 
79
- describe('marker mode', () => {
169
+ describe('placeMarker mode', () => {
80
170
  it('places marker, clears selection, and emits event', () => {
81
- const { result, deps } = setup({ interactionMode: 'marker' })
171
+ const { result, deps } = setup({ interactionModes: ['placeMarker'] })
82
172
 
83
173
  click(result)
84
174
 
@@ -88,7 +178,7 @@ describe('marker mode', () => {
88
178
  expect(deps.mapState.markers.add).toHaveBeenCalledWith(
89
179
  'location',
90
180
  [1, 2],
91
- { color: 'red' }
181
+ { symbol: 'pin', backgroundColor: 'red' }
92
182
  )
93
183
  expect(deps.services.eventBus.emit).toHaveBeenCalledWith(
94
184
  'interact:markerchange',
@@ -98,32 +188,30 @@ describe('marker mode', () => {
98
188
  })
99
189
 
100
190
  /* ------------------------------------------------------------------ */
101
- /* Select & Auto modes */
191
+ /* selectFeature mode */
102
192
  /* ------------------------------------------------------------------ */
103
193
 
104
- describe.each(['select', 'auto'])('%s mode feature selection', mode => {
105
- it('dispatches selection for matching feature', () => {
106
- const { result, deps } = setup({ interactionMode: mode })
194
+ it('selectFeature mode dispatches selection for matching feature', () => {
195
+ const { result, deps } = setup({ interactionModes: ['selectFeature'] })
107
196
 
108
- click(result)
197
+ click(result)
109
198
 
110
- expect(deps.pluginState.dispatch).toHaveBeenCalledWith(
111
- expect.objectContaining({
112
- type: 'TOGGLE_SELECTED_FEATURES',
113
- payload: expect.objectContaining({
114
- featureId: 'P1',
115
- layerId: 'parcels'
116
- })
199
+ expect(deps.pluginState.dispatch).toHaveBeenCalledWith(
200
+ expect.objectContaining({
201
+ type: 'TOGGLE_SELECTED_FEATURES',
202
+ payload: expect.objectContaining({
203
+ featureId: 'P1',
204
+ layerId: 'parcels'
117
205
  })
118
- )
119
- })
206
+ })
207
+ )
120
208
  })
121
209
 
122
- it('falls back to marker in auto mode when no feature found', () => {
210
+ it('falls back to placeMarker when selectFeature finds no match', () => {
123
211
  featureQueries.getFeaturesAtPoint.mockReturnValue([])
124
212
  featureQueries.findMatchingFeature.mockReturnValue(null)
125
213
 
126
- const { result, deps } = setup({ interactionMode: 'auto' })
214
+ const { result, deps } = setup({ interactionModes: ['selectFeature', 'placeMarker'] })
127
215
 
128
216
  click(result)
129
217
 
@@ -248,32 +336,58 @@ describe('deselectOnClickOutside', () => {
248
336
  })
249
337
 
250
338
  /* ------------------------------------------------------------------ */
251
- /* Marker condition guard (FULL COVERAGE) */
339
+ /* placeMarker / selectFeature guards */
252
340
  /* ------------------------------------------------------------------ */
253
341
 
254
- it('does NOT place marker in auto mode when no dataLayers exist', () => {
342
+ it('places marker with placeMarker mode even when no layers exist', () => {
255
343
  featureQueries.getFeaturesAtPoint.mockReturnValue([])
256
344
  featureQueries.findMatchingFeature.mockReturnValue(null)
257
345
 
258
346
  const { result, deps } = setup({
259
- interactionMode: 'auto',
260
- dataLayers: []
347
+ interactionModes: ['selectFeature', 'placeMarker'],
348
+ layers: []
261
349
  })
262
350
 
263
351
  click(result)
264
352
 
353
+ expect(deps.mapState.markers.add).toHaveBeenCalled()
354
+ })
355
+
356
+ it('does not place marker when placeMarker is not in interactionModes', () => {
357
+ featureQueries.getFeaturesAtPoint.mockReturnValue([])
358
+ featureQueries.findMatchingFeature.mockReturnValue(null)
359
+
360
+ const { result, deps } = setup({ interactionModes: ['selectFeature'] })
361
+
362
+ click(result)
363
+
265
364
  expect(deps.mapState.markers.add).not.toHaveBeenCalled()
266
365
  })
267
366
 
367
+ it('does not check markers when selectMarker is not in interactionModes', () => {
368
+ const markerEl = makeMarkerEl({ left: 5, top: 15, right: 15, bottom: 25 })
369
+ const markerRefs = new Map([['marker-1', markerEl]])
370
+ const markerItems = [{ id: 'marker-1', coords: [1, 2] }]
371
+
372
+ const { result, deps } = setup({ interactionModes: ['selectFeature'] }, markerItems, markerRefs)
373
+ click(result)
374
+
375
+ expect(deps.pluginState.dispatch).not.toHaveBeenCalledWith(
376
+ expect.objectContaining({ type: 'TOGGLE_SELECTED_MARKERS' })
377
+ )
378
+ expect(featureQueries.getFeaturesAtPoint).toHaveBeenCalled()
379
+ })
380
+
268
381
  /* ------------------------------------------------------------------ */
269
382
  /* Selection change event */
270
383
  /* ------------------------------------------------------------------ */
271
384
 
272
385
  it('emits selectionchange once when bounds exist', () => {
273
386
  const deps = {
274
- mapState: { markers: { add: jest.fn(), remove: jest.fn() } },
387
+ mapState: { markers: { add: jest.fn(), remove: jest.fn(), items: [], markerRefs: new Map() } },
275
388
  pluginState: {
276
389
  selectedFeatures: [{ featureId: 'F1' }],
390
+ selectedMarkers: [],
277
391
  selectionBounds: { sw: [0, 0], ne: [1, 1] }
278
392
  },
279
393
  services: { eventBus: { emit: jest.fn() } },
@@ -286,6 +400,7 @@ it('emits selectionchange once when bounds exist', () => {
286
400
  'interact:selectionchange',
287
401
  expect.objectContaining({
288
402
  selectedFeatures: deps.pluginState.selectedFeatures,
403
+ selectedMarkers: [],
289
404
  selectionBounds: deps.pluginState.selectionBounds,
290
405
  canMerge: false,
291
406
  canSplit: false
@@ -299,8 +414,8 @@ it('skips emission when selection remains empty after being cleared', () => {
299
414
  // 1. First render with a feature (prev is null, emission happens)
300
415
  const { rerender } = renderHook(
301
416
  ({ features }) => useInteractionHandlers({
302
- mapState: { markers: {} },
303
- pluginState: { selectedFeatures: features, selectionBounds: { b: 1 } },
417
+ mapState: { markers: { items: [], markerRefs: new Map() } },
418
+ pluginState: { selectedFeatures: features, selectedMarkers: [], selectionBounds: { b: 1 } },
304
419
  services: { eventBus },
305
420
  mapProvider: {}
306
421
  }),
@@ -21,11 +21,4 @@
21
21
  .im-o-app--mobile .im-c-mark-location .im-c-map-button {
22
22
  flex: 1 1 auto;
23
23
  min-width: fit-content;
24
- }
25
-
26
- // Position touch select button
27
- .im-c-button-wrapper--select-at-target {
28
- position: absolute;
29
- bottom: calc(100% + var(--primary-gap));
30
- z-index: 0;
31
24
  }
@@ -16,24 +16,6 @@ export const manifest = {
16
16
  },
17
17
 
18
18
  buttons: [{
19
- id: 'selectAtTarget',
20
- label: 'Select',
21
- iconId: 'select',
22
- variant: 'touch',
23
- hiddenWhen: ({ appState, pluginState }) => !pluginState.enabled || appState.interfaceType !== 'touch',
24
- mobile: {
25
- slot: 'actions',
26
- showLabel: false
27
- },
28
- tablet: {
29
- slot: 'actions',
30
- showLabel: false
31
- },
32
- desktop: {
33
- slot: 'actions',
34
- showLabel: false
35
- }
36
- }, {
37
19
  id: 'selectCancel',
38
20
  label: 'Back',
39
21
  variant: 'tertiary',
@@ -50,6 +32,20 @@ export const manifest = {
50
32
  slot: 'actions',
51
33
  showLabel: true
52
34
  }
35
+ }, {
36
+ id: 'selectAtTarget',
37
+ label: 'Select',
38
+ variant: 'primary',
39
+ hiddenWhen: ({ appState, pluginState }) => !pluginState.enabled || appState.interfaceType !== 'touch',
40
+ mobile: {
41
+ slot: 'actions'
42
+ },
43
+ tablet: {
44
+ slot: 'actions'
45
+ },
46
+ desktop: {
47
+ slot: 'actions'
48
+ }
53
49
  }, {
54
50
  id: 'selectDone',
55
51
  label: 'Continue',
@@ -25,7 +25,9 @@ describe('manifest', () => {
25
25
  manifest.buttons.forEach(b => {
26
26
  ['mobile', 'tablet', 'desktop'].forEach(dev => {
27
27
  expect(b[dev].slot).toBe('actions')
28
- expect(typeof b[dev].showLabel).toBe('boolean')
28
+ if (b[dev].showLabel !== undefined) {
29
+ expect(typeof b[dev].showLabel).toBe('boolean')
30
+ }
29
31
  })
30
32
  })
31
33
  })