@defra/interactive-map 0.0.4-alpha → 0.0.5-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.
- package/dist/css/index.css +1 -1
- package/dist/esm/im-core.js +1 -1
- package/dist/esm/index.js +1 -1
- package/dist/umd/im-core.js +1 -1
- package/dist/umd/index.js +1 -1
- package/docs/api.md +8 -0
- package/package.json +3 -2
- package/plugins/beta/datasets/dist/esm/im-datasets-plugin.js +2 -1
- package/plugins/beta/datasets/dist/esm/im-datasets-plugin.js.LICENSE.txt +1 -0
- package/plugins/beta/datasets/dist/umd/im-datasets-plugin.js +2 -1
- package/plugins/beta/datasets/dist/umd/im-datasets-plugin.js.LICENSE.txt +1 -0
- package/plugins/beta/datasets/dist/umd/index.js +1 -1
- package/plugins/beta/datasets/src/datasets.js +55 -2
- package/plugins/beta/datasets/src/fetch/createDynamicSource.js +206 -0
- package/plugins/beta/datasets/src/fetch/fetchGeoJSON.js +38 -0
- package/plugins/beta/datasets/src/handleSetMapStyle.js +8 -1
- package/plugins/beta/datasets/src/mapLayers.js +41 -3
- package/plugins/beta/datasets/src/utils/bbox.js +113 -2
- package/plugins/beta/draw-ml/dist/css/index.css +1 -1
- package/plugins/beta/draw-ml/dist/esm/im-draw-ml-plugin.js +1 -1
- package/plugins/beta/draw-ml/dist/umd/im-draw-ml-plugin.js +1 -1
- package/plugins/beta/draw-ml/dist/umd/index.js +1 -1
- package/plugins/beta/draw-ml/src/api/addFeature.js +15 -3
- package/plugins/beta/draw-ml/src/api/editFeature.js +16 -5
- package/plugins/beta/draw-ml/src/api/newLine.js +32 -7
- package/plugins/beta/draw-ml/src/api/newPolygon.js +33 -8
- package/plugins/beta/draw-ml/src/defaults.js +1 -0
- package/plugins/beta/draw-ml/src/draw.scss +23 -0
- package/plugins/beta/draw-ml/src/events.js +55 -113
- package/plugins/beta/draw-ml/src/manifest.js +64 -38
- package/plugins/beta/draw-ml/src/mapboxSnap.js +16 -12
- package/plugins/beta/draw-ml/src/modes/createDrawMode.js +4 -3
- package/plugins/beta/draw-ml/src/modes/editVertex/geometryHelpers.js +135 -0
- package/plugins/beta/draw-ml/src/modes/editVertex/helpers.js +2 -0
- package/plugins/beta/draw-ml/src/modes/editVertex/touchHandlers.js +134 -0
- package/plugins/beta/draw-ml/src/modes/editVertex/undoHandlers.js +133 -0
- package/plugins/beta/draw-ml/src/modes/editVertex/vertexOperations.js +141 -0
- package/plugins/beta/draw-ml/src/modes/editVertex/vertexQueries.js +121 -0
- package/plugins/beta/draw-ml/src/modes/editVertexMode.js +51 -406
- package/plugins/beta/draw-ml/src/utils/flattenStyleProperties.js +49 -0
- package/plugins/beta/frame/dist/esm/im-frame-plugin.js +1 -1
- package/plugins/beta/frame/dist/umd/im-frame-plugin.js +1 -1
- package/plugins/beta/frame/src/Frame.jsx +1 -1
- package/plugins/beta/map-styles/dist/css/index.css +1 -1
- package/plugins/beta/map-styles/dist/esm/index.js +1 -1
- package/plugins/beta/map-styles/dist/umd/im-map-styles-plugin.js +1 -1
- package/plugins/beta/map-styles/dist/umd/index.js +1 -1
- package/plugins/beta/map-styles/src/mapStyles.scss +4 -1
- package/plugins/interact/dist/css/index.css +1 -1
- package/plugins/interact/dist/esm/im-interact-plugin.js +1 -1
- package/plugins/interact/dist/umd/im-interact-plugin.js +1 -1
- package/plugins/interact/dist/umd/index.js +1 -1
- package/plugins/interact/src/InteractInit.jsx +1 -1
- package/plugins/interact/src/defaults.js +1 -0
- package/plugins/interact/src/events.js +37 -52
- package/plugins/interact/src/events.test.js +35 -0
- package/plugins/interact/src/hooks/useHighlightSync.js +1 -1
- package/plugins/interact/src/hooks/useInteractionHandlers.js +80 -48
- package/plugins/interact/src/hooks/useInteractionHandlers.test.js +64 -8
- package/plugins/interact/src/interact.scss +6 -0
- package/plugins/interact/src/reducer.js +25 -27
- package/plugins/interact/src/reducer.test.js +19 -6
- package/plugins/interact/src/utils/featureQueries.js +2 -2
- package/plugins/interact/src/utils/spatial.js +95 -0
- package/plugins/interact/src/utils/spatial.test.js +93 -0
- package/plugins/search/dist/css/index.css +1 -1
- package/plugins/search/dist/esm/im-search-plugin.js +1 -1
- package/plugins/search/dist/umd/im-search-plugin.js +1 -1
- package/plugins/search/dist/umd/index.js +1 -1
- package/plugins/search/src/Search.jsx +2 -2
- package/plugins/search/src/components/Form/Form.jsx +1 -1
- package/plugins/search/src/components/Suggestions/Suggestions.jsx +5 -5
- package/plugins/search/src/datasets.js +1 -1
- package/plugins/search/src/defaults.js +3 -0
- package/plugins/search/src/events/fetchSuggestions.js +48 -53
- package/plugins/search/src/events/formHandlers.js +3 -2
- package/plugins/search/src/events/index.js +1 -1
- package/plugins/search/src/events/inputHandlers.js +3 -1
- package/plugins/search/src/events/suggestionHandlers.js +12 -3
- package/plugins/search/src/search.scss +4 -1
- package/plugins/search/src/utils/parseOsNamesResults.js +12 -12
- package/providers/beta/esri/src/esriProvider.js +1 -1
- package/providers/beta/esri/src/index.js +1 -1
- package/providers/maplibre/dist/esm/im-maplibre-provider.js +1 -1
- package/providers/maplibre/dist/esm/index.js +1 -1
- package/providers/maplibre/dist/umd/im-maplibre-framework.js +1 -1
- package/providers/maplibre/dist/umd/im-maplibre-provider.js +1 -1
- package/providers/maplibre/dist/umd/index.js +1 -1
- package/providers/maplibre/src/defaults.js +3 -2
- package/providers/maplibre/src/index.js +22 -19
- package/providers/maplibre/src/maplibreProvider.js +13 -11
- package/providers/maplibre/src/utils/detectWebgl.js +1 -2
- package/providers/maplibre/src/utils/highlightFeatures.js +78 -67
- package/providers/maplibre/src/utils/labels.js +174 -120
- package/providers/maplibre/src/utils/maplibreFixes.js +2 -2
- package/providers/maplibre/src/utils/queryFeatures.js +146 -0
- package/providers/maplibre/src/utils/spatial.js +14 -17
- package/sonar-project.properties +39 -2
- package/src/App/components/Actions/Actions.jsx +2 -2
- package/src/App/components/Actions/Actions.module.scss +2 -2
- package/src/App/components/KeyboardHelp/KeyboardHelp.jsx +3 -1
- package/src/App/components/KeyboardHelp/KeyboardHelp.test.jsx +9 -0
- package/src/App/components/Logo/Logo.jsx +1 -1
- package/src/App/components/MapButton/MapButton.jsx +217 -41
- package/src/App/components/MapButton/MapButton.module.scss +32 -21
- package/src/App/components/MapButton/MapButton.test.jsx +128 -84
- package/src/App/components/Markers/Markers.jsx +1 -1
- package/src/App/components/Markers/Markers.module.scss +0 -5
- package/src/App/components/Panel/Panel.jsx +75 -45
- package/src/App/components/Panel/Panel.module.scss +18 -24
- package/src/App/components/Panel/Panel.test.jsx +18 -0
- package/src/App/components/PopupMenu/PopupMenu.jsx +246 -0
- package/src/App/components/PopupMenu/PopupMenu.module.scss +70 -0
- package/src/App/components/PopupMenu/PopupMenu.test.jsx +304 -0
- package/src/App/components/Tooltip/Tooltip.jsx +4 -2
- package/src/App/components/Tooltip/getTooltipPosition.js +1 -1
- package/src/App/components/Viewport/MapController.jsx +1 -1
- package/src/App/components/Viewport/MapStatus.jsx +1 -1
- package/src/App/components/Viewport/Viewport.jsx +2 -2
- package/src/App/components/Viewport/Viewport.module.scss +2 -2
- package/src/App/controls/keyboardActions.js +3 -2
- package/src/App/controls/keyboardShortcuts.js +4 -2
- package/src/App/hooks/useButtonStateEvaluator.js +58 -45
- package/src/App/hooks/useButtonStateEvaluator.test.js +36 -3
- package/src/App/hooks/useCrossHairAPI.js +49 -47
- package/src/App/hooks/useFocusVisible.js +2 -2
- package/src/App/hooks/useInterfaceAPI.js +16 -4
- package/src/App/hooks/useKeyboardHint.js +1 -1
- package/src/App/hooks/useKeyboardShortcuts.js +4 -2
- package/src/App/hooks/useMapAnnouncements.js +34 -32
- package/src/App/hooks/useMapEvents.js +12 -1
- package/src/App/hooks/useMapProviderOverrides.js +3 -3
- package/src/App/hooks/useMapStateSync.js +7 -1
- package/src/App/hooks/useMapURLSync.js +6 -1
- package/src/App/hooks/useMarkersAPI.js +88 -50
- package/src/App/hooks/useMediaQueryDispatch.test.js +48 -0
- package/src/App/hooks/useModalPanelBehaviour.js +46 -40
- package/src/App/hooks/useResizeObserver.js +2 -2
- package/src/App/initialiseApp.js +45 -38
- package/src/App/layout/Layout.jsx +6 -4
- package/src/App/layout/layout.module.scss +13 -17
- package/src/App/registry/keyboardShortcutRegistry.js +12 -2
- package/src/App/registry/keyboardShortcutRegistry.test.js +28 -0
- package/src/App/registry/mergeManifests.js +1 -1
- package/src/App/registry/panelRegistry.js +1 -1
- package/src/App/registry/panelRegistry.test.js +30 -1
- package/src/App/registry/pluginRegistry.js +14 -51
- package/src/App/registry/pluginRegistry.test.js +54 -6
- package/src/App/renderer/HtmlElementHost.jsx +186 -0
- package/src/App/renderer/HtmlElementHost.test.jsx +231 -0
- package/src/App/renderer/mapButtons.js +11 -8
- package/src/App/renderer/mapButtons.test.js +6 -4
- package/src/App/renderer/mapControls.js +6 -0
- package/src/App/renderer/mapControls.test.js +10 -2
- package/src/App/renderer/mapPanels.js +39 -26
- package/src/App/renderer/mapPanels.test.js +29 -0
- package/src/App/renderer/pluginWrapper.test.js +28 -0
- package/src/App/renderer/slotHelpers.js +60 -0
- package/src/App/renderer/slotHelpers.test.js +82 -0
- package/src/App/store/AppProvider.jsx +3 -0
- package/src/App/store/AppProvider.test.jsx +40 -0
- package/src/App/store/PluginProvider.jsx +7 -5
- package/src/App/store/appActionsMap.js +25 -1
- package/src/App/store/appActionsMap.test.js +21 -0
- package/src/App/store/appReducer.js +1 -0
- package/src/App/store/mapActionsMap.js +12 -24
- package/src/App/store/mapReducer.js +1 -1
- package/src/InteractiveMap/InteractiveMap.js +79 -6
- package/src/InteractiveMap/InteractiveMap.test.js +164 -2
- package/src/InteractiveMap/behaviourController.js +12 -2
- package/src/InteractiveMap/behaviourController.test.js +82 -1
- package/src/InteractiveMap/deviceChecker.js +1 -1
- package/src/InteractiveMap/deviceChecker.test.js +2 -2
- package/src/InteractiveMap/historyManager.js +41 -5
- package/src/InteractiveMap/historyManager.test.js +37 -6
- package/src/InteractiveMap/polyfills.js +39 -0
- package/src/InteractiveMap/polyfills.test.js +127 -0
- package/src/config/appConfig.js +5 -5
- package/src/config/appConfig.test.js +107 -42
- package/src/config/defaults.js +1 -0
- package/src/index.umd.js +1 -1
- package/src/scss/main.scss +1 -0
- package/src/scss/settings/_dimensions.scss +6 -2
- package/src/services/eventBus.test.js +24 -1
- package/src/test-utils.js +1 -0
- package/src/types.js +5 -0
- package/src/utils/detectBreakpoint.js +78 -77
- package/src/utils/detectBreakpoint.test.js +50 -4
- package/src/utils/detectInterfaceType.js +4 -4
- package/src/utils/getIsFullscreen.js +3 -1
- package/src/utils/getSafeZoneInset.js +23 -7
- package/src/utils/getSafeZoneInset.test.js +18 -0
- package/src/utils/mapStateSync.js +3 -3
- package/src/utils/queryString.js +1 -1
- package/src/utils/stringToKebab.js +1 -1
- package/src/utils/toggleInertElements.js +37 -12
- package/webpack.dev.mjs +3 -4
- package/plugins/interact/src/utils/turfHelpers.js +0 -30
- package/plugins/interact/src/utils/turfHelpers.test.js +0 -30
- package/plugins/search/src/utils/fetchDataset.js +0 -23
- package/providers/maplibre/dist/esm/im-maplibre-legacy-framework.js +0 -1
- 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
|
|
27
|
-
|
|
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
|
-
//
|
|
35
|
-
const handleMapClick = (e) =>
|
|
36
|
-
|
|
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
|
|
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 (
|
|
61
|
-
|
|
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
|
-
|
|
72
|
-
return
|
|
63
|
+
if (pluginState.closeOnAction ?? true) {
|
|
64
|
+
closeApp()
|
|
73
65
|
}
|
|
74
|
-
|
|
75
|
-
closeApp()
|
|
76
66
|
}
|
|
77
|
-
selectCancel.onClick = handleSelectCancel
|
|
78
67
|
|
|
79
|
-
const
|
|
80
|
-
|
|
68
|
+
const toggleFeature = createFeatureHandler(mapState, pluginState)
|
|
69
|
+
const handleSelect = (args) => toggleFeature(args, true)
|
|
70
|
+
const handleUnselect = (args) => toggleFeature(args, false)
|
|
81
71
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
|
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
|
-
|
|
23
|
-
|
|
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
|
-
|
|
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
|
|
56
|
-
|
|
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
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
})
|
|
75
|
+
if (!featureId) {
|
|
76
|
+
return
|
|
77
|
+
}
|
|
74
78
|
|
|
75
|
-
|
|
76
|
-
|
|
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
|
|
4
|
+
import { isContiguousWithAny } from '../utils/spatial.js'
|
|
5
5
|
|
|
6
6
|
/* ------------------------------------------------------------------ */
|
|
7
7
|
/* Mocks */
|
|
8
8
|
/* ------------------------------------------------------------------ */
|
|
9
9
|
|
|
10
|
-
jest.mock('
|
|
11
|
-
jest.
|
|
12
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
|
35
|
-
|
|
36
|
-
const existingIndex =
|
|
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
|
-
|
|
43
|
-
|
|
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
|
-
//
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
//
|
|
57
|
-
|
|
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
|
-
|
|
57
|
+
selectedCopy.push(featureObj)
|
|
60
58
|
} else {
|
|
61
|
-
|
|
62
|
-
selected.splice(existingIndex, 1)
|
|
59
|
+
selectedCopy.splice(existingIndex, 1)
|
|
63
60
|
}
|
|
64
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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 []
|