Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions demo/js/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -62,8 +62,7 @@ const interactPlugin = createInteractPlugin({
}],
debug: true,
interactionModes: ['selectMarker', 'placeMarker', 'selectFeature'], // e.g. ['selectMarker'], ['selectFeature'], ['placeMarker'], or combinations
// multiSelect: true,
contiguous: true,
multiSelect: true,
deselectOnClickOutside: true
})

Expand Down
11 changes: 1 addition & 10 deletions docs/plugins/interact.md
Original file line number Diff line number Diff line change
Expand Up @@ -109,14 +109,6 @@ When `true`, clicking additional features adds them to the selection rather than

---

### `contiguous`
**Type:** `boolean`
**Default:** `false`

When `true`, only features that touch or overlap the existing selection can be added. Uses spatial intersection to determine contiguity. Works with polygons, lines, and points.

---

### `deselectOnClickOutside`
**Type:** `boolean`
**Default:** `false`
Expand Down Expand Up @@ -350,8 +342,7 @@ Emitted whenever the selected features or selected markers change.
],
selectedMarkers: ['...'], // array of selected marker IDs
selectionBounds: [west, south, east, north] | null,
canMerge: boolean, // true when all selected features are contiguous
canSplit: boolean // true when exactly one Polygon or MultiPolygon is selected
contiguous: boolean // true when 2+ features are selected and all form a single contiguous group
}
```

Expand Down
1 change: 0 additions & 1 deletion plugins/interact/src/defaults.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ export const DEFAULTS = {
tolerance: 10,
interactionModes: ['selectMarker'],
multiSelect: false,
contiguous: false,
deselectOnClickOutside: false,
marker: {},
selectedStrokeWidth: 3
Expand Down
13 changes: 5 additions & 8 deletions plugins/interact/src/hooks/useInteractionHandlers.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useRef } from 'react'
import { isContiguousWithAny, canSplitFeatures, areAllContiguous } from '../utils/spatial.js'
import { areAllContiguous } from '../utils/spatial.js'
import { getFeaturesAtPoint, findMatchingFeature, buildLayerConfigMap } from '../utils/featureQueries.js'
import { scaleFactor } from '../../../../src/config/appConfig.js'
import { isStandaloneLabel } from '../../../../src/utils/symbolUtils.js'
Expand Down Expand Up @@ -59,8 +59,7 @@ const useSelectionChangeEmitter = (eventBus, selectedFeatures, selectedMarkers,
selectedFeatures,
selectedMarkers,
selectionBounds,
canMerge: areAllContiguous(selectedFeatures),
canSplit: canSplitFeatures(selectedFeatures)
contiguous: areAllContiguous(selectedFeatures)
})

lastEmittedSelectionChange.current = { features: selectedFeatures, markers: selectedMarkers }
Expand All @@ -84,13 +83,12 @@ const useSelectionChangeEmitter = (eventBus, selectedFeatures, selectedMarkers,
*/
export const useInteractionHandlers = ({ mapState, pluginState, services, mapProvider }) => {
const { markers, mapSize } = mapState
const { dispatch, layers, interactionModes, multiSelect, contiguous, marker: markerOptions, tolerance, selectedFeatures, selectedMarkers, selectionBounds, deselectOnClickOutside } = pluginState
const { dispatch, layers, interactionModes, multiSelect, marker: markerOptions, tolerance, selectedFeatures, selectedMarkers, selectionBounds, deselectOnClickOutside } = pluginState
const { eventBus } = services
const layerConfigMap = buildLayerConfigMap(layers)
const scale = scaleFactor[mapSize] ?? 1
const processFeatureMatch = useCallback(({ feature, config }) => {
markers.remove('location')
const isNewContiguous = contiguous && isContiguousWithAny(feature, selectedFeatures)
const featureId = feature.properties?.[config.idProperty] ?? feature.id
if (featureId == null) {
return
Expand All @@ -103,11 +101,10 @@ export const useInteractionHandlers = ({ mapState, pluginState, services, mapPro
layerId: config.layerId,
idProperty: config.idProperty,
properties: feature.properties,
geometry: feature.geometry,
replaceAll: contiguous && !isNewContiguous
geometry: feature.geometry
}
})
}, [markers, contiguous, selectedFeatures, dispatch, multiSelect])
}, [markers, dispatch, multiSelect])

const processFallback = useCallback(({ coords }) => {
const canPlace = interactionModes.includes('placeMarker')
Expand Down
77 changes: 18 additions & 59 deletions plugins/interact/src/hooks/useInteractionHandlers.test.js
Original file line number Diff line number Diff line change
@@ -1,15 +1,11 @@
import { renderHook, act } from '@testing-library/react'
import { useInteractionHandlers } from './useInteractionHandlers.js'
import * as featureQueries from '../utils/featureQueries.js'
import { isContiguousWithAny } from '../utils/spatial.js'

/* ------------------------------------------------------------------ */
/* Mocks */
/* ------------------------------------------------------------------ */

jest.mock('../utils/spatial.js', () => ({
isContiguousWithAny: jest.fn(),
canSplitFeatures: jest.fn(() => false),
areAllContiguous: jest.fn(() => false)
}))
jest.mock('../utils/featureQueries.js', () => ({
Expand Down Expand Up @@ -48,7 +44,6 @@ const setup = (pluginOverrides = {}, markerItems = [], markerRefs = new Map()) =
layers: [{ layerId: 'parcels', idProperty: 'parcelId' }],
interactionModes: ['selectMarker', 'selectFeature'],
multiSelect: false,
contiguous: false,
marker: { symbol: 'pin', backgroundColor: 'red' },
selectedFeatures: [],
selectedMarkers: [],
Expand Down Expand Up @@ -257,58 +252,6 @@ it('passes multiSelect flag through to dispatch', () => {
)
})

/* ------------------------------------------------------------------ */
/* Contiguous selection (FULL COVERAGE) */
/* ------------------------------------------------------------------ */

describe('contiguous selection', () => {
it('does NOT replace selection when feature is contiguous', () => {
isContiguousWithAny.mockReturnValue(true) // contiguous

const { result, deps } = setup({
contiguous: true,
selectedFeatures: [{ geometry: { type: 'Polygon' } }]
})

click(result)

expect(deps.pluginState.dispatch).toHaveBeenCalledWith(
expect.objectContaining({
payload: expect.objectContaining({
replaceAll: false
})
})
)
})

it('replaces selection when feature is NOT contiguous', () => {
isContiguousWithAny.mockReturnValue(false) // disjoint

const { result, deps } = setup({
contiguous: true,
selectedFeatures: [{ geometry: { type: 'Polygon' } }]
})

click(result)

expect(deps.pluginState.dispatch).toHaveBeenCalledWith(
expect.objectContaining({
payload: expect.objectContaining({
replaceAll: true
})
})
)
})

it('does not compute contiguity when contiguous is false', () => {
const { result } = setup({ contiguous: false })

click(result)

expect(isContiguousWithAny).not.toHaveBeenCalled()
})
})

/* ------------------------------------------------------------------ */
/* deselectOnClickOutside */
/* ------------------------------------------------------------------ */
Expand Down Expand Up @@ -384,6 +327,23 @@ it('does not check markers when selectMarker is not in interactionModes', () =>
/* Selection change event */
/* ------------------------------------------------------------------ */

it('does not emit selectionchange when features are selected but bounds not yet calculated', () => {
const deps = {
mapState: { markers: { add: jest.fn(), remove: jest.fn(), items: [], markerRefs: new Map() } },
pluginState: {
selectedFeatures: [{ featureId: 'F1' }],
selectedMarkers: [],
selectionBounds: null
},
services: { eventBus: { emit: jest.fn() } },
mapProvider: {}
}

renderHook(() => useInteractionHandlers(deps))

expect(deps.services.eventBus.emit).not.toHaveBeenCalled()
})

it('emits selectionchange once when bounds exist', () => {
const deps = {
mapState: { markers: { add: jest.fn(), remove: jest.fn(), items: [], markerRefs: new Map() } },
Expand All @@ -404,8 +364,7 @@ it('emits selectionchange once when bounds exist', () => {
selectedFeatures: deps.pluginState.selectedFeatures,
selectedMarkers: [],
selectionBounds: deps.pluginState.selectionBounds,
canMerge: false,
canSplit: false
contiguous: false
})
)
})
Expand Down
1 change: 0 additions & 1 deletion plugins/interact/src/reducer.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ const initialState = {
marker: null,
interactionModes: null,
multiSelect: false,
contiguous: false,
deselectOnClickOutside: false,
selectedFeatures: [],
selectedMarkers: [],
Expand Down
1 change: 0 additions & 1 deletion plugins/interact/src/reducer.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ describe('initialState', () => {
marker: null,
interactionModes: null,
multiSelect: false,
contiguous: false,
deselectOnClickOutside: false,
selectedFeatures: [],
selectedMarkers: [],
Expand Down
15 changes: 0 additions & 15 deletions plugins/interact/src/utils/spatial.js
Original file line number Diff line number Diff line change
Expand Up @@ -41,20 +41,6 @@ function isContiguousWithAny (feature, features) {
return features.some(f => !booleanDisjoint(toTurfGeometry(f), toTurfGeometry(feature)))
}

/**
* Check if a single polygon/multipolygon feature can be split.
*
* @param {Array} features - Array of features
* @returns {boolean} True if exactly one polygon or multipolygon feature
*/
function canSplitFeatures (features) {
if (features.length !== 1) {
return false
}
const type = features[0].geometry?.type
return type === 'Polygon' || type === 'MultiPolygon'
}

/**
* Check if all features form a single contiguous group (can be merged).
* Uses flood-fill to find connected components.
Expand Down Expand Up @@ -94,6 +80,5 @@ function areAllContiguous (features) {
export {
toTurfGeometry,
isContiguousWithAny,
canSplitFeatures,
areAllContiguous
}
14 changes: 1 addition & 13 deletions plugins/interact/src/utils/spatial.test.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { toTurfGeometry, isContiguousWithAny, canSplitFeatures, areAllContiguous } from './spatial.js'
import { toTurfGeometry, isContiguousWithAny, areAllContiguous } from './spatial.js'
import { polygon, multiPolygon, lineString, multiLineString, point, multiPoint } from '@turf/helpers'

describe('toTurfGeometry', () => {
Expand Down Expand Up @@ -61,18 +61,6 @@ describe('isContiguousWithAny', () => {
})
})

describe('canSplitFeatures', () => {
it.each([
[[{ geometry: { type: 'Polygon' } }], true],
[[{ geometry: { type: 'MultiPolygon' } }], true],
[[{ geometry: { type: 'LineString' } }], false],
[[], false],
[[{ geometry: { type: 'Polygon' } }, { geometry: { type: 'Polygon' } }], false]
])('returns expected result for %j', (features, expected) => {
expect(canSplitFeatures(features)).toBe(expected)
})
})

describe('areAllContiguous', () => {
const poly = (coords) => ({ geometry: { type: 'Polygon', coordinates: [coords] } })
const A = poly([[0, 0], [2, 0], [2, 2], [0, 2], [0, 0]])
Expand Down
Loading