@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.
Files changed (92) hide show
  1. package/dist/esm/im-core.js +1 -1
  2. package/dist/esm/im-shell.js +1 -1
  3. package/dist/umd/im-core.js +1 -1
  4. package/dist/umd/index.js +1 -1
  5. package/docs/assets/css/docusaurus.scss +4 -0
  6. package/package.json +1 -1
  7. package/plugins/beta/draw-ml/dist/esm/im-draw-ml-plugin.js +1 -1
  8. package/plugins/beta/draw-ml/dist/umd/im-draw-ml-plugin.js +1 -1
  9. package/plugins/beta/draw-ml/src/defaults.js +14 -14
  10. package/plugins/beta/draw-ml/src/mapboxDraw.js +5 -0
  11. package/plugins/beta/draw-ml/src/mapboxSnap.js +1 -1
  12. package/plugins/beta/draw-ml/src/modes/editVertex/touchHandlers.js +10 -2
  13. package/plugins/beta/draw-ml/src/modes/editVertexMode.js +2 -0
  14. package/plugins/beta/draw-ml/src/reducer.js +4 -1
  15. package/plugins/beta/draw-ml/src/styles.js +42 -39
  16. package/plugins/beta/draw-ol/dist/css/index.css +13 -0
  17. package/plugins/beta/draw-ol/dist/esm/DrawMode.js +1 -0
  18. package/plugins/beta/draw-ol/dist/esm/EditMode.js +1 -0
  19. package/plugins/beta/draw-ol/dist/esm/geometryHelpers.js +1 -0
  20. package/plugins/beta/draw-ol/dist/esm/im-draw-ol-plugin.js +1 -0
  21. package/plugins/beta/draw-ol/dist/esm/index.js +1 -0
  22. package/plugins/beta/draw-ol/src/DrawInit.jsx +67 -0
  23. package/plugins/beta/draw-ol/src/api/addFeature.js +29 -0
  24. package/plugins/beta/draw-ol/src/api/deleteFeature.js +11 -0
  25. package/plugins/beta/draw-ol/src/api/editFeature.js +50 -0
  26. package/plugins/beta/draw-ol/src/api/newLine.js +44 -0
  27. package/plugins/beta/draw-ol/src/api/newPolygon.js +44 -0
  28. package/plugins/beta/draw-ol/src/core/OLDrawManager.js +150 -0
  29. package/plugins/beta/draw-ol/src/core/featureStore.js +65 -0
  30. package/plugins/beta/draw-ol/src/core/styles.js +98 -0
  31. package/plugins/beta/draw-ol/src/core/undoStack.js +31 -0
  32. package/plugins/beta/draw-ol/src/defaults.js +16 -0
  33. package/plugins/beta/draw-ol/src/draw/DrawMode.js +113 -0
  34. package/plugins/beta/draw-ol/src/draw/drawInput.js +199 -0
  35. package/plugins/beta/draw-ol/src/draw.scss +17 -0
  36. package/plugins/beta/draw-ol/src/edit/EditMode.js +424 -0
  37. package/plugins/beta/draw-ol/src/edit/keyboardHandler.js +227 -0
  38. package/plugins/beta/draw-ol/src/edit/midpointLayer.js +57 -0
  39. package/plugins/beta/draw-ol/src/edit/touchHandler.js +171 -0
  40. package/plugins/beta/draw-ol/src/edit/undoOps.js +95 -0
  41. package/plugins/beta/draw-ol/src/edit/vertexHitTest.js +72 -0
  42. package/plugins/beta/draw-ol/src/edit/vertexLayer.js +49 -0
  43. package/plugins/beta/draw-ol/src/edit/vertexOps.js +99 -0
  44. package/plugins/beta/draw-ol/src/events.js +114 -0
  45. package/plugins/beta/draw-ol/src/index.js +12 -0
  46. package/plugins/beta/draw-ol/src/manifest.js +121 -0
  47. package/plugins/beta/draw-ol/src/olDraw.js +38 -0
  48. package/plugins/beta/draw-ol/src/reducer.js +41 -0
  49. package/plugins/beta/draw-ol/src/snap/snapEngine.js +109 -0
  50. package/plugins/beta/draw-ol/src/snap/snapGeometry.js +200 -0
  51. package/plugins/beta/draw-ol/src/snap/snapIndicator.js +81 -0
  52. package/plugins/beta/draw-ol/src/snap/snapInteraction.js +56 -0
  53. package/plugins/beta/draw-ol/src/snap/snapManager.js +90 -0
  54. package/plugins/beta/draw-ol/src/utils/flattenStyleProperties.js +47 -0
  55. package/plugins/beta/draw-ol/src/utils/geometryHelpers.js +97 -0
  56. package/plugins/beta/draw-ol/src/utils/olCoords.js +35 -0
  57. package/plugins/beta/draw-ol/src/utils/resolveColors.js +39 -0
  58. package/plugins/beta/draw-ol/src/utils/spatial.js +28 -0
  59. package/plugins/beta/draw-ol/src/utils/touchTarget.js +61 -0
  60. package/plugins/interact/dist/esm/im-interact-plugin.js +1 -1
  61. package/plugins/interact/dist/umd/im-interact-plugin.js +1 -1
  62. package/plugins/interact/src/defaults.js +1 -0
  63. package/plugins/interact/src/hooks/useInteractionHandlers.js +150 -43
  64. package/plugins/interact/src/hooks/useInteractionHandlers.test.js +240 -8
  65. package/plugins/interact/src/reducer.js +7 -0
  66. package/plugins/interact/src/reducer.test.js +16 -0
  67. package/providers/beta/openlayers/dist/esm/im-openlayers-provider.js +1 -1
  68. package/providers/beta/openlayers/dist/umd/im-openlayers-provider.js +1 -1
  69. package/providers/beta/openlayers/dist/umd/index.js +1 -1
  70. package/providers/beta/openlayers/src/appEvents.js +9 -13
  71. package/providers/beta/openlayers/src/appEvents.test.js +25 -17
  72. package/providers/beta/openlayers/src/mapEvents.js +32 -16
  73. package/providers/beta/openlayers/src/mapEvents.test.js +43 -10
  74. package/providers/beta/openlayers/src/openlayersProvider.js +45 -26
  75. package/providers/beta/openlayers/src/openlayersProvider.test.js +12 -34
  76. package/providers/beta/openlayers/src/utils/highlightFeatures.js +245 -0
  77. package/providers/beta/openlayers/src/utils/hoverCursor.js +65 -0
  78. package/providers/beta/openlayers/src/utils/hoverCursor.test.js +235 -0
  79. package/providers/beta/openlayers/src/utils/openLayersFixes.js +11 -0
  80. package/providers/beta/openlayers/src/utils/openLayersFixes.test.js +53 -0
  81. package/providers/beta/openlayers/src/utils/queryFeatures.js +77 -0
  82. package/providers/beta/openlayers/src/utils/queryFeatures.test.js +181 -0
  83. package/providers/beta/openlayers/src/utils/tileLayers.js +19 -1
  84. package/providers/beta/openlayers/src/utils/tileLayers.test.js +74 -3
  85. package/providers/beta/openlayers/src/utils/vtTileFragments.js +86 -0
  86. package/rollup.esm.mjs +6 -0
  87. package/src/App/controls/keyboardActions.js +2 -2
  88. package/src/InteractiveMap/InteractiveMap.js +38 -10
  89. package/src/InteractiveMap/InteractiveMap.test.js +282 -356
  90. package/src/InteractiveMap/historyManager.js +7 -0
  91. package/src/InteractiveMap/historyManager.test.js +11 -0
  92. 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 { dispatch, layers, interactionModes, multiSelect, marker: markerOptions, tolerance, selectedFeatures, selectedMarkers, selectionBounds, deselectOnClickOutside } = pluginState
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
- dispatch({
97
- type: 'TOGGLE_SELECTED_FEATURES',
98
- payload: {
99
- featureId,
100
- multiSelect,
101
- layerId: config.layerId,
102
- idProperty: config.idProperty,
103
- properties: feature.properties,
104
- geometry: feature.geometry
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
- }, [markers, dispatch, multiSelect])
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 = useCallback(({ point, coords }) => {
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: { type: 'Polygon' },
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'