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
5 changes: 1 addition & 4 deletions docs/plugins/interact.md
Original file line number Diff line number Diff line change
Expand Up @@ -295,7 +295,6 @@ Emitted when the user confirms their selection (clicks "Done").

// If features were selected:
selectedFeatures: [...],
selectionBounds: [west, south, east, north],

// If markers were selected:
selectedMarkers: ['...']
Expand Down Expand Up @@ -340,18 +339,16 @@ Emitted whenever the selected features or selected markers change.
```js
{
selectedFeatures: [
{ featureId: '...', layerId: '...', properties: {...}, geometry: {...} }
{ featureId: '...', layerId: '...', idProperty: '...', properties: {...} }
],
selectedMarkers: ['...'], // array of selected marker IDs
selectionBounds: [west, south, east, north] | null,
contiguous: boolean // true when 2+ features are selected and all form a single contiguous group
}
```

```js
interactiveMap.on('interact:selectionchange', (e) => {
console.log('Selected features:', e.selectedFeatures)
console.log('Bounds:', e.selectionBounds)
})
```

Expand Down
4 changes: 1 addition & 3 deletions plugins/interact/src/InteractInit.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ export const InteractInit = ({
mapProvider,
pluginState
}) => {
const { dispatch, enabled, selectedFeatures, interactionModes, layers } = pluginState
const { enabled, selectedFeatures, interactionModes, layers } = pluginState
const { eventBus } = services
const { crossHair, mapStyle, markers } = mapState

Expand All @@ -44,13 +44,11 @@ export const InteractInit = ({
mapProvider
})

// Highlight features and sync state selectedBounds from mapProvider
useHighlightSync({
mapProvider,
mapStyle,
pluginState,
selectedFeatures,
dispatch,
events: EVENTS,
eventBus
})
Expand Down
2 changes: 0 additions & 2 deletions plugins/interact/src/InteractInit.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,6 @@ beforeEach(() => {
enabled: true,
selectedFeatures: [],
selectedMarkers: [],
selectionBounds: {},
interactionModes: ['selectFeature'],
layers: []
}
Expand All @@ -63,7 +62,6 @@ describe('InteractInit — hook delegation', () => {
mapStyle: props.mapState.mapStyle,
pluginState: props.pluginState,
selectedFeatures: props.pluginState.selectedFeatures,
dispatch: props.pluginState.dispatch,
events: EVENTS,
eventBus: props.services.eventBus
}))
Expand Down
2 changes: 1 addition & 1 deletion plugins/interact/src/events.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ const MOCK_COORDS = [1, 2]

const createParams = () => {
const appState = { layoutRefs: { viewportRef: { current: document.body } }, disabledButtons: new Set() }
const pluginState = { dispatch: jest.fn(), selectionBounds: null, selectedFeatures: [], selectedMarkers: [], multiSelect: false }
const pluginState = { dispatch: jest.fn(), selectedFeatures: [], selectedMarkers: [], multiSelect: false }
const clickReadyRef = { current: false }
return {
appState,
Expand Down
11 changes: 1 addition & 10 deletions plugins/interact/src/hooks/useHighlightSync.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,16 +9,12 @@ import { buildStylesMap } from '../utils/buildStylesMap.js'
* (shown with the keyboard cursor ring) as separate arguments so the provider can style them
* differently. Also re-applies highlights after a map style reload, since highlight layers
* are removed when the base style changes.
*
* Dispatches UPDATE_SELECTED_BOUNDS with the bounding box returned by the provider so
* downstream consumers (e.g. interact:done) receive up-to-date bounds.
*/
export const useHighlightSync = ({
mapProvider,
mapStyle,
pluginState,
selectedFeatures,
dispatch,
events,
eventBus
}) => {
Expand All @@ -37,12 +33,7 @@ export const useHighlightSync = ({
const activeFeatures = listboxActiveItem
? [{ featureId: listboxActiveItem.featureId, layerId: listboxActiveItem.layerId, idProperty: listboxActiveItem.idProperty, geometry: listboxActiveItem.geometry }]
: []
const bounds = mapProvider.updateHighlightedFeatures?.(selectedFeatures, activeFeatures, stylesMap)

dispatch({
type: 'UPDATE_SELECTED_BOUNDS',
payload: bounds
})
mapProvider.updateHighlightedFeatures?.(selectedFeatures, activeFeatures, stylesMap)
}

useEffect(() => {
Expand Down
23 changes: 2 additions & 21 deletions plugins/interact/src/hooks/useHighlightSync.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,6 @@ beforeEach(() => {
layers: [{ layerId: 'layer1' }]
},
selectedFeatures: [],
dispatch: jest.fn(),
events: { MAP_STYLE_CHANGE: STYLE_CHANGE, MAP_DATA_CHANGE: DATA_CHANGE },
eventBus: {
on: jest.fn((event, handler) => {
Expand All @@ -51,7 +50,7 @@ beforeEach(() => {
// ─── useHighlightSync — highlighting ─────────────────────────────────────────

describe('useHighlightSync — highlighting', () => {
it('updates map highlights and dispatches bounds', () => {
it('updates map highlights', () => {
mockDeps.selectedFeatures = [{ featureId: 'F1', layerId: 'layer1' }]

render()
Expand All @@ -61,22 +60,6 @@ describe('useHighlightSync — highlighting', () => {
[],
expect.any(Object)
)
expect(mockDeps.dispatch).toHaveBeenCalledWith({
type: 'UPDATE_SELECTED_BOUNDS',
payload: { sw: [0, 0], ne: [1, 1] }
})
})

it('dispatches null bounds when provider returns null', () => {
mockDeps.selectedFeatures = [{ featureId: 'F1' }]
mockDeps.mapProvider.updateHighlightedFeatures.mockReturnValue(null)

render()

expect(mockDeps.dispatch).toHaveBeenCalledWith({
type: 'UPDATE_SELECTED_BOUNDS',
payload: null
})
})
})

Expand Down Expand Up @@ -144,9 +127,7 @@ describe('useHighlightSync — guards', () => {
mockDeps.mapProvider = null
mockDeps.selectedFeatures = [{ featureId: 'F1' }]

render()

expect(mockDeps.dispatch).not.toHaveBeenCalled()
expect(() => render()).not.toThrow()
})

it('does nothing when mapStyle is null', () => {
Expand Down
16 changes: 5 additions & 11 deletions plugins/interact/src/hooks/useInteractionHandlers.js
Original file line number Diff line number Diff line change
Expand Up @@ -37,30 +37,24 @@ const findMarkerAtPoint = (markers, point, scale) => {
return null
}

const useSelectionChangeEmitter = (eventBus, selectedFeatures, selectedMarkers, selectionBounds) => {
const useSelectionChangeEmitter = (eventBus, selectedFeatures, selectedMarkers) => {
const lastEmittedSelectionChange = useRef(null)

useEffect(() => {
const awaitingBounds = selectedFeatures.length > 0 && !selectionBounds
if (awaitingBounds) {
return
}

const prev = lastEmittedSelectionChange.current
const wasEmpty = prev === null || (prev.features.length === 0 && prev.markers.length === 0)
if (wasEmpty && selectedFeatures.length === 0 && selectedMarkers.length === 0) {
return
}

eventBus.emit('interact:selectionchange', {
selectedFeatures,
selectedFeatures: selectedFeatures.map(({ featureId, layerId, idProperty, properties }) => ({ featureId, layerId, idProperty, properties })),
selectedMarkers,
selectionBounds,
contiguous: areAllContiguous(selectedFeatures)
})

lastEmittedSelectionChange.current = { features: selectedFeatures, markers: selectedMarkers }
}, [selectedFeatures, selectedMarkers, selectionBounds])
}, [selectedFeatures, selectedMarkers])
}

/**
Expand Down Expand Up @@ -220,7 +214,7 @@ export const useInteractionHandlers = ({ mapState, pluginState, services, mapPro
const {
dispatch, layers, interactionModes, multiSelect, contiguous,
marker: markerOptions, tolerance, selectedFeatures, selectedMarkers,
selectionBounds, deselectOnClickOutside
deselectOnClickOutside
} = pluginState
const { eventBus } = services
const layerConfigMap = buildLayerConfigMap(layers)
Expand Down Expand Up @@ -272,6 +266,6 @@ export const useInteractionHandlers = ({ mapState, pluginState, services, mapPro
scale
})

useSelectionChangeEmitter(eventBus, selectedFeatures, selectedMarkers, selectionBounds)
useSelectionChangeEmitter(eventBus, selectedFeatures, selectedMarkers)
return { handleInteraction }
}
30 changes: 5 additions & 25 deletions plugins/interact/src/hooks/useInteractionHandlers.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,6 @@ const setup = (pluginOverrides = {}, markerItems = [], markerRefs = new Map()) =
marker: { symbol: 'pin', backgroundColor: 'red' },
selectedFeatures: [],
selectedMarkers: [],
selectionBounds: null,
...pluginOverrides
},
services: {
Expand Down Expand Up @@ -345,30 +344,12 @@ 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', () => {
it('emits selectionchange when features are selected', () => {
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: { getFeatureGeometry: jest.fn(() => null) }
}

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() } },
pluginState: {
selectedFeatures: [{ featureId: 'F1' }],
selectedMarkers: [],
selectionBounds: { sw: [0, 0], ne: [1, 1] }
selectedFeatures: [{ featureId: 'F1', layerId: 'l1', idProperty: 'id', properties: { name: 'A' }, geometry: { type: 'Point', coordinates: [0, 0] } }],
selectedMarkers: []
},
services: { eventBus: { emit: jest.fn() } },
mapProvider: { getFeatureGeometry: jest.fn(() => null) }
Expand All @@ -379,9 +360,8 @@ it('emits selectionchange once when bounds exist', () => {
expect(deps.services.eventBus.emit).toHaveBeenCalledWith(
'interact:selectionchange',
expect.objectContaining({
selectedFeatures: deps.pluginState.selectedFeatures,
selectedFeatures: [{ featureId: 'F1', layerId: 'l1', idProperty: 'id', properties: { name: 'A' } }],
selectedMarkers: [],
selectionBounds: deps.pluginState.selectionBounds,
contiguous: false
})
)
Expand All @@ -394,7 +374,7 @@ it('skips emission when selection remains empty after being cleared', () => {
const { rerender } = renderHook(
({ features }) => useInteractionHandlers({
mapState: { markers: { items: [], markerRefs: new Map() } },
pluginState: { selectedFeatures: features, selectedMarkers: [], selectionBounds: { b: 1 } },
pluginState: { selectedFeatures: features, selectedMarkers: [] },
services: { eventBus },
mapProvider: { getFeatureGeometry: jest.fn(() => null) }
}),
Expand Down
26 changes: 4 additions & 22 deletions plugins/interact/src/reducer.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ const initialState = {
deselectOnClickOutside: false,
selectedFeatures: [],
selectedMarkers: [],
selectionBounds: null,
closeOnAction: true, // Done or Cancel
listboxActiveItem: null // { featureId, layerId, idProperty, geometry } | null — ring shown but no selectionchange
}
Expand All @@ -26,7 +25,6 @@ const disable = (state) => {
enabled: false,
selectedFeatures: [],
selectedMarkers: [],
selectionBounds: null,
listboxActiveItem: null
}
}
Expand All @@ -46,7 +44,7 @@ const toggleSelectedFeatures = (state, payload) => {
// 1. Handle explicit unselect
if (addToExisting === false) {
const filtered = currentSelected.filter((_, i) => i !== existingIndex)
return { ...state, selectedFeatures: filtered, selectionBounds: null }
return { ...state, selectedFeatures: filtered }
}

// Define the feature object once to avoid repetition
Expand All @@ -70,18 +68,7 @@ const toggleSelectedFeatures = (state, payload) => {
nextSelected = isSameSingle ? [] : [featureObj]
}

return { ...state, selectedFeatures: nextSelected, selectedMarkers: multiSelect && !replaceAll ? state.selectedMarkers : [], selectionBounds: null }
}

// Update bounds (called from useEffect after map provider calculates them)
const updateSelectedBounds = (state, payload) => {
if (JSON.stringify(payload) === JSON.stringify(state.selectionBounds)) {
return state
}
return {
...state,
selectionBounds: payload
}
return { ...state, selectedFeatures: nextSelected, selectedMarkers: multiSelect && !replaceAll ? state.selectedMarkers : [] }
}

const toggleSelectedMarkers = (state, { markerId, multiSelect }) => {
Expand All @@ -94,7 +81,6 @@ const toggleSelectedMarkers = (state, { markerId, multiSelect }) => {
return {
...state,
selectedFeatures: [],
selectionBounds: null,
selectedMarkers: exists && current.length === 1 ? [] : [markerId]
}
}
Expand All @@ -103,8 +89,7 @@ const clearSelectedFeatures = (state) => {
return {
...state,
selectedFeatures: [],
selectedMarkers: [],
selectionBounds: null
selectedMarkers: []
}
}

Expand All @@ -124,7 +109,6 @@ const selectMarker = (state, { markerId, multiSelect }) => {
return {
...state,
selectedFeatures: multiSelect ? state.selectedFeatures : [],
selectionBounds: null,
selectedMarkers: nextMarkers
}
}
Expand All @@ -145,16 +129,14 @@ const unselectMarker = (state, { markerId }) => {

const setSelectedFeatures = (state, features) => ({
...state,
selectedFeatures: features,
selectionBounds: null
selectedFeatures: features
})

const actions = {
ENABLE: enable,
DISABLE: disable,
TOGGLE_SELECTED_FEATURES: toggleSelectedFeatures,
TOGGLE_SELECTED_MARKERS: toggleSelectedMarkers,
UPDATE_SELECTED_BOUNDS: updateSelectedBounds,
CLEAR_SELECTED_FEATURES: clearSelectedFeatures,
SET_SELECTED_FEATURES: setSelectedFeatures,
SELECT_MARKER: selectMarker,
Expand Down
Loading
Loading