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
2 changes: 2 additions & 0 deletions docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -791,6 +791,8 @@ interactiveMap.showPanel('info-panel', { focus: false })

Hides a panel without removing it, preserving its content for when it is shown again.

If focus is inside the panel, it moves to the element that opened the panel, or to the map if that element is no longer on the page. Otherwise focus stays where it is.

| Parameter | Type | Description |
|-----------|------|-------------|
| `id` | `string` | Panel identifier to hide |
Expand Down
2 changes: 2 additions & 0 deletions docs/api/context.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,8 @@ Current map state, including:
}
```

`mapState.mapSizeScale` is the scale for the current map size (`small`: 1, `medium`: 1.5, `large`: 2). Multiply screen positions from `mapProvider.mapToScreen` by it to position elements over the map.

`mapState.isMapReady` is `true` once the map instance has been created (`map:ready`). The base map may still be loading at that point; check `mapProvider.isBaseMapReady()` before adding sources or layers to the underlying map.

---
Expand Down
23 changes: 6 additions & 17 deletions plugins/datasets/src/initialise/DatasetsInit.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,39 +4,28 @@ import { EVENTS } from '../../../../src/config/events.js'
import { initialiseDatasets } from './initialiseDatasets.js'
import { loadLayerAdapter } from '../adapters/loadLayerAdapter.js'
import { hasLayersMenu } from '../utils/hasLayersMenu.js'
import { getPanelElementId } from '../../../../src/utils/getPanelElementId.js'

const LAYERS_PANEL_ID = 'datasetsLayers'

// Closes the Layers panel if its menu empties while it's open
function useCloseEmptyLayersPanel ({ appConfig, appState, pluginConfig, pluginState, services }) {
const { eventBus, focusMap } = services
function useCloseEmptyLayersPanel ({ appState, pluginConfig, pluginState, services }) {
const { eventBus } = services
const hasMenu = hasLayersMenu({ pluginConfig, pluginState })
const isLayersPanelOpen = Boolean(appState.openPanels[LAYERS_PANEL_ID])
const focusFrameRef = useRef(null)

useEffect(() => {
if (hasMenu || !isLayersPanelOpen) {
return
}
const panelEl = document.getElementById(getPanelElementId(appConfig.id, LAYERS_PANEL_ID))
const hadFocus = Boolean(panelEl?.contains(document.activeElement))
eventBus.emit(EVENTS.APP_HIDE_PANEL, LAYERS_PANEL_ID)
if (hadFocus) {
// Let the panel close and release modal focus/inert first, as Panel's handleClose does
focusFrameRef.current = requestAnimationFrame(() => focusMap())
if (!hasMenu && isLayersPanelOpen) {
eventBus.emit(EVENTS.APP_HIDE_PANEL, LAYERS_PANEL_ID)
}
}, [hasMenu, isLayersPanelOpen])

useEffect(() => () => cancelAnimationFrame(focusFrameRef.current), [])
}

export function DatasetsInit ({ appConfig, appState, pluginConfig, pluginState, mapState, mapProvider, services }) {
export function DatasetsInit ({ appState, pluginConfig, pluginState, mapState, mapProvider, services }) {
const { dispatch, datasetRegistry } = pluginState
const { eventBus, symbolRegistry, patternRegistry } = services
const isBaseMapReady = Boolean(mapProvider?.isBaseMapReady())

useCloseEmptyLayersPanel({ appConfig, appState, pluginConfig, pluginState, services })
useCloseEmptyLayersPanel({ appState, pluginConfig, pluginState, services })

// Keep a ref to the latest pluginState so event handlers can access current data
const pluginStateRef = useRef(pluginState)
Expand Down
46 changes: 3 additions & 43 deletions plugins/datasets/src/initialise/DatasetsInit.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,6 @@ const makeProps = (overrides = {}) => {
mapProvider: { isBaseMapReady: jest.fn().mockReturnValue(true) },
services: {
eventBus: makeEventBus(),
focusMap: jest.fn(),
symbolRegistry: {},
patternRegistry: {}
},
Expand All @@ -80,20 +79,6 @@ describe('DatasetsInit', () => {

describe('menu panel management', () => {
const openPanelState = { openPanels: { datasetsLayers: {} } }
let panelEl

beforeEach(() => {
jest.useFakeTimers()
panelEl = document.createElement('div')
panelEl.id = 'test-map-panel-datasets-layers'
panelEl.tabIndex = -1
document.body.appendChild(panelEl)
})

afterEach(() => {
panelEl.remove()
jest.useRealTimers()
})

it('does not remove the panel or hide the button when there is no menu', () => {
const props = makeProps({ pluginConfig: { datasets: [] } })
Expand All @@ -103,28 +88,13 @@ describe('DatasetsInit', () => {
expect(props.services.eventBus.emit).not.toHaveBeenCalledWith(EVENTS.APP_TOGGLE_BUTTON_STATE, expect.anything())
})

it('closes the open panel and focuses the map when the menu empties with focus inside it', () => {
const props = makeProps({ appState: openPanelState })
const { eventBus, focusMap } = props.services
const { rerender } = render(<DatasetsInit {...props} />)
panelEl.focus()

rerender(<DatasetsInit {...props} pluginState={makePluginState()} />)
expect(eventBus.emit).toHaveBeenCalledWith(EVENTS.APP_HIDE_PANEL, 'datasetsLayers')
expect(focusMap).not.toHaveBeenCalled()
act(() => jest.runOnlyPendingTimers())
expect(focusMap).toHaveBeenCalledTimes(1)
})

it('closes the open panel without moving focus when focus is outside it', () => {
it('closes the open panel when the menu empties', () => {
const props = makeProps({ appState: openPanelState })
const { eventBus, focusMap } = props.services
const { rerender } = render(<DatasetsInit {...props} />)
expect(props.services.eventBus.emit).not.toHaveBeenCalledWith(EVENTS.APP_HIDE_PANEL, 'datasetsLayers')

rerender(<DatasetsInit {...props} pluginState={makePluginState()} />)
act(() => jest.runOnlyPendingTimers())
expect(eventBus.emit).toHaveBeenCalledWith(EVENTS.APP_HIDE_PANEL, 'datasetsLayers')
expect(focusMap).not.toHaveBeenCalled()
expect(props.services.eventBus.emit).toHaveBeenCalledWith(EVENTS.APP_HIDE_PANEL, 'datasetsLayers')
})

it('closes a panel that is open on load when there is no menu', () => {
Expand All @@ -142,16 +112,6 @@ describe('DatasetsInit', () => {
render(<DatasetsInit {...props} pluginState={pluginState} />)
expect(props.services.eventBus.emit).not.toHaveBeenCalledWith(EVENTS.APP_HIDE_PANEL, 'datasetsLayers')
})

it('cancels a pending map focus on unmount', () => {
const props = makeProps({ appState: openPanelState })
const { rerender, unmount } = render(<DatasetsInit {...props} />)
panelEl.focus()
rerender(<DatasetsInit {...props} pluginState={makePluginState()} />)
unmount()
act(() => jest.runOnlyPendingTimers())
expect(props.services.focusMap).not.toHaveBeenCalled()
})
})

// ─── initialisation ──────────────────────────────────────────────────────────
Expand Down
3 changes: 1 addition & 2 deletions plugins/draw/src/api/editFeature.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { MAP_SIZE_SCALES } from '../defaults.js'
import { validateGeometry } from '../validation/validateGeometry.js'

export const editFeature = ({ appState, appConfig, mapState, pluginConfig, pluginState, mapProvider, services }, featureId, options = {}) => {
Expand Down Expand Up @@ -34,7 +33,7 @@ export const editFeature = ({ appState, appConfig, mapState, pluginConfig, plugi
undoButtonId: `${appConfig.id}-draw-undo`,
isPanEnabled: appState.interfaceType !== 'keyboard',
interfaceType: appState.interfaceType,
scale: MAP_SIZE_SCALES[mapState.mapSize],
scale: mapState.mapSizeScale,
featureId,
getSnapEnabled: () => draw.isSnapEnabled()
})
Expand Down
4 changes: 1 addition & 3 deletions plugins/draw/src/api/editFeature.test.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
import { editFeature } from './editFeature.js'

jest.mock('../defaults.js', () => ({ MAP_SIZE_SCALES: { medium: 1.5, large: 2 } }))

const makeContext = (overrides = {}) => {
const dispatch = jest.fn()
const eventBus = { emit: jest.fn() }
Expand All @@ -14,7 +12,7 @@ const makeContext = (overrides = {}) => {
const context = {
appState: { layoutRefs: { viewportRef: { current: 'viewport' } }, interfaceType: 'mouse' },
appConfig: { id: 'app' },
mapState: { mapSize: 'medium' },
mapState: { mapSizeScale: 1.5 },
pluginConfig: { snapLayers: ['pc-layer'] },
pluginState: { dispatch },
mapProvider: { draw },
Expand Down
3 changes: 1 addition & 2 deletions plugins/draw/src/events.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@
import { ADAPTER_EVENTS } from './adapterEvents.js'
import { validateGeometry } from './validation/validateGeometry.js'
import { MIN_VERTICES_REASONS } from './validation/rules.js'
import { MAP_SIZE_SCALES } from './defaults.js'
import { stripInternalProperties } from './utils/stripInternalProperties.js'

const EDIT_VERTEX_MODE = 'edit_vertex'
Expand Down Expand Up @@ -68,7 +67,7 @@ const enterEditVertexMode = ({ draw, appState, appConfig, mapState, dispatch },
undoButtonId: `${appConfig?.id}-draw-undo`,
isPanEnabled: appState?.interfaceType !== 'keyboard',
interfaceType: appState?.interfaceType,
scale: MAP_SIZE_SCALES[mapState?.mapSize],
scale: mapState?.mapSizeScale,
featureId,
getSnapEnabled: () => draw.isSnapEnabled()
})
Expand Down
2 changes: 1 addition & 1 deletion plugins/draw/src/events.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ const setup = (overrides = {}) => {
const buttonConfig = { drawDone: {}, drawCancel: {}, drawUndo: {}, drawDeletePoint: {}, drawSnap: {}, ...overrides.buttonConfig }
const appState = { layoutRefs: { viewportRef: { current: 'viewport' } }, interfaceType: 'mouse' }
const appConfig = { id: 'app' }
const mapState = { mapSize: 'medium' }
const mapState = { mapSizeScale: 1.5 }

const detach = attachEvents({ appState, appConfig, mapState, pluginState, mapProvider, buttonConfig, eventBus, hints })
return { draw, dispatch, pluginState, mapProvider, eventBus, hints, buttonConfig, detach }
Expand Down
21 changes: 10 additions & 11 deletions plugins/draw/src/hooks/useSpatialList.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { useEffect, useRef } from 'react'
import { EVENTS } from '../../../../src/config/events.js'
import { scaleFactor } from '../../../../src/config/appConfig.js'
import { ADAPTER_EVENTS } from '../adapterEvents.js'

const EDIT_VERTEX_MODE = 'edit_vertex'
Expand All @@ -9,9 +8,9 @@ const DRAW_PROVIDER_ID = 'draw'
// Mirrors interact's own projectToScreen (plugins/interact/src/hooks/useSpatialList.js) —
// mapProvider.mapToScreen is the provider-agnostic projection every mapProvider implements,
// not any adapter-internal pixel conversion.
const projectToScreen = (mapProvider, mapSize, coord) => {
const projectToScreen = (mapProvider, mapSizeScale, coord) => {
const { x, y } = mapProvider.mapToScreen(coord)
return { x: x * scaleFactor[mapSize], y: y * scaleFactor[mapSize] }
return { x: x * mapSizeScale, y: y * mapSizeScale }
}

// ids stay flat vertices-then-midpoints indices — matches the mode's own selectedVertexIndex
Expand All @@ -21,21 +20,21 @@ const projectToScreen = (mapProvider, mapSize, coord) => {
// N and M" label (and this interleave) only reads correctly for a single ring/part (the
// common case); a shape with holes or multiple parts would need ring-aware numbering to stay
// accurate, not done here.
const buildVertexItems = (mapProvider, mapSize) => {
const buildVertexItems = (mapProvider, mapSizeScale) => {
const { vertices, midpoints } = mapProvider.draw?.getVertexItems?.() ?? { vertices: [], midpoints: [] }
const toVertexItem = (coord, i) => ({
id: String(i),
label: `Point ${i + 1}`,
isMidpoint: false,
...projectToScreen(mapProvider, mapSize, coord)
...projectToScreen(mapProvider, mapSizeScale, coord)
})
const toMidpointItem = (coord, i) => ({
id: String(vertices.length + i),
// Kept as short as possible — a voice user hears/reads this in full each time, and
// "Insert point between N and M" was long enough to lose.
label: `Insert ${i + 1}-${((i + 1) % vertices.length) + 1}`,
isMidpoint: true,
...projectToScreen(mapProvider, mapSize, coord)
...projectToScreen(mapProvider, mapSizeScale, coord)
})
const items = []
vertices.forEach((coord, i) => {
Expand All @@ -57,10 +56,10 @@ const buildVertexItems = (mapProvider, mapSize) => {
* meanwhile, restored automatically once the claim is released) rather than contributing
* additively like interact does.
*/
function useVertexItemSync ({ pluginState, mapProvider, mapSize, spatialListRegistry, itemsRef, eventBus }) {
function useVertexItemSync ({ pluginState, mapProvider, mapSizeScale, spatialListRegistry, itemsRef, eventBus }) {
useEffect(() => {
const rebuild = () => {
itemsRef.current = pluginState.mode === EDIT_VERTEX_MODE ? buildVertexItems(mapProvider, mapSize) : []
itemsRef.current = pluginState.mode === EDIT_VERTEX_MODE ? buildVertexItems(mapProvider, mapSizeScale) : []
spatialListRegistry.notifyItemsChanged(DRAW_PROVIDER_ID)
}
rebuild()
Expand All @@ -74,7 +73,7 @@ function useVertexItemSync ({ pluginState, mapProvider, mapSize, spatialListRegi
eventBus.off(EVENTS.MAP_MOVE_END, rebuild)
draw.off(ADAPTER_EVENTS.UPDATE, rebuild)
}
}, [pluginState.mode, mapProvider, mapSize, spatialListRegistry, itemsRef, eventBus])
}, [pluginState.mode, mapProvider, mapSizeScale, spatialListRegistry, itemsRef, eventBus])

// Registers once, for the lifetime of the hook — safe outside edit_vertex too, since an
// exclusive-capable provider only ever contributes while it holds the claim (see
Expand Down Expand Up @@ -170,11 +169,11 @@ function useConfirmHandler ({ eventBus, mapProvider, pluginState, activeIdRef, i
* @param {{ mapState: object, pluginState: object, services: object, mapProvider: object, spatialListRegistry: object, viewportRef: React.RefObject }} params
*/
export function useSpatialList ({ mapState, pluginState, services, mapProvider, spatialListRegistry, viewportRef }) {
const { mapSize } = mapState
const { mapSizeScale } = mapState
const { eventBus, announce } = services
const itemsRef = useRef([])
const activeIdRef = useRef(null)
useVertexItemSync({ pluginState, mapProvider, mapSize, spatialListRegistry, itemsRef, eventBus })
useVertexItemSync({ pluginState, mapProvider, mapSizeScale, spatialListRegistry, itemsRef, eventBus })
useActiveVertexHandler({ eventBus, mapProvider, pluginState, activeIdRef })
useConfirmHandler({ eventBus, mapProvider, pluginState, activeIdRef, itemsRef, viewportRef, announce })
}
6 changes: 3 additions & 3 deletions plugins/draw/src/hooks/useSpatialList.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ const makeMapProvider = (draw) => ({

const makeViewportRef = () => ({ current: { focus: jest.fn() } })

const setup = ({ mode = 'edit_vertex', draw, eventBus, mapSize = 'small', registry, viewportRef, announce } = {}) => {
const setup = ({ mode = 'edit_vertex', draw, eventBus, mapSizeScale = 1, registry, viewportRef, announce } = {}) => {
const eb = eventBus ?? makeEventBus()
const adapter = draw ?? makeDrawAdapter()
const mp = makeMapProvider(adapter)
Expand All @@ -53,7 +53,7 @@ const setup = ({ mode = 'edit_vertex', draw, eventBus, mapSize = 'small', regist
const spatialListRegistry = registry ?? createSpatialListRegistry({ eventBus: eb })
const { result, unmount, rerender } = renderHook(
({ mode }) => useSpatialList({
mapState: { mapSize },
mapState: { mapSizeScale },
pluginState: { mode },
services: { eventBus: eb, announce: announceSpy },
mapProvider: mp,
Expand Down Expand Up @@ -97,7 +97,7 @@ describe('useSpatialList — lifecycle', () => {
const eb = makeEventBus()
const mp = { draw: null, mapToScreen: jest.fn() }
expect(() => renderHook(() => useSpatialList({
mapState: { mapSize: 'small' },
mapState: { mapSizeScale: 1 },
pluginState: { mode: 'edit_vertex' },
services: { eventBus: eb },
mapProvider: mp,
Expand Down
8 changes: 3 additions & 5 deletions plugins/interact/src/hooks/useInteractionHandlers.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { useCallback, useEffect, useRef } from 'react'
import { areAllContiguous, isContiguousWithAny } 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 All @@ -13,7 +12,7 @@ import { isStandaloneLabel } from '../../../../src/utils/symbolUtils.js'
*
* @param {Object} markers - markers object from mapState (has .items and .markerRefs)
* @param {{ x: number, y: number }} point - container-relative pixel coordinates
* @param {number} scale - scaleFactor for the current mapSize (e.g. 1.5 for medium)
* @param {number} scale - mapState.mapSizeScale (e.g. 1.5 for medium)
* @returns {string|null}
*/
const findMarkerAtPoint = (markers, point, scale) => {
Expand Down Expand Up @@ -178,7 +177,7 @@ const logDebugFeatures = (coords, features) => {
* marker hit-testing is never performed.
*
* @param {Object} deps
* @param {Object} deps.mapState - Map state including markers and mapSize
* @param {Object} deps.mapState - Map state including markers and mapSizeScale
* @param {Object} deps.pluginState - Plugin state including interactionModes, layers, etc.
* @param {Object} deps.services - Services including eventBus
* @param {Object} deps.mapProvider - Map provider instance for feature queries
Expand Down Expand Up @@ -210,15 +209,14 @@ const useHandleInteraction = ({ mapProvider, layers, interactionModes, multiSele
}

export const useInteractionHandlers = ({ mapState, pluginState, services, mapProvider }) => {
const { markers, mapSize } = mapState
const { markers, mapSizeScale: scale } = mapState
const {
dispatch, layers, interactionModes, multiSelect, contiguous,
marker: markerOptions, tolerance, selectedFeatures, selectedMarkers,
deselectOnClickOutside
} = pluginState
const { eventBus } = services
const layerConfigMap = buildLayerConfigMap(layers)
const scale = scaleFactor[mapSize] ?? 1

const processFeatureMatch = useCallback(({ feature, config }) => {
markers.remove('location')
Expand Down
7 changes: 4 additions & 3 deletions plugins/interact/src/hooks/useInteractionHandlers.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,8 @@ const makeMarkerEl = (rect) => ({
const setup = (pluginOverrides = {}, markerItems = [], markerRefs = new Map()) => {
const deps = {
mapState: {
markers: { add: jest.fn(), remove: jest.fn(), items: markerItems, markerRefs }
markers: { add: jest.fn(), remove: jest.fn(), items: markerItems, markerRefs },
mapSizeScale: 1
},
pluginState: {
dispatch: jest.fn(),
Expand Down Expand Up @@ -346,7 +347,7 @@ it('does not check markers when selectMarker is not in interactionModes', () =>

it('emits selectionchange when features are selected', () => {
const deps = {
mapState: { markers: { add: jest.fn(), remove: jest.fn(), items: [], markerRefs: new Map() } },
mapState: { markers: { add: jest.fn(), remove: jest.fn(), items: [], markerRefs: new Map() }, mapSizeScale: 1 },
pluginState: {
selectedFeatures: [{ featureId: 'F1', layerId: 'l1', idProperty: 'id', properties: { name: 'A' }, geometry: { type: 'Point', coordinates: [0, 0] } }],
selectedMarkers: []
Expand All @@ -373,7 +374,7 @@ it('skips emission when selection remains empty after being cleared', () => {
// 1. First render with a feature (prev is null, emission happens)
const { rerender } = renderHook(
({ features }) => useInteractionHandlers({
mapState: { markers: { items: [], markerRefs: new Map() } },
mapState: { markers: { items: [], markerRefs: new Map() }, mapSizeScale: 1 },
pluginState: { selectedFeatures: features, selectedMarkers: [] },
services: { eventBus },
mapProvider: { getFeatureGeometry: jest.fn(() => null) }
Expand Down
Loading
Loading