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
4 changes: 2 additions & 2 deletions demo/js/draw.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import createInteractPlugin from '/plugins/interact/src/index.js'
const pointData = {type: 'FeatureCollection','features': [{'type': 'Feature','properties': {},'geometry': {'coordinates': [-2.882445487962059,54.70938250564518],'type': 'Point'}},{'type': 'Feature','properties': {},'geometry': {'coordinates': [-2.8775970686837695,54.70966586215056],'type': 'Point'}},{'type': 'Feature','properties': {},'geometry': {'coordinates': [-2.8732152153681056,54.70892223300439],'type': 'Point'}}]}

const interactPlugin = createInteractPlugin({
dataLayers: [{
layers: [{
layerId: 'field-parcels',
// idProperty: 'gid'
},{
Expand All @@ -35,7 +35,7 @@ const interactPlugin = createInteractPlugin({
idProperty: 'id'
}],
debug: true,
interactionMode: 'select', // 'auto', 'select', 'marker' // defaults to 'marker'
interactionModes: ['selectMarker', 'selectFeature'], // e.g. ['selectMarker'], ['selectFeature'], ['placeMarker'], or combinations
multiSelect: true,
contiguous: true,
deselectOnClickOutside: true
Expand Down
4 changes: 2 additions & 2 deletions demo/js/farming.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,14 +19,14 @@ import createFramePlugin from '/plugins/beta/frame/src/index.js'
var feature = { id: 'test1234', type: 'Feature', geometry: { coordinates: [[[-2.9406643378873127,54.918060570259456],[-2.9092219779267054,54.91564249172612],[-2.904350626383433,54.90329530000005],[-2.909664828067463,54.89540129642464],[-2.9225074821353587,54.88979816151294],[-2.937121536764323,54.88826989853317],[-2.95682836800691,54.88916139231736],[-2.965463945742613,54.898966521920045],[-2.966349646023133,54.910805898763385],[-2.9406643378873127,54.918060570259456]]], type: 'Polygon' }}

var interactPlugin = createInteractPlugin({
dataLayers: [{
layers: [{
layerId: 'field-parcels',
// idProperty: 'gid'
},{
layerId: 'linked-parcels',
// idProperty: 'gid'
}],
interactionMode: 'auto', // 'auto', 'select', 'marker' // defaults to 'marker'
interactionModes: ['selectMarker', 'selectFeature', 'placeMarker'], // e.g. ['selectMarker'], ['selectFeature'], ['placeMarker'], or combinations
multiSelect: true,
// excludeModes: ['draw']
})
Expand Down
14 changes: 11 additions & 3 deletions demo/js/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ import createFramePlugin from '/plugins/beta/frame/src/index.js'
const pointData = {type: 'FeatureCollection',features: [{type: 'Feature',properties: {category:'prehistoric'},geometry: {coordinates: [-2.4558622,54.5617135],type: 'Point'}},{type: 'Feature',properties: {category:'roman'},geometry: {coordinates: [-2.439823,54.5525437],type: 'Point'}},{type: 'Feature',properties: {category:'medieval'},geometry: {coordinates: [-2.4481939,54.5575261],type: 'Point'}}]}

const interactPlugin = createInteractPlugin({
dataLayers: [{
layers: [{
layerId: 'historic-monuments-prehistoric-symbol',
// idProperty: 'gid'
},{
Expand All @@ -50,6 +50,9 @@ const interactPlugin = createInteractPlugin({
},{
layerId: 'OS/TopographicArea_1/Agricultural Land',
idProperty: 'TOID'
},{
layerId: 'hedge-control',
idProperty: 'id'
},{
layerId: 'fill-inactive.cold',
idProperty: 'id'
Expand All @@ -58,8 +61,8 @@ const interactPlugin = createInteractPlugin({
idProperty: 'id'
}],
debug: true,
interactionMode: 'select', // 'auto', 'select', 'marker' // defaults to 'marker'
multiSelect: true,
interactionModes: ['selectMarker', 'selectFeature'], // e.g. ['selectMarker'], ['selectFeature'], ['placeMarker'], or combinations
// multiSelect: true,
contiguous: true,
deselectOnClickOutside: true
})
Expand Down Expand Up @@ -302,6 +305,10 @@ interactiveMap.on('map:ready', function (e) {
// aspectRatio: 1
// })
interactPlugin.enable()
interactiveMap.addMarker('my-marker-1', [-2.4555608,54.5655407])
interactiveMap.addMarker('my-marker-2', [-2.4511636,54.5638338], {
symbol: 'square'
})
})

interactiveMap.on('datasets:ready', function () {
Expand All @@ -323,6 +330,7 @@ interactiveMap.on('interact:cancel', function (e) {
})

interactiveMap.on('interact:selectionchange', function (e) {
console.log(e)
const drawLayers = ['stroke-inactive.cold', 'fill-inactive.cold']
const singleFeature = e.selectedFeatures.length === 1
const anyFeature = e.selectedFeatures.length > 0
Expand Down
2 changes: 1 addition & 1 deletion demo/js/planning.js
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ const interactPlugin = createInteractPlugin({
backgroundColor: { outdoor: '#0b0c0c', dark: '#ffffff' },
foregroundColor: { outdoor: '#ffff', dark: '#0b0c0c' }
},
// interactionMode: 'marker', // 'auto', 'select', 'marker' // defaults to 'marker'
// interactionModes: ['selectMarker'], // e.g. ['selectMarker'], ['selectFeature'], ['placeMarker'], or combinations
// multiSelect: true
})

Expand Down
20 changes: 19 additions & 1 deletion docs/api/symbol-config.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ Symbol properties control the appearance of markers and point dataset features.

Each property is optional. A value set directly on a marker or dataset layer takes priority over everything else. If a property is not set there, the value registered with the symbol is used. If the symbol has no value for that property, the app-wide `symbolDefaults` from the constructor applies. If none of those are set, the built-in fallback listed under each property below is used.

`haloColor`, `selectedColor`, `haloWidth`, and `selectedWidth` are required tokens in the SVG structure (see [SVG structure](#svg-structure)). Include them in any custom `symbolSvgContent` — the app resolves their values automatically.
`haloColor`, `selectedColor`, `haloWidth`, and `selectedWidth` are required tokens in the SVG structure (see [SVG structure](#svg-structure)). Include them in any custom `symbolSvgContent` — the app resolves their values automatically. Note that `haloColor` and `selectedColor` are always derived from the active map style and cannot be configured.

## Style-keyed colours

Expand Down Expand Up @@ -87,6 +87,22 @@ Foreground fill colour — the inner graphic element (e.g. the dot inside a pin)

---

### `haloWidth`
**Type:** `number`
**Default:** `1`

Stroke width of the halo around the symbol background shape. Can be set in constructor `symbolDefaults`, at symbol registration, or per marker/dataset layer.

---

### `selectedWidth`
**Type:** `number`
**Default:** `6`

Stroke width of the selection ring shown when a marker is selected. Can be set in constructor `symbolDefaults` or per marker/dataset layer.

---

### `graphic`
**Type:** `string`

Expand Down Expand Up @@ -140,3 +156,5 @@ svg: `
- **Layer 1** — selection ring (stroke only, fill none) — hidden in normal rendering, visible when selected
- **Layer 2** — background shape with halo stroke
- **Layer 3** — foreground graphic (e.g. inner dot)

> `{{haloColor}}` and `{{selectedColor}}` are always injected from the active map style. They must be present in the SVG but cannot be configured.
55 changes: 38 additions & 17 deletions docs/plugins/interact.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,9 @@ The interact plugin provides a unified way to handle user interactions for selec
import createInteractPlugin from '@defra/interactive-map/plugins/interact'

const interactPlugin = createInteractPlugin({
interactionMode: 'auto',
interactionModes: ['selectMarker', 'selectFeature'],
multiSelect: true,
dataLayers: [
layers: [
{ layerId: 'my-layer', idProperty: 'id' }
]
})
Expand Down Expand Up @@ -40,32 +40,43 @@ Array of mode identifiers. When set, the plugin does not render when the app is

---

### `interactionMode`
**Type:** `'marker' | 'select' | 'auto'`
**Default:** `'marker'`
### `interactionModes`
**Type:** `Array<'selectMarker' | 'selectFeature' | 'placeMarker'>`
**Default:** `['selectMarker']`

Controls how user clicks are interpreted.
Controls which interactions are active when the user clicks the map. Values can be combined freely — the plugin always processes them in a fixed priority order: marker selection → feature selection → place marker.

- `'marker'` — clicking always places a location marker at the clicked coordinates
- `'select'` — clicking attempts to match a feature from `dataLayers`; click outside clears selection (unless `deselectOnClickOutside` is `false`)
- `'auto'` — attempts feature matching first, falls back to placing a marker if no feature is found
- `'selectMarker'` — clicking a placed marker toggles its selection state
- `'selectFeature'` — clicking the map attempts to match a feature from `layers`
- `'placeMarker'` — if no feature is matched (or `selectFeature` is not active), places a location marker at the clicked coordinates

**Common combinations:**

```js
interactionModes: ['selectMarker'] // marker selection only (default)
interactionModes: ['selectFeature'] // feature selection only
interactionModes: ['placeMarker'] // always place a marker on click
interactionModes: ['selectMarker', 'selectFeature'] // select markers or features
interactionModes: ['selectFeature', 'placeMarker'] // select features, fall back to placing a marker
interactionModes: ['selectMarker', 'selectFeature', 'placeMarker'] // all interactions active
```

---

### `dataLayers`
**Type:** `Array<DataLayer>`
### `layers`
**Type:** `Array<LayerConfig>`
**Default:** `[]`

Array of map layer configurations that are selectable. Each entry specifies which layer to watch and how to identify features.

```js
dataLayers: [
layers: [
{ layerId: 'my-polygons', idProperty: 'id' },
{ layerId: 'my-lines' }
]
```

#### `DataLayer` properties
#### `LayerConfig` properties

| Property | Type | Description |
|----------|------|-------------|
Expand All @@ -75,6 +86,16 @@ dataLayers: [
| `selectedFill` | `string` | Overrides the selection fill colour for this layer. Defaults to `transparent` |
| `selectedStrokeWidth` | `number` | Overrides the selection stroke width for this layer. Defaults to `3` |

#### Finding layer IDs

What to use as `layerId` depends on how your data is added to the map — these are the layers the plugin will enable for feature selection:

- **MapLibre directly** — use the layer IDs defined in your style or added via `map.addLayer()`
- **Datasets plugin** — use the dataset ID, or the sublayer ID for datasets with sublayers
- **Draw plugin** — uses generated layer IDs such as `fill-inactive.cold` and `stroke-inactive.cold`

If you're unsure of the layer IDs available at runtime, set `debug: true` in the map config — this lets you query the map and inspect layer names in the browser console.

---

### `multiSelect`
Expand Down Expand Up @@ -105,7 +126,7 @@ When `true`, clicking outside any selectable layer clears the current selection.
**Type:** `number`
**Default:** `10`

Click detection radius in pixels. Increases the hit area around the cursor when matching features, which is useful for lines and points.
Click detection radius in pixels applied to line features. Lines have a 1px rendered width so a buffer is required for reliable selection. Polygon and symbol/icon features use exact hit detection and are unaffected by this value.

---

Expand Down Expand Up @@ -140,9 +161,9 @@ When not set, the marker inherits from the constructor `symbolDefaults` cascade.
**Type:** `number`
**Default:** `3`

Stroke width used to highlight selected features. Can be overridden per layer via `dataLayers[].selectedStrokeWidth`.
Stroke width used to highlight selected features. Can be overridden per layer via `layers[].selectedStrokeWidth`.

> **Selection colours** — stroke and fill colours for selected features are not configured here. Stroke colour comes from `MapStyleConfig.selectedColor` (falling back to the `mapColorScheme` scheme default), ensuring the selection colour stays consistent with the rest of the map theme. Fill defaults to `transparent`. Both can be overridden per layer via `dataLayers[].selectedStroke` and `dataLayers[].selectedFill`.
> **Selection colours** — stroke and fill colours for selected features are not configured here. Stroke colour comes from `MapStyleConfig.selectedColor` (falling back to the `mapColorScheme` scheme default), ensuring the selection colour stays consistent with the rest of the map theme. Fill defaults to `transparent`. Both can be overridden per layer via `layers[].selectedStroke` and `layers[].selectedFill`.

---

Expand All @@ -166,7 +187,7 @@ interactiveMap.on('map:ready', () => {
})

// Override options at runtime
interactPlugin.enable({ multiSelect: true, interactionMode: 'select' })
interactPlugin.enable({ multiSelect: true, interactionModes: ['selectFeature'] })
```

---
Expand Down
2 changes: 1 addition & 1 deletion plugins/beta/datasets/src/adapters/maplibre/layerIds.js
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ export const getLayerIds = (dataset) => {
if (hasSymbol(dataset)) {
return { fillLayerId: null, strokeLayerId: null, symbolLayerId: dataset.id }
}
const hasFill = !!dataset.fill || hasPattern(dataset)
const hasFill = (!!dataset.fill && dataset.fill !== 'transparent') || hasPattern(dataset)
const hasStroke = !!dataset.stroke
const fillLayerId = hasFill ? dataset.id : null
let strokeLayerId = null
Expand Down
8 changes: 8 additions & 0 deletions plugins/interact/src/InteractInit.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { useEffect, useRef } from 'react'
import { EVENTS } from '../../../src/config/events.js'
import { useInteractionHandlers } from './hooks/useInteractionHandlers.js'
import { useHighlightSync } from './hooks/useHighlightSync.js'
import { useHoverCursor } from './hooks/useHoverCursor.js'
import { attachEvents } from './events.js'

export const InteractInit = ({
Expand Down Expand Up @@ -61,6 +62,13 @@ export const InteractInit = ({
eventBus
})

// Notify other components (e.g. Markers) whether interact is active
useEffect(() => {
eventBus.emit('interact:active', { active: enabled })
}, [enabled])

useHoverCursor(mapProvider, enabled, pluginState.interactionModes, pluginState.layers)

// Toggle target marker visibility
useEffect(() => {
if (enabled && isTouchOrKeyboard) {
Expand Down
19 changes: 15 additions & 4 deletions plugins/interact/src/InteractInit.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,12 @@ import { EVENTS } from '../../../src/config/events.js'
import { InteractInit } from './InteractInit.jsx'
import { useInteractionHandlers } from './hooks/useInteractionHandlers.js'
import { useHighlightSync } from './hooks/useHighlightSync.js'
import { useHoverCursor } from './hooks/useHoverCursor.js'
import { attachEvents } from './events.js'

jest.mock('./hooks/useInteractionHandlers.js')
jest.mock('./hooks/useHighlightSync.js')
jest.mock('./hooks/useHoverCursor.js')
jest.mock('./events.js')

describe('InteractInit', () => {
Expand All @@ -20,15 +22,24 @@ describe('InteractInit', () => {

useInteractionHandlers.mockReturnValue({ handleInteraction: handleInteractionMock })
useHighlightSync.mockReturnValue(undefined)
useHoverCursor.mockReturnValue(undefined)
attachEvents.mockReturnValue(cleanupMock)

props = {
appState: { interfaceType: 'mouse' },
appState: { interfaceType: 'mouse', layoutRefs: { viewportRef: { current: null } } },
mapState: { crossHair: { fixAtCenter: jest.fn(), hide: jest.fn() }, mapStyle: {} },
services: { eventBus: {}, closeApp: jest.fn() },
services: { eventBus: { emit: jest.fn() }, closeApp: jest.fn() },
buttonConfig: {},
mapProvider: {},
pluginState: { dispatch: jest.fn(), enabled: true, selectedFeatures: [], selectionBounds: {} }
mapProvider: { setHoverCursor: jest.fn() },
pluginState: {
dispatch: jest.fn(),
enabled: true,
selectedFeatures: [],
selectedMarkers: [],
selectionBounds: {},
interactionModes: ['selectFeature'],
layers: []
}
}
})

Expand Down
6 changes: 3 additions & 3 deletions plugins/interact/src/api/enable.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,18 +10,18 @@ describe('enable', () => {

it('dispatches ENABLE with merged payload correctly', () => {
const pluginConfig = { marker: { symbol: 'pin', backgroundColor: 'blue' } }
const options = { interactionMode: 'select', marker: { symbol: 'circle', backgroundColor: 'green' }, dataLayers: [{ layerId: 'test' }] }
const options = { interactionModes: ['selectFeature'], marker: { symbol: 'circle', backgroundColor: 'green' }, layers: [{ layerId: 'test' }] }

enable({ pluginState: { dispatch }, pluginConfig }, options)

expect(dispatch).toHaveBeenCalledTimes(1)
expect(dispatch).toHaveBeenCalledWith({
type: 'ENABLE',
payload: expect.objectContaining({
interactionMode: 'select',
interactionModes: ['selectFeature'],
multiSelect: DEFAULTS.multiSelect,
marker: { symbol: 'circle', backgroundColor: 'green' },
dataLayers: [{ layerId: 'test' }]
layers: [{ layerId: 'test' }]
})
})
})
Expand Down
2 changes: 1 addition & 1 deletion plugins/interact/src/defaults.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
export const DEFAULTS = {
tolerance: 10,
interactionMode: 'marker',
interactionModes: ['selectMarker'],
multiSelect: false,
contiguous: false,
deselectOnClickOutside: false,
Expand Down
15 changes: 9 additions & 6 deletions plugins/interact/src/events.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,10 @@
const buildDonePayload = (coords, selectedFeatures, selectedMarkers, selectionBounds) => ({
...(coords && { coords }),
...(!coords && selectedFeatures && { selectedFeatures }),
...(!coords && selectedMarkers?.length && { selectedMarkers }),
...(!coords && selectionBounds && { selectionBounds })
})

// Helper for feature toggling logic
const createFeatureHandler = (mapState, getPluginState) => (args, addToExisting) => {
const pluginState = getPluginState()
Expand Down Expand Up @@ -48,17 +55,13 @@ export function attachEvents ({
const pluginState = getPluginState()
const marker = mapState.markers.getMarker('location')
const { coords } = marker || {}
const { selectionBounds, selectedFeatures } = pluginState
const { selectionBounds, selectedFeatures, selectedMarkers } = pluginState

if (getAppState().disabledButtons.has('selectDone')) {
return
}

eventBus.emit('interact:done', {
...(coords && { coords }),
...(!coords && selectedFeatures && { selectedFeatures }),
...(!coords && selectionBounds && { selectionBounds })
})
eventBus.emit('interact:done', buildDonePayload(coords, selectedFeatures, selectedMarkers, selectionBounds))

if (pluginState.closeOnAction ?? true) {
closeApp()
Expand Down
Loading
Loading