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
6 changes: 3 additions & 3 deletions demo/js/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ const interactPlugin = createInteractPlugin({
}
],
debug: true,
interactionModes: ['selectMarker', 'selectFeature'], // e.g. ['selectMarker'], ['selectFeature'], ['placeMarker'], or combinations
interactionModes: ['selectMarker', 'selectFeature', 'placeMarker'], // e.g. ['selectMarker'], ['selectFeature'], ['placeMarker'], or combinations
multiSelect: true,
deselectOnClickOutside: true
})
Expand Down Expand Up @@ -270,7 +270,7 @@ const interactiveMap = new InteractiveMap('map', {
}),
// maxMobileWidth: 700,
// minDesktopWidth: 960,
mapLabel: 'Map showing Carlisle',
mapLabel: 'Map showing field parcels and land use',
// zoom: 14,
minZoom: 6,
maxZoom: 20,
Expand All @@ -279,7 +279,7 @@ const interactiveMap = new InteractiveMap('map', {
bounds: [-2.450804, 54.5599279, -2.403804, 54.6199279],
containerHeight: '650px',
transformRequest: transformTileRequest,
readMapText: true,
// readMapText: true,
// urlPosition: 'none',
// enableFullscreen: true,
// hasExitButton: true,
Expand Down
2 changes: 1 addition & 1 deletion docs/assets/css/docusaurus.scss
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@

.govuk-template--rebranded .app-masthead .govuk-grid-column-one-third-from-desktop {
@media (min-width: 48.125em) {
background-image: url('/images/hero.png');
background-image: url('https://defra.github.io/interactive-map/images/hero.png');
background-repeat: no-repeat;
background-position: center bottom;
background-size: 220px;
Expand Down
2 changes: 1 addition & 1 deletion docs/plugins/interact.md
Original file line number Diff line number Diff line change
Expand Up @@ -201,7 +201,7 @@ interactPlugin.disable()

### `clear()`

Clear all selected features and markers, and remove the location marker.
Clear all selected features and markers.

```js
interactPlugin.clear()
Expand Down
72 changes: 19 additions & 53 deletions plugins/interact/src/InteractInit.jsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,24 @@
import { useEffect, useRef } from 'react'
import { useEffect } from 'react'
import { EVENTS } from '../../../src/config/events.js'
import { useInteractionHandlers } from './hooks/useInteractionHandlers.js'
import { useMapItemList } from './hooks/useMapItemList.js'
import { useHighlightSync } from './hooks/useHighlightSync.js'
import { useHoverCursor } from './hooks/useHoverCursor.js'
import { attachEvents } from './events.js'
import { useCrossHairVisibility } from './hooks/useCrossHairVisibility.js'
import { useAttachEvents } from './hooks/useAttachEvents.js'
import { isSelectMarkerOnly } from './utils/interactionModes.js'

function useListboxCapable ({ enabled, interactionModes, markers, layers, eventBus }) {
useEffect(() => {
if (!enabled) { return }
const hasLabeledMarkers = interactionModes?.includes('selectMarker') && markers.items.some(m => m.label)
const hasFeatureLayers = interactionModes?.includes('selectFeature') && layers.some(l => l.labelProperty)
if (hasLabeledMarkers || hasFeatureLayers) {
eventBus.emit('interact:listboxcapable')
}
}, [enabled, interactionModes, markers, layers, eventBus])
}

export const InteractInit = ({
appState,
mapState,
Expand All @@ -15,12 +27,10 @@ export const InteractInit = ({
mapProvider,
pluginState
}) => {
const { interfaceType } = appState
const { dispatch, enabled, selectedFeatures, interactionModes, layers } = pluginState
const { eventBus, closeApp } = services
const { crossHair, mapStyle } = mapState
const { crossHair, mapStyle, markers } = mapState

const isTouchOrKeyboard = ['touch', 'keyboard'].includes(interfaceType)
const selectMarkerOnly = isSelectMarkerOnly(interactionModes)

useMapItemList({ mapState, pluginState, services, mapProvider })
Expand All @@ -34,27 +44,6 @@ export const InteractInit = ({
mapProvider
})

// Refs updated synchronously each render — keeps callbacks fresh without re-attaching events
const handleInteractionRef = useRef(handleInteraction)
handleInteractionRef.current = handleInteraction

const pluginStateRef = useRef(pluginState)
pluginStateRef.current = pluginState

const appStateRef = useRef(appState)
appStateRef.current = appState

// Defer click handling by one macrotask so any click that triggered the enable
// (e.g. finishing a draw gesture) fires before this handler is live.
// Managed separately from attachEvents so re-runs of that effect don't reset it —
// only resets when enabled actually changes.
const clickReadyRef = useRef(false)
useEffect(() => {
clickReadyRef.current = false
const timer = setTimeout(() => { clickReadyRef.current = true }, 0)
return () => clearTimeout(timer)
}, [pluginState.enabled])

// Highlight features and sync state selectedBounds from mapProvider
useHighlightSync({
mapProvider,
Expand All @@ -71,36 +60,13 @@ export const InteractInit = ({
eventBus.emit('interact:active', { active: enabled, interactionModes })
}, [enabled, interactionModes])

useHoverCursor(mapProvider, enabled, interactionModes, layers)

// Toggle target marker visibility
useEffect(() => {
if (enabled && isTouchOrKeyboard && !(interfaceType === 'touch' && selectMarkerOnly)) {
crossHair.fixAtCenter()
} else {
crossHair.hide()
}
}, [enabled, interfaceType, interactionModes])
useListboxCapable({ enabled, interactionModes, markers, layers, eventBus })

useEffect(() => {
if (!pluginState.enabled) {
return undefined // Explicit return
}
useHoverCursor(mapProvider, enabled, interactionModes, layers)

const cleanupEvents = attachEvents({
getAppState: () => appStateRef.current,
mapState,
getPluginState: () => pluginStateRef.current,
buttonConfig,
events: EVENTS,
eventBus,
handleInteraction: (event) => handleInteractionRef.current(event),
clickReadyRef,
closeApp
})
useCrossHairVisibility({ crossHair, enabled, selectMarkerOnly, appState })

return cleanupEvents
}, [pluginState.enabled, buttonConfig, eventBus, closeApp])
useAttachEvents({ pluginState, appState, mapState, buttonConfig, eventBus, handleInteraction, closeApp })

return null
}
137 changes: 56 additions & 81 deletions plugins/interact/src/InteractInit.test.js
Original file line number Diff line number Diff line change
@@ -1,51 +1,50 @@
import { act, render } from '@testing-library/react'
import { render } from '@testing-library/react'
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 { useMapItemList } from './hooks/useMapItemList.js'
import { attachEvents } from './events.js'

const LISTBOX_CAPABLE = 'interact:listboxcapable'

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

describe('InteractInit', () => {
let props
let handleInteractionMock
let cleanupMock

beforeEach(() => {
handleInteractionMock = jest.fn()
cleanupMock = jest.fn()

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

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

let props
let handleInteractionMock

beforeEach(() => {
handleInteractionMock = jest.fn()

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

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

describe('InteractInit — hook delegation', () => {
it('calls useInteractionHandlers with correct arguments', () => {
render(<InteractInit {...props} />)
expect(useInteractionHandlers).toHaveBeenCalledWith(expect.objectContaining({
Expand All @@ -69,44 +68,9 @@ describe('InteractInit', () => {
eventBus: props.services.eventBus
}))
})
})

it('fixes or hides crossHair based on interfaceType and enabled', () => {
// enabled true + non-touch = hide
render(<InteractInit {...props} />)
expect(props.mapState.crossHair.hide).toHaveBeenCalled()
expect(props.mapState.crossHair.fixAtCenter).not.toHaveBeenCalled()

// touch interface
props.appState.interfaceType = 'touch'
render(<InteractInit {...props} />)
expect(props.mapState.crossHair.fixAtCenter).toHaveBeenCalled()
})

it('attaches events and returns cleanup', () => {
const { unmount } = render(<InteractInit {...props} />)
expect(attachEvents).toHaveBeenCalledWith(expect.objectContaining({
getAppState: expect.any(Function),
getPluginState: expect.any(Function),
handleInteraction: expect.any(Function),
mapState: props.mapState,
buttonConfig: props.buttonConfig,
events: EVENTS,
eventBus: props.services.eventBus,
closeApp: props.services.closeApp
}))

const { getAppState, getPluginState, handleInteraction } = attachEvents.mock.calls.at(-1)[0]
expect(getAppState()).toMatchObject(props.appState)
expect(getPluginState()).toMatchObject({ enabled: props.pluginState.enabled })

const event = { point: {}, coords: [] }
handleInteraction(event)
expect(handleInteractionMock).toHaveBeenCalledWith(event)

unmount()
expect(cleanupMock).toHaveBeenCalled()
})

describe('InteractInit — event bus emissions', () => {
it('emits interact:active with active state and interactionModes on enable', () => {
render(<InteractInit {...props} />)
expect(props.services.eventBus.emit).toHaveBeenCalledWith('interact:active', {
Expand All @@ -115,20 +79,31 @@ describe('InteractInit', () => {
})
})

it('enables click handling after a macrotask', () => {
jest.useFakeTimers()
render(<InteractInit {...props} />)
act(() => jest.runAllTimers())
jest.useRealTimers()
it('emits interact:listboxcapable when enabled with a feature layer that has a labelProperty', () => {
const capableProps = {
...props,
pluginState: { ...props.pluginState, interactionModes: ['selectFeature'], layers: [{ layerId: 'myLayer', labelProperty: 'name' }] }
}
render(<InteractInit {...capableProps} />)
expect(capableProps.services.eventBus.emit).toHaveBeenCalledWith(LISTBOX_CAPABLE)
})

it('emits interact:listboxcapable when enabled with a labeled marker', () => {
const capableProps = {
...props,
mapState: { ...props.mapState, markers: { items: [{ id: 'm1', label: 'My marker' }] } },
pluginState: { ...props.pluginState, interactionModes: ['selectMarker'] }
}
render(<InteractInit {...capableProps} />)
expect(capableProps.services.eventBus.emit).toHaveBeenCalledWith(LISTBOX_CAPABLE)
})

it('does not attach events if plugin not enabled', () => {
it('does not emit interact:listboxcapable when disabled', () => {
const disabledProps = {
...props,
pluginState: { ...props.pluginState, enabled: false } // fresh object
pluginState: { ...props.pluginState, enabled: false, interactionModes: ['selectFeature'], layers: [{ layerId: 'myLayer', labelProperty: 'name' }] }
}
attachEvents.mockClear() // ensure previous calls don't interfere
render(<InteractInit {...disabledProps} />)
expect(attachEvents).not.toHaveBeenCalled()
expect(disabledProps.services.eventBus.emit).not.toHaveBeenCalledWith(LISTBOX_CAPABLE)
})
})
46 changes: 46 additions & 0 deletions plugins/interact/src/hooks/useAttachEvents.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { useEffect, useRef } from 'react'
import { EVENTS } from '../../../../src/config/events.js'
import { attachEvents } from '../events.js'

export function useAttachEvents ({ pluginState, appState, mapState, buttonConfig, eventBus, handleInteraction, closeApp }) {
// Refs updated synchronously each render — keeps callbacks fresh without re-attaching events
const handleInteractionRef = useRef(handleInteraction)
handleInteractionRef.current = handleInteraction

const pluginStateRef = useRef(pluginState)
pluginStateRef.current = pluginState

const appStateRef = useRef(appState)
appStateRef.current = appState

// Defer click handling by one macrotask so any click that triggered the enable
// (e.g. finishing a draw gesture) fires before this handler is live.
// Managed separately from attachEvents so re-runs of that effect don't reset it —
// only resets when enabled actually changes.
const clickReadyRef = useRef(false)
useEffect(() => {
clickReadyRef.current = false
const timer = setTimeout(() => { clickReadyRef.current = true }, 0)
return () => clearTimeout(timer)
}, [pluginState.enabled])

useEffect(() => {
if (!pluginState.enabled) {
return undefined
}

const cleanupEvents = attachEvents({
getAppState: () => appStateRef.current,
mapState,
getPluginState: () => pluginStateRef.current,
buttonConfig,
events: EVENTS,
eventBus,
handleInteraction: (event) => handleInteractionRef.current(event),
clickReadyRef,
closeApp
})

return cleanupEvents
}, [pluginState.enabled, buttonConfig, eventBus, closeApp])
}
Loading
Loading