@defra/interactive-map 0.0.26-alpha → 0.0.27-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/esm/im-core.js +1 -1
- package/dist/esm/im-shell.js +1 -1
- package/dist/umd/im-core.js +1 -1
- package/dist/umd/index.js +1 -1
- package/docs/assets/css/docusaurus.scss +4 -0
- package/package.json +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/src/defaults.js +14 -14
- package/plugins/beta/draw-ml/src/mapboxDraw.js +5 -0
- package/plugins/beta/draw-ml/src/mapboxSnap.js +1 -1
- package/plugins/beta/draw-ml/src/modes/editVertex/touchHandlers.js +10 -2
- package/plugins/beta/draw-ml/src/modes/editVertexMode.js +2 -0
- package/plugins/beta/draw-ml/src/reducer.js +4 -1
- package/plugins/beta/draw-ml/src/styles.js +42 -39
- package/plugins/beta/draw-ol/dist/css/index.css +13 -0
- package/plugins/beta/draw-ol/dist/esm/DrawMode.js +1 -0
- package/plugins/beta/draw-ol/dist/esm/EditMode.js +1 -0
- package/plugins/beta/draw-ol/dist/esm/geometryHelpers.js +1 -0
- package/plugins/beta/draw-ol/dist/esm/im-draw-ol-plugin.js +1 -0
- package/plugins/beta/draw-ol/dist/esm/index.js +1 -0
- package/plugins/beta/draw-ol/src/DrawInit.jsx +67 -0
- package/plugins/beta/draw-ol/src/api/addFeature.js +29 -0
- package/plugins/beta/draw-ol/src/api/deleteFeature.js +11 -0
- package/plugins/beta/draw-ol/src/api/editFeature.js +50 -0
- package/plugins/beta/draw-ol/src/api/newLine.js +44 -0
- package/plugins/beta/draw-ol/src/api/newPolygon.js +44 -0
- package/plugins/beta/draw-ol/src/core/OLDrawManager.js +150 -0
- package/plugins/beta/draw-ol/src/core/featureStore.js +65 -0
- package/plugins/beta/draw-ol/src/core/styles.js +98 -0
- package/plugins/beta/draw-ol/src/core/undoStack.js +31 -0
- package/plugins/beta/draw-ol/src/defaults.js +16 -0
- package/plugins/beta/draw-ol/src/draw/DrawMode.js +113 -0
- package/plugins/beta/draw-ol/src/draw/drawInput.js +199 -0
- package/plugins/beta/draw-ol/src/draw.scss +17 -0
- package/plugins/beta/draw-ol/src/edit/EditMode.js +424 -0
- package/plugins/beta/draw-ol/src/edit/keyboardHandler.js +227 -0
- package/plugins/beta/draw-ol/src/edit/midpointLayer.js +57 -0
- package/plugins/beta/draw-ol/src/edit/touchHandler.js +171 -0
- package/plugins/beta/draw-ol/src/edit/undoOps.js +95 -0
- package/plugins/beta/draw-ol/src/edit/vertexHitTest.js +72 -0
- package/plugins/beta/draw-ol/src/edit/vertexLayer.js +49 -0
- package/plugins/beta/draw-ol/src/edit/vertexOps.js +99 -0
- package/plugins/beta/draw-ol/src/events.js +114 -0
- package/plugins/beta/draw-ol/src/index.js +12 -0
- package/plugins/beta/draw-ol/src/manifest.js +121 -0
- package/plugins/beta/draw-ol/src/olDraw.js +38 -0
- package/plugins/beta/draw-ol/src/reducer.js +41 -0
- package/plugins/beta/draw-ol/src/snap/snapEngine.js +109 -0
- package/plugins/beta/draw-ol/src/snap/snapGeometry.js +200 -0
- package/plugins/beta/draw-ol/src/snap/snapIndicator.js +81 -0
- package/plugins/beta/draw-ol/src/snap/snapInteraction.js +56 -0
- package/plugins/beta/draw-ol/src/snap/snapManager.js +90 -0
- package/plugins/beta/draw-ol/src/utils/flattenStyleProperties.js +47 -0
- package/plugins/beta/draw-ol/src/utils/geometryHelpers.js +97 -0
- package/plugins/beta/draw-ol/src/utils/olCoords.js +35 -0
- package/plugins/beta/draw-ol/src/utils/resolveColors.js +39 -0
- package/plugins/beta/draw-ol/src/utils/spatial.js +28 -0
- package/plugins/beta/draw-ol/src/utils/touchTarget.js +61 -0
- 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/src/defaults.js +1 -0
- package/plugins/interact/src/hooks/useInteractionHandlers.js +150 -43
- package/plugins/interact/src/hooks/useInteractionHandlers.test.js +240 -8
- package/plugins/interact/src/reducer.js +7 -0
- package/plugins/interact/src/reducer.test.js +16 -0
- package/providers/beta/openlayers/dist/esm/im-openlayers-provider.js +1 -1
- package/providers/beta/openlayers/dist/umd/im-openlayers-provider.js +1 -1
- package/providers/beta/openlayers/dist/umd/index.js +1 -1
- package/providers/beta/openlayers/src/appEvents.js +9 -13
- package/providers/beta/openlayers/src/appEvents.test.js +25 -17
- package/providers/beta/openlayers/src/mapEvents.js +32 -16
- package/providers/beta/openlayers/src/mapEvents.test.js +43 -10
- package/providers/beta/openlayers/src/openlayersProvider.js +45 -26
- package/providers/beta/openlayers/src/openlayersProvider.test.js +12 -34
- package/providers/beta/openlayers/src/utils/highlightFeatures.js +245 -0
- package/providers/beta/openlayers/src/utils/hoverCursor.js +65 -0
- package/providers/beta/openlayers/src/utils/hoverCursor.test.js +235 -0
- package/providers/beta/openlayers/src/utils/openLayersFixes.js +11 -0
- package/providers/beta/openlayers/src/utils/openLayersFixes.test.js +53 -0
- package/providers/beta/openlayers/src/utils/queryFeatures.js +77 -0
- package/providers/beta/openlayers/src/utils/queryFeatures.test.js +181 -0
- package/providers/beta/openlayers/src/utils/tileLayers.js +19 -1
- package/providers/beta/openlayers/src/utils/tileLayers.test.js +74 -3
- package/providers/beta/openlayers/src/utils/vtTileFragments.js +86 -0
- package/rollup.esm.mjs +6 -0
- package/src/App/controls/keyboardActions.js +2 -2
- package/src/InteractiveMap/InteractiveMap.js +38 -10
- package/src/InteractiveMap/InteractiveMap.test.js +282 -356
- package/src/InteractiveMap/historyManager.js +7 -0
- package/src/InteractiveMap/historyManager.test.js +11 -0
- package/webpack.dev.mjs +3 -1
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { useCallback, useEffect, useRef } from 'react'
|
|
2
|
-
import { areAllContiguous } from '../utils/spatial.js'
|
|
2
|
+
import { areAllContiguous, isContiguousWithAny } from '../utils/spatial.js'
|
|
3
3
|
import { getFeaturesAtPoint, findMatchingFeature, buildLayerConfigMap } from '../utils/featureQueries.js'
|
|
4
4
|
import { scaleFactor } from '../../../../src/config/appConfig.js'
|
|
5
5
|
import { isStandaloneLabel } from '../../../../src/utils/symbolUtils.js'
|
|
@@ -16,7 +16,6 @@ import { isStandaloneLabel } from '../../../../src/utils/symbolUtils.js'
|
|
|
16
16
|
* @param {number} scale - scaleFactor for the current mapSize (e.g. 1.5 for medium)
|
|
17
17
|
* @returns {string|null}
|
|
18
18
|
*/
|
|
19
|
-
|
|
20
19
|
const findMarkerAtPoint = (markers, point, scale) => {
|
|
21
20
|
for (const marker of markers.items) {
|
|
22
21
|
const el = markers.markerRefs?.get(marker.id)
|
|
@@ -42,13 +41,11 @@ const useSelectionChangeEmitter = (eventBus, selectedFeatures, selectedMarkers,
|
|
|
42
41
|
const lastEmittedSelectionChange = useRef(null)
|
|
43
42
|
|
|
44
43
|
useEffect(() => {
|
|
45
|
-
// Skip if features exist but bounds not yet calculated
|
|
46
44
|
const awaitingBounds = selectedFeatures.length > 0 && !selectionBounds
|
|
47
45
|
if (awaitingBounds) {
|
|
48
46
|
return
|
|
49
47
|
}
|
|
50
48
|
|
|
51
|
-
// Skip if selection was already empty and remains empty
|
|
52
49
|
const prev = lastEmittedSelectionChange.current
|
|
53
50
|
const wasEmpty = prev === null || (prev.features.length === 0 && prev.markers.length === 0)
|
|
54
51
|
if (wasEmpty && selectedFeatures.length === 0 && selectedMarkers.length === 0) {
|
|
@@ -66,6 +63,109 @@ const useSelectionChangeEmitter = (eventBus, selectedFeatures, selectedMarkers,
|
|
|
66
63
|
}, [selectedFeatures, selectedMarkers, selectionBounds])
|
|
67
64
|
}
|
|
68
65
|
|
|
66
|
+
/**
|
|
67
|
+
* Given a set of selected features, returns the largest contiguous sub-group
|
|
68
|
+
* that stays connected to features[0]. Used when deselecting a feature that
|
|
69
|
+
* may act as a bridge between two otherwise disconnected parts of the selection.
|
|
70
|
+
*
|
|
71
|
+
* Uses flood-fill: starts from features[0] and repeatedly adds any feature that
|
|
72
|
+
* touches the growing connected set, until no more can be added.
|
|
73
|
+
*
|
|
74
|
+
* @param {Array} features - Current selection minus the feature being deselected.
|
|
75
|
+
* Each feature must have an enriched `geometry` (set by the provider at selection time).
|
|
76
|
+
* @returns {Array|null} The connected sub-group anchored at features[0],
|
|
77
|
+
* or null if all features are already contiguous (no trimming needed).
|
|
78
|
+
*/
|
|
79
|
+
const trimToContiguousGroup = (features) => {
|
|
80
|
+
const connected = new Set([0])
|
|
81
|
+
let changed = true
|
|
82
|
+
while (changed) {
|
|
83
|
+
changed = false
|
|
84
|
+
for (let i = 1; i < features.length; i++) {
|
|
85
|
+
if (connected.has(i)) {
|
|
86
|
+
continue
|
|
87
|
+
}
|
|
88
|
+
const connectedFeatures = [...connected].map(idx => features[idx])
|
|
89
|
+
if (features[i].geometry?.type && isContiguousWithAny(features[i], connectedFeatures)) {
|
|
90
|
+
connected.add(i)
|
|
91
|
+
changed = true
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
if (connected.size === features.length) {
|
|
96
|
+
return null
|
|
97
|
+
}
|
|
98
|
+
return [...connected].map(idx => features[idx])
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const buildTogglePayload = (featureId, multiSelect, config, feature) => ({
|
|
102
|
+
featureId,
|
|
103
|
+
multiSelect,
|
|
104
|
+
layerId: config.layerId,
|
|
105
|
+
idProperty: config.idProperty,
|
|
106
|
+
properties: feature.properties,
|
|
107
|
+
geometry: feature.geometry
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Enforces the contiguous selection constraint when a feature is clicked.
|
|
112
|
+
* Intercepts the click and dispatches its own action when needed, returning
|
|
113
|
+
* true so the caller knows not to dispatch again.
|
|
114
|
+
*
|
|
115
|
+
* Two cases are handled:
|
|
116
|
+
* - Deselecting a feature: if removing it would split the remaining selection
|
|
117
|
+
* into disconnected parts, the selection is trimmed to the group that stays
|
|
118
|
+
* connected to the first selected feature, rather than allowing a split.
|
|
119
|
+
* - Adding a feature: if the new feature does not touch any already-selected
|
|
120
|
+
* feature, the entire existing selection is replaced rather than extended,
|
|
121
|
+
* keeping the selection contiguous.
|
|
122
|
+
*
|
|
123
|
+
* Returns false (no-op) when the click is a straightforward toggle that does
|
|
124
|
+
* not violate contiguity, leaving the caller to dispatch normally.
|
|
125
|
+
*
|
|
126
|
+
* @param {{ featureId, feature, config, selectedFeatures, dispatch, multiSelect }} params
|
|
127
|
+
* feature.geometry must already be enriched by the provider before calling.
|
|
128
|
+
* @returns {boolean} True if this function dispatched; false if the caller should.
|
|
129
|
+
*/
|
|
130
|
+
const resolveContiguousDispatch = ({ featureId, feature, config, selectedFeatures, dispatch, multiSelect }) => {
|
|
131
|
+
if (!selectedFeatures.length) {
|
|
132
|
+
return false
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const existingIndex = selectedFeatures.findIndex(
|
|
136
|
+
f => f.featureId === featureId && f.layerId === config.layerId
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
if (existingIndex !== -1) {
|
|
140
|
+
// Deselect: trim to first connected group if the removal splits the selection.
|
|
141
|
+
if (selectedFeatures.length < 3) { // NOSONAR
|
|
142
|
+
return false
|
|
143
|
+
}
|
|
144
|
+
const remaining = selectedFeatures.filter((_, i) => i !== existingIndex)
|
|
145
|
+
const trimmed = trimToContiguousGroup(remaining)
|
|
146
|
+
if (!trimmed) {
|
|
147
|
+
return false
|
|
148
|
+
}
|
|
149
|
+
dispatch({ type: 'SET_SELECTED_FEATURES', payload: trimmed })
|
|
150
|
+
return true
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Add: replace selection if the new feature doesn't touch any existing feature.
|
|
154
|
+
const validSelected = selectedFeatures.filter(f => f.geometry?.type)
|
|
155
|
+
if (!feature.geometry?.type || !validSelected.length) {
|
|
156
|
+
return false
|
|
157
|
+
}
|
|
158
|
+
if (isContiguousWithAny(feature, validSelected)) {
|
|
159
|
+
return false
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
dispatch({
|
|
163
|
+
type: 'TOGGLE_SELECTED_FEATURES',
|
|
164
|
+
payload: { ...buildTogglePayload(featureId, multiSelect, config, feature), replaceAll: true }
|
|
165
|
+
})
|
|
166
|
+
return true
|
|
167
|
+
}
|
|
168
|
+
|
|
69
169
|
/**
|
|
70
170
|
* Core interaction hook. Processes map clicks in fixed priority order:
|
|
71
171
|
* selectMarker → selectFeature → placeMarker (fallback).
|
|
@@ -81,30 +181,60 @@ const useSelectionChangeEmitter = (eventBus, selectedFeatures, selectedMarkers,
|
|
|
81
181
|
* @param {Object} deps.mapProvider - Map provider instance for feature queries
|
|
82
182
|
* @returns {{ handleInteraction: Function }}
|
|
83
183
|
*/
|
|
184
|
+
const useHandleInteraction = ({ mapProvider, layers, interactionModes, multiSelect, dispatch, markers, layerConfigMap, debug, tolerance, processFeatureMatch, processFallback, scale }) => {
|
|
185
|
+
return useCallback(({ point, coords }) => {
|
|
186
|
+
const debugFeatures = debug ? getFeaturesAtPoint(mapProvider, point, { radius: tolerance }) : null
|
|
187
|
+
if (debugFeatures) {
|
|
188
|
+
console.log(`--- Features at ${coords} ---`, debugFeatures) // NOSONAR
|
|
189
|
+
}
|
|
190
|
+
if (interactionModes.includes('selectMarker')) {
|
|
191
|
+
const markerHit = findMarkerAtPoint(markers, point, scale)
|
|
192
|
+
if (markerHit) {
|
|
193
|
+
dispatch({ type: 'TOGGLE_SELECTED_MARKERS', payload: { markerId: markerHit, multiSelect } })
|
|
194
|
+
return
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
if (interactionModes.includes('selectFeature') && layers.length > 0) {
|
|
198
|
+
const allFeatures = debugFeatures ?? getFeaturesAtPoint(mapProvider, point, { radius: tolerance })
|
|
199
|
+
const match = findMatchingFeature(allFeatures, layerConfigMap)
|
|
200
|
+
if (match) {
|
|
201
|
+
processFeatureMatch(match)
|
|
202
|
+
return
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
processFallback({ coords })
|
|
206
|
+
}, [mapProvider, layers, interactionModes, multiSelect, dispatch, markers, layerConfigMap, debug, tolerance, processFeatureMatch, processFallback, scale])
|
|
207
|
+
}
|
|
208
|
+
|
|
84
209
|
export const useInteractionHandlers = ({ mapState, pluginState, services, mapProvider }) => {
|
|
85
210
|
const { markers, mapSize } = mapState
|
|
86
|
-
const {
|
|
211
|
+
const {
|
|
212
|
+
dispatch, layers, interactionModes, multiSelect, contiguous,
|
|
213
|
+
marker: markerOptions, tolerance, selectedFeatures, selectedMarkers,
|
|
214
|
+
selectionBounds, deselectOnClickOutside
|
|
215
|
+
} = pluginState
|
|
87
216
|
const { eventBus } = services
|
|
88
217
|
const layerConfigMap = buildLayerConfigMap(layers)
|
|
89
218
|
const scale = scaleFactor[mapSize] ?? 1
|
|
219
|
+
|
|
90
220
|
const processFeatureMatch = useCallback(({ feature, config }) => {
|
|
91
221
|
markers.remove('location')
|
|
92
222
|
const featureId = feature.properties?.[config.idProperty] ?? feature.id
|
|
93
223
|
if (featureId == null) {
|
|
94
224
|
return
|
|
95
225
|
}
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
multiSelect
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
geometry: feature.geometry
|
|
226
|
+
const enrichedGeometry = mapProvider.getFeatureGeometry?.(config.layerId, featureId, config.idProperty)
|
|
227
|
+
const enrichedFeature = enrichedGeometry ? { ...feature, geometry: enrichedGeometry } : feature
|
|
228
|
+
if (contiguous && multiSelect) {
|
|
229
|
+
const handled = resolveContiguousDispatch(
|
|
230
|
+
{ featureId, feature: enrichedFeature, config, selectedFeatures, dispatch, multiSelect }
|
|
231
|
+
)
|
|
232
|
+
if (handled) {
|
|
233
|
+
return
|
|
105
234
|
}
|
|
106
|
-
}
|
|
107
|
-
|
|
235
|
+
}
|
|
236
|
+
dispatch({ type: 'TOGGLE_SELECTED_FEATURES', payload: buildTogglePayload(featureId, multiSelect, config, enrichedFeature) })
|
|
237
|
+
}, [markers, dispatch, multiSelect, contiguous, selectedFeatures, mapProvider])
|
|
108
238
|
|
|
109
239
|
const processFallback = useCallback(({ coords }) => {
|
|
110
240
|
const canPlace = interactionModes.includes('placeMarker')
|
|
@@ -118,31 +248,7 @@ export const useInteractionHandlers = ({ mapState, pluginState, services, mapPro
|
|
|
118
248
|
}
|
|
119
249
|
}, [interactionModes, dispatch, markers, markerOptions, eventBus, deselectOnClickOutside])
|
|
120
250
|
|
|
121
|
-
const handleInteraction =
|
|
122
|
-
const debugFeatures = pluginState?.debug ? getFeaturesAtPoint(mapProvider, point, { radius: tolerance }) : null
|
|
123
|
-
if (debugFeatures) {
|
|
124
|
-
console.log(`--- Features at ${coords} ---`, debugFeatures) // NOSONAR
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
if (interactionModes.includes('selectMarker')) {
|
|
128
|
-
const markerHit = findMarkerAtPoint(markers, point, scale)
|
|
129
|
-
if (markerHit) {
|
|
130
|
-
dispatch({ type: 'TOGGLE_SELECTED_MARKERS', payload: { markerId: markerHit, multiSelect } })
|
|
131
|
-
return
|
|
132
|
-
}
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
if (interactionModes.includes('selectFeature') && layers.length > 0) {
|
|
136
|
-
const allFeatures = debugFeatures ?? getFeaturesAtPoint(mapProvider, point, { radius: tolerance })
|
|
137
|
-
const match = findMatchingFeature(allFeatures, layerConfigMap)
|
|
138
|
-
if (match) {
|
|
139
|
-
processFeatureMatch(match)
|
|
140
|
-
return
|
|
141
|
-
}
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
processFallback({ coords })
|
|
145
|
-
}, [
|
|
251
|
+
const handleInteraction = useHandleInteraction({
|
|
146
252
|
mapProvider,
|
|
147
253
|
layers,
|
|
148
254
|
interactionModes,
|
|
@@ -150,12 +256,13 @@ export const useInteractionHandlers = ({ mapState, pluginState, services, mapPro
|
|
|
150
256
|
dispatch,
|
|
151
257
|
markers,
|
|
152
258
|
layerConfigMap,
|
|
153
|
-
pluginState?.debug,
|
|
259
|
+
debug: pluginState?.debug,
|
|
154
260
|
tolerance,
|
|
155
261
|
processFeatureMatch,
|
|
156
262
|
processFallback,
|
|
157
263
|
scale
|
|
158
|
-
|
|
264
|
+
})
|
|
265
|
+
|
|
159
266
|
useSelectionChangeEmitter(eventBus, selectedFeatures, selectedMarkers, selectionBounds)
|
|
160
267
|
return { handleInteraction }
|
|
161
268
|
}
|
|
@@ -5,15 +5,33 @@ import * as featureQueries from '../utils/featureQueries.js'
|
|
|
5
5
|
/* Mocks */
|
|
6
6
|
/* ------------------------------------------------------------------ */
|
|
7
7
|
|
|
8
|
-
jest.mock('../utils/spatial.js', () => ({
|
|
9
|
-
areAllContiguous: jest.fn(() => false)
|
|
10
|
-
}))
|
|
11
8
|
jest.mock('../utils/featureQueries.js', () => ({
|
|
12
9
|
getFeaturesAtPoint: jest.fn(),
|
|
13
10
|
findMatchingFeature: jest.fn(),
|
|
14
11
|
buildLayerConfigMap: jest.fn(() => ({}))
|
|
15
12
|
}))
|
|
16
13
|
|
|
14
|
+
/* ------------------------------------------------------------------ */
|
|
15
|
+
/* Real geometries — unit squares along the x-axis */
|
|
16
|
+
/* */
|
|
17
|
+
/* geomA geomB geomC geomD (isolated) */
|
|
18
|
+
/* [0-1] [1-2] [2-3] ... [10-11] */
|
|
19
|
+
/* A and B share the x=1 edge (contiguous) */
|
|
20
|
+
/* B and C share the x=2 edge (contiguous) */
|
|
21
|
+
/* A and C do NOT touch (non-contiguous) */
|
|
22
|
+
/* D does not touch any of A, B, or C */
|
|
23
|
+
/* ------------------------------------------------------------------ */
|
|
24
|
+
|
|
25
|
+
const square = (x0, y0, x1, y1) => ({
|
|
26
|
+
type: 'Polygon',
|
|
27
|
+
coordinates: [[[x0, y0], [x1, y0], [x1, y1], [x0, y1], [x0, y0]]]
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
const geomA = square(0, 0, 1, 1)
|
|
31
|
+
const geomB = square(1, 0, 2, 1) // NOSONAR
|
|
32
|
+
const geomC = square(2, 0, 3, 1) // NOSONAR
|
|
33
|
+
const geomD = square(10, 0, 11, 1) // NOSONAR
|
|
34
|
+
|
|
17
35
|
/* ------------------------------------------------------------------ */
|
|
18
36
|
/* Helpers */
|
|
19
37
|
/* ------------------------------------------------------------------ */
|
|
@@ -53,7 +71,7 @@ const setup = (pluginOverrides = {}, markerItems = [], markerRefs = new Map()) =
|
|
|
53
71
|
services: {
|
|
54
72
|
eventBus: { emit: jest.fn() }
|
|
55
73
|
},
|
|
56
|
-
mapProvider: {}
|
|
74
|
+
mapProvider: { getFeatureGeometry: jest.fn(() => null) }
|
|
57
75
|
}
|
|
58
76
|
|
|
59
77
|
const utils = renderHook(() => useInteractionHandlers(deps))
|
|
@@ -62,7 +80,7 @@ const setup = (pluginOverrides = {}, markerItems = [], markerRefs = new Map()) =
|
|
|
62
80
|
|
|
63
81
|
const baseFeature = {
|
|
64
82
|
properties: { parcelId: 'P1' },
|
|
65
|
-
geometry:
|
|
83
|
+
geometry: geomB,
|
|
66
84
|
layer: { id: 'parcels' }
|
|
67
85
|
}
|
|
68
86
|
|
|
@@ -336,7 +354,7 @@ it('does not emit selectionchange when features are selected but bounds not yet
|
|
|
336
354
|
selectionBounds: null
|
|
337
355
|
},
|
|
338
356
|
services: { eventBus: { emit: jest.fn() } },
|
|
339
|
-
mapProvider: {}
|
|
357
|
+
mapProvider: { getFeatureGeometry: jest.fn(() => null) }
|
|
340
358
|
}
|
|
341
359
|
|
|
342
360
|
renderHook(() => useInteractionHandlers(deps))
|
|
@@ -353,7 +371,7 @@ it('emits selectionchange once when bounds exist', () => {
|
|
|
353
371
|
selectionBounds: { sw: [0, 0], ne: [1, 1] }
|
|
354
372
|
},
|
|
355
373
|
services: { eventBus: { emit: jest.fn() } },
|
|
356
|
-
mapProvider: {}
|
|
374
|
+
mapProvider: { getFeatureGeometry: jest.fn(() => null) }
|
|
357
375
|
}
|
|
358
376
|
|
|
359
377
|
renderHook(() => useInteractionHandlers(deps))
|
|
@@ -378,7 +396,7 @@ it('skips emission when selection remains empty after being cleared', () => {
|
|
|
378
396
|
mapState: { markers: { items: [], markerRefs: new Map() } },
|
|
379
397
|
pluginState: { selectedFeatures: features, selectedMarkers: [], selectionBounds: { b: 1 } },
|
|
380
398
|
services: { eventBus },
|
|
381
|
-
mapProvider: {}
|
|
399
|
+
mapProvider: { getFeatureGeometry: jest.fn(() => null) }
|
|
382
400
|
}),
|
|
383
401
|
{ initialProps: { features: [{ id: 'f1' }] } }
|
|
384
402
|
)
|
|
@@ -418,3 +436,217 @@ it('logs features when debug mode is enabled', () => {
|
|
|
418
436
|
|
|
419
437
|
logSpy.mockRestore()
|
|
420
438
|
})
|
|
439
|
+
|
|
440
|
+
/* ------------------------------------------------------------------ */
|
|
441
|
+
/* contiguous enforcement */
|
|
442
|
+
/* existingFeature uses geomA; baseFeature (clicked) uses geomB. */
|
|
443
|
+
/* geomA and geomB share the x=1 edge so they are contiguous. */
|
|
444
|
+
/* ------------------------------------------------------------------ */
|
|
445
|
+
|
|
446
|
+
const existingFeature = { featureId: 'P0', layerId: 'parcels', geometry: geomA }
|
|
447
|
+
|
|
448
|
+
describe('contiguous enforcement — selecting features', () => {
|
|
449
|
+
it('allows first selection when no features already selected', () => {
|
|
450
|
+
const { result, deps } = setup({ contiguous: true, multiSelect: true, selectedFeatures: [] })
|
|
451
|
+
|
|
452
|
+
click(result)
|
|
453
|
+
|
|
454
|
+
expect(deps.pluginState.dispatch).toHaveBeenCalledWith(
|
|
455
|
+
expect.objectContaining({ type: 'TOGGLE_SELECTED_FEATURES' })
|
|
456
|
+
)
|
|
457
|
+
})
|
|
458
|
+
|
|
459
|
+
it('allows adding a contiguous feature', () => {
|
|
460
|
+
// baseFeature has geomB which shares an edge with geomA (existingFeature)
|
|
461
|
+
const { result, deps } = setup({ contiguous: true, multiSelect: true, selectedFeatures: [existingFeature] })
|
|
462
|
+
|
|
463
|
+
click(result)
|
|
464
|
+
|
|
465
|
+
expect(deps.pluginState.dispatch).toHaveBeenCalledWith(
|
|
466
|
+
expect.objectContaining({ type: 'TOGGLE_SELECTED_FEATURES' })
|
|
467
|
+
)
|
|
468
|
+
})
|
|
469
|
+
|
|
470
|
+
it('replaces selection when clicking a non-contiguous feature', () => {
|
|
471
|
+
// geomD is isolated from geomA — replaceAll: true replaces rather than extends the selection
|
|
472
|
+
featureQueries.findMatchingFeature.mockReturnValue({
|
|
473
|
+
feature: { ...baseFeature, geometry: geomD },
|
|
474
|
+
config: { layerId: 'parcels', idProperty: 'parcelId' }
|
|
475
|
+
})
|
|
476
|
+
const { result, deps } = setup({ contiguous: true, multiSelect: true, selectedFeatures: [existingFeature] })
|
|
477
|
+
|
|
478
|
+
click(result)
|
|
479
|
+
|
|
480
|
+
expect(deps.pluginState.dispatch).toHaveBeenCalledWith(
|
|
481
|
+
expect.objectContaining({ type: 'TOGGLE_SELECTED_FEATURES', payload: expect.objectContaining({ replaceAll: true }) })
|
|
482
|
+
)
|
|
483
|
+
})
|
|
484
|
+
|
|
485
|
+
it('allows deselecting an already-selected feature regardless of contiguity', () => {
|
|
486
|
+
const alreadySelected = { featureId: 'P1', layerId: 'parcels', geometry: geomB }
|
|
487
|
+
const { result, deps } = setup({ contiguous: true, multiSelect: true, selectedFeatures: [alreadySelected] })
|
|
488
|
+
|
|
489
|
+
click(result)
|
|
490
|
+
|
|
491
|
+
expect(deps.pluginState.dispatch).toHaveBeenCalledWith(
|
|
492
|
+
expect.objectContaining({ type: 'TOGGLE_SELECTED_FEATURES' })
|
|
493
|
+
)
|
|
494
|
+
})
|
|
495
|
+
})
|
|
496
|
+
|
|
497
|
+
describe('contiguous enforcement — bypass conditions', () => {
|
|
498
|
+
it('does not enforce contiguous when contiguous is false', () => {
|
|
499
|
+
// geomD is isolated from geomA but contiguous enforcement is disabled
|
|
500
|
+
featureQueries.findMatchingFeature.mockReturnValue({
|
|
501
|
+
feature: { ...baseFeature, geometry: geomD },
|
|
502
|
+
config: { layerId: 'parcels', idProperty: 'parcelId' }
|
|
503
|
+
})
|
|
504
|
+
const { result, deps } = setup({ contiguous: false, multiSelect: true, selectedFeatures: [existingFeature] })
|
|
505
|
+
|
|
506
|
+
click(result)
|
|
507
|
+
|
|
508
|
+
expect(deps.pluginState.dispatch).toHaveBeenCalledWith(
|
|
509
|
+
expect.objectContaining({ type: 'TOGGLE_SELECTED_FEATURES' })
|
|
510
|
+
)
|
|
511
|
+
})
|
|
512
|
+
|
|
513
|
+
it('does not enforce contiguous in single-select mode', () => {
|
|
514
|
+
// geomD is isolated but contiguous enforcement only applies in multi-select
|
|
515
|
+
featureQueries.findMatchingFeature.mockReturnValue({
|
|
516
|
+
feature: { ...baseFeature, geometry: geomD },
|
|
517
|
+
config: { layerId: 'parcels', idProperty: 'parcelId' }
|
|
518
|
+
})
|
|
519
|
+
const { result, deps } = setup({ contiguous: true, multiSelect: false, selectedFeatures: [existingFeature] })
|
|
520
|
+
|
|
521
|
+
click(result)
|
|
522
|
+
|
|
523
|
+
expect(deps.pluginState.dispatch).toHaveBeenCalledWith(
|
|
524
|
+
expect.objectContaining({ type: 'TOGGLE_SELECTED_FEATURES' })
|
|
525
|
+
)
|
|
526
|
+
})
|
|
527
|
+
|
|
528
|
+
it('falls through to normal toggle when selected features have no usable geometry', () => {
|
|
529
|
+
const noGeomFeature = { featureId: 'P0', layerId: 'parcels' }
|
|
530
|
+
const { result, deps } = setup({ contiguous: true, multiSelect: true, selectedFeatures: [noGeomFeature] })
|
|
531
|
+
|
|
532
|
+
click(result)
|
|
533
|
+
|
|
534
|
+
expect(deps.pluginState.dispatch).toHaveBeenCalledWith(
|
|
535
|
+
expect.objectContaining({ type: 'TOGGLE_SELECTED_FEATURES', payload: expect.not.objectContaining({ replaceAll: true }) })
|
|
536
|
+
)
|
|
537
|
+
})
|
|
538
|
+
})
|
|
539
|
+
|
|
540
|
+
describe('contiguous enforcement — geometry enrichment', () => {
|
|
541
|
+
it('uses the geometry returned by the provider as the dispatched geometry', () => {
|
|
542
|
+
const enriched = { type: 'MultiPolygon', coordinates: [[[[0, 0], [1, 0], [1, 1], [0, 0]]]] }
|
|
543
|
+
const { result, deps } = setup({ contiguous: false, multiSelect: true, selectedFeatures: [] })
|
|
544
|
+
deps.mapProvider.getFeatureGeometry.mockReturnValue(enriched)
|
|
545
|
+
|
|
546
|
+
click(result)
|
|
547
|
+
|
|
548
|
+
expect(deps.pluginState.dispatch).toHaveBeenCalledWith(
|
|
549
|
+
expect.objectContaining({ payload: expect.objectContaining({ geometry: enriched }) })
|
|
550
|
+
)
|
|
551
|
+
})
|
|
552
|
+
|
|
553
|
+
it('uses stored geometry when provider returns null', () => {
|
|
554
|
+
const { result, deps } = setup({ contiguous: false, multiSelect: true, selectedFeatures: [] })
|
|
555
|
+
deps.mapProvider.getFeatureGeometry.mockReturnValue(null)
|
|
556
|
+
|
|
557
|
+
click(result)
|
|
558
|
+
|
|
559
|
+
expect(deps.pluginState.dispatch).toHaveBeenCalledWith(
|
|
560
|
+
expect.objectContaining({ payload: expect.objectContaining({ geometry: baseFeature.geometry }) })
|
|
561
|
+
)
|
|
562
|
+
})
|
|
563
|
+
|
|
564
|
+
it('uses stored geometry when provider has no getFeatureGeometry', () => {
|
|
565
|
+
const { result, deps } = setup({ contiguous: false, multiSelect: true, selectedFeatures: [] })
|
|
566
|
+
deps.mapProvider.getFeatureGeometry = undefined
|
|
567
|
+
|
|
568
|
+
click(result)
|
|
569
|
+
|
|
570
|
+
expect(deps.pluginState.dispatch).toHaveBeenCalledWith(
|
|
571
|
+
expect.objectContaining({ payload: expect.objectContaining({ geometry: baseFeature.geometry }) })
|
|
572
|
+
)
|
|
573
|
+
})
|
|
574
|
+
|
|
575
|
+
it('falls through to normal toggle when clicked feature has no geometry', () => {
|
|
576
|
+
featureQueries.findMatchingFeature.mockReturnValue({
|
|
577
|
+
feature: { properties: { parcelId: 'P1' }, geometry: null, layer: { id: 'parcels' } },
|
|
578
|
+
config: { layerId: 'parcels', idProperty: 'parcelId' }
|
|
579
|
+
})
|
|
580
|
+
const { result, deps } = setup({ contiguous: true, multiSelect: true, selectedFeatures: [existingFeature] })
|
|
581
|
+
|
|
582
|
+
click(result)
|
|
583
|
+
|
|
584
|
+
expect(deps.pluginState.dispatch).toHaveBeenCalledWith(
|
|
585
|
+
expect.objectContaining({ type: 'TOGGLE_SELECTED_FEATURES' })
|
|
586
|
+
)
|
|
587
|
+
})
|
|
588
|
+
})
|
|
589
|
+
|
|
590
|
+
describe('contiguous enforcement — deselect splits', () => {
|
|
591
|
+
// A=[0-1], B=[1-2], C=[2-3]: A-B touch, B-C touch, but A-C do not touch
|
|
592
|
+
const featureA = { featureId: 'P0', layerId: 'parcels', geometry: geomA }
|
|
593
|
+
const featureB = { featureId: 'P1', layerId: 'parcels', geometry: geomB }
|
|
594
|
+
const featureC = { featureId: 'P2', layerId: 'parcels', geometry: geomC }
|
|
595
|
+
|
|
596
|
+
it('trims to first contiguous group when deselecting the bridge feature', () => {
|
|
597
|
+
// A-B-C selected; deselect B (baseFeature returns P1); remaining [A, C] are non-contiguous
|
|
598
|
+
// flood-fill from A cannot reach C — result is trimmed to [A]
|
|
599
|
+
const { result, deps } = setup({
|
|
600
|
+
contiguous: true,
|
|
601
|
+
multiSelect: true,
|
|
602
|
+
selectedFeatures: [featureA, featureB, featureC]
|
|
603
|
+
})
|
|
604
|
+
|
|
605
|
+
click(result) // clicks P1 = featureB
|
|
606
|
+
|
|
607
|
+
expect(deps.pluginState.dispatch).toHaveBeenCalledWith({
|
|
608
|
+
type: 'SET_SELECTED_FEATURES',
|
|
609
|
+
payload: [featureA]
|
|
610
|
+
})
|
|
611
|
+
})
|
|
612
|
+
|
|
613
|
+
it('uses normal toggle when deselecting an end feature leaves a contiguous set', () => {
|
|
614
|
+
// A-B-C selected; deselect C (override mock to return P2); remaining [A, B] share the x=1 edge
|
|
615
|
+
featureQueries.findMatchingFeature.mockReturnValue({
|
|
616
|
+
feature: { properties: { parcelId: 'P2' }, geometry: { type: 'Polygon' }, layer: { id: 'parcels' } },
|
|
617
|
+
config: { layerId: 'parcels', idProperty: 'parcelId' }
|
|
618
|
+
})
|
|
619
|
+
const { result, deps } = setup({
|
|
620
|
+
contiguous: true,
|
|
621
|
+
multiSelect: true,
|
|
622
|
+
selectedFeatures: [featureA, featureB, featureC]
|
|
623
|
+
})
|
|
624
|
+
|
|
625
|
+
click(result) // clicks P2 = featureC
|
|
626
|
+
|
|
627
|
+
expect(deps.pluginState.dispatch).toHaveBeenCalledWith(
|
|
628
|
+
expect.objectContaining({ type: 'TOGGLE_SELECTED_FEATURES' })
|
|
629
|
+
)
|
|
630
|
+
expect(deps.pluginState.dispatch).not.toHaveBeenCalledWith(
|
|
631
|
+
expect.objectContaining({ type: 'SET_SELECTED_FEATURES' })
|
|
632
|
+
)
|
|
633
|
+
})
|
|
634
|
+
|
|
635
|
+
it('does not check for split when fewer than 3 features selected', () => {
|
|
636
|
+
// Only 2 features — the split-check threshold is < 3, so goes straight to normal toggle
|
|
637
|
+
const { result, deps } = setup({
|
|
638
|
+
contiguous: true,
|
|
639
|
+
multiSelect: true,
|
|
640
|
+
selectedFeatures: [featureA, featureB]
|
|
641
|
+
})
|
|
642
|
+
|
|
643
|
+
click(result) // clicks P1 = featureB
|
|
644
|
+
|
|
645
|
+
expect(deps.pluginState.dispatch).toHaveBeenCalledWith(
|
|
646
|
+
expect.objectContaining({ type: 'TOGGLE_SELECTED_FEATURES' })
|
|
647
|
+
)
|
|
648
|
+
expect(deps.pluginState.dispatch).not.toHaveBeenCalledWith(
|
|
649
|
+
expect.objectContaining({ type: 'SET_SELECTED_FEATURES' })
|
|
650
|
+
)
|
|
651
|
+
})
|
|
652
|
+
})
|
|
@@ -143,6 +143,12 @@ const unselectMarker = (state, { markerId }) => {
|
|
|
143
143
|
}
|
|
144
144
|
}
|
|
145
145
|
|
|
146
|
+
const setSelectedFeatures = (state, features) => ({
|
|
147
|
+
...state,
|
|
148
|
+
selectedFeatures: features,
|
|
149
|
+
selectionBounds: null
|
|
150
|
+
})
|
|
151
|
+
|
|
146
152
|
const actions = {
|
|
147
153
|
ENABLE: enable,
|
|
148
154
|
DISABLE: disable,
|
|
@@ -150,6 +156,7 @@ const actions = {
|
|
|
150
156
|
TOGGLE_SELECTED_MARKERS: toggleSelectedMarkers,
|
|
151
157
|
UPDATE_SELECTED_BOUNDS: updateSelectedBounds,
|
|
152
158
|
CLEAR_SELECTED_FEATURES: clearSelectedFeatures,
|
|
159
|
+
SET_SELECTED_FEATURES: setSelectedFeatures,
|
|
153
160
|
SELECT_MARKER: selectMarker,
|
|
154
161
|
UNSELECT_MARKER: unselectMarker,
|
|
155
162
|
SET_LISTBOX_ACTIVE: setListboxActive
|
|
@@ -240,6 +240,21 @@ describe('UNSELECT_MARKER action', () => {
|
|
|
240
240
|
})
|
|
241
241
|
})
|
|
242
242
|
|
|
243
|
+
describe('SET_SELECTED_FEATURES action', () => {
|
|
244
|
+
it('replaces selectedFeatures and clears bounds', () => {
|
|
245
|
+
const state = {
|
|
246
|
+
...initialState,
|
|
247
|
+
selectedFeatures: [{ featureId: 'A' }, { featureId: 'B' }, { featureId: 'C' }],
|
|
248
|
+
selectionBounds: [0, 0, 1, 1]
|
|
249
|
+
}
|
|
250
|
+
const trimmed = [{ featureId: 'A' }]
|
|
251
|
+
const result = actions.SET_SELECTED_FEATURES(state, trimmed)
|
|
252
|
+
expect(result.selectedFeatures).toEqual(trimmed)
|
|
253
|
+
expect(result.selectionBounds).toBeNull()
|
|
254
|
+
expect(result).not.toBe(state)
|
|
255
|
+
})
|
|
256
|
+
})
|
|
257
|
+
|
|
243
258
|
describe('actions object', () => {
|
|
244
259
|
it('exports all action handlers as functions', () => {
|
|
245
260
|
expect(Object.keys(actions)).toEqual([
|
|
@@ -249,6 +264,7 @@ describe('actions object', () => {
|
|
|
249
264
|
'TOGGLE_SELECTED_MARKERS',
|
|
250
265
|
'UPDATE_SELECTED_BOUNDS',
|
|
251
266
|
'CLEAR_SELECTED_FEATURES',
|
|
267
|
+
'SET_SELECTED_FEATURES',
|
|
252
268
|
'SELECT_MARKER',
|
|
253
269
|
'UNSELECT_MARKER',
|
|
254
270
|
'SET_LISTBOX_ACTIVE'
|