@defra/interactive-map 0.0.4-alpha → 0.0.6-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 (203) hide show
  1. package/dist/css/index.css +1 -1
  2. package/dist/esm/im-core.js +1 -1
  3. package/dist/esm/index.js +1 -1
  4. package/dist/umd/im-core.js +1 -1
  5. package/dist/umd/index.js +1 -1
  6. package/docs/api.md +8 -0
  7. package/package.json +3 -2
  8. package/plugins/beta/datasets/dist/esm/im-datasets-plugin.js +2 -1
  9. package/plugins/beta/datasets/dist/esm/im-datasets-plugin.js.LICENSE.txt +1 -0
  10. package/plugins/beta/datasets/dist/umd/im-datasets-plugin.js +2 -1
  11. package/plugins/beta/datasets/dist/umd/im-datasets-plugin.js.LICENSE.txt +1 -0
  12. package/plugins/beta/datasets/dist/umd/index.js +1 -1
  13. package/plugins/beta/datasets/src/datasets.js +55 -2
  14. package/plugins/beta/datasets/src/fetch/createDynamicSource.js +206 -0
  15. package/plugins/beta/datasets/src/fetch/fetchGeoJSON.js +38 -0
  16. package/plugins/beta/datasets/src/handleSetMapStyle.js +8 -1
  17. package/plugins/beta/datasets/src/mapLayers.js +41 -3
  18. package/plugins/beta/datasets/src/utils/bbox.js +113 -2
  19. package/plugins/beta/draw-ml/dist/css/index.css +1 -1
  20. package/plugins/beta/draw-ml/dist/esm/im-draw-ml-plugin.js +1 -1
  21. package/plugins/beta/draw-ml/dist/umd/im-draw-ml-plugin.js +1 -1
  22. package/plugins/beta/draw-ml/dist/umd/index.js +1 -1
  23. package/plugins/beta/draw-ml/src/api/addFeature.js +15 -3
  24. package/plugins/beta/draw-ml/src/api/editFeature.js +16 -5
  25. package/plugins/beta/draw-ml/src/api/newLine.js +32 -7
  26. package/plugins/beta/draw-ml/src/api/newPolygon.js +33 -8
  27. package/plugins/beta/draw-ml/src/defaults.js +1 -0
  28. package/plugins/beta/draw-ml/src/draw.scss +23 -0
  29. package/plugins/beta/draw-ml/src/events.js +55 -113
  30. package/plugins/beta/draw-ml/src/manifest.js +64 -38
  31. package/plugins/beta/draw-ml/src/mapboxSnap.js +16 -12
  32. package/plugins/beta/draw-ml/src/modes/createDrawMode.js +4 -3
  33. package/plugins/beta/draw-ml/src/modes/editVertex/geometryHelpers.js +135 -0
  34. package/plugins/beta/draw-ml/src/modes/editVertex/helpers.js +2 -0
  35. package/plugins/beta/draw-ml/src/modes/editVertex/touchHandlers.js +134 -0
  36. package/plugins/beta/draw-ml/src/modes/editVertex/undoHandlers.js +133 -0
  37. package/plugins/beta/draw-ml/src/modes/editVertex/vertexOperations.js +141 -0
  38. package/plugins/beta/draw-ml/src/modes/editVertex/vertexQueries.js +121 -0
  39. package/plugins/beta/draw-ml/src/modes/editVertexMode.js +51 -406
  40. package/plugins/beta/draw-ml/src/utils/flattenStyleProperties.js +49 -0
  41. package/plugins/beta/frame/dist/esm/im-frame-plugin.js +1 -1
  42. package/plugins/beta/frame/dist/umd/im-frame-plugin.js +1 -1
  43. package/plugins/beta/frame/src/Frame.jsx +1 -1
  44. package/plugins/beta/map-styles/dist/css/index.css +1 -1
  45. package/plugins/beta/map-styles/dist/esm/index.js +1 -1
  46. package/plugins/beta/map-styles/dist/umd/im-map-styles-plugin.js +1 -1
  47. package/plugins/beta/map-styles/dist/umd/index.js +1 -1
  48. package/plugins/beta/map-styles/src/mapStyles.scss +4 -1
  49. package/plugins/interact/dist/css/index.css +1 -1
  50. package/plugins/interact/dist/esm/im-interact-plugin.js +1 -1
  51. package/plugins/interact/dist/umd/im-interact-plugin.js +1 -1
  52. package/plugins/interact/dist/umd/index.js +1 -1
  53. package/plugins/interact/src/InteractInit.jsx +1 -1
  54. package/plugins/interact/src/defaults.js +1 -0
  55. package/plugins/interact/src/events.js +37 -52
  56. package/plugins/interact/src/events.test.js +35 -0
  57. package/plugins/interact/src/hooks/useHighlightSync.js +1 -1
  58. package/plugins/interact/src/hooks/useInteractionHandlers.js +80 -48
  59. package/plugins/interact/src/hooks/useInteractionHandlers.test.js +64 -8
  60. package/plugins/interact/src/interact.scss +6 -0
  61. package/plugins/interact/src/reducer.js +25 -27
  62. package/plugins/interact/src/reducer.test.js +19 -6
  63. package/plugins/interact/src/utils/featureQueries.js +2 -2
  64. package/plugins/interact/src/utils/spatial.js +99 -0
  65. package/plugins/interact/src/utils/spatial.test.js +100 -0
  66. package/plugins/search/dist/css/index.css +1 -1
  67. package/plugins/search/dist/esm/im-search-plugin.js +1 -1
  68. package/plugins/search/dist/umd/im-search-plugin.js +1 -1
  69. package/plugins/search/dist/umd/index.js +1 -1
  70. package/plugins/search/src/Search.jsx +2 -2
  71. package/plugins/search/src/components/Form/Form.jsx +1 -1
  72. package/plugins/search/src/components/Suggestions/Suggestions.jsx +5 -5
  73. package/plugins/search/src/datasets.js +1 -1
  74. package/plugins/search/src/defaults.js +3 -0
  75. package/plugins/search/src/events/fetchSuggestions.js +48 -53
  76. package/plugins/search/src/events/formHandlers.js +3 -2
  77. package/plugins/search/src/events/index.js +1 -1
  78. package/plugins/search/src/events/inputHandlers.js +3 -1
  79. package/plugins/search/src/events/suggestionHandlers.js +12 -3
  80. package/plugins/search/src/search.scss +4 -1
  81. package/plugins/search/src/utils/parseOsNamesResults.js +12 -12
  82. package/providers/beta/esri/src/esriProvider.js +1 -1
  83. package/providers/beta/esri/src/index.js +1 -1
  84. package/providers/maplibre/dist/esm/im-maplibre-provider.js +1 -1
  85. package/providers/maplibre/dist/esm/index.js +1 -1
  86. package/providers/maplibre/dist/umd/im-maplibre-framework.js +1 -1
  87. package/providers/maplibre/dist/umd/im-maplibre-provider.js +1 -1
  88. package/providers/maplibre/dist/umd/index.js +1 -1
  89. package/providers/maplibre/src/defaults.js +3 -2
  90. package/providers/maplibre/src/index.js +22 -19
  91. package/providers/maplibre/src/maplibreProvider.js +13 -11
  92. package/providers/maplibre/src/utils/detectWebgl.js +1 -2
  93. package/providers/maplibre/src/utils/highlightFeatures.js +78 -67
  94. package/providers/maplibre/src/utils/labels.js +174 -120
  95. package/providers/maplibre/src/utils/maplibreFixes.js +2 -2
  96. package/providers/maplibre/src/utils/queryFeatures.js +146 -0
  97. package/providers/maplibre/src/utils/spatial.js +14 -17
  98. package/sonar-project.properties +39 -2
  99. package/src/App/components/Actions/Actions.jsx +2 -2
  100. package/src/App/components/Actions/Actions.module.scss +2 -2
  101. package/src/App/components/KeyboardHelp/KeyboardHelp.jsx +3 -1
  102. package/src/App/components/KeyboardHelp/KeyboardHelp.test.jsx +9 -0
  103. package/src/App/components/Logo/Logo.jsx +1 -1
  104. package/src/App/components/MapButton/MapButton.jsx +217 -41
  105. package/src/App/components/MapButton/MapButton.module.scss +32 -21
  106. package/src/App/components/MapButton/MapButton.test.jsx +128 -84
  107. package/src/App/components/Markers/Markers.jsx +1 -1
  108. package/src/App/components/Markers/Markers.module.scss +0 -5
  109. package/src/App/components/Panel/Panel.jsx +75 -45
  110. package/src/App/components/Panel/Panel.module.scss +18 -24
  111. package/src/App/components/Panel/Panel.test.jsx +18 -0
  112. package/src/App/components/PopupMenu/PopupMenu.jsx +246 -0
  113. package/src/App/components/PopupMenu/PopupMenu.module.scss +70 -0
  114. package/src/App/components/PopupMenu/PopupMenu.test.jsx +304 -0
  115. package/src/App/components/Tooltip/Tooltip.jsx +4 -2
  116. package/src/App/components/Tooltip/getTooltipPosition.js +1 -1
  117. package/src/App/components/Viewport/MapController.jsx +1 -1
  118. package/src/App/components/Viewport/MapStatus.jsx +1 -1
  119. package/src/App/components/Viewport/Viewport.jsx +2 -2
  120. package/src/App/components/Viewport/Viewport.module.scss +2 -2
  121. package/src/App/controls/keyboardActions.js +3 -2
  122. package/src/App/controls/keyboardShortcuts.js +4 -2
  123. package/src/App/hooks/useButtonStateEvaluator.js +58 -45
  124. package/src/App/hooks/useButtonStateEvaluator.test.js +36 -3
  125. package/src/App/hooks/useCrossHairAPI.js +49 -47
  126. package/src/App/hooks/useFocusVisible.js +2 -2
  127. package/src/App/hooks/useInterfaceAPI.js +16 -4
  128. package/src/App/hooks/useKeyboardHint.js +1 -1
  129. package/src/App/hooks/useKeyboardShortcuts.js +4 -2
  130. package/src/App/hooks/useMapAnnouncements.js +34 -32
  131. package/src/App/hooks/useMapEvents.js +12 -1
  132. package/src/App/hooks/useMapProviderOverrides.js +3 -3
  133. package/src/App/hooks/useMapStateSync.js +7 -1
  134. package/src/App/hooks/useMapURLSync.js +6 -1
  135. package/src/App/hooks/useMarkersAPI.js +88 -50
  136. package/src/App/hooks/useMediaQueryDispatch.test.js +48 -0
  137. package/src/App/hooks/useModalPanelBehaviour.js +46 -40
  138. package/src/App/hooks/useResizeObserver.js +2 -2
  139. package/src/App/initialiseApp.js +45 -38
  140. package/src/App/layout/Layout.jsx +6 -4
  141. package/src/App/layout/layout.module.scss +13 -17
  142. package/src/App/registry/keyboardShortcutRegistry.js +12 -2
  143. package/src/App/registry/keyboardShortcutRegistry.test.js +28 -0
  144. package/src/App/registry/mergeManifests.js +1 -1
  145. package/src/App/registry/panelRegistry.js +1 -1
  146. package/src/App/registry/panelRegistry.test.js +30 -1
  147. package/src/App/registry/pluginRegistry.js +14 -51
  148. package/src/App/registry/pluginRegistry.test.js +54 -6
  149. package/src/App/renderer/HtmlElementHost.jsx +186 -0
  150. package/src/App/renderer/HtmlElementHost.test.jsx +231 -0
  151. package/src/App/renderer/mapButtons.js +11 -8
  152. package/src/App/renderer/mapButtons.test.js +6 -4
  153. package/src/App/renderer/mapControls.js +6 -0
  154. package/src/App/renderer/mapControls.test.js +10 -2
  155. package/src/App/renderer/mapPanels.js +39 -26
  156. package/src/App/renderer/mapPanels.test.js +29 -0
  157. package/src/App/renderer/pluginWrapper.test.js +28 -0
  158. package/src/App/renderer/slotHelpers.js +60 -0
  159. package/src/App/renderer/slotHelpers.test.js +82 -0
  160. package/src/App/store/AppProvider.jsx +21 -18
  161. package/src/App/store/AppProvider.test.jsx +40 -0
  162. package/src/App/store/PluginProvider.jsx +7 -5
  163. package/src/App/store/appActionsMap.js +28 -1
  164. package/src/App/store/appActionsMap.test.js +26 -0
  165. package/src/App/store/appReducer.js +1 -0
  166. package/src/App/store/mapActionsMap.js +12 -24
  167. package/src/App/store/mapReducer.js +1 -1
  168. package/src/InteractiveMap/InteractiveMap.js +79 -6
  169. package/src/InteractiveMap/InteractiveMap.test.js +164 -2
  170. package/src/InteractiveMap/behaviourController.js +12 -2
  171. package/src/InteractiveMap/behaviourController.test.js +82 -1
  172. package/src/InteractiveMap/deviceChecker.js +1 -1
  173. package/src/InteractiveMap/deviceChecker.test.js +2 -2
  174. package/src/InteractiveMap/historyManager.js +41 -5
  175. package/src/InteractiveMap/historyManager.test.js +37 -6
  176. package/src/InteractiveMap/polyfills.js +39 -0
  177. package/src/InteractiveMap/polyfills.test.js +127 -0
  178. package/src/config/appConfig.js +5 -5
  179. package/src/config/appConfig.test.js +107 -42
  180. package/src/config/defaults.js +1 -0
  181. package/src/index.umd.js +1 -1
  182. package/src/scss/main.scss +1 -0
  183. package/src/scss/settings/_colors.scss +1 -1
  184. package/src/scss/settings/_dimensions.scss +4 -0
  185. package/src/services/eventBus.test.js +24 -1
  186. package/src/test-utils.js +1 -0
  187. package/src/types.js +5 -0
  188. package/src/utils/detectBreakpoint.js +78 -77
  189. package/src/utils/detectBreakpoint.test.js +50 -4
  190. package/src/utils/detectInterfaceType.js +6 -5
  191. package/src/utils/getIsFullscreen.js +3 -1
  192. package/src/utils/getSafeZoneInset.js +23 -7
  193. package/src/utils/getSafeZoneInset.test.js +18 -0
  194. package/src/utils/mapStateSync.js +3 -3
  195. package/src/utils/queryString.js +1 -1
  196. package/src/utils/stringToKebab.js +1 -1
  197. package/src/utils/toggleInertElements.js +37 -12
  198. package/webpack.dev.mjs +3 -4
  199. package/plugins/interact/src/utils/turfHelpers.js +0 -30
  200. package/plugins/interact/src/utils/turfHelpers.test.js +0 -30
  201. package/plugins/search/src/utils/fetchDataset.js +0 -23
  202. package/providers/maplibre/dist/esm/im-maplibre-legacy-framework.js +0 -1
  203. package/providers/maplibre/dist/umd/im-maplibre-legacy-framework.js +0 -1
@@ -1,3 +1,16 @@
1
+ // Helper for feature toggling logic
2
+ const createFeatureHandler = (mapState, pluginState) => (args, addToExisting) => {
3
+ mapState.markers.remove('location')
4
+ pluginState.dispatch({
5
+ type: 'TOGGLE_SELECTED_FEATURES',
6
+ payload: {
7
+ multiSelect: pluginState.multiSelect,
8
+ addToExisting,
9
+ ...args
10
+ }
11
+ })
12
+ }
13
+
1
14
  export function attachEvents ({
2
15
  appState,
3
16
  mapState,
@@ -8,41 +21,24 @@ export function attachEvents ({
8
21
  handleInteraction,
9
22
  closeApp
10
23
  }) {
11
- const {
12
- selectDone,
13
- selectAtTarget,
14
- selectCancel
15
- } = buttonConfig
16
-
24
+ const { selectDone, selectAtTarget, selectCancel } = buttonConfig
17
25
  const { viewportRef } = appState.layoutRefs
18
26
 
27
+ // Keyboard Logic
19
28
  let enterOnViewport = false
20
- const handleKeydown = (e) => {
21
- enterOnViewport = e.key === 'Enter' && viewportRef.current === e.target
22
- }
23
- document.addEventListener('keydown', handleKeydown)
24
-
29
+ const handleKeydown = (e) => { enterOnViewport = e.key === 'Enter' && viewportRef.current === e.target }
25
30
  const handleKeyup = (e) => {
26
- if (e.key !== 'Enter' || !enterOnViewport) {
27
- return
31
+ if (e.key === 'Enter' && enterOnViewport) {
32
+ e.preventDefault()
33
+ handleSelectAtTarget()
28
34
  }
29
- e.preventDefault()
30
- handleSelectAtTarget()
31
35
  }
32
- document.addEventListener('keyup', handleKeyup)
33
36
 
34
- // Allow tapping on touch devices as well as accurate placement
35
- const handleMapClick = (e) => {
36
- handleInteraction(e)
37
- }
38
- eventBus.on(events.MAP_CLICK, handleMapClick)
37
+ // Interaction Handlers
38
+ const handleMapClick = (e) => handleInteraction(e)
39
+ const handleSelectAtTarget = () => handleInteraction(mapState.crossHair.getDetail())
39
40
 
40
- const handleSelectAtTarget = () => {
41
- handleInteraction(mapState.crossHair.getDetail())
42
- }
43
- selectAtTarget.onClick = handleSelectAtTarget
44
-
45
- const handleSelectDone = (e) => {
41
+ const handleSelectDone = () => {
46
42
  const marker = mapState.markers.getMarker('location')
47
43
  const { coords } = marker || {}
48
44
  const { selectionBounds, selectedFeatures } = pluginState
@@ -57,43 +53,32 @@ export function attachEvents ({
57
53
  ...(!coords && selectionBounds && { selectionBounds })
58
54
  })
59
55
 
60
- if (!(pluginState.closeOnAction ?? true)) {
61
- return
56
+ if (pluginState.closeOnAction ?? true) {
57
+ closeApp()
62
58
  }
63
-
64
- closeApp()
65
59
  }
66
- selectDone.onClick = handleSelectDone
67
60
 
68
61
  const handleSelectCancel = () => {
69
62
  eventBus.emit('interact:cancel')
70
-
71
- if (!(pluginState.closeOnAction ?? true)) {
72
- return
63
+ if (pluginState.closeOnAction ?? true) {
64
+ closeApp()
73
65
  }
74
-
75
- closeApp()
76
66
  }
77
- selectCancel.onClick = handleSelectCancel
78
67
 
79
- const handleToggleFeature = (args, addToExisting) => {
80
- mapState.markers.remove('location')
68
+ const toggleFeature = createFeatureHandler(mapState, pluginState)
69
+ const handleSelect = (args) => toggleFeature(args, true)
70
+ const handleUnselect = (args) => toggleFeature(args, false)
81
71
 
82
- pluginState.dispatch({
83
- type: 'TOGGLE_SELECTED_FEATURES',
84
- payload: {
85
- multiSelect: pluginState.multiSelect,
86
- addToExisting,
87
- ...args
88
- }
89
- })
90
- }
91
- const handleSelect = (args) => handleToggleFeature(args, true)
92
- const handleUnselect = (args) => handleToggleFeature(args, false)
72
+ // Attach Listeners
73
+ document.addEventListener('keydown', handleKeydown)
74
+ document.addEventListener('keyup', handleKeyup)
75
+ eventBus.on(events.MAP_CLICK, handleMapClick)
93
76
  eventBus.on('interact:selectFeature', handleSelect)
94
77
  eventBus.on('interact:unselectFeature', handleUnselect)
78
+ selectAtTarget.onClick = handleSelectAtTarget
79
+ selectDone.onClick = handleSelectDone
80
+ selectCancel.onClick = handleSelectCancel
95
81
 
96
- // Return cleanup function
97
82
  return () => {
98
83
  selectDone.onClick = null
99
84
  selectAtTarget.onClick = null
@@ -148,4 +148,39 @@ describe('attachEvents', () => {
148
148
  cleanup()
149
149
  Object.values(params.buttonConfig).forEach(btn => expect(btn.onClick).toBeNull())
150
150
  })
151
+
152
+ it('selectDone handles emission when no marker/coords exist', () => {
153
+ const params = createParams()
154
+ cleanup = attachEvents(params)
155
+
156
+ // Ensure marker returns null (no coords)
157
+ params.mapState.markers.getMarker.mockReturnValue(null)
158
+
159
+ // Set up features and bounds
160
+ params.pluginState.selectedFeatures = [{ id: 'f1' }]
161
+ params.pluginState.selectionBounds = { sw: [0, 0], ne: [1, 1] }
162
+
163
+ params.buttonConfig.selectDone.onClick()
164
+
165
+ expect(params.eventBus.emit).toHaveBeenCalledWith('interact:done', {
166
+ selectedFeatures: [{ id: 'f1' }],
167
+ selectionBounds: { sw: [0, 0], ne: [1, 1] }
168
+ })
169
+ })
170
+
171
+ it('respects default closeOnAction when value is undefined (fallback to true)', () => {
172
+ const params = createParams()
173
+ // Explicitly set to undefined to trigger the ?? fallback
174
+ params.pluginState.closeOnAction = undefined
175
+ cleanup = attachEvents(params)
176
+
177
+ // Test for selectDone
178
+ params.buttonConfig.selectDone.onClick()
179
+ expect(params.closeApp).toHaveBeenCalledTimes(1)
180
+
181
+ // Test for selectCancel
182
+ params.closeApp.mockClear()
183
+ params.buttonConfig.selectCancel.onClick()
184
+ expect(params.closeApp).toHaveBeenCalledTimes(1)
185
+ })
151
186
  })
@@ -32,7 +32,7 @@ export const useHighlightSync = ({
32
32
 
33
33
  useEffect(() => {
34
34
  if (!mapProvider || !selectedFeatures || !stylesMap) {
35
- return
35
+ return undefined // Explicit return to match the cleanup function return below
36
36
  }
37
37
 
38
38
  // Update updateHighlightedFeatures on interaction
@@ -1,8 +1,35 @@
1
1
  import { useCallback, useEffect, useRef } from 'react'
2
- import booleanDisjoint from '@turf/boolean-disjoint'
3
- import { toTurfGeometry } from '../utils/turfHelpers.js'
2
+ import { isContiguousWithAny, canSplitFeatures, areAllContiguous } from '../utils/spatial.js'
4
3
  import { getFeaturesAtPoint, findMatchingFeature, buildLayerConfigMap } from '../utils/featureQueries.js'
5
4
 
5
+ const useSelectionChangeEmitter = (eventBus, selectedFeatures, selectionBounds) => {
6
+ const lastEmittedSelectionChange = useRef(null)
7
+
8
+ useEffect(() => {
9
+ // Skip if features exist but bounds not yet calculated
10
+ const awaitingBounds = selectedFeatures.length > 0 && !selectionBounds
11
+ if (awaitingBounds) {
12
+ return
13
+ }
14
+
15
+ // Skip if selection was already empty and remains empty
16
+ const prev = lastEmittedSelectionChange.current
17
+ const wasEmpty = prev === null || prev.length === 0
18
+ if (wasEmpty && selectedFeatures.length === 0) {
19
+ return
20
+ }
21
+
22
+ eventBus.emit('interact:selectionchange', {
23
+ selectedFeatures,
24
+ selectionBounds,
25
+ canMerge: areAllContiguous(selectedFeatures),
26
+ canSplit: canSplitFeatures(selectedFeatures)
27
+ })
28
+
29
+ lastEmittedSelectionChange.current = selectedFeatures
30
+ }, [selectedFeatures, selectionBounds])
31
+ }
32
+
6
33
  export const useInteractionHandlers = ({
7
34
  mapState,
8
35
  pluginState,
@@ -10,70 +37,75 @@ export const useInteractionHandlers = ({
10
37
  mapProvider,
11
38
  }) => {
12
39
  const { markers } = mapState
13
- const { dispatch, dataLayers, interactionMode, multiSelect, contiguous, markerColor, selectedFeatures, selectionBounds } = pluginState
40
+ const { dispatch, dataLayers, interactionMode, multiSelect, contiguous, markerColor, tolerance, selectedFeatures, selectionBounds } = pluginState
14
41
  const { eventBus } = services
15
- const lastEmittedSelectionChange = useRef(null)
16
42
  const layerConfigMap = buildLayerConfigMap(dataLayers)
17
43
 
18
44
  const handleInteraction = useCallback(({ point, coords }) => {
19
- const allFeatures = getFeaturesAtPoint(mapProvider, point)
45
+ const allFeatures = getFeaturesAtPoint(mapProvider, point, { radius: tolerance })
20
46
  const hasDataLayers = dataLayers.length > 0
21
47
 
22
- const canMatchFeature = hasDataLayers && (interactionMode === 'select' || interactionMode === 'auto')
23
- const match = canMatchFeature ? findMatchingFeature(allFeatures, layerConfigMap) : null
24
-
25
- if (match) {
26
- markers.remove('location')
27
- const { feature, config } = match
28
-
29
- // Using Turf not booleanDisjoint to test if the new feature is contiguous
30
- let isNewFeatureContiguous = false
31
- if (contiguous) {
32
- isNewFeatureContiguous = selectedFeatures.some(sf => !booleanDisjoint(toTurfGeometry(sf), toTurfGeometry(feature)))
33
- }
48
+ if (pluginState?.debug) {
49
+ console.log(`--- Features at ${coords} ---`, allFeatures)
50
+ }
34
51
 
35
- const featureId = feature.properties?.[config.idProperty]
36
-
37
- if (featureId) {
38
- dispatch({
39
- type: 'TOGGLE_SELECTED_FEATURES',
40
- payload: {
41
- featureId,
42
- multiSelect,
43
- layerId: config.layerId,
44
- idProperty: config.idProperty,
45
- properties: feature.properties,
46
- geometry: feature.geometry,
47
- replaceAll: contiguous && !isNewFeatureContiguous
48
- },
49
- })
50
- }
52
+ const canMatch = hasDataLayers && (interactionMode === 'select' || interactionMode === 'auto')
53
+ const match = canMatch ? findMatchingFeature(allFeatures, layerConfigMap) : null
51
54
 
55
+ // 1. Handle Feature Match
56
+ if (match) {
57
+ processFeatureMatch(match)
52
58
  return
53
59
  }
54
60
 
55
- // Marker mode
56
- if (interactionMode === 'marker' || (interactionMode === 'auto' && hasDataLayers)) {
61
+ // 2. Handle Marker Mode (Fallback)
62
+ const isMarkerMode = interactionMode === 'marker' || (interactionMode === 'auto' && hasDataLayers)
63
+ if (isMarkerMode) {
57
64
  dispatch({ type: 'CLEAR_SELECTED_FEATURES' })
58
65
  markers.add('location', coords, { color: markerColor })
59
-
60
66
  eventBus.emit('interact:markerchange', { coords })
61
67
  }
62
- }, [mapProvider, dataLayers, interactionMode, multiSelect, eventBus, dispatch, markers])
63
68
 
64
- // Emit event when selectedFeatures change
65
- useEffect(() => {
66
- if (!selectionBounds || selectedFeatures === lastEmittedSelectionChange.current) {
67
- return
68
- }
69
+ // Internal helper to keep complexity low
70
+ function processFeatureMatch({ feature, config }) {
71
+ markers.remove('location')
72
+ const isNewContiguous = contiguous && isContiguousWithAny(feature, selectedFeatures)
73
+ const featureId = feature.properties?.[config.idProperty] ?? feature.id
69
74
 
70
- eventBus.emit('interact:selectionchange', {
71
- selectedFeatures,
72
- selectionBounds
73
- })
75
+ if (!featureId) {
76
+ return
77
+ }
74
78
 
75
- lastEmittedSelectionChange.current = selectedFeatures
76
- }, [selectedFeatures, selectionBounds])
79
+ dispatch({
80
+ type: 'TOGGLE_SELECTED_FEATURES',
81
+ payload: {
82
+ featureId,
83
+ multiSelect,
84
+ layerId: config.layerId,
85
+ idProperty: config.idProperty,
86
+ properties: feature.properties,
87
+ geometry: feature.geometry,
88
+ replaceAll: contiguous && !isNewContiguous
89
+ }
90
+ })
91
+ }
92
+ }, [
93
+ mapProvider,
94
+ dataLayers,
95
+ interactionMode,
96
+ multiSelect,
97
+ eventBus,
98
+ dispatch,
99
+ markers,
100
+ contiguous,
101
+ selectedFeatures,
102
+ layerConfigMap,
103
+ pluginState?.debug,
104
+ tolerance,
105
+ markerColor
106
+ ])
107
+
108
+ useSelectionChangeEmitter(eventBus, selectedFeatures, selectionBounds)
77
109
 
78
110
  return {
79
111
  handleInteraction
@@ -1,15 +1,16 @@
1
1
  import { renderHook, act } from '@testing-library/react'
2
2
  import { useInteractionHandlers } from './useInteractionHandlers.js'
3
3
  import * as featureQueries from '../utils/featureQueries.js'
4
- import booleanDisjoint from '@turf/boolean-disjoint'
4
+ import { isContiguousWithAny } from '../utils/spatial.js'
5
5
 
6
6
  /* ------------------------------------------------------------------ */
7
7
  /* Mocks */
8
8
  /* ------------------------------------------------------------------ */
9
9
 
10
- jest.mock('@turf/boolean-disjoint', () => jest.fn())
11
- jest.mock('../utils/turfHelpers.js', () => ({
12
- toTurfGeometry: jest.fn(g => g)
10
+ jest.mock('../utils/spatial.js', () => ({
11
+ isContiguousWithAny: jest.fn(),
12
+ canSplitFeatures: jest.fn(() => false),
13
+ areAllContiguous: jest.fn(() => false)
13
14
  }))
14
15
  jest.mock('../utils/featureQueries.js', () => ({
15
16
  getFeaturesAtPoint: jest.fn(),
@@ -172,7 +173,7 @@ it('passes multiSelect flag through to dispatch', () => {
172
173
 
173
174
  describe('contiguous selection', () => {
174
175
  it('does NOT replace selection when feature is contiguous', () => {
175
- booleanDisjoint.mockReturnValue(false) // overlapping
176
+ isContiguousWithAny.mockReturnValue(true) // contiguous
176
177
 
177
178
  const { result, deps } = setup({
178
179
  contiguous: true,
@@ -191,7 +192,7 @@ describe('contiguous selection', () => {
191
192
  })
192
193
 
193
194
  it('replaces selection when feature is NOT contiguous', () => {
194
- booleanDisjoint.mockReturnValue(true) // disjoint
195
+ isContiguousWithAny.mockReturnValue(false) // disjoint
195
196
 
196
197
  const { result, deps } = setup({
197
198
  contiguous: true,
@@ -214,7 +215,7 @@ describe('contiguous selection', () => {
214
215
 
215
216
  click(result)
216
217
 
217
- expect(booleanDisjoint).not.toHaveBeenCalled()
218
+ expect(isContiguousWithAny).not.toHaveBeenCalled()
218
219
  })
219
220
  })
220
221
 
@@ -255,6 +256,61 @@ it('emits selectionchange once when bounds exist', () => {
255
256
 
256
257
  expect(deps.services.eventBus.emit).toHaveBeenCalledWith(
257
258
  'interact:selectionchange',
258
- deps.pluginState
259
+ expect.objectContaining({
260
+ selectedFeatures: deps.pluginState.selectedFeatures,
261
+ selectionBounds: deps.pluginState.selectionBounds,
262
+ canMerge: false,
263
+ canSplit: false
264
+ })
265
+ )
266
+ })
267
+
268
+ it('skips emission when selection remains empty after being cleared', () => {
269
+ const eventBus = { emit: jest.fn() }
270
+
271
+ // 1. First render with a feature (prev is null, emission happens)
272
+ const { rerender } = renderHook(
273
+ ({ features }) => useInteractionHandlers({
274
+ mapState: { markers: {} },
275
+ pluginState: { selectedFeatures: features, selectionBounds: { b: 1 } },
276
+ services: { eventBus },
277
+ mapProvider: {}
278
+ }),
279
+ { initialProps: { features: [{ id: 'f1' }] } }
280
+ )
281
+
282
+ expect(eventBus.emit).toHaveBeenCalledTimes(1)
283
+ eventBus.emit.mockClear()
284
+
285
+ // 2. Rerender with empty selection (prev is now [{id: 'f1'}], emission happens)
286
+ rerender({ features: [] })
287
+ expect(eventBus.emit).toHaveBeenCalledTimes(1)
288
+ eventBus.emit.mockClear()
289
+
290
+ // 3. Rerender with empty selection AGAIN
291
+ // This triggers: prev !== null AND prev.length === 0
292
+ rerender({ features: [] })
293
+
294
+ // Should skip emission because wasEmpty is true (via prev.length === 0)
295
+ // and current features.length is 0
296
+ expect(eventBus.emit).not.toHaveBeenCalled()
297
+ })
298
+
299
+ /* ------------------------------------------------------------------ */
300
+ /* Debug mode */
301
+ /* ------------------------------------------------------------------ */
302
+
303
+ it('logs features when debug mode is enabled', () => {
304
+ const logSpy = jest.spyOn(console, 'log').mockImplementation(() => {})
305
+
306
+ const { result } = setup({ debug: true })
307
+
308
+ click(result)
309
+
310
+ expect(logSpy).toHaveBeenCalledWith(
311
+ expect.stringContaining('--- Features at'),
312
+ expect.any(Array)
259
313
  )
314
+
315
+ logSpy.mockRestore()
260
316
  })
@@ -21,4 +21,10 @@
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));
24
30
  }
@@ -21,7 +21,9 @@ const enable = (state, payload) => {
21
21
  const disable = (state) => {
22
22
  return {
23
23
  ...state,
24
- enabled: false
24
+ enabled: false,
25
+ selectedFeatures: [],
26
+ selectionBounds: null
25
27
  }
26
28
  }
27
29
 
@@ -31,44 +33,40 @@ const disable = (state) => {
31
33
  */
32
34
  const toggleSelectedFeatures = (state, payload) => {
33
35
  const { featureId, multiSelect, layerId, idProperty, addToExisting = true, replaceAll = false, properties, geometry } = payload
34
- const selected = Array.isArray(state.selectedFeatures) ? [...state.selectedFeatures] : []
35
-
36
- const existingIndex = selected.findIndex(
36
+ const currentSelected = Array.isArray(state.selectedFeatures) ? state.selectedFeatures : []
37
+
38
+ const existingIndex = currentSelected.findIndex(
37
39
  f => f.featureId === featureId && f.layerId === layerId
38
40
  )
39
41
 
40
- // Handle explicit unselect
42
+ // 1. Handle explicit unselect
41
43
  if (addToExisting === false) {
42
- if (existingIndex !== -1) {
43
- selected.splice(existingIndex, 1) // remove the feature
44
- }
45
- return { ...state, selectedFeatures: selected }
44
+ const filtered = currentSelected.filter((_, i) => i !== existingIndex)
45
+ return { ...state, selectedFeatures: filtered, selectionBounds: null }
46
46
  }
47
47
 
48
- // Replace all selected features if flag is true
49
- if (replaceAll) {
50
- return {
51
- ...state,
52
- selectedFeatures: [{ featureId, layerId, idProperty, properties, geometry }]
53
- }
54
- }
48
+ // Define the feature object once to avoid repetition
49
+ const featureObj = { featureId, layerId, idProperty, properties, geometry }
50
+ let nextSelected
55
51
 
56
- // Multi-select mode (add to selection)
57
- if (multiSelect) {
52
+ // 2. Determine New State
53
+ // We combine 'replaceAll' and 'single-select' because they share the same logic
54
+ if (multiSelect && !replaceAll) {
55
+ const selectedCopy = [...currentSelected]
58
56
  if (existingIndex === -1) {
59
- selected.push({ featureId, layerId, idProperty, properties, geometry })
57
+ selectedCopy.push(featureObj)
60
58
  } else {
61
- // optional: could also remove existing on toggle
62
- selected.splice(existingIndex, 1)
59
+ selectedCopy.splice(existingIndex, 1)
63
60
  }
64
- return { ...state, selectedFeatures: selected }
61
+ nextSelected = selectedCopy
62
+ } else {
63
+ // Both 'replaceAll' and single-select mode logic:
64
+ // If same feature is already the only one, toggle off; otherwise return just this feature.
65
+ const isSameSingle = existingIndex !== -1 && currentSelected.length === 1
66
+ nextSelected = isSameSingle ? [] : [featureObj]
65
67
  }
66
68
 
67
- // Single-select mode
68
- const isSameSingle = existingIndex !== -1 && selected.length === 1
69
- const newSelected = isSameSingle ? [] : [{ featureId, layerId, idProperty, properties, geometry }]
70
-
71
- return { ...state, selectedFeatures: newSelected }
69
+ return { ...state, selectedFeatures: nextSelected, selectionBounds: null }
72
70
  }
73
71
 
74
72
  // Update bounds (called from useEffect after map provider calculates them)
@@ -28,13 +28,15 @@ describe('ENABLE/DISABLE actions', () => {
28
28
  expect(result).not.toBe(state)
29
29
  })
30
30
 
31
- it('DISABLE sets enabled to false but preserves other state', () => {
32
- const state = { ...initialState, enabled: true, dataLayers: [1], markerColor: 'red' }
31
+ it('DISABLE sets enabled to false, clears selection, and preserves other state', () => {
32
+ const state = { ...initialState, enabled: true, dataLayers: [1], markerColor: 'red', selectedFeatures: [{ featureId: 'f1' }], selectionBounds: [0, 0, 1, 1] }
33
33
  const result = actions.DISABLE(state)
34
34
 
35
35
  expect(result.enabled).toBe(false)
36
36
  expect(result.dataLayers).toEqual([1])
37
37
  expect(result.markerColor).toBe('red')
38
+ expect(result.selectedFeatures).toEqual([])
39
+ expect(result.selectionBounds).toBeNull()
38
40
  expect(result).not.toBe(state)
39
41
  })
40
42
  })
@@ -50,7 +52,7 @@ describe('TOGGLE_SELECTED_FEATURES action', () => {
50
52
  })
51
53
 
52
54
  it('handles single-select, multi-select, add/remove, and replaceAll', () => {
53
- let state = { ...initialState }
55
+ let state = { ...initialState, selectionBounds: { sw: [0, 0], ne: [1, 1] } }
54
56
 
55
57
  // Single-select: add
56
58
  state = actions.TOGGLE_SELECTED_FEATURES(state, createFeature('f1'))
@@ -60,22 +62,33 @@ describe('TOGGLE_SELECTED_FEATURES action', () => {
60
62
  state = actions.TOGGLE_SELECTED_FEATURES(state, createFeature('f2'))
61
63
  expect(state.selectedFeatures[0].featureId).toBe('f2')
62
64
 
63
- // Toggle off same
65
+ // Toggle off same - clears bounds
64
66
  state = actions.TOGGLE_SELECTED_FEATURES(state, createFeature('f2'))
65
67
  expect(state.selectedFeatures).toHaveLength(0)
68
+ expect(state.selectionBounds).toBeNull()
66
69
 
67
70
  // Multi-select: add multiple
71
+ state = { ...state, selectionBounds: { sw: [0, 0], ne: [1, 1] } }
68
72
  state = actions.TOGGLE_SELECTED_FEATURES(state, { ...createFeature('f1'), multiSelect: true })
69
73
  state = actions.TOGGLE_SELECTED_FEATURES(state, { ...createFeature('f2'), multiSelect: true })
70
74
  expect(state.selectedFeatures.map(f => f.featureId)).toEqual(['f1', 'f2'])
71
75
 
72
- // Multi-select: remove
76
+ // Multi-select: remove (not last) - clears bounds for recalculation
73
77
  state = actions.TOGGLE_SELECTED_FEATURES(state, { ...createFeature('f1'), multiSelect: true })
74
78
  expect(state.selectedFeatures.map(f => f.featureId)).toEqual(['f2'])
79
+ expect(state.selectionBounds).toBeNull()
80
+
81
+ // Multi-select: remove last - clears bounds
82
+ state = actions.TOGGLE_SELECTED_FEATURES(state, { ...createFeature('f2'), multiSelect: true })
83
+ expect(state.selectedFeatures).toHaveLength(0)
84
+ expect(state.selectionBounds).toBeNull()
75
85
 
76
- // addToExisting false removes only the feature
86
+ // addToExisting false removes feature - clears bounds when empty
87
+ state = { ...state, selectionBounds: { sw: [0, 0], ne: [1, 1] } }
88
+ state = actions.TOGGLE_SELECTED_FEATURES(state, { ...createFeature('f2'), multiSelect: true })
77
89
  state = actions.TOGGLE_SELECTED_FEATURES(state, { ...createFeature('f2'), addToExisting: false })
78
90
  expect(state.selectedFeatures).toHaveLength(0)
91
+ expect(state.selectionBounds).toBeNull()
79
92
 
80
93
  // replaceAll replaces everything
81
94
  state = actions.TOGGLE_SELECTED_FEATURES(state, { ...createFeature('f3'), replaceAll: true })
@@ -6,9 +6,9 @@ export const buildLayerConfigMap = dataLayers => {
6
6
  return map
7
7
  }
8
8
 
9
- export const getFeaturesAtPoint = (mapProvider, point) => {
9
+ export const getFeaturesAtPoint = (mapProvider, point, options) => {
10
10
  try {
11
- return mapProvider?.getFeaturesAtPoint(point) || []
11
+ return mapProvider?.getFeaturesAtPoint(point, options) || []
12
12
  } catch (err) {
13
13
  console.warn('Feature query failed:', err)
14
14
  return []