From 6210d7d308cec8d18b2d9b0e257aff7f7eae2cd4 Mon Sep 17 00:00:00 2001 From: Dan Leech Date: Wed, 1 Jul 2026 21:24:13 +0100 Subject: [PATCH 01/89] Draw plugin refactored using adapters --- demo/js/draw-ol.js | 2 +- demo/js/draw.js | 2 +- plugins/beta/draw/src/DrawInit.jsx | 78 +++ .../beta/draw/src/adapters/loadDrawAdapter.js | 14 + .../adapters/maplibre/MaplibreDrawAdapter.js | 184 ++++++ .../draw/src/adapters/maplibre/defaults.js | 1 + .../draw/src/adapters/maplibre/mapboxDraw.js | 153 +++++ .../draw/src/adapters/maplibre/mapboxSnap.js | 321 ++++++++++ .../adapters/maplibre/modes/createDrawMode.js | 552 ++++++++++++++++++ .../adapters/maplibre/modes/disabledMode.js | 23 + .../adapters/maplibre/modes/drawLineMode.js | 18 + .../maplibre/modes/drawPolygonMode.js | 16 + .../modes/editVertex/geometryHelpers.js | 135 +++++ .../maplibre/modes/editVertex/helpers.js | 2 + .../modes/editVertex/touchHandlers.js | 142 +++++ .../maplibre/modes/editVertex/undoHandlers.js | 133 +++++ .../modes/editVertex/vertexOperations.js | 142 +++++ .../modes/editVertex/vertexQueries.js | 121 ++++ .../adapters/maplibre/modes/editVertexMode.js | 514 ++++++++++++++++ .../beta/draw/src/adapters/maplibre/styles.js | 190 ++++++ .../draw/src/adapters/maplibre/undoStack.js | 36 ++ .../adapters/maplibre/utils/snapHelpers.js | 200 +++++++ .../src/adapters/openlayers/OLDrawAdapter.js | 89 +++ .../adapters/openlayers/core/OLDrawManager.js | 154 +++++ .../adapters/openlayers/core/featureStore.js | 65 +++ .../src/adapters/openlayers/core/styles.js | 98 ++++ .../src/adapters/openlayers/core/undoStack.js | 31 + .../draw/src/adapters/openlayers/defaults.js | 1 + .../src/adapters/openlayers/draw/DrawMode.js | 121 ++++ .../src/adapters/openlayers/draw/drawInput.js | 243 ++++++++ .../src/adapters/openlayers/edit/EditMode.js | 424 ++++++++++++++ .../openlayers/edit/keyboardHandler.js | 229 ++++++++ .../adapters/openlayers/edit/midpointLayer.js | 57 ++ .../adapters/openlayers/edit/touchHandler.js | 171 ++++++ .../src/adapters/openlayers/edit/undoOps.js | 95 +++ .../adapters/openlayers/edit/vertexHitTest.js | 72 +++ .../adapters/openlayers/edit/vertexLayer.js | 49 ++ .../src/adapters/openlayers/edit/vertexOps.js | 99 ++++ .../draw/src/adapters/openlayers/olDraw.js | 38 ++ .../adapters/openlayers/snap/snapEngine.js | 153 +++++ .../adapters/openlayers/snap/snapGeometry.js | 177 ++++++ .../adapters/openlayers/snap/snapIndicator.js | 81 +++ .../openlayers/snap/snapInteraction.js | 57 ++ .../adapters/openlayers/snap/snapManager.js | 99 ++++ .../utils/flattenStyleProperties.js | 47 ++ .../openlayers/utils/geometryHelpers.js | 103 ++++ .../src/adapters/openlayers/utils/olCoords.js | 35 ++ .../openlayers/utils/resolveColors.js | 39 ++ .../src/adapters/openlayers/utils/spatial.js | 24 + .../adapters/openlayers/utils/touchTarget.js | 61 ++ plugins/beta/draw/src/api/addFeature.js | 22 + plugins/beta/draw/src/api/deleteFeature.js | 11 + plugins/beta/draw/src/api/editFeature.js | 42 ++ plugins/beta/draw/src/api/merge.js | 11 + plugins/beta/draw/src/api/newLine.js | 36 ++ plugins/beta/draw/src/api/newPolygon.js | 36 ++ plugins/beta/draw/src/api/split.js | 66 +++ plugins/beta/draw/src/defaults.js | 16 + plugins/beta/draw/src/draw.scss | 43 ++ plugins/beta/draw/src/events.js | 111 ++++ plugins/beta/draw/src/index.js | 10 + plugins/beta/draw/src/manifest.js | 131 +++++ plugins/beta/draw/src/reducer.js | 59 ++ plugins/beta/draw/src/utils/debounce.js | 16 + .../draw/src/utils/flattenStyleProperties.js | 25 + plugins/beta/draw/src/utils/spatial.js | 258 ++++++++ 66 files changed, 6782 insertions(+), 2 deletions(-) create mode 100644 plugins/beta/draw/src/DrawInit.jsx create mode 100644 plugins/beta/draw/src/adapters/loadDrawAdapter.js create mode 100644 plugins/beta/draw/src/adapters/maplibre/MaplibreDrawAdapter.js create mode 100644 plugins/beta/draw/src/adapters/maplibre/defaults.js create mode 100755 plugins/beta/draw/src/adapters/maplibre/mapboxDraw.js create mode 100644 plugins/beta/draw/src/adapters/maplibre/mapboxSnap.js create mode 100644 plugins/beta/draw/src/adapters/maplibre/modes/createDrawMode.js create mode 100755 plugins/beta/draw/src/adapters/maplibre/modes/disabledMode.js create mode 100644 plugins/beta/draw/src/adapters/maplibre/modes/drawLineMode.js create mode 100755 plugins/beta/draw/src/adapters/maplibre/modes/drawPolygonMode.js create mode 100644 plugins/beta/draw/src/adapters/maplibre/modes/editVertex/geometryHelpers.js create mode 100644 plugins/beta/draw/src/adapters/maplibre/modes/editVertex/helpers.js create mode 100644 plugins/beta/draw/src/adapters/maplibre/modes/editVertex/touchHandlers.js create mode 100644 plugins/beta/draw/src/adapters/maplibre/modes/editVertex/undoHandlers.js create mode 100644 plugins/beta/draw/src/adapters/maplibre/modes/editVertex/vertexOperations.js create mode 100644 plugins/beta/draw/src/adapters/maplibre/modes/editVertex/vertexQueries.js create mode 100755 plugins/beta/draw/src/adapters/maplibre/modes/editVertexMode.js create mode 100755 plugins/beta/draw/src/adapters/maplibre/styles.js create mode 100644 plugins/beta/draw/src/adapters/maplibre/undoStack.js create mode 100644 plugins/beta/draw/src/adapters/maplibre/utils/snapHelpers.js create mode 100644 plugins/beta/draw/src/adapters/openlayers/OLDrawAdapter.js create mode 100644 plugins/beta/draw/src/adapters/openlayers/core/OLDrawManager.js create mode 100644 plugins/beta/draw/src/adapters/openlayers/core/featureStore.js create mode 100644 plugins/beta/draw/src/adapters/openlayers/core/styles.js create mode 100644 plugins/beta/draw/src/adapters/openlayers/core/undoStack.js create mode 100644 plugins/beta/draw/src/adapters/openlayers/defaults.js create mode 100644 plugins/beta/draw/src/adapters/openlayers/draw/DrawMode.js create mode 100644 plugins/beta/draw/src/adapters/openlayers/draw/drawInput.js create mode 100644 plugins/beta/draw/src/adapters/openlayers/edit/EditMode.js create mode 100644 plugins/beta/draw/src/adapters/openlayers/edit/keyboardHandler.js create mode 100644 plugins/beta/draw/src/adapters/openlayers/edit/midpointLayer.js create mode 100644 plugins/beta/draw/src/adapters/openlayers/edit/touchHandler.js create mode 100644 plugins/beta/draw/src/adapters/openlayers/edit/undoOps.js create mode 100644 plugins/beta/draw/src/adapters/openlayers/edit/vertexHitTest.js create mode 100644 plugins/beta/draw/src/adapters/openlayers/edit/vertexLayer.js create mode 100644 plugins/beta/draw/src/adapters/openlayers/edit/vertexOps.js create mode 100644 plugins/beta/draw/src/adapters/openlayers/olDraw.js create mode 100644 plugins/beta/draw/src/adapters/openlayers/snap/snapEngine.js create mode 100644 plugins/beta/draw/src/adapters/openlayers/snap/snapGeometry.js create mode 100644 plugins/beta/draw/src/adapters/openlayers/snap/snapIndicator.js create mode 100644 plugins/beta/draw/src/adapters/openlayers/snap/snapInteraction.js create mode 100644 plugins/beta/draw/src/adapters/openlayers/snap/snapManager.js create mode 100644 plugins/beta/draw/src/adapters/openlayers/utils/flattenStyleProperties.js create mode 100644 plugins/beta/draw/src/adapters/openlayers/utils/geometryHelpers.js create mode 100644 plugins/beta/draw/src/adapters/openlayers/utils/olCoords.js create mode 100644 plugins/beta/draw/src/adapters/openlayers/utils/resolveColors.js create mode 100644 plugins/beta/draw/src/adapters/openlayers/utils/spatial.js create mode 100644 plugins/beta/draw/src/adapters/openlayers/utils/touchTarget.js create mode 100644 plugins/beta/draw/src/api/addFeature.js create mode 100644 plugins/beta/draw/src/api/deleteFeature.js create mode 100644 plugins/beta/draw/src/api/editFeature.js create mode 100644 plugins/beta/draw/src/api/merge.js create mode 100644 plugins/beta/draw/src/api/newLine.js create mode 100644 plugins/beta/draw/src/api/newPolygon.js create mode 100644 plugins/beta/draw/src/api/split.js create mode 100644 plugins/beta/draw/src/defaults.js create mode 100644 plugins/beta/draw/src/draw.scss create mode 100644 plugins/beta/draw/src/events.js create mode 100644 plugins/beta/draw/src/index.js create mode 100644 plugins/beta/draw/src/manifest.js create mode 100644 plugins/beta/draw/src/reducer.js create mode 100644 plugins/beta/draw/src/utils/debounce.js create mode 100644 plugins/beta/draw/src/utils/flattenStyleProperties.js create mode 100755 plugins/beta/draw/src/utils/spatial.js diff --git a/demo/js/draw-ol.js b/demo/js/draw-ol.js index 69c64fdd0..57dc6220e 100644 --- a/demo/js/draw-ol.js +++ b/demo/js/draw-ol.js @@ -7,7 +7,7 @@ import openLayersProvider from '/providers/beta/openlayers/src/index.js' import openNamesProvider from '/providers/beta/open-names/src/index.js' // Plugins import mapStylesPlugin from '/plugins/beta/map-styles/src/index.js' -import createDrawPlugin from '/plugins/beta/draw-ol/src/index.js' +import createDrawPlugin from '/plugins/beta/draw/src/index.js' import searchPlugin from '/plugins/search/src/index.js' import createInteractPlugin from '/plugins/interact/src/index.js' diff --git a/demo/js/draw.js b/demo/js/draw.js index 98495edad..67a2fa65b 100755 --- a/demo/js/draw.js +++ b/demo/js/draw.js @@ -9,7 +9,7 @@ import openNamesProvider from '/providers/beta/open-names/src/index.js' // Plugins import mapStylesPlugin from '/plugins/beta/map-styles/src/index.js' import createDatasetsPlugin from '/plugins/beta/datasets/src/index.js' -import createDrawPlugin from '/plugins/beta/draw-ml/src/index.js' +import createDrawPlugin from '/plugins/beta/draw/src/index.js' import scaleBarPlugin from '/plugins/beta/scale-bar/src/index.js' import searchPlugin from '/plugins/search/src/index.js' import createInteractPlugin from '/plugins/interact/src/index.js' diff --git a/plugins/beta/draw/src/DrawInit.jsx b/plugins/beta/draw/src/DrawInit.jsx new file mode 100644 index 000000000..53c1c803d --- /dev/null +++ b/plugins/beta/draw/src/DrawInit.jsx @@ -0,0 +1,78 @@ +import { useEffect } from 'react' +import { EVENTS } from '../../../../src/config/events.js' +import { loadDrawAdapter } from './adapters/loadDrawAdapter.js' +import { attachEvents } from './events.js' + +export const DrawInit = ({ appState, appConfig, mapState, pluginConfig, pluginState, services, mapProvider, buttonConfig }) => { + const { eventBus } = services + const { crossHair } = mapState + const isTouchOrKeyboard = ['touch', 'keyboard'].includes(appState.interfaceType) + + useEffect(() => { + const inModeWhitelist = pluginConfig.includeModes?.includes(appState.mode) ?? true + const inExcludeModes = pluginConfig.excludeModes?.includes(appState.mode) ?? false + + if (!mapState.isMapReady || !inModeWhitelist || inExcludeModes) { + return undefined + } + + let isMounted = true + + loadDrawAdapter(mapProvider, { + mapStyle: mapState.mapStyle, + snapLayers: pluginConfig.snapLayers, + events: EVENTS, + eventBus + }).then(adapter => { + if (!isMounted) return + mapProvider.draw = adapter + pluginState.dispatch({ type: 'SET_HAS_SNAP_LAYERS', payload: pluginConfig.snapLayers?.length > 0 }) + eventBus.emit('draw:ready') + }) + + return () => { + isMounted = false + mapProvider.draw?.remove() + mapProvider.draw = null + } + }, [mapState.isMapReady, appState.mode]) + + useEffect(() => { + if (['draw_polygon', 'draw_line'].includes(pluginState.mode) && isTouchOrKeyboard) { + const wasAlreadyVisible = crossHair.isVisible + crossHair.fixAtCenter() + return () => { + if (!wasAlreadyVisible) { crossHair.hide() } + } + } + return undefined + }, [pluginState.mode, appState.interfaceType]) + + // Keep edit mode in sync with the global interface type so the touch offset + // target shows/hides immediately when the input device changes. + useEffect(() => { + if (pluginState.mode !== 'edit_vertex' || !mapProvider.draw) { + return undefined + } + mapProvider.draw.setInterfaceType(appState.interfaceType) + return undefined + }, [appState.interfaceType, pluginState.mode]) + + // Attach events when plugin state or map provider changes + useEffect(() => { + if (!mapProvider.draw) { + return undefined + } + + return attachEvents({ + appState, + appConfig, + mapState, + mapProvider, + buttonConfig, + pluginState, + events: EVENTS, + eventBus + }) + }, [mapProvider, appState, pluginState]) +} diff --git a/plugins/beta/draw/src/adapters/loadDrawAdapter.js b/plugins/beta/draw/src/adapters/loadDrawAdapter.js new file mode 100644 index 000000000..cb03341e0 --- /dev/null +++ b/plugins/beta/draw/src/adapters/loadDrawAdapter.js @@ -0,0 +1,14 @@ +export const loadDrawAdapter = async (mapProvider, options) => { + switch (mapProvider.name) { + case 'MapLibreProvider': { + const { MaplibreDrawAdapter } = await import(/* webpackChunkName: "im-draw-ml-adapter" */ './maplibre/MaplibreDrawAdapter.js') + return new MaplibreDrawAdapter(mapProvider, options) + } + case 'OpenLayersProvider': { + const { OLDrawAdapter } = await import(/* webpackChunkName: "im-draw-ol-adapter" */ './openlayers/OLDrawAdapter.js') + return new OLDrawAdapter(mapProvider, options) + } + default: + throw new Error(`No draw adapter available for map provider "${mapProvider.name}"`) + } +} diff --git a/plugins/beta/draw/src/adapters/maplibre/MaplibreDrawAdapter.js b/plugins/beta/draw/src/adapters/maplibre/MaplibreDrawAdapter.js new file mode 100644 index 000000000..b27c5d288 --- /dev/null +++ b/plugins/beta/draw/src/adapters/maplibre/MaplibreDrawAdapter.js @@ -0,0 +1,184 @@ +import { createMapboxDraw } from './mapboxDraw.js' +import { getSnapInstance, clearSnapState, clearSnapIndicator } from './utils/snapHelpers.js' + +const createEventBus = () => { + const listeners = new Map() + return { + on (type, handler) { + if (!listeners.has(type)) { listeners.set(type, new Set()) } + listeners.get(type).add(handler) + }, + off (type, handler) { listeners.get(type)?.delete(handler) }, + emit (type, ...args) { + const handlers = listeners.get(type) + if (handlers) { [...handlers].forEach(h => h(...args)) } + } + } +} + +/** + * Draw adapter for MapLibre GL. + * + * Wraps the MapboxDraw instance and normalises its map-event-based API into the + * shared adapter interface consumed by events.js, DrawInit, and the api entry points. + * + * Adapter interface (also implemented by OLDrawAdapter): + * changeMode(name, options) + * getMode() + * setInterfaceType(type) + * done() / cancel() / undo() / deleteVertex() + * get(id) / add(feature) / delete(id) / deleteAll() + * setSnapEnabled(bool) / setSnapLayers(layers) / isSnapEnabled() + * on(event, handler) / off(event, handler) + * remove() + */ +export class MaplibreDrawAdapter { + constructor (mapProvider, options) { + this._mapProvider = mapProvider + this._map = mapProvider.map + this._bus = createEventBus() + this._editingFeatureId = null + + const { draw, remove } = createMapboxDraw({ + mapStyle: options.mapStyle, + mapProvider, + events: options.events, + eventBus: options.eventBus, + snapLayers: options.snapLayers + }) + + this._draw = draw + this._cleanupDraw = remove + + // Normalise ML map events → shared adapter event bus. + // draw-ol emits these same event names directly from OLDrawManager. + this._mapHandlers = { + create: (e) => this._bus.emit('create', e.features[0]), + editfinish: (e) => this._bus.emit('editfinish', e.features[0]), + cancel: () => this._bus.emit('cancel'), + // Normalise typo: draw-ml fires numVertecies, shared interface uses numVertices + vertexselection: (e) => this._bus.emit('vertexselection', { ...e, numVertices: e.numVertecies }), + vertexchange: (e) => this._bus.emit('vertexchange', { ...e, numVertices: e.numVertecies }), + undochange: (e) => this._bus.emit('undochange', e.length), + update: (e) => this._bus.emit('update', e.features[0]), + geometrychange: (e) => this._bus.emit('geometrychange', e), + modechange: (e) => this._handleModeChange(e), + styledata: () => this._handleStyleData() + } + + this._map.on('draw.create', this._mapHandlers.create) + this._map.on('draw.editfinish', this._mapHandlers.editfinish) + this._map.on('draw.cancel', this._mapHandlers.cancel) + this._map.on('draw.vertexselection', this._mapHandlers.vertexselection) + this._map.on('draw.vertexchange', this._mapHandlers.vertexchange) + this._map.on('draw.undochange', this._mapHandlers.undochange) + this._map.on('draw.update', this._mapHandlers.update) + this._map.on('draw.geometrychange', this._mapHandlers.geometrychange) + this._map.on('draw.modechange', this._mapHandlers.modechange) + this._map.on('styledata', this._mapHandlers.styledata) + } + + changeMode (name, options = {}) { + if (name === 'edit_vertex') { + this._editingFeatureId = options.featureId ?? null + } + this._draw.changeMode(name, options) + } + + getMode () { return this._draw.getMode() } + + setInterfaceType (type) { + this._map.fire('draw.interfacetypechange', { interfaceType: type }) + } + + done () { + this._mapProvider.undoStack?.clear() + const mode = this._draw.getMode() + if (mode === 'edit_vertex' && this._editingFeatureId) { + this._map.fire('draw.editfinish', { features: [this._draw.get(this._editingFeatureId)] }) + return + } + if (mode === 'draw_polygon' || mode === 'draw_line') { + this._draw.changeMode('disabled') + } + } + + cancel () { + this._mapProvider.undoStack?.clear() + this._draw.trash() + this._draw.changeMode('disabled') + } + + undo () { + this._map.fire('draw.undo') + } + + deleteVertex () { + // TODO: wire delete-vertex into the ML edit mode (currently keyboard-only in draw-ml) + } + + get (id) { return this._draw.get(id) } + add (feature) { return this._draw.add(feature) } + delete (id) { this._draw.delete(id) } + deleteAll () { this._draw.deleteAll() } + + setSnapEnabled (bool) { + this._mapProvider.snapEnabled = bool + const snap = getSnapInstance(this._map) + if (snap?.setSnapStatus) { snap.setSnapStatus(bool) } + if (!bool && snap) { + clearSnapState(snap) + if (this._map.getLayer('snap-helper-circle')) { + this._map.setLayoutProperty('snap-helper-circle', 'visibility', 'none') + } + } + } + + setSnapLayers (layers) { + const snap = getSnapInstance(this._map) + if (snap?.setSnapLayers) { + snap.setSnapLayers(layers) + } else if (layers) { + this._map._pendingSnapLayers = layers + } + } + + isSnapEnabled () { return this._mapProvider.snapEnabled === true } + + setFeatureProperty (id, property, value) { this._draw.setFeatureProperty(id, property, value) } + + on (type, handler) { this._bus.on(type, handler) } + off (type, handler) { this._bus.off(type, handler) } + + _handleModeChange (e) { + const DRAW_MODES = new Set(['draw_polygon', 'draw_line']) + if (!DRAW_MODES.has(e.mode)) { + clearSnapIndicator(getSnapInstance(this._map), this._map) + } + } + + // Keeps draw layers on top after MapLibre style reloads + _handleStyleData () { + const layers = this._map.getStyle().layers || [] + if (!layers.length || layers[layers.length - 1].source?.startsWith('mapbox-gl-draw')) { + return + } + layers + .filter(l => l.source?.startsWith('mapbox-gl-draw')) + .forEach(l => this._map.moveLayer(l.id)) + } + + remove () { + this._map.off('draw.create', this._mapHandlers.create) + this._map.off('draw.editfinish', this._mapHandlers.editfinish) + this._map.off('draw.cancel', this._mapHandlers.cancel) + this._map.off('draw.vertexselection', this._mapHandlers.vertexselection) + this._map.off('draw.vertexchange', this._mapHandlers.vertexchange) + this._map.off('draw.undochange', this._mapHandlers.undochange) + this._map.off('draw.update', this._mapHandlers.update) + this._map.off('draw.geometrychange', this._mapHandlers.geometrychange) + this._map.off('draw.modechange', this._mapHandlers.modechange) + this._map.off('styledata', this._mapHandlers.styledata) + this._cleanupDraw() + } +} diff --git a/plugins/beta/draw/src/adapters/maplibre/defaults.js b/plugins/beta/draw/src/adapters/maplibre/defaults.js new file mode 100644 index 000000000..bca89f53e --- /dev/null +++ b/plugins/beta/draw/src/adapters/maplibre/defaults.js @@ -0,0 +1 @@ +export { DEFAULTS } from '../../defaults.js' diff --git a/plugins/beta/draw/src/adapters/maplibre/mapboxDraw.js b/plugins/beta/draw/src/adapters/maplibre/mapboxDraw.js new file mode 100755 index 000000000..b577ac7c3 --- /dev/null +++ b/plugins/beta/draw/src/adapters/maplibre/mapboxDraw.js @@ -0,0 +1,153 @@ +import MapboxDraw from '@mapbox/mapbox-gl-draw' +import { DisabledMode } from './modes/disabledMode.js' +import { EditVertexMode } from './modes/editVertexMode.js' +import { DrawPolygonMode } from './modes/drawPolygonMode.js' +import { DrawLineMode } from './modes/drawLineMode.js' +import { createDrawStyles, updateDrawStyles } from './styles.js' +import { initMapLibreSnap } from './mapboxSnap.js' +import { createUndoStack } from './undoStack.js' +import { applyTouchVertexColors } from './modes/editVertex/touchHandlers.js' + +/** + * Creates and manages a MapLibre/Mapbox Draw control instance configured for polygon editing. + * Returns an object with a `.remove()` cleanup function that removes all listeners + * and safely disposes of the Draw control. + * + * Features: + * - Custom modes for editing and drawing vertices + * - Dynamic runtime style updates on `events.MAP_SET_STYLE` event + * - Safe reapplication of styles if map.setStyle is called + * + * @param {string} options.mapStyle - Map style object + * @param {Object} options.mapProvider - Object containing the map instance + * @param {Object} options.eventBus - Event bus for app-level events + * @returns {{ draw: MapboxDraw, remove: Function }} draw instance and cleanup function + */ +export const createMapboxDraw = ({ mapStyle, mapProvider, events, eventBus, snapLayers }) => { + const { map } = mapProvider + + // --- Configure MapLibre GL Draw CSS classes --- + MapboxDraw.constants.classes.CONTROL_BASE = 'maplibregl-ctrl' + MapboxDraw.constants.classes.CONTROL_PREFIX = 'maplibregl-ctrl-' + MapboxDraw.constants.classes.CONTROL_GROUP = 'maplibregl-ctrl-group' + + // --- Register custom modes --- + const modes = { + ...MapboxDraw.modes, + disabled: DisabledMode, + edit_vertex: EditVertexMode, + draw_polygon: DrawPolygonMode, + draw_line: DrawLineMode + } + + // --- Create or reuse MapLibre Draw instance --- + let draw = mapProvider._mapboxDrawInstance + if (!draw) { + draw = new MapboxDraw({ + modes, + styles: createDrawStyles(mapStyle), + displayControlsDefault: false, + userProperties: true, + defaultMode: 'disabled' + }) + map.addControl(draw) + mapProvider._mapboxDrawInstance = draw + } else { + // Update modes on existing draw instance when adapter is recreated + Object.assign(draw.modes, modes) + } + + // Workaround: mapbox-gl-draw calls preventDefault() on touchend even in disabled mode, + // which prevents the browser from synthesizing a click event. We detect taps and + // manually dispatch a click event when in disabled mode. + const canvas = map.getCanvas() + let touchStart = null + + const handleTouchStart = (e) => { + if (e.touches.length === 1) { + touchStart = { x: e.touches[0].clientX, y: e.touches[0].clientY, time: Date.now() } + } + } + + const handleTouchEnd = (e) => { + if (draw.getMode() !== 'disabled' || !touchStart) { + touchStart = null + return + } + + const touch = e.changedTouches[0] + const dx = touch.clientX - touchStart.x + const dy = touch.clientY - touchStart.y + const duration = Date.now() - touchStart.time + + // Only synthesize click for quick taps with minimal movement + if (duration < 300 && Math.abs(dx) < 10 && Math.abs(dy) < 10) { + canvas.dispatchEvent(new MouseEvent('click', { + bubbles: true, + cancelable: true, + clientX: touch.clientX, + clientY: touch.clientY + })) + } + + touchStart = null + } + + canvas.addEventListener('touchstart', handleTouchStart, { passive: true }) + canvas.addEventListener('touchend', handleTouchEnd, { passive: true }) + + // We need a reference to this + mapProvider.draw = draw + map._drawCurrentMapStyle = mapStyle + // Initialize snap as disabled (matches initialState.snap = false) + mapProvider.snapEnabled = false + // Initialize undo stack (reuse if already exists) + let undoStack = mapProvider.undoStack + if (!undoStack) { + undoStack = createUndoStack(map) + mapProvider.undoStack = undoStack + } + map._undoStack = undoStack + + // --- Initialize MapboxSnap using external module --- + // Start with status: false to match initial snap disabled state + initMapLibreSnap(map, draw, { + layers: snapLayers, + radius: 10, + rules: ['vertex', 'edge'] + }) + + // --- Update colour scheme --- + const handleSetMapStyle = (e) => { + map._drawCurrentMapStyle = e + map.once('idle', () => { + updateDrawStyles(map, e) + const svg = map._drawEditContainer?.querySelector('[data-touch-vertex-target]') + applyTouchVertexColors(svg, e) + }) + } + eventBus.on(events.MAP_SET_STYLE, handleSetMapStyle) + + // --- Update map scale --- + const handleSetMapSize = (e) => { + map.fire('draw.scalechange', { scale: { small: 1, medium: 1.5, large: 2 }[e] }) + } + eventBus.on(events.MAP_SET_SIZE, handleSetMapSize) + + // --- Return instance and cleanup function --- + return { + draw, + remove () { + // Remove touch workaround listeners + canvas.removeEventListener('touchstart', handleTouchStart) + canvas.removeEventListener('touchend', handleTouchEnd) + // Remove event listeners + eventBus.off(events.MAP_SET_STYLE, handleSetMapStyle) + eventBus.off(events.MAP_SET_SIZE, handleSetMapSize) + // Disable draw mode but keep control on map for reuse + draw.changeMode('disabled') + // Clear adapter reference (but not _mapboxDrawInstance so it persists) + mapProvider.draw = null + } + } +} diff --git a/plugins/beta/draw/src/adapters/maplibre/mapboxSnap.js b/plugins/beta/draw/src/adapters/maplibre/mapboxSnap.js new file mode 100644 index 000000000..4f616df74 --- /dev/null +++ b/plugins/beta/draw/src/adapters/maplibre/mapboxSnap.js @@ -0,0 +1,321 @@ +import MapboxSnap from 'mapbox-gl-snap/dist/esm/MapboxSnap.js' +import { polygon, lineString } from '@turf/helpers' +import { DEFAULTS } from './defaults.js' + +const SNAP_HELPER_LAYER = 'snap-helper-circle' + +/** Apply patches to MapboxSnap prototype (once only) */ +function applyMapboxSnapPatches (colors) { + if (MapboxSnap.prototype.__snapPatched) { + return + } + MapboxSnap.prototype.__snapPatched = true + + const proto = MapboxSnap.prototype + const orig = { + setMapData: proto.setMapData, + drawingSnapCheck: proto.drawingSnapCheck, + getLines: proto.getLines, + getCloseFeatures: proto.getCloseFeatures, + searchInVertex: proto.searchInVertex, + searchInMidPoint: proto.searchInMidPoint, + searchInEdge: proto.searchInEdge, + snapToClosestPoint: proto.snapToClosestPoint + } + + // Disable changeSnappedPoints - we handle snap ourselves in drag handlers + proto.changeSnappedPoints = () => {} + + // Skip setMapData when disabled, ensure layer visibility when enabled + proto.setMapData = function (data) { + if (!this.status) { + return + } + const result = orig.setMapData.call(this, data) + if (data?.features?.length > 0 && this.map?.getLayer(SNAP_HELPER_LAYER)) { + this.map.setLayoutProperty(SNAP_HELPER_LAYER, 'visibility', 'visible') + } + return result + } + + // Skip drawingSnapCheck when disabled + proto.drawingSnapCheck = function () { + if (!this.status) { + return + } + return orig.drawingSnapCheck.call(this) + } + + // Fix typo: original uses 'coodinates' instead of 'coordinates' for Multi* types + // Also validate coordinates to prevent "coordinates must contain numbers" errors + proto.getLines = function (feature, mouse, radiusArg) { + const geom = feature.geometry + if (!geom || !geom.coordinates) { + return [] + } + const coords = geom.coordinates + // Validate that we have actual coordinate arrays + if (!Array.isArray(coords) || coords.length === 0) { + return [] + } + try { + if (geom.type === 'MultiPolygon') { + return coords.filter(c => Array.isArray(c) && c.length > 0).map(c => polygon(c)) + } + if (geom.type === 'MultiLineString') { + return coords.filter(c => Array.isArray(c) && c.length > 0).map(c => lineString(c)) + } + return orig.getLines.call(this, feature, mouse, radiusArg) + } catch (e) { + // Invalid geometry - skip this feature + console.log(e) + return [] + } + } + + // Query within radius bbox instead of just point, filter to existing layers + proto.getCloseFeatures = function (e, radiusInMeters) { + if (!this.status) { + return [] + } + // Use active layers (per-call override) or fall back to default layers + const activeLayers = this._activeLayers || this._defaultLayers || [] + this.options.layers = activeLayers.filter(l => this.map.getLayer(l)) + const r = this.options.radius || 15 + const origPt = e.point + e.point = [[origPt.x - r, origPt.y - r], [origPt.x + r, origPt.y + r]] + const result = orig.getCloseFeatures.call(this, e, radiusInMeters) + e.point = origPt + return result + } + + // Custom colors for snap indicators + proto.searchInVertex = function (...args) { + const r = orig.searchInVertex.apply(this, args) + if (r) { + r.color = colors.vertex + } + return r + } + proto.searchInMidPoint = function (...args) { + const r = orig.searchInMidPoint.apply(this, args) + if (r) { + r.color = colors.midpoint + } + return r + } + proto.searchInEdge = function (...args) { + const r = orig.searchInEdge.apply(this, args) + if (r) { + r.color = colors.edge + } + return r + } + + // Skip when disabled or zooming, clean up internal arrays to prevent memory accumulation + proto.snapToClosestPoint = function (e) { + if (!this.status || this.map?._isZooming) { + return + } + try { + const result = orig.snapToClosestPoint.call(this, e) + if (this.closeFeatures?.length > 100) { + this.closeFeatures.length = 0 + } + if (this.lines?.length > 100) { + this.lines.length = 0 + } + return result + } catch (err) { + // Invalid geometry encountered - clear state and continue + console.log(err) + this.snapStatus = false + this.snapCoords = null + } + } +} + +/** Poll until checkFn returns truthy, then call onSuccess with the result */ +function pollUntil (checkFn, onSuccess) { + (function poll () { + const result = checkFn() + // null signals to stop polling, falsy continues polling + if (result === null) return + result ? onSuccess(result) : requestAnimationFrame(poll) + })() +} + +/** + * Patch a GeoJSON source to expose _data for MapboxSnap compatibility + * MapboxSnap expects source._data.features but MapLibre doesn't expose this + */ +export function patchSourceData (source) { + if (!source || (source._data && Array.isArray(source._data?.features))) { + return + } + + let dataCache = { type: 'FeatureCollection', features: [] } + Object.defineProperty(source, '_data', { + get: () => dataCache, + set: (val) => { + dataCache = (val && typeof val === 'object' && Array.isArray(val.features)) + ? val + : { type: 'FeatureCollection', features: [] } + }, + configurable: true + }) +} + +/** Initialize MapboxSnap with MapLibre + MapboxDraw */ +export function initMapLibreSnap (map, draw, snapOptions = {}) { + // Prevent multiple initializations (causes event listener duplication) + if (map._snapInitialized) { + return map._snapInstance + } + map._snapInitialized = true + + const { + layers = [], + radius = 15, + rules = ['vertex', 'midpoint', 'edge'], + status = false, + onSnapped = () => {}, + colors = {} + } = snapOptions + + // Apply global patches to MapboxSnap prototype + applyMapboxSnapPatches({ vertex: DEFAULTS.snapVertex, midpoint: DEFAULTS.snapMidpoint, edge: DEFAULTS.snapEdge, ...colors }) + + // Clean up old snap instance's source and layer + function cleanupOldSnap () { + if (map.getLayer(SNAP_HELPER_LAYER)) { + map.removeLayer(SNAP_HELPER_LAYER) + } + if (map.getSource(SNAP_HELPER_LAYER)) { + map.removeSource(SNAP_HELPER_LAYER) + } + } + + // Create snap instance once source is available + function createSnap (source) { + // Prevent duplicate creation (race condition between initial poll and style.load) + if (map._snapInstance || map._snapCreating) { + return map._snapInstance + } + + map._snapCreating = true + + // Clean up any existing layer/source before creating new instance + cleanupOldSnap() + + patchSourceData(source) + + /** @type {any} */ + const snap = new MapboxSnap({ + map, + drawing: draw, + options: { layers, radius, rules }, + status, + onSnapped + }) + + // Override the status property to prevent library from auto-setting it + // The library sets status=true on draw.modechange and draw.selectionchange + // We want external control only via setSnapStatus() + let controlledStatus = status + + Object.defineProperty(snap, 'status', { + get () { // nosonar + return controlledStatus + }, + set () { // nosonar + // intentionally empty: library writes are ignored + }, + configurable: true + }) + + // Provide a controlled method for updating status + snap.setSnapStatus = (value) => { + controlledStatus = value + } + + // Store default layers and provide method to override per-call + snap._defaultLayers = layers + snap._activeLayers = null + + // Set snap layers (overrides defaults, pass null to reset to defaults) + snap.setSnapLayers = (overrideLayers) => { + if (overrideLayers === null || overrideLayers === undefined) { + snap._activeLayers = null // Use defaults + } else if (Array.isArray(overrideLayers)) { + snap._activeLayers = overrideLayers // Override defaults + } else { + // No action + } + } + + // Apply any pending snap layers that were set before instance was ready + if (map._pendingSnapLayers !== undefined) { + snap.setSnapLayers(map._pendingSnapLayers) + delete map._pendingSnapLayers + } + + map._snapInstance = snap + + return snap + } + + // Handle style changes - re-patch source and ensure snap layer exists + map.on('style.load', () => { + pollUntil( + () => map._removed ? null : map.getSource('mapbox-gl-draw-hot'), + (source) => { + patchSourceData(source) + + // Restore snap source/layer if gone after style change + if (!map.getSource(SNAP_HELPER_LAYER)) { + map.addSource(SNAP_HELPER_LAYER, { + type: 'geojson', + data: { type: 'FeatureCollection', features: [] } + }) + } + if (!map.getLayer(SNAP_HELPER_LAYER)) { + map.addLayer({ + id: SNAP_HELPER_LAYER, + type: 'fill', + source: SNAP_HELPER_LAYER, + paint: { 'fill-color': ['get', 'color'], 'fill-opacity': 0.6 }, + layout: { visibility: map._snapInstance?.status ? 'visible' : 'none' } + }) + } + + if (!map._snapInstance) { + createSnap(source) + } + } + ) + }) + + // Suppress snap processing during zoom (indicator freezes in place) + map.on('zoomstart', () => { + map._isZooming = true + }) + + map.on('zoomend', () => { + map._isZooming = false + // Force hide then re-show to reset indicator at new zoom level (Safari fix) + if (map.getLayer(SNAP_HELPER_LAYER)) { + map.setLayoutProperty(SNAP_HELPER_LAYER, 'visibility', 'none') + const snap = map._snapInstance + if (snap?.status) { + map.setLayoutProperty(SNAP_HELPER_LAYER, 'visibility', 'visible') + } + } + }) + + // Initial setup - poll until draw source exists + pollUntil( + () => map._removed ? null : map.getSource('mapbox-gl-draw-hot'), + createSnap + ) +} diff --git a/plugins/beta/draw/src/adapters/maplibre/modes/createDrawMode.js b/plugins/beta/draw/src/adapters/maplibre/modes/createDrawMode.js new file mode 100644 index 000000000..61f34b6c9 --- /dev/null +++ b/plugins/beta/draw/src/adapters/maplibre/modes/createDrawMode.js @@ -0,0 +1,552 @@ +import createVertex from '../../../../../../../node_modules/@mapbox/mapbox-gl-draw/src/lib/create_vertex.js' // NOSONAR + +import { + getSnapInstance, + isSnapActive, + isSnapEnabled, + getSnapLngLat, + triggerSnapAtPoint, + triggerSnapAtCenter, + createSnappedEvent, + createSnappedClickEvent +} from '../utils/snapHelpers.js' + +/** + * Factory function to create a draw mode for either polygons or lines. + * Reduces duplication by sharing common event handling, snap detection, etc. + * + * @param {Object} ParentMode - DrawPolygon or DrawLineString from mapbox-gl-draw + * @param {Object} config - Configuration for the mode + * @param {string} config.featureProp - Property name on state ('polygon' or 'line') + * @param {string} config.geometryType - 'Polygon' or 'LineString' + * @param {Function} config.getCoords - Function to get coordinates from feature + * @param {Function} config.validateClick - Validation function for clicks + * @param {Function} config.createVertices - Function to create vertex display features + */ +export const createDrawMode = (ParentMode, config) => { // NOSONAR — factory returns a single cohesive mode object; splitting across files would obscure the event flow + const { + featureProp, + geometryType, + getCoords, + validateClick, + createVertices, + excludeFeatureIdFromSetup = false, + finishOnInvalidClick = false // For lines: finish when clicking same spot (like double-click) + } = config + + const getFeature = (state) => state[featureProp] + const RUBBER_BAND_OFFSET = 2 // ring is [...placed, last_placed, rubber_band]; splice(-2,1) removes last_placed + + return { + ...ParentMode, + + onSetup (options) { + const { map } = this + + // Some parent modes (DrawLineString) interpret featureId as "continue existing" + // rather than "use this ID for new feature" + const parentOptions = excludeFeatureIdFromSetup + ? { ...options, featureId: null } + : options + + const state = { + ...ParentMode.onSetup.call(this, parentOptions), + ...options + } + + // Add initial props + state[featureProp].properties = options.properties + + const { container, vertexMarkerId, getInterfaceType } = state + const currentInterfaceType = getInterfaceType ? getInterfaceType() : state.interfaceType + state.interfaceType = currentInterfaceType + const vertexMarker = container.querySelector(`#${vertexMarkerId}`) + state.vertexMarker = vertexMarker + if (['touch', 'keyboard'].includes(currentInterfaceType)) { + this._showCrossHair(state) + } else { + this._hideCrossHair(state) + } + + // Bind all handlers once + const bind = (name, fn) => (this[name] = fn.bind(this, state)) + const handlers = { + keydownHandler: this.onKeydown, + keyupHandler: this.onKeyup, + blurHandler: this.onBlur, + createHandler: this.onCreate, + moveHandler: this.onMove, + pointerdownHandler: this.onPointerdown, + pointermoveHandler: this.onPointermove, + pointerupHandler: this.onPointerup, + vertexButtonClickHandler: this.onVertexButtonClick, + undoHandler: this.onUndo + } + Object.entries(handlers).forEach(([k, fn]) => bind(k, fn)) + + // Register events + this._listeners = [ + [window, 'keydown', this.keydownHandler], + [window, 'keyup', this.keyupHandler], + [window, 'click', this.vertexButtonClickHandler], + [container, 'blur', this.blurHandler], + [container, 'pointermove', this.pointermoveHandler], + [container, 'pointerup', this.pointerupHandler], + [map, 'pointerdown', this.pointerdownHandler], + [map, 'draw.create', this.createHandler], + [map, 'move', this.moveHandler], + [map, 'draw.undo', this.undoHandler] + ] + this._listeners.forEach(([t, e, h]) => t.addEventListener ? t.addEventListener(e, h) : t.on(e, h)) + + return state + }, + + onClick (state, e) { + // Skip non-primary clicks, undo operations, or clicks outside canvas + if (e.originalEvent.button > 0 || this.map._undoInProgress || e.originalEvent.target !== this.map.getCanvas()) { + return + } + const snap = getSnapInstance(this.map) + if (isSnapEnabled(state) && isSnapActive(snap)) { + e = createSnappedEvent(e, snap) + } else { + const coords = getCoords(getFeature(state)) + if (coords.length > 0) { + coords[coords.length - 1] = [e.lngLat.lng, e.lngLat.lat] + } + // For polygon: prevent duplicate-coordinate clicks from reaching ParentMode, which + // would trigger a changeMode chain and cause a runtime error on coords.length access + if (!finishOnInvalidClick && !validateClick(getFeature(state))) { + return + } + } + const coordsBefore = getCoords(getFeature(state)).length + ParentMode.onClick.call(this, state, e) + // Push undo and update count if a vertex was added + if (getCoords(getFeature(state)).length > coordsBefore) { + this.pushDrawUndo(state) + this.dispatchVertexChange(getCoords(getFeature(state))) + } + }, + + onTap () { + + }, + + doClick (state) { + // Skip during undo operation + if (this.map._undoInProgress) { + return + } + + const feature = getFeature(state) + const coords = getCoords(feature) + this.dispatchVertexChange(coords) + + if (!validateClick(feature)) { + // For lines: clicking same spot (like double-click) should finish the line + if (finishOnInvalidClick && coords.length > 1) { + coords.pop() + this.map.fire('draw.create', { features: [feature.toGeoJSON()] }) + this.changeMode('simple_select', { featureIds: [feature.id] }) + } + return + } + + const snap = getSnapInstance(this.map) + const snappedEvent = isSnapEnabled(state) && createSnappedClickEvent(this.map, snap) + const coordsBefore = coords.length + + if (snappedEvent) { + ParentMode.onClick.call(this, state, snappedEvent) + this._ctx.store.render() + } else { + this._simulateMouse('click', ParentMode.onClick, state) + } + + // Push undo and update count if a vertex was added + const newCoords = getCoords(getFeature(state)) + if (newCoords.length > coordsBefore) { + this.pushDrawUndo(state) + this.dispatchVertexChange(newCoords) + } + }, + + dispatchVertexChange (coords) { + // Both polygon ring and LineString store [v0...vN, rubber_band] during drawing — subtract 1 to get placed vertex count + this.map.fire('draw.vertexchange', { + numVertecies: Math.max(0, coords.length - 1) + }) + }, + + /** + * Push an undo operation for the last added vertex + */ + pushDrawUndo (state) { + const undoStack = this.map._undoStack + // Don't push during undo operations + if (!undoStack || this.map._undoInProgress) { + return + } + undoStack.push({ + type: 'draw_vertex', + geometryType, + featureId: getFeature(state).id + }) + }, + + /** + * Undo the last added vertex during drawing + */ + undoVertex (state) { + const feature = getFeature(state) + const coords = getCoords(feature) + + if (coords.length < 2) { + return false + } + + // Undoing last vertex requires reinitializing the feature + if (coords.length === 2) { + return this._reinitializeFeature(state, feature) + } + + this._removeLastVertex(state, feature, coords) + return true + }, + + /** + * Reinitialize feature when undoing to 0 vertices + * For Polygon: reinitialize in place + * For LineString: restart the draw mode with fresh state + */ + _reinitializeFeature (state, feature) { + const featureId = feature.id + this._ctx.store.delete([featureId]) + + // LineString: restart the draw mode with fresh state but same feature ID + if (geometryType === 'LineString') { + const undoStack = this.map._undoStack + if (undoStack) { + undoStack.clear() + } + // Restart draw with same options (excludeFeatureIdFromSetup prevents "continue" mode) + this._ctx.api.changeMode('draw_line', { + featureId, + container: state.container, + interfaceType: state.interfaceType, + crossHair: state.crossHair, + vertexMarkerId: state.vertexMarkerId, + addVertexButtonId: state.addVertexButtonId, + getSnapEnabled: state.getSnapEnabled, + properties: state.properties + }) + return true + } + + // Polygon: reinitialize in place + const center = this.map.getCenter() + const initialCoords = [[center.lng, center.lat], [center.lng, center.lat]] + const newFeature = this.newFeature({ + type: 'Feature', + properties: state.properties || {}, + geometry: { + type: geometryType, + coordinates: [initialCoords] + } + }) + newFeature.id = featureId + this._ctx.store.add(newFeature) + + state[featureProp] = newFeature + state.currentVertexPosition = 0 + + this._ctx.store.render() + this._simulateMouse('mousemove', ParentMode.onMouseMove, state) + this._ctx.store.render() + + this.dispatchVertexChange(initialCoords) + return true + }, + + /** + * Remove the last committed vertex and update rubber band + */ + _removeLastVertex (state, feature, coords) { + // Structure during drawing: [v1, v2, ..., vN, rubber_band] + const ring = geometryType === 'Polygon' ? feature.coordinates[0] : coords + ring.splice(-RUBBER_BAND_OFFSET, 1) + + // Snap rubber band to new last vertex position + const newLastVertex = ring[ring.length - 2] + if (newLastVertex) { + ring[ring.length - 1] = [...newLastVertex] + } + + // Keep parent mode's vertex counter in sync (min 1 for rubber band) + state.currentVertexPosition = Math.max(1, state.currentVertexPosition - 1) + + this._ctx.store.render() + this._updateRubberBand(state, getCoords(feature)) + }, + + /** + * Update rubber band position based on interface type + */ + _updateRubberBand (state, coords) { + if (['touch', 'keyboard'].includes(state.interfaceType)) { + // Touch/keyboard: move to map center for add point to work + this._simulateMouse('mousemove', ParentMode.onMouseMove, state) + this._ctx.store.render() + } else { + // Mouse: keep rubber band at current position + const rubberBandIndex = geometryType === 'Polygon' ? coords.length - 2 : coords.length - 1 + const rubberBandPos = coords[rubberBandIndex] + if (rubberBandPos) { + const lngLat = { lng: rubberBandPos[0], lat: rubberBandPos[1] } + const point = this.map.project(lngLat) + ParentMode.onMouseMove.call(this, state, { + lngLat, + point, + originalEvent: new MouseEvent('mousemove', { clientX: point.x, clientY: point.y }) + }) + this._ctx.store.render() + } + } + this.dispatchVertexChange(coords) + }, + + /** + * Handle draw.undo event + */ + onUndo (state, e) { + if (e.operation?.type === 'draw_vertex') { + this.undoVertex(state) + } + }, + + _simulateMouse (type, fn, state) { + const { map } = this + const center = map.getCenter() + const point = map.project(center) + fn.call(this, state, { + lngLat: center, + point, + originalEvent: new MouseEvent(type, { + clientX: point.x, + clientY: point.y, + bubbles: true, + cancelable: true + }) + }) + this._ctx.store.render() + + this.map.fire('draw.geometrychange', state.polygon || state.line) + }, + + _showCrossHair (state) { + if (state.crossHair) { state.crossHair.show() } else { state.vertexMarker.style.display = 'block' } + }, + + _hideCrossHair (state) { + if (state.crossHair) { state.crossHair.hide() } else { state.vertexMarker.style.display = 'none' } + }, + + _setInterface (state, type, show = true) { + state.interfaceType = type + if (show) { + this._showCrossHair(state) + } + }, + + onCreate (state, e) { + const draw = this._ctx.api + const feature = e.features[0] + draw.delete(feature.id) + feature.id = state.featureId + draw.add(feature, { userProperties: true }) + }, + + onVertexButtonClick (state, e) { + // Only trigger for the specific add vertex button, and skip during undo + if (state.addVertexButtonId && !this.map._undoInProgress && e.target.closest(`#${state.addVertexButtonId}`)) { + this.doClick(state) + } + }, + + onTouchStart (state, e) { + this._setInterface(state, 'touch') + this.onMove(state, e) + }, + + onTouchEnd (state, e) { + this._setInterface(state, 'touch') + this.onMove(state, e) + }, + + _handleUndoKeydown (state, e) { + const tag = document.activeElement?.tagName + if (tag === 'INPUT' || tag === 'TEXTAREA') { + return + } + e.preventDefault() + e.stopPropagation() + const undoStack = this.map._undoStack + if (undoStack && undoStack.length > 0) { + const operation = undoStack.pop() + if (operation?.type === 'draw_vertex') { + // Set flag to prevent click interference during undo + this.map._undoInProgress = true + setTimeout(() => { this.map._undoInProgress = false }, 100) + this.undoVertex(state) + } + } + }, + + onKeydown (state, e) { + if (e.key === 'z' && (e.metaKey || e.ctrlKey) && !e.shiftKey) { + this._handleUndoKeydown(state, e) + return + } + if (document.activeElement !== state.container) { + return + } + if (e.key === 'Escape') { + e.preventDefault() + return + } + if (e.key === 'Enter') { + state.isActive = true + } + this._setInterface(state, 'keyboard') + this.onMove(state, e) + }, + + onKeyup (state, e) { + if (e.key === 'Escape') { + if (state.interfaceType !== 'keyboard') { + // Mouse/touch: cancel drawing — onKeyUp (capital U) won't fire since container isn't focused + this.map.fire('draw.cancel') + } + // Keyboard: onKeyUp (capital U) handles reinitialize (container is focused, event reaches it) + return + } + if (document.activeElement !== state.container) { + return + } + this._setInterface(state, 'keyboard') + this.onMove(state, e) + if (e.key === 'Enter' && state.isActive) { + this.doClick(state) + } + }, + + // Called by mapbox-gl-draw's event system (capital U — distinct from onKeyup above). + // Registered on ctx.container, so only fires when the viewport has focus (keyboard drawing). + // 1. A UI element inside the viewport has focus (e.g. popup menu) → ignore, let React handle + // 2. Keyboard drawing (container focused, interfaceType === 'keyboard') → Escape restarts + // 3. Non-keyboard with container focused → skip (already handled by window onKeyup via draw.cancel) + onKeyUp (state, e) { + const activeEl = document.activeElement + if (activeEl && activeEl !== state.container && state.container.contains(activeEl)) { + return + } + if (e.key === 'Escape') { + if (state.interfaceType === 'keyboard') { + const undoStack = this.map._undoStack + if (undoStack) { + undoStack.clear() + } + this._reinitializeFeature(state, getFeature(state)) + } + // Non-keyboard already handled by onKeyup (window) via draw.cancel + return + } + if (activeEl !== state.container) { + ParentMode.onKeyUp.call(this, state, e) + } + }, + + onBlur (state, e) { + if (e.target !== state.container) { + this._hideCrossHair(state) + } + }, + + onMouseMove (state, e) { + if (isSnapEnabled(state)) { + const snap = getSnapInstance(this.map) + triggerSnapAtPoint(snap, this.map, e.point) + + const snappedLngLat = getSnapLngLat(snap) + if (snappedLngLat) { + e = { ...e, lngLat: snappedLngLat } + } + } + + this.map.fire('draw.geometrychange', state.polygon || state.line) + + ParentMode.onMouseMove.call(this, state, e) + }, + + onMove (state) { + if (['touch', 'keyboard'].includes(state.interfaceType)) { + if (isSnapEnabled(state)) { + triggerSnapAtCenter(getSnapInstance(this.map), this.map) + } + + const snap = getSnapInstance(this.map) + const snappedLngLat = isSnapEnabled(state) && getSnapLngLat(snap) + + if (snappedLngLat) { + const point = this.map.project([snappedLngLat.lng, snappedLngLat.lat]) + ParentMode.onMouseMove.call(this, state, { + lngLat: snappedLngLat, + point, + originalEvent: new MouseEvent('mousemove', { + clientX: point.x, + clientY: point.y, + bubbles: true, + cancelable: true + }) + }) + this._ctx.store.render() + } else { + this._simulateMouse('mousemove', ParentMode.onMouseMove, state) + } + } + }, + + onPointerdown (state, e) { + if (e.pointerType !== 'touch') { + this._setInterface(state, 'mouse', false) + } + }, + + onPointermove (state, e) { + if (e.pointerType !== 'touch') { + this._hideCrossHair(state) + } + }, + + onPointerup (state) { + this.dispatchVertexChange(getCoords(getFeature(state))) + }, + + toDisplayFeatures (state, geojson, display) { + ParentMode.toDisplayFeatures.call(this, state, geojson, display) + + const feature = getFeature(state) + if (geojson.geometry.type === geometryType && geojson.id === feature.id) { + createVertices(geojson, display, createVertex) + } + }, + + onStop (state) { + ParentMode.onStop.call(this, state) + this._listeners.forEach(([t, e, h]) => t.removeEventListener ? t.removeEventListener(e, h) : t.off(e, h)) + this._hideCrossHair(state) + } + } +} diff --git a/plugins/beta/draw/src/adapters/maplibre/modes/disabledMode.js b/plugins/beta/draw/src/adapters/maplibre/modes/disabledMode.js new file mode 100755 index 000000000..7f15e5cea --- /dev/null +++ b/plugins/beta/draw/src/adapters/maplibre/modes/disabledMode.js @@ -0,0 +1,23 @@ +export const DisabledMode = { + onSetup () { + return {} // Return empty state + }, + + onClick () { + // Prevent feature selection + return false + }, + + onKeyUp () { + return false + }, + + onDrag () { + return false + }, + + toDisplayFeatures (state, geojson, display) { + geojson.properties.active = 'false' + display(geojson) + } +} diff --git a/plugins/beta/draw/src/adapters/maplibre/modes/drawLineMode.js b/plugins/beta/draw/src/adapters/maplibre/modes/drawLineMode.js new file mode 100644 index 000000000..63526183f --- /dev/null +++ b/plugins/beta/draw/src/adapters/maplibre/modes/drawLineMode.js @@ -0,0 +1,18 @@ +import DrawLineString from '../../../../../../../node_modules/@mapbox/mapbox-gl-draw/src/modes/draw_line_string.js' +import { isValidLineClick } from '../../../utils/spatial.js' +import { createDrawMode } from './createDrawMode.js' + +export const DrawLineMode = createDrawMode(DrawLineString, { + featureProp: 'line', + geometryType: 'LineString', + getCoords: (feature) => feature.coordinates, + validateClick: (feature) => isValidLineClick(feature.coordinates), + excludeFeatureIdFromSetup: true, // DrawLineString interprets featureId as "continue existing" + finishOnInvalidClick: true, // Clicking same spot (like double-click) finishes the line + createVertices: (geojson, display, createVertex) => { + const coords = geojson.geometry.coordinates + for (let i = 1; i < coords.length - 1; i++) { + display(createVertex(geojson.id, coords[i], `${i}`)) + } + } +}) diff --git a/plugins/beta/draw/src/adapters/maplibre/modes/drawPolygonMode.js b/plugins/beta/draw/src/adapters/maplibre/modes/drawPolygonMode.js new file mode 100755 index 000000000..19f3884b8 --- /dev/null +++ b/plugins/beta/draw/src/adapters/maplibre/modes/drawPolygonMode.js @@ -0,0 +1,16 @@ +import DrawPolygon from '../../../../../../../node_modules/@mapbox/mapbox-gl-draw/src/modes/draw_polygon.js' +import { isValidClick } from '../../../utils/spatial.js' +import { createDrawMode } from './createDrawMode.js' + +export const DrawPolygonMode = createDrawMode(DrawPolygon, { + featureProp: 'polygon', + geometryType: 'Polygon', + getCoords: (feature) => feature.coordinates[0], + validateClick: (feature) => isValidClick(feature.coordinates), + createVertices: (geojson, display, createVertex) => { + const ring = geojson.geometry.coordinates[0] + for (let i = 1; i < ring.length - 2; i++) { + display(createVertex(geojson.id, ring[i], `0.${i}`)) + } + } +}) diff --git a/plugins/beta/draw/src/adapters/maplibre/modes/editVertex/geometryHelpers.js b/plugins/beta/draw/src/adapters/maplibre/modes/editVertex/geometryHelpers.js new file mode 100644 index 000000000..47e9e3305 --- /dev/null +++ b/plugins/beta/draw/src/adapters/maplibre/modes/editVertex/geometryHelpers.js @@ -0,0 +1,135 @@ +/** + * Pure geometry helper functions for multi-ring/multi-part geometry support. + * These functions handle coordinate transformations between flat arrays and + * hierarchical GeoJSON structures for Polygon, MultiPolygon, LineString, and MultiLineString. + */ + +/** + * Get flat coordinates array from feature for all geometry types. + * Flattens all rings/parts into a single array for unified vertex navigation. + * + * @param {Object} feature - GeoJSON geometry object + * @returns {Array<[number, number]>} Flat array of all coordinates + */ +export const getCoords = (feature) => { + if (!feature?.coordinates) return [] + switch (feature.type) { + case 'LineString': + return feature.coordinates + case 'Polygon': + return feature.coordinates.flat(1) + case 'MultiLineString': + return feature.coordinates.flat(1) + case 'MultiPolygon': + return feature.coordinates.flat(2) + default: + return [] + } +} + +/** + * Get segment metadata for multi-ring/multi-part geometries. + * Each segment represents a ring (for Polygon) or part (for Multi*). + * + * @param {Object} feature - GeoJSON geometry object + * @returns {Array<{start: number, length: number, path: number[], closed: boolean}>} + * Array of segment metadata objects where: + * - start: Starting index in flat coordinates array + * - length: Number of coordinates in this segment + * - path: Hierarchical path indices to reach this segment in GeoJSON + * - closed: Whether this segment is closed (true for Polygon rings) + */ +export const getRingSegments = (feature) => { + if (!feature?.coordinates) return [] + const segments = [] + let start = 0 + + switch (feature.type) { + case 'LineString': + segments.push({ start: 0, length: feature.coordinates.length, path: [], closed: false }) + break + case 'Polygon': + feature.coordinates.forEach((ring, ringIdx) => { + segments.push({ start, length: ring.length, path: [ringIdx], closed: true }) + start += ring.length + }) + break + case 'MultiLineString': + feature.coordinates.forEach((line, lineIdx) => { + segments.push({ start, length: line.length, path: [lineIdx], closed: false }) + start += line.length + }) + break + case 'MultiPolygon': + feature.coordinates.forEach((polygon, polyIdx) => { + polygon.forEach((ring, ringIdx) => { + segments.push({ start, length: ring.length, path: [polyIdx, ringIdx], closed: true }) + start += ring.length + }) + }) + break + default: + break + } + + return segments +} + +/** + * Find which segment a flat vertex index belongs to. + * + * @param {Array} segments - Array of segment metadata from getRingSegments + * @param {number} flatIdx - Flat vertex index + * @returns {{segment: Object, localIdx: number}|null} + * Object with segment metadata and local index within that segment, or null if not found + */ +export const getSegmentForIndex = (segments, flatIdx) => { + for (const seg of segments) { + if (flatIdx >= seg.start && flatIdx < seg.start + seg.length) { + return { segment: seg, localIdx: flatIdx - seg.start } + } + } + return null +} + +/** + * Get modifiable coordinate array at a specific hierarchical path. + * Returns a reference to the actual coordinate array in the GeoJSON structure. + * + * @param {Object} geojson - Full GeoJSON feature object + * @param {number[]} path - Hierarchical path indices (e.g., [0] for first ring, [1, 0] for second polygon's first ring) + * @returns {Array<[number, number]>} Reference to coordinate array at path + */ +export const getModifiableCoords = (geojson, path) => { + let coords = geojson.geometry.coordinates + for (const idx of path) { + coords = coords[idx] + } + return coords +} + +/** + * Convert mapbox-gl-draw coord_path string to flat vertex index. + * coord_path format: "ringIdx.vertexIdx" for Polygon, "polyIdx.ringIdx.vertexIdx" for MultiPolygon, etc. + * + * @param {Object} feature - GeoJSON geometry object + * @param {string} coordPath - coord_path string from mapbox-gl-draw + * @returns {number} Flat vertex index in the unified coordinate array + */ +export const coordPathToFlatIndex = (feature, coordPath) => { + const parts = coordPath.split('.').map(Number) + const segments = getRingSegments(feature) + + // Match coord_path to segment + for (const seg of segments) { + // Check if path matches (compare all but last element which is the local vertex index) + const pathMatches = seg.path.every((val, idx) => val === parts[idx]) + if (pathMatches && parts.length === seg.path.length + 1) { + const localIdx = parts[parts.length - 1] + return seg.start + localIdx + } + } + + // Fallback: just use the last number (works for simple geometries) + return parts[parts.length - 1] +} diff --git a/plugins/beta/draw/src/adapters/maplibre/modes/editVertex/helpers.js b/plugins/beta/draw/src/adapters/maplibre/modes/editVertex/helpers.js new file mode 100644 index 000000000..29e29e091 --- /dev/null +++ b/plugins/beta/draw/src/adapters/maplibre/modes/editVertex/helpers.js @@ -0,0 +1,2 @@ +export const scalePoint = (point, scale) => ({ x: point.x * scale, y: point.y * scale }) +export const isOnSVG = (el) => el instanceof window.SVGElement || el.ownerSVGElement diff --git a/plugins/beta/draw/src/adapters/maplibre/modes/editVertex/touchHandlers.js b/plugins/beta/draw/src/adapters/maplibre/modes/editVertex/touchHandlers.js new file mode 100644 index 000000000..cabce04e4 --- /dev/null +++ b/plugins/beta/draw/src/adapters/maplibre/modes/editVertex/touchHandlers.js @@ -0,0 +1,142 @@ +import { + getSnapInstance, isSnapEnabled, triggerSnapAtPoint, getSnapLngLat, + clearSnapState, clearSnapIndicator +} from '../../utils/snapHelpers.js' +import { coordPathToFlatIndex } from './geometryHelpers.js' +import { isOnSVG } from './helpers.js' + +const touchVertexTarget = ` + +` + +export const applyTouchVertexColors = (el, mapStyle) => { + if (!el) { return } + const dark = mapStyle?.mapColorScheme === 'dark' + el.style.setProperty('--touch-fill', dark ? '#ffffff' : '#000000') + el.style.setProperty('--touch-gfx', dark ? '#000000' : '#ffffff') +} + +export const touchHandlers = { + addTouchVertexTarget (state) { + let el = state.container.querySelector('[data-touch-vertex-target]') + if (!el) { + state.container.insertAdjacentHTML('beforeend', touchVertexTarget) + el = state.container.querySelector('[data-touch-vertex-target]') + } + state.touchVertexTarget = el + applyTouchVertexColors(el, this.map._drawCurrentMapStyle) + }, + + updateTouchVertexTarget (state, point) { + if (point && state.interfaceType === 'touch' && state.selectedVertexIndex >= 0) { + Object.assign(state.touchVertexTarget.style, { display: 'block', top: `${point.y}px`, left: `${point.x}px` }) + } else { + state.touchVertexTarget.style.display = 'none' + } + }, + + hideTouchVertexIndicator (state) { + state.touchVertexTarget.style.display = 'none' + }, + + onPointerevent (state, e) { + state.interfaceType = e.pointerType === 'touch' ? 'touch' : 'mouse' + state.isPanEnabled = true + if (e.pointerType === 'touch' && e.type === 'pointermove' && !isOnSVG(e.target.parentNode) && !state._ignorePointermoveDeselect) { + this.changeMode(state, { selectedVertexIndex: -1, selectedVertexType: null, coordPath: null }) + } + }, + + // Empty stubs required by DirectSelect + onTouchStart () {}, + onTouchMove () {}, + onTouchEnd () {}, + + onTouchend (state) { + clearSnapState(getSnapInstance(this.map)) + if (state?.featureId) { + this.syncVertices(state) + + // Push undo for the move if touch actually moved + if (state._touchMoved && state._moveStartPosition && state._moveStartIndex !== undefined) { + this.pushUndo({ + type: 'move_vertex', + featureId: state.featureId, + vertexIndex: state._moveStartIndex, + previousPosition: state._moveStartPosition + }) + } + state._moveStartPosition = null + state._moveStartIndex = undefined + state._touchMoved = false + } + }, + + onTap (state, e) { + // Hide snap indicator on any tap + const snap = getSnapInstance(this.map) + if (snap) { + clearSnapIndicator(snap, this.map) + } + + const meta = e.featureTarget?.properties.meta + const coordPath = e.featureTarget?.properties.coord_path + + if (meta === 'vertex') { + const feature = this.getFeature(state.featureId) + const idx = coordPathToFlatIndex(feature, coordPath) + this.changeMode(state, { + selectedVertexIndex: idx, + selectedVertexType: 'vertex', + coordPath + }) + } else if (meta === 'midpoint') { + this.insertVertex({ ...state, selectedVertexIndex: this.getVertexIndexFromMidpoint(state, coordPath), selectedVertexType: 'midpoint' }) + } else { + this.clickNoTarget(state) + } + }, + + onTouchstart (state, e) { + clearSnapState(getSnapInstance(this.map)) + const vertex = state.vertecies?.[state.selectedVertexIndex] + if (!vertex || !isOnSVG(e.target.parentNode)) { + return + } + + // Save starting position for undo + state._moveStartPosition = [...vertex] + state._moveStartIndex = state.selectedVertexIndex + state._touchMoved = false + + const touch = { x: e.touches[0].clientX, y: e.touches[0].clientY } + const style = window.getComputedStyle(state.touchVertexTarget) + state.deltaTarget = { x: touch.x - Number.parseFloat(style.left), y: touch.y - Number.parseFloat(style.top) } + const vertexPt = this.map.project(vertex) + state.deltaVertex = { x: (touch.x / state.scale) - vertexPt.x, y: (touch.y / state.scale) - vertexPt.y } + }, + + onTouchmove (state, e) { + if (state.selectedVertexIndex < 0 || !isOnSVG(e.target.parentNode)) { + return + } + + state._touchMoved = true + + const touch = { x: e.touches[0].clientX, y: e.touches[0].clientY } + const screenPt = { x: (touch.x / state.scale) - state.deltaVertex.x, y: (touch.y / state.scale) - state.deltaVertex.y } + + let finalCoord = this.map.unproject(screenPt) + if (isSnapEnabled(state)) { + const snap = getSnapInstance(this.map) + triggerSnapAtPoint(snap, this.map, screenPt) + finalCoord = getSnapLngLat(snap) || finalCoord + } + + this.moveVertex(state, finalCoord) + this.updateTouchVertexTarget(state, { x: touch.x - state.deltaTarget.x, y: touch.y - state.deltaTarget.y }) + } +} diff --git a/plugins/beta/draw/src/adapters/maplibre/modes/editVertex/undoHandlers.js b/plugins/beta/draw/src/adapters/maplibre/modes/editVertex/undoHandlers.js new file mode 100644 index 000000000..f87db359f --- /dev/null +++ b/plugins/beta/draw/src/adapters/maplibre/modes/editVertex/undoHandlers.js @@ -0,0 +1,133 @@ +import { + getRingSegments, + getSegmentForIndex, + getModifiableCoords +} from './geometryHelpers.js' +import { scalePoint } from './helpers.js' + +export const undoHandlers = { + // Fire geometry change event (for external listeners) + fireGeometryChange (state) { + const feature = this.getFeature(state.featureId) + if (feature) { + this.map.fire('draw.update', { + features: [feature.toGeoJSON()], + action: 'change_coordinates' + }) + } + }, + + // Undo support + pushUndo (operation) { + const undoStack = this.map._undoStack + if (!undoStack) { + return + } + undoStack.push(operation) + }, + + handleUndo (state) { + const undoStack = this.map._undoStack + if (!undoStack || undoStack.length === 0) { + return + } + + const op = undoStack.pop() + + if (op.type === 'move_vertex') { + this.undoMoveVertex(state, op) + } else if (op.type === 'insert_vertex') { + this.undoInsertVertex(state, op) + } else if (op.type === 'delete_vertex') { + this.undoDeleteVertex(state, op) + } + }, + + undoMoveVertex (state, op) { + const { vertexIndex, previousPosition, featureId } = op + const feature = this.getFeature(featureId) + if (!feature) return + + const geojson = feature.toGeoJSON() + const segments = getRingSegments(feature) + const result = getSegmentForIndex(segments, vertexIndex) + if (!result) return + + const coords = getModifiableCoords(geojson, result.segment.path) + coords[result.localIdx] = previousPosition + this._applyUndoAndSync(state, geojson, featureId) + + // Update touch vertex target position + const vertex = state.vertecies[state.selectedVertexIndex] + if (vertex) { + this.updateTouchVertexTarget(state, scalePoint(this.map.project(vertex), state.scale)) + } + }, + + undoInsertVertex (state, op) { + const { vertexIndex, featureId } = op + const feature = this.getFeature(featureId) + if (!feature) return + + const geojson = feature.toGeoJSON() + const segments = getRingSegments(feature) + const result = getSegmentForIndex(segments, vertexIndex) + if (!result) return + + const coords = getModifiableCoords(geojson, result.segment.path) + coords.splice(result.localIdx, 1) + this._applyUndoAndSync(state, geojson, featureId) + + // Clear DirectSelect's coordinate selection + this.clearSelectedCoordinates() + this.hideTouchVertexIndicator(state) + this.changeMode(state, { selectedVertexIndex: -1, selectedVertexType: null }) + }, + + undoDeleteVertex (state, op) { + const { vertexIndex, position, featureId } = op + const feature = this.getFeature(featureId) + if (!feature) { + return + } + + const geojson = feature.toGeoJSON() + const segments = getRingSegments(feature) + + // Try to find segment containing vertexIndex + let result = getSegmentForIndex(segments, vertexIndex) + + // If not found, vertex might be at segment boundary + if (!result) { + for (const seg of segments) { + if (vertexIndex === seg.start + seg.length) { + result = { segment: seg, localIdx: seg.length } + break + } + } + } + + if (!result) { + return + } + + const coords = getModifiableCoords(geojson, result.segment.path) + coords.splice(result.localIdx, 0, position) + this._applyUndoAndSync(state, geojson, featureId) + + // Update touch vertex target to restored vertex position + const vertex = state.vertecies[vertexIndex] + if (vertex) { + this.updateTouchVertexTarget(state, scalePoint(this.map.project(vertex), state.scale)) + } + this.changeMode(state, { selectedVertexIndex: vertexIndex, selectedVertexType: 'vertex', coordPath: this.getCoordPath(state, vertexIndex) }) + }, + + _applyUndoAndSync (state, geojson, featureId) { + this._ctx.api.add(geojson) + state.vertecies = this.getVerticies(featureId) + state.midpoints = this.getMidpoints(featureId) + this._ctx.store.render() + this.fireGeometryChange(state) + } +} diff --git a/plugins/beta/draw/src/adapters/maplibre/modes/editVertex/vertexOperations.js b/plugins/beta/draw/src/adapters/maplibre/modes/editVertex/vertexOperations.js new file mode 100644 index 000000000..634754e7c --- /dev/null +++ b/plugins/beta/draw/src/adapters/maplibre/modes/editVertex/vertexOperations.js @@ -0,0 +1,142 @@ +import { + getCoords, + getRingSegments, + getSegmentForIndex, + getModifiableCoords +} from './geometryHelpers.js' + +const ARROW_OFFSETS = { ArrowUp: [0, -1], ArrowDown: [0, 1], ArrowLeft: [-1, 0], ArrowRight: [1, 0] } +const NUDGE = 1; const STEP = 5 + +export const vertexOperations = { + updateMidpoint (coordinates) { + setTimeout(() => { + this.map.getSource('mapbox-gl-draw-hot').setData({ + type: 'Feature', + properties: { meta: 'midpoint', active: 'true', id: 'active-midpoint' }, + geometry: { type: 'Point', coordinates } + }) + }, 0) + }, + + updateVertex (state, direction) { + const [idx, type] = this.getVertexOrMidpoint(state, direction) + if (idx < 0 || !type) { + return + } + this.changeMode(state, { selectedVertexIndex: idx, selectedVertexType: type, ...(type === 'vertex' && { coordPath: this.getCoordPath(state, idx) }) }) + }, + + getOffset (coord, e) { + const pt = this.map.project(coord) + const offset = e?.shiftKey ? NUDGE : STEP + const [dx, dy] = e ? ARROW_OFFSETS[e.key].map(v => v * offset) : [0, 0] + return this.map.unproject({ x: pt.x + dx, y: pt.y + dy }) + }, + + getNewCoord (state, e) { + return this.getOffset(getCoords(this.getFeature(state.featureId))[state.selectedVertexIndex], e) + }, + + insertVertex (state, e) { + const midIdx = state.selectedVertexIndex - state.vertecies.length + const newCoord = this.getOffset(state.midpoints[midIdx], e) + const feature = this.getFeature(state.featureId) + const geojson = feature.toGeoJSON() + + // Find which segment this midpoint belongs to and calculate insertion position + const segments = getRingSegments(feature) + let globalInsertIdx = midIdx + 1 + let insertSegment = null + let localInsertIdx = 0 + + // Map midpoint index to segment and local position + let midpointCounter = 0 + for (const seg of segments) { + // Must match getMidpoints calculation + const segMidpoints = seg.closed ? seg.length : seg.length - 1 + if (midIdx < midpointCounter + segMidpoints) { + insertSegment = seg + localInsertIdx = (midIdx - midpointCounter) + 1 + globalInsertIdx = seg.start + localInsertIdx + break + } + midpointCounter += segMidpoints + } + + if (!insertSegment) return + + const coords = getModifiableCoords(geojson, insertSegment.path) + coords.splice(localInsertIdx, 0, [newCoord.lng, newCoord.lat]) + this._ctx.api.add(geojson) + + this.pushUndo({ type: 'insert_vertex', featureId: state.featureId, vertexIndex: globalInsertIdx }) + this.changeMode(state, { selectedVertexIndex: globalInsertIdx, selectedVertexType: 'vertex', coordPath: this.getCoordPath(state, globalInsertIdx) }) + }, + + moveVertex (state, coord, options = {}) { + if (options.checkSnap && state.enableSnap !== false) { + const snap = this.map._snapInstance + if (snap?.snapStatus && snap.snapCoords?.length >= 2) { + coord = { lng: snap.snapCoords[0], lat: snap.snapCoords[1] } + } + } + + const feature = this.getFeature(state.featureId) + const geojson = feature.toGeoJSON() + const segments = getRingSegments(feature) + const result = getSegmentForIndex(segments, state.selectedVertexIndex) + if (!result) return + + const coords = getModifiableCoords(geojson, result.segment.path) + coords[result.localIdx] = [coord.lng, coord.lat] + this._ctx.api.add(geojson) + state.vertecies = this.getVerticies(state.featureId) + + this.map.fire('draw.geometrychange', state.feature) + }, + + deleteVertex (state) { + const feature = this.getFeature(state.featureId) + if (!feature) { + return + } + + const segments = getRingSegments(feature) + const result = getSegmentForIndex(segments, state.selectedVertexIndex) + if (!result) { + return + } + + const { segment } = result + // Minimum vertices per segment: 3 for closed rings (mapbox-gl-draw's internal representation), 2 for lines + const minVertices = segment.closed ? 3 : 2 + if (segment.length <= minVertices) { + return + } + + // Save position for undo before deletion + const deletedPosition = [...state.vertecies[state.selectedVertexIndex]] + const deletedIndex = state.selectedVertexIndex + + // Remove the coordinate directly rather than via this._ctx.api.trash(), which routes through + // DirectSelect.trash() and calls onSetup({ featureId }) with incomplete options, crashing event registration. + const coordPath = [...result.segment.path, result.localIdx].join('.') + feature.removeCoordinate(coordPath) + this.fireUpdate() + this.clearSelectedCoordinates() + feature.changed() + this._ctx.store.render() + + // Push undo operation + this.pushUndo({ + type: 'delete_vertex', + featureId: state.featureId, + vertexIndex: deletedIndex, + position: deletedPosition + }) + + // Clear selection after delete + this.changeMode(state, { selectedVertexIndex: -1, selectedVertexType: null }) + } +} diff --git a/plugins/beta/draw/src/adapters/maplibre/modes/editVertex/vertexQueries.js b/plugins/beta/draw/src/adapters/maplibre/modes/editVertex/vertexQueries.js new file mode 100644 index 000000000..721ac1f60 --- /dev/null +++ b/plugins/beta/draw/src/adapters/maplibre/modes/editVertex/vertexQueries.js @@ -0,0 +1,121 @@ +import { + getCoords, + getRingSegments, + getSegmentForIndex +} from './geometryHelpers.js' +import { spatialNavigate } from '../../../../utils/spatial.js' + +export const vertexQueries = { + findVertexIndex (coords, targetCoord, currentIdx) { + // Search for vertex, preferring matches near currentIdx to handle duplicate coords (e.g., closing vertices) + const matches = [] + coords.forEach((c, i) => { + if (c[0] === targetCoord[0] && c[1] === targetCoord[1]) { + matches.push(i) + } + }) + + if (matches.length === 0) return -1 + if (matches.length === 1) return matches[0] + + // Multiple matches - pick closest to current selection + if (currentIdx >= 0) { + return matches.reduce((best, idx) => + Math.abs(idx - currentIdx) < Math.abs(best - currentIdx) ? idx : best + ) + } + return matches[0] + }, + + getCoordPath (state, idx) { + const feature = this.getFeature(state.featureId) + if (!feature) return '0' + + const segments = getRingSegments(feature) + const result = getSegmentForIndex(segments, idx) + if (!result) return '0' + + const { segment, localIdx } = result + return [...segment.path, localIdx].join('.') + }, + + syncVertices (state) { + state.vertecies = this.getVerticies(state.featureId) + state.midpoints = this.getMidpoints(state.featureId) + }, + + getVerticies (featureId) { + return getCoords(this.getFeature(featureId)) || [] + }, + + getMidpoints (featureId) { + const feature = this.getFeature(featureId) + const coords = getCoords(feature) + const segments = getRingSegments(feature) + if (!coords?.length || !segments.length) { + return [] + } + + const midpoints = [] + // Create midpoints within each segment, respecting boundaries + for (const seg of segments) { + // For closed rings, create midpoint between every vertex including last→first + // For open lines, create midpoints only between consecutive vertices (no wrap-around) + const count = seg.closed ? seg.length : seg.length - 1 + for (let i = 0; i < count; i++) { + const idx = seg.start + i + const nextIdx = seg.start + ((i + 1) % seg.length) + const [x1, y1] = coords[idx] + const [x2, y2] = coords[nextIdx] + midpoints.push([(x1 + x2) / 2, (y1 + y2) / 2]) + } + } + return midpoints + }, + + getVertexOrMidpoint (state, direction) { + // Ensure vertices and midpoints are populated + if (!state.vertecies?.length) { + state.vertecies = this.getVerticies(state.featureId) + state.midpoints = this.getMidpoints(state.featureId) + } + if (!state.vertecies?.length) { + return [-1, null] + } + const project = (p) => p ? Object.values(this.map.project(p)) : null + const pixels = [...state.vertecies.map(project), ...state.midpoints.map(project)].filter(Boolean) + if (!pixels.length) { + return [-1, null] + } + const start = pixels[state.selectedVertexIndex] || Object.values(this.map.project(this.map.getCenter())) + const idx = spatialNavigate(start, pixels, direction) + return [idx, idx < state.vertecies.length ? 'vertex' : 'midpoint'] + }, + + getVertexIndexFromMidpoint (state, coordPath) { + const feature = this.getFeature(state.featureId) + const segments = getRingSegments(feature) + const parts = coordPath.split('.').map(Number) + + // Find which segment this coord_path belongs to + let midpointOffset = 0 + for (const seg of segments) { + const pathMatches = seg.path.every((val, idx) => val === parts[idx]) + if (pathMatches && parts.length === seg.path.length + 1) { + // In DirectSelect, midpoint coord_path represents the insertion index + // The midpoint between vertex N and N+1 has coord_path ending in N+1 + // So our flat midpoint index is one less than the coord_path index + const insertionIdx = parts[parts.length - 1] + const localMidpointIdx = insertionIdx > 0 ? insertionIdx - 1 : seg.length - 2 + // Midpoints are indexed after all vertices + return state.vertecies.length + midpointOffset + localMidpointIdx + } + // Count midpoints in this segment (must match getMidpoints calculation) + const segMidpoints = seg.closed ? seg.length : seg.length - 1 + midpointOffset += segMidpoints + } + + // Fallback + return state.vertecies.length + } +} diff --git a/plugins/beta/draw/src/adapters/maplibre/modes/editVertexMode.js b/plugins/beta/draw/src/adapters/maplibre/modes/editVertexMode.js new file mode 100755 index 000000000..b926f4ffb --- /dev/null +++ b/plugins/beta/draw/src/adapters/maplibre/modes/editVertexMode.js @@ -0,0 +1,514 @@ +import DirectSelect from '../../../../../../../node_modules/@mapbox/mapbox-gl-draw/src/modes/direct_select.js' // NOSONAR +import { + getSnapInstance, isSnapActive, isSnapEnabled, getSnapLngLat, + getSnapRadius, triggerSnapAtPoint, clearSnapIndicator, clearSnapState +} from '../utils/snapHelpers.js' +import { getCoords, coordPathToFlatIndex } from './editVertex/geometryHelpers.js' +import { scalePoint } from './editVertex/helpers.js' +import { undoHandlers } from './editVertex/undoHandlers.js' +import { touchHandlers } from './editVertex/touchHandlers.js' +import { vertexOperations } from './editVertex/vertexOperations.js' +import { vertexQueries } from './editVertex/vertexQueries.js' + +const ARROW_KEYS = new Set(['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown']) +const ARROW_OFFSETS = { ArrowUp: [0, -1], ArrowDown: [0, 1], ArrowLeft: [-1, 0], ArrowRight: [1, 0] } + +export const EditVertexMode = { + ...DirectSelect, + ...undoHandlers, + ...touchHandlers, + ...vertexOperations, + ...vertexQueries, + + onSetup (options) { + const state = DirectSelect.onSetup.call(this, options) + Object.assign(state, { + container: options.container, + interfaceType: options.interfaceType, + deleteVertexButtonId: options.deleteVertexButtonId, + undoButtonId: options.undoButtonId, + isPanEnabled: options.isPanEnabled, + getSnapEnabled: options.getSnapEnabled, + featureId: state.featureId || options.featureId, + selectedVertexIndex: options.selectedVertexIndex ?? -1, + selectedVertexType: options.selectedVertexType, + coordPath: options.coordPath, + scale: options.scale ?? 1 + }) + + + // Get feature type for later reference + const feature = this.getFeature(state.featureId) + state.featureType = feature?.type + + state.vertecies = this.getVerticies(state.featureId) + state.midpoints = this.getMidpoints(state.featureId) + this.setupEventListeners(state) + + this.applyVertexSelection(state, options) + this.map._drawEditContainer = options.container + this.addTouchVertexTarget(state) + + // Clear any snap indicator when entering edit mode + const snap = getSnapInstance(this.map) + if (snap) { + clearSnapIndicator(snap, this.map) + } + + // Show touch target if entering with a selected vertex on touch interface + if (state.interfaceType === 'touch' && state.selectedVertexIndex >= 0 && state.selectedVertexType === 'vertex') { + const vertex = state.vertecies[state.selectedVertexIndex] + if (vertex) { + setTimeout(() => { + this.updateTouchVertexTarget(state, scalePoint(this.map.project(vertex), state.scale)) + }, 0) + } + } + + // Ignore pointermove deselection briefly after setup to let Safari settle + state._ignorePointermoveDeselect = true + setTimeout(() => { state._ignorePointermoveDeselect = false }, 100) + + return state + }, + + setupEventListeners (state) { + const bind = (fn) => (e) => fn.call(this, state, e) + const h = this.handlers = { + keydown: bind(this.onKeydown), + keyup: bind(this.onKeyup), + pointerdown: bind(this.onPointerevent), + pointermove: bind(this.onPointerevent), + pointerup: bind(this.onPointerevent), + click: bind(this.onButtonClick), + touchstart: bind(this.onTouchstart), + touchmove: bind(this.onTouchmove), + touchend: bind(this.onTouchend), + selectionchange: bind(this.onSelectionChange), + scalechange: bind(this.onScaleChange), + update: bind(this.onUpdate), + move: bind(this.onMove), + interfacetypechange: bind(this.onInterfaceTypeChange) + } + + window.addEventListener('keydown', h.keydown, { capture: true }) + window.addEventListener('keyup', h.keyup, { capture: true }) + window.addEventListener('click', h.click) + state.container.addEventListener('pointerdown', h.pointerdown) + state.container.addEventListener('pointermove', h.pointermove) + state.container.addEventListener('pointerup', h.pointerup) + state.container.addEventListener('touchstart', h.touchstart, { passive: false }) + state.container.addEventListener('touchmove', h.touchmove, { passive: false }) + state.container.addEventListener('touchend', h.touchend, { passive: false }) + this.map.on('draw.selectionchange', h.selectionchange) + this.map.on('draw.scalechange', h.scalechange) + this.map.on('draw.update', h.update) + this.map.on('move', h.move) + this.map.on('draw.interfacetypechange', h.interfacetypechange) + }, + + applyVertexSelection (state, options) { + if (options.selectedVertexType === 'midpoint') { + state.selectedCoordPaths = [] + this.clearSelectedCoordinates() + if (state.feature) { state.feature.changed() } + this._ctx.store.render() + this.updateMidpoint(state.midpoints[options.selectedVertexIndex - state.vertecies.length]) + return + } + if (options.selectedVertexIndex === -1) { + state.selectedCoordPaths = [] + this.clearSelectedCoordinates() + if (state.feature) { state.feature.changed() } + this._ctx.store.render() + } + }, + + onSelectionChange (state, e) { + // Refresh vertex list so numVertecies reflects the latest geometry (e.g. after midpoint insertion) + this.syncVertices(state) + + const vertexCoord = e.points[e.points.length - 1]?.geometry.coordinates + + // Only update selectedVertexIndex from event if not keyboard mode AND event has valid vertex + // For keyboard mode or when we have coordPath, trust the existing selectedVertexIndex + if (state.interfaceType !== 'keyboard' && vertexCoord && !state.coordPath) { + // No coordPath available - need to search for vertex by coordinates + const geom = e.features[0]?.geometry + const coords = getCoords(geom) + state.selectedVertexIndex = this.findVertexIndex(coords, vertexCoord, state.selectedVertexIndex) + } + // If we have coordPath, selectedVertexIndex is already correct from onTap/changeMode + + state.selectedVertexType ??= state.selectedVertexIndex >= 0 ? 'vertex' : null + + this.map.fire('draw.vertexselection', { + index: state.selectedVertexType === 'vertex' ? state.selectedVertexIndex : -1, + numVertecies: state.vertecies.length + }) + + // Use vertex from event if available, otherwise fall back to state + const vertex = vertexCoord || (state.selectedVertexIndex >= 0 ? state.vertecies[state.selectedVertexIndex] : null) + this.updateTouchVertexTarget(state, vertex ? scalePoint(this.map.project(vertex), state.scale) : null) + }, + + onScaleChange (state, e) { + state.scale = e.scale + }, + + onInterfaceTypeChange (state, e) { + state.interfaceType = e.interfaceType + const vertex = state.selectedVertexIndex >= 0 ? state.vertecies[state.selectedVertexIndex] : null + this.updateTouchVertexTarget(state, vertex ? scalePoint(this.map.project(vertex), state.scale) : null) + }, + + onUpdate (state) { + const prev = new Set(state.vertecies.map(c => JSON.stringify(c))) + if (prev.size === state.vertecies.length) { + return + } + state.selectedVertexIndex = state.vertecies.findIndex(c => !prev.has(JSON.stringify(c))) + state.selectedVertexType ??= state.selectedVertexIndex >= 0 ? 'vertex' : null + }, + + onKeydown (state, e) { + const isInteractiveElementFocused = () => { + const el = document.activeElement + if (!el || el === document.body) return false + // Allow shortcuts even on interactive elements if they're inside the map viewport + if (state.container?.contains(el)) return false + const interactiveTags = new Set(['INPUT', 'TEXTAREA', 'BUTTON', 'SELECT', 'A']) + return interactiveTags.has(el.tagName) || el.isContentEditable || el.hasAttribute('tabindex') + } + + if (isInteractiveElementFocused()) { + return + } + + state.interfaceType = 'keyboard' + this.hideTouchVertexIndicator(state) + + if (e.key === ' ') { + e.preventDefault() + } + + if (e.key === ' ' && state.selectedVertexIndex < 0) { + // Clear snap indicator when starting keyboard selection + const snap = getSnapInstance(this.map) + if (snap) { + clearSnapIndicator(snap, this.map) + } + + // Ensure we have vertices to select + if (!state.vertecies?.length) { + state.vertecies = this.getVerticies(state.featureId) + state.midpoints = this.getMidpoints(state.featureId) + } + if (!state.vertecies?.length) { + return + } + state.isPanEnabled = false + return this.updateVertex(state) + } + + if (!e.altKey && ARROW_KEYS.has(e.key) && state.selectedVertexIndex >= 0) { + e.preventDefault() + e.stopPropagation() + if (state.selectedVertexType === 'midpoint') { + return this.insertVertex(state, e) + } + + const snap = getSnapInstance(this.map) + const feature = this.getFeature(state.featureId) + if (!feature) { + return + } + const coords = getCoords(feature) + const currentCoord = coords?.[state.selectedVertexIndex] + if (!currentCoord) { + return + } + + // Save starting position for undo (only on first move of sequence) + if (!state._keyboardMoveStartPosition) { + state._keyboardMoveStartPosition = [...currentCoord] + state._keyboardMoveStartIndex = state.selectedVertexIndex + } + + // Break out of snap by moving outside snap radius + if (isSnapEnabled(state) && state._isSnapped && snap) { + const offset = getSnapRadius(snap) + 1 + const pt = this.map.project(currentCoord) + const [dx, dy] = ARROW_OFFSETS[e.key].map(v => v * offset) + state._isSnapped = false + clearSnapIndicator(snap, this.map) + return this.moveVertex(state, this.map.unproject({ x: pt.x + dx, y: pt.y + dy })) + } + + const newCoord = this.getNewCoord(state, e) + if (isSnapEnabled(state) && snap) { + triggerSnapAtPoint(snap, this.map, this.map.project(newCoord)) + if (isSnapActive(snap)) { + state._isSnapped = true + return this.moveVertex(state, getSnapLngLat(snap)) + } + } + state._isSnapped = false + return this.moveVertex(state, newCoord) + } + + if (e.altKey && ARROW_KEYS.has(e.key) && state.selectedVertexIndex >= 0) { + e.preventDefault() + e.stopPropagation() + return this.updateVertex(state, e.key) + } + + if (e.key === 'Escape') { + this.changeMode(state, { isPanEnabled: true, selectedVertexIndex: -1, selectedVertexType: null }) + } + + // Undo with Cmd/Ctrl+Z (works without viewport focus, but not in input fields) + if (e.key === 'z' && (e.metaKey || e.ctrlKey) && !e.shiftKey) { + const tag = document.activeElement?.tagName + if (tag === 'INPUT' || tag === 'TEXTAREA') { + return + } + e.preventDefault() + e.stopPropagation() + return this.handleUndo(state) + } + }, + + onKeyup (state, e) { + const isInteractiveElementFocused = () => { + const el = document.activeElement + if (!el || el === document.body) return false + if (state.container?.contains(el)) return false + const interactiveTags = new Set(['INPUT', 'TEXTAREA', 'BUTTON', 'SELECT', 'A']) + return interactiveTags.has(el.tagName) || el.isContentEditable || el.hasAttribute('tabindex') + } + + if (isInteractiveElementFocused()) { + return + } + + state.interfaceType = 'keyboard' + if (ARROW_KEYS.has(e.key) && state.selectedVertexIndex >= 0) { + e.stopPropagation() + + // Push undo for keyboard move sequence + if (state._keyboardMoveStartPosition && state._keyboardMoveStartIndex !== undefined) { + this.pushUndo({ + type: 'move_vertex', + featureId: state.featureId, + vertexIndex: state._keyboardMoveStartIndex, + previousPosition: state._keyboardMoveStartPosition + }) + state._keyboardMoveStartPosition = null + state._keyboardMoveStartIndex = undefined + } + } + if (e.key === 'Delete') { + this.deleteVertex(state) + } + }, + + onMouseDown (state, e) { + clearSnapState(getSnapInstance(this.map)) + const meta = e.featureTarget?.properties.meta + const coordPath = e.featureTarget?.properties.coord_path + + if (['vertex', 'midpoint'].includes(meta)) { + state.dragMoveLocation = e.lngLat + state.dragMoving = false + DirectSelect.onMouseDown.call(this, state, e) + + // Update selection state for vertex clicks (so onSelectionChange has correct context) + if (meta === 'vertex' && coordPath) { + const feature = this.getFeature(state.featureId) + const vertexIndex = coordPathToFlatIndex(feature, coordPath) + state.selectedVertexIndex = vertexIndex + state.selectedVertexType = 'vertex' + state.coordPath = coordPath + const vertex = state.vertecies?.[vertexIndex] + if (vertex) { + state._moveStartPosition = [...vertex] + state._moveStartIndex = vertexIndex + } + } + } + if (meta === 'midpoint') { + // DirectSelect converts midpoint to vertex - track this as an insert + const feature = this.getFeature(state.featureId) + const insertedIndex = coordPathToFlatIndex(feature, coordPath) + + // Track this insertion for undo (will be pushed on mouseUp if drag occurred) + state._insertedVertexIndex = insertedIndex + state._isInsertingVertex = true + + state.selectedVertexIndex = this.getVertexIndexFromMidpoint(state, coordPath) + state.selectedVertexType = 'vertex' + state.coordPath = null // Clear coordPath for midpoints + this.map.fire('draw.vertexselection', { index: state.selectedVertexIndex, numVertecies: state.vertecies.length }) + } + }, + + onClick (state, e) { // NOSONAR — complexity accumulated from object-level context; single guard clause, see feedback_mgl_click_vs_mouseup.md + if (state._isInsertingVertex && state._insertedVertexIndex !== undefined) { + const insertedIndex = state._insertedVertexIndex + this.syncVertices(state) + this.pushUndo({ type: 'insert_vertex', featureId: state.featureId, vertexIndex: insertedIndex }) + state.selectedVertexIndex = insertedIndex + state.selectedVertexType = 'vertex' + state._isInsertingVertex = false + state._insertedVertexIndex = undefined + this.map.fire('draw.vertexselection', { index: insertedIndex, numVertecies: state.vertecies.length }) + return + } + DirectSelect.onClick.call(this, state, e) + }, + + onMouseUp (state, e) { + clearSnapState(getSnapInstance(this.map)) + + // Check if vertex actually moved by comparing current position to start position + // This is more robust than relying on state.dragMoving which can be inconsistent + // IMPORTANT: Get current position from the feature, not state.vertecies (which is cached) + let vertexMoved = false + if (state._moveStartPosition && state._moveStartIndex !== undefined) { + const feature = this.getFeature(state.featureId) + if (feature) { + const currentVertex = getCoords(feature)?.[state._moveStartIndex] + if (currentVertex) { + vertexMoved = currentVertex[0] !== state._moveStartPosition[0] || + currentVertex[1] !== state._moveStartPosition[1] + } + } + } + + // Also check for insertions (dragMoving is reliable for midpoint drags) + const wasInsertion = state._isInsertingVertex && state._insertedVertexIndex !== undefined + + if (state.dragMoving || vertexMoved || wasInsertion) { + this.syncVertices(state) + + // Push undo for vertex insertion (from dragging midpoint) + if (wasInsertion) { + const insertedIndex = state._insertedVertexIndex + this.pushUndo({ + type: 'insert_vertex', + featureId: state.featureId, + vertexIndex: insertedIndex + }) + // selectedVertexIndex was pointing to the old midpoint-range index; + // update it to the actual flat index of the newly inserted vertex + state.selectedVertexIndex = insertedIndex + state.selectedVertexType = 'vertex' + state._isInsertingVertex = false + state._insertedVertexIndex = undefined + // Broadcast the updated vertex count — DirectSelect.onMouseUp only fires + // draw.update (not draw.selectionchange), so onSelectionChange never runs + this.map.fire('draw.vertexselection', { + index: insertedIndex, numVertecies: state.vertecies.length + }) + } else if (vertexMoved && state._moveStartPosition && state._moveStartIndex !== undefined) { + // Push undo for the move if vertex actually moved + this.pushUndo({ + type: 'move_vertex', + featureId: state.featureId, + vertexIndex: state._moveStartIndex, + previousPosition: state._moveStartPosition + }) + } else { + // No action + } + } + + // Clean up move state + state._moveStartPosition = null + state._moveStartIndex = null + + DirectSelect.onMouseUp.call(this, state, e) + }, + + onDrag (state, e) { + if (state.interfaceType === 'touch') { + return + } + + this.map.fire('draw.geometrychange', state.feature) + + const snap = getSnapInstance(this.map) + if (snap) { + snap.snapStatus = false + snap.snapCoords = null + } + + if (!isSnapEnabled(state) || !snap?.status) { + DirectSelect.onDrag.call(this, state, e) + return + } + + if (!state.selectedCoordPaths?.length || !state.canDragMove) { + return + } + + state.dragMoving = true + e.originalEvent.stopPropagation() + triggerSnapAtPoint(snap, this.map, e.point) + + const finalLngLat = getSnapLngLat(snap) || e.lngLat + state.feature.updateCoordinate(state.selectedCoordPaths[0], finalLngLat.lng, finalLngLat.lat) + state.dragMoveLocation = e.lngLat + }, + + onMove (state) { + const vertex = state.vertecies[state.selectedVertexIndex] + if (vertex) { + this.updateTouchVertexTarget(state, scalePoint(this.map.project(vertex), state.scale)) + } + }, + + onButtonClick (state, e) { + if (e.target.closest(`#${state.deleteVertexButtonId}`) && state.selectedVertexType === 'vertex') { + this.deleteVertex(state) + } + if (e.target.closest(`#${state.undoButtonId}`)) { + this.handleUndo(state) + } + }, + + clickNoTarget (state) { + this.changeMode(state, { selectedVertexIndex: -1, selectedVertexType: null, isPanEnabled: true }) + }, + + // Prevent selecting other features + changeMode (state, updates) { + if (!state.featureId) { + return + } + this._ctx.api.changeMode('edit_vertex', { ...state, ...updates }) + }, + + onStop (state) { + this.map._drawEditContainer = null + this.map._editingFeatureId = null + const h = this.handlers + state.container.removeEventListener('pointerdown', h.pointerdown) + state.container.removeEventListener('pointermove', h.pointermove) + state.container.removeEventListener('pointerup', h.pointerup) + state.container.removeEventListener('touchstart', h.touchstart) + state.container.removeEventListener('touchmove', h.touchmove) + state.container.removeEventListener('touchend', h.touchend) + this.map.off('draw.selectionchange', h.selectionchange) + this.map.off('draw.scalechange', h.scalechange) + this.map.off('draw.update', h.update) + this.map.off('move', h.move) + this.map.off('draw.interfacetypechange', h.interfacetypechange) + this.map.dragPan.enable() + window.removeEventListener('click', h.click) + window.removeEventListener('keydown', h.keydown, { capture: true }) + window.removeEventListener('keyup', h.keyup, { capture: true }) + this.hideTouchVertexIndicator(state) + } +} diff --git a/plugins/beta/draw/src/adapters/maplibre/styles.js b/plugins/beta/draw/src/adapters/maplibre/styles.js new file mode 100755 index 000000000..eaad6faf2 --- /dev/null +++ b/plugins/beta/draw/src/adapters/maplibre/styles.js @@ -0,0 +1,190 @@ +// styles.js +import { DEFAULTS } from './defaults.js' + +const getColorScheme = (mapStyle) => mapStyle.mapColorScheme ?? 'light' + +const getUserProp = (mapStyle, prop, defaultsKey = prop) => [ + 'coalesce', + ['get', `user_${prop}${mapStyle.id.charAt(0).toUpperCase() + mapStyle.id.slice(1)}`], + ['get', `user_${prop}`], + DEFAULTS[defaultsKey] +] + +// Inactive lines and fills +const fillInactive = (mapStyle) => ({ + id: 'fill-inactive', + type: 'fill', + filter: ['all', ['==', '$type', 'Polygon'], ['==', 'active', 'false']], + paint: { 'fill-color': getUserProp(mapStyle, 'fill', 'shapeFill') } +}) + +const strokeInactive = (mapStyle) => ({ + id: 'stroke-inactive', + type: 'line', + filter: ['all', ['any', ['==', '$type', 'Polygon'], ['==', '$type', 'LineString']], ['==', 'active', 'false'], ['!has', 'user_splitter']], + layout: { 'line-cap': 'round', 'line-join': 'round' }, + paint: { + 'line-color': getUserProp(mapStyle, 'stroke', 'shapeStroke'), + 'line-width': getUserProp(mapStyle, 'strokeWidth') + } +}) + +// Active lines and fills +const fillActive = (editStrokeColor) => ({ + id: 'fill-active', + type: 'fill', + filter: ['all', ['==', '$type', 'Polygon'], ['==', 'active', 'true']], + paint: { 'fill-color': editStrokeColor, 'fill-opacity': 0.1 } +}) + +const strokeActive = (editStrokeColor) => ({ + id: 'stroke-active', + type: 'line', + filter: ['all', ['any', ['==', '$type', 'Polygon'], ['==', '$type', 'LineString']], ['==', 'active', 'true'], ['!has', 'user_splitter']], + layout: { 'line-cap': 'round', 'line-join': 'round' }, + paint: { 'line-color': editStrokeColor, 'line-width': 2, 'line-opacity': 1 } +}) + +// Splitter line +const drawInvalidSplitter = (splitInvalidColor) => ({ + id: 'stroke-invalid-splitter', + type: 'line', + filter: ['all', ['==', '$type', 'LineString'], ['==', 'active', 'true'], ['==', 'user_splitter', 'invalid']], + layout: { 'line-cap': 'round', 'line-join': 'round' }, + paint: { + 'line-color': splitInvalidColor, + 'line-width': 2, + 'line-dasharray': [0.2, 2], + 'line-opacity': 1 + } +}) + +const drawValidSplitter = (splitValidColor) => ({ + id: 'stroke-valid-splitter', + type: 'line', + filter: ['all', ['==', '$type', 'LineString'], ['==', 'active', 'true'], ['==', 'user_splitter', 'valid']], + layout: { 'line-cap': 'round', 'line-join': 'round' }, + paint: { + 'line-color': splitValidColor, + 'line-width': 2, + 'line-opacity': 1 + } +}) + +// Dashed preview line +const drawPreviewLine = (editStrokeColor) => ({ + id: 'stroke-preview-line', + type: 'line', + filter: ['all', ['==', '$type', 'LineString'], ['==', 'active', 'true'], ['!has', 'user_splitter']], + layout: { 'line-cap': 'round', 'line-join': 'round' }, + paint: { 'line-color': editStrokeColor, 'line-width': 2, 'line-dasharray': [0.2, 2], 'line-opacity': 1 } +}) + +// Vertex layers +const vertex = (editVertexColor) => ({ + id: 'vertex', + type: 'circle', + filter: ['all', ['==', '$type', 'Point'], ['==', 'meta', 'vertex']], + paint: { 'circle-radius': 6, 'circle-color': editVertexColor } +}) + +const vertexHalo = (editHaloColor, editActiveColor) => ({ + id: 'vertex-halo', + type: 'circle', + filter: ['all', ['==', '$type', 'Point'], ['==', 'meta', 'vertex'], ['==', 'active', 'true']], + paint: { 'circle-radius': 8, 'circle-stroke-width': 3, 'circle-color': editHaloColor, 'circle-stroke-color': editActiveColor } +}) + +const vertexActive = (editVertexColor) => ({ + id: 'vertex-active', + type: 'circle', + filter: ['all', ['==', '$type', 'Point'], ['==', 'meta', 'vertex'], ['==', 'active', 'true']], + paint: { 'circle-radius': 6, 'circle-color': editVertexColor } +}) + +// Midpoints +const midpoint = (editMidpointColor) => ({ + id: 'midpoint', + type: 'circle', + filter: ['all', ['==', '$type', 'Point'], ['==', 'meta', 'midpoint']], + paint: { 'circle-radius': 4, 'circle-color': editMidpointColor } +}) + +const midpointHalo = (editHaloColor, editActiveColor) => ({ + id: 'midpoint-halo', + type: 'circle', + filter: ['all', ['==', '$type', 'Point'], ['==', 'meta', 'midpoint'], ['==', 'active', 'true']], + paint: { 'circle-radius': 6, 'circle-stroke-width': 3, 'circle-color': editHaloColor, 'circle-stroke-color': editActiveColor } +}) + +const midpointActive = (editMidpointColor) => ({ + id: 'midpoint-active', + type: 'circle', + filter: ['all', ['==', '$type', 'Point'], ['==', 'meta', 'midpoint'], ['==', 'active', 'true']], + paint: { 'circle-radius': 4, 'circle-color': editMidpointColor } +}) + +const circle = (editStrokeColor) => ({ + id: 'circle', + type: 'line', + filter: ['==', 'id', 'circle'], + paint: { 'line-color': editStrokeColor, 'line-width': 2, 'line-opacity': 0.8 } +}) + +const touchVertexIndicator = () => ({ + id: 'touch-vertex-indicator', + type: 'circle', + filter: ['all', ['==', '$type', 'Point'], ['==', 'meta', 'touch-vertex-indicator']], + paint: { 'circle-radius': 30, 'circle-color': '#3bb2d0', 'circle-stroke-width': 3, 'circle-stroke-color': '#ffffff', 'circle-opacity': 0.9 } +}) + +const createDrawStyles = (mapStyle) => { + const scheme = getColorScheme(mapStyle) + const editStrokeColor = DEFAULTS.editStroke[scheme] + const editVertexColor = DEFAULTS.editVertex[scheme] + const editMidpointColor = DEFAULTS.editMidpoint[scheme] + const editHaloColor = DEFAULTS.editHalo[scheme] + const editActiveColor = DEFAULTS.editActive[scheme] + const splitInvalidColor = DEFAULTS.splitInvalid[scheme] + const splitValidColor = DEFAULTS.splitValid[scheme] + + return [ + fillInactive(mapStyle), + fillActive(editStrokeColor), + strokeActive(editStrokeColor), + strokeInactive(mapStyle), + drawInvalidSplitter(splitInvalidColor), + drawValidSplitter(splitValidColor), + drawPreviewLine(editStrokeColor), + midpoint(editMidpointColor), + midpointHalo(editHaloColor, editActiveColor), + midpointActive(editMidpointColor), + vertex(editVertexColor), + vertexHalo(editHaloColor, editActiveColor), + vertexActive(editVertexColor), + circle(editStrokeColor), + touchVertexIndicator() + ] +} + +/** + * Helper to iterate over a MapLibre map and apply new paint properties + */ +const updateDrawStyles = (map, mapStyle) => { + const layers = createDrawStyles(mapStyle) + layers.forEach(layer => { + Object.entries(layer.paint).forEach(([prop, value]) => { + if (map.getLayer(`${layer.id}.cold`)) { + map.setPaintProperty(`${layer.id}.cold`, prop, value) + } + if (map.getLayer(`${layer.id}.hot`)) { + map.setPaintProperty(`${layer.id}.hot`, prop, value) + } + }) + }) +} + +export { + createDrawStyles, + updateDrawStyles +} diff --git a/plugins/beta/draw/src/adapters/maplibre/undoStack.js b/plugins/beta/draw/src/adapters/maplibre/undoStack.js new file mode 100644 index 000000000..8e08122bb --- /dev/null +++ b/plugins/beta/draw/src/adapters/maplibre/undoStack.js @@ -0,0 +1,36 @@ +/** + * Creates an undo stack manager for draw operations. + * Fires 'draw.undochange' events when stack changes for UI updates. + * + * @param {Object} map - MapLibre map instance + * @returns {Object} Undo stack manager + */ +export const createUndoStack = (map) => { + const stack = [] + + const fireChange = () => { + map.fire('draw.undochange', { length: stack.length }) + } + + return { + push (operation) { + stack.push(operation) + fireChange() + }, + + pop () { + const op = stack.pop() + fireChange() + return op + }, + + clear () { + stack.length = 0 + fireChange() + }, + + get length () { + return stack.length + } + } +} diff --git a/plugins/beta/draw/src/adapters/maplibre/utils/snapHelpers.js b/plugins/beta/draw/src/adapters/maplibre/utils/snapHelpers.js new file mode 100644 index 000000000..03afdf796 --- /dev/null +++ b/plugins/beta/draw/src/adapters/maplibre/utils/snapHelpers.js @@ -0,0 +1,200 @@ +/** + * Snap helper utilities for draw modes + * Provides a consistent interface for snap detection and coordinate retrieval + */ + +/** + * Get the snap instance from the map + * @param {maplibregl.Map} map - Map instance + * @returns {MapboxSnap|null} Snap instance or null + */ +export function getSnapInstance (map) { + return map?._snapInstance ?? null +} + +/** + * Check if snapping is currently active + * @param {MapboxSnap} snap - Snap instance + * @returns {boolean} True if snap is active with valid coordinates + */ +export function isSnapActive (snap) { + // Also check snap.status to ensure snap feature is enabled + return !!(snap?.status && snap?.snapStatus && snap.snapCoords?.length >= 2) +} + +/** + * Get snapped coordinates as lngLat object + * @param {MapboxSnap} snap - Snap instance + * @returns {{ lng: number, lat: number }|null} Snapped coordinates or null + */ +export function getSnapLngLat (snap) { + if (!isSnapActive(snap)) { + return null + } + return { + lng: snap.snapCoords[0], + lat: snap.snapCoords[1] + } +} + +/** + * Get snapped coordinates as array [lng, lat] + * @param {MapboxSnap} snap - Snap instance + * @returns {[number, number]|null} Snapped coordinates or null + */ +export function getSnapCoords (snap) { + if (!isSnapActive(snap)) { + return null + } + return [snap.snapCoords[0], snap.snapCoords[1]] +} + +/** + * Trigger snap detection at a given point + * @param {MapboxSnap} snap - Snap instance + * @param {maplibregl.Map} map - Map instance + * @param {{ x: number, y: number }} point - Screen point + * @returns {boolean} True if snap was triggered + */ +export function triggerSnapAtPoint (snap, map, point) { + if (!snap || !map || !snap.status) { + return false + } + + const lngLat = map.unproject(point) + snap.snapToClosestPoint({ point, lngLat }) + return true +} + +/** + * Trigger snap detection at map center (for touch/keyboard modes) + * @param {MapboxSnap} snap - Snap instance + * @param {maplibregl.Map} map - Map instance + * @returns {boolean} True if snap was triggered + */ +export function triggerSnapAtCenter (snap, map) { + // Don't trigger snap if library is disabled + if (!snap || !map || !snap.status) { + return false + } + + const center = map.getCenter() + const point = map.project(center) + snap.snapToClosestPoint({ point, lngLat: center }) + return true +} + +/** + * Clear the snap indicator circle and all internal snap state + * @param {MapboxSnap} snap - Snap instance + * @param {maplibregl.Map} [map] - Optional map instance for direct layer control + */ +export function clearSnapIndicator (snap, map) { + if (snap) { + snap.snapStatus = false + snap.snapCoords = null + // Clear arrays in place (avoids creating new objects, reduces GC pressure) + if (snap.snappedFeatures?.length) { + snap.snappedFeatures.length = 0 + } + if (snap.closeFeatures?.length) { + snap.closeFeatures.length = 0 + } + if (snap.lines?.length) { + snap.lines.length = 0 + } + // Note: Avoid calling setMapData here - it's expensive in Safari + } + + // Just hide the layer - much cheaper than setData() in Safari + if (map?.getLayer('snap-helper-circle')) { + map.setLayoutProperty('snap-helper-circle', 'visibility', 'none') + } +} + +/** + * Clear all snap state (for use between drag operations) + * @param {MapboxSnap} snap - Snap instance + */ +export function clearSnapState (snap) { + if (!snap) { + return + } + snap.snapStatus = false + snap.snapCoords = null + // Clear arrays in place (avoids creating new objects, reduces GC pressure) + if (snap.snappedFeatures?.length) { + snap.snappedFeatures.length = 0 + } + if (snap.closeFeatures?.length) { + snap.closeFeatures.length = 0 + } + if (snap.lines?.length) { + snap.lines.length = 0 + } +} + +/** + * Get snap radius in pixels + * @param {MapboxSnap} snap - Snap instance + * @returns {number} Snap radius in pixels (default 15) + */ +export function getSnapRadius (snap) { + return snap?.options?.radius ?? 15 +} + +/** + * Check if snapping is enabled for a given state + * @param {object} state - Mode state with optional getSnapEnabled function + * @returns {boolean} True if snapping is enabled + */ +export function isSnapEnabled (state) { + // Only return true if getSnapEnabled exists and explicitly returns true + if (typeof state?.getSnapEnabled !== 'function') { + return false + } + return state.getSnapEnabled() === true +} + +/** + * Create a synthetic map event with snapped coordinates + * @param {object} e - Original event + * @param {MapboxSnap} snap - Snap instance + * @returns {object} Event with snapped lngLat or original event + */ +export function createSnappedEvent (e, snap) { + const lngLat = getSnapLngLat(snap) + if (!lngLat) { + return e + } + + return { + ...e, + lngLat + } +} + +/** + * Create a synthetic click event at snapped coordinates + * @param {maplibregl.Map} map - Map instance + * @param {MapboxSnap} snap - Snap instance + * @returns {object|null} Synthetic event or null if no snap + */ +export function createSnappedClickEvent (map, snap) { + const lngLat = getSnapLngLat(snap) + if (!lngLat) { + return null + } + + const point = map.project([lngLat.lng, lngLat.lat]) + return { + lngLat, + point, + originalEvent: new MouseEvent('click', { + clientX: point.x, + clientY: point.y, + bubbles: true, + cancelable: true + }) + } +} diff --git a/plugins/beta/draw/src/adapters/openlayers/OLDrawAdapter.js b/plugins/beta/draw/src/adapters/openlayers/OLDrawAdapter.js new file mode 100644 index 000000000..df425216c --- /dev/null +++ b/plugins/beta/draw/src/adapters/openlayers/OLDrawAdapter.js @@ -0,0 +1,89 @@ +import { createOLDraw } from './olDraw.js' + +/** + * Draw adapter for OpenLayers. + * + * Wraps OLDrawManager (via createOLDraw) and exposes the shared adapter interface + * consumed by events.js, DrawInit, and the api entry points. + * + * OLDrawManager already exposes on/off/emit, changeMode, done/cancel/undo/deleteVertex, + * get/add/delete/deleteAll, and setInterfaceType. This adapter adds the snap methods + * and undo-stack clearing (OL manages its own undo stack on the manager). + * + * Adapter interface (also implemented by MaplibreDrawAdapter): + * changeMode(name, options) + * getMode() + * setInterfaceType(type) + * done() / cancel() / undo() / deleteVertex() + * get(id) / add(feature) / delete(id) / deleteAll() + * setSnapEnabled(bool) / setSnapLayers(layers) / isSnapEnabled() + * on(event, handler) / off(event, handler) + * remove() + */ +export class OLDrawAdapter { + constructor (mapProvider, options) { + this._snapEnabled = false + + const { remove } = createOLDraw({ + mapProvider, + events: options.events, + eventBus: options.eventBus, + pluginConfig: { snapLayers: options.snapLayers }, + mapStyle: options.mapStyle + }) + this._cleanupOLDraw = remove + // createOLDraw sets mapProvider.draw = manager; save it before DrawInit overwrites it + this._manager = mapProvider.draw + this._mapProvider = mapProvider + } + + changeMode (name, options = {}) { + // Inject OL-specific options that the unified api doesn't supply + const opts = { ...options, mapProvider: this._mapProvider } + if (name === 'draw_polygon') { opts.geometryType = 'Polygon' } + if (name === 'draw_line') { opts.geometryType = 'LineString' } + return this._manager.changeMode(name, opts) + } + + getMode () { return this._manager.getMode() } + + setInterfaceType (type) { this._manager.setInterfaceType(type) } + + done () { + this._manager.undoStack.clear() + this._manager.done() + } + + cancel () { + this._manager.undoStack.clear() + this._manager.cancel() + } + + undo () { this._manager.undo() } + deleteVertex () { this._manager.deleteVertex() } + + get (id) { return this._manager.get(id) } + add (feature) { return this._manager.add(feature) } + delete (id) { return this._manager.delete(id) } + deleteAll () { return this._manager.deleteAll() } + + setSnapEnabled (bool) { + this._snapEnabled = bool + this._manager.snap?.setActive(bool) + } + + setSnapLayers (layers) { + this._manager.snap?.setSnapLayers(layers) + } + + isSnapEnabled () { return this._snapEnabled } + + setFeatureProperty () { /* not implemented for OL */ } + + on (type, handler) { this._manager.on(type, handler) } + off (type, handler) { this._manager.off(type, handler) } + + remove () { + this._cleanupOLDraw() + } +} diff --git a/plugins/beta/draw/src/adapters/openlayers/core/OLDrawManager.js b/plugins/beta/draw/src/adapters/openlayers/core/OLDrawManager.js new file mode 100644 index 000000000..32546b4eb --- /dev/null +++ b/plugins/beta/draw/src/adapters/openlayers/core/OLDrawManager.js @@ -0,0 +1,154 @@ +import VectorLayer from 'ol/layer/Vector.js' +import { createFeatureStore } from './featureStore.js' +import { createUndoStack } from './undoStack.js' +import { createStyles } from './styles.js' +import { resolveColors } from '../utils/resolveColors.js' +import { createSnapManager } from '../snap/snapManager.js' +import { DEFAULTS } from '../defaults.js' + +/** + * Mode machine for the OL draw plugin. + * + * Owns the VectorSource/Layer, undo stack, and current mode instance. + * Exposes a minimal on/off/emit event bus for internal plugin communication + * (separate from the public eventBus used for consumer-facing events). + * + * Consumer-facing events are always emitted via eventBus by events.js after + * listening to the manager's internal events. + */ +export class OLDrawManager { + constructor (map, pluginConfig = {}) { + this._map = map + this._pluginConfig = pluginConfig + this._mode = 'disabled' + this._modeInstance = null + this._listeners = new Map() + + this.store = createFeatureStore() + this.undoStack = createUndoStack((length) => this.emit('undochange', length)) + + this.colors = resolveColors(null, pluginConfig) + this.styles = createStyles(this.colors) + this.snap = createSnapManager(map, pluginConfig.snapLayers ?? null, this.colors, pluginConfig.snapRadius ?? DEFAULTS.snapRadius) + + this._layer = new VectorLayer({ + source: this.store.source, + style: this.styles.createFeatureStyle(), + zIndex: 100 + }) + this._layer.set('layerId', 'draw') + map.addLayer(this._layer) + } + + // --- Color / style updates --- + + setMapStyle (mapStyle) { + this.colors = resolveColors(mapStyle, this._pluginConfig) + this.styles = createStyles(this.colors) + this._layer.setStyle(this.styles.createFeatureStyle()) + this.store.source.changed() + this.snap?.updateColors(this.colors) + this.emit('styleschanged', this.styles) + } + + // --- Internal event bus --- + + on (type, handler) { + if (!this._listeners.has(type)) { + this._listeners.set(type, new Set()) + } + this._listeners.get(type).add(handler) + } + + off (type, handler) { + this._listeners.get(type)?.delete(handler) + } + + emit (type, detail) { + const handlers = this._listeners.get(type) + if (handlers) { [...handlers].forEach(h => h(detail)) } + } + + // --- Mode machine --- + + async changeMode (modeName, options = {}) { + this._modeInstance?.destroy() + this._modeInstance = null + this._mode = modeName + + const isDrawMode = modeName === 'draw_polygon' || modeName === 'draw_line' || modeName === 'edit_vertex' + this.snap?.setIndicatorActive(isDrawMode) + + const modeOptions = { ...options, snap: this.snap } + + if (modeName === 'draw_polygon' || modeName === 'draw_line') { + const { createDrawMode } = await import('../draw/DrawMode.js') + this._modeInstance = createDrawMode({ map: this._map, manager: this, options: modeOptions }) + } else if (modeName === 'edit_vertex') { + const { createEditMode } = await import('../edit/EditMode.js') + this._modeInstance = createEditMode({ map: this._map, manager: this, options: modeOptions }) + } else { + // disabled — no mode instance needed + } + // Reattach snap interaction after mode's interactions are added so it + // processes pointermove first (OL: last-added interaction = first to handle events). + this.snap?.reattach() + } + + getMode () { + return this._mode + } + + // --- High-level operations called by events.js --- + + done () { + this._modeInstance?.done() + } + + cancel () { + this._modeInstance?.cancel() + this.changeMode('disabled') + } + + undo () { + this._modeInstance?.undo() + } + + deleteVertex () { + this._modeInstance?.deleteVertex() + } + + setInterfaceType (type) { + this._modeInstance?.setInterfaceType?.(type) + } + + // --- Feature store delegation --- + + get (id) { + return this.store.get(id) + } + + add (geojsonFeature) { + return this.store.add(geojsonFeature) + } + + delete (id) { + return this.store.remove(id) + } + + deleteAll () { + return this.store.clear() + } + + // --- Cleanup --- + + remove () { + this._modeInstance?.destroy() + this._modeInstance = null + this.snap?.destroy() + this.snap = null + this.store.clear() + this._map.removeLayer(this._layer) + this._listeners.clear() + } +} diff --git a/plugins/beta/draw/src/adapters/openlayers/core/featureStore.js b/plugins/beta/draw/src/adapters/openlayers/core/featureStore.js new file mode 100644 index 000000000..5d3aa6e72 --- /dev/null +++ b/plugins/beta/draw/src/adapters/openlayers/core/featureStore.js @@ -0,0 +1,65 @@ +import VectorSource from 'ol/source/Vector.js' +import GeoJSON from 'ol/format/GeoJSON.js' + +const PROJECTION = 'EPSG:27700' + +// No coordinate transformation: data and map both use BNG. +const format = new GeoJSON({ dataProjection: PROJECTION, featureProjection: PROJECTION }) + +/** + * Wraps an OL VectorSource with a GeoJSON-oriented API keyed by feature ID. + * All GeoJSON in and out uses EPSG:27700 coordinates (BNG easting/northing). + */ +export const createFeatureStore = () => { + const source = new VectorSource() + + return { + /** The underlying VectorSource, passed to OL interactions and layers. */ + source, + + /** Get the raw OL Feature by ID, or null. */ + getOL (id) { + return source.getFeatureById(String(id)) ?? null + }, + + /** Add or replace a GeoJSON feature. Returns the OL Feature. */ + add (geojsonFeature) { + const existing = this.getOL(geojsonFeature.id) + if (existing) { + source.removeFeature(existing) + } + const olFeature = format.readFeature(geojsonFeature) + source.addFeature(olFeature) + return olFeature + }, + + /** Get a GeoJSON feature by ID, or null. */ + get (id) { + const feature = this.getOL(id) + return feature ? format.writeFeatureObject(feature) : null + }, + + /** Remove a feature by ID. */ + remove (id) { + const feature = this.getOL(id) + if (feature) { + source.removeFeature(feature) + } + }, + + /** Remove all features. */ + clear () { + source.clear() + }, + + /** Convert an OL Feature to a GeoJSON object. */ + toGeoJSON (olFeature) { + return format.writeFeatureObject(olFeature) + }, + + /** Convert a GeoJSON object to an OL Feature (no side effects). */ + fromGeoJSON (geojsonFeature) { + return format.readFeature(geojsonFeature) + } + } +} diff --git a/plugins/beta/draw/src/adapters/openlayers/core/styles.js b/plugins/beta/draw/src/adapters/openlayers/core/styles.js new file mode 100644 index 000000000..d00b246b0 --- /dev/null +++ b/plugins/beta/draw/src/adapters/openlayers/core/styles.js @@ -0,0 +1,98 @@ +import Style from 'ol/style/Style.js' +import Fill from 'ol/style/Fill.js' +import Stroke from 'ol/style/Stroke.js' +import CircleStyle from 'ol/style/Circle.js' + +const selectedVertexRadii = { outer: 11, mid: 8, inner: 6 } +const selectedMidpointRadii = { outer: 9, mid: 6, inner: 4 } + +const fillArc = (ctx, cx, cy, radius, fillStyle) => { + ctx.beginPath() + ctx.arc(cx, cy, radius, 0, Math.PI * 2) + ctx.fillStyle = fillStyle + ctx.fill() +} + +// Custom renderer draws all arcs at the same (cx,cy) so concentric rings never +// drift at fractional CSS scales (e.g. 1.5×) the way separate drawImage calls can. +const makeRingRenderer = ({ outer, mid, inner }, colors, innerKey) => (pixelCoordinates, state) => { + const ctx = state.context + const pr = state.pixelRatio + const [cx, cy] = /** @type {number[]} */ (pixelCoordinates) + ctx.save() + fillArc(ctx, cx, cy, outer * pr, colors.editActive) + fillArc(ctx, cx, cy, mid * pr, colors.editHalo) + fillArc(ctx, cx, cy, inner * pr, colors[innerKey]) + ctx.restore() +} + +const capitalize = (s) => s.charAt(0).toUpperCase() + s.slice(1) + +/** + * Create all draw-ol style instances for the given resolved color set. + * + * @param {object} colors - Output of resolveColors() + * @returns {{ vertexStyle, selectedVertexStyle, midpointStyle, selectedMidpointStyle, + * editFeatureStyle, createSketchStyle, createFeatureStyle }} + */ +export const createStyles = (colors) => { + const vertexStyle = new Style({ + image: new CircleStyle({ + radius: 6, + fill: new Fill({ color: colors.editVertex }) + }) + }) + + const selectedVertexStyle = new Style({ renderer: makeRingRenderer(selectedVertexRadii, colors, 'editVertex') }) + + const midpointStyle = new Style({ + image: new CircleStyle({ + radius: 4, + fill: new Fill({ color: colors.editMidpoint }) + }) + }) + + const selectedMidpointStyle = new Style({ renderer: makeRingRenderer(selectedMidpointRadii, colors, 'editMidpoint') }) + + const editFeatureStyle = new Style({ + stroke: new Stroke({ color: colors.editStroke, width: 2 }), + fill: new Fill({ color: colors.shapeFill }) + }) + + const sketchLineStyle = new Style({ + stroke: new Stroke({ color: colors.editStroke, width: 2 }), + fill: new Fill({ color: colors.shapeFill }) + }) + + const sketchPointStyle = new Style({ + image: new CircleStyle({ + radius: 5, + fill: new Fill({ color: colors.editVertex }) + }) + }) + + const createSketchStyle = () => (feature) => + feature.getGeometry().getType() === 'Point' ? [sketchPointStyle] : [sketchLineStyle] + + const createFeatureStyle = () => (feature) => { + const p = feature.getProperties() + const id = colors.mapStyleId + const stroke = (id && p[`stroke${capitalize(id)}`]) || p.stroke || colors.shapeStroke + const fill = (id && p[`fill${capitalize(id)}`]) || p.fill || colors.shapeFill + const strokeWidth = p.strokeWidth || colors.strokeWidth + return [new Style({ + stroke: new Stroke({ color: stroke, width: strokeWidth }), + fill: new Fill({ color: fill }) + })] + } + + return { + vertexStyle, + selectedVertexStyle, + midpointStyle, + selectedMidpointStyle, + editFeatureStyle, + createSketchStyle, + createFeatureStyle + } +} diff --git a/plugins/beta/draw/src/adapters/openlayers/core/undoStack.js b/plugins/beta/draw/src/adapters/openlayers/core/undoStack.js new file mode 100644 index 000000000..bd27e4659 --- /dev/null +++ b/plugins/beta/draw/src/adapters/openlayers/core/undoStack.js @@ -0,0 +1,31 @@ +/** + * Undo stack for draw operations. + * Calls onChange(length) whenever the stack changes so the UI can update. + * + * @param {(length: number) => void} onChange + */ +export const createUndoStack = (onChange) => { + const stack = [] + + return { + push (operation) { + stack.push(operation) + onChange(stack.length) + }, + + pop () { + const op = stack.pop() + onChange(stack.length) + return op + }, + + clear () { + stack.length = 0 + onChange(stack.length) + }, + + get length () { + return stack.length + } + } +} diff --git a/plugins/beta/draw/src/adapters/openlayers/defaults.js b/plugins/beta/draw/src/adapters/openlayers/defaults.js new file mode 100644 index 000000000..bca89f53e --- /dev/null +++ b/plugins/beta/draw/src/adapters/openlayers/defaults.js @@ -0,0 +1 @@ +export { DEFAULTS } from '../../defaults.js' diff --git a/plugins/beta/draw/src/adapters/openlayers/draw/DrawMode.js b/plugins/beta/draw/src/adapters/openlayers/draw/DrawMode.js new file mode 100644 index 000000000..fc12e4542 --- /dev/null +++ b/plugins/beta/draw/src/adapters/openlayers/draw/DrawMode.js @@ -0,0 +1,121 @@ +import Draw from 'ol/interaction/Draw.js' +import { noModifierKeys } from 'ol/events/condition.js' +import { createDrawInput } from './drawInput.js' +import { getCoords } from '../utils/geometryHelpers.js' + +const SNAP_TOLERANCE_PX = 12 +const MIN_VERTICES = { Polygon: 3, LineString: 2 } + +const canFinish = (geometryType, sketchFeature) => { + if (!sketchFeature) { return false } + const geom = sketchFeature.getGeometry() + const coords = getCoords({ type: geometryType, coordinates: geom.getCoordinates() }) + // OL keeps a trailing rubber-band coordinate; subtract 1 to get real vertex count + return coords.length - 1 >= MIN_VERTICES[geometryType] +} + +// OL closes Polygon rings by appending v1: [...placed, rubber_band, v1_closing]; last placed is 3 from end. +const POLY_LAST_PLACED_OFFSET = 3 + +const getLastPlacedCoord = (geom) => { + if (geom.getType() === 'Polygon') { + const ring = geom.getCoordinates()[0] || [] + return ring.length >= POLY_LAST_PLACED_OFFSET ? ring[ring.length - POLY_LAST_PLACED_OFFSET] : null + } + const coords = geom.getCoordinates() + return coords.length >= 2 ? coords[coords.length - 2] : null +} + +const DUPLICATE_TOLERANCE_PX = 2 + +const buildCondition = (map, geometryType, getSketchFeature) => (e) => { + if (!noModifierKeys(e)) { return false } + const sf = getSketchFeature() + if (!sf || canFinish(geometryType, sf)) { return true } + const prev = getLastPlacedCoord(sf.getGeometry()) + if (!prev) { return true } + const pp = map.getPixelFromCoordinate(prev) + if (!pp) { return true } + const dx = e.pixel[0] - pp[0]; const dy = e.pixel[1] - pp[1] + return dx * dx + dy * dy > DUPLICATE_TOLERANCE_PX * DUPLICATE_TOLERANCE_PX +} + +export const createDrawMode = ({ map, manager, options }) => { + const { + geometryType, + featureId, + properties = {}, + container, + interfaceType, + addVertexButtonId, + mapProvider, + snap + } = options + + let sketchFeature = null + + const drawInteraction = new Draw({ + type: geometryType, + style: manager.styles.createSketchStyle(), + stopClick: true, + snapTolerance: SNAP_TOLERANCE_PX, + condition: buildCondition(map, geometryType, () => sketchFeature) + }) + map.addInteraction(drawInteraction) + // OL internal: overlay_ is the private VectorLayer used for the sketch geometry. + // updateWhileAnimating_ forces per-frame redraws during view animations (keyboard pan). + // Without this, geom.setCoordinates() calls are ignored while the ANIMATING hint is set. + // Check ol/interaction/Draw.js and ol/layer/BaseVector.js if this breaks after an OL upgrade. + drawInteraction.overlay_.updateWhileAnimating_ = true + + const updateVertexCount = () => { + if (!sketchFeature) { return } + const geom = sketchFeature.getGeometry() + const coords = getCoords({ type: geometryType, coordinates: geom.getCoordinates() }) + // OL always keeps a trailing rubber-band coordinate; subtract 1 + manager.emit('vertexchange', { numVertices: Math.max(0, coords.length - 1) }) + } + + drawInteraction.on('drawstart', (e) => { + sketchFeature = e.feature + sketchFeature.getGeometry().on('change', updateVertexCount) + }) + + drawInteraction.on('drawend', (e) => { + const olFeature = e.feature + olFeature.setId(String(featureId)) + olFeature.setProperties(properties) + manager.store.source.addFeature(olFeature) + manager.emit('create', manager.store.toGeoJSON(olFeature)) + // Mode switches to disabled in events.js after receiving 'create' + }) + + drawInteraction.on('drawabort', () => { manager.emit('cancel') }) + + const input = createDrawInput({ + drawInteraction, + manager, + options: { + container, + interfaceType, + addVertexButtonId, + mapProvider, + snap, + onUndo: () => { drawInteraction.removeLastPoint(); updateVertexCount() }, + canFinish: () => canFinish(geometryType, sketchFeature) + } + }) + + return { + done () { + if (canFinish(geometryType, sketchFeature)) { drawInteraction.finishDrawing() } + }, + cancel () { drawInteraction.abortDrawing() }, + undo () { drawInteraction.removeLastPoint(); updateVertexCount() }, + destroy () { + input.destroy() + map.removeInteraction(drawInteraction) + sketchFeature = null + } + } +} diff --git a/plugins/beta/draw/src/adapters/openlayers/draw/drawInput.js b/plugins/beta/draw/src/adapters/openlayers/draw/drawInput.js new file mode 100644 index 000000000..212a67e55 --- /dev/null +++ b/plugins/beta/draw/src/adapters/openlayers/draw/drawInput.js @@ -0,0 +1,243 @@ +import { coordToPixel, pixelDist } from '../utils/olCoords.js' + +const SNAP_TOLERANCE = 12 // pixels +// Minimum ring length to allow snap-to-close (placed vertices + rubber-band) +const MIN_SKETCH_COORDS = { Polygon: 4, LineString: 3 } +const DUPLICATE_TOLERANCE_PX = 2 +// OL Polygon ring layout after addToDrawing_: [...committed, rubber_band, closing_v1] +const POLY_COMMITTED_OFFSET = 3 +const ARROW_KEYS = new Set(['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown']) + +const isCloseToFirstVertex = (map, coord, sketchCoords, geometryType) => { + if (geometryType !== 'Polygon' || sketchCoords.length < MIN_SKETCH_COORDS.Polygon) { + return false + } + const firstCoord = sketchCoords[0] + const currentPixel = coordToPixel(map, coord) + const firstPixel = coordToPixel(map, firstCoord) + if (!currentPixel || !firstPixel) { + return false + } + return pixelDist(currentPixel, firstPixel) < SNAP_TOLERANCE +} + +const applyRubberbanding = (geom, centerCoord) => { + if (geom.getType() === 'LineString') { + const updated = [...geom.getCoordinates()] + updated[updated.length - 1] = centerCoord + geom.setCoordinates(updated) + } else if (geom.getType() === 'Polygon') { + const updated = geom.getCoordinates().map((ring, i) => { + if (i !== 0) { + return ring + } + const r = [...ring] + r[r.length - 1] = centerCoord + return r + }) + geom.setCoordinates(updated) + } else { + // No action + } +} + +// Returns the last vertex committed by OL's Draw interaction (not the rubber-band or +// the closing copy that OL appends to Polygon rings). +const getLastCommittedVertex = (geom) => { + if (geom.getType() === 'Polygon') { + const ring = geom.getCoordinates()[0] || [] + return ring.length >= POLY_COMMITTED_OFFSET ? ring[ring.length - POLY_COMMITTED_OFFSET] : null + } + const coords = geom.getCoordinates() + return coords.length >= 2 ? coords[coords.length - 2] : null +} + +const wireInputEvents = ({ + container, addVertexButtonId, olView, onUndo, + getInterfaceType, setInterfaceType, clearLastCoord, + updateRubberbanding, placeVertex +}) => { + const onCenterChange = () => { + if (getInterfaceType() !== 'mouse') { + updateRubberbanding() + } + } + olView?.on('change:center', onCenterChange) + + const onKeydown = (e) => { + if (!container.contains(document.activeElement)) { + return + } + if (ARROW_KEYS.has(e.key)) { + setInterfaceType('keyboard') + return + } + if (e.key === 'Enter') { + e.preventDefault() + setInterfaceType('keyboard') + placeVertex() + } + if (e.key === 'z' && (e.metaKey || e.ctrlKey)) { + e.preventDefault() + onUndo?.() + } + } + + const onButtonClick = (e) => { + if (addVertexButtonId && e.target.closest(`#${addVertexButtonId}`)) { + placeVertex() + } + } + + const onPointerdown = (e) => { + if (e.pointerType !== 'touch') { + setInterfaceType('mouse') + clearLastCoord() + } + } + + const onTouchstart = () => { + setInterfaceType('touch') + } + + const onPointerMove = () => { + if (getInterfaceType() === 'mouse') { + return + } + updateRubberbanding() + } + + globalThis.addEventListener('keydown', onKeydown) + globalThis.addEventListener('click', onButtonClick) + container.addEventListener('pointerdown', onPointerdown) + container.addEventListener('touchstart', onTouchstart, { passive: true }) + container.addEventListener('pointermove', onPointerMove) + + return { + destroy () { + olView?.un('change:center', onCenterChange) + globalThis.removeEventListener('keydown', onKeydown) + globalThis.removeEventListener('click', onButtonClick) + container.removeEventListener('pointerdown', onPointerdown) + container.removeEventListener('touchstart', onTouchstart) + container.removeEventListener('pointermove', onPointerMove) + } + } +} + +export const createDrawInput = ({ drawInteraction, options }) => { + const { container, addVertexButtonId, mapProvider, snap, onUndo, canFinish } = options + let interfaceType = options.interfaceType ?? 'mouse' + let sketchFeature = null + let lastPlacedCoord = null + + drawInteraction.on('drawstart', (e) => { + sketchFeature = e.feature + lastPlacedCoord = null + }) + drawInteraction.on('drawend', () => { + sketchFeature = null + lastPlacedCoord = null + }) + drawInteraction.on('drawabort', () => { + sketchFeature = null + lastPlacedCoord = null + }) + + const updateRubberbanding = () => { + if (!sketchFeature) { + // No sketch yet — update snap indicator at crosshair position so targets are + // visible before the first vertex is placed (touch/keyboard only; mouse uses + // the OL snap interaction's pointermove handler instead). + if (interfaceType !== 'mouse' && snap) { + snap.apply(mapProvider.getCenter()) + } + return + } + const geom = sketchFeature.getGeometry() + const coords = geom.getCoordinates() + if (!coords.length) { + return + } + const raw = mapProvider.getCenter() + const centerCoord = (interfaceType !== 'mouse' && snap) ? snap.apply(raw) : raw + applyRubberbanding(geom, centerCoord) + } + + // Returns true if the vertex was handled as a close/finish attempt (caller should not append). + const tryClose = (geom, sketchCoords, coord) => { + if (lastPlacedCoord && lastPlacedCoord[0] === coord[0] && lastPlacedCoord[1] === coord[1]) { + // Same position as last placed: don't duplicate. Close only if enough real vertices exist. + if (canFinish?.()) { drawInteraction.finishDrawing() } + lastPlacedCoord = null + return true + } + if (isCloseToFirstVertex(drawInteraction.getMap(), coord, sketchCoords, geom.getType())) { + drawInteraction.finishDrawing() + return true + } + // When the add-vertex button overlays the map (touch UI), OL's native pointer handler + // and this button click handler both fire for the same tap. Detect that OL already + // committed a vertex at coord's position and skip the duplicate appendCoordinates, + // but register coord as lastPlacedCoord so a second tap at the same position can close. + const map = drawInteraction.getMap() + const lastCommitted = getLastCommittedVertex(geom) + if (lastCommitted) { + const p1 = map.getPixelFromCoordinate(lastCommitted) + const p2 = map.getPixelFromCoordinate(coord) + if (p1 && p2) { + const dx = p1[0] - p2[0]; const dy = p1[1] - p2[1] + if (dx * dx + dy * dy < DUPLICATE_TOLERANCE_PX * DUPLICATE_TOLERANCE_PX) { + lastPlacedCoord = coord + return true + } + } + } + return false + } + + const placeVertex = () => { + const raw = mapProvider.getCenter() + const coord = (interfaceType !== 'mouse' && snap) ? snap.apply(raw) : raw + snap?.hideIndicator() + if (sketchFeature) { + const geom = sketchFeature.getGeometry() + const rawCoords = geom.getCoordinates() + const sketchCoords = geom.getType() === 'Polygon' ? (rawCoords[0] || []) : rawCoords + if (tryClose(geom, sketchCoords, coord)) { return } + } + drawInteraction.appendCoordinates([coord]) + lastPlacedCoord = coord + } + + const map = drawInteraction.getMap() + const olView = map?.getView() + + const events = wireInputEvents({ + container, + addVertexButtonId, + olView, + onUndo, + getInterfaceType: () => interfaceType, + setInterfaceType: (t) => { interfaceType = t }, + clearLastCoord: () => { lastPlacedCoord = null }, + updateRubberbanding, + placeVertex + }) + + // change:center fires once when a keyboard pan animation starts; postrender tracks each frame. + const onMapRender = () => { + if (interfaceType !== 'mouse' && olView?.getAnimating()) { + updateRubberbanding() + } + } + map?.on('postrender', onMapRender) + + return { + getInterfaceType: () => interfaceType, + destroy () { + events.destroy() + map?.un('postrender', onMapRender) + } + } +} diff --git a/plugins/beta/draw/src/adapters/openlayers/edit/EditMode.js b/plugins/beta/draw/src/adapters/openlayers/edit/EditMode.js new file mode 100644 index 000000000..f756770d9 --- /dev/null +++ b/plugins/beta/draw/src/adapters/openlayers/edit/EditMode.js @@ -0,0 +1,424 @@ +import Modify from 'ol/interaction/Modify.js' +import Collection from 'ol/Collection.js' +import VectorSource from 'ol/source/Vector.js' +import VectorLayer from 'ol/layer/Vector.js' +import Feature from 'ol/Feature.js' +import Point from 'ol/geom/Point.js' +import { createMidpointLayer } from './midpointLayer.js' +import { createVertexLayer } from './vertexLayer.js' +import { createTouchHandler } from './touchHandler.js' +import { createKeyboardHandler } from './keyboardHandler.js' +import { findNearest } from './vertexHitTest.js' +import { deleteVertex, insertAtMidpoint } from './vertexOps.js' +import { applyUndo } from './undoOps.js' +import { getCoords, getMidpoints } from '../utils/geometryHelpers.js' + +/** + * Edit vertex mode — handles edit_vertex. + * + * OL Modify handles pointer/mouse vertex dragging natively. + * touchHandler covers touch drag via the SVG offset target. + * keyboardHandler covers keyboard navigation and nudging. + * + * @returns {{ done, cancel, undo, deleteVertex: fn, destroy }} + */ +export const createEditMode = ({ map, manager, options }) => { + const { featureId, container, interfaceType, deleteVertexButtonId, snap } = options + const { store, undoStack } = manager + + const olFeature = store.getOL(featureId) + if (!olFeature) { + return null + } + + const originalFeatureStyle = olFeature.getStyle() + olFeature.setStyle(manager.styles.editFeatureStyle) + + // Mutable state shared across sub-handlers + const state = { + olFeature, + selectedVertexIndex: -1, + selectedVertexType: null, + vertices: [], + midpoints: [], + interfaceType: interfaceType ?? 'mouse' + } + + const getState = () => state + let onDeselect = null // set after touchHandler is created; hides offset target on any deselect + let onUpdate = null // set after touchHandler is created; repositions offset target when vertex coords change + + const setState = (updates) => { + Object.assign(state, updates) + if (updates.selectedVertexIndex !== undefined) { + vertexLayer.setSelected(state.selectedVertexType === 'vertex' ? state.selectedVertexIndex : -1) + midpointLayer.setSelected( + state.selectedVertexType === 'midpoint' ? state.selectedVertexIndex - state.vertices.length : -1 + ) + if (state.selectedVertexIndex < 0) { + onDeselect?.() + } + updateActiveLayer() + manager.emit('vertexselection', { + index: state.selectedVertexType === 'vertex' ? state.selectedVertexIndex : -1, + numVertices: state.vertices.length + }) + } + if (updates.vertices !== undefined) { + const plainGeom = { + type: olFeature.getGeometry().getType(), + coordinates: olFeature.getGeometry().getCoordinates() + } + midpointLayer.update(plainGeom) + vertexLayer.update(plainGeom) + state.midpoints = midpointLayer.getCoords() + updateActiveLayer() + onUpdate?.() + map.render() + } + } + + // Lightweight per-frame update during drag — updates layers without emitting events + const updateLayersFromGeom = () => { + const geom = olFeature.getGeometry() + const plainGeom = { type: geom.getType(), coordinates: geom.getCoordinates() } + state.vertices = getCoords(plainGeom) + state.midpoints = getMidpoints(plainGeom) + midpointLayer.update(plainGeom) + vertexLayer.update(plainGeom) + updateActiveLayer() + } + + const syncGeom = () => { + updateLayersFromGeom() + manager.emit('vertexchange', { numVertices: state.vertices.length }) + manager.emit('update', store.toGeoJSON(olFeature)) + } + + // Keep overlay layers in sync on every geometry change (e.g. during pointer drag) + const onGeometryChange = () => updateLayersFromGeom() + olFeature.getGeometry().on('change', onGeometryChange) + + // --- OL Modify (handles pointer vertex drag + midpoint insertion natively) --- + const collection = new Collection([olFeature]) + const modifyCondition = (mapBrowserEvent) => { + if (state.interfaceType === 'touch') { + return false + } + const olPixel = map.getEventPixel(mapBrowserEvent.originalEvent) + return findNearest(map, state.vertices, state.midpoints, { x: olPixel[0], y: olPixel[1] }) !== null + } + const modifyInteraction = new Modify({ + features: collection, + style: () => [], // vertex circles rendered by vertexLayer instead + pixelTolerance: 12, + // Only activate when clicking on a vertex or midpoint circle, not anywhere on a segment. + // Touch drags are handled by touchHandler; returning false here lets them pass through to + // DragPan (touchHandler uses preventDefault on the offset target to stop unwanted panning). + condition: modifyCondition + }) + map.addInteraction(modifyInteraction) + + // Track move start for undo + let modifyStartCoords = null + + modifyInteraction.on('modifystart', () => { + if (state.interfaceType === 'touch') { + return + } + modifyStartCoords = state.vertices.map(c => [...c]) + }) + + modifyInteraction.on('modifyend', () => { + if (state.interfaceType === 'touch') { + return + } + const prevCoords = modifyStartCoords + syncGeom() + if (!prevCoords) { + return + } + + const newCoords = state.vertices + if (newCoords.length > prevCoords.length) { + // Midpoint drag inserted a vertex — find it and select it + const insertedIdx = newCoords.findIndex((c, i) => !prevCoords[i] || c[0] !== prevCoords[i][0]) + const idx = Math.max(0, insertedIdx) + undoStack.push({ type: 'insert_vertex', vertexIndex: idx }) + setState({ selectedVertexIndex: idx, selectedVertexType: 'vertex' }) + } else if (newCoords.length === prevCoords.length) { + const movedIdx = newCoords.findIndex((c, i) => c[0] !== prevCoords[i][0] || c[1] !== prevCoords[i][1]) + if (movedIdx >= 0) { + undoStack.push({ type: 'move_vertex', vertexIndex: movedIdx, previousCoord: prevCoords[movedIdx] }) + setState({ selectedVertexIndex: movedIdx, selectedVertexType: 'vertex' }) + } + } else { + // no change in vertex count (shouldn't happen, but satisfies linter) + } + modifyStartCoords = null + }) + + // --- Vertex + midpoint layers (always-visible handles) --- + const midpointLayer = createMidpointLayer(map, manager.styles.midpointStyle) + const vertexLayer = createVertexLayer(map, manager.styles.vertexStyle) + + // --- Active selection overlay — always on top of vertex and midpoint layers --- + const activeSource = new VectorSource() + const activeLayer = new VectorLayer({ source: activeSource, zIndex: 103 }) + map.addLayer(activeLayer) + + const updateActiveLayer = () => { + activeSource.clear() + const { selectedVertexIndex, selectedVertexType, vertices, midpoints } = state + if (selectedVertexIndex < 0) { + return + } + let coord, style + if (selectedVertexType === 'vertex') { + coord = vertices[selectedVertexIndex] + style = manager.styles.selectedVertexStyle + } else if (selectedVertexType === 'midpoint') { + coord = midpoints[selectedVertexIndex - vertices.length] + style = manager.styles.selectedMidpointStyle + } else { + return + } + if (!coord) { + return + } + const f = new Feature({ geometry: new Point(coord) }) + f.setStyle(style) + activeSource.addFeature(f) + } + + syncGeom() // initial populate + + // --- Style hot-swap when map style changes --- + const onStylesChanged = (styles) => { + olFeature.setStyle(styles.editFeatureStyle) + vertexLayer.updateStyle(styles.vertexStyle) + midpointLayer.updateStyle(styles.midpointStyle) + updateActiveLayer() + touchHandler.updateColors(manager.colors) + } + manager.on('styleschanged', onStylesChanged) + + // --- Pointer hit detection --- + const onPointerdown = (e) => { + if (e.pointerType === 'touch') { + state.interfaceType = 'touch' + touchHandler.updateTargetPosition() + return + } + state.interfaceType = 'mouse' + + const olPixel = map.getEventPixel(e) + const pixel = { x: olPixel[0], y: olPixel[1] } + const hit = findNearest(map, state.vertices, state.midpoints, pixel) + if (hit?.type === 'vertex') { + setState({ selectedVertexIndex: hit.index, selectedVertexType: 'vertex' }) + } + } + + // click fires after OL Modify finishes, so state.vertices reflects any insertions/moves + const onContainerClick = (e) => { + if (state.interfaceType === 'touch') { + return + } + const olPixel = map.getEventPixel(e) + const pixel = { x: olPixel[0], y: olPixel[1] } + const hit = findNearest(map, state.vertices, state.midpoints, pixel) + if (hit?.type === 'vertex') { + setState({ selectedVertexIndex: hit.index, selectedVertexType: 'vertex' }) + } else if (hit?.type === 'midpoint') { + // modifyend already selected the inserted vertex — nothing to do here + } else { + setState({ selectedVertexIndex: -1, selectedVertexType: null }) + } + } + + // Switch to pointer mode and hide the touch target as soon as the mouse moves. + const onPointerMove = (e) => { + if (e.pointerType !== 'mouse') { + return + } + if (state.interfaceType === 'mouse') { + return + } + state.interfaceType = 'mouse' + touchHandler.hide() + } + + container.addEventListener('pointerdown', onPointerdown) + container.addEventListener('pointerenter', onPointerMove) + container.addEventListener('pointermove', onPointerMove) + container.addEventListener('click', onContainerClick) + + // --- Button click (delete vertex) --- + const onButtonClick = (e) => { + if (deleteVertexButtonId && e.target.closest(`#${deleteVertexButtonId}`)) { + doDeleteVertex() + } + } + globalThis.addEventListener('click', onButtonClick) + + // --- Operations --- + + const doDeleteVertex = () => { + if (state.selectedVertexType !== 'vertex' || state.selectedVertexIndex < 0) { + return + } + const result = deleteVertex(olFeature, state.selectedVertexIndex) + if (!result) { + return + } + undoStack.push({ type: 'delete_vertex', vertexIndex: result.deletedIndex, deletedCoord: result.deletedCoord }) + syncGeom() + setState({ selectedVertexIndex: -1, selectedVertexType: null }) + } + + const doUndo = () => { + const op = undoStack.pop() + if (!op) { + return + } + const previousIndex = state.selectedVertexIndex + const restoredIndex = applyUndo(olFeature, op) + syncGeom() + // Only re-select if a vertex was already active — undo must not create a new selection + const newIndex = previousIndex >= 0 ? restoredIndex : -1 + setState({ + selectedVertexIndex: newIndex, + selectedVertexType: newIndex >= 0 ? 'vertex' : null + }) + if (previousIndex >= 0 && newIndex >= 0) { + onUpdate?.() + } + } + + // --- Touch handler --- + const touchHandler = createTouchHandler({ + map, + container, + getState, + setState, + colors: manager.colors, + snap, + onVertexMoved ({ vertexIndex, previousCoord }) { + undoStack.push({ type: 'move_vertex', vertexIndex, previousCoord }) + syncGeom() + setState({ selectedVertexIndex: vertexIndex, selectedVertexType: 'vertex' }) + touchHandler.updateTargetPosition() + }, + onTap (hit) { + if (!hit) { + setState({ selectedVertexIndex: -1, selectedVertexType: null }) + return + } + if (hit.type === 'vertex') { + setState({ selectedVertexIndex: hit.index, selectedVertexType: 'vertex' }) + touchHandler.updateTargetPosition() + return + } + if (hit.type === 'midpoint') { + const result = insertAtMidpoint(olFeature, state.midpoints, hit.index, state.vertices.length) + if (!result) { + return + } + undoStack.push({ type: 'insert_vertex', vertexIndex: result.insertedIndex }) + syncGeom() + setState({ selectedVertexIndex: result.insertedIndex, selectedVertexType: 'vertex' }) + touchHandler.updateTargetPosition() + } + } + }) + onDeselect = () => touchHandler.hide() + onUpdate = () => { + if (state.interfaceType === 'touch') { + touchHandler.updateTargetPosition() + } + } + + // Reposition the touch target after OL re-renders with the new size. + // change:size fires before the render, so we wait for postrender to get + // correct pixel coords from getPixelFromCoordinate. + const onMapSizeChange = () => { + if (state.interfaceType !== 'touch' || state.selectedVertexIndex < 0) { + return + } + map.once('postrender', () => touchHandler.updateTargetPosition()) + } + map.on('change:size', onMapSizeChange) + + // --- Keyboard handler --- + const keyboardHandler = createKeyboardHandler({ + map, + getState, + setState, + snap, + onVertexMoved ({ vertexIndex, previousCoord }) { + undoStack.push({ type: 'move_vertex', vertexIndex, previousCoord }) + syncGeom() + setState({ selectedVertexIndex: vertexIndex, selectedVertexType: 'vertex' }) + }, + onInserted ({ insertedIndex }) { + undoStack.push({ type: 'insert_vertex', vertexIndex: insertedIndex }) + syncGeom() + }, + onDeleted: doDeleteVertex, + onUndo: doUndo, + onKeyboardActive () { + if (state.interfaceType === 'keyboard') { + return + } + state.interfaceType = 'keyboard' + touchHandler.hide() + container.focus({ preventScroll: true }) + } + }) + + return { + setInterfaceType (type) { + if (type === state.interfaceType) { + return + } + state.interfaceType = type + if (type === 'touch') { + touchHandler.updateTargetPosition() + } else { + touchHandler.hide() + } + }, + + done () { + manager.emit('editfinish', store.toGeoJSON(olFeature)) + }, + + cancel () { + // Restore original feature from store (re-read from initial state) + // The original was stored as tempFeature in reducer — events.js handles restore + }, + + undo: doUndo, + deleteVertex: doDeleteVertex, + + destroy () { + olFeature.setStyle(originalFeatureStyle) + olFeature.getGeometry().un('change', onGeometryChange) + manager.off('styleschanged', onStylesChanged) + container.removeEventListener('pointerdown', onPointerdown) + container.removeEventListener('pointerenter', onPointerMove) + container.removeEventListener('pointermove', onPointerMove) + container.removeEventListener('click', onContainerClick) + globalThis.removeEventListener('click', onButtonClick) + map.un('change:size', onMapSizeChange) + map.removeInteraction(modifyInteraction) + activeSource.clear() + map.removeLayer(activeLayer) + midpointLayer.remove() + vertexLayer.remove() + touchHandler.destroy() + keyboardHandler.destroy() + } + } +} diff --git a/plugins/beta/draw/src/adapters/openlayers/edit/keyboardHandler.js b/plugins/beta/draw/src/adapters/openlayers/edit/keyboardHandler.js new file mode 100644 index 000000000..88dae2157 --- /dev/null +++ b/plugins/beta/draw/src/adapters/openlayers/edit/keyboardHandler.js @@ -0,0 +1,229 @@ +import { coordToPixel, nudgeCoord } from '../utils/olCoords.js' +import { spatialNavigate } from '../utils/spatial.js' +import { moveVertex, insertAtMidpoint } from './vertexOps.js' + +const ARROW_KEYS = new Set(['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown']) +const INTERACTIVE_TAGS = new Set(['INPUT', 'TEXTAREA', 'BUTTON', 'SELECT', 'A']) +const NUDGE_PX = 1 +const STEP_PX = 5 + +const selectNearest = (map, getState, setState) => { + const { vertices, midpoints } = getState() + if (!vertices.length) { + return + } + const centerCoord = map.getView().getCenter() + const centerPx = coordToPixel(map, centerCoord) + if (!centerPx) { + return + } + const allPixels = [ + ...vertices.map(c => coordToPixel(map, c)), + ...midpoints.map(c => coordToPixel(map, c)) + ].filter(Boolean).map(p => [p.x, p.y]) + const idx = spatialNavigate([centerPx.x, centerPx.y], allPixels, undefined) + setState({ selectedVertexIndex: idx, selectedVertexType: idx < vertices.length ? 'vertex' : 'midpoint' }) +} + +const navigateTo = (direction, map, getState, setState) => { + const { selectedVertexIndex, vertices, midpoints } = getState() + if (!vertices.length) { + return + } + const allCoords = [...vertices, ...midpoints] + const allPixels = allCoords.map(c => coordToPixel(map, c)).filter(Boolean).map(p => [p.x, p.y]) + const startPx = selectedVertexIndex >= 0 + ? allPixels[selectedVertexIndex] + : (() => { + const c = coordToPixel(map, map.getView().getCenter()) + return c ? [c.x, c.y] : null + })() + if (!startPx) { + return + } + const idx = spatialNavigate(startPx, allPixels, direction) + setState({ selectedVertexIndex: idx, selectedVertexType: idx < vertices.length ? 'vertex' : 'midpoint' }) +} + +// Returns snappedCoord, but escapes snap if it prevents sufficient progress in the nudge direction. +// Covers vertex-stuck (snap holds position) and edge-hugging (vertex slides along edge). +const resolveSnappedCoord = (snap, map, current, nudgedCoord, snappedCoord, dx, dy) => { + if (!snap) { + return snappedCoord + } + const nudgeVec = [nudgedCoord[0] - current[0], nudgedCoord[1] - current[1]] + const actualVec = [snappedCoord[0] - current[0], snappedCoord[1] - current[1]] + const nudgeLenSq = nudgeVec[0] ** 2 + nudgeVec[1] ** 2 + const dot = actualVec[0] * nudgeVec[0] + actualVec[1] * nudgeVec[1] + if (nudgeLenSq > 0 && dot / nudgeLenSq < 0.5) { + const escapePx = snap.snapRadius + 1 + return nudgeCoord(map, current, dx === 0 ? 0 : Math.sign(dx) * escapePx, dy === 0 ? 0 : Math.sign(dy) * escapePx) + } + return snappedCoord +} + +const wireNudge = ({ map, snap, getState, setState, onInserted }) => { + const keyMove = { start: null, index: null } + + // Insert the midpoint as a vertex then move it — midpoints stay as midpoints until actually moved. + const nudgeMidpoint = (olFeature, midpoints, selectedVertexIndex, vertices, dx, dy) => { + const result = insertAtMidpoint(olFeature, midpoints, selectedVertexIndex, vertices.length) + if (!result) { + return + } + onInserted({ insertedIndex: result.insertedIndex }) // pushes insert_vertex undo + syncGeom + const updatedVertices = getState().vertices + const insertedCoord = updatedVertices[result.insertedIndex] + if (!insertedCoord) { + return + } + keyMove.start = [...insertedCoord] + keyMove.index = result.insertedIndex + const nudgedCoord = nudgeCoord(map, insertedCoord, dx, dy) + const movedCoord = snap ? snap.apply(nudgedCoord) : nudgedCoord + moveVertex(olFeature, result.insertedIndex, movedCoord) + setState({ + selectedVertexIndex: result.insertedIndex, + selectedVertexType: 'vertex', + vertices: updatedVertices.map((c, i) => i === result.insertedIndex ? movedCoord : c) + }) + } + + const nudge = (e) => { + const { selectedVertexIndex, selectedVertexType, vertices, midpoints, olFeature } = getState() + if (!olFeature) { + return + } + const step = e.shiftKey ? NUDGE_PX : STEP_PX + const offsets = { ArrowUp: [0, -step], ArrowDown: [0, step], ArrowLeft: [-step, 0], ArrowRight: [step, 0] } + const [dx, dy] = offsets[e.key] + if (selectedVertexType === 'midpoint') { + nudgeMidpoint(olFeature, midpoints, selectedVertexIndex, vertices, dx, dy) + return + } + if (selectedVertexIndex < 0 || !vertices[selectedVertexIndex]) { + return + } + const current = vertices[selectedVertexIndex] + if (!keyMove.start) { + keyMove.start = [...current] + keyMove.index = selectedVertexIndex + } + const nudgedCoord = nudgeCoord(map, current, dx, dy) + const snappedCoord = snap ? snap.apply(nudgedCoord) : nudgedCoord + snap?.hideIndicator() + const newCoord = resolveSnappedCoord(snap, map, current, nudgedCoord, snappedCoord, dx, dy) + moveVertex(olFeature, selectedVertexIndex, newCoord) + setState({ vertices: vertices.map((c, i) => i === selectedVertexIndex ? newCoord : c) }) + } + + return { nudge, keyMove } +} + +const isInteractiveElementFocused = (appViewport) => { + const el = document.activeElement + if (!el || el === document.body) { + return false + } + if (appViewport.contains(el)) { + return false + } + const tag = el.tagName + return INTERACTIVE_TAGS.has(tag) || el.isContentEditable || el.hasAttribute('tabindex') +} + +const wireKeyboardEvents = ({ map, snap, getState, setState, onVertexMoved, onInserted, onDeleted, onUndo, onKeyboardActive }) => { + const { nudge, keyMove } = wireNudge({ map, snap, getState, setState, onInserted }) + const appViewport = map.getViewport().closest('[role="application"]') ?? map.getViewport() + const isFocused = () => isInteractiveElementFocused(appViewport) + + const handleArrowKey = (e) => { + if (e.altKey) { + e.preventDefault() + e.stopPropagation() + navigateTo(e.key, map, getState, setState) + } else if (getState().selectedVertexIndex >= 0) { + e.preventDefault() + e.stopPropagation() + nudge(e) + } else { + // No action: arrow with no selection and no alt modifier + } + } + + const handleKey = (e) => { + onKeyboardActive?.() + if (e.key === ' ') { + e.preventDefault() + if (getState().selectedVertexIndex < 0) { + selectNearest(map, getState, setState) + } + } else if (ARROW_KEYS.has(e.key)) { + handleArrowKey(e) + } else if (e.key === 'z' && (e.metaKey || e.ctrlKey)) { + const tag = document.activeElement?.tagName + if (!INTERACTIVE_TAGS.has(tag)) { + e.preventDefault() + e.stopPropagation() + onUndo() + } + } else { + // No action + } + } + + const onKeydown = (e) => { + if (!isFocused()) { + if (e.key === 'Escape' && getState().selectedVertexIndex >= 0) { + e.preventDefault() + keyMove.start = null + keyMove.index = null + setState({ selectedVertexIndex: -1, selectedVertexType: null }) + } else { + handleKey(e) + } + } + } + + const onKeyup = (e) => { + if (!isFocused()) { + if (ARROW_KEYS.has(e.key) && keyMove.start && keyMove.index != null) { + snap?.hideIndicator() + onVertexMoved({ vertexIndex: keyMove.index, previousCoord: keyMove.start }) + keyMove.start = null + keyMove.index = null + } + if (e.key === 'Delete') { + onDeleted() + } + } + } + + window.addEventListener('keydown', onKeydown, { capture: true }) + window.addEventListener('keyup', onKeyup, { capture: true }) + + return { + destroy () { + window.removeEventListener('keydown', onKeydown, { capture: true }) + window.removeEventListener('keyup', onKeyup, { capture: true }) + } + } +} + +/** + * Keyboard handler for edit mode. + * + * Space — select nearest vertex or midpoint to crosshair (only when nothing selected) + * Alt+Arrow — navigate to next vertex/midpoint in that direction (requires selection) + * Arrow — move selected vertex; if midpoint selected, inserts it as a vertex and moves it + * Shift+Arrow — same but fine nudge (1px vs 5px) + * Delete — delete selected vertex (no-op on midpoints) + * Ctrl/Cmd+Z — undo + * + * Midpoints remain midpoints until moved — navigating to a midpoint (Space/Alt+Arrow) does not + * convert it. Only pressing a plain/Shift arrow converts it. + * + * @param {{ map, container, getState, setState, onVertexMoved, onInserted, onDeleted, onUndo }} + * @returns {{ destroy }} + */ +export const createKeyboardHandler = (options) => wireKeyboardEvents(options) diff --git a/plugins/beta/draw/src/adapters/openlayers/edit/midpointLayer.js b/plugins/beta/draw/src/adapters/openlayers/edit/midpointLayer.js new file mode 100644 index 000000000..34f2358a3 --- /dev/null +++ b/plugins/beta/draw/src/adapters/openlayers/edit/midpointLayer.js @@ -0,0 +1,57 @@ +import VectorSource from 'ol/source/Vector.js' +import VectorLayer from 'ol/layer/Vector.js' +import Feature from 'ol/Feature.js' +import Point from 'ol/geom/Point.js' +import { getMidpoints } from '../utils/geometryHelpers.js' + +/** + * Manages a dedicated overlay layer for midpoint handles in edit mode. + * Midpoints are always visible (unlike OL Modify's native midpoints which + * only appear when the pointer is near a segment). The selected midpoint is + * rendered by the separate active-selection layer in EditMode (zIndex 103). + */ +export const createMidpointLayer = (map, midpointStyle) => { + let currentStyle = midpointStyle + let selectedIndex = -1 + const source = new VectorSource() + const layer = new VectorLayer({ + source, + style: (feature) => feature.get('midpointIndex') === selectedIndex ? null : [currentStyle], + zIndex: 101 + }) + map.addLayer(layer) + + return { + update (geom) { + source.clear() + const midpoints = getMidpoints(geom) + const features = midpoints.map((coord, i) => { + const f = new Feature({ geometry: new Point(coord) }) + f.set('midpointIndex', i) + return f + }) + source.addFeatures(features) + }, + + setSelected (index) { + selectedIndex = index + source.changed() + }, + + updateStyle (newMidpointStyle) { + currentStyle = newMidpointStyle + source.changed() + }, + + getCoords () { + return source.getFeatures() + .sort((a, b) => a.get('midpointIndex') - b.get('midpointIndex')) + .map(f => f.getGeometry().getCoordinates()) + }, + + remove () { + source.clear() + map.removeLayer(layer) + } + } +} diff --git a/plugins/beta/draw/src/adapters/openlayers/edit/touchHandler.js b/plugins/beta/draw/src/adapters/openlayers/edit/touchHandler.js new file mode 100644 index 000000000..e7b41ca2f --- /dev/null +++ b/plugins/beta/draw/src/adapters/openlayers/edit/touchHandler.js @@ -0,0 +1,171 @@ +import { coordToPixel, pixelToCoord } from '../utils/olCoords.js' +import { createTouchTarget, applyTouchTargetColors, showTouchTarget, hideTouchTarget, isOnTouchTarget } from '../utils/touchTarget.js' +import { moveVertex } from './vertexOps.js' +import { findNearest } from './vertexHitTest.js' + +const TAP_MOVE_THRESHOLD = 10 +const TAP_TIME_THRESHOLD = 400 +const TOUCH_TOLERANCE = 24 + +const wireTouchEvents = ({ container, map, targetEl, olToCSS, cssToOl, getState, setState, onVertexMoved, onTap, snap }) => { + let dragStartCoord = null + let dragStartIndex = null + let vertexTouchDelta = null + let targetTouchDelta = null + let tapStart = null + + const onTouchstart = (e) => { + const touch = e.touches[0] + const onTarget = isOnTouchTarget(e.target) + tapStart = { x: touch.clientX, y: touch.clientY, time: Date.now(), onTarget } + if (!onTarget) { + return + } + const { selectedVertexIndex, vertices } = getState() + const vertex = vertices[selectedVertexIndex] + if (!vertex) { + return + } + const tOl = map.getEventPixel({ clientX: touch.clientX, clientY: touch.clientY }) + const vertexPx = coordToPixel(map, vertex) + const style = getComputedStyle(targetEl) + const svgOlPx = cssToOl({ x: Number.parseFloat(style.left), y: Number.parseFloat(style.top) }) + dragStartCoord = [...vertex] + dragStartIndex = selectedVertexIndex + vertexTouchDelta = { x: tOl[0] - vertexPx.x, y: tOl[1] - vertexPx.y } + targetTouchDelta = { x: tOl[0] - svgOlPx.x, y: tOl[1] - svgOlPx.y } + e.preventDefault() + } + + const onTouchmove = (e) => { + if (!isOnTouchTarget(e.target) || dragStartIndex == null) { + return + } + e.preventDefault() + const tOl = map.getEventPixel({ clientX: e.touches[0].clientX, clientY: e.touches[0].clientY }) + const rawCoord = pixelToCoord(map, { x: tOl[0] - vertexTouchDelta.x, y: tOl[1] - vertexTouchDelta.y }) + const newCoord = snap ? snap.apply(rawCoord) : rawCoord + snap?.hideIndicator() + const { olFeature, vertices } = getState() + if (!olFeature) { + return + } + moveVertex(olFeature, dragStartIndex, newCoord) + setState({ vertices: vertices.map((c, i) => i === dragStartIndex ? newCoord : c) }) + showTouchTarget(targetEl, olToCSS({ x: tOl[0] - targetTouchDelta.x, y: tOl[1] - targetTouchDelta.y })) + } + + const onTouchend = (e) => { + const wasDragging = dragStartIndex != null + if (!wasDragging) { + if (tapStart && !tapStart.onTarget && e.changedTouches.length > 0) { + const t = e.changedTouches[0] + const dt = Date.now() - tapStart.time + if (Math.hypot(t.clientX - tapStart.x, t.clientY - tapStart.y) < TAP_MOVE_THRESHOLD && dt < TAP_TIME_THRESHOLD) { + const tOl = map.getEventPixel({ clientX: t.clientX, clientY: t.clientY }) + const tapState = getState() + onTap?.(findNearest(map, tapState.vertices, tapState.midpoints, { x: tOl[0], y: tOl[1] }, TOUCH_TOLERANCE)) + e.preventDefault() + } + } + tapStart = null + return + } + tapStart = null + const { vertices } = getState() + if (vertices[dragStartIndex] && dragStartCoord) { + onVertexMoved({ vertexIndex: dragStartIndex, previousCoord: dragStartCoord }) + } + snap?.hideIndicator() + dragStartCoord = null; dragStartIndex = null; vertexTouchDelta = null; targetTouchDelta = null + e.preventDefault() + } + + container.addEventListener('touchstart', onTouchstart, { passive: false }) + container.addEventListener('touchmove', onTouchmove, { passive: false }) + container.addEventListener('touchend', onTouchend, { passive: false }) + + return { + isDragging: () => dragStartIndex != null, + destroy () { + container.removeEventListener('touchstart', onTouchstart) + container.removeEventListener('touchmove', onTouchmove) + container.removeEventListener('touchend', onTouchend) + } + } +} + +/** + * Touch vertex drag handler for edit mode. + * Shows an SVG offset target below the finger so the vertex can be repositioned + * without finger occlusion. Tap on a vertex or midpoint selects it via onTap. + * + * @param {{ map, container, getState, setState, onVertexMoved, onTap, colors }} options + * @returns {{ updateTargetPosition, updateColors, hide, destroy }} + */ +export const createTouchHandler = ({ map, container, getState, setState, onVertexMoved, onTap, colors, snap }) => { + const targetEl = createTouchTarget(container) + applyTouchTargetColors(targetEl, colors) + + // OL pixel space is relative to ol-viewport at its pre-scale CSS size. + // Container CSS space is larger when a CSS transform scales up ol-viewport + // (e.g. scale(1.5) at medium map size makes OL pixels 1.5× smaller than CSS pixels). + const cssTx = { scale: 1, ox: 0, oy: 0 } + const olToCSS = (p) => ({ x: p.x * cssTx.scale + cssTx.ox, y: p.y * cssTx.scale + cssTx.oy }) + const cssToOl = (p) => ({ x: (p.x - cssTx.ox) / cssTx.scale, y: (p.y - cssTx.oy) / cssTx.scale }) + + const syncCssTx = () => { + const vpEl = map.getViewport() + const vpRect = vpEl.getBoundingClientRect() + const cRect = container.getBoundingClientRect() + const vpScale = vpEl.offsetWidth > 0 ? vpRect.width / vpEl.offsetWidth : 1 + const cScale = container.offsetWidth > 0 ? cRect.width / container.offsetWidth : 1 + Object.assign(cssTx, { + scale: vpScale / cScale, + ox: (vpRect.left - cRect.left) / cScale, + oy: (vpRect.top - cRect.top) / cScale + }) + } + + const touchEvents = wireTouchEvents({ container, map, targetEl, olToCSS, cssToOl, getState, setState, onVertexMoved, onTap, snap }) + + const updateTargetPosition = () => { + const { selectedVertexIndex, vertices, interfaceType } = getState() + if (selectedVertexIndex < 0 || !vertices[selectedVertexIndex] || interfaceType !== 'touch') { + hideTouchTarget(targetEl) + return + } + const px = coordToPixel(map, vertices[selectedVertexIndex]) + if (!px) { + hideTouchTarget(targetEl) + return + } + showTouchTarget(targetEl, olToCSS(px)) + } + + // Reposition on every render — keeps target anchored during pinch-zoom and pan. + // Skipped during drag since touchmove handles position directly. + const onPostrender = () => { + const { selectedVertexIndex, interfaceType } = getState() + if (selectedVertexIndex >= 0 && !touchEvents.isDragging() && interfaceType === 'touch') { + updateTargetPosition() + } + } + map.on('postrender', onPostrender) + + const onSizeChange = () => { syncCssTx(); map.once('postrender', updateTargetPosition) } + map.on('change:size', onSizeChange) + syncCssTx() + + return { + updateTargetPosition, + updateColors (newColors) { applyTouchTargetColors(targetEl, newColors) }, + hide () { hideTouchTarget(targetEl) }, + destroy () { + map.un('change:size', onSizeChange) + map.un('postrender', onPostrender) + touchEvents.destroy() + hideTouchTarget(targetEl) + } + } +} diff --git a/plugins/beta/draw/src/adapters/openlayers/edit/undoOps.js b/plugins/beta/draw/src/adapters/openlayers/edit/undoOps.js new file mode 100644 index 000000000..0ae197ad8 --- /dev/null +++ b/plugins/beta/draw/src/adapters/openlayers/edit/undoOps.js @@ -0,0 +1,95 @@ +import { + getRingSegments, + getSegmentForIndex, + getModifiableCoords +} from '../utils/geometryHelpers.js' + +/** + * Undo handlers for edit mode. + * Each operation receives the OL feature and the saved op payload, + * mutates the geometry, and returns the vertex index to re-select (or -1). + */ + +export const undoMoveVertex = (olFeature, op) => { + const { vertexIndex, previousCoord } = op + const geom = olFeature.getGeometry() + const geojsonGeom = { type: geom.getType(), coordinates: geom.getCoordinates() } + const segments = getRingSegments(geojsonGeom) + const result = getSegmentForIndex(segments, vertexIndex) + if (!result) { + return -1 + } + + const ring = getModifiableCoords(geojsonGeom, result.segment.path) + ring[result.localIdx] = [...previousCoord] + if (result.segment.closed && result.localIdx === 0) { + ring[ring.length - 1] = [...previousCoord] + } + geom.setCoordinates(geojsonGeom.coordinates) + return vertexIndex +} + +export const undoInsertVertex = (olFeature, op) => { + const { vertexIndex } = op + const geom = olFeature.getGeometry() + const geojsonGeom = { type: geom.getType(), coordinates: geom.getCoordinates() } + const segments = getRingSegments(geojsonGeom) + const result = getSegmentForIndex(segments, vertexIndex) + if (!result) { + return -1 + } + + const ring = getModifiableCoords(geojsonGeom, result.segment.path) + ring.splice(result.localIdx, 1) + if (result.segment.closed) { + ring[ring.length - 1] = [...ring[0]] + } + geom.setCoordinates(geojsonGeom.coordinates) + return -1 +} + +export const undoDeleteVertex = (olFeature, op) => { + const { vertexIndex, deletedCoord } = op + const geom = olFeature.getGeometry() + const geojsonGeom = { type: geom.getType(), coordinates: geom.getCoordinates() } + const segments = getRingSegments(geojsonGeom) + + let result = getSegmentForIndex(segments, vertexIndex) + // Vertex might be at a segment boundary after deletion shifted indices + if (!result) { + for (const seg of segments) { + if (vertexIndex === seg.start + seg.length) { + result = { segment: seg, localIdx: seg.length } + break + } + } + } + if (!result) { + return -1 + } + + const ring = getModifiableCoords(geojsonGeom, result.segment.path) + ring.splice(result.localIdx, 0, [...deletedCoord]) + if (result.segment.closed) { + ring[ring.length - 1] = [...ring[0]] + } + geom.setCoordinates(geojsonGeom.coordinates) + return vertexIndex +} + +/** + * Dispatch the correct undo handler based on operation type. + * @returns {number} vertex index to re-select after undo, or -1 for none + */ +export const applyUndo = (olFeature, op) => { + switch (op.type) { + case 'move_vertex': + return undoMoveVertex(olFeature, op) + case 'insert_vertex': + return undoInsertVertex(olFeature, op) + case 'delete_vertex': + return undoDeleteVertex(olFeature, op) + default: + return -1 + } +} diff --git a/plugins/beta/draw/src/adapters/openlayers/edit/vertexHitTest.js b/plugins/beta/draw/src/adapters/openlayers/edit/vertexHitTest.js new file mode 100644 index 000000000..34a991d47 --- /dev/null +++ b/plugins/beta/draw/src/adapters/openlayers/edit/vertexHitTest.js @@ -0,0 +1,72 @@ +import { coordToPixel, pixelDist } from '../utils/olCoords.js' + +const PIXEL_TOLERANCE = 12 + +/** + * Find the nearest vertex to a screen pixel within tolerance. + * + * @param {import('ol/Map').default} map + * @param {number[][]} vertices - flat coordinate array [[e,n], ...] + * @param {{ x: number, y: number }} pixel + * @param {number} [tolerance] + * @returns {{ index: number, type: 'vertex' } | null} + */ +export const findNearestVertex = (map, vertices, pixel, tolerance = PIXEL_TOLERANCE) => { + let bestIdx = -1 + let bestDist = tolerance + + vertices.forEach((coord, i) => { + const px = coordToPixel(map, coord) + if (!px) { + return + } + const d = pixelDist(px, pixel) + if (d < bestDist) { + bestDist = d + bestIdx = i + } + }) + + return bestIdx >= 0 ? { index: bestIdx, type: 'vertex' } : null +} + +/** + * Find the nearest midpoint to a screen pixel within tolerance. + * + * @param {import('ol/Map').default} map + * @param {number[][]} midpoints - midpoint coordinate array + * @param {{ x: number, y: number }} pixel + * @param {number} vertexCount - number of actual vertices (midpoint index offset) + * @param {number} [tolerance] + * @returns {{ index: number, type: 'midpoint' } | null} + */ +export const findNearestMidpoint = (map, midpoints, pixel, vertexCount, tolerance = PIXEL_TOLERANCE) => { + let bestIdx = -1 + let bestDist = tolerance + + midpoints.forEach((coord, i) => { + const px = coordToPixel(map, coord) + if (!px) { + return + } + const d = pixelDist(px, pixel) + if (d < bestDist) { + bestDist = d + bestIdx = i + } + }) + + return bestIdx >= 0 ? { index: vertexCount + bestIdx, type: 'midpoint' } : null +} + +/** + * Find the nearest vertex or midpoint to a pixel. + * Vertices take priority when equidistant. + * + * @param {number} [tolerance] + * @returns {{ index: number, type: 'vertex'|'midpoint' } | null} + */ +export const findNearest = (map, vertices, midpoints, pixel, tolerance = PIXEL_TOLERANCE) => { + return findNearestVertex(map, vertices, pixel, tolerance) ?? + findNearestMidpoint(map, midpoints, pixel, vertices.length, tolerance) +} diff --git a/plugins/beta/draw/src/adapters/openlayers/edit/vertexLayer.js b/plugins/beta/draw/src/adapters/openlayers/edit/vertexLayer.js new file mode 100644 index 000000000..057acbb02 --- /dev/null +++ b/plugins/beta/draw/src/adapters/openlayers/edit/vertexLayer.js @@ -0,0 +1,49 @@ +import VectorSource from 'ol/source/Vector.js' +import VectorLayer from 'ol/layer/Vector.js' +import Feature from 'ol/Feature.js' +import Point from 'ol/geom/Point.js' +import { getCoords } from '../utils/geometryHelpers.js' + +/** + * Always-visible vertex handle layer for edit mode. + * OL Modify's built-in vertex handles only appear on hover; this layer + * keeps circles visible at all times. The selected vertex is rendered by + * the separate active-selection layer in EditMode (zIndex 103). + */ +export const createVertexLayer = (map, vertexStyle) => { + let currentStyle = vertexStyle + let selectedIndex = -1 + const source = new VectorSource() + const layer = new VectorLayer({ + source, + style: (feature) => feature.get('vertexIndex') === selectedIndex ? null : [currentStyle], + zIndex: 102 + }) + map.addLayer(layer) + + return { + update (geom) { + source.clear() + getCoords(geom).forEach((coord, i) => { + const f = new Feature({ geometry: new Point(coord) }) + f.set('vertexIndex', i) + source.addFeature(f) + }) + }, + + setSelected (index) { + selectedIndex = index + source.changed() + }, + + updateStyle (newVertexStyle) { + currentStyle = newVertexStyle + source.changed() + }, + + remove () { + source.clear() + map.removeLayer(layer) + } + } +} diff --git a/plugins/beta/draw/src/adapters/openlayers/edit/vertexOps.js b/plugins/beta/draw/src/adapters/openlayers/edit/vertexOps.js new file mode 100644 index 000000000..e35b9cdb6 --- /dev/null +++ b/plugins/beta/draw/src/adapters/openlayers/edit/vertexOps.js @@ -0,0 +1,99 @@ +import { + getCoords, + getRingSegments, + getSegmentForIndex, + getModifiableCoords +} from '../utils/geometryHelpers.js' + +/** + * Delete the vertex at `selectedIndex` from the OL feature's geometry. + * Respects minimum vertex counts (3 for closed rings, 2 for lines). + * + * @returns {{ deletedIndex: number, deletedCoord: number[] } | null} undo payload, or null if not deleted + */ +export const deleteVertex = (olFeature, selectedIndex) => { + const geom = olFeature.getGeometry() + const geojsonGeom = { type: geom.getType(), coordinates: geom.getCoordinates() } + const coords = getCoords(geojsonGeom) + const segments = getRingSegments(geojsonGeom) + const result = getSegmentForIndex(segments, selectedIndex) + if (!result) { + return null + } + + const { segment } = result + const minVertices = segment.closed ? 3 : 2 + if (segment.length <= minVertices) { + return null + } + + const deletedCoord = [...coords[selectedIndex]] + const ring = getModifiableCoords(geojsonGeom, segment.path) + ring.splice(result.localIdx, 1) + if (segment.closed) { + ring[ring.length - 1] = [...ring[0]] + } + + geom.setCoordinates(geojsonGeom.coordinates) + return { deletedIndex: selectedIndex, deletedCoord } +} + +/** + * Insert a vertex after `afterIndex` in the OL feature's geometry. + * Used when the user activates a midpoint (touch tap or keyboard insert). + * + * @param {number[][]} midpoints - current midpoint array (from midpointLayer) + * @param {number} midpointFlatIndex - flat index (vertexCount + midpointOffset) + * @param {number} vertexCount - number of actual vertices + * @param {number[][]} vertices - current vertex array + * @returns {{ insertedIndex: number } | null} + */ +export const insertAtMidpoint = (olFeature, midpoints, midpointFlatIndex, vertexCount) => { + const midpointLocalIdx = midpointFlatIndex - vertexCount + const midCoord = midpoints[midpointLocalIdx] + if (!midCoord) { + return null + } + + const geom = olFeature.getGeometry() + const geojsonGeom = { type: geom.getType(), coordinates: geom.getCoordinates() } + const segments = getRingSegments(geojsonGeom) + + // Map midpoint local index to insertion position in the coordinate array + let midpointCounter = 0 + for (const seg of segments) { + const segMidpoints = seg.closed ? seg.length : seg.length - 1 + if (midpointLocalIdx < midpointCounter + segMidpoints) { + const localMidIdx = midpointLocalIdx - midpointCounter + const insertLocalIdx = localMidIdx + 1 + const insertGlobalIdx = seg.start + insertLocalIdx + const ring = getModifiableCoords(geojsonGeom, seg.path) + ring.splice(insertLocalIdx, 0, [...midCoord]) + geom.setCoordinates(geojsonGeom.coordinates) + return { insertedIndex: insertGlobalIdx } + } + midpointCounter += segMidpoints + } + + return null +} + +/** + * Move vertex at `index` to `newCoord` in the OL feature's geometry. + */ +export const moveVertex = (olFeature, index, newCoord) => { + const geom = olFeature.getGeometry() + const geojsonGeom = { type: geom.getType(), coordinates: geom.getCoordinates() } + const segments = getRingSegments(geojsonGeom) + const result = getSegmentForIndex(segments, index) + if (!result) { + return + } + + const ring = getModifiableCoords(geojsonGeom, result.segment.path) + ring[result.localIdx] = [...newCoord] + if (result.segment.closed && result.localIdx === 0) { + ring[ring.length - 1] = [...newCoord] + } + geom.setCoordinates(geojsonGeom.coordinates) +} diff --git a/plugins/beta/draw/src/adapters/openlayers/olDraw.js b/plugins/beta/draw/src/adapters/openlayers/olDraw.js new file mode 100644 index 000000000..73c966f33 --- /dev/null +++ b/plugins/beta/draw/src/adapters/openlayers/olDraw.js @@ -0,0 +1,38 @@ +import { OLDrawManager } from './core/OLDrawManager.js' + +/** + * Creates the OLDrawManager, attaches it to mapProvider, and wires + * app-level events (MAP_SET_SIZE for scale-aware touch targets, + * MAP_SET_STYLE for dynamic color updates). + * + * @returns {{ remove: () => void }} + */ +export const createOLDraw = ({ mapProvider, events, eventBus, pluginConfig = {}, mapStyle = null }) => { + const { map } = mapProvider + const manager = new OLDrawManager(map, pluginConfig) + + if (mapStyle) { + manager.setMapStyle(mapStyle) + } + + mapProvider.draw = manager + + const handleSetMapSize = (size) => { + mapProvider.drawScale = { small: 1, medium: 1.5, large: 2 }[size] ?? 1 + } + eventBus.on(events.MAP_SET_SIZE, handleSetMapSize) + + const handleSetMapStyle = (newMapStyle) => { + manager.setMapStyle(newMapStyle) + } + eventBus.on(events.MAP_SET_STYLE, handleSetMapStyle) + + return { + remove () { + eventBus.off(events.MAP_SET_SIZE, handleSetMapSize) + eventBus.off(events.MAP_SET_STYLE, handleSetMapStyle) + manager.remove() + mapProvider.draw = null + } + } +} diff --git a/plugins/beta/draw/src/adapters/openlayers/snap/snapEngine.js b/plugins/beta/draw/src/adapters/openlayers/snap/snapEngine.js new file mode 100644 index 000000000..35b7f7039 --- /dev/null +++ b/plugins/beta/draw/src/adapters/openlayers/snap/snapEngine.js @@ -0,0 +1,153 @@ +import { transform as projTransform } from 'ol/proj.js' +import VectorLayer from 'ol/layer/Vector.js' +import VectorTileLayer from 'ol/layer/VectorTile.js' +import { testOLFeature, testRenderFeature } from './snapGeometry.js' + +// VectorTile features are clipped to tile extents, producing artificial straight edges at tile +// boundaries. MVT tiles also carry a buffer region (geometry from adjacent tiles extending +// past the tile boundary). Both the exact boundary and the buffer zone must be filtered. +// Standard MVT buffer is 128 out of 4096 tile coordinate units. +const TILE_BOUNDARY_EPS = 1 // source-projection units of margin beyond the buffer +const MVT_BUFFER_UNITS = 128 +const MVT_TILE_EXTENT = 4096 + +const buildTileBoundaryState = (layer, map) => { + const source = layer.getSource() + const tileGrid = source?.getTileGrid() + if (!tileGrid) { return null } + const viewProj = map.getView().getProjection() + const sourceProj = source.getProjection() ?? viewProj + const zoom = tileGrid.getZForResolution(map.getView().getResolution(), 0) + return { tileGrid, viewProj, sourceProj, zoom } +} + +const isOnTileBoundary = (state, coord) => { + if (!state) { return false } + const { tileGrid, viewProj, sourceProj, zoom } = state + const c = projTransform(coord, viewProj, sourceProj) + const tileCoord = tileGrid.getTileCoordForCoordAndZ(c, zoom) + const [minX, minY, maxX, maxY] = tileGrid.getTileCoordExtent(tileCoord) + // Buffer zone in source projection: edges within this distance of a tile boundary are + // MVT buffer clip artefacts (geometry from the adjacent tile included for rendering overlap). + const tileBuffer = (maxX - minX) * (MVT_BUFFER_UNITS / MVT_TILE_EXTENT) + const eps = tileBuffer + TILE_BOUNDARY_EPS + return ( + Math.abs(c[0] - minX) < eps || + Math.abs(c[0] - maxX) < eps || + Math.abs(c[1] - minY) < eps || + Math.abs(c[1] - maxY) < eps + ) +} + +// Two same-style fill polygons share an invisible boundary — snapping to it is confusing. +// Detect this by projecting a test point slightly past the snap position (away from the cursor) +// and checking whether the same fill layer covers that side too. +// If it does → same-style invisible boundary → skip. +// If not (empty space, road, different layer) → visible outer edge → include. +const INVISIBLE_FILL_MIN_DIST_SQ = 1 // (1m)² — below this, cursor is on the edge, skip direction check + +const isInvisibleFillBoundary = (edgeCoord, cursorCoord, layerId, map, vtLayers, resolution) => { + const dx = edgeCoord[0] - cursorCoord[0] + const dy = edgeCoord[1] - cursorCoord[1] + const distSq = dx * dx + dy * dy + if (distSq < INVISIBLE_FILL_MIN_DIST_SQ) { return false } + const dist = Math.sqrt(distSq) + const step = resolution * 4 + const testCoord = [edgeCoord[0] + (dx / dist) * step, edgeCoord[1] + (dy / dist) * step] + const testPixel = map.getPixelFromCoordinate(testCoord) + if (!testPixel) { return false } + return !!map.forEachFeatureAtPixel( + testPixel, + (f) => f.get('mapbox-layer')?.id === layerId, + { hitTolerance: 2, layerFilter: (l) => vtLayers.includes(l) } + ) +} + +const pickBest = (a, b) => { + if (!b) { return a } + if (!a) { return b } + if (a.type === 'vertex' && b.type === 'edge') { return a } + if (a.type === 'edge' && b.type === 'vertex') { return b } + return b.distSq < a.distSq ? b : a +} + +// Collected on each query — VectorTileLayers are replaced when the map style changes +const getVTLayers = (map) => { + const layers = [] + map.getLayers().forEach(l => { + if (l instanceof VectorTileLayer) { layers.push(l) } + }) + return layers +} + +export const createSnapEngine = (map, snapLayers = []) => { + let vtLayerNames = new Set() + let olLayers = [] + + const setLayers = (layers) => { + vtLayerNames = new Set() + olLayers = [] + for (const entry of layers ?? []) { + if (typeof entry === 'string') { + vtLayerNames.add(entry) + } else if (entry instanceof VectorLayer) { + olLayers.push(entry) + } else { + // unsupported layer type — skip + } + } + } + + setLayers(snapLayers) + + const query = (coord, radiusPx) => { + const resolution = map.getView().getResolution() + if (!resolution) { return null } + const toleranceMapUnits = radiusPx * resolution + const toleranceSq = toleranceMapUnits * toleranceMapUnits + const ext = [ + coord[0] - toleranceMapUnits, + coord[1] - toleranceMapUnits, + coord[0] + toleranceMapUnits, + coord[1] + toleranceMapUnits + ] + + let best = null + + for (const layer of olLayers) { + const source = layer.getSource() + if (!source) { continue } + for (const feature of source.getFeaturesInExtent(ext)) { + best = pickBest(best, testOLFeature(feature, coord, toleranceSq)) + } + } + + if (vtLayerNames.size > 0) { + const vtLayers = getVTLayers(map) + const pixel = vtLayers.length > 0 ? map.getPixelFromCoordinate(coord) : null + if (pixel) { + const tileBoundaryStates = new Map() + map.forEachFeatureAtPixel( + pixel, + (feature, layer) => { + const mapboxLayer = feature.get('mapbox-layer') + if (!vtLayerNames.has(mapboxLayer?.id)) { return } + const candidate = testRenderFeature(feature, coord, toleranceSq) + if (!candidate) { return } + if (!tileBoundaryStates.has(layer)) { + tileBoundaryStates.set(layer, buildTileBoundaryState(layer, map)) + } + if (isOnTileBoundary(tileBoundaryStates.get(layer), candidate.coord)) { return } + if (candidate.type === 'edge' && mapboxLayer?.type === 'fill' && isInvisibleFillBoundary(candidate.coord, coord, mapboxLayer.id, map, vtLayers, resolution)) { return } + best = pickBest(best, candidate) + }, + { hitTolerance: radiusPx, layerFilter: (l) => vtLayers.includes(l) } + ) + } + } + + return best ? { type: best.type, coord: best.coord } : null + } + + return { query, setLayers } +} diff --git a/plugins/beta/draw/src/adapters/openlayers/snap/snapGeometry.js b/plugins/beta/draw/src/adapters/openlayers/snap/snapGeometry.js new file mode 100644 index 000000000..16aecebd4 --- /dev/null +++ b/plugins/beta/draw/src/adapters/openlayers/snap/snapGeometry.js @@ -0,0 +1,177 @@ +const dist2 = (a, b) => { + const dx = a[0] - b[0] + const dy = a[1] - b[1] + return dx * dx + dy * dy +} + +const closestPointOnSegment = (p, a, b) => { + const dx = b[0] - a[0] + const dy = b[1] - a[1] + const lenSq = dx * dx + dy * dy + if (lenSq === 0) { + return [a[0], a[1]] + } + const t = Math.max(0, Math.min(1, ((p[0] - a[0]) * dx + (p[1] - a[1]) * dy) / lenSq)) + return [a[0] + t * dx, a[1] + t * dy] +} + +const bestOf = (current, candidate) => better(current, candidate) ? candidate : current + +const better = (a, b) => { + if (!a) { + return !!b + } + if (!b) { + return false + } + // Vertex always beats edge — only compare distance within the same type + if (a.type === 'edge' && b.type === 'vertex') { + return true + } + if (a.type === 'vertex' && b.type === 'edge') { + return false + } + return b.distSq < a.distSq +} + +// OL Vector rings duplicate the first coord as last; VectorTile rings do not. +// isClosedRing=true: skip the duplicated last vertex, wrap closing edge back to first. +const testCoords = (coords, query, toleranceSq, isClosedRing) => { + let best = null + const n = isClosedRing && coords.length > 1 ? coords.length - 1 : coords.length + const edgeCount = isClosedRing ? n : n - 1 + + for (let i = 0; i < n; i++) { + const v = coords[i] + const dSq = dist2(query, v) + if (dSq <= toleranceSq) { + best = bestOf(best, { type: 'vertex', coord: [v[0], v[1]], distSq: dSq }) + } + } + + for (let i = 0; i < edgeCount; i++) { + const a = coords[i] + const b = coords[(i + 1) % n] + const pt = closestPointOnSegment(query, a, b) + const dSq = dist2(query, pt) + if (dSq <= toleranceSq) { + best = bestOf(best, { type: 'edge', coord: pt, distSq: dSq }) + } + } + + return best +} + +const getBestEdge = (flat, start, numPairs, edgeCount, query, toleranceSq) => { + let best = null + for (let i = 0; i < edgeCount; i++) { + const ai = start + i * 2 + const bi = start + ((i + 1) % numPairs) * 2 + const a = [flat[ai], flat[ai + 1]] + const b = [flat[bi], flat[bi + 1]] + const pt = closestPointOnSegment(query, a, b) + const dSq = dist2(query, pt) + if (dSq <= toleranceSq) { + best = bestOf(best, { type: 'edge', coord: pt, distSq: dSq }) + } + } + return best +} + +const getBestVertex = (flat, start, numPairs, query, toleranceSq) => { + let best = null + for (let i = 0; i < numPairs; i++) { + const xi = start + i * 2 + const v = [flat[xi], flat[xi + 1]] + const dSq = dist2(query, v) + if (dSq <= toleranceSq) { + best = bestOf(best, { type: 'vertex', coord: v, distSq: dSq }) + } + } + return best +} + +const getBestPair = (flat, start, numPairs, edgeCount, query, toleranceSq) => { + return bestOf( + getBestVertex(flat, start, numPairs, query, toleranceSq), + getBestEdge(flat, start, numPairs, edgeCount, query, toleranceSq) + ) +} + +const testFlatCoords = (flat, start, end, query, toleranceSq, isClosedRing) => { + const numPairs = (end - start) / 2 + const edgeCount = isClosedRing ? numPairs : numPairs - 1 + return getBestPair(flat, start, numPairs, edgeCount, query, toleranceSq) +} + +const olGeomHandlers = { + point: (geom, query, toleranceSq) => { + const c = geom.getCoordinates() + const dSq = dist2(query, c) + return dSq <= toleranceSq ? { type: 'vertex', coord: [c[0], c[1]], distSq: dSq } : null + }, + lineString: (geom, query, toleranceSq) => { + return testCoords(geom.getCoordinates(), query, toleranceSq, false) + }, + linearRing: (geom, query, toleranceSq) => { + return testCoords(geom.getCoordinates(), query, toleranceSq, true) + }, + polygon: (geom, query, toleranceSq) => { + let best = null + for (const ring of geom.getCoordinates()) { + best = bestOf(best, testCoords(ring, query, toleranceSq, true)) + } + return best + }, + multiLineString: (geom, query, toleranceSq) => { + let best = null + for (const line of geom.getCoordinates()) { + best = bestOf(best, testCoords(line, query, toleranceSq, false)) + } + return best + }, + multiPolygon: (geom, query, toleranceSq) => { + let best = null + for (const polygon of geom.getCoordinates()) { + for (const ring of polygon) { + best = bestOf(best, testCoords(ring, query, toleranceSq, true)) + } + } + return best + } +} + +export const testOLFeature = (feature, query, toleranceSq) => { + const geom = feature.getGeometry() + if (!geom) { return null } + const rawType = geom.getType() + const handler = olGeomHandlers[rawType[0].toLowerCase() + rawType.slice(1)] + return handler ? handler(geom, query, toleranceSq) : null +} + +export const testRenderFeature = (feature, query, toleranceSq) => { + const type = feature.getType() + const flat = feature.getFlatCoordinates() + let best = null + + if (type === 'Point') { + const dSq = dist2(query, flat) + if (dSq <= toleranceSq) { + best = { type: 'vertex', coord: [flat[0], flat[1]], distSq: dSq } + } + } else if (type === 'LineString') { + best = testFlatCoords(flat, 0, flat.length, query, toleranceSq, false) + } else if (type === 'Polygon' || type === 'MultiLineString') { + const ends = feature.getEnds() + let start = 0 + const isClosedRing = type === 'Polygon' + for (const end of ends) { + best = bestOf(best, testFlatCoords(flat, start, end, query, toleranceSq, isClosedRing)) + start = end + } + } else { + // MultiPoint / unknown — no snap candidates + } + + return best +} diff --git a/plugins/beta/draw/src/adapters/openlayers/snap/snapIndicator.js b/plugins/beta/draw/src/adapters/openlayers/snap/snapIndicator.js new file mode 100644 index 000000000..de75e26ff --- /dev/null +++ b/plugins/beta/draw/src/adapters/openlayers/snap/snapIndicator.js @@ -0,0 +1,81 @@ +/** + * Snap indicator — a single OL VectorLayer that shows a circle at the active + * snap candidate position. + * + * Vertex snap → orange semi-transparent circle (snapVertex color) + * Edge snap → blue semi-transparent circle (snapEdge color) + * + * Uses Style.renderer (single canvas call) so the circle renders correctly + * at fractional CSS scale factors. + */ + +import VectorLayer from 'ol/layer/Vector.js' +import VectorSource from 'ol/source/Vector.js' +import Feature from 'ol/Feature.js' +import Point from 'ol/geom/Point.js' +import { Style } from 'ol/style.js' + +const RADIUS_PX = 10 + +const makeRenderer = (color) => (coords, state) => { + const ctx = state.context + const [cx, cy] = coords + ctx.beginPath() + ctx.arc(cx, cy, RADIUS_PX * state.pixelRatio, 0, Math.PI * 2) + ctx.fillStyle = color + ctx.fill() +} + +const makeStyles = (colors) => ({ + vertex: new Style({ renderer: makeRenderer(colors.snapVertex) }), + edge: new Style({ renderer: makeRenderer(colors.snapEdge) }) +}) + +export const createSnapIndicator = (map, colors) => { + let styles = makeStyles(colors) + const source = new VectorSource() + const layer = new VectorLayer({ + source, + style: (f) => styles[f.get('snapType')] ?? null, + zIndex: 200, + updateWhileAnimating: true, + updateWhileInteracting: true + }) + map.addLayer(layer) + + const feature = new Feature() + let showing = false + + return { + show (coord, type) { + feature.setGeometry(new Point(coord)) + feature.set('snapType', type, true) + if (showing) { + source.changed() + } else { + source.addFeature(feature) + showing = true + } + }, + + hide () { + if (!showing) { + return + } + source.clear() + showing = false + }, + + updateColors (newColors) { + styles = makeStyles(newColors) + if (showing) { + source.changed() + } + }, + + remove () { + source.clear() + map.removeLayer(layer) + } + } +} diff --git a/plugins/beta/draw/src/adapters/openlayers/snap/snapInteraction.js b/plugins/beta/draw/src/adapters/openlayers/snap/snapInteraction.js new file mode 100644 index 000000000..67279e5b5 --- /dev/null +++ b/plugins/beta/draw/src/adapters/openlayers/snap/snapInteraction.js @@ -0,0 +1,57 @@ +/** + * Custom OL Interaction that intercepts pointer events and rewrites + * mapBrowserEvent.coordinate to the nearest snap candidate before any draw + * or modify interaction sees it. + * + * Coordinate snapping applies to all pointer events (pointermove, pointerdown, + * pointerup, singleclick) so that both rubberbanding and vertex placement are snapped. + * The visual indicator is only updated on pointermove. + * + * Must be added to the map AFTER the Draw/Modify interaction so it is processed + * first (OL iterates interactions in reverse-add order). + * snapManager.reattach() handles this after each mode change. + */ + +import Interaction from 'ol/interaction/Interaction.js' + +const SNAP_EVENTS = new Set(['pointermove', 'pointerdrag', 'pointerdown', 'pointerup', 'singleclick', 'click']) + +const processSnapEvent = (mapBrowserEvent, engine, indicator, snapRadius, isIndicatorActive) => { + const { type } = mapBrowserEvent + + if (type === 'pointerout' || type === 'pointerleave') { + indicator.hide() + return + } + + if (!SNAP_EVENTS.has(type)) { + return + } + + const result = engine.query(mapBrowserEvent.coordinate, snapRadius) + if (result) { + mapBrowserEvent.coordinate = result.coord.slice() + } + + // Only update indicator during free mouse movement — hide during drag, no-op for clicks + if (type === 'pointermove' && isIndicatorActive()) { + result ? indicator.show(result.coord, result.type) : indicator.hide() + } else if (type === 'pointerdrag') { + indicator.hide() + } else { + // no indicator update for click/down/up events + } +} + +export const createSnapInteraction = (engine, indicator, snapRadius, isIndicatorActive) => { + const interaction = new Interaction({ + handleEvent (mapBrowserEvent) { + if (interaction.getActive()) { + processSnapEvent(mapBrowserEvent, engine, indicator, snapRadius, isIndicatorActive) + } + return true + } + }) + + return interaction +} diff --git a/plugins/beta/draw/src/adapters/openlayers/snap/snapManager.js b/plugins/beta/draw/src/adapters/openlayers/snap/snapManager.js new file mode 100644 index 000000000..767776abe --- /dev/null +++ b/plugins/beta/draw/src/adapters/openlayers/snap/snapManager.js @@ -0,0 +1,99 @@ +/** + * Snap manager — orchestrates the snap engine, indicator, and OL interaction. + * + * Returned as manager.snap; null when no snapLayers are configured. + * + * Interface used by draw and edit modes: + * snap.apply(coord) — query + show/hide indicator; returns snapped coord or original + * snap.hideIndicator() — explicit hide (e.g. on touch/keyboard commit) + * snap.setActive(bool) — enable / disable (wired to the UI snap toggle) + * snap.reattach() — re-add the OL interaction after a mode change so it stays + * last-added (= first to process pointermove events) + * snap.destroy() — full cleanup + */ + +import { createSnapEngine } from './snapEngine.js' +import { createSnapIndicator } from './snapIndicator.js' +import { createSnapInteraction } from './snapInteraction.js' + +export const createSnapManager = (map, snapLayers, colors, snapRadius) => { + if (!snapLayers?.length) { + return null + } + + const engine = createSnapEngine(map, snapLayers) + const indicator = createSnapIndicator(map, colors) + + let indicatorActive = false + const interaction = createSnapInteraction(engine, indicator, snapRadius, () => indicatorActive) + + map.addInteraction(interaction) + interaction.setActive(false) // matches reducer initial state: snap: false + + let active = false + + return { + /** + * Apply snap at coord. Updates the indicator and returns the snapped + * coordinate, or the original coordinate when no snap candidate is found. + * Returns original coord unchanged when snap is disabled. + */ + snapRadius, + + apply (coord) { + if (!active) { + return coord + } + const result = engine.query(coord, snapRadius) + if (result) { + indicator.show(result.coord, result.type) + return result.coord + } + indicator.hide() + return coord + }, + + hideIndicator () { + indicator.hide() + }, + + setIndicatorActive (value) { + indicatorActive = value + if (!value) { + indicator.hide() + } + }, + + setActive (value) { + active = value + interaction.setActive(value) + if (!value) { + indicator.hide() + } + }, + + /** + * Remove and re-add the OL interaction so it sits at the top of the + * interaction stack (last-added = first to handle pointermove). + * Call after each changeMode() so the interaction always runs before + * the newly added Draw or Modify interaction. + */ + setSnapLayers (layers) { + engine.setLayers(layers === null || layers === undefined ? (snapLayers ?? []) : layers) + }, + + reattach () { + map.removeInteraction(interaction) + map.addInteraction(interaction) + }, + + updateColors (newColors) { + indicator.updateColors(newColors) + }, + + destroy () { + map.removeInteraction(interaction) + indicator.remove() + } + } +} diff --git a/plugins/beta/draw/src/adapters/openlayers/utils/flattenStyleProperties.js b/plugins/beta/draw/src/adapters/openlayers/utils/flattenStyleProperties.js new file mode 100644 index 000000000..cfd3df61e --- /dev/null +++ b/plugins/beta/draw/src/adapters/openlayers/utils/flattenStyleProperties.js @@ -0,0 +1,47 @@ +const STYLE_PROPS = ['stroke', 'fill', 'strokeWidth'] + +/** + * Flatten style properties that may be strings or variant objects + * keyed by style ID into flat GeoJSON-compatible properties. + * + * @param {object} props - Object containing style properties + * @returns {object} Flattened properties + * + * @example + * flattenStyleProperties({ + * stroke: { outdoor: '#e6c700', dark: '#ffd700' }, + * fill: 'rgba(255, 221, 0, 0.1)', + * strokeWidth: 3 + * }) + * // Returns: + * // { + * // stroke: '#e6c700', + * // strokeOutdoor: '#e6c700', + * // strokeDark: '#ffd700', + * // fill: 'rgba(255, 221, 0, 0.1)', + * // strokeWidth: 3 + * // } + */ +export const flattenStyleProperties = (props) => { + if (!props) { + return {} + } + + const result = {} + + for (const [key, value] of Object.entries(props)) { + if (STYLE_PROPS.includes(key) && typeof value === 'object' && value !== null) { + const entries = Object.entries(value) + if (entries.length > 0) { + result[key] = entries[0][1] + } + for (const [styleId, styleValue] of entries) { + result[`${key}${styleId.charAt(0).toUpperCase() + styleId.slice(1)}`] = styleValue + } + } else { + result[key] = value + } + } + + return result +} diff --git a/plugins/beta/draw/src/adapters/openlayers/utils/geometryHelpers.js b/plugins/beta/draw/src/adapters/openlayers/utils/geometryHelpers.js new file mode 100644 index 000000000..2ac3dc94a --- /dev/null +++ b/plugins/beta/draw/src/adapters/openlayers/utils/geometryHelpers.js @@ -0,0 +1,103 @@ +/** + * Pure geometry helpers for multi-ring/multi-part geometry support. + * Handles coordinate transformations between flat arrays and hierarchical GeoJSON. + * Supports Polygon, MultiPolygon, LineString, MultiLineString. + */ + +export const getCoords = (geom) => { + if (!geom?.coordinates) { + return [] + } + switch (geom.type) { + case 'LineString': return geom.coordinates + case 'Polygon': return geom.coordinates.flatMap(ring => ring.slice(0, -1)) + case 'MultiLineString': return geom.coordinates.flat(1) + case 'MultiPolygon': return geom.coordinates.flatMap(poly => poly.flatMap(ring => ring.slice(0, -1))) + default: return [] + } +} + +/** + * Segment metadata for each ring or part. + * { start, length, path, closed } + */ +export const getRingSegments = (geom) => { + if (!geom?.coordinates) { + return [] + } + const segments = [] + let start = 0 + + switch (geom.type) { + case 'LineString': + segments.push({ start: 0, length: geom.coordinates.length, path: [], closed: false }) + break + case 'Polygon': + geom.coordinates.forEach((ring, i) => { + const len = ring.length - 1 + segments.push({ start, length: len, path: [i], closed: true }) + start += len + }) + break + case 'MultiLineString': + geom.coordinates.forEach((line, i) => { + segments.push({ start, length: line.length, path: [i], closed: false }) + start += line.length + }) + break + case 'MultiPolygon': + geom.coordinates.forEach((polygon, pi) => { + polygon.forEach((ring, ri) => { + const len = ring.length - 1 + segments.push({ start, length: len, path: [pi, ri], closed: true }) + start += len + }) + }) + break + default: + break + } + + return segments +} + +/** Find which segment a flat vertex index belongs to. */ +export const getSegmentForIndex = (segments, flatIdx) => { + for (const seg of segments) { + if (flatIdx >= seg.start && flatIdx < seg.start + seg.length) { + return { segment: seg, localIdx: flatIdx - seg.start } + } + } + return null +} + +/** Return a reference to the coordinate array at a hierarchical path. */ +export const getModifiableCoords = (geojsonGeometry, path) => { + let coords = geojsonGeometry.coordinates + for (const idx of path) { + coords = coords[idx] + } + return coords +} + +/** Compute midpoints for all segments, respecting open/closed ring boundaries. */ +export const getMidpoints = (geom) => { + const coords = getCoords(geom) + const segments = getRingSegments(geom) + if (!coords.length || !segments.length) { + return [] + } + + const midpoints = [] + for (const seg of segments) { + const count = seg.closed ? seg.length : seg.length - 1 + for (let i = 0; i < count; i++) { + const idx = seg.start + i + const nextIdx = seg.start + ((i + 1) % seg.length) + const [x1, y1] = coords[idx] + const [x2, y2] = coords[nextIdx] + midpoints.push([(x1 + x2) / 2, (y1 + y2) / 2]) + } + } + return midpoints +} diff --git a/plugins/beta/draw/src/adapters/openlayers/utils/olCoords.js b/plugins/beta/draw/src/adapters/openlayers/utils/olCoords.js new file mode 100644 index 000000000..de730e136 --- /dev/null +++ b/plugins/beta/draw/src/adapters/openlayers/utils/olCoords.js @@ -0,0 +1,35 @@ +/** + * Thin helpers bridging OL's array-based pixel/coordinate API + * and the {x, y} object convention used in touch/keyboard handlers. + */ + +/** OL coordinate [e, n] → screen pixel { x, y } */ +export const coordToPixel = (map, coord) => { + const px = map.getPixelFromCoordinate(coord) + if (!px) { return null } + return { x: px[0], y: px[1] } +} + +/** Screen pixel { x, y } → OL coordinate [e, n] */ +export const pixelToCoord = (map, pixel) => { + return map.getCoordinateFromPixel([pixel.x, pixel.y]) +} + +/** Pixel distance between two { x, y } points */ +export const pixelDist = (a, b) => Math.sqrt((a.x - b.x) ** 2 + (a.y - b.y) ** 2) + +/** OL pixel array [x, y] → { x, y } */ +export const arrayToPixel = ([x, y]) => ({ x, y }) + +/** { x, y } → OL pixel array [x, y] */ +export const pixelToArray = ({ x, y }) => [x, y] + +/** + * Nudge a coordinate by (dx, dy) screen pixels. + * Converts pixel offset to map coordinate delta using the current resolution. + */ +export const nudgeCoord = (map, coord, dx, dy) => { + const px = map.getPixelFromCoordinate(coord) + if (!px) { return coord } + return map.getCoordinateFromPixel([px[0] + dx, px[1] + dy]) +} diff --git a/plugins/beta/draw/src/adapters/openlayers/utils/resolveColors.js b/plugins/beta/draw/src/adapters/openlayers/utils/resolveColors.js new file mode 100644 index 000000000..2c3dffe50 --- /dev/null +++ b/plugins/beta/draw/src/adapters/openlayers/utils/resolveColors.js @@ -0,0 +1,39 @@ +import { DEFAULTS } from '../defaults.js' + +const resolveVariant = (value, scheme, styleId) => { + if (typeof value !== 'object' || value === null) { return value } + if (styleId && value[styleId] !== undefined) { return value[styleId] } + if (value[scheme] !== undefined) { return value[scheme] } + if (value.light !== undefined) { return value.light } + return Object.values(value)[0] +} + +/** + * Resolve all draw-ol colors for the given map style and plugin config overrides. + * + * Values in pluginConfig may be plain strings or variant objects (e.g. { light: '...', dark: '...' }). + * Variant resolution order: exact style ID match → color scheme → 'light' fallback → first value. + * + * @param {object|null} mapStyle - Current map style object (has .id and .mapColorScheme) + * @param {object} pluginConfig - Plugin-level user overrides (may override any DEFAULTS key) + * @returns {object} Flat color values ready for use in createStyles() + */ +export const resolveColors = (mapStyle, pluginConfig = {}) => { + const scheme = mapStyle?.mapColorScheme ?? 'light' + const styleId = mapStyle?.id ?? null + const r = (key) => resolveVariant(pluginConfig[key] ?? DEFAULTS[key], scheme, styleId) + + return { + editStroke: r('editStroke'), + editVertex: r('editVertex'), + editMidpoint: r('editMidpoint'), + editActive: r('editActive'), + editHalo: r('editHalo'), + shapeStroke: r('shapeStroke'), + strokeWidth: pluginConfig.strokeWidth ?? DEFAULTS.strokeWidth, + shapeFill: r('shapeFill'), + snapVertex: r('snapVertex'), + snapEdge: r('snapEdge'), + mapStyleId: styleId + } +} diff --git a/plugins/beta/draw/src/adapters/openlayers/utils/spatial.js b/plugins/beta/draw/src/adapters/openlayers/utils/spatial.js new file mode 100644 index 000000000..b9238b6a0 --- /dev/null +++ b/plugins/beta/draw/src/adapters/openlayers/utils/spatial.js @@ -0,0 +1,24 @@ +/** + * Navigate spatially from a start pixel toward a direction quadrant. + * Returns the index of the nearest pixel in that direction. + * + * @param {[number, number]} start - Current pixel [x, y] + * @param {Array<[number, number]>} pixels - All candidate pixels + * @param {string} direction - ArrowUp | ArrowDown | ArrowLeft | ArrowRight | undefined (nearest) + * @returns {number} Index into pixels array + */ +export const spatialNavigate = (start, pixels, direction) => { + const quadrant = pixels.filter((p) => { + const dx = Math.abs(p[0] - start[0]) + const dy = Math.abs(p[1] - start[1]) + let inQuadrant = false + if (direction === 'ArrowUp') { inQuadrant = p[1] <= start[1] && dy >= dx } else if (direction === 'ArrowDown') { inQuadrant = p[1] > start[1] && dy >= dx } else if (direction === 'ArrowLeft') { inQuadrant = p[0] <= start[0] && dy < dx } else if (direction === 'ArrowRight') { inQuadrant = p[0] > start[0] && dy < dx } else { inQuadrant = true } + return inQuadrant && JSON.stringify(p) !== JSON.stringify(start) + }) + + if (!quadrant.length) { quadrant.push(start) } + + const dist = (p) => Math.hypot(start[0] - p[0], start[1] - p[1]) + const closest = quadrant.reduce((best, p) => dist(p) < dist(best) ? p : best, quadrant[0]) + return pixels.findIndex(p => JSON.stringify(p) === JSON.stringify(closest)) +} diff --git a/plugins/beta/draw/src/adapters/openlayers/utils/touchTarget.js b/plugins/beta/draw/src/adapters/openlayers/utils/touchTarget.js new file mode 100644 index 000000000..663ff499d --- /dev/null +++ b/plugins/beta/draw/src/adapters/openlayers/utils/touchTarget.js @@ -0,0 +1,61 @@ +/** + * SVG offset vertex target — shown below the finger in touch edit mode + * so the user can accurately reposition a vertex without finger occlusion. + * This module handles DOM management only; drag logic lives in touchHandler.js. + * + * The SVG uses CSS custom properties so colors update when the map style changes: + * --draw-halo outer ring (black on light, white on dark) + * --draw-bg inner background (white on light, near-black on dark) + * --draw-primary arrow icons and centre dot (blue on light, white on dark) + */ + +const SVG_HTML = ` + +` + +export const createTouchTarget = (container) => { + let el = container.querySelector('[data-im-draw-touch-target]') + if (!el) { + container.insertAdjacentHTML('beforeend', SVG_HTML) + el = container.querySelector('[data-im-draw-touch-target]') + } + return el +} + +export const applyTouchTargetColors = (el, colors) => { + if (!el) { + return + } + el.style.setProperty('--draw-halo', colors.editActive) + el.style.setProperty('--draw-bg', colors.editHalo) + el.style.setProperty('--draw-primary', colors.editVertex) +} + +export const showTouchTarget = (el, pixel) => { + if (!pixel || !el) { + return + } + el.style.left = `${pixel.x}px` + el.style.top = `${pixel.y}px` + el.style.display = 'block' +} + +export const hideTouchTarget = (el) => { + if (el) { + el.style.display = 'none' + } +} + +/** True when the event target is part of the SVG touch target element. */ +export const isOnTouchTarget = (el) => { + if (!el) { + return false + } + const parent = el.parentNode + return (parent instanceof globalThis.SVGElement) || (parent?.ownerSVGElement != null) +} diff --git a/plugins/beta/draw/src/api/addFeature.js b/plugins/beta/draw/src/api/addFeature.js new file mode 100644 index 000000000..daad75a53 --- /dev/null +++ b/plugins/beta/draw/src/api/addFeature.js @@ -0,0 +1,22 @@ +import { flattenStyleProperties } from '../utils/flattenStyleProperties.js' + +export const addFeature = ({ mapProvider, services }, feature) => { + const { draw } = mapProvider + const { eventBus } = services + + if (!draw) { + return + } + + const { stroke, fill, strokeWidth, properties, ...featureRest } = feature + const flatFeature = { + ...featureRest, + properties: { + ...properties, + ...flattenStyleProperties({ stroke, fill, strokeWidth }) + } + } + + draw.add(flatFeature) + eventBus.emit('draw:add', flatFeature) +} diff --git a/plugins/beta/draw/src/api/deleteFeature.js b/plugins/beta/draw/src/api/deleteFeature.js new file mode 100644 index 000000000..3e9096b96 --- /dev/null +++ b/plugins/beta/draw/src/api/deleteFeature.js @@ -0,0 +1,11 @@ +export const deleteFeature = ({ mapProvider, services }, featureId) => { + const { draw } = mapProvider + const { eventBus } = services + + if (!draw) { + return + } + + draw.delete(featureId) + eventBus.emit('draw:delete', { featureId }) +} diff --git a/plugins/beta/draw/src/api/editFeature.js b/plugins/beta/draw/src/api/editFeature.js new file mode 100644 index 000000000..f1a485432 --- /dev/null +++ b/plugins/beta/draw/src/api/editFeature.js @@ -0,0 +1,42 @@ +export const editFeature = ({ appState, appConfig, mapState, pluginConfig, pluginState, mapProvider, services }, featureId, options = {}) => { + const { dispatch } = pluginState + const { draw } = mapProvider + const { eventBus } = services + + if (!draw) { + return false + } + + const existingFeature = draw.get(featureId) + if (!existingFeature) { + return false + } + + const editModeMap = { LineString: 'edit_line', Polygon: 'edit_polygon' } + eventBus.emit('draw:editstart', { mode: editModeMap[existingFeature.geometry.type] }) + + const snapLayers = options.snapLayers !== undefined ? options.snapLayers : (pluginConfig.snapLayers ?? null) + draw.setSnapLayers(snapLayers) + dispatch({ type: 'SET_HAS_SNAP_LAYERS', payload: snapLayers?.length > 0 }) + + draw.changeMode('edit_vertex', { + container: appState.layoutRefs.viewportRef.current, + deleteVertexButtonId: `${appConfig.id}-draw-delete-point`, + undoButtonId: `${appConfig.id}-draw-undo`, + isPanEnabled: appState.interfaceType !== 'keyboard', + interfaceType: appState.interfaceType, + scale: { small: 1, medium: 1.5, large: 2 }[mapState.mapSize], + featureId, + getSnapEnabled: () => draw.isSnapEnabled() + }) + + const feature = draw.get(featureId) + dispatch({ + type: 'SET_FEATURE', + payload: { feature, tempFeature: feature } + }) + + dispatch({ type: 'SET_MODE', payload: 'edit_vertex' }) + + return true +} diff --git a/plugins/beta/draw/src/api/merge.js b/plugins/beta/draw/src/api/merge.js new file mode 100644 index 000000000..3a8ed2f90 --- /dev/null +++ b/plugins/beta/draw/src/api/merge.js @@ -0,0 +1,11 @@ +/** + * Merge multiple polygons into a single polygon. + * + * Not yet implemented — stub only. + * + * @param {object} context - plugin context + * @param {Array} polygons - array of GeoJSON polygon features to merge + */ +export const merge = ({ services }, polygons) => { + console.warn('draw: merge is not yet implemented', polygons) +} diff --git a/plugins/beta/draw/src/api/newLine.js b/plugins/beta/draw/src/api/newLine.js new file mode 100644 index 000000000..8734c86de --- /dev/null +++ b/plugins/beta/draw/src/api/newLine.js @@ -0,0 +1,36 @@ +import { flattenStyleProperties } from '../utils/flattenStyleProperties.js' + +export const newLine = ({ appState, appConfig, pluginConfig, pluginState, mapState, mapProvider, services }, featureId, options = {}) => { + const { dispatch } = pluginState + const { draw } = mapProvider + const { eventBus } = services + + if (!draw) { + return + } + + eventBus.emit('draw:started', { mode: 'draw_line' }) + + const snapLayers = options.snapLayers !== undefined ? options.snapLayers : (pluginConfig.snapLayers ?? null) + draw.setSnapLayers(snapLayers) + dispatch({ type: 'SET_HAS_SNAP_LAYERS', payload: snapLayers?.length > 0 }) + + const { stroke, fill, strokeWidth, properties: customProperties, ...modeOptions } = options + const properties = { + ...customProperties, + ...flattenStyleProperties({ stroke, fill, strokeWidth }) + } + + draw.changeMode('draw_line', { + container: appState.layoutRefs.viewportRef.current, + addVertexButtonId: `${appConfig.id}-draw-add-point`, + interfaceType: appState.interfaceType, + crossHair: mapState.crossHair, + getSnapEnabled: () => draw.isSnapEnabled(), + featureId, + ...modeOptions, + properties + }) + + dispatch({ type: 'SET_MODE', payload: 'draw_line' }) +} diff --git a/plugins/beta/draw/src/api/newPolygon.js b/plugins/beta/draw/src/api/newPolygon.js new file mode 100644 index 000000000..eae356b8a --- /dev/null +++ b/plugins/beta/draw/src/api/newPolygon.js @@ -0,0 +1,36 @@ +import { flattenStyleProperties } from '../utils/flattenStyleProperties.js' + +export const newPolygon = ({ appState, appConfig, pluginConfig, pluginState, mapState, mapProvider, services }, featureId, options = {}) => { + const { dispatch } = pluginState + const { draw } = mapProvider + const { eventBus } = services + + if (!draw) { + return + } + + eventBus.emit('draw:started', { mode: 'draw_polygon' }) + + const snapLayers = options.snapLayers !== undefined ? options.snapLayers : (pluginConfig.snapLayers ?? null) + draw.setSnapLayers(snapLayers) + dispatch({ type: 'SET_HAS_SNAP_LAYERS', payload: snapLayers?.length > 0 }) + + const { stroke, fill, strokeWidth, properties: customProperties, ...modeOptions } = options + const properties = { + ...customProperties, + ...flattenStyleProperties({ stroke, fill, strokeWidth }) + } + + draw.changeMode('draw_polygon', { + container: appState.layoutRefs.viewportRef.current, + addVertexButtonId: `${appConfig.id}-draw-add-point`, + interfaceType: appState.interfaceType, + crossHair: mapState.crossHair, + getSnapEnabled: () => draw.isSnapEnabled(), + featureId, + ...modeOptions, + properties + }) + + dispatch({ type: 'SET_MODE', payload: 'draw_polygon' }) +} diff --git a/plugins/beta/draw/src/api/split.js b/plugins/beta/draw/src/api/split.js new file mode 100644 index 000000000..18e5da051 --- /dev/null +++ b/plugins/beta/draw/src/api/split.js @@ -0,0 +1,66 @@ +import { splitPolygon } from '../utils/spatial.js' +import { debounce } from '../utils/debounce.js' + +/** + * Start drawing a split line for a polygon. + * + * Only fully implemented for MapLibre. For OpenLayers the geometry calculation + * will run but real-time preview (geometrychange) and snap-to-outline are not + * wired up — coordinate-system differences mean results may be incorrect too. + * + * @param {object} context - plugin context + * @param {string} featureId - ID of the polygon to split + * @param {object} options - Options including snapLayers. + */ +export const split = ({ appState, appConfig, pluginState, mapState, mapProvider }, featureId, options = {}) => { + const { dispatch } = pluginState + const { draw } = mapProvider + + if (!draw) { + return + } + + const polygonFeature = draw.get(featureId) + + // Always include the draw outline layer so the split line snaps to it + const snapLayers = ['stroke-inactive.cold', ...(options.snapLayers || [])] + draw.setSnapLayers(snapLayers) + dispatch({ type: 'SET_HAS_SNAP_LAYERS', payload: true }) + + draw.changeMode('draw_line', { + container: appState.layoutRefs.viewportRef.current, + addVertexButtonId: `${appConfig.id}-draw-add-point`, + interfaceType: appState.interfaceType, + crossHair: mapState?.crossHair, + getSnapEnabled: () => draw.isSnapEnabled(), + featureId: '_splitter', + properties: { splitter: 'invalid' } + }) + + // One-shot: compute split result once the line is finalised + const onSplitCreate = (geojsonFeature) => { + draw.off('create', onSplitCreate) + const featureCollection = splitPolygon(polygonFeature, geojsonFeature) + draw.setFeatureProperty('_splitter', 'splitter', featureCollection ? 'valid' : 'invalid') + dispatch({ type: 'SET_ACTION', payload: { name: 'split', isValid: !!featureCollection } }) + } + draw.on('create', onSplitCreate) + + // Real-time preview: update split validity as vertices are placed (ML only) + const DEBOUNCE_MS = 50 + const onGeometryChange = debounce((e) => { + if (e.coordinates.length < 2) { + return + } + const lineFeature = { id: '_splitter', geometry: { type: 'LineString', coordinates: e.coordinates } } + const featureCollection = splitPolygon(polygonFeature, lineFeature) + const isValid = !!featureCollection + e.properties.splitter = isValid ? 'valid' : 'invalid' + e.ctx?.store?.render() + dispatch({ type: 'SET_ACTION', payload: { name: 'split', isValid } }) + }, DEBOUNCE_MS) + draw.on('geometrychange', onGeometryChange) + + dispatch({ type: 'SET_MODE', payload: 'draw_line' }) + dispatch({ type: 'SET_ACTION', payload: { name: 'split' } }) +} diff --git a/plugins/beta/draw/src/defaults.js b/plugins/beta/draw/src/defaults.js new file mode 100644 index 000000000..002e5f7ea --- /dev/null +++ b/plugins/beta/draw/src/defaults.js @@ -0,0 +1,16 @@ +export const DEFAULTS = { + editStroke: { light: '#1a65a6', dark: '#ffffff' }, + editVertex: { light: '#1a65a6', dark: '#ffffff' }, + editMidpoint: { light: '#1a65a6', dark: '#ffffff' }, + editHalo: { light: '#ffffff', dark: 'rgba(11,12,12,1)' }, + editActive: { light: '#000000', dark: '#ffffff' }, + splitInvalid: { light: 'rgba(29,112,184,1)', dark: 'rgba(29,112,184,1)' }, + splitValid: { light: 'rgba(29,112,184,1)', dark: 'rgba(29,112,184,1)' }, + shapeStroke: '#1a65a6', + shapeFill: 'rgba(26,101,166,0.1)', + strokeWidth: 2, + snapVertex: 'rgba(212,53,28,0.5)', + snapMidpoint: 'rgba(40,161,151,1)', + snapEdge: 'rgba(29,112,184,0.5)', + snapRadius: 10 +} diff --git a/plugins/beta/draw/src/draw.scss b/plugins/beta/draw/src/draw.scss new file mode 100644 index 000000000..1b9dfe0e4 --- /dev/null +++ b/plugins/beta/draw/src/draw.scss @@ -0,0 +1,43 @@ +// Touch vertex target (MapLibre) +.touch-vertex-target { + circle { + fill: var(--map-overlay-foreground-color); + } + path { + fill: var(--map-overlay-halo-color); + } +} + +// Touch vertex target (OpenLayers) +.im-draw-touch-target { + color: #3b82f6; + filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.15)); + cursor: grab; + + &:active { + cursor: grabbing; + } +} + +// Accessibility: focus visible on buttons +.im-draw-button:focus-visible { + outline: 2px solid #3b82f6; + outline-offset: 2px; +} + +// Ensure action buttons take up same width +.im-c-actions { + .im-c-button-wrapper--draw-done, + .im-c-button-wrapper--draw-menu, + .im-c-button-wrapper--draw-cancel { + width: 33.33%; + } +} +.im-o-app--tablet .im-c-actions, +.im-o-app--desktop .im-c-actions { + .im-c-button-wrapper--draw-done, + .im-c-button-wrapper--draw-menu, + .im-c-button-wrapper--draw-cancel { + width: 100px; + } +} diff --git a/plugins/beta/draw/src/events.js b/plugins/beta/draw/src/events.js new file mode 100644 index 000000000..1df90c6f5 --- /dev/null +++ b/plugins/beta/draw/src/events.js @@ -0,0 +1,111 @@ +/** + * Button and map-event wiring for the draw plugin. + * + * Uses the normalised adapter interface (draw.on/off, draw.done(), draw.cancel(), + * draw.setSnapEnabled(), etc.) so this file is map-framework-agnostic. + * All MapLibre / OL specifics live in the adapter. + */ +export function attachEvents ({ pluginState, mapProvider, buttonConfig, eventBus }) { + const { drawDone, drawCancel, drawUndo, drawDeletePoint, drawSnap } = buttonConfig + const { draw } = mapProvider + const { dispatch, feature, tempFeature } = pluginState + + const resetState = () => { + dispatch({ type: 'SET_MODE', payload: null }) + dispatch({ type: 'SET_FEATURE', payload: { feature: null, tempFeature: null } }) + } + + const disableSnap = () => { + dispatch({ type: 'SET_SNAP', payload: false }) + draw.setSnapEnabled(false) + } + + const handleDone = () => { + disableSnap() + draw.done() + } + + const handleCancel = () => { + const mode = draw.getMode() + if (mode === 'edit_vertex' && tempFeature?.id) { + draw.add(feature) + } + disableSnap() + draw.cancel() + resetState() + eventBus.emit('draw:cancelled', feature) + } + + const handleUndo = () => { draw.undo() } + + const handleDeleteVertex = () => { draw.deleteVertex() } + + const handleSnap = () => { + const newSnapState = !pluginState.snap + dispatch({ type: 'TOGGLE_SNAP' }) + draw.setSnapEnabled(newSnapState) + } + + const onCreate = (geojsonFeature) => { + disableSnap() + resetState() + setTimeout(() => draw.changeMode('disabled'), 0) + eventBus.emit('draw:created', geojsonFeature) + } + + const onEditFinish = (geojsonFeature) => { + disableSnap() + resetState() + setTimeout(() => draw.changeMode('disabled'), 0) + eventBus.emit('draw:edited', geojsonFeature) + } + + const onCancel = () => {} + + const onVertexSelection = (e) => { + dispatch({ type: 'SET_SELECTED_VERTEX_INDEX', payload: e }) + eventBus.emit('draw:vertexselection', e) + } + + const onVertexChange = (e) => { + dispatch({ type: 'SET_SELECTED_VERTEX_INDEX', payload: { index: -1, numVertices: e.numVertices } }) + } + + const onUndoChange = (length) => { + dispatch({ type: 'SET_UNDO_STACK_LENGTH', payload: length }) + } + + const onUpdate = (geojsonFeature) => { + eventBus.emit('draw:updated', geojsonFeature) + } + + drawDone.onClick = handleDone + drawCancel.onClick = handleCancel + drawUndo.onClick = handleUndo + if (drawDeletePoint) { drawDeletePoint.onClick = handleDeleteVertex } + if (drawSnap) { drawSnap.onClick = handleSnap } + + draw.on('create', onCreate) + draw.on('editfinish', onEditFinish) + draw.on('cancel', onCancel) + draw.on('vertexselection', onVertexSelection) + draw.on('vertexchange', onVertexChange) + draw.on('undochange', onUndoChange) + draw.on('update', onUpdate) + + return () => { + drawDone.onClick = null + drawCancel.onClick = null + drawUndo.onClick = null + if (drawDeletePoint) { drawDeletePoint.onClick = null } + if (drawSnap) { drawSnap.onClick = null } + + draw.off('create', onCreate) + draw.off('editfinish', onEditFinish) + draw.off('cancel', onCancel) + draw.off('vertexselection', onVertexSelection) + draw.off('vertexchange', onVertexChange) + draw.off('undochange', onUndoChange) + draw.off('update', onUpdate) + } +} diff --git a/plugins/beta/draw/src/index.js b/plugins/beta/draw/src/index.js new file mode 100644 index 000000000..0a9b09870 --- /dev/null +++ b/plugins/beta/draw/src/index.js @@ -0,0 +1,10 @@ +export default function createPlugin (options = {}) { + return { + ...options, + id: 'draw', + load: async () => { + const module = (await import(/* webpackChunkName: "im-draw-plugin" */ './manifest.js')).manifest + return module + } + } +} diff --git a/plugins/beta/draw/src/manifest.js b/plugins/beta/draw/src/manifest.js new file mode 100644 index 000000000..917a10d80 --- /dev/null +++ b/plugins/beta/draw/src/manifest.js @@ -0,0 +1,131 @@ +import { initialState, actions } from './reducer.js' +import { DrawInit } from './DrawInit.jsx' +import { newPolygon } from './api/newPolygon.js' +import { newLine } from './api/newLine.js' +import { editFeature } from './api/editFeature.js' +import { addFeature } from './api/addFeature.js' +import { deleteFeature } from './api/deleteFeature.js' +import { split } from './api/split.js' +import { merge } from './api/merge.js' + +const createButtonSlots = (showLabel) => ({ + mobile: { slot: 'actions', showLabel }, + tablet: { slot: 'actions', showLabel }, + desktop: { slot: 'actions', showLabel } +}) + +export const manifest = { + reducer: { + initialState, + actions + }, + + InitComponent: DrawInit, + + buttons: [ + { + id: 'drawCancel', + label: 'Cancel', + variant: 'tertiary', + exclusiveSlot: true, + hiddenWhen: ({ pluginState }) => !pluginState.mode, + ...createButtonSlots(true) + }, + { + id: 'drawAddPoint', + label: 'Add point', + variant: 'primary', + exclusiveSlot: true, + hiddenWhen: ({ appState, pluginState }) => + !['draw_polygon', 'draw_line'].includes(pluginState.mode) || appState.interfaceType !== 'touch', + ...createButtonSlots(true) + }, + { + id: 'drawDone', + label: 'Done', + variant: 'primary', + exclusiveSlot: true, + hiddenWhen: ({ pluginState }) => !['draw_polygon', 'draw_line', 'edit_vertex'].includes(pluginState.mode), + enableWhen: ({ pluginState }) => { + if (pluginState.mode === 'draw_polygon') { return pluginState.numVertices >= 3 } // NOSONAR + if (pluginState.mode === 'draw_line') { return pluginState.numVertices >= 2 } // NOSONAR + if (pluginState.mode === 'edit_vertex') { return true } + return false + }, + ...createButtonSlots(true) + }, + { + id: 'drawMenu', + label: 'Menu', + iconId: 'menu', + exclusiveSlot: true, + hiddenWhen: ({ pluginState }) => !['draw_polygon', 'draw_line', 'edit_vertex'].includes(pluginState.mode), + menuItems: [ + { + id: 'drawUndo', + label: 'Undo', + iconId: 'undo', + hiddenWhen: ({ pluginState }) => !['draw_polygon', 'draw_line', 'edit_vertex'].includes(pluginState.mode), + enableWhen: ({ pluginState }) => { + if (['draw_polygon', 'draw_line'].includes(pluginState.mode)) { + return pluginState.numVertices > 0 + } + return pluginState.undoStackLength > 0 + } + }, + { + id: 'drawSnap', + label: 'Snap to feature', + iconId: 'magnet', + hiddenWhen: ({ pluginState }) => !pluginState.mode || !pluginState.hasSnapLayers, + pressedWhen: ({ pluginState }) => !!pluginState.snap + }, + { + id: 'drawDeletePoint', + label: 'Delete point', + iconId: 'trash', + enableWhen: ({ pluginState }) => { + if (pluginState.selectedVertexIndex < 0) { return false } + const isPolygon = pluginState.feature?.geometry?.type === 'Polygon' + return isPolygon ? pluginState.numVertices > 3 : pluginState.numVertices > 2 // NOSONAR + }, + hiddenWhen: ({ pluginState }) => pluginState.mode !== 'edit_vertex' + } + ], + mobile: { slot: 'bottom-right' }, + tablet: { slot: 'top-middle' }, + desktop: { slot: 'top-middle' } + } + ], + + keyboardShortcuts: [{ + id: 'drawStart', + group: 'Drawing', + title: 'Edit vertex', + command: 'Spacebar' + }], + + icons: [{ + id: 'menu', + svgContent: '' + }, { + id: 'undo', + svgContent: '' + }, { + id: 'magnet', + svgContent: '' + }, { + id: 'trash', + svgContent: '' + }], + + api: { + newPolygon, + newLine, + editFeature, + addFeature, + deleteFeature, + split, + merge + } +} diff --git a/plugins/beta/draw/src/reducer.js b/plugins/beta/draw/src/reducer.js new file mode 100644 index 000000000..a2352ff92 --- /dev/null +++ b/plugins/beta/draw/src/reducer.js @@ -0,0 +1,59 @@ +const initialState = { + mode: null, + action: null, + actionValid: false, + feature: null, + tempFeature: null, + selectedVertexIndex: -1, + numVertices: null, + snap: false, + hasSnapLayers: false, + undoStackLength: 0 +} + +const DRAW_MODES = new Set(['draw_polygon', 'draw_line']) + +const setMode = (state, payload) => ({ + ...state, + mode: payload, + numVertices: DRAW_MODES.has(payload) ? 0 : state.numVertices +}) + +const setAction = (state, payload) => ({ + ...state, + action: payload.name, + actionValid: payload.isValid +}) + +const setSelectedVertexIndex = (state, payload) => ({ + ...state, + selectedVertexIndex: payload.index, + numVertices: payload.numVertices +}) + +const setFeature = (state, payload) => ({ + ...state, + feature: payload.feature === undefined ? state.feature : payload.feature, + tempFeature: payload.tempFeature === undefined ? state.tempFeature : payload.tempFeature +}) + +const toggleSnap = (state) => ({ ...state, snap: !state.snap }) + +const setSnap = (state, payload) => ({ ...state, snap: !!payload }) + +const setHasSnapLayers = (state, payload) => ({ ...state, hasSnapLayers: !!payload }) + +const setUndoStackLength = (state, payload) => ({ ...state, undoStackLength: payload }) + +const actions = { + SET_MODE: setMode, + SET_ACTION: setAction, + SET_FEATURE: setFeature, + SET_SELECTED_VERTEX_INDEX: setSelectedVertexIndex, + TOGGLE_SNAP: toggleSnap, + SET_SNAP: setSnap, + SET_HAS_SNAP_LAYERS: setHasSnapLayers, + SET_UNDO_STACK_LENGTH: setUndoStackLength +} + +export { initialState, actions } diff --git a/plugins/beta/draw/src/utils/debounce.js b/plugins/beta/draw/src/utils/debounce.js new file mode 100644 index 000000000..0e480ff6d --- /dev/null +++ b/plugins/beta/draw/src/utils/debounce.js @@ -0,0 +1,16 @@ +// --- Debounce Helper --- +export const debounce = (fn, delay) => { + let timeout + + const debounced = (...args) => { + clearTimeout(timeout) + timeout = setTimeout(() => fn(...args), delay) + } + + debounced.cancel = () => { + clearTimeout(timeout) + timeout = null + } + + return debounced +} diff --git a/plugins/beta/draw/src/utils/flattenStyleProperties.js b/plugins/beta/draw/src/utils/flattenStyleProperties.js new file mode 100644 index 000000000..55ebbb9a3 --- /dev/null +++ b/plugins/beta/draw/src/utils/flattenStyleProperties.js @@ -0,0 +1,25 @@ +const STYLE_PROPS = ['stroke', 'fill', 'strokeWidth'] + +export const flattenStyleProperties = (props) => { + if (!props) { + return {} + } + + const result = {} + + for (const [key, value] of Object.entries(props)) { + if (STYLE_PROPS.includes(key) && typeof value === 'object' && value !== null) { + const entries = Object.entries(value) + if (entries.length > 0) { + result[key] = entries[0][1] + } + for (const [styleId, styleValue] of entries) { + result[`${key}${styleId.charAt(0).toUpperCase() + styleId.slice(1)}`] = styleValue + } + } else { + result[key] = value + } + } + + return result +} diff --git a/plugins/beta/draw/src/utils/spatial.js b/plugins/beta/draw/src/utils/spatial.js new file mode 100755 index 000000000..28939bb7d --- /dev/null +++ b/plugins/beta/draw/src/utils/spatial.js @@ -0,0 +1,258 @@ +import polygonSplitter from 'polygon-splitter' +import turfBearing from '@turf/bearing' +import turfDestination from '@turf/destination' +import turfBooleanValid from '@turf/boolean-valid' +import turfArea from '@turf/area' +import { + featureCollection as turfFeatureCollection, + polygon as turfPolygon, + multiPolygon as turfMultiPolygon, + lineString as turfLineString, + multiLineString as turfMultiLineString, + point as turfPoint, + multiPoint as turfMultiPoint +} from '@turf/helpers' + +/** + * @typedef {import('geojson').Feature} Polygon + * @typedef {import('geojson').Feature} Line + * @typedef {import('geojson').Feature} Feature + * @typedef {import('geojson').FeatureCollection} FeatureCollection + */ + +/** + * Extend a LineString at endpoints AND intermediate vertices. + * For intermediate vertices on the polygon boundary, this creates small + * extensions that ensure polygon-splitter recognizes them as crossing points. + * + * @param {Feature} line + * @param {number} extendDist (distance to extend in Turf units) + */ +function extendLine (line, extendDist = 1, units = 'meters') { + const coords = line.geometry.coordinates + const result = [] + + // Extend start point backward + const startBearing = turfBearing(coords[1], coords[0]) + const newStart = turfDestination(coords[0], extendDist, startBearing, { units }) + result.push(newStart.geometry.coordinates) + + // Process each vertex + for (let i = 0; i < coords.length; i++) { + if (i > 0 && i < coords.length - 1) { + // Intermediate vertex: add extension past it (creates spike for boundary crossing) + const incomingBearing = turfBearing(coords[i - 1], coords[i]) + const pastPt = turfDestination(coords[i], extendDist, incomingBearing, { units }) + result.push(pastPt.geometry.coordinates) + } + + result.push(coords[i]) + } + + // Extend end point forward + const endBearing = turfBearing(coords[coords.length - 2], coords[coords.length - 1]) + const newEnd = turfDestination(coords[coords.length - 1], extendDist, endBearing, { units }) + result.push(newEnd.geometry.coordinates) + + return turfLineString(result) +} + +/** + * Split a polygon using a line. + * Only accepts splits that result in exactly two polygons. + * + * @param {Feature} polygon + * @param {Feature} line + * @returns {FeatureCollection|null} + */ +const splitPolygon = (polygon, line) => { + // Extend only start and end vertices + const extended = extendLine(line) // assume extendLine only touches start/end now + + let result + try { + result = polygonSplitter(polygon, extended) + } catch { + return null + } + + // Must result in exactly 2 polygons + let polygons = [] + if (result.geometry.type === 'MultiPolygon') { + if (result.geometry.coordinates.length !== 2) { + return null + } + polygons = result.geometry.coordinates.map(coords => turfPolygon(coords, polygon.properties)) + } else { + return null + } + + // Assign IDs & properties + const baseId = polygon.id ?? polygon.properties?.id ?? 'poly' + const features = polygons.map((poly, i) => + turfPolygon( + poly.geometry.coordinates, + { ...polygon.properties, id: baseId }, + { id: `${baseId}-${i + 1}` } + ) + ) + + return turfFeatureCollection(features) +} + +/** + * Convert a GeoJSON Feature or geometry-like object into a Turf geometry. + * + * @param {Object} featureOrGeom - Either a Feature with a `.geometry` property or a raw GeoJSON geometry object. + * @returns {Object} Turf geometry (Polygon, LineString, Point, etc.) + * + * @throws Will throw if the geometry type is not supported. + */ +const toTurfGeometry = (featureOrGeom) => { + const geom = featureOrGeom.geometry || featureOrGeom + + switch (geom.type) { + case 'Polygon': + return turfPolygon(geom.coordinates) + case 'MultiPolygon': + return turfMultiPolygon(geom.coordinates) + case 'LineString': + return turfLineString(geom.coordinates) + case 'MultiLineString': + return turfMultiLineString(geom.coordinates) + case 'Point': + return turfPoint(geom.coordinates) + case 'MultiPoint': + return turfMultiPoint(geom.coordinates) + default: + throw new Error(`Unsupported geometry type: ${geom.type}`) + } +} + +const haversine = ([lon1, lat1], [lon2, lat2]) => { + const toRad = deg => deg * Math.PI / 180 + const R = 6371000 // meters + const dLat = toRad(lat2 - lat1) + const dLon = toRad(lon2 - lon1) + const a = Math.sin(dLat / 2) ** 2 + Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon / 2) ** 2 + return 2 * R * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)) +} + +const isNewCoordinate = (coords, tolerance = 0.01) => { + // First coord + if (coords[0].length <= 1) { + return true + } + // Subsequent coordsmust be different + if (coords[0].length <= 3) { + for (let i = 0; i < coords[0].length; i++) { + for (let j = i + 1; j < coords[0].length; j++) { + if (haversine(coords[0][i], coords[0][j]) < tolerance) { + return false + } + } + } + } + return true +} + +const isNewLineCoordinate = (coords, tolerance = 0.01) => { + // First coord is always valid + if (coords.length <= 1) { + return true + } + // Check last two coords are different + if (coords.length >= 2) { + const last = coords[coords.length - 1] + const secondLast = coords[coords.length - 2] + if (haversine(last, secondLast) < tolerance) { + return false + } + } + return true +} + +const isValidLineClick = (coords) => { + // First coord is always valid + if (coords.length <= 1) { + return true + } + // Check that the new coordinate is different from the previous one + return isNewLineCoordinate(coords) +} + +const isValidClick = (coords) => { + // Less than 4 and new coordinates + if (coords[0].length <= 1 || isNewCoordinate(coords)) { + return true + } + + // Basic checks + if (!Array.isArray(coords) || coords.length < 4) { + return false + } + + // Check if ring is closed + const first = coords[0] + const last = coords[coords.length - 1] + const isClosed = first[0] === last[0] && first[1] === last[1] + if (!isClosed) { + return false + } + + // Create a turf polygon + const turfPoly = turfPolygon([coords]) + + // Check if geometry is valid (non-self-intersecting) + const valid = turfBooleanValid(turfPoly) + if (!valid) { + return false + } + + // Check if area is positive + const polyArea = turfArea(turfPoly) + if (polyArea <= 0) { + return false + } + + return true +} + +const spatialNavigate = (start, pixels, direction) => { + const quadrant = pixels.filter((p, i) => { + const offsetX = Math.abs(p[0] - start[0]) + const offsetY = Math.abs(p[1] - start[1]) + let isQuadrant = false + if (direction === 'ArrowUp') { + isQuadrant = p[1] <= start[1] && offsetY >= offsetX + } else if (direction === 'ArrowDown') { + isQuadrant = p[1] > start[1] && offsetY >= offsetX + } else if (direction === 'ArrowLeft') { + isQuadrant = p[0] <= start[0] && offsetY < offsetX + } else if (direction === 'ArrowRight') { + isQuadrant = p[0] > start[0] && offsetY < offsetX + } else { + isQuadrant = true + } + return isQuadrant && (JSON.stringify(p) !== JSON.stringify(start)) + }) + + if (!quadrant.length) { + quadrant.push(start) + } + + const pythagorean = (a, b) => Math.sqrt(Math.pow(a, 2) + Math.pow(b, 2)) + const distances = quadrant.map(p => pythagorean(Math.abs(start[0] - p[0]), Math.abs(start[1] - p[1]))) + const closest = quadrant[distances.indexOf(Math.min(...distances))] + return pixels.findIndex(i => JSON.stringify(i) === JSON.stringify(closest)) +} + +export { + toTurfGeometry, + splitPolygon, + extendLine, + isNewCoordinate, + isValidClick, + isValidLineClick, + spatialNavigate +} From d1a7fa65b114de5ccde2438042e8cf5cfdb5a8e6 Mon Sep 17 00:00:00 2001 From: Dan Leech Date: Thu, 2 Jul 2026 10:10:48 +0100 Subject: [PATCH 02/89] Maplibre undo stack fixes --- .../src/adapters/maplibre/modes/editVertex/touchHandlers.js | 4 +++- .../beta/draw/src/adapters/maplibre/modes/editVertexMode.js | 6 ++++++ .../beta/draw/src/adapters/openlayers/core/OLDrawManager.js | 4 ++-- 3 files changed, 11 insertions(+), 3 deletions(-) diff --git a/plugins/beta/draw/src/adapters/maplibre/modes/editVertex/touchHandlers.js b/plugins/beta/draw/src/adapters/maplibre/modes/editVertex/touchHandlers.js index cabce04e4..c039a6790 100644 --- a/plugins/beta/draw/src/adapters/maplibre/modes/editVertex/touchHandlers.js +++ b/plugins/beta/draw/src/adapters/maplibre/modes/editVertex/touchHandlers.js @@ -102,7 +102,9 @@ export const touchHandlers = { onTouchstart (state, e) { clearSnapState(getSnapInstance(this.map)) - const vertex = state.vertecies?.[state.selectedVertexIndex] + // Always get fresh vertex data in case coordinates changed during previous edits + const freshVertices = this.getVerticies(state.featureId) + const vertex = freshVertices?.[state.selectedVertexIndex] if (!vertex || !isOnSVG(e.target.parentNode)) { return } diff --git a/plugins/beta/draw/src/adapters/maplibre/modes/editVertexMode.js b/plugins/beta/draw/src/adapters/maplibre/modes/editVertexMode.js index b926f4ffb..831e561a5 100755 --- a/plugins/beta/draw/src/adapters/maplibre/modes/editVertexMode.js +++ b/plugins/beta/draw/src/adapters/maplibre/modes/editVertexMode.js @@ -36,6 +36,12 @@ export const EditVertexMode = { scale: options.scale ?? 1 }) + // Clear undo stack only on initial entry to edit mode for a feature + // Only clear if we're starting a new editing session (not already editing) + if (this.map._lastEditFeatureId !== state.featureId) { + this.map._undoStack?.clear() + this.map._lastEditFeatureId = state.featureId + } // Get feature type for later reference const feature = this.getFeature(state.featureId) diff --git a/plugins/beta/draw/src/adapters/openlayers/core/OLDrawManager.js b/plugins/beta/draw/src/adapters/openlayers/core/OLDrawManager.js index 32546b4eb..3b5d76c22 100644 --- a/plugins/beta/draw/src/adapters/openlayers/core/OLDrawManager.js +++ b/plugins/beta/draw/src/adapters/openlayers/core/OLDrawManager.js @@ -4,6 +4,8 @@ import { createUndoStack } from './undoStack.js' import { createStyles } from './styles.js' import { resolveColors } from '../utils/resolveColors.js' import { createSnapManager } from '../snap/snapManager.js' +import { createDrawMode } from '../draw/DrawMode.js' +import { createEditMode } from '../edit/EditMode.js' import { DEFAULTS } from '../defaults.js' /** @@ -82,10 +84,8 @@ export class OLDrawManager { const modeOptions = { ...options, snap: this.snap } if (modeName === 'draw_polygon' || modeName === 'draw_line') { - const { createDrawMode } = await import('../draw/DrawMode.js') this._modeInstance = createDrawMode({ map: this._map, manager: this, options: modeOptions }) } else if (modeName === 'edit_vertex') { - const { createEditMode } = await import('../edit/EditMode.js') this._modeInstance = createEditMode({ map: this._map, manager: this, options: modeOptions }) } else { // disabled — no mode instance needed From 5f4af6b005c99ef6494cccff9859f77077c62030 Mon Sep 17 00:00:00 2001 From: Dan Leech Date: Thu, 2 Jul 2026 10:21:42 +0100 Subject: [PATCH 03/89] Refactored to rduce function line count --- plugins/beta/draw/src/events.js | 165 ++++++++++++++------------------ 1 file changed, 71 insertions(+), 94 deletions(-) diff --git a/plugins/beta/draw/src/events.js b/plugins/beta/draw/src/events.js index 1df90c6f5..5e8224cd9 100644 --- a/plugins/beta/draw/src/events.js +++ b/plugins/beta/draw/src/events.js @@ -5,107 +5,84 @@ * draw.setSnapEnabled(), etc.) so this file is map-framework-agnostic. * All MapLibre / OL specifics live in the adapter. */ -export function attachEvents ({ pluginState, mapProvider, buttonConfig, eventBus }) { - const { drawDone, drawCancel, drawUndo, drawDeletePoint, drawSnap } = buttonConfig - const { draw } = mapProvider - const { dispatch, feature, tempFeature } = pluginState - - const resetState = () => { - dispatch({ type: 'SET_MODE', payload: null }) - dispatch({ type: 'SET_FEATURE', payload: { feature: null, tempFeature: null } }) - } - - const disableSnap = () => { - dispatch({ type: 'SET_SNAP', payload: false }) - draw.setSnapEnabled(false) - } - - const handleDone = () => { - disableSnap() - draw.done() - } - - const handleCancel = () => { - const mode = draw.getMode() - if (mode === 'edit_vertex' && tempFeature?.id) { - draw.add(feature) - } - disableSnap() - draw.cancel() - resetState() - eventBus.emit('draw:cancelled', feature) - } - - const handleUndo = () => { draw.undo() } - - const handleDeleteVertex = () => { draw.deleteVertex() } - - const handleSnap = () => { - const newSnapState = !pluginState.snap - dispatch({ type: 'TOGGLE_SNAP' }) - draw.setSnapEnabled(newSnapState) - } - - const onCreate = (geojsonFeature) => { - disableSnap() - resetState() - setTimeout(() => draw.changeMode('disabled'), 0) - eventBus.emit('draw:created', geojsonFeature) - } - const onEditFinish = (geojsonFeature) => { - disableSnap() - resetState() - setTimeout(() => draw.changeMode('disabled'), 0) - eventBus.emit('draw:edited', geojsonFeature) +function createHandlers ({ pluginState, mapProvider, eventBus, resetState, disableSnap }) { + const { draw } = mapProvider + const { feature, tempFeature } = pluginState + return { + handleDone: () => { disableSnap(); draw.done() }, + handleCancel: () => { + const mode = draw.getMode() + if (mode === 'edit_vertex' && tempFeature?.id) { draw.add(feature) } + disableSnap(); draw.cancel(); resetState() + eventBus.emit('draw:cancelled', feature) + }, + handleUndo: () => draw.undo(), + handleDeleteVertex: () => draw.deleteVertex(), + handleSnap: () => { + pluginState.dispatch({ type: 'TOGGLE_SNAP' }) + draw.setSnapEnabled(!pluginState.snap) + }, + onCreate: (f) => { disableSnap(); resetState(); setTimeout(() => draw.changeMode('disabled'), 0); eventBus.emit('draw:created', f) }, + onEditFinish: (f) => { disableSnap(); resetState(); setTimeout(() => draw.changeMode('disabled'), 0); eventBus.emit('draw:edited', f) }, + onCancel: () => {}, + onVertexSelection: (e) => { pluginState.dispatch({ type: 'SET_SELECTED_VERTEX_INDEX', payload: e }); eventBus.emit('draw:vertexselection', e) }, + onVertexChange: (e) => { pluginState.dispatch({ type: 'SET_SELECTED_VERTEX_INDEX', payload: { index: -1, numVertices: e.numVertices } }) }, + onUndoChange: (l) => { pluginState.dispatch({ type: 'SET_UNDO_STACK_LENGTH', payload: l }) }, + onUpdate: (f) => { eventBus.emit('draw:updated', f) } } +} - const onCancel = () => {} +function attachButtonHandlers (buttonConfig, handlers) { + const { drawDone, drawCancel, drawUndo, drawDeletePoint, drawSnap } = buttonConfig + drawDone.onClick = handlers.handleDone + drawCancel.onClick = handlers.handleCancel + drawUndo.onClick = handlers.handleUndo + if (drawDeletePoint) { drawDeletePoint.onClick = handlers.handleDeleteVertex } + if (drawSnap) { drawSnap.onClick = handlers.handleSnap } +} - const onVertexSelection = (e) => { - dispatch({ type: 'SET_SELECTED_VERTEX_INDEX', payload: e }) - eventBus.emit('draw:vertexselection', e) - } +function attachDrawEvents (draw, handlers) { + draw.on('create', handlers.onCreate) + draw.on('editfinish', handlers.onEditFinish) + draw.on('cancel', handlers.onCancel) + draw.on('vertexselection', handlers.onVertexSelection) + draw.on('vertexchange', handlers.onVertexChange) + draw.on('undochange', handlers.onUndoChange) + draw.on('update', handlers.onUpdate) +} - const onVertexChange = (e) => { - dispatch({ type: 'SET_SELECTED_VERTEX_INDEX', payload: { index: -1, numVertices: e.numVertices } }) - } +function detachButtonHandlers (buttonConfig) { + const { drawDone, drawCancel, drawUndo, drawDeletePoint, drawSnap } = buttonConfig + drawDone.onClick = null + drawCancel.onClick = null + drawUndo.onClick = null + if (drawDeletePoint) { drawDeletePoint.onClick = null } + if (drawSnap) { drawSnap.onClick = null } +} - const onUndoChange = (length) => { - dispatch({ type: 'SET_UNDO_STACK_LENGTH', payload: length }) - } +function detachDrawEvents (draw, handlers) { + draw.off('create', handlers.onCreate) + draw.off('editfinish', handlers.onEditFinish) + draw.off('cancel', handlers.onCancel) + draw.off('vertexselection', handlers.onVertexSelection) + draw.off('vertexchange', handlers.onVertexChange) + draw.off('undochange', handlers.onUndoChange) + draw.off('update', handlers.onUpdate) +} - const onUpdate = (geojsonFeature) => { - eventBus.emit('draw:updated', geojsonFeature) +export function attachEvents ({ pluginState, mapProvider, buttonConfig, eventBus }) { + const { draw } = mapProvider + const resetState = () => { + pluginState.dispatch({ type: 'SET_MODE', payload: null }) + pluginState.dispatch({ type: 'SET_FEATURE', payload: { feature: null, tempFeature: null } }) } - - drawDone.onClick = handleDone - drawCancel.onClick = handleCancel - drawUndo.onClick = handleUndo - if (drawDeletePoint) { drawDeletePoint.onClick = handleDeleteVertex } - if (drawSnap) { drawSnap.onClick = handleSnap } - - draw.on('create', onCreate) - draw.on('editfinish', onEditFinish) - draw.on('cancel', onCancel) - draw.on('vertexselection', onVertexSelection) - draw.on('vertexchange', onVertexChange) - draw.on('undochange', onUndoChange) - draw.on('update', onUpdate) - - return () => { - drawDone.onClick = null - drawCancel.onClick = null - drawUndo.onClick = null - if (drawDeletePoint) { drawDeletePoint.onClick = null } - if (drawSnap) { drawSnap.onClick = null } - - draw.off('create', onCreate) - draw.off('editfinish', onEditFinish) - draw.off('cancel', onCancel) - draw.off('vertexselection', onVertexSelection) - draw.off('vertexchange', onVertexChange) - draw.off('undochange', onUndoChange) - draw.off('update', onUpdate) + const disableSnap = () => { + pluginState.dispatch({ type: 'SET_SNAP', payload: false }) + draw.setSnapEnabled(false) } + const handlers = createHandlers({ pluginState, mapProvider, eventBus, resetState, disableSnap }) + attachButtonHandlers(buttonConfig, handlers) + attachDrawEvents(draw, handlers) + return () => { detachButtonHandlers(buttonConfig); detachDrawEvents(draw, handlers) } } From 3ec4ab969f10d4e57e324d79227edf182780ca3e Mon Sep 17 00:00:00 2001 From: Dan Leech Date: Thu, 2 Jul 2026 10:37:10 +0100 Subject: [PATCH 04/89] Colour default consistency fixes between adapters --- .../beta/draw/src/adapters/maplibre/mapboxSnap.js | 2 +- plugins/beta/draw/src/adapters/maplibre/styles.js | 2 +- plugins/beta/draw/src/defaults.js | 12 ++++++------ 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/plugins/beta/draw/src/adapters/maplibre/mapboxSnap.js b/plugins/beta/draw/src/adapters/maplibre/mapboxSnap.js index 4f616df74..90cd06084 100644 --- a/plugins/beta/draw/src/adapters/maplibre/mapboxSnap.js +++ b/plugins/beta/draw/src/adapters/maplibre/mapboxSnap.js @@ -284,7 +284,7 @@ export function initMapLibreSnap (map, draw, snapOptions = {}) { id: SNAP_HELPER_LAYER, type: 'fill', source: SNAP_HELPER_LAYER, - paint: { 'fill-color': ['get', 'color'], 'fill-opacity': 0.6 }, + paint: { 'fill-color': ['get', 'color'] }, layout: { visibility: map._snapInstance?.status ? 'visible' : 'none' } }) } diff --git a/plugins/beta/draw/src/adapters/maplibre/styles.js b/plugins/beta/draw/src/adapters/maplibre/styles.js index eaad6faf2..5ff1fba44 100755 --- a/plugins/beta/draw/src/adapters/maplibre/styles.js +++ b/plugins/beta/draw/src/adapters/maplibre/styles.js @@ -34,7 +34,7 @@ const fillActive = (editStrokeColor) => ({ id: 'fill-active', type: 'fill', filter: ['all', ['==', '$type', 'Polygon'], ['==', 'active', 'true']], - paint: { 'fill-color': editStrokeColor, 'fill-opacity': 0.1 } + paint: { 'fill-color': editStrokeColor } }) const strokeActive = (editStrokeColor) => ({ diff --git a/plugins/beta/draw/src/defaults.js b/plugins/beta/draw/src/defaults.js index 002e5f7ea..1b03206b7 100644 --- a/plugins/beta/draw/src/defaults.js +++ b/plugins/beta/draw/src/defaults.js @@ -1,13 +1,13 @@ export const DEFAULTS = { - editStroke: { light: '#1a65a6', dark: '#ffffff' }, - editVertex: { light: '#1a65a6', dark: '#ffffff' }, - editMidpoint: { light: '#1a65a6', dark: '#ffffff' }, + editStroke: { light: 'rgba(29,112,184,1)', dark: '#ffffff' }, + editVertex: { light: 'rgba(29,112,184,1)', dark: '#ffffff' }, + editMidpoint: { light: 'rgba(29,112,184,1)', dark: '#ffffff' }, editHalo: { light: '#ffffff', dark: 'rgba(11,12,12,1)' }, - editActive: { light: '#000000', dark: '#ffffff' }, + editActive: { light: 'rgba(11,12,12,1)', dark: '#ffffff' }, splitInvalid: { light: 'rgba(29,112,184,1)', dark: 'rgba(29,112,184,1)' }, splitValid: { light: 'rgba(29,112,184,1)', dark: 'rgba(29,112,184,1)' }, - shapeStroke: '#1a65a6', - shapeFill: 'rgba(26,101,166,0.1)', + shapeStroke: 'rgba(212,53,28,1)', + shapeFill: 'rgba(212,53,28,0.5)', strokeWidth: 2, snapVertex: 'rgba(212,53,28,0.5)', snapMidpoint: 'rgba(40,161,151,1)', From b016c10298826451f654134b464f6cd1b2020771 Mon Sep 17 00:00:00 2001 From: Dan Leech Date: Thu, 2 Jul 2026 12:12:51 +0100 Subject: [PATCH 05/89] Colour fixes --- .../beta/draw/src/adapters/maplibre/styles.js | 24 +++--- .../maplibre/utils/mouseCursorIndicator.js | 76 +++++++++++++++++++ .../src/adapters/openlayers/core/styles.js | 4 +- .../src/adapters/openlayers/draw/DrawMode.js | 11 ++- .../openlayers/utils/resolveColors.js | 30 +++----- plugins/beta/draw/src/defaults.js | 18 +++-- .../beta/draw/src/utils/getColorForScheme.js | 30 ++++++++ 7 files changed, 154 insertions(+), 39 deletions(-) create mode 100644 plugins/beta/draw/src/adapters/maplibre/utils/mouseCursorIndicator.js create mode 100644 plugins/beta/draw/src/utils/getColorForScheme.js diff --git a/plugins/beta/draw/src/adapters/maplibre/styles.js b/plugins/beta/draw/src/adapters/maplibre/styles.js index 5ff1fba44..3eeeb2337 100755 --- a/plugins/beta/draw/src/adapters/maplibre/styles.js +++ b/plugins/beta/draw/src/adapters/maplibre/styles.js @@ -1,5 +1,6 @@ // styles.js import { DEFAULTS } from './defaults.js' +import { getColorForScheme } from '../../utils/getColorForScheme.js' const getColorScheme = (mapStyle) => mapStyle.mapColorScheme ?? 'light' @@ -29,12 +30,12 @@ const strokeInactive = (mapStyle) => ({ } }) -// Active lines and fills -const fillActive = (editStrokeColor) => ({ +// Active lines and fills (sketch during drawing) +const fillActive = (editFillColor) => ({ id: 'fill-active', type: 'fill', filter: ['all', ['==', '$type', 'Polygon'], ['==', 'active', 'true']], - paint: { 'fill-color': editStrokeColor } + paint: { 'fill-color': editFillColor } }) const strokeActive = (editStrokeColor) => ({ @@ -140,17 +141,18 @@ const touchVertexIndicator = () => ({ const createDrawStyles = (mapStyle) => { const scheme = getColorScheme(mapStyle) - const editStrokeColor = DEFAULTS.editStroke[scheme] - const editVertexColor = DEFAULTS.editVertex[scheme] - const editMidpointColor = DEFAULTS.editMidpoint[scheme] - const editHaloColor = DEFAULTS.editHalo[scheme] - const editActiveColor = DEFAULTS.editActive[scheme] - const splitInvalidColor = DEFAULTS.splitInvalid[scheme] - const splitValidColor = DEFAULTS.splitValid[scheme] + const editStrokeColor = getColorForScheme(DEFAULTS.editStroke, scheme) + const editFillColor = getColorForScheme(DEFAULTS.editFill, scheme) + const editVertexColor = getColorForScheme(DEFAULTS.editVertex, scheme) + const editMidpointColor = getColorForScheme(DEFAULTS.editMidpoint, scheme) + const editHaloColor = getColorForScheme(DEFAULTS.editHalo, scheme) + const editActiveColor = getColorForScheme(DEFAULTS.editActive, scheme) + const splitInvalidColor = getColorForScheme(DEFAULTS.splitInvalid, scheme) + const splitValidColor = getColorForScheme(DEFAULTS.splitValid, scheme) return [ fillInactive(mapStyle), - fillActive(editStrokeColor), + fillActive(editFillColor), strokeActive(editStrokeColor), strokeInactive(mapStyle), drawInvalidSplitter(splitInvalidColor), diff --git a/plugins/beta/draw/src/adapters/maplibre/utils/mouseCursorIndicator.js b/plugins/beta/draw/src/adapters/maplibre/utils/mouseCursorIndicator.js new file mode 100644 index 000000000..ffbaae65e --- /dev/null +++ b/plugins/beta/draw/src/adapters/maplibre/utils/mouseCursorIndicator.js @@ -0,0 +1,76 @@ +const CURSOR_LAYER_ID = 'draw-mouse-cursor' +const CURSOR_SOURCE_ID = 'draw-mouse-cursor-source' + +export const createMouseCursorIndicator = (map) => { + let isActive = false + + const addLayer = () => { + if (map.getLayer(CURSOR_LAYER_ID)) { + return + } + + if (!map.getSource(CURSOR_SOURCE_ID)) { + map.addSource(CURSOR_SOURCE_ID, { + type: 'geojson', + data: { type: 'FeatureCollection', features: [] } + }) + } + + map.addLayer({ + id: CURSOR_LAYER_ID, + type: 'circle', + source: CURSOR_SOURCE_ID, + paint: { + 'circle-radius': 3, + 'circle-color': '#1a65a6', + 'circle-opacity': 0.8 + } + }) + } + + const removeLayer = () => { + if (map.getLayer(CURSOR_LAYER_ID)) { + map.removeLayer(CURSOR_LAYER_ID) + } + if (map.getSource(CURSOR_SOURCE_ID)) { + map.removeSource(CURSOR_SOURCE_ID) + } + } + + const updateCursor = (lngLat) => { + const source = map.getSource(CURSOR_SOURCE_ID) + if (source) { + source.setData({ + type: 'FeatureCollection', + features: [{ + type: 'Feature', + geometry: { type: 'Point', coordinates: [lngLat.lng, lngLat.lat] } + }] + }) + } + } + + return { + activate () { + if (isActive) { + return + } + isActive = true + addLayer() + }, + + deactivate () { + if (!isActive) { + return + } + isActive = false + removeLayer() + }, + + updateFromEvent (e) { + if (isActive) { + updateCursor(e.lngLat) + } + } + } +} diff --git a/plugins/beta/draw/src/adapters/openlayers/core/styles.js b/plugins/beta/draw/src/adapters/openlayers/core/styles.js index d00b246b0..dbbb6ed0c 100644 --- a/plugins/beta/draw/src/adapters/openlayers/core/styles.js +++ b/plugins/beta/draw/src/adapters/openlayers/core/styles.js @@ -56,12 +56,12 @@ export const createStyles = (colors) => { const editFeatureStyle = new Style({ stroke: new Stroke({ color: colors.editStroke, width: 2 }), - fill: new Fill({ color: colors.shapeFill }) + fill: new Fill({ color: colors.editFill }) }) const sketchLineStyle = new Style({ stroke: new Stroke({ color: colors.editStroke, width: 2 }), - fill: new Fill({ color: colors.shapeFill }) + fill: new Fill({ color: colors.editFill }) }) const sketchPointStyle = new Style({ diff --git a/plugins/beta/draw/src/adapters/openlayers/draw/DrawMode.js b/plugins/beta/draw/src/adapters/openlayers/draw/DrawMode.js index fc12e4542..c0e8bd073 100644 --- a/plugins/beta/draw/src/adapters/openlayers/draw/DrawMode.js +++ b/plugins/beta/draw/src/adapters/openlayers/draw/DrawMode.js @@ -53,15 +53,23 @@ export const createDrawMode = ({ map, manager, options }) => { } = options let sketchFeature = null + let currentSketchStyle = manager.styles.createSketchStyle() const drawInteraction = new Draw({ type: geometryType, - style: manager.styles.createSketchStyle(), + style: (feature) => currentSketchStyle(feature), stopClick: true, snapTolerance: SNAP_TOLERANCE_PX, condition: buildCondition(map, geometryType, () => sketchFeature) }) map.addInteraction(drawInteraction) + + // Update sketch style when map style changes + const onStylesChanged = () => { + currentSketchStyle = manager.styles.createSketchStyle() + drawInteraction.overlay_.changed() + } + manager.on('styleschanged', onStylesChanged) // OL internal: overlay_ is the private VectorLayer used for the sketch geometry. // updateWhileAnimating_ forces per-frame redraws during view animations (keyboard pan). // Without this, geom.setCoordinates() calls are ignored while the ANIMATING hint is set. @@ -113,6 +121,7 @@ export const createDrawMode = ({ map, manager, options }) => { cancel () { drawInteraction.abortDrawing() }, undo () { drawInteraction.removeLastPoint(); updateVertexCount() }, destroy () { + manager.off('styleschanged', onStylesChanged) input.destroy() map.removeInteraction(drawInteraction) sketchFeature = null diff --git a/plugins/beta/draw/src/adapters/openlayers/utils/resolveColors.js b/plugins/beta/draw/src/adapters/openlayers/utils/resolveColors.js index 2c3dffe50..5712b4ef8 100644 --- a/plugins/beta/draw/src/adapters/openlayers/utils/resolveColors.js +++ b/plugins/beta/draw/src/adapters/openlayers/utils/resolveColors.js @@ -1,12 +1,5 @@ import { DEFAULTS } from '../defaults.js' - -const resolveVariant = (value, scheme, styleId) => { - if (typeof value !== 'object' || value === null) { return value } - if (styleId && value[styleId] !== undefined) { return value[styleId] } - if (value[scheme] !== undefined) { return value[scheme] } - if (value.light !== undefined) { return value.light } - return Object.values(value)[0] -} +import { getColorForScheme } from '../../../utils/getColorForScheme.js' /** * Resolve all draw-ol colors for the given map style and plugin config overrides. @@ -21,19 +14,20 @@ const resolveVariant = (value, scheme, styleId) => { export const resolveColors = (mapStyle, pluginConfig = {}) => { const scheme = mapStyle?.mapColorScheme ?? 'light' const styleId = mapStyle?.id ?? null - const r = (key) => resolveVariant(pluginConfig[key] ?? DEFAULTS[key], scheme, styleId) + const resolveColor = (key) => getColorForScheme(pluginConfig[key] ?? DEFAULTS[key], scheme, styleId) return { - editStroke: r('editStroke'), - editVertex: r('editVertex'), - editMidpoint: r('editMidpoint'), - editActive: r('editActive'), - editHalo: r('editHalo'), - shapeStroke: r('shapeStroke'), + editStroke: resolveColor('editStroke'), + editFill: resolveColor('editFill'), + editVertex: resolveColor('editVertex'), + editMidpoint: resolveColor('editMidpoint'), + editActive: resolveColor('editActive'), + editHalo: resolveColor('editHalo'), + shapeStroke: resolveColor('shapeStroke'), strokeWidth: pluginConfig.strokeWidth ?? DEFAULTS.strokeWidth, - shapeFill: r('shapeFill'), - snapVertex: r('snapVertex'), - snapEdge: r('snapEdge'), + shapeFill: resolveColor('shapeFill'), + snapVertex: resolveColor('snapVertex'), + snapEdge: resolveColor('snapEdge'), mapStyleId: styleId } } diff --git a/plugins/beta/draw/src/defaults.js b/plugins/beta/draw/src/defaults.js index 1b03206b7..f33c8afd7 100644 --- a/plugins/beta/draw/src/defaults.js +++ b/plugins/beta/draw/src/defaults.js @@ -1,11 +1,15 @@ +const PRIMARY_LIGHT = 'rgba(29,112,184,1)' +const PRIMARY_DARK = '#ffffff' + export const DEFAULTS = { - editStroke: { light: 'rgba(29,112,184,1)', dark: '#ffffff' }, - editVertex: { light: 'rgba(29,112,184,1)', dark: '#ffffff' }, - editMidpoint: { light: 'rgba(29,112,184,1)', dark: '#ffffff' }, - editHalo: { light: '#ffffff', dark: 'rgba(11,12,12,1)' }, - editActive: { light: 'rgba(11,12,12,1)', dark: '#ffffff' }, - splitInvalid: { light: 'rgba(29,112,184,1)', dark: 'rgba(29,112,184,1)' }, - splitValid: { light: 'rgba(29,112,184,1)', dark: 'rgba(29,112,184,1)' }, + editStroke: { light: PRIMARY_LIGHT, dark: PRIMARY_DARK }, + editFill: { light: 'rgba(29,112,184,0.1)', dark: 'rgba(255,255,255,0.1)' }, + editVertex: { light: PRIMARY_LIGHT, dark: PRIMARY_DARK }, + editMidpoint: { light: PRIMARY_LIGHT, dark: PRIMARY_DARK }, + editHalo: { light: PRIMARY_DARK, dark: 'rgba(11,12,12,1)' }, + editActive: { light: 'rgba(11,12,12,1)', dark: PRIMARY_DARK }, + splitInvalid: PRIMARY_LIGHT, + splitValid: PRIMARY_LIGHT, shapeStroke: 'rgba(212,53,28,1)', shapeFill: 'rgba(212,53,28,0.5)', strokeWidth: 2, diff --git a/plugins/beta/draw/src/utils/getColorForScheme.js b/plugins/beta/draw/src/utils/getColorForScheme.js new file mode 100644 index 000000000..ca43fef2e --- /dev/null +++ b/plugins/beta/draw/src/utils/getColorForScheme.js @@ -0,0 +1,30 @@ +/** + * Resolve a color value which may be either a string (same for all schemes) + * or an object with scheme/style variants. + * + * Resolution order for objects: + * 1. Exact style ID match (e.g., { outdoor: '...', dark: '...' }) + * 2. Scheme match (e.g., { light: '...', dark: '...' }) + * 3. Fallback to 'light' property + * 4. First value in object + * + * @param {string|object} colorValue - Color as string or variant object + * @param {string} scheme - Current scheme ('light' or 'dark') + * @param {string|null} styleId - Map style ID for per-style customization + * @returns {string} Resolved color value + */ +export const getColorForScheme = (colorValue, scheme, styleId = null) => { + if (typeof colorValue !== 'object' || colorValue === null) { + return colorValue + } + if (styleId && colorValue[styleId] !== undefined) { + return colorValue[styleId] + } + if (colorValue[scheme] !== undefined) { + return colorValue[scheme] + } + if (colorValue.light !== undefined) { + return colorValue.light + } + return Object.values(colorValue)[0] +} From b700ce701043fdbe527283eaa879969d36bbe7b0 Mon Sep 17 00:00:00 2001 From: Dan Leech Date: Thu, 2 Jul 2026 13:16:48 +0100 Subject: [PATCH 06/89] =?UTF-8?q?Centralize=20touch=20target=20generation?= =?UTF-8?q?=20from=20config=20=E2=80=94=20both=20adapters=20now=20use=20sh?= =?UTF-8?q?ared=20utility?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Moved touch target SVG generation out of individual adapters into a centralized utility (/src/utils/touchTarget.js) that generates the SVG dynamically based on SIZES config. Both adapters now import and use the same createTouchTarget, applyTouchTargetColors, showTouchTarget, hideTouchTarget, and isOnTouchTarget functions. This eliminates duplicate SVG strings and makes touch target sizing configuration-driven, allowing coordinated changes across frameworks by updating SIZES.touchTargetSize or SIZES.touchIndicatorRadius in a single location. Changes: - Created /src/utils/touchTarget.js with dynamic SVG generation - Updated maplibre touchHandlers.js to use shared utility - Updated openlayers touchTarget.js to re-export shared utility - Fixed selector in mapboxDraw.js to use data-im-draw-touch-target Co-Authored-By: Claude Haiku 4.5 --- .../adapters/maplibre/MaplibreDrawAdapter.js | 16 +--- .../draw/src/adapters/maplibre/defaults.js | 2 +- .../draw/src/adapters/maplibre/mapboxDraw.js | 5 +- .../draw/src/adapters/maplibre/mapboxSnap.js | 4 +- .../modes/editVertex/touchHandlers.js | 31 +++--- .../beta/draw/src/adapters/maplibre/styles.js | 59 ++++++------ .../adapters/openlayers/core/OLDrawManager.js | 4 +- .../src/adapters/openlayers/core/styles.js | 9 +- .../draw/src/adapters/openlayers/defaults.js | 2 +- .../src/adapters/openlayers/draw/DrawMode.js | 5 +- .../openlayers/edit/keyboardHandler.js | 2 +- .../utils/flattenStyleProperties.js | 47 --------- .../openlayers/utils/resolveColors.js | 8 +- .../src/adapters/openlayers/utils/spatial.js | 24 ----- .../adapters/openlayers/utils/touchTarget.js | 69 ++------------ plugins/beta/draw/src/defaults.js | 32 +++++-- plugins/beta/draw/src/utils/eventBus.js | 29 ++++++ plugins/beta/draw/src/utils/touchTarget.js | 95 +++++++++++++++++++ 18 files changed, 221 insertions(+), 222 deletions(-) delete mode 100644 plugins/beta/draw/src/adapters/openlayers/utils/flattenStyleProperties.js delete mode 100644 plugins/beta/draw/src/adapters/openlayers/utils/spatial.js create mode 100644 plugins/beta/draw/src/utils/eventBus.js create mode 100644 plugins/beta/draw/src/utils/touchTarget.js diff --git a/plugins/beta/draw/src/adapters/maplibre/MaplibreDrawAdapter.js b/plugins/beta/draw/src/adapters/maplibre/MaplibreDrawAdapter.js index b27c5d288..c67b858dd 100644 --- a/plugins/beta/draw/src/adapters/maplibre/MaplibreDrawAdapter.js +++ b/plugins/beta/draw/src/adapters/maplibre/MaplibreDrawAdapter.js @@ -1,20 +1,6 @@ import { createMapboxDraw } from './mapboxDraw.js' import { getSnapInstance, clearSnapState, clearSnapIndicator } from './utils/snapHelpers.js' - -const createEventBus = () => { - const listeners = new Map() - return { - on (type, handler) { - if (!listeners.has(type)) { listeners.set(type, new Set()) } - listeners.get(type).add(handler) - }, - off (type, handler) { listeners.get(type)?.delete(handler) }, - emit (type, ...args) { - const handlers = listeners.get(type) - if (handlers) { [...handlers].forEach(h => h(...args)) } - } - } -} +import { createEventBus } from '../../utils/eventBus.js' /** * Draw adapter for MapLibre GL. diff --git a/plugins/beta/draw/src/adapters/maplibre/defaults.js b/plugins/beta/draw/src/adapters/maplibre/defaults.js index bca89f53e..4f3f12002 100644 --- a/plugins/beta/draw/src/adapters/maplibre/defaults.js +++ b/plugins/beta/draw/src/adapters/maplibre/defaults.js @@ -1 +1 @@ -export { DEFAULTS } from '../../defaults.js' +export { COLORS, SIZES, TOLERANCES } from '../../defaults.js' diff --git a/plugins/beta/draw/src/adapters/maplibre/mapboxDraw.js b/plugins/beta/draw/src/adapters/maplibre/mapboxDraw.js index b577ac7c3..90eaa4494 100755 --- a/plugins/beta/draw/src/adapters/maplibre/mapboxDraw.js +++ b/plugins/beta/draw/src/adapters/maplibre/mapboxDraw.js @@ -7,6 +7,7 @@ import { createDrawStyles, updateDrawStyles } from './styles.js' import { initMapLibreSnap } from './mapboxSnap.js' import { createUndoStack } from './undoStack.js' import { applyTouchVertexColors } from './modes/editVertex/touchHandlers.js' +import { TOLERANCES } from './defaults.js' /** * Creates and manages a MapLibre/Mapbox Draw control instance configured for polygon editing. @@ -113,7 +114,7 @@ export const createMapboxDraw = ({ mapStyle, mapProvider, events, eventBus, snap // Start with status: false to match initial snap disabled state initMapLibreSnap(map, draw, { layers: snapLayers, - radius: 10, + radius: TOLERANCES.snapRadius, rules: ['vertex', 'edge'] }) @@ -122,7 +123,7 @@ export const createMapboxDraw = ({ mapStyle, mapProvider, events, eventBus, snap map._drawCurrentMapStyle = e map.once('idle', () => { updateDrawStyles(map, e) - const svg = map._drawEditContainer?.querySelector('[data-touch-vertex-target]') + const svg = map._drawEditContainer?.querySelector('[data-im-draw-touch-target]') applyTouchVertexColors(svg, e) }) } diff --git a/plugins/beta/draw/src/adapters/maplibre/mapboxSnap.js b/plugins/beta/draw/src/adapters/maplibre/mapboxSnap.js index 90cd06084..cccd3a11a 100644 --- a/plugins/beta/draw/src/adapters/maplibre/mapboxSnap.js +++ b/plugins/beta/draw/src/adapters/maplibre/mapboxSnap.js @@ -1,6 +1,6 @@ import MapboxSnap from 'mapbox-gl-snap/dist/esm/MapboxSnap.js' import { polygon, lineString } from '@turf/helpers' -import { DEFAULTS } from './defaults.js' +import { COLORS } from './defaults.js' const SNAP_HELPER_LAYER = 'snap-helper-circle' @@ -184,7 +184,7 @@ export function initMapLibreSnap (map, draw, snapOptions = {}) { } = snapOptions // Apply global patches to MapboxSnap prototype - applyMapboxSnapPatches({ vertex: DEFAULTS.snapVertex, midpoint: DEFAULTS.snapMidpoint, edge: DEFAULTS.snapEdge, ...colors }) + applyMapboxSnapPatches({ vertex: COLORS.snapVertex, midpoint: COLORS.snapMidpoint, edge: COLORS.snapEdge, ...colors }) // Clean up old snap instance's source and layer function cleanupOldSnap () { diff --git a/plugins/beta/draw/src/adapters/maplibre/modes/editVertex/touchHandlers.js b/plugins/beta/draw/src/adapters/maplibre/modes/editVertex/touchHandlers.js index c039a6790..e4848b832 100644 --- a/plugins/beta/draw/src/adapters/maplibre/modes/editVertex/touchHandlers.js +++ b/plugins/beta/draw/src/adapters/maplibre/modes/editVertex/touchHandlers.js @@ -4,30 +4,25 @@ import { } from '../../utils/snapHelpers.js' import { coordPathToFlatIndex } from './geometryHelpers.js' import { isOnSVG } from './helpers.js' +import { createTouchTarget, applyTouchTargetColors } from '../../../../utils/touchTarget.js' +import { COLORS } from '../../defaults.js' +import { getColorForScheme } from '../../../../utils/getColorForScheme.js' -const touchVertexTarget = ` - -` - -export const applyTouchVertexColors = (el, mapStyle) => { +const applyTouchVertexColors = (el, mapStyle) => { if (!el) { return } - const dark = mapStyle?.mapColorScheme === 'dark' - el.style.setProperty('--touch-fill', dark ? '#ffffff' : '#000000') - el.style.setProperty('--touch-gfx', dark ? '#000000' : '#ffffff') + const scheme = mapStyle?.mapColorScheme ?? 'light' + const colors = { + editActive: getColorForScheme(COLORS.editActive, scheme), + editHalo: getColorForScheme(COLORS.editHalo, scheme), + editVertex: getColorForScheme(COLORS.editVertex, scheme) + } + applyTouchTargetColors(el, colors) } export const touchHandlers = { addTouchVertexTarget (state) { - let el = state.container.querySelector('[data-touch-vertex-target]') - if (!el) { - state.container.insertAdjacentHTML('beforeend', touchVertexTarget) - el = state.container.querySelector('[data-touch-vertex-target]') - } - state.touchVertexTarget = el - applyTouchVertexColors(el, this.map._drawCurrentMapStyle) + state.touchVertexTarget = createTouchTarget(state.container) + applyTouchVertexColors(state.touchVertexTarget, this.map._drawCurrentMapStyle) }, updateTouchVertexTarget (state, point) { diff --git a/plugins/beta/draw/src/adapters/maplibre/styles.js b/plugins/beta/draw/src/adapters/maplibre/styles.js index 3eeeb2337..791a23f92 100755 --- a/plugins/beta/draw/src/adapters/maplibre/styles.js +++ b/plugins/beta/draw/src/adapters/maplibre/styles.js @@ -1,5 +1,5 @@ // styles.js -import { DEFAULTS } from './defaults.js' +import { COLORS, SIZES } from './defaults.js' import { getColorForScheme } from '../../utils/getColorForScheme.js' const getColorScheme = (mapStyle) => mapStyle.mapColorScheme ?? 'light' @@ -8,7 +8,7 @@ const getUserProp = (mapStyle, prop, defaultsKey = prop) => [ 'coalesce', ['get', `user_${prop}${mapStyle.id.charAt(0).toUpperCase() + mapStyle.id.slice(1)}`], ['get', `user_${prop}`], - DEFAULTS[defaultsKey] + COLORS[defaultsKey] ] // Inactive lines and fills @@ -26,7 +26,7 @@ const strokeInactive = (mapStyle) => ({ layout: { 'line-cap': 'round', 'line-join': 'round' }, paint: { 'line-color': getUserProp(mapStyle, 'stroke', 'shapeStroke'), - 'line-width': getUserProp(mapStyle, 'strokeWidth') + 'line-width': SIZES.strokeWidth } }) @@ -82,47 +82,47 @@ const drawPreviewLine = (editStrokeColor) => ({ }) // Vertex layers -const vertex = (editVertexColor) => ({ +const vertex = (editVertexColor, vertexRadius) => ({ id: 'vertex', type: 'circle', filter: ['all', ['==', '$type', 'Point'], ['==', 'meta', 'vertex']], - paint: { 'circle-radius': 6, 'circle-color': editVertexColor } + paint: { 'circle-radius': vertexRadius, 'circle-color': editVertexColor } }) -const vertexHalo = (editHaloColor, editActiveColor) => ({ +const vertexHalo = (editHaloColor, editActiveColor, vertexHaloRadius) => ({ id: 'vertex-halo', type: 'circle', filter: ['all', ['==', '$type', 'Point'], ['==', 'meta', 'vertex'], ['==', 'active', 'true']], - paint: { 'circle-radius': 8, 'circle-stroke-width': 3, 'circle-color': editHaloColor, 'circle-stroke-color': editActiveColor } + paint: { 'circle-radius': vertexHaloRadius, 'circle-stroke-width': 3, 'circle-color': editHaloColor, 'circle-stroke-color': editActiveColor } }) -const vertexActive = (editVertexColor) => ({ +const vertexActive = (editVertexColor, vertexRadius) => ({ id: 'vertex-active', type: 'circle', filter: ['all', ['==', '$type', 'Point'], ['==', 'meta', 'vertex'], ['==', 'active', 'true']], - paint: { 'circle-radius': 6, 'circle-color': editVertexColor } + paint: { 'circle-radius': vertexRadius, 'circle-color': editVertexColor } }) // Midpoints -const midpoint = (editMidpointColor) => ({ +const midpoint = (editMidpointColor, midpointRadius) => ({ id: 'midpoint', type: 'circle', filter: ['all', ['==', '$type', 'Point'], ['==', 'meta', 'midpoint']], - paint: { 'circle-radius': 4, 'circle-color': editMidpointColor } + paint: { 'circle-radius': midpointRadius, 'circle-color': editMidpointColor } }) -const midpointHalo = (editHaloColor, editActiveColor) => ({ +const midpointHalo = (editHaloColor, editActiveColor, vertexHaloRadius) => ({ id: 'midpoint-halo', type: 'circle', filter: ['all', ['==', '$type', 'Point'], ['==', 'meta', 'midpoint'], ['==', 'active', 'true']], - paint: { 'circle-radius': 6, 'circle-stroke-width': 3, 'circle-color': editHaloColor, 'circle-stroke-color': editActiveColor } + paint: { 'circle-radius': vertexHaloRadius, 'circle-stroke-width': 3, 'circle-color': editHaloColor, 'circle-stroke-color': editActiveColor } }) -const midpointActive = (editMidpointColor) => ({ +const midpointActive = (editMidpointColor, midpointRadius) => ({ id: 'midpoint-active', type: 'circle', filter: ['all', ['==', '$type', 'Point'], ['==', 'meta', 'midpoint'], ['==', 'active', 'true']], - paint: { 'circle-radius': 4, 'circle-color': editMidpointColor } + paint: { 'circle-radius': midpointRadius, 'circle-color': editMidpointColor } }) const circle = (editStrokeColor) => ({ @@ -141,14 +141,15 @@ const touchVertexIndicator = () => ({ const createDrawStyles = (mapStyle) => { const scheme = getColorScheme(mapStyle) - const editStrokeColor = getColorForScheme(DEFAULTS.editStroke, scheme) - const editFillColor = getColorForScheme(DEFAULTS.editFill, scheme) - const editVertexColor = getColorForScheme(DEFAULTS.editVertex, scheme) - const editMidpointColor = getColorForScheme(DEFAULTS.editMidpoint, scheme) - const editHaloColor = getColorForScheme(DEFAULTS.editHalo, scheme) - const editActiveColor = getColorForScheme(DEFAULTS.editActive, scheme) - const splitInvalidColor = getColorForScheme(DEFAULTS.splitInvalid, scheme) - const splitValidColor = getColorForScheme(DEFAULTS.splitValid, scheme) + const editStrokeColor = getColorForScheme(COLORS.editStroke, scheme) + const editFillColor = getColorForScheme(COLORS.editFill, scheme) + const editVertexColor = getColorForScheme(COLORS.editVertex, scheme) + const editMidpointColor = getColorForScheme(COLORS.editMidpoint, scheme) + const editHaloColor = getColorForScheme(COLORS.editHalo, scheme) + const editActiveColor = getColorForScheme(COLORS.editActive, scheme) + const splitInvalidColor = getColorForScheme(COLORS.splitInvalid, scheme) + const splitValidColor = getColorForScheme(COLORS.splitValid, scheme) + const { vertexRadius, midpointRadius, vertexHaloRadius } = SIZES return [ fillInactive(mapStyle), @@ -158,12 +159,12 @@ const createDrawStyles = (mapStyle) => { drawInvalidSplitter(splitInvalidColor), drawValidSplitter(splitValidColor), drawPreviewLine(editStrokeColor), - midpoint(editMidpointColor), - midpointHalo(editHaloColor, editActiveColor), - midpointActive(editMidpointColor), - vertex(editVertexColor), - vertexHalo(editHaloColor, editActiveColor), - vertexActive(editVertexColor), + midpoint(editMidpointColor, midpointRadius), + midpointHalo(editHaloColor, editActiveColor, vertexHaloRadius), + midpointActive(editMidpointColor, midpointRadius), + vertex(editVertexColor, vertexRadius), + vertexHalo(editHaloColor, editActiveColor, vertexHaloRadius), + vertexActive(editVertexColor, vertexRadius), circle(editStrokeColor), touchVertexIndicator() ] diff --git a/plugins/beta/draw/src/adapters/openlayers/core/OLDrawManager.js b/plugins/beta/draw/src/adapters/openlayers/core/OLDrawManager.js index 3b5d76c22..cae10b257 100644 --- a/plugins/beta/draw/src/adapters/openlayers/core/OLDrawManager.js +++ b/plugins/beta/draw/src/adapters/openlayers/core/OLDrawManager.js @@ -6,7 +6,7 @@ import { resolveColors } from '../utils/resolveColors.js' import { createSnapManager } from '../snap/snapManager.js' import { createDrawMode } from '../draw/DrawMode.js' import { createEditMode } from '../edit/EditMode.js' -import { DEFAULTS } from '../defaults.js' +import { TOLERANCES } from '../defaults.js' /** * Mode machine for the OL draw plugin. @@ -31,7 +31,7 @@ export class OLDrawManager { this.colors = resolveColors(null, pluginConfig) this.styles = createStyles(this.colors) - this.snap = createSnapManager(map, pluginConfig.snapLayers ?? null, this.colors, pluginConfig.snapRadius ?? DEFAULTS.snapRadius) + this.snap = createSnapManager(map, pluginConfig.snapLayers ?? null, this.colors, pluginConfig.snapRadius ?? TOLERANCES.snapRadius) this._layer = new VectorLayer({ source: this.store.source, diff --git a/plugins/beta/draw/src/adapters/openlayers/core/styles.js b/plugins/beta/draw/src/adapters/openlayers/core/styles.js index dbbb6ed0c..53931072a 100644 --- a/plugins/beta/draw/src/adapters/openlayers/core/styles.js +++ b/plugins/beta/draw/src/adapters/openlayers/core/styles.js @@ -2,9 +2,10 @@ import Style from 'ol/style/Style.js' import Fill from 'ol/style/Fill.js' import Stroke from 'ol/style/Stroke.js' import CircleStyle from 'ol/style/Circle.js' +import { SIZES } from '../defaults.js' -const selectedVertexRadii = { outer: 11, mid: 8, inner: 6 } -const selectedMidpointRadii = { outer: 9, mid: 6, inner: 4 } +const selectedVertexRadii = { outer: SIZES.vertexHaloRadius + 3, mid: SIZES.vertexHaloRadius, inner: SIZES.vertexRadius } +const selectedMidpointRadii = { outer: SIZES.vertexHaloRadius + 1, mid: SIZES.vertexHaloRadius, inner: SIZES.midpointRadius } const fillArc = (ctx, cx, cy, radius, fillStyle) => { ctx.beginPath() @@ -38,7 +39,7 @@ const capitalize = (s) => s.charAt(0).toUpperCase() + s.slice(1) export const createStyles = (colors) => { const vertexStyle = new Style({ image: new CircleStyle({ - radius: 6, + radius: SIZES.vertexRadius, fill: new Fill({ color: colors.editVertex }) }) }) @@ -47,7 +48,7 @@ export const createStyles = (colors) => { const midpointStyle = new Style({ image: new CircleStyle({ - radius: 4, + radius: SIZES.midpointRadius, fill: new Fill({ color: colors.editMidpoint }) }) }) diff --git a/plugins/beta/draw/src/adapters/openlayers/defaults.js b/plugins/beta/draw/src/adapters/openlayers/defaults.js index bca89f53e..4f3f12002 100644 --- a/plugins/beta/draw/src/adapters/openlayers/defaults.js +++ b/plugins/beta/draw/src/adapters/openlayers/defaults.js @@ -1 +1 @@ -export { DEFAULTS } from '../../defaults.js' +export { COLORS, SIZES, TOLERANCES } from '../../defaults.js' diff --git a/plugins/beta/draw/src/adapters/openlayers/draw/DrawMode.js b/plugins/beta/draw/src/adapters/openlayers/draw/DrawMode.js index c0e8bd073..067e08cb0 100644 --- a/plugins/beta/draw/src/adapters/openlayers/draw/DrawMode.js +++ b/plugins/beta/draw/src/adapters/openlayers/draw/DrawMode.js @@ -2,8 +2,7 @@ import Draw from 'ol/interaction/Draw.js' import { noModifierKeys } from 'ol/events/condition.js' import { createDrawInput } from './drawInput.js' import { getCoords } from '../utils/geometryHelpers.js' - -const SNAP_TOLERANCE_PX = 12 +import { TOLERANCES } from '../defaults.js' const MIN_VERTICES = { Polygon: 3, LineString: 2 } const canFinish = (geometryType, sketchFeature) => { @@ -59,7 +58,7 @@ export const createDrawMode = ({ map, manager, options }) => { type: geometryType, style: (feature) => currentSketchStyle(feature), stopClick: true, - snapTolerance: SNAP_TOLERANCE_PX, + snapTolerance: TOLERANCES.snapRadius, condition: buildCondition(map, geometryType, () => sketchFeature) }) map.addInteraction(drawInteraction) diff --git a/plugins/beta/draw/src/adapters/openlayers/edit/keyboardHandler.js b/plugins/beta/draw/src/adapters/openlayers/edit/keyboardHandler.js index 88dae2157..21de45396 100644 --- a/plugins/beta/draw/src/adapters/openlayers/edit/keyboardHandler.js +++ b/plugins/beta/draw/src/adapters/openlayers/edit/keyboardHandler.js @@ -1,5 +1,5 @@ import { coordToPixel, nudgeCoord } from '../utils/olCoords.js' -import { spatialNavigate } from '../utils/spatial.js' +import { spatialNavigate } from '../../../utils/spatial.js' import { moveVertex, insertAtMidpoint } from './vertexOps.js' const ARROW_KEYS = new Set(['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown']) diff --git a/plugins/beta/draw/src/adapters/openlayers/utils/flattenStyleProperties.js b/plugins/beta/draw/src/adapters/openlayers/utils/flattenStyleProperties.js deleted file mode 100644 index cfd3df61e..000000000 --- a/plugins/beta/draw/src/adapters/openlayers/utils/flattenStyleProperties.js +++ /dev/null @@ -1,47 +0,0 @@ -const STYLE_PROPS = ['stroke', 'fill', 'strokeWidth'] - -/** - * Flatten style properties that may be strings or variant objects - * keyed by style ID into flat GeoJSON-compatible properties. - * - * @param {object} props - Object containing style properties - * @returns {object} Flattened properties - * - * @example - * flattenStyleProperties({ - * stroke: { outdoor: '#e6c700', dark: '#ffd700' }, - * fill: 'rgba(255, 221, 0, 0.1)', - * strokeWidth: 3 - * }) - * // Returns: - * // { - * // stroke: '#e6c700', - * // strokeOutdoor: '#e6c700', - * // strokeDark: '#ffd700', - * // fill: 'rgba(255, 221, 0, 0.1)', - * // strokeWidth: 3 - * // } - */ -export const flattenStyleProperties = (props) => { - if (!props) { - return {} - } - - const result = {} - - for (const [key, value] of Object.entries(props)) { - if (STYLE_PROPS.includes(key) && typeof value === 'object' && value !== null) { - const entries = Object.entries(value) - if (entries.length > 0) { - result[key] = entries[0][1] - } - for (const [styleId, styleValue] of entries) { - result[`${key}${styleId.charAt(0).toUpperCase() + styleId.slice(1)}`] = styleValue - } - } else { - result[key] = value - } - } - - return result -} diff --git a/plugins/beta/draw/src/adapters/openlayers/utils/resolveColors.js b/plugins/beta/draw/src/adapters/openlayers/utils/resolveColors.js index 5712b4ef8..bc2ede957 100644 --- a/plugins/beta/draw/src/adapters/openlayers/utils/resolveColors.js +++ b/plugins/beta/draw/src/adapters/openlayers/utils/resolveColors.js @@ -1,4 +1,4 @@ -import { DEFAULTS } from '../defaults.js' +import { COLORS, SIZES } from '../defaults.js' import { getColorForScheme } from '../../../utils/getColorForScheme.js' /** @@ -8,13 +8,13 @@ import { getColorForScheme } from '../../../utils/getColorForScheme.js' * Variant resolution order: exact style ID match → color scheme → 'light' fallback → first value. * * @param {object|null} mapStyle - Current map style object (has .id and .mapColorScheme) - * @param {object} pluginConfig - Plugin-level user overrides (may override any DEFAULTS key) + * @param {object} pluginConfig - Plugin-level user overrides (may override any COLORS key) * @returns {object} Flat color values ready for use in createStyles() */ export const resolveColors = (mapStyle, pluginConfig = {}) => { const scheme = mapStyle?.mapColorScheme ?? 'light' const styleId = mapStyle?.id ?? null - const resolveColor = (key) => getColorForScheme(pluginConfig[key] ?? DEFAULTS[key], scheme, styleId) + const resolveColor = (key) => getColorForScheme(pluginConfig[key] ?? COLORS[key], scheme, styleId) return { editStroke: resolveColor('editStroke'), @@ -24,7 +24,7 @@ export const resolveColors = (mapStyle, pluginConfig = {}) => { editActive: resolveColor('editActive'), editHalo: resolveColor('editHalo'), shapeStroke: resolveColor('shapeStroke'), - strokeWidth: pluginConfig.strokeWidth ?? DEFAULTS.strokeWidth, + strokeWidth: pluginConfig.strokeWidth ?? SIZES.strokeWidth, shapeFill: resolveColor('shapeFill'), snapVertex: resolveColor('snapVertex'), snapEdge: resolveColor('snapEdge'), diff --git a/plugins/beta/draw/src/adapters/openlayers/utils/spatial.js b/plugins/beta/draw/src/adapters/openlayers/utils/spatial.js deleted file mode 100644 index b9238b6a0..000000000 --- a/plugins/beta/draw/src/adapters/openlayers/utils/spatial.js +++ /dev/null @@ -1,24 +0,0 @@ -/** - * Navigate spatially from a start pixel toward a direction quadrant. - * Returns the index of the nearest pixel in that direction. - * - * @param {[number, number]} start - Current pixel [x, y] - * @param {Array<[number, number]>} pixels - All candidate pixels - * @param {string} direction - ArrowUp | ArrowDown | ArrowLeft | ArrowRight | undefined (nearest) - * @returns {number} Index into pixels array - */ -export const spatialNavigate = (start, pixels, direction) => { - const quadrant = pixels.filter((p) => { - const dx = Math.abs(p[0] - start[0]) - const dy = Math.abs(p[1] - start[1]) - let inQuadrant = false - if (direction === 'ArrowUp') { inQuadrant = p[1] <= start[1] && dy >= dx } else if (direction === 'ArrowDown') { inQuadrant = p[1] > start[1] && dy >= dx } else if (direction === 'ArrowLeft') { inQuadrant = p[0] <= start[0] && dy < dx } else if (direction === 'ArrowRight') { inQuadrant = p[0] > start[0] && dy < dx } else { inQuadrant = true } - return inQuadrant && JSON.stringify(p) !== JSON.stringify(start) - }) - - if (!quadrant.length) { quadrant.push(start) } - - const dist = (p) => Math.hypot(start[0] - p[0], start[1] - p[1]) - const closest = quadrant.reduce((best, p) => dist(p) < dist(best) ? p : best, quadrant[0]) - return pixels.findIndex(p => JSON.stringify(p) === JSON.stringify(closest)) -} diff --git a/plugins/beta/draw/src/adapters/openlayers/utils/touchTarget.js b/plugins/beta/draw/src/adapters/openlayers/utils/touchTarget.js index 663ff499d..0a3263240 100644 --- a/plugins/beta/draw/src/adapters/openlayers/utils/touchTarget.js +++ b/plugins/beta/draw/src/adapters/openlayers/utils/touchTarget.js @@ -1,61 +1,8 @@ -/** - * SVG offset vertex target — shown below the finger in touch edit mode - * so the user can accurately reposition a vertex without finger occlusion. - * This module handles DOM management only; drag logic lives in touchHandler.js. - * - * The SVG uses CSS custom properties so colors update when the map style changes: - * --draw-halo outer ring (black on light, white on dark) - * --draw-bg inner background (white on light, near-black on dark) - * --draw-primary arrow icons and centre dot (blue on light, white on dark) - */ - -const SVG_HTML = ` - -` - -export const createTouchTarget = (container) => { - let el = container.querySelector('[data-im-draw-touch-target]') - if (!el) { - container.insertAdjacentHTML('beforeend', SVG_HTML) - el = container.querySelector('[data-im-draw-touch-target]') - } - return el -} - -export const applyTouchTargetColors = (el, colors) => { - if (!el) { - return - } - el.style.setProperty('--draw-halo', colors.editActive) - el.style.setProperty('--draw-bg', colors.editHalo) - el.style.setProperty('--draw-primary', colors.editVertex) -} - -export const showTouchTarget = (el, pixel) => { - if (!pixel || !el) { - return - } - el.style.left = `${pixel.x}px` - el.style.top = `${pixel.y}px` - el.style.display = 'block' -} - -export const hideTouchTarget = (el) => { - if (el) { - el.style.display = 'none' - } -} - -/** True when the event target is part of the SVG touch target element. */ -export const isOnTouchTarget = (el) => { - if (!el) { - return false - } - const parent = el.parentNode - return (parent instanceof globalThis.SVGElement) || (parent?.ownerSVGElement != null) -} +// Re-export centralized touch target utilities from shared implementation +export { + createTouchTarget, + applyTouchTargetColors, + showTouchTarget, + hideTouchTarget, + isOnTouchTarget +} from '../../../utils/touchTarget.js' diff --git a/plugins/beta/draw/src/defaults.js b/plugins/beta/draw/src/defaults.js index f33c8afd7..9659f5ba8 100644 --- a/plugins/beta/draw/src/defaults.js +++ b/plugins/beta/draw/src/defaults.js @@ -1,20 +1,36 @@ const PRIMARY_LIGHT = 'rgba(29,112,184,1)' const PRIMARY_DARK = '#ffffff' +const EDIT_LIGHT_TINT = 'rgba(29,112,184,0.1)' +const EDIT_DARK_TINT = 'rgba(255,255,255,0.1)' +const SHAPE_STROKE_COLOR = 'rgba(212,53,28,1)' +const SHAPE_STROKE_TINT = 'rgba(212,53,28,0.5)' +const SNAP_MIDPOINT_COLOR = 'rgba(40,161,151,1)' -export const DEFAULTS = { +export const COLORS = { editStroke: { light: PRIMARY_LIGHT, dark: PRIMARY_DARK }, - editFill: { light: 'rgba(29,112,184,0.1)', dark: 'rgba(255,255,255,0.1)' }, + editFill: { light: EDIT_LIGHT_TINT, dark: EDIT_DARK_TINT }, editVertex: { light: PRIMARY_LIGHT, dark: PRIMARY_DARK }, editMidpoint: { light: PRIMARY_LIGHT, dark: PRIMARY_DARK }, editHalo: { light: PRIMARY_DARK, dark: 'rgba(11,12,12,1)' }, editActive: { light: 'rgba(11,12,12,1)', dark: PRIMARY_DARK }, splitInvalid: PRIMARY_LIGHT, splitValid: PRIMARY_LIGHT, - shapeStroke: 'rgba(212,53,28,1)', - shapeFill: 'rgba(212,53,28,0.5)', + shapeStroke: SHAPE_STROKE_COLOR, + shapeFill: SHAPE_STROKE_TINT, + snapVertex: SHAPE_STROKE_TINT, + snapMidpoint: SNAP_MIDPOINT_COLOR, + snapEdge: 'rgba(29,112,184,0.5)' +} + +export const SIZES = { strokeWidth: 2, - snapVertex: 'rgba(212,53,28,0.5)', - snapMidpoint: 'rgba(40,161,151,1)', - snapEdge: 'rgba(29,112,184,0.5)', - snapRadius: 10 + vertexRadius: 6, + midpointRadius: 4, + vertexHaloRadius: 8, + touchTargetSize: 48, + touchIndicatorRadius: 30 +} + +export const TOLERANCES = { + snapRadius: 12 } diff --git a/plugins/beta/draw/src/utils/eventBus.js b/plugins/beta/draw/src/utils/eventBus.js new file mode 100644 index 000000000..ca0a68e1e --- /dev/null +++ b/plugins/beta/draw/src/utils/eventBus.js @@ -0,0 +1,29 @@ +/** + * Simple pub-sub event bus for plugin-internal communication. + * Used to decouple components that need to communicate events. + * + * @returns {{on: Function, off: Function, emit: Function}} + */ +export const createEventBus = () => { + const listeners = new Map() + + return { + on (type, handler) { + if (!listeners.has(type)) { + listeners.set(type, new Set()) + } + listeners.get(type).add(handler) + }, + + off (type, handler) { + listeners.get(type)?.delete(handler) + }, + + emit (type, ...args) { + const handlers = listeners.get(type) + if (handlers) { + [...handlers].forEach(h => h(...args)) + } + } + } +} diff --git a/plugins/beta/draw/src/utils/touchTarget.js b/plugins/beta/draw/src/utils/touchTarget.js new file mode 100644 index 000000000..662d4d996 --- /dev/null +++ b/plugins/beta/draw/src/utils/touchTarget.js @@ -0,0 +1,95 @@ +/** + * SVG offset vertex target for touch edit mode + * Shown below the finger so users can accurately reposition vertices without finger occlusion. + * + * Colors update dynamically based on current color scheme via CSS custom properties: + * --draw-halo outer ring (editActive color) + * --draw-bg inner background (editHalo color) + * --draw-primary arrow icons and centre dot (editVertex color) + */ + +import { SIZES } from '../defaults.js' + +const HALF_SIZE = SIZES.touchTargetSize / 2 + +/** + * Generate SVG touch target HTML string based on configured size + * @returns {string} SVG HTML + */ +function generateSvgHtml () { + const size = SIZES.touchTargetSize + const half = HALF_SIZE + return ` + + ` +} + +/** + * Create or retrieve the touch target SVG element + * @param {HTMLElement} container - Parent container element + * @returns {SVGElement} The touch target element + */ +export const createTouchTarget = (container) => { + let el = container.querySelector('[data-im-draw-touch-target]') + if (!el) { + container.insertAdjacentHTML('beforeend', generateSvgHtml()) + el = container.querySelector('[data-im-draw-touch-target]') + } + return el +} + +/** + * Apply color scheme to touch target SVG + * @param {SVGElement} el - Touch target element + * @param {object} colors - Color object with editActive, editHalo, editVertex properties + */ +export const applyTouchTargetColors = (el, colors) => { + if (!el) { + return + } + el.style.setProperty('--draw-halo', colors.editActive) + el.style.setProperty('--draw-bg', colors.editHalo) + el.style.setProperty('--draw-primary', colors.editVertex) +} + +/** + * Show touch target at specified pixel position + * @param {SVGElement} el - Touch target element + * @param {object} pixel - Position object with x, y properties + */ +export const showTouchTarget = (el, pixel) => { + if (!pixel || !el) { + return + } + el.style.left = `${pixel.x}px` + el.style.top = `${pixel.y}px` + el.style.display = 'block' +} + +/** + * Hide touch target + * @param {SVGElement} el - Touch target element + */ +export const hideTouchTarget = (el) => { + if (el) { + el.style.display = 'none' + } +} + +/** + * Check if event target is part of the touch target SVG + * @param {HTMLElement} el - Element to check + * @returns {boolean} True if element is part of touch target + */ +export const isOnTouchTarget = (el) => { + if (!el) { + return false + } + const parent = el.parentNode + return (parent instanceof globalThis.SVGElement) || (parent?.ownerSVGElement != null) +} From 251a7ca5adbe8ac46c41a7753bbfd36025917639 Mon Sep 17 00:00:00 2001 From: Dan Leech Date: Thu, 2 Jul 2026 13:18:04 +0100 Subject: [PATCH 07/89] Export applyTouchVertexColors from touchHandlers The function needs to be exported since mapboxDraw.js imports it. Co-Authored-By: Claude Haiku 4.5 --- .../src/adapters/maplibre/modes/editVertex/touchHandlers.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/beta/draw/src/adapters/maplibre/modes/editVertex/touchHandlers.js b/plugins/beta/draw/src/adapters/maplibre/modes/editVertex/touchHandlers.js index e4848b832..c8ed19d53 100644 --- a/plugins/beta/draw/src/adapters/maplibre/modes/editVertex/touchHandlers.js +++ b/plugins/beta/draw/src/adapters/maplibre/modes/editVertex/touchHandlers.js @@ -8,7 +8,7 @@ import { createTouchTarget, applyTouchTargetColors } from '../../../../utils/tou import { COLORS } from '../../defaults.js' import { getColorForScheme } from '../../../../utils/getColorForScheme.js' -const applyTouchVertexColors = (el, mapStyle) => { +export const applyTouchVertexColors = (el, mapStyle) => { if (!el) { return } const scheme = mapStyle?.mapColorScheme ?? 'light' const colors = { From 84122f67353c0e6c985bae5004fee7ea7b5ba542 Mon Sep 17 00:00:00 2001 From: Dan Leech Date: Thu, 2 Jul 2026 13:28:37 +0100 Subject: [PATCH 08/89] Consolidate keyboard nudge/step amounts to shared defaults Move hardcoded keyboard movement amounts (NUDGE=1, STEP=5 pixels) from individual adapters to a centralized KEYBOARD config in defaults.js alongside COLORS, SIZES, and TOLERANCES. Both MapLibre and OpenLayers now import from the same source, ensuring consistent keyboard behavior across adapters. This makes it easy to adjust keyboard movement sensitivity globally if needed, and maintains consistency with the plugin's other configuration patterns. Changes: - Added KEYBOARD export to /src/defaults.js with nudgeAmount and stepAmount - Updated maplibre/modes/editVertex/vertexOperations.js to use KEYBOARD config - Updated openlayers/edit/keyboardHandler.js to use KEYBOARD config Co-Authored-By: Claude Haiku 4.5 --- .../adapters/maplibre/modes/editVertex/vertexOperations.js | 4 ++-- .../draw/src/adapters/openlayers/edit/keyboardHandler.js | 5 ++--- plugins/beta/draw/src/defaults.js | 5 +++++ 3 files changed, 9 insertions(+), 5 deletions(-) diff --git a/plugins/beta/draw/src/adapters/maplibre/modes/editVertex/vertexOperations.js b/plugins/beta/draw/src/adapters/maplibre/modes/editVertex/vertexOperations.js index 634754e7c..eadca36cf 100644 --- a/plugins/beta/draw/src/adapters/maplibre/modes/editVertex/vertexOperations.js +++ b/plugins/beta/draw/src/adapters/maplibre/modes/editVertex/vertexOperations.js @@ -4,9 +4,9 @@ import { getSegmentForIndex, getModifiableCoords } from './geometryHelpers.js' +import { KEYBOARD } from '../../../../defaults.js' const ARROW_OFFSETS = { ArrowUp: [0, -1], ArrowDown: [0, 1], ArrowLeft: [-1, 0], ArrowRight: [1, 0] } -const NUDGE = 1; const STEP = 5 export const vertexOperations = { updateMidpoint (coordinates) { @@ -29,7 +29,7 @@ export const vertexOperations = { getOffset (coord, e) { const pt = this.map.project(coord) - const offset = e?.shiftKey ? NUDGE : STEP + const offset = e?.shiftKey ? KEYBOARD.nudgeAmount : KEYBOARD.stepAmount const [dx, dy] = e ? ARROW_OFFSETS[e.key].map(v => v * offset) : [0, 0] return this.map.unproject({ x: pt.x + dx, y: pt.y + dy }) }, diff --git a/plugins/beta/draw/src/adapters/openlayers/edit/keyboardHandler.js b/plugins/beta/draw/src/adapters/openlayers/edit/keyboardHandler.js index 21de45396..e6495ed5c 100644 --- a/plugins/beta/draw/src/adapters/openlayers/edit/keyboardHandler.js +++ b/plugins/beta/draw/src/adapters/openlayers/edit/keyboardHandler.js @@ -1,11 +1,10 @@ import { coordToPixel, nudgeCoord } from '../utils/olCoords.js' import { spatialNavigate } from '../../../utils/spatial.js' import { moveVertex, insertAtMidpoint } from './vertexOps.js' +import { KEYBOARD } from '../../../defaults.js' const ARROW_KEYS = new Set(['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown']) const INTERACTIVE_TAGS = new Set(['INPUT', 'TEXTAREA', 'BUTTON', 'SELECT', 'A']) -const NUDGE_PX = 1 -const STEP_PX = 5 const selectNearest = (map, getState, setState) => { const { vertices, midpoints } = getState() @@ -94,7 +93,7 @@ const wireNudge = ({ map, snap, getState, setState, onInserted }) => { if (!olFeature) { return } - const step = e.shiftKey ? NUDGE_PX : STEP_PX + const step = e.shiftKey ? KEYBOARD.nudgeAmount : KEYBOARD.stepAmount const offsets = { ArrowUp: [0, -step], ArrowDown: [0, step], ArrowLeft: [-step, 0], ArrowRight: [step, 0] } const [dx, dy] = offsets[e.key] if (selectedVertexType === 'midpoint') { diff --git a/plugins/beta/draw/src/defaults.js b/plugins/beta/draw/src/defaults.js index 9659f5ba8..f2141ead0 100644 --- a/plugins/beta/draw/src/defaults.js +++ b/plugins/beta/draw/src/defaults.js @@ -34,3 +34,8 @@ export const SIZES = { export const TOLERANCES = { snapRadius: 12 } + +export const KEYBOARD = { + nudgeAmount: 1, + stepAmount: 5 +} From 8796f92a9ace86665a8d8578e44cefd889651ff2 Mon Sep 17 00:00:00 2001 From: Dan Leech Date: Thu, 2 Jul 2026 13:51:59 +0100 Subject: [PATCH 09/89] Consolidate keyboard nudge/step amounts to shared defaults. --- plugins/beta/draw/src/DrawInit.jsx | 7 ++++++- plugins/beta/draw/src/adapters/maplibre/mapboxDraw.js | 10 ++++++++++ .../draw/src/adapters/maplibre/modes/createDrawMode.js | 3 +++ .../beta/draw/src/adapters/openlayers/draw/DrawMode.js | 3 +++ src/utils/detectInterfaceType.js | 6 +++++- 5 files changed, 27 insertions(+), 2 deletions(-) diff --git a/plugins/beta/draw/src/DrawInit.jsx b/plugins/beta/draw/src/DrawInit.jsx index 53c1c803d..4f9da30d4 100644 --- a/plugins/beta/draw/src/DrawInit.jsx +++ b/plugins/beta/draw/src/DrawInit.jsx @@ -42,7 +42,12 @@ export const DrawInit = ({ appState, appConfig, mapState, pluginConfig, pluginSt const wasAlreadyVisible = crossHair.isVisible crossHair.fixAtCenter() return () => { - if (!wasAlreadyVisible) { crossHair.hide() } + // Only hide crosshair if it wasn't visible before drawing AND we're not currently + // in keyboard/touch mode (user might have switched input devices during drawing). + // This ensures crosshair stays visible if user switched to keyboard mid-drawing. + if (!wasAlreadyVisible && !['touch', 'keyboard'].includes(appState.interfaceType)) { + crossHair.hide() + } } } return undefined diff --git a/plugins/beta/draw/src/adapters/maplibre/mapboxDraw.js b/plugins/beta/draw/src/adapters/maplibre/mapboxDraw.js index 90eaa4494..3d05f59b5 100755 --- a/plugins/beta/draw/src/adapters/maplibre/mapboxDraw.js +++ b/plugins/beta/draw/src/adapters/maplibre/mapboxDraw.js @@ -129,6 +129,15 @@ export const createMapboxDraw = ({ mapStyle, mapProvider, events, eventBus, snap } eventBus.on(events.MAP_SET_STYLE, handleSetMapStyle) + // --- Sync final interface type when exiting draw modes --- + // When user switches devices during drawing (e.g., mouse to keyboard), the draw + // mode's local interfaceType diverges from appState.interfaceType. When exiting + // draw mode, we emit the final interfaceType so appState can be updated. + const handleDrawInterfaceTypeChange = (e) => { + eventBus.emit('draw:interfacetypechange', { interfaceType: e.interfaceType }) + } + map.on('draw.interfacetypechange', handleDrawInterfaceTypeChange) + // --- Update map scale --- const handleSetMapSize = (e) => { map.fire('draw.scalechange', { scale: { small: 1, medium: 1.5, large: 2 }[e] }) @@ -145,6 +154,7 @@ export const createMapboxDraw = ({ mapStyle, mapProvider, events, eventBus, snap // Remove event listeners eventBus.off(events.MAP_SET_STYLE, handleSetMapStyle) eventBus.off(events.MAP_SET_SIZE, handleSetMapSize) + map.off('draw.interfacetypechange', handleDrawInterfaceTypeChange) // Disable draw mode but keep control on map for reuse draw.changeMode('disabled') // Clear adapter reference (but not _mapboxDrawInstance so it persists) diff --git a/plugins/beta/draw/src/adapters/maplibre/modes/createDrawMode.js b/plugins/beta/draw/src/adapters/maplibre/modes/createDrawMode.js index 61f34b6c9..85ed90785 100644 --- a/plugins/beta/draw/src/adapters/maplibre/modes/createDrawMode.js +++ b/plugins/beta/draw/src/adapters/maplibre/modes/createDrawMode.js @@ -547,6 +547,9 @@ export const createDrawMode = (ParentMode, config) => { // NOSONAR — factory r ParentMode.onStop.call(this, state) this._listeners.forEach(([t, e, h]) => t.removeEventListener ? t.removeEventListener(e, h) : t.off(e, h)) this._hideCrossHair(state) + // Sync the final interfaceType from draw mode back to app state so crosshair + // visibility is correct when exiting draw mode (e.g., if user switched from mouse to keyboard) + this.map.fire('draw.interfacetypechange', { interfaceType: state.interfaceType }) } } } diff --git a/plugins/beta/draw/src/adapters/openlayers/draw/DrawMode.js b/plugins/beta/draw/src/adapters/openlayers/draw/DrawMode.js index 067e08cb0..e3880246b 100644 --- a/plugins/beta/draw/src/adapters/openlayers/draw/DrawMode.js +++ b/plugins/beta/draw/src/adapters/openlayers/draw/DrawMode.js @@ -121,6 +121,9 @@ export const createDrawMode = ({ map, manager, options }) => { undo () { drawInteraction.removeLastPoint(); updateVertexCount() }, destroy () { manager.off('styleschanged', onStylesChanged) + // Emit the final interfaceType from draw mode so it's synced back to appState + // This ensures crosshair visibility is correct when exiting draw mode + manager.emit('interfacetypechange', { interfaceType: input.getInterfaceType() }) input.destroy() map.removeInteraction(drawInteraction) sketchFeature = null diff --git a/src/utils/detectInterfaceType.js b/src/utils/detectInterfaceType.js index 900497c9f..0fa1b57cc 100755 --- a/src/utils/detectInterfaceType.js +++ b/src/utils/detectInterfaceType.js @@ -59,7 +59,11 @@ function createInterfaceDetector () { } const handleKeyDown = e => { - if (e.key === 'Tab') { + // Recognize keyboard mode from Tab (explicit focus), arrow keys (navigation), + // Enter (confirmation), or other significant keys. This allows the interface type + // to update even when keyboard input happens during drawing (where focus is on map). + const keyboardModeKeys = new Set(['Tab', 'Enter', 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Escape']) + if (keyboardModeKeys.has(e.key)) { notifyListeners('keyboard') } } From 864c23e2b5f7c16e34e0c371252d6b0070b0a347 Mon Sep 17 00:00:00 2001 From: Dan Leech Date: Thu, 2 Jul 2026 15:13:34 +0100 Subject: [PATCH 10/89] ESM and UMD updated with unified draw plugin --- rollup.esm.mjs | 6 ++++++ webpack.umd.mjs | 1 + 2 files changed, 7 insertions(+) diff --git a/rollup.esm.mjs b/rollup.esm.mjs index f9cfef484..967673737 100644 --- a/rollup.esm.mjs +++ b/rollup.esm.mjs @@ -274,6 +274,12 @@ const ALL_BUILDS = [ outDir: 'plugins/beta/map-styles/dist/esm', manualChunks: (id) => { if (id.includes('/manifest')) return 'im-map-styles-plugin' } }, + { + entryPath: './plugins/beta/draw/src/index.js', + outDir: 'plugins/beta/draw/dist/esm', + extraExternals: [/^ol\//], + manualChunks: (id) => { if (id.includes('/manifest')) return 'im-draw-plugin' } + }, { entryPath: './plugins/beta/draw-ml/src/index.js', outDir: 'plugins/beta/draw-ml/dist/esm', diff --git a/webpack.umd.mjs b/webpack.umd.mjs index 3704729f2..649eacbf9 100755 --- a/webpack.umd.mjs +++ b/webpack.umd.mjs @@ -146,6 +146,7 @@ const ALL_BUILDS = [ { entryPath: './plugins/interact/src/index.js', libraryPath: 'interactPlugin', outDir: 'plugins/interact/dist/umd' }, { entryPath: './plugins/beta/datasets/src/index.js', libraryPath: 'datasetsPlugin', outDir: 'plugins/beta/datasets/dist/umd', cssOutDir: 'plugins/beta/datasets/dist' }, { entryPath: './plugins/beta/map-styles/src/index.js', libraryPath: 'mapStylesPlugin', outDir: 'plugins/beta/map-styles/dist/umd' }, + { entryPath: './plugins/beta/draw/src/index.js', libraryPath: 'drawPlugin', outDir: 'plugins/beta/draw/dist/umd' }, { entryPath: './plugins/beta/draw-ml/src/index.js', libraryPath: 'drawMLPlugin', outDir: 'plugins/beta/draw-ml/dist/umd' }, { entryPath: './plugins/beta/frame/src/index.js', libraryPath: 'framePlugin', outDir: 'plugins/beta/frame/dist/umd' } ] From 613faf750b9a3bfc5598546140b6d10fd2e02c15 Mon Sep 17 00:00:00 2001 From: Dan Leech Date: Thu, 2 Jul 2026 15:35:41 +0100 Subject: [PATCH 11/89] ESM build sonar fixes --- rollup.esm.mjs | 54 ++++++++++++++++++++++++++------------------------ 1 file changed, 28 insertions(+), 26 deletions(-) diff --git a/rollup.esm.mjs b/rollup.esm.mjs index 967673737..c7e0b870a 100644 --- a/rollup.esm.mjs +++ b/rollup.esm.mjs @@ -1,6 +1,6 @@ -import path, { dirname } from 'path' -import { fileURLToPath } from 'url' -import fs from 'fs' +import path, { dirname } from 'node:path' +import { fileURLToPath } from 'node:url' +import fs from 'node:fs' import { nodeResolve } from '@rollup/plugin-node-resolve' import commonjs from '@rollup/plugin-commonjs' @@ -11,7 +11,7 @@ import terser from '@rollup/plugin-terser' import postcss from 'rollup-plugin-postcss' import { visualizer } from 'rollup-plugin-visualizer' -const __dirname = dirname(fileURLToPath(import.meta.url)) +const __dirname = dirname(fileURLToPath(import.meta.url)) // NOSONAR - Standard Node.js convention in bundler configs /** * Cleans output directories before each build starts. @@ -69,7 +69,7 @@ const removeFullCssPlugin = (cssDir) => ({ // rewritten by the alias plugin before Rollup's external check fires. const PREACT_EXTERNALS = [ 'preact', - 'preact/compat', + 'preact/compat', // NOSONAR 'preact/compat/client', 'preact/hooks', 'preact/jsx-runtime' @@ -178,7 +178,7 @@ const createESMConfig = (entryPath, outDir, isCore = false, manualChunks = null, // Only runs when ANALYZE=1 is set; writes stats to dist/stats/.html ...(process.env.ANALYZE ? [visualizer({ - filename: path.resolve(__dirname, 'dist/stats', `${outDir.replace(/\//g, '-')}.html`), + filename: path.resolve(__dirname, 'dist/stats', `${outDir.replace(/\//g, '-')}.html`), // NOSONAR replaceAll() requires Chrome 84 or later open: false, gzipSize: true })] : []) @@ -217,89 +217,91 @@ const ALL_BUILDS = [ entryPath: './providers/maplibre/src/index.js', outDir: 'providers/maplibre/dist/esm', // maplibre-gl is external; only the provider class itself becomes a chunk - manualChunks: (id) => { if (id.includes('/maplibreProvider')) return 'im-maplibre-provider' } + manualChunks: (id) => id.includes('/maplibreProvider') ? 'im-maplibre-provider' : undefined }, { entryPath: './providers/beta/open-names/src/index.js', outDir: 'providers/beta/open-names/dist/esm', - manualChunks: (id) => { if (id.includes('/reverseGeocode')) return 'im-reverse-geocode' } + manualChunks: (id) => id.includes('/reverseGeocode') ? 'im-reverse-geocode' : undefined }, { entryPath: './providers/beta/esri/src/index.js', outDir: 'providers/beta/esri/dist/esm', - manualChunks: (id) => { if (id.includes('/esriProvider')) return 'im-esri-provider' } + manualChunks: (id) => id.includes('/esriProvider') ? 'im-esri-provider' : undefined }, { entryPath: './providers/beta/openlayers/src/index.js', outDir: 'providers/beta/openlayers/dist/esm', extraExternals: [/^ol\//, 'proj4'], - manualChunks: (id) => { - if (id.includes('/openlayersProvider')) { - return 'im-openlayers-provider' - } - } + manualChunks: (id) => id.includes('/openlayersProvider') ? 'im-openlayers-provider' : undefined }, // Plugins — each lazy-loads ./manifest.js; manualChunks names that split chunk { entryPath: './plugins/beta/scale-bar/src/index.js', outDir: 'plugins/beta/scale-bar/dist/esm', - manualChunks: (id) => { if (id.includes('/manifest')) return 'im-scale-bar-plugin' } + manualChunks: (id) => id.includes('/manifest') ? 'im-scale-bar-plugin' : undefined }, { entryPath: './plugins/beta/use-location/src/index.js', outDir: 'plugins/beta/use-location/dist/esm', - manualChunks: (id) => { if (id.includes('/manifest')) return 'im-use-location-plugin' } + manualChunks: (id) => id.includes('/manifest') ? 'im-use-location-plugin' : undefined }, { entryPath: './plugins/search/src/index.js', outDir: 'plugins/search/dist/esm', - manualChunks: (id) => { if (id.includes('/manifest')) return 'im-search-plugin' } + manualChunks: (id) => id.includes('/manifest') ? 'im-search-plugin' : undefined }, { entryPath: './plugins/interact/src/index.js', outDir: 'plugins/interact/dist/esm', - manualChunks: (id) => { if (id.includes('/manifest')) return 'im-interact-plugin' } + manualChunks: (id) => id.includes('/manifest') ? 'im-interact-plugin' : undefined }, { entryPath: './plugins/beta/datasets/src/index.js', outDir: 'plugins/beta/datasets/dist/esm', manualChunks: (id) => { - if (id.includes('/manifest')) return 'im-datasets-plugin' - if (id.includes('maplibreLayerAdapter')) return 'im-datasets-ml-adapter' + if (id.includes('/manifest')) { return 'im-datasets-plugin' } + if (id.includes('maplibreLayerAdapter')) { return 'im-datasets-ml-adapter' } + return undefined } }, { entryPath: './plugins/beta/map-styles/src/index.js', outDir: 'plugins/beta/map-styles/dist/esm', - manualChunks: (id) => { if (id.includes('/manifest')) return 'im-map-styles-plugin' } + manualChunks: (id) => id.includes('/manifest') ? 'im-map-styles-plugin' : undefined }, { entryPath: './plugins/beta/draw/src/index.js', outDir: 'plugins/beta/draw/dist/esm', extraExternals: [/^ol\//], - manualChunks: (id) => { if (id.includes('/manifest')) return 'im-draw-plugin' } + manualChunks: (id) => { + if (id.includes('/manifest')) { return 'im-draw-plugin' } + if (id.includes('MaplibreDrawAdapter')) { return 'im-draw-ml-adapter' } + if (id.includes('OLDrawAdapter')) { return 'im-draw-ol-adapter' } + return undefined + } }, { entryPath: './plugins/beta/draw-ml/src/index.js', outDir: 'plugins/beta/draw-ml/dist/esm', - manualChunks: (id) => { if (id.includes('/manifest')) return 'im-draw-ml-plugin' } + manualChunks: (id) => id.includes('/manifest') ? 'im-draw-ml-plugin' : undefined }, { entryPath: './plugins/beta/draw-es/src/index.js', outDir: 'plugins/beta/draw-es/dist/esm', - manualChunks: (id) => { if (id.includes('/manifest')) return 'im-draw-es-plugin' } + manualChunks: (id) => id.includes('/manifest') ? 'im-draw-es-plugin' : undefined }, { entryPath: './plugins/beta/draw-ol/src/index.js', outDir: 'plugins/beta/draw-ol/dist/esm', extraExternals: [/^ol\//], - manualChunks: (id) => { if (id.includes('/manifest')) return 'im-draw-ol-plugin' } + manualChunks: (id) => id.includes('/manifest') ? 'im-draw-ol-plugin' : undefined }, { entryPath: './plugins/beta/frame/src/index.js', outDir: 'plugins/beta/frame/dist/esm', - manualChunks: (id) => { if (id.includes('/manifest')) return 'im-frame-plugin' } + manualChunks: (id) => id.includes('/manifest') ? 'im-frame-plugin' : undefined } ] From e4f48e4decb3391e9041e55f36dc08c21d06a8c2 Mon Sep 17 00:00:00 2001 From: Dan Leech Date: Thu, 2 Jul 2026 16:37:58 +0100 Subject: [PATCH 12/89] Consistent mouse pointer vertex marker --- .../draw/src/adapters/maplibre/mapboxDraw.js | 70 ++++++++++++++++++- .../modes/editVertex/touchHandlers.js | 8 +-- .../beta/draw/src/adapters/maplibre/styles.js | 18 ++--- .../src/adapters/openlayers/core/styles.js | 5 +- .../openlayers/utils/resolveColors.js | 6 +- plugins/beta/draw/src/defaults.js | 47 +++++++------ .../beta/draw/src/utils/getColorForScheme.js | 34 +++++---- .../interact/src/hooks/useHighlightSync.js | 1 + plugins/interact/src/utils/buildStylesMap.js | 3 + .../maplibre/src/utils/highlightFeatures.js | 1 + 10 files changed, 141 insertions(+), 52 deletions(-) diff --git a/plugins/beta/draw/src/adapters/maplibre/mapboxDraw.js b/plugins/beta/draw/src/adapters/maplibre/mapboxDraw.js index 3d05f59b5..6d0cde6ca 100755 --- a/plugins/beta/draw/src/adapters/maplibre/mapboxDraw.js +++ b/plugins/beta/draw/src/adapters/maplibre/mapboxDraw.js @@ -7,7 +7,7 @@ import { createDrawStyles, updateDrawStyles } from './styles.js' import { initMapLibreSnap } from './mapboxSnap.js' import { createUndoStack } from './undoStack.js' import { applyTouchVertexColors } from './modes/editVertex/touchHandlers.js' -import { TOLERANCES } from './defaults.js' +import { COLORS, SIZES, TOLERANCES } from './defaults.js' /** * Creates and manages a MapLibre/Mapbox Draw control instance configured for polygon editing. @@ -118,6 +118,74 @@ export const createMapboxDraw = ({ mapStyle, mapProvider, events, eventBus, snap rules: ['vertex', 'edge'] }) + // --- Setup cursor indicator for draw modes --- + const CURSOR_SOURCE = 'draw-cursor-indicator' + const CURSOR_LAYER = 'draw-cursor-indicator-layer' + const cursorIndicatorSource = { + type: 'geojson', + data: { type: 'FeatureCollection', features: [] } + } + if (!map.getSource(CURSOR_SOURCE)) { + map.addSource(CURSOR_SOURCE, cursorIndicatorSource) + } + + const resolveColor = (colorConfig, styleId) => { + if (typeof colorConfig === 'string') { + return colorConfig + } + if (typeof colorConfig === 'object' && colorConfig !== null) { + return styleId && colorConfig[styleId] ? colorConfig[styleId] : Object.values(colorConfig)[0] ?? null + } + return null + } + + const ensureCursorLayer = () => { + if (!map.getLayer(CURSOR_LAYER)) { + const pointerColor = resolveColor(COLORS.mousePointer, mapStyle?.id) + const pointerHaloColor = resolveColor(COLORS.mousePointerHalo, mapStyle?.id) + map.addLayer({ + id: CURSOR_LAYER, + type: 'circle', + source: CURSOR_SOURCE, + paint: { + 'circle-radius': SIZES.mousePointerRadius, + 'circle-color': pointerColor, + 'circle-stroke-width': 1, + 'circle-stroke-color': pointerHaloColor + } + }) + } + } + + let cursorIndicatorActive = false + const handleMapMouseMove = (e) => { + if (!cursorIndicatorActive) { + return + } + const feature = { + type: 'Feature', + geometry: { type: 'Point', coordinates: [e.lngLat.lng, e.lngLat.lat] } + } + map.getSource(CURSOR_SOURCE).setData({ + type: 'FeatureCollection', + features: [feature] + }) + } + + const originalChangeMode = draw.changeMode.bind(draw) + draw.changeMode = function (mode, opts) { + const isDrawMode = ['draw_polygon', 'draw_line'].includes(mode) + cursorIndicatorActive = isDrawMode + if (isDrawMode) { + ensureCursorLayer() + map.on('mousemove', handleMapMouseMove) + } else { + map.off('mousemove', handleMapMouseMove) + map.getSource(CURSOR_SOURCE).setData({ type: 'FeatureCollection', features: [] }) + } + return originalChangeMode(mode, opts) + } + // --- Update colour scheme --- const handleSetMapStyle = (e) => { map._drawCurrentMapStyle = e diff --git a/plugins/beta/draw/src/adapters/maplibre/modes/editVertex/touchHandlers.js b/plugins/beta/draw/src/adapters/maplibre/modes/editVertex/touchHandlers.js index c8ed19d53..9b4c0c91f 100644 --- a/plugins/beta/draw/src/adapters/maplibre/modes/editVertex/touchHandlers.js +++ b/plugins/beta/draw/src/adapters/maplibre/modes/editVertex/touchHandlers.js @@ -6,15 +6,15 @@ import { coordPathToFlatIndex } from './geometryHelpers.js' import { isOnSVG } from './helpers.js' import { createTouchTarget, applyTouchTargetColors } from '../../../../utils/touchTarget.js' import { COLORS } from '../../defaults.js' -import { getColorForScheme } from '../../../../utils/getColorForScheme.js' +import { getValueForStyle } from '../../../../utils/getColorForScheme.js' export const applyTouchVertexColors = (el, mapStyle) => { if (!el) { return } const scheme = mapStyle?.mapColorScheme ?? 'light' const colors = { - editActive: getColorForScheme(COLORS.editActive, scheme), - editHalo: getColorForScheme(COLORS.editHalo, scheme), - editVertex: getColorForScheme(COLORS.editVertex, scheme) + editActive: getValueForStyle(COLORS.editActive, scheme), + editHalo: getValueForStyle(COLORS.editHalo, scheme), + editVertex: getValueForStyle(COLORS.editVertex, scheme) } applyTouchTargetColors(el, colors) } diff --git a/plugins/beta/draw/src/adapters/maplibre/styles.js b/plugins/beta/draw/src/adapters/maplibre/styles.js index 791a23f92..0d6af6b43 100755 --- a/plugins/beta/draw/src/adapters/maplibre/styles.js +++ b/plugins/beta/draw/src/adapters/maplibre/styles.js @@ -1,6 +1,6 @@ // styles.js import { COLORS, SIZES } from './defaults.js' -import { getColorForScheme } from '../../utils/getColorForScheme.js' +import { getValueForStyle } from '../../utils/getColorForScheme.js' const getColorScheme = (mapStyle) => mapStyle.mapColorScheme ?? 'light' @@ -141,14 +141,14 @@ const touchVertexIndicator = () => ({ const createDrawStyles = (mapStyle) => { const scheme = getColorScheme(mapStyle) - const editStrokeColor = getColorForScheme(COLORS.editStroke, scheme) - const editFillColor = getColorForScheme(COLORS.editFill, scheme) - const editVertexColor = getColorForScheme(COLORS.editVertex, scheme) - const editMidpointColor = getColorForScheme(COLORS.editMidpoint, scheme) - const editHaloColor = getColorForScheme(COLORS.editHalo, scheme) - const editActiveColor = getColorForScheme(COLORS.editActive, scheme) - const splitInvalidColor = getColorForScheme(COLORS.splitInvalid, scheme) - const splitValidColor = getColorForScheme(COLORS.splitValid, scheme) + const editStrokeColor = getValueForStyle(COLORS.editStroke, scheme) + const editFillColor = getValueForStyle(COLORS.editFill, scheme) + const editVertexColor = getValueForStyle(COLORS.editVertex, scheme) + const editMidpointColor = getValueForStyle(COLORS.editMidpoint, scheme) + const editHaloColor = getValueForStyle(COLORS.editHalo, scheme) + const editActiveColor = getValueForStyle(COLORS.editActive, scheme) + const splitInvalidColor = getValueForStyle(COLORS.splitInvalid, scheme) + const splitValidColor = getValueForStyle(COLORS.splitValid, scheme) const { vertexRadius, midpointRadius, vertexHaloRadius } = SIZES return [ diff --git a/plugins/beta/draw/src/adapters/openlayers/core/styles.js b/plugins/beta/draw/src/adapters/openlayers/core/styles.js index 53931072a..10ff1824d 100644 --- a/plugins/beta/draw/src/adapters/openlayers/core/styles.js +++ b/plugins/beta/draw/src/adapters/openlayers/core/styles.js @@ -67,8 +67,9 @@ export const createStyles = (colors) => { const sketchPointStyle = new Style({ image: new CircleStyle({ - radius: 5, - fill: new Fill({ color: colors.editVertex }) + radius: SIZES.mousePointerRadius, + fill: new Fill({ color: colors.mousePointer }), + stroke: new Stroke({ color: colors.mousePointerHalo, width: 1 }) }) }) diff --git a/plugins/beta/draw/src/adapters/openlayers/utils/resolveColors.js b/plugins/beta/draw/src/adapters/openlayers/utils/resolveColors.js index bc2ede957..4b723543b 100644 --- a/plugins/beta/draw/src/adapters/openlayers/utils/resolveColors.js +++ b/plugins/beta/draw/src/adapters/openlayers/utils/resolveColors.js @@ -1,5 +1,5 @@ import { COLORS, SIZES } from '../defaults.js' -import { getColorForScheme } from '../../../utils/getColorForScheme.js' +import { getValueForStyle } from '../../../utils/getColorForScheme.js' /** * Resolve all draw-ol colors for the given map style and plugin config overrides. @@ -14,9 +14,11 @@ import { getColorForScheme } from '../../../utils/getColorForScheme.js' export const resolveColors = (mapStyle, pluginConfig = {}) => { const scheme = mapStyle?.mapColorScheme ?? 'light' const styleId = mapStyle?.id ?? null - const resolveColor = (key) => getColorForScheme(pluginConfig[key] ?? COLORS[key], scheme, styleId) + const resolveColor = (key) => getValueForStyle(pluginConfig[key] ?? COLORS[key], scheme, styleId) return { + mousePointer: resolveColor('mousePointer'), + mousePointerHalo: resolveColor('mousePointerHalo'), editStroke: resolveColor('editStroke'), editFill: resolveColor('editFill'), editVertex: resolveColor('editVertex'), diff --git a/plugins/beta/draw/src/defaults.js b/plugins/beta/draw/src/defaults.js index f2141ead0..db8aa54d7 100644 --- a/plugins/beta/draw/src/defaults.js +++ b/plugins/beta/draw/src/defaults.js @@ -1,28 +1,35 @@ -const PRIMARY_LIGHT = 'rgba(29,112,184,1)' -const PRIMARY_DARK = '#ffffff' -const EDIT_LIGHT_TINT = 'rgba(29,112,184,0.1)' -const EDIT_DARK_TINT = 'rgba(255,255,255,0.1)' -const SHAPE_STROKE_COLOR = 'rgba(212,53,28,1)' -const SHAPE_STROKE_TINT = 'rgba(212,53,28,0.5)' -const SNAP_MIDPOINT_COLOR = 'rgba(40,161,151,1)' +const BLUE = 'rgba(29,112,184,1)' +const WHITE = '#ffffff' +const BLACK = 'rgba(11,12,12,1)' +const LIGHT_BLUE = 'rgba(29,112,184,0.1)' +const LIGHT_WHITE = 'rgba(255,255,255,0.1)' +const RED = 'rgba(212,53,28,1)' +const MID_ORANGE = 'rgba(212,53,28,0.5)' +const MID_BLUE = 'rgba(29,112,184,0.5)' +const GREEN = 'rgba(40,161,151,1)' export const COLORS = { - editStroke: { light: PRIMARY_LIGHT, dark: PRIMARY_DARK }, - editFill: { light: EDIT_LIGHT_TINT, dark: EDIT_DARK_TINT }, - editVertex: { light: PRIMARY_LIGHT, dark: PRIMARY_DARK }, - editMidpoint: { light: PRIMARY_LIGHT, dark: PRIMARY_DARK }, - editHalo: { light: PRIMARY_DARK, dark: 'rgba(11,12,12,1)' }, - editActive: { light: 'rgba(11,12,12,1)', dark: PRIMARY_DARK }, - splitInvalid: PRIMARY_LIGHT, - splitValid: PRIMARY_LIGHT, - shapeStroke: SHAPE_STROKE_COLOR, - shapeFill: SHAPE_STROKE_TINT, - snapVertex: SHAPE_STROKE_TINT, - snapMidpoint: SNAP_MIDPOINT_COLOR, - snapEdge: 'rgba(29,112,184,0.5)' + mousePointer: { light: BLUE, dark: WHITE }, + mousePointerHalo: { light: WHITE, dark: BLACK }, + drawPointer: { light: BLUE, dark: WHITE }, + drawPointerHalo: { light: WHITE, dark: BLACK }, + editStroke: { light: BLUE, dark: WHITE }, + editFill: { light: LIGHT_BLUE, dark: LIGHT_WHITE }, + editVertex: { light: BLUE, dark: WHITE }, + editMidpoint: { light: BLUE, dark: WHITE }, + editHalo: { light: WHITE, dark: BLACK }, + editActive: { light: BLACK, dark: WHITE }, + splitInvalid: BLUE, + splitValid: BLUE, + shapeStroke: RED, + shapeFill: MID_ORANGE, + snapVertex: MID_ORANGE, + snapMidpoint: GREEN, + snapEdge: MID_BLUE } export const SIZES = { + mousePointerRadius: 4, strokeWidth: 2, vertexRadius: 6, midpointRadius: 4, diff --git a/plugins/beta/draw/src/utils/getColorForScheme.js b/plugins/beta/draw/src/utils/getColorForScheme.js index ca43fef2e..8d9ea815e 100644 --- a/plugins/beta/draw/src/utils/getColorForScheme.js +++ b/plugins/beta/draw/src/utils/getColorForScheme.js @@ -1,30 +1,36 @@ /** - * Resolve a color value which may be either a string (same for all schemes) + * Resolve a configuration value which may be either a simple value (same for all styles) * or an object with scheme/style variants. * + * Useful for resolving any value (colors, sizes, etc.) across different map styles and color schemes. + * Can resolve based on both style ID and color scheme. + * * Resolution order for objects: * 1. Exact style ID match (e.g., { outdoor: '...', dark: '...' }) * 2. Scheme match (e.g., { light: '...', dark: '...' }) * 3. Fallback to 'light' property * 4. First value in object * - * @param {string|object} colorValue - Color as string or variant object - * @param {string} scheme - Current scheme ('light' or 'dark') + * @param {any} value - Simple value or variant object + * @param {string} scheme - Current color scheme ('light' or 'dark') * @param {string|null} styleId - Map style ID for per-style customization - * @returns {string} Resolved color value + * @returns {any} Resolved value */ -export const getColorForScheme = (colorValue, scheme, styleId = null) => { - if (typeof colorValue !== 'object' || colorValue === null) { - return colorValue +export const getValueForStyle = (value, scheme, styleId = null) => { + if (typeof value !== 'object' || value === null) { + return value } - if (styleId && colorValue[styleId] !== undefined) { - return colorValue[styleId] + if (styleId && value[styleId] !== undefined) { + return value[styleId] } - if (colorValue[scheme] !== undefined) { - return colorValue[scheme] + if (value[scheme] !== undefined) { + return value[scheme] } - if (colorValue.light !== undefined) { - return colorValue.light + if (value.light !== undefined) { + return value.light } - return Object.values(colorValue)[0] + return Object.values(value)[0] } + +// Legacy alias for backwards compatibility +export const getColorForScheme = getValueForStyle diff --git a/plugins/interact/src/hooks/useHighlightSync.js b/plugins/interact/src/hooks/useHighlightSync.js index 576870188..002a6bb7e 100755 --- a/plugins/interact/src/hooks/useHighlightSync.js +++ b/plugins/interact/src/hooks/useHighlightSync.js @@ -33,6 +33,7 @@ export const useHighlightSync = ({ const activeFeatures = listboxActiveItem ? [{ featureId: listboxActiveItem.featureId, layerId: listboxActiveItem.layerId, idProperty: listboxActiveItem.idProperty, geometry: listboxActiveItem.geometry }] : [] + console.log('[interact-highlight-sync] updateHighlightedFeatures: mapProvider.updateHighlightedFeatures =', !!mapProvider?.updateHighlightedFeatures, 'stylesMap =', stylesMap) mapProvider.updateHighlightedFeatures?.(selectedFeatures, activeFeatures, stylesMap) } diff --git a/plugins/interact/src/utils/buildStylesMap.js b/plugins/interact/src/utils/buildStylesMap.js index 720ee2bfe..00837b7e4 100755 --- a/plugins/interact/src/utils/buildStylesMap.js +++ b/plugins/interact/src/utils/buildStylesMap.js @@ -21,9 +21,12 @@ export const buildStylesMap = (dataLayers, mapStyle) => { const stylesMap = {} if (!mapStyle) { + console.warn('[interact] buildStylesMap: mapStyle is null/undefined, cannot build styles') return stylesMap } + console.log('[interact] buildStylesMap: mapStyle =', mapStyle.id, 'mapColorScheme =', mapStyle.mapColorScheme) + const scheme = THEME_COLORS[mapStyle.mapColorScheme] ?? THEME_COLORS.light const schemeActiveColor = mapStyle.activeColor ?? scheme.activeColor const schemeSelectedColor = mapStyle.selectedColor ?? scheme.selectedColor diff --git a/providers/maplibre/src/utils/highlightFeatures.js b/providers/maplibre/src/utils/highlightFeatures.js index 2fdaaf09b..7c3286e81 100755 --- a/providers/maplibre/src/utils/highlightFeatures.js +++ b/providers/maplibre/src/utils/highlightFeatures.js @@ -241,6 +241,7 @@ export function updateHighlightedFeatures ({ LngLatBounds, map, selectedFeatures if (!map) { return null } + console.log('[maplibre-highlight] updateHighlightedFeatures called with selectedFeatures:', selectedFeatures?.length, 'activeFeatures:', activeFeatures?.length, 'stylesMap keys:', Object.keys(stylesMap || {})) // Active cursor features — rendered first so selected layers appear on top if (activeFeatures?.length) { applyFeatureHighlights(map, activeFeatures, stylesMap, ACTIVE_PREFIX, getActiveImageId) From 8ee16ed96946ab3de8dc799b8e2f4848f837b280 Mon Sep 17 00:00:00 2001 From: Dan Leech Date: Thu, 2 Jul 2026 18:53:17 +0100 Subject: [PATCH 13/89] OL snap at tile buffers fix --- .../adapters/openlayers/snap/snapEngine.js | 135 +++++++++++++----- .../adapters/openlayers/snap/snapGeometry.js | 59 +++++--- 2 files changed, 141 insertions(+), 53 deletions(-) diff --git a/plugins/beta/draw/src/adapters/openlayers/snap/snapEngine.js b/plugins/beta/draw/src/adapters/openlayers/snap/snapEngine.js index 35b7f7039..bb277a27c 100644 --- a/plugins/beta/draw/src/adapters/openlayers/snap/snapEngine.js +++ b/plugins/beta/draw/src/adapters/openlayers/snap/snapEngine.js @@ -1,13 +1,14 @@ import { transform as projTransform } from 'ol/proj.js' import VectorLayer from 'ol/layer/Vector.js' import VectorTileLayer from 'ol/layer/VectorTile.js' -import { testOLFeature, testRenderFeature } from './snapGeometry.js' - -// VectorTile features are clipped to tile extents, producing artificial straight edges at tile -// boundaries. MVT tiles also carry a buffer region (geometry from adjacent tiles extending -// past the tile boundary). Both the exact boundary and the buffer zone must be filtered. -// Standard MVT buffer is 128 out of 4096 tile coordinate units. -const TILE_BOUNDARY_EPS = 1 // source-projection units of margin beyond the buffer +import { testOLFeature, testRenderFeature, bestOf } from './snapGeometry.js' + +// VectorTile geometry is clipped to a rectangle (tile extent + buffer), producing fake +// axis-aligned segments — and fake vertices where the clip cut the ring — that don't +// exist in the real data and mustn't be snap targets. They are detected by orientation: +// clip segments are exactly axis-aligned in tile space AND sit near a tile edge, a +// combination real boundaries essentially never produce. This works without knowing +// the exact buffer size the tiler used. Standard MVT buffer is 128/4096 tile units. const MVT_BUFFER_UNITS = 128 const MVT_TILE_EXTENT = 4096 @@ -21,24 +22,58 @@ const buildTileBoundaryState = (layer, map) => { return { tileGrid, viewProj, sourceProj, zoom } } -const isOnTileBoundary = (state, coord) => { - if (!state) { return false } +// Per-candidate context: the candidate's tile extent plus the two tolerances, +// computed once so both adjacent segments of a vertex reuse it. +const getClipContext = (state, coord) => { + if (!state) { return null } const { tileGrid, viewProj, sourceProj, zoom } = state - const c = projTransform(coord, viewProj, sourceProj) - const tileCoord = tileGrid.getTileCoordForCoordAndZ(c, zoom) - const [minX, minY, maxX, maxY] = tileGrid.getTileCoordExtent(tileCoord) - // Buffer zone in source projection: edges within this distance of a tile boundary are - // MVT buffer clip artefacts (geometry from the adjacent tile included for rendering overlap). - const tileBuffer = (maxX - minX) * (MVT_BUFFER_UNITS / MVT_TILE_EXTENT) - const eps = tileBuffer + TILE_BOUNDARY_EPS - return ( - Math.abs(c[0] - minX) < eps || - Math.abs(c[0] - maxX) < eps || - Math.abs(c[1] - minY) < eps || - Math.abs(c[1] - maxY) < eps - ) + const toSource = (c) => c ? projTransform(c, viewProj, sourceProj) : null + const anchor = toSource(coord) + const tileCoord = tileGrid.getTileCoordForCoordAndZ(anchor, zoom) + const extent = tileGrid.getTileCoordExtent(tileCoord) + const tileWidth = extent[2] - extent[0] + return { + toSource, + anchor, + extent, + // Coarse gate: within twice the standard MVT buffer of a tile edge. Generous on + // purpose (the axis-alignment test does the real discrimination) so tilers with + // a non-standard buffer size are still covered. Relative to tile width, so it is + // projection-unit independent. + band: tileWidth * (2 * MVT_BUFFER_UNITS / MVT_TILE_EXTENT), + // MVT coordinates are quantized to 1/4096 of the tile extent; two quantization + // steps of tolerance decides whether a segment is exactly axis-aligned. + eps: 2 * (tileWidth / MVT_TILE_EXTENT) + } +} + +const isArtefactSegment = (ctx, pa, pb) => { + if (!pa || !pb) { return false } + const [minX, minY, maxX, maxY] = ctx.extent + const nearEdge = (v, lo, hi) => Math.min(Math.abs(v - lo), Math.abs(v - hi)) <= ctx.band + const vertical = Math.abs(pa[0] - pb[0]) <= ctx.eps && nearEdge(pa[0], minX, maxX) + const horizontal = Math.abs(pa[1] - pb[1]) <= ctx.eps && nearEdge(pa[1], minY, maxY) + return vertical || horizontal } +// Edges: artefact when the snapped segment itself is an axis-aligned boundary segment. +// Vertices: fake vertices are the points where clipping cut the ring, i.e. endpoints +// of artefact segments — test both adjacent segments. +const isClipArtefact = (state, candidate) => { + const ctx = getClipContext(state, candidate.coord) + if (!ctx) { return false } + if (candidate.type === 'edge') { + const [a, b] = candidate.seg ?? [] + return isArtefactSegment(ctx, ctx.toSource(a), ctx.toSource(b)) + } + const [prev, next] = candidate.adjacent ?? [] + return isArtefactSegment(ctx, ctx.anchor, ctx.toSource(prev)) || + isArtefactSegment(ctx, ctx.anchor, ctx.toSource(next)) +} + +// Read at call time so it can be toggled from the console at any point: window.DEBUG_SNAP_VISIBILITY = true +const isDebugEnabled = () => globalThis.DEBUG_SNAP_VISIBILITY === true + // Two same-style fill polygons share an invisible boundary — snapping to it is confusing. // Detect this by projecting a test point slightly past the snap position (away from the cursor) // and checking whether the same fill layer covers that side too. @@ -63,12 +98,42 @@ const isInvisibleFillBoundary = (edgeCoord, cursorCoord, layerId, map, vtLayers, ) } -const pickBest = (a, b) => { - if (!b) { return a } - if (!a) { return b } - if (a.type === 'vertex' && b.type === 'edge') { return a } - if (a.type === 'edge' && b.type === 'vertex') { return b } - return b.distSq < a.distSq ? b : a +const logCandidate = (candidate, mapboxLayer) => { + if (!isDebugEnabled()) { return } + console.log('[snap-candidate-found]', { + type: candidate.type, + layerId: mapboxLayer?.id, + layerType: mapboxLayer?.type, + coord: [candidate.coord[0].toFixed(2), candidate.coord[1].toFixed(2)], + distSq: candidate.distSq.toFixed(2) + }) +} + +// Returns true when the candidate is a rendering artefact rather than a visible +// snap target: either a tile clip artefact, or an invisible same-fill boundary. +const shouldFilterCandidate = ({ candidate, cursorCoord, mapboxLayer, boundaryState, map, vtLayers, resolution }) => { + if (isClipArtefact(boundaryState, candidate)) { + if (isDebugEnabled()) { console.log('[snap-filtered] tile clip artefact', candidate.type) } + return true + } + if (candidate.type === 'edge' && mapboxLayer?.type === 'fill' && + isInvisibleFillBoundary(candidate.coord, cursorCoord, mapboxLayer.id, map, vtLayers, resolution)) { + if (isDebugEnabled()) { console.log('[snap-filtered] invisible fill boundary') } + return true + } + return false +} + +// Folds a feature's candidates into the current best, skipping rendering artefacts +const pickVisibleCandidates = (best, candidates, context) => { + let result = best + for (const candidate of candidates) { + logCandidate(candidate, context.mapboxLayer) + if (!shouldFilterCandidate({ candidate, ...context })) { + result = bestOf(result, candidate) + } + } + return result } // Collected on each query — VectorTileLayers are replaced when the map style changes @@ -101,6 +166,9 @@ export const createSnapEngine = (map, snapLayers = []) => { setLayers(snapLayers) const query = (coord, radiusPx) => { + if (isDebugEnabled()) { + console.log('[snap-query]', { coord: [coord[0].toFixed(2), coord[1].toFixed(2)], radiusPx, vtLayerCount: vtLayerNames.size, olLayerCount: olLayers.length }) + } const resolution = map.getView().getResolution() if (!resolution) { return null } const toleranceMapUnits = radiusPx * resolution @@ -118,7 +186,7 @@ export const createSnapEngine = (map, snapLayers = []) => { const source = layer.getSource() if (!source) { continue } for (const feature of source.getFeaturesInExtent(ext)) { - best = pickBest(best, testOLFeature(feature, coord, toleranceSq)) + best = bestOf(best, testOLFeature(feature, coord, toleranceSq)) } } @@ -132,14 +200,13 @@ export const createSnapEngine = (map, snapLayers = []) => { (feature, layer) => { const mapboxLayer = feature.get('mapbox-layer') if (!vtLayerNames.has(mapboxLayer?.id)) { return } - const candidate = testRenderFeature(feature, coord, toleranceSq) - if (!candidate) { return } + const candidates = testRenderFeature(feature, coord, toleranceSq) + if (!candidates.length) { return } if (!tileBoundaryStates.has(layer)) { tileBoundaryStates.set(layer, buildTileBoundaryState(layer, map)) } - if (isOnTileBoundary(tileBoundaryStates.get(layer), candidate.coord)) { return } - if (candidate.type === 'edge' && mapboxLayer?.type === 'fill' && isInvisibleFillBoundary(candidate.coord, coord, mapboxLayer.id, map, vtLayers, resolution)) { return } - best = pickBest(best, candidate) + const boundaryState = tileBoundaryStates.get(layer) + best = pickVisibleCandidates(best, candidates, { cursorCoord: coord, mapboxLayer, boundaryState, map, vtLayers, resolution }) }, { hitTolerance: radiusPx, layerFilter: (l) => vtLayers.includes(l) } ) diff --git a/plugins/beta/draw/src/adapters/openlayers/snap/snapGeometry.js b/plugins/beta/draw/src/adapters/openlayers/snap/snapGeometry.js index 16aecebd4..272b2a3b5 100644 --- a/plugins/beta/draw/src/adapters/openlayers/snap/snapGeometry.js +++ b/plugins/beta/draw/src/adapters/openlayers/snap/snapGeometry.js @@ -15,7 +15,8 @@ const closestPointOnSegment = (p, a, b) => { return [a[0] + t * dx, a[1] + t * dy] } -const bestOf = (current, candidate) => better(current, candidate) ? candidate : current +// Exported for the snap engine to merge candidates across features/layers +export const bestOf = (current, candidate) => better(current, candidate) ? candidate : current const better = (a, b) => { if (!a) { @@ -62,6 +63,9 @@ const testCoords = (coords, query, toleranceSq, isClosedRing) => { return best } +// Edge candidates carry their segment endpoints (seg) and vertex candidates their +// neighbouring vertices (adjacent) so the snap engine can test segment orientation — +// used to recognise tile clip artefacts, which are always axis-aligned in tile space. const getBestEdge = (flat, start, numPairs, edgeCount, query, toleranceSq) => { let best = null for (let i = 0; i < edgeCount; i++) { @@ -72,36 +76,47 @@ const getBestEdge = (flat, start, numPairs, edgeCount, query, toleranceSq) => { const pt = closestPointOnSegment(query, a, b) const dSq = dist2(query, pt) if (dSq <= toleranceSq) { - best = bestOf(best, { type: 'edge', coord: pt, distSq: dSq }) + best = bestOf(best, { type: 'edge', coord: pt, distSq: dSq, seg: [a, b] }) } } return best } -const getBestVertex = (flat, start, numPairs, query, toleranceSq) => { +// Neighbouring vertex indices; closed rings (edgeCount === numPairs) wrap around the ring +const adjacentIndices = (i, numPairs, wraps) => { + const prev = i > 0 ? i - 1 : numPairs - 1 + const next = i < numPairs - 1 ? i + 1 : 0 + return [ + (i > 0 || wraps) ? prev : null, + (i < numPairs - 1 || wraps) ? next : null + ] +} + +const getBestVertex = (flat, start, numPairs, edgeCount, query, toleranceSq) => { + const wraps = edgeCount === numPairs + const coordAt = (i) => i === null ? null : [flat[start + i * 2], flat[start + i * 2 + 1]] let best = null for (let i = 0; i < numPairs; i++) { - const xi = start + i * 2 - const v = [flat[xi], flat[xi + 1]] + const v = coordAt(i) const dSq = dist2(query, v) if (dSq <= toleranceSq) { - best = bestOf(best, { type: 'vertex', coord: v, distSq: dSq }) + const [prevIdx, nextIdx] = adjacentIndices(i, numPairs, wraps) + const adjacent = [coordAt(prevIdx), coordAt(nextIdx)] + best = bestOf(best, { type: 'vertex', coord: v, distSq: dSq, adjacent }) } } return best } -const getBestPair = (flat, start, numPairs, edgeCount, query, toleranceSq) => { - return bestOf( - getBestVertex(flat, start, numPairs, query, toleranceSq), - getBestEdge(flat, start, numPairs, edgeCount, query, toleranceSq) - ) -} - +// Returns the best vertex and best edge separately so the caller can filter one +// (e.g. a clip-artefact vertex) while still snapping to the other. const testFlatCoords = (flat, start, end, query, toleranceSq, isClosedRing) => { const numPairs = (end - start) / 2 const edgeCount = isClosedRing ? numPairs : numPairs - 1 - return getBestPair(flat, start, numPairs, edgeCount, query, toleranceSq) + return { + vertex: getBestVertex(flat, start, numPairs, edgeCount, query, toleranceSq), + edge: getBestEdge(flat, start, numPairs, edgeCount, query, toleranceSq) + } } const olGeomHandlers = { @@ -149,29 +164,35 @@ export const testOLFeature = (feature, query, toleranceSq) => { return handler ? handler(geom, query, toleranceSq) : null } +// Returns an array of candidates (best vertex and best edge, when within tolerance) +// rather than a single winner, so the snap engine can filter clip artefacts per +// candidate and still fall back to the other. export const testRenderFeature = (feature, query, toleranceSq) => { const type = feature.getType() const flat = feature.getFlatCoordinates() - let best = null + let bestVertex = null + let bestEdge = null if (type === 'Point') { const dSq = dist2(query, flat) if (dSq <= toleranceSq) { - best = { type: 'vertex', coord: [flat[0], flat[1]], distSq: dSq } + bestVertex = { type: 'vertex', coord: [flat[0], flat[1]], distSq: dSq } } } else if (type === 'LineString') { - best = testFlatCoords(flat, 0, flat.length, query, toleranceSq, false) + ({ vertex: bestVertex, edge: bestEdge } = testFlatCoords(flat, 0, flat.length, query, toleranceSq, false)) } else if (type === 'Polygon' || type === 'MultiLineString') { const ends = feature.getEnds() let start = 0 const isClosedRing = type === 'Polygon' for (const end of ends) { - best = bestOf(best, testFlatCoords(flat, start, end, query, toleranceSq, isClosedRing)) + const pair = testFlatCoords(flat, start, end, query, toleranceSq, isClosedRing) + bestVertex = bestOf(bestVertex, pair.vertex) + bestEdge = bestOf(bestEdge, pair.edge) start = end } } else { // MultiPoint / unknown — no snap candidates } - return best + return [bestVertex, bestEdge].filter(Boolean) } From e2cc38e0f37bef088a12f6dab373b6b994e4b33f Mon Sep 17 00:00:00 2001 From: Dan Leech Date: Thu, 2 Jul 2026 19:13:32 +0100 Subject: [PATCH 14/89] Refactoring and de duplication --- .../draw/src/adapters/maplibre/defaults.js | 2 +- .../draw/src/adapters/maplibre/mapboxDraw.js | 126 ++---------------- .../modes/editVertex/touchHandlers.js | 2 +- .../modes/editVertex/vertexOperations.js | 2 +- .../beta/draw/src/adapters/maplibre/styles.js | 2 +- .../draw/src/adapters/maplibre/undoStack.js | 36 ----- .../maplibre/utils/cursorIndicator.js | 92 +++++++++++++ .../maplibre/utils/mouseCursorIndicator.js | 76 ----------- .../maplibre/utils/touchClickWorkaround.js | 57 ++++++++ .../adapters/openlayers/core/OLDrawManager.js | 2 +- .../draw/src/adapters/openlayers/defaults.js | 2 +- .../openlayers/edit/keyboardHandler.js | 2 +- .../draw/src/adapters/openlayers/olDraw.js | 3 +- .../openlayers/utils/resolveColors.js | 2 +- plugins/beta/draw/src/api/editFeature.js | 4 +- plugins/beta/draw/src/defaults.js | 7 + ...tColorForScheme.js => getValueForStyle.js} | 3 - .../openlayers/core => utils}/undoStack.js | 0 18 files changed, 183 insertions(+), 237 deletions(-) delete mode 100644 plugins/beta/draw/src/adapters/maplibre/undoStack.js create mode 100644 plugins/beta/draw/src/adapters/maplibre/utils/cursorIndicator.js delete mode 100644 plugins/beta/draw/src/adapters/maplibre/utils/mouseCursorIndicator.js create mode 100644 plugins/beta/draw/src/adapters/maplibre/utils/touchClickWorkaround.js rename plugins/beta/draw/src/utils/{getColorForScheme.js => getValueForStyle.js} (92%) rename plugins/beta/draw/src/{adapters/openlayers/core => utils}/undoStack.js (100%) diff --git a/plugins/beta/draw/src/adapters/maplibre/defaults.js b/plugins/beta/draw/src/adapters/maplibre/defaults.js index 4f3f12002..c92e6ee24 100644 --- a/plugins/beta/draw/src/adapters/maplibre/defaults.js +++ b/plugins/beta/draw/src/adapters/maplibre/defaults.js @@ -1 +1 @@ -export { COLORS, SIZES, TOLERANCES } from '../../defaults.js' +export { COLORS, SIZES, TOLERANCES, KEYBOARD, MAP_SIZE_SCALES } from '../../defaults.js' diff --git a/plugins/beta/draw/src/adapters/maplibre/mapboxDraw.js b/plugins/beta/draw/src/adapters/maplibre/mapboxDraw.js index 6d0cde6ca..0932e02b8 100755 --- a/plugins/beta/draw/src/adapters/maplibre/mapboxDraw.js +++ b/plugins/beta/draw/src/adapters/maplibre/mapboxDraw.js @@ -5,9 +5,11 @@ import { DrawPolygonMode } from './modes/drawPolygonMode.js' import { DrawLineMode } from './modes/drawLineMode.js' import { createDrawStyles, updateDrawStyles } from './styles.js' import { initMapLibreSnap } from './mapboxSnap.js' -import { createUndoStack } from './undoStack.js' +import { createUndoStack } from '../../utils/undoStack.js' +import { setupCursorIndicator } from './utils/cursorIndicator.js' +import { setupTouchClickWorkaround } from './utils/touchClickWorkaround.js' import { applyTouchVertexColors } from './modes/editVertex/touchHandlers.js' -import { COLORS, SIZES, TOLERANCES } from './defaults.js' +import { TOLERANCES, MAP_SIZE_SCALES } from './defaults.js' /** * Creates and manages a MapLibre/Mapbox Draw control instance configured for polygon editing. @@ -58,44 +60,8 @@ export const createMapboxDraw = ({ mapStyle, mapProvider, events, eventBus, snap Object.assign(draw.modes, modes) } - // Workaround: mapbox-gl-draw calls preventDefault() on touchend even in disabled mode, - // which prevents the browser from synthesizing a click event. We detect taps and - // manually dispatch a click event when in disabled mode. - const canvas = map.getCanvas() - let touchStart = null - - const handleTouchStart = (e) => { - if (e.touches.length === 1) { - touchStart = { x: e.touches[0].clientX, y: e.touches[0].clientY, time: Date.now() } - } - } - - const handleTouchEnd = (e) => { - if (draw.getMode() !== 'disabled' || !touchStart) { - touchStart = null - return - } - - const touch = e.changedTouches[0] - const dx = touch.clientX - touchStart.x - const dy = touch.clientY - touchStart.y - const duration = Date.now() - touchStart.time - - // Only synthesize click for quick taps with minimal movement - if (duration < 300 && Math.abs(dx) < 10 && Math.abs(dy) < 10) { - canvas.dispatchEvent(new MouseEvent('click', { - bubbles: true, - cancelable: true, - clientX: touch.clientX, - clientY: touch.clientY - })) - } - - touchStart = null - } - - canvas.addEventListener('touchstart', handleTouchStart, { passive: true }) - canvas.addEventListener('touchend', handleTouchEnd, { passive: true }) + // mapbox-gl-draw swallows tap clicks in disabled mode — synthesize them + const touchClickWorkaround = setupTouchClickWorkaround(map, draw) // We need a reference to this mapProvider.draw = draw @@ -105,7 +71,7 @@ export const createMapboxDraw = ({ mapStyle, mapProvider, events, eventBus, snap // Initialize undo stack (reuse if already exists) let undoStack = mapProvider.undoStack if (!undoStack) { - undoStack = createUndoStack(map) + undoStack = createUndoStack((length) => map.fire('draw.undochange', { length })) mapProvider.undoStack = undoStack } map._undoStack = undoStack @@ -118,79 +84,15 @@ export const createMapboxDraw = ({ mapStyle, mapProvider, events, eventBus, snap rules: ['vertex', 'edge'] }) - // --- Setup cursor indicator for draw modes --- - const CURSOR_SOURCE = 'draw-cursor-indicator' - const CURSOR_LAYER = 'draw-cursor-indicator-layer' - const cursorIndicatorSource = { - type: 'geojson', - data: { type: 'FeatureCollection', features: [] } - } - if (!map.getSource(CURSOR_SOURCE)) { - map.addSource(CURSOR_SOURCE, cursorIndicatorSource) - } - - const resolveColor = (colorConfig, styleId) => { - if (typeof colorConfig === 'string') { - return colorConfig - } - if (typeof colorConfig === 'object' && colorConfig !== null) { - return styleId && colorConfig[styleId] ? colorConfig[styleId] : Object.values(colorConfig)[0] ?? null - } - return null - } - - const ensureCursorLayer = () => { - if (!map.getLayer(CURSOR_LAYER)) { - const pointerColor = resolveColor(COLORS.mousePointer, mapStyle?.id) - const pointerHaloColor = resolveColor(COLORS.mousePointerHalo, mapStyle?.id) - map.addLayer({ - id: CURSOR_LAYER, - type: 'circle', - source: CURSOR_SOURCE, - paint: { - 'circle-radius': SIZES.mousePointerRadius, - 'circle-color': pointerColor, - 'circle-stroke-width': 1, - 'circle-stroke-color': pointerHaloColor - } - }) - } - } - - let cursorIndicatorActive = false - const handleMapMouseMove = (e) => { - if (!cursorIndicatorActive) { - return - } - const feature = { - type: 'Feature', - geometry: { type: 'Point', coordinates: [e.lngLat.lng, e.lngLat.lat] } - } - map.getSource(CURSOR_SOURCE).setData({ - type: 'FeatureCollection', - features: [feature] - }) - } - - const originalChangeMode = draw.changeMode.bind(draw) - draw.changeMode = function (mode, opts) { - const isDrawMode = ['draw_polygon', 'draw_line'].includes(mode) - cursorIndicatorActive = isDrawMode - if (isDrawMode) { - ensureCursorLayer() - map.on('mousemove', handleMapMouseMove) - } else { - map.off('mousemove', handleMapMouseMove) - map.getSource(CURSOR_SOURCE).setData({ type: 'FeatureCollection', features: [] }) - } - return originalChangeMode(mode, opts) - } + // --- Mouse cursor indicator during draw modes --- + const cursorIndicator = setupCursorIndicator(map, draw) // --- Update colour scheme --- const handleSetMapStyle = (e) => { map._drawCurrentMapStyle = e map.once('idle', () => { updateDrawStyles(map, e) + cursorIndicator.refreshColors() const svg = map._drawEditContainer?.querySelector('[data-im-draw-touch-target]') applyTouchVertexColors(svg, e) }) @@ -208,7 +110,7 @@ export const createMapboxDraw = ({ mapStyle, mapProvider, events, eventBus, snap // --- Update map scale --- const handleSetMapSize = (e) => { - map.fire('draw.scalechange', { scale: { small: 1, medium: 1.5, large: 2 }[e] }) + map.fire('draw.scalechange', { scale: MAP_SIZE_SCALES[e] }) } eventBus.on(events.MAP_SET_SIZE, handleSetMapSize) @@ -216,15 +118,15 @@ export const createMapboxDraw = ({ mapStyle, mapProvider, events, eventBus, snap return { draw, remove () { - // Remove touch workaround listeners - canvas.removeEventListener('touchstart', handleTouchStart) - canvas.removeEventListener('touchend', handleTouchEnd) + touchClickWorkaround.remove() // Remove event listeners eventBus.off(events.MAP_SET_STYLE, handleSetMapStyle) eventBus.off(events.MAP_SET_SIZE, handleSetMapSize) map.off('draw.interfacetypechange', handleDrawInterfaceTypeChange) // Disable draw mode but keep control on map for reuse draw.changeMode('disabled') + // Unwrap changeMode so wrappers don't stack when the adapter is recreated + cursorIndicator.remove() // Clear adapter reference (but not _mapboxDrawInstance so it persists) mapProvider.draw = null } diff --git a/plugins/beta/draw/src/adapters/maplibre/modes/editVertex/touchHandlers.js b/plugins/beta/draw/src/adapters/maplibre/modes/editVertex/touchHandlers.js index 9b4c0c91f..325e2f3a6 100644 --- a/plugins/beta/draw/src/adapters/maplibre/modes/editVertex/touchHandlers.js +++ b/plugins/beta/draw/src/adapters/maplibre/modes/editVertex/touchHandlers.js @@ -6,7 +6,7 @@ import { coordPathToFlatIndex } from './geometryHelpers.js' import { isOnSVG } from './helpers.js' import { createTouchTarget, applyTouchTargetColors } from '../../../../utils/touchTarget.js' import { COLORS } from '../../defaults.js' -import { getValueForStyle } from '../../../../utils/getColorForScheme.js' +import { getValueForStyle } from '../../../../utils/getValueForStyle.js' export const applyTouchVertexColors = (el, mapStyle) => { if (!el) { return } diff --git a/plugins/beta/draw/src/adapters/maplibre/modes/editVertex/vertexOperations.js b/plugins/beta/draw/src/adapters/maplibre/modes/editVertex/vertexOperations.js index eadca36cf..d4d782bff 100644 --- a/plugins/beta/draw/src/adapters/maplibre/modes/editVertex/vertexOperations.js +++ b/plugins/beta/draw/src/adapters/maplibre/modes/editVertex/vertexOperations.js @@ -4,7 +4,7 @@ import { getSegmentForIndex, getModifiableCoords } from './geometryHelpers.js' -import { KEYBOARD } from '../../../../defaults.js' +import { KEYBOARD } from '../../defaults.js' const ARROW_OFFSETS = { ArrowUp: [0, -1], ArrowDown: [0, 1], ArrowLeft: [-1, 0], ArrowRight: [1, 0] } diff --git a/plugins/beta/draw/src/adapters/maplibre/styles.js b/plugins/beta/draw/src/adapters/maplibre/styles.js index 0d6af6b43..77760ce93 100755 --- a/plugins/beta/draw/src/adapters/maplibre/styles.js +++ b/plugins/beta/draw/src/adapters/maplibre/styles.js @@ -1,6 +1,6 @@ // styles.js import { COLORS, SIZES } from './defaults.js' -import { getValueForStyle } from '../../utils/getColorForScheme.js' +import { getValueForStyle } from '../../utils/getValueForStyle.js' const getColorScheme = (mapStyle) => mapStyle.mapColorScheme ?? 'light' diff --git a/plugins/beta/draw/src/adapters/maplibre/undoStack.js b/plugins/beta/draw/src/adapters/maplibre/undoStack.js deleted file mode 100644 index 8e08122bb..000000000 --- a/plugins/beta/draw/src/adapters/maplibre/undoStack.js +++ /dev/null @@ -1,36 +0,0 @@ -/** - * Creates an undo stack manager for draw operations. - * Fires 'draw.undochange' events when stack changes for UI updates. - * - * @param {Object} map - MapLibre map instance - * @returns {Object} Undo stack manager - */ -export const createUndoStack = (map) => { - const stack = [] - - const fireChange = () => { - map.fire('draw.undochange', { length: stack.length }) - } - - return { - push (operation) { - stack.push(operation) - fireChange() - }, - - pop () { - const op = stack.pop() - fireChange() - return op - }, - - clear () { - stack.length = 0 - fireChange() - }, - - get length () { - return stack.length - } - } -} diff --git a/plugins/beta/draw/src/adapters/maplibre/utils/cursorIndicator.js b/plugins/beta/draw/src/adapters/maplibre/utils/cursorIndicator.js new file mode 100644 index 000000000..c5dc03e08 --- /dev/null +++ b/plugins/beta/draw/src/adapters/maplibre/utils/cursorIndicator.js @@ -0,0 +1,92 @@ +import { getValueForStyle } from '../../../utils/getValueForStyle.js' +import { COLORS, SIZES } from '../defaults.js' + +const CURSOR_SOURCE = 'draw-cursor-indicator' +const CURSOR_LAYER = 'draw-cursor-indicator-layer' +const EMPTY_FC = { type: 'FeatureCollection', features: [] } +const DRAW_MODES = new Set(['draw_polygon', 'draw_line']) + +/** + * Small filled circle that follows the mouse pointer during draw modes, showing + * where the next vertex will be placed (matches the OL sketch point behaviour). + * + * Wraps draw.changeMode to activate/deactivate with mode changes. Reads the live + * map style (map._drawCurrentMapStyle) so colours stay correct after style + * switches, and recreates its source/layer on draw-mode entry since a style + * switch wipes custom sources and layers. + * + * @param {Object} map - MapLibre map instance + * @param {Object} draw - MapboxDraw instance (its changeMode is wrapped) + * @returns {{ refreshColors: Function, remove: Function }} + */ +export const setupCursorIndicator = (map, draw) => { + const paintColors = () => { + const style = map._drawCurrentMapStyle + const scheme = style?.mapColorScheme ?? 'light' + return { + 'circle-color': getValueForStyle(COLORS.mousePointer, scheme, style?.id), + 'circle-stroke-color': getValueForStyle(COLORS.mousePointerHalo, scheme, style?.id) + } + } + + const ensureLayer = () => { + if (!map.getSource(CURSOR_SOURCE)) { + map.addSource(CURSOR_SOURCE, { type: 'geojson', data: EMPTY_FC }) + } + if (!map.getLayer(CURSOR_LAYER)) { + map.addLayer({ + id: CURSOR_LAYER, + type: 'circle', + source: CURSOR_SOURCE, + paint: { + 'circle-radius': SIZES.mousePointerRadius, + 'circle-stroke-width': 1, + ...paintColors() + } + }) + } + } + + let active = false + const onMouseMove = (e) => { + if (!active) { + return + } + map.getSource(CURSOR_SOURCE)?.setData({ + type: 'FeatureCollection', + features: [{ + type: 'Feature', + geometry: { type: 'Point', coordinates: [e.lngLat.lng, e.lngLat.lat] } + }] + }) + } + + const originalChangeMode = draw.changeMode.bind(draw) + draw.changeMode = (mode, opts) => { + active = DRAW_MODES.has(mode) + map.off('mousemove', onMouseMove) + if (active) { + ensureLayer() + map.on('mousemove', onMouseMove) + } else { + map.getSource(CURSOR_SOURCE)?.setData(EMPTY_FC) + } + return originalChangeMode(mode, opts) + } + + return { + // Re-apply colours after a map style change + refreshColors () { + if (map.getLayer(CURSOR_LAYER)) { + Object.entries(paintColors()).forEach(([prop, value]) => map.setPaintProperty(CURSOR_LAYER, prop, value)) + } + }, + + // Restores the original changeMode so wrappers don't stack when the + // adapter is recreated against the persistent MapboxDraw instance + remove () { + map.off('mousemove', onMouseMove) + draw.changeMode = originalChangeMode + } + } +} diff --git a/plugins/beta/draw/src/adapters/maplibre/utils/mouseCursorIndicator.js b/plugins/beta/draw/src/adapters/maplibre/utils/mouseCursorIndicator.js deleted file mode 100644 index ffbaae65e..000000000 --- a/plugins/beta/draw/src/adapters/maplibre/utils/mouseCursorIndicator.js +++ /dev/null @@ -1,76 +0,0 @@ -const CURSOR_LAYER_ID = 'draw-mouse-cursor' -const CURSOR_SOURCE_ID = 'draw-mouse-cursor-source' - -export const createMouseCursorIndicator = (map) => { - let isActive = false - - const addLayer = () => { - if (map.getLayer(CURSOR_LAYER_ID)) { - return - } - - if (!map.getSource(CURSOR_SOURCE_ID)) { - map.addSource(CURSOR_SOURCE_ID, { - type: 'geojson', - data: { type: 'FeatureCollection', features: [] } - }) - } - - map.addLayer({ - id: CURSOR_LAYER_ID, - type: 'circle', - source: CURSOR_SOURCE_ID, - paint: { - 'circle-radius': 3, - 'circle-color': '#1a65a6', - 'circle-opacity': 0.8 - } - }) - } - - const removeLayer = () => { - if (map.getLayer(CURSOR_LAYER_ID)) { - map.removeLayer(CURSOR_LAYER_ID) - } - if (map.getSource(CURSOR_SOURCE_ID)) { - map.removeSource(CURSOR_SOURCE_ID) - } - } - - const updateCursor = (lngLat) => { - const source = map.getSource(CURSOR_SOURCE_ID) - if (source) { - source.setData({ - type: 'FeatureCollection', - features: [{ - type: 'Feature', - geometry: { type: 'Point', coordinates: [lngLat.lng, lngLat.lat] } - }] - }) - } - } - - return { - activate () { - if (isActive) { - return - } - isActive = true - addLayer() - }, - - deactivate () { - if (!isActive) { - return - } - isActive = false - removeLayer() - }, - - updateFromEvent (e) { - if (isActive) { - updateCursor(e.lngLat) - } - } - } -} diff --git a/plugins/beta/draw/src/adapters/maplibre/utils/touchClickWorkaround.js b/plugins/beta/draw/src/adapters/maplibre/utils/touchClickWorkaround.js new file mode 100644 index 000000000..6f8125802 --- /dev/null +++ b/plugins/beta/draw/src/adapters/maplibre/utils/touchClickWorkaround.js @@ -0,0 +1,57 @@ +const MAX_TAP_DURATION_MS = 300 +const MAX_TAP_MOVEMENT_PX = 10 + +/** + * Workaround: mapbox-gl-draw calls preventDefault() on touchend even in disabled + * mode, which stops the browser synthesizing a click event. Detect quick taps + * while in disabled mode and manually dispatch a click on the canvas so app-level + * click handlers (e.g. feature selection) still fire. + * + * @param {Object} map - MapLibre map instance + * @param {Object} draw - MapboxDraw instance (used to check the current mode) + * @returns {{ remove: Function }} + */ +export const setupTouchClickWorkaround = (map, draw) => { + const canvas = map.getCanvas() + let touchStart = null + + const handleTouchStart = (e) => { + if (e.touches.length === 1) { + touchStart = { x: e.touches[0].clientX, y: e.touches[0].clientY, time: Date.now() } + } + } + + const handleTouchEnd = (e) => { + if (draw.getMode() !== 'disabled' || !touchStart) { + touchStart = null + return + } + + const touch = e.changedTouches[0] + const dx = touch.clientX - touchStart.x + const dy = touch.clientY - touchStart.y + const duration = Date.now() - touchStart.time + + // Only synthesize click for quick taps with minimal movement + if (duration < MAX_TAP_DURATION_MS && Math.abs(dx) < MAX_TAP_MOVEMENT_PX && Math.abs(dy) < MAX_TAP_MOVEMENT_PX) { + canvas.dispatchEvent(new MouseEvent('click', { + bubbles: true, + cancelable: true, + clientX: touch.clientX, + clientY: touch.clientY + })) + } + + touchStart = null + } + + canvas.addEventListener('touchstart', handleTouchStart, { passive: true }) + canvas.addEventListener('touchend', handleTouchEnd, { passive: true }) + + return { + remove () { + canvas.removeEventListener('touchstart', handleTouchStart) + canvas.removeEventListener('touchend', handleTouchEnd) + } + } +} diff --git a/plugins/beta/draw/src/adapters/openlayers/core/OLDrawManager.js b/plugins/beta/draw/src/adapters/openlayers/core/OLDrawManager.js index cae10b257..78a642c6a 100644 --- a/plugins/beta/draw/src/adapters/openlayers/core/OLDrawManager.js +++ b/plugins/beta/draw/src/adapters/openlayers/core/OLDrawManager.js @@ -1,6 +1,6 @@ import VectorLayer from 'ol/layer/Vector.js' import { createFeatureStore } from './featureStore.js' -import { createUndoStack } from './undoStack.js' +import { createUndoStack } from '../../../utils/undoStack.js' import { createStyles } from './styles.js' import { resolveColors } from '../utils/resolveColors.js' import { createSnapManager } from '../snap/snapManager.js' diff --git a/plugins/beta/draw/src/adapters/openlayers/defaults.js b/plugins/beta/draw/src/adapters/openlayers/defaults.js index 4f3f12002..c92e6ee24 100644 --- a/plugins/beta/draw/src/adapters/openlayers/defaults.js +++ b/plugins/beta/draw/src/adapters/openlayers/defaults.js @@ -1 +1 @@ -export { COLORS, SIZES, TOLERANCES } from '../../defaults.js' +export { COLORS, SIZES, TOLERANCES, KEYBOARD, MAP_SIZE_SCALES } from '../../defaults.js' diff --git a/plugins/beta/draw/src/adapters/openlayers/edit/keyboardHandler.js b/plugins/beta/draw/src/adapters/openlayers/edit/keyboardHandler.js index e6495ed5c..335efd1d9 100644 --- a/plugins/beta/draw/src/adapters/openlayers/edit/keyboardHandler.js +++ b/plugins/beta/draw/src/adapters/openlayers/edit/keyboardHandler.js @@ -1,7 +1,7 @@ import { coordToPixel, nudgeCoord } from '../utils/olCoords.js' import { spatialNavigate } from '../../../utils/spatial.js' import { moveVertex, insertAtMidpoint } from './vertexOps.js' -import { KEYBOARD } from '../../../defaults.js' +import { KEYBOARD } from '../defaults.js' const ARROW_KEYS = new Set(['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown']) const INTERACTIVE_TAGS = new Set(['INPUT', 'TEXTAREA', 'BUTTON', 'SELECT', 'A']) diff --git a/plugins/beta/draw/src/adapters/openlayers/olDraw.js b/plugins/beta/draw/src/adapters/openlayers/olDraw.js index 73c966f33..bd008115a 100644 --- a/plugins/beta/draw/src/adapters/openlayers/olDraw.js +++ b/plugins/beta/draw/src/adapters/openlayers/olDraw.js @@ -1,4 +1,5 @@ import { OLDrawManager } from './core/OLDrawManager.js' +import { MAP_SIZE_SCALES } from './defaults.js' /** * Creates the OLDrawManager, attaches it to mapProvider, and wires @@ -18,7 +19,7 @@ export const createOLDraw = ({ mapProvider, events, eventBus, pluginConfig = {}, mapProvider.draw = manager const handleSetMapSize = (size) => { - mapProvider.drawScale = { small: 1, medium: 1.5, large: 2 }[size] ?? 1 + mapProvider.drawScale = MAP_SIZE_SCALES[size] ?? 1 } eventBus.on(events.MAP_SET_SIZE, handleSetMapSize) diff --git a/plugins/beta/draw/src/adapters/openlayers/utils/resolveColors.js b/plugins/beta/draw/src/adapters/openlayers/utils/resolveColors.js index 4b723543b..ced62d962 100644 --- a/plugins/beta/draw/src/adapters/openlayers/utils/resolveColors.js +++ b/plugins/beta/draw/src/adapters/openlayers/utils/resolveColors.js @@ -1,5 +1,5 @@ import { COLORS, SIZES } from '../defaults.js' -import { getValueForStyle } from '../../../utils/getColorForScheme.js' +import { getValueForStyle } from '../../../utils/getValueForStyle.js' /** * Resolve all draw-ol colors for the given map style and plugin config overrides. diff --git a/plugins/beta/draw/src/api/editFeature.js b/plugins/beta/draw/src/api/editFeature.js index f1a485432..243f2381a 100644 --- a/plugins/beta/draw/src/api/editFeature.js +++ b/plugins/beta/draw/src/api/editFeature.js @@ -1,3 +1,5 @@ +import { MAP_SIZE_SCALES } from '../defaults.js' + export const editFeature = ({ appState, appConfig, mapState, pluginConfig, pluginState, mapProvider, services }, featureId, options = {}) => { const { dispatch } = pluginState const { draw } = mapProvider @@ -25,7 +27,7 @@ export const editFeature = ({ appState, appConfig, mapState, pluginConfig, plugi undoButtonId: `${appConfig.id}-draw-undo`, isPanEnabled: appState.interfaceType !== 'keyboard', interfaceType: appState.interfaceType, - scale: { small: 1, medium: 1.5, large: 2 }[mapState.mapSize], + scale: MAP_SIZE_SCALES[mapState.mapSize], featureId, getSnapEnabled: () => draw.isSnapEnabled() }) diff --git a/plugins/beta/draw/src/defaults.js b/plugins/beta/draw/src/defaults.js index db8aa54d7..4dd283070 100644 --- a/plugins/beta/draw/src/defaults.js +++ b/plugins/beta/draw/src/defaults.js @@ -46,3 +46,10 @@ export const KEYBOARD = { nudgeAmount: 1, stepAmount: 5 } + +// Scale factor applied to draw UI (touch targets, vertex handles) per app map size +export const MAP_SIZE_SCALES = { + small: 1, + medium: 1.5, + large: 2 +} diff --git a/plugins/beta/draw/src/utils/getColorForScheme.js b/plugins/beta/draw/src/utils/getValueForStyle.js similarity index 92% rename from plugins/beta/draw/src/utils/getColorForScheme.js rename to plugins/beta/draw/src/utils/getValueForStyle.js index 8d9ea815e..7e3b4054c 100644 --- a/plugins/beta/draw/src/utils/getColorForScheme.js +++ b/plugins/beta/draw/src/utils/getValueForStyle.js @@ -31,6 +31,3 @@ export const getValueForStyle = (value, scheme, styleId = null) => { } return Object.values(value)[0] } - -// Legacy alias for backwards compatibility -export const getColorForScheme = getValueForStyle diff --git a/plugins/beta/draw/src/adapters/openlayers/core/undoStack.js b/plugins/beta/draw/src/utils/undoStack.js similarity index 100% rename from plugins/beta/draw/src/adapters/openlayers/core/undoStack.js rename to plugins/beta/draw/src/utils/undoStack.js From b83a09f81170024c1365bd96137bf315d81fa7d7 Mon Sep 17 00:00:00 2001 From: Dan Leech Date: Fri, 3 Jul 2026 10:35:09 +0100 Subject: [PATCH 15/89] Draw vertex display consistency fixes --- .../draw/src/adapters/maplibre/mapboxDraw.js | 7 -- .../adapters/maplibre/modes/createDrawMode.js | 3 +- .../adapters/maplibre/modes/drawLineMode.js | 6 +- .../maplibre/modes/drawPolygonMode.js | 6 +- .../maplibre/utils/cursorIndicator.js | 92 ------------------- .../src/adapters/openlayers/core/styles.js | 36 ++++++-- .../src/adapters/openlayers/draw/DrawMode.js | 4 +- .../openlayers/utils/resolveColors.js | 2 - plugins/beta/draw/src/defaults.js | 5 - 9 files changed, 41 insertions(+), 120 deletions(-) delete mode 100644 plugins/beta/draw/src/adapters/maplibre/utils/cursorIndicator.js diff --git a/plugins/beta/draw/src/adapters/maplibre/mapboxDraw.js b/plugins/beta/draw/src/adapters/maplibre/mapboxDraw.js index 0932e02b8..5255d3335 100755 --- a/plugins/beta/draw/src/adapters/maplibre/mapboxDraw.js +++ b/plugins/beta/draw/src/adapters/maplibre/mapboxDraw.js @@ -6,7 +6,6 @@ import { DrawLineMode } from './modes/drawLineMode.js' import { createDrawStyles, updateDrawStyles } from './styles.js' import { initMapLibreSnap } from './mapboxSnap.js' import { createUndoStack } from '../../utils/undoStack.js' -import { setupCursorIndicator } from './utils/cursorIndicator.js' import { setupTouchClickWorkaround } from './utils/touchClickWorkaround.js' import { applyTouchVertexColors } from './modes/editVertex/touchHandlers.js' import { TOLERANCES, MAP_SIZE_SCALES } from './defaults.js' @@ -84,15 +83,11 @@ export const createMapboxDraw = ({ mapStyle, mapProvider, events, eventBus, snap rules: ['vertex', 'edge'] }) - // --- Mouse cursor indicator during draw modes --- - const cursorIndicator = setupCursorIndicator(map, draw) - // --- Update colour scheme --- const handleSetMapStyle = (e) => { map._drawCurrentMapStyle = e map.once('idle', () => { updateDrawStyles(map, e) - cursorIndicator.refreshColors() const svg = map._drawEditContainer?.querySelector('[data-im-draw-touch-target]') applyTouchVertexColors(svg, e) }) @@ -125,8 +120,6 @@ export const createMapboxDraw = ({ mapStyle, mapProvider, events, eventBus, snap map.off('draw.interfacetypechange', handleDrawInterfaceTypeChange) // Disable draw mode but keep control on map for reuse draw.changeMode('disabled') - // Unwrap changeMode so wrappers don't stack when the adapter is recreated - cursorIndicator.remove() // Clear adapter reference (but not _mapboxDrawInstance so it persists) mapProvider.draw = null } diff --git a/plugins/beta/draw/src/adapters/maplibre/modes/createDrawMode.js b/plugins/beta/draw/src/adapters/maplibre/modes/createDrawMode.js index 85ed90785..37376d040 100644 --- a/plugins/beta/draw/src/adapters/maplibre/modes/createDrawMode.js +++ b/plugins/beta/draw/src/adapters/maplibre/modes/createDrawMode.js @@ -537,8 +537,9 @@ export const createDrawMode = (ParentMode, config) => { // NOSONAR — factory r toDisplayFeatures (state, geojson, display) { ParentMode.toDisplayFeatures.call(this, state, geojson, display) + // Display features carry the id in properties.id (no top-level id) const feature = getFeature(state) - if (geojson.geometry.type === geometryType && geojson.id === feature.id) { + if (geojson.geometry.type === geometryType && geojson.properties.id === feature.id) { createVertices(geojson, display, createVertex) } }, diff --git a/plugins/beta/draw/src/adapters/maplibre/modes/drawLineMode.js b/plugins/beta/draw/src/adapters/maplibre/modes/drawLineMode.js index 63526183f..1a5322631 100644 --- a/plugins/beta/draw/src/adapters/maplibre/modes/drawLineMode.js +++ b/plugins/beta/draw/src/adapters/maplibre/modes/drawLineMode.js @@ -10,9 +10,11 @@ export const DrawLineMode = createDrawMode(DrawLineString, { excludeFeatureIdFromSetup: true, // DrawLineString interprets featureId as "continue existing" finishOnInvalidClick: true, // Clicking same spot (like double-click) finishes the line createVertices: (geojson, display, createVertex) => { + // Coords during drawing: [v0...vN, rubber_band]. + // Parent mode already displays vN (last placed) — fill in v0 and the middle vertices const coords = geojson.geometry.coordinates - for (let i = 1; i < coords.length - 1; i++) { - display(createVertex(geojson.id, coords[i], `${i}`)) + for (let i = 0; i < coords.length - 2; i++) { + display(createVertex(geojson.properties.id, coords[i], `${i}`, false)) } } }) diff --git a/plugins/beta/draw/src/adapters/maplibre/modes/drawPolygonMode.js b/plugins/beta/draw/src/adapters/maplibre/modes/drawPolygonMode.js index 19f3884b8..fbe8e2614 100755 --- a/plugins/beta/draw/src/adapters/maplibre/modes/drawPolygonMode.js +++ b/plugins/beta/draw/src/adapters/maplibre/modes/drawPolygonMode.js @@ -8,9 +8,11 @@ export const DrawPolygonMode = createDrawMode(DrawPolygon, { getCoords: (feature) => feature.coordinates[0], validateClick: (feature) => isValidClick(feature.coordinates), createVertices: (geojson, display, createVertex) => { + // Ring during drawing: [v0...vN, rubber_band, v0_closing]. + // Parent mode already displays v0 and vN (last placed) — fill in the middle vertices const ring = geojson.geometry.coordinates[0] - for (let i = 1; i < ring.length - 2; i++) { - display(createVertex(geojson.id, ring[i], `0.${i}`)) + for (let i = 1; i < ring.length - 3; i++) { + display(createVertex(geojson.properties.id, ring[i], `0.${i}`, false)) } } }) diff --git a/plugins/beta/draw/src/adapters/maplibre/utils/cursorIndicator.js b/plugins/beta/draw/src/adapters/maplibre/utils/cursorIndicator.js deleted file mode 100644 index c5dc03e08..000000000 --- a/plugins/beta/draw/src/adapters/maplibre/utils/cursorIndicator.js +++ /dev/null @@ -1,92 +0,0 @@ -import { getValueForStyle } from '../../../utils/getValueForStyle.js' -import { COLORS, SIZES } from '../defaults.js' - -const CURSOR_SOURCE = 'draw-cursor-indicator' -const CURSOR_LAYER = 'draw-cursor-indicator-layer' -const EMPTY_FC = { type: 'FeatureCollection', features: [] } -const DRAW_MODES = new Set(['draw_polygon', 'draw_line']) - -/** - * Small filled circle that follows the mouse pointer during draw modes, showing - * where the next vertex will be placed (matches the OL sketch point behaviour). - * - * Wraps draw.changeMode to activate/deactivate with mode changes. Reads the live - * map style (map._drawCurrentMapStyle) so colours stay correct after style - * switches, and recreates its source/layer on draw-mode entry since a style - * switch wipes custom sources and layers. - * - * @param {Object} map - MapLibre map instance - * @param {Object} draw - MapboxDraw instance (its changeMode is wrapped) - * @returns {{ refreshColors: Function, remove: Function }} - */ -export const setupCursorIndicator = (map, draw) => { - const paintColors = () => { - const style = map._drawCurrentMapStyle - const scheme = style?.mapColorScheme ?? 'light' - return { - 'circle-color': getValueForStyle(COLORS.mousePointer, scheme, style?.id), - 'circle-stroke-color': getValueForStyle(COLORS.mousePointerHalo, scheme, style?.id) - } - } - - const ensureLayer = () => { - if (!map.getSource(CURSOR_SOURCE)) { - map.addSource(CURSOR_SOURCE, { type: 'geojson', data: EMPTY_FC }) - } - if (!map.getLayer(CURSOR_LAYER)) { - map.addLayer({ - id: CURSOR_LAYER, - type: 'circle', - source: CURSOR_SOURCE, - paint: { - 'circle-radius': SIZES.mousePointerRadius, - 'circle-stroke-width': 1, - ...paintColors() - } - }) - } - } - - let active = false - const onMouseMove = (e) => { - if (!active) { - return - } - map.getSource(CURSOR_SOURCE)?.setData({ - type: 'FeatureCollection', - features: [{ - type: 'Feature', - geometry: { type: 'Point', coordinates: [e.lngLat.lng, e.lngLat.lat] } - }] - }) - } - - const originalChangeMode = draw.changeMode.bind(draw) - draw.changeMode = (mode, opts) => { - active = DRAW_MODES.has(mode) - map.off('mousemove', onMouseMove) - if (active) { - ensureLayer() - map.on('mousemove', onMouseMove) - } else { - map.getSource(CURSOR_SOURCE)?.setData(EMPTY_FC) - } - return originalChangeMode(mode, opts) - } - - return { - // Re-apply colours after a map style change - refreshColors () { - if (map.getLayer(CURSOR_LAYER)) { - Object.entries(paintColors()).forEach(([prop, value]) => map.setPaintProperty(CURSOR_LAYER, prop, value)) - } - }, - - // Restores the original changeMode so wrappers don't stack when the - // adapter is recreated against the persistent MapboxDraw instance - remove () { - map.off('mousemove', onMouseMove) - draw.changeMode = originalChangeMode - } - } -} diff --git a/plugins/beta/draw/src/adapters/openlayers/core/styles.js b/plugins/beta/draw/src/adapters/openlayers/core/styles.js index 10ff1824d..28fde1c19 100644 --- a/plugins/beta/draw/src/adapters/openlayers/core/styles.js +++ b/plugins/beta/draw/src/adapters/openlayers/core/styles.js @@ -2,6 +2,7 @@ import Style from 'ol/style/Style.js' import Fill from 'ol/style/Fill.js' import Stroke from 'ol/style/Stroke.js' import CircleStyle from 'ol/style/Circle.js' +import MultiPoint from 'ol/geom/MultiPoint.js' import { SIZES } from '../defaults.js' const selectedVertexRadii = { outer: SIZES.vertexHaloRadius + 3, mid: SIZES.vertexHaloRadius, inner: SIZES.vertexRadius } @@ -29,6 +30,17 @@ const makeRingRenderer = ({ outer, mid, inner }, colors, innerKey) => (pixelCoor const capitalize = (s) => s.charAt(0).toUpperCase() + s.slice(1) +// Placed vertices of an in-progress sketch, excluding OL's trailing rubber-band +// coordinate (LineString: [...placed, rubber_band]; +// Polygon ring: [...placed, rubber_band, v0_closing]) +const getPlacedSketchCoords = (geom) => { + if (geom.getType() === 'Polygon') { + const ring = geom.getCoordinates()[0] ?? [] + return ring.slice(0, Math.max(0, ring.length - 2)) + } + return geom.getCoordinates().slice(0, -1) +} + /** * Create all draw-ol style instances for the given resolved color set. * @@ -65,16 +77,26 @@ export const createStyles = (colors) => { fill: new Fill({ color: colors.editFill }) }) - const sketchPointStyle = new Style({ + const sketchVertexStyle = new Style({ image: new CircleStyle({ - radius: SIZES.mousePointerRadius, - fill: new Fill({ color: colors.mousePointer }), - stroke: new Stroke({ color: colors.mousePointerHalo, width: 1 }) - }) + radius: SIZES.vertexRadius, + fill: new Fill({ color: colors.editVertex }) + }), + geometry: (feature) => { + const coords = getPlacedSketchCoords(feature.getGeometry()) + return coords.length ? new MultiPoint(coords) : null + } }) - const createSketchStyle = () => (feature) => - feature.getGeometry().getType() === 'Point' ? [sketchPointStyle] : [sketchLineStyle] + // No style for the Point sketch (cursor-following ghost marker); placed + // vertices get markers on the sketch feature instead. geometryType filters + // out the extra LineString sketch OL renders alongside a Polygon sketch, + // so vertices aren't drawn twice. + const createSketchStyle = (geometryType) => (feature) => { + const type = feature.getGeometry().getType() + if (type === 'Point') { return [] } + return type === geometryType ? [sketchLineStyle, sketchVertexStyle] : [sketchLineStyle] + } const createFeatureStyle = () => (feature) => { const p = feature.getProperties() diff --git a/plugins/beta/draw/src/adapters/openlayers/draw/DrawMode.js b/plugins/beta/draw/src/adapters/openlayers/draw/DrawMode.js index e3880246b..e68c25cc4 100644 --- a/plugins/beta/draw/src/adapters/openlayers/draw/DrawMode.js +++ b/plugins/beta/draw/src/adapters/openlayers/draw/DrawMode.js @@ -52,7 +52,7 @@ export const createDrawMode = ({ map, manager, options }) => { } = options let sketchFeature = null - let currentSketchStyle = manager.styles.createSketchStyle() + let currentSketchStyle = manager.styles.createSketchStyle(geometryType) const drawInteraction = new Draw({ type: geometryType, @@ -65,7 +65,7 @@ export const createDrawMode = ({ map, manager, options }) => { // Update sketch style when map style changes const onStylesChanged = () => { - currentSketchStyle = manager.styles.createSketchStyle() + currentSketchStyle = manager.styles.createSketchStyle(geometryType) drawInteraction.overlay_.changed() } manager.on('styleschanged', onStylesChanged) diff --git a/plugins/beta/draw/src/adapters/openlayers/utils/resolveColors.js b/plugins/beta/draw/src/adapters/openlayers/utils/resolveColors.js index ced62d962..f14787d7e 100644 --- a/plugins/beta/draw/src/adapters/openlayers/utils/resolveColors.js +++ b/plugins/beta/draw/src/adapters/openlayers/utils/resolveColors.js @@ -17,8 +17,6 @@ export const resolveColors = (mapStyle, pluginConfig = {}) => { const resolveColor = (key) => getValueForStyle(pluginConfig[key] ?? COLORS[key], scheme, styleId) return { - mousePointer: resolveColor('mousePointer'), - mousePointerHalo: resolveColor('mousePointerHalo'), editStroke: resolveColor('editStroke'), editFill: resolveColor('editFill'), editVertex: resolveColor('editVertex'), diff --git a/plugins/beta/draw/src/defaults.js b/plugins/beta/draw/src/defaults.js index 4dd283070..49103a966 100644 --- a/plugins/beta/draw/src/defaults.js +++ b/plugins/beta/draw/src/defaults.js @@ -9,10 +9,6 @@ const MID_BLUE = 'rgba(29,112,184,0.5)' const GREEN = 'rgba(40,161,151,1)' export const COLORS = { - mousePointer: { light: BLUE, dark: WHITE }, - mousePointerHalo: { light: WHITE, dark: BLACK }, - drawPointer: { light: BLUE, dark: WHITE }, - drawPointerHalo: { light: WHITE, dark: BLACK }, editStroke: { light: BLUE, dark: WHITE }, editFill: { light: LIGHT_BLUE, dark: LIGHT_WHITE }, editVertex: { light: BLUE, dark: WHITE }, @@ -29,7 +25,6 @@ export const COLORS = { } export const SIZES = { - mousePointerRadius: 4, strokeWidth: 2, vertexRadius: 6, midpointRadius: 4, From 15730e9c3bc1b3439f63b88c907c3f5743bda028 Mon Sep 17 00:00:00 2001 From: Dan Leech Date: Fri, 3 Jul 2026 10:49:36 +0100 Subject: [PATCH 16/89] Code simplification --- .../adapters/maplibre/modes/createDrawMode.js | 17 ++++++--- .../adapters/maplibre/modes/drawLineMode.js | 10 +---- .../maplibre/modes/drawPolygonMode.js | 10 +---- .../beta/draw/src/adapters/maplibre/styles.js | 4 +- .../src/adapters/openlayers/core/styles.js | 38 +++++++++---------- .../src/adapters/openlayers/draw/DrawMode.js | 26 ++----------- .../src/adapters/openlayers/draw/drawInput.js | 16 +------- .../openlayers/utils/sketchHelpers.js | 22 +++++++++++ 8 files changed, 63 insertions(+), 80 deletions(-) create mode 100644 plugins/beta/draw/src/adapters/openlayers/utils/sketchHelpers.js diff --git a/plugins/beta/draw/src/adapters/maplibre/modes/createDrawMode.js b/plugins/beta/draw/src/adapters/maplibre/modes/createDrawMode.js index 37376d040..a922baa82 100644 --- a/plugins/beta/draw/src/adapters/maplibre/modes/createDrawMode.js +++ b/plugins/beta/draw/src/adapters/maplibre/modes/createDrawMode.js @@ -1,5 +1,3 @@ -import createVertex from '../../../../../../../node_modules/@mapbox/mapbox-gl-draw/src/lib/create_vertex.js' // NOSONAR - import { getSnapInstance, isSnapActive, @@ -21,7 +19,7 @@ import { * @param {string} config.geometryType - 'Polygon' or 'LineString' * @param {Function} config.getCoords - Function to get coordinates from feature * @param {Function} config.validateClick - Validation function for clicks - * @param {Function} config.createVertices - Function to create vertex display features + * @param {Function} config.getPlacedCoords - Function to get placed vertex coordinates from a display geojson */ export const createDrawMode = (ParentMode, config) => { // NOSONAR — factory returns a single cohesive mode object; splitting across files would obscure the event flow const { @@ -29,7 +27,7 @@ export const createDrawMode = (ParentMode, config) => { // NOSONAR — factory r geometryType, getCoords, validateClick, - createVertices, + getPlacedCoords, excludeFeatureIdFromSetup = false, finishOnInvalidClick = false // For lines: finish when clicking same spot (like double-click) } = config @@ -540,7 +538,16 @@ export const createDrawMode = (ParentMode, config) => { // NOSONAR — factory r // Display features carry the id in properties.id (no top-level id) const feature = getFeature(state) if (geojson.geometry.type === geometryType && geojson.properties.id === feature.id) { - createVertices(geojson, display, createVertex) + // Parent modes render only some placed vertices (which ones varies by + // mapbox-gl-draw version) — add a marker on every placed vertex. The + // 'draw-vertex' meta is display-only: it's not in mapbox-gl-draw's + // META_TYPES, so featuresAt ignores these markers and the parent's own + // vertex click targets (click first/last to finish) keep working. + getPlacedCoords(geojson).forEach((coordinates) => display({ + type: 'Feature', + properties: { meta: 'draw-vertex', parent: feature.id, active: 'false' }, + geometry: { type: 'Point', coordinates } + })) } }, diff --git a/plugins/beta/draw/src/adapters/maplibre/modes/drawLineMode.js b/plugins/beta/draw/src/adapters/maplibre/modes/drawLineMode.js index 1a5322631..9f013056d 100644 --- a/plugins/beta/draw/src/adapters/maplibre/modes/drawLineMode.js +++ b/plugins/beta/draw/src/adapters/maplibre/modes/drawLineMode.js @@ -9,12 +9,6 @@ export const DrawLineMode = createDrawMode(DrawLineString, { validateClick: (feature) => isValidLineClick(feature.coordinates), excludeFeatureIdFromSetup: true, // DrawLineString interprets featureId as "continue existing" finishOnInvalidClick: true, // Clicking same spot (like double-click) finishes the line - createVertices: (geojson, display, createVertex) => { - // Coords during drawing: [v0...vN, rubber_band]. - // Parent mode already displays vN (last placed) — fill in v0 and the middle vertices - const coords = geojson.geometry.coordinates - for (let i = 0; i < coords.length - 2; i++) { - display(createVertex(geojson.properties.id, coords[i], `${i}`, false)) - } - } + // Display coords during drawing: [v0...vN, rubber_band] + getPlacedCoords: (geojson) => geojson.geometry.coordinates.slice(0, -1) }) diff --git a/plugins/beta/draw/src/adapters/maplibre/modes/drawPolygonMode.js b/plugins/beta/draw/src/adapters/maplibre/modes/drawPolygonMode.js index fbe8e2614..f98ef412a 100755 --- a/plugins/beta/draw/src/adapters/maplibre/modes/drawPolygonMode.js +++ b/plugins/beta/draw/src/adapters/maplibre/modes/drawPolygonMode.js @@ -7,12 +7,6 @@ export const DrawPolygonMode = createDrawMode(DrawPolygon, { geometryType: 'Polygon', getCoords: (feature) => feature.coordinates[0], validateClick: (feature) => isValidClick(feature.coordinates), - createVertices: (geojson, display, createVertex) => { - // Ring during drawing: [v0...vN, rubber_band, v0_closing]. - // Parent mode already displays v0 and vN (last placed) — fill in the middle vertices - const ring = geojson.geometry.coordinates[0] - for (let i = 1; i < ring.length - 3; i++) { - display(createVertex(geojson.properties.id, ring[i], `0.${i}`, false)) - } - } + // Display ring during drawing: [v0...vN, rubber_band, v0_closing] + getPlacedCoords: (geojson) => geojson.geometry.coordinates[0].slice(0, -2) }) diff --git a/plugins/beta/draw/src/adapters/maplibre/styles.js b/plugins/beta/draw/src/adapters/maplibre/styles.js index 77760ce93..7b6a70b2d 100755 --- a/plugins/beta/draw/src/adapters/maplibre/styles.js +++ b/plugins/beta/draw/src/adapters/maplibre/styles.js @@ -81,11 +81,11 @@ const drawPreviewLine = (editStrokeColor) => ({ paint: { 'line-color': editStrokeColor, 'line-width': 2, 'line-dasharray': [0.2, 2], 'line-opacity': 1 } }) -// Vertex layers +// Vertex layers ('draw-vertex' = display-only markers on placed vertices while drawing) const vertex = (editVertexColor, vertexRadius) => ({ id: 'vertex', type: 'circle', - filter: ['all', ['==', '$type', 'Point'], ['==', 'meta', 'vertex']], + filter: ['all', ['==', '$type', 'Point'], ['in', 'meta', 'vertex', 'draw-vertex']], paint: { 'circle-radius': vertexRadius, 'circle-color': editVertexColor } }) diff --git a/plugins/beta/draw/src/adapters/openlayers/core/styles.js b/plugins/beta/draw/src/adapters/openlayers/core/styles.js index 28fde1c19..b72190e30 100644 --- a/plugins/beta/draw/src/adapters/openlayers/core/styles.js +++ b/plugins/beta/draw/src/adapters/openlayers/core/styles.js @@ -4,6 +4,7 @@ import Stroke from 'ol/style/Stroke.js' import CircleStyle from 'ol/style/Circle.js' import MultiPoint from 'ol/geom/MultiPoint.js' import { SIZES } from '../defaults.js' +import { getPlacedSketchCoords } from '../utils/sketchHelpers.js' const selectedVertexRadii = { outer: SIZES.vertexHaloRadius + 3, mid: SIZES.vertexHaloRadius, inner: SIZES.vertexRadius } const selectedMidpointRadii = { outer: SIZES.vertexHaloRadius + 1, mid: SIZES.vertexHaloRadius, inner: SIZES.midpointRadius } @@ -30,17 +31,6 @@ const makeRingRenderer = ({ outer, mid, inner }, colors, innerKey) => (pixelCoor const capitalize = (s) => s.charAt(0).toUpperCase() + s.slice(1) -// Placed vertices of an in-progress sketch, excluding OL's trailing rubber-band -// coordinate (LineString: [...placed, rubber_band]; -// Polygon ring: [...placed, rubber_band, v0_closing]) -const getPlacedSketchCoords = (geom) => { - if (geom.getType() === 'Polygon') { - const ring = geom.getCoordinates()[0] ?? [] - return ring.slice(0, Math.max(0, ring.length - 2)) - } - return geom.getCoordinates().slice(0, -1) -} - /** * Create all draw-ol style instances for the given resolved color set. * @@ -49,13 +39,14 @@ const getPlacedSketchCoords = (geom) => { * editFeatureStyle, createSketchStyle, createFeatureStyle }} */ export const createStyles = (colors) => { - const vertexStyle = new Style({ - image: new CircleStyle({ - radius: SIZES.vertexRadius, - fill: new Fill({ color: colors.editVertex }) - }) + // Shared by edit-mode vertices and in-progress sketch vertices so they always look the same + const vertexImage = new CircleStyle({ + radius: SIZES.vertexRadius, + fill: new Fill({ color: colors.editVertex }) }) + const vertexStyle = new Style({ image: vertexImage }) + const selectedVertexStyle = new Style({ renderer: makeRingRenderer(selectedVertexRadii, colors, 'editVertex') }) const midpointStyle = new Style({ @@ -77,14 +68,19 @@ export const createStyles = (colors) => { fill: new Fill({ color: colors.editFill }) }) + // Reused across renders — the geometry function runs every frame while sketching, + // so mutate one MultiPoint (setCoordinates bumps its revision, keeping OL's + // render caches correct) instead of allocating a new one per frame + const sketchVertices = new MultiPoint([]) const sketchVertexStyle = new Style({ - image: new CircleStyle({ - radius: SIZES.vertexRadius, - fill: new Fill({ color: colors.editVertex }) - }), + image: vertexImage, geometry: (feature) => { const coords = getPlacedSketchCoords(feature.getGeometry()) - return coords.length ? new MultiPoint(coords) : null + if (!coords.length) { + return null + } + sketchVertices.setCoordinates(coords) + return sketchVertices } }) diff --git a/plugins/beta/draw/src/adapters/openlayers/draw/DrawMode.js b/plugins/beta/draw/src/adapters/openlayers/draw/DrawMode.js index e68c25cc4..14961f8e6 100644 --- a/plugins/beta/draw/src/adapters/openlayers/draw/DrawMode.js +++ b/plugins/beta/draw/src/adapters/openlayers/draw/DrawMode.js @@ -1,28 +1,13 @@ import Draw from 'ol/interaction/Draw.js' import { noModifierKeys } from 'ol/events/condition.js' import { createDrawInput } from './drawInput.js' -import { getCoords } from '../utils/geometryHelpers.js' +import { getPlacedSketchCoords, getLastPlacedSketchCoord } from '../utils/sketchHelpers.js' import { TOLERANCES } from '../defaults.js' const MIN_VERTICES = { Polygon: 3, LineString: 2 } const canFinish = (geometryType, sketchFeature) => { if (!sketchFeature) { return false } - const geom = sketchFeature.getGeometry() - const coords = getCoords({ type: geometryType, coordinates: geom.getCoordinates() }) - // OL keeps a trailing rubber-band coordinate; subtract 1 to get real vertex count - return coords.length - 1 >= MIN_VERTICES[geometryType] -} - -// OL closes Polygon rings by appending v1: [...placed, rubber_band, v1_closing]; last placed is 3 from end. -const POLY_LAST_PLACED_OFFSET = 3 - -const getLastPlacedCoord = (geom) => { - if (geom.getType() === 'Polygon') { - const ring = geom.getCoordinates()[0] || [] - return ring.length >= POLY_LAST_PLACED_OFFSET ? ring[ring.length - POLY_LAST_PLACED_OFFSET] : null - } - const coords = geom.getCoordinates() - return coords.length >= 2 ? coords[coords.length - 2] : null + return getPlacedSketchCoords(sketchFeature.getGeometry()).length >= MIN_VERTICES[geometryType] } const DUPLICATE_TOLERANCE_PX = 2 @@ -31,7 +16,7 @@ const buildCondition = (map, geometryType, getSketchFeature) => (e) => { if (!noModifierKeys(e)) { return false } const sf = getSketchFeature() if (!sf || canFinish(geometryType, sf)) { return true } - const prev = getLastPlacedCoord(sf.getGeometry()) + const prev = getLastPlacedSketchCoord(sf.getGeometry()) if (!prev) { return true } const pp = map.getPixelFromCoordinate(prev) if (!pp) { return true } @@ -77,10 +62,7 @@ export const createDrawMode = ({ map, manager, options }) => { const updateVertexCount = () => { if (!sketchFeature) { return } - const geom = sketchFeature.getGeometry() - const coords = getCoords({ type: geometryType, coordinates: geom.getCoordinates() }) - // OL always keeps a trailing rubber-band coordinate; subtract 1 - manager.emit('vertexchange', { numVertices: Math.max(0, coords.length - 1) }) + manager.emit('vertexchange', { numVertices: getPlacedSketchCoords(sketchFeature.getGeometry()).length }) } drawInteraction.on('drawstart', (e) => { diff --git a/plugins/beta/draw/src/adapters/openlayers/draw/drawInput.js b/plugins/beta/draw/src/adapters/openlayers/draw/drawInput.js index 212a67e55..0d3159706 100644 --- a/plugins/beta/draw/src/adapters/openlayers/draw/drawInput.js +++ b/plugins/beta/draw/src/adapters/openlayers/draw/drawInput.js @@ -1,11 +1,10 @@ import { coordToPixel, pixelDist } from '../utils/olCoords.js' +import { getLastPlacedSketchCoord } from '../utils/sketchHelpers.js' const SNAP_TOLERANCE = 12 // pixels // Minimum ring length to allow snap-to-close (placed vertices + rubber-band) const MIN_SKETCH_COORDS = { Polygon: 4, LineString: 3 } const DUPLICATE_TOLERANCE_PX = 2 -// OL Polygon ring layout after addToDrawing_: [...committed, rubber_band, closing_v1] -const POLY_COMMITTED_OFFSET = 3 const ARROW_KEYS = new Set(['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown']) const isCloseToFirstVertex = (map, coord, sketchCoords, geometryType) => { @@ -41,17 +40,6 @@ const applyRubberbanding = (geom, centerCoord) => { } } -// Returns the last vertex committed by OL's Draw interaction (not the rubber-band or -// the closing copy that OL appends to Polygon rings). -const getLastCommittedVertex = (geom) => { - if (geom.getType() === 'Polygon') { - const ring = geom.getCoordinates()[0] || [] - return ring.length >= POLY_COMMITTED_OFFSET ? ring[ring.length - POLY_COMMITTED_OFFSET] : null - } - const coords = geom.getCoordinates() - return coords.length >= 2 ? coords[coords.length - 2] : null -} - const wireInputEvents = ({ container, addVertexButtonId, olView, onUndo, getInterfaceType, setInterfaceType, clearLastCoord, @@ -181,7 +169,7 @@ export const createDrawInput = ({ drawInteraction, options }) => { // committed a vertex at coord's position and skip the duplicate appendCoordinates, // but register coord as lastPlacedCoord so a second tap at the same position can close. const map = drawInteraction.getMap() - const lastCommitted = getLastCommittedVertex(geom) + const lastCommitted = getLastPlacedSketchCoord(geom) if (lastCommitted) { const p1 = map.getPixelFromCoordinate(lastCommitted) const p2 = map.getPixelFromCoordinate(coord) diff --git a/plugins/beta/draw/src/adapters/openlayers/utils/sketchHelpers.js b/plugins/beta/draw/src/adapters/openlayers/utils/sketchHelpers.js new file mode 100644 index 000000000..adea36456 --- /dev/null +++ b/plugins/beta/draw/src/adapters/openlayers/utils/sketchHelpers.js @@ -0,0 +1,22 @@ +/** + * Coordinate-layout helpers for ol/interaction/Draw sketch geometries. + * + * During drawing OL keeps a trailing rubber-band coordinate, and for Polygons + * additionally appends a copy of the first coordinate to close the ring: + * LineString: [...placed, rubber_band] + * Polygon ring: [...placed, rubber_band, v0_closing] + * + * This is the single place that layout is encoded — if an OL upgrade changes + * the Draw interaction's sketch bookkeeping, update it here. + */ +const TRAILING_COORDS = { Polygon: 2, LineString: 1 } + +/** Placed vertex coordinates of an in-progress sketch geometry. */ +export const getPlacedSketchCoords = (geom) => { + const type = geom.getType() + const coords = type === 'Polygon' ? (geom.getCoordinates()[0] ?? []) : geom.getCoordinates() + return coords.slice(0, -TRAILING_COORDS[type]) +} + +/** Last vertex committed by OL's Draw interaction, or null if none placed yet. */ +export const getLastPlacedSketchCoord = (geom) => getPlacedSketchCoords(geom).at(-1) ?? null From fcc5e39b6a7887a8487313d0fd93bb72a83f3336 Mon Sep 17 00:00:00 2001 From: Dan Leech Date: Fri, 3 Jul 2026 10:57:12 +0100 Subject: [PATCH 17/89] Maplibre keyboard crosshair marker apearing on shortcut fix --- .../draw/src/adapters/maplibre/modes/createDrawMode.js | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/plugins/beta/draw/src/adapters/maplibre/modes/createDrawMode.js b/plugins/beta/draw/src/adapters/maplibre/modes/createDrawMode.js index a922baa82..4fba18ef2 100644 --- a/plugins/beta/draw/src/adapters/maplibre/modes/createDrawMode.js +++ b/plugins/beta/draw/src/adapters/maplibre/modes/createDrawMode.js @@ -34,6 +34,9 @@ export const createDrawMode = (ParentMode, config) => { // NOSONAR — factory r const getFeature = (state) => state[featureProp] const RUBBER_BAND_OFFSET = 2 // ring is [...placed, last_placed, rubber_band]; splice(-2,1) removes last_placed + // Only these keys signal a switch to keyboard drawing (matches the OL adapter's + // drawInput) — modifier keys and shortcut chords like cmd+z must not show the crosshair + const INTERFACE_KEYS = new Set(['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'Enter']) return { ...ParentMode, @@ -417,6 +420,9 @@ export const createDrawMode = (ParentMode, config) => { // NOSONAR — factory r if (e.key === 'Enter') { state.isActive = true } + if (!INTERFACE_KEYS.has(e.key)) { + return + } this._setInterface(state, 'keyboard') this.onMove(state, e) }, @@ -433,6 +439,9 @@ export const createDrawMode = (ParentMode, config) => { // NOSONAR — factory r if (document.activeElement !== state.container) { return } + if (!INTERFACE_KEYS.has(e.key)) { + return + } this._setInterface(state, 'keyboard') this.onMove(state, e) if (e.key === 'Enter' && state.isActive) { From cef41440fc4f56f2c3b07d0646bca8566c413852 Mon Sep 17 00:00:00 2001 From: Dan Leech Date: Fri, 3 Jul 2026 11:01:39 +0100 Subject: [PATCH 18/89] Midpoint highlight fix --- plugins/beta/draw/src/adapters/maplibre/styles.js | 8 ++++---- plugins/beta/draw/src/adapters/openlayers/core/styles.js | 2 +- plugins/beta/draw/src/defaults.js | 1 + 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/plugins/beta/draw/src/adapters/maplibre/styles.js b/plugins/beta/draw/src/adapters/maplibre/styles.js index 7b6a70b2d..ce4cebfa8 100755 --- a/plugins/beta/draw/src/adapters/maplibre/styles.js +++ b/plugins/beta/draw/src/adapters/maplibre/styles.js @@ -111,11 +111,11 @@ const midpoint = (editMidpointColor, midpointRadius) => ({ paint: { 'circle-radius': midpointRadius, 'circle-color': editMidpointColor } }) -const midpointHalo = (editHaloColor, editActiveColor, vertexHaloRadius) => ({ +const midpointHalo = (editHaloColor, editActiveColor, midpointHaloRadius) => ({ id: 'midpoint-halo', type: 'circle', filter: ['all', ['==', '$type', 'Point'], ['==', 'meta', 'midpoint'], ['==', 'active', 'true']], - paint: { 'circle-radius': vertexHaloRadius, 'circle-stroke-width': 3, 'circle-color': editHaloColor, 'circle-stroke-color': editActiveColor } + paint: { 'circle-radius': midpointHaloRadius, 'circle-stroke-width': 3, 'circle-color': editHaloColor, 'circle-stroke-color': editActiveColor } }) const midpointActive = (editMidpointColor, midpointRadius) => ({ @@ -149,7 +149,7 @@ const createDrawStyles = (mapStyle) => { const editActiveColor = getValueForStyle(COLORS.editActive, scheme) const splitInvalidColor = getValueForStyle(COLORS.splitInvalid, scheme) const splitValidColor = getValueForStyle(COLORS.splitValid, scheme) - const { vertexRadius, midpointRadius, vertexHaloRadius } = SIZES + const { vertexRadius, midpointRadius, vertexHaloRadius, midpointHaloRadius } = SIZES return [ fillInactive(mapStyle), @@ -160,7 +160,7 @@ const createDrawStyles = (mapStyle) => { drawValidSplitter(splitValidColor), drawPreviewLine(editStrokeColor), midpoint(editMidpointColor, midpointRadius), - midpointHalo(editHaloColor, editActiveColor, vertexHaloRadius), + midpointHalo(editHaloColor, editActiveColor, midpointHaloRadius), midpointActive(editMidpointColor, midpointRadius), vertex(editVertexColor, vertexRadius), vertexHalo(editHaloColor, editActiveColor, vertexHaloRadius), diff --git a/plugins/beta/draw/src/adapters/openlayers/core/styles.js b/plugins/beta/draw/src/adapters/openlayers/core/styles.js index b72190e30..73ee58281 100644 --- a/plugins/beta/draw/src/adapters/openlayers/core/styles.js +++ b/plugins/beta/draw/src/adapters/openlayers/core/styles.js @@ -7,7 +7,7 @@ import { SIZES } from '../defaults.js' import { getPlacedSketchCoords } from '../utils/sketchHelpers.js' const selectedVertexRadii = { outer: SIZES.vertexHaloRadius + 3, mid: SIZES.vertexHaloRadius, inner: SIZES.vertexRadius } -const selectedMidpointRadii = { outer: SIZES.vertexHaloRadius + 1, mid: SIZES.vertexHaloRadius, inner: SIZES.midpointRadius } +const selectedMidpointRadii = { outer: SIZES.midpointHaloRadius + 3, mid: SIZES.midpointHaloRadius, inner: SIZES.midpointRadius } const fillArc = (ctx, cx, cy, radius, fillStyle) => { ctx.beginPath() diff --git a/plugins/beta/draw/src/defaults.js b/plugins/beta/draw/src/defaults.js index 49103a966..5b1fc4ad3 100644 --- a/plugins/beta/draw/src/defaults.js +++ b/plugins/beta/draw/src/defaults.js @@ -29,6 +29,7 @@ export const SIZES = { vertexRadius: 6, midpointRadius: 4, vertexHaloRadius: 8, + midpointHaloRadius: 6, touchTargetSize: 48, touchIndicatorRadius: 30 } From 9407d83e9551131e8765082feaf187d71c4e7ecd Mon Sep 17 00:00:00 2001 From: Dan Leech Date: Fri, 3 Jul 2026 12:04:11 +0100 Subject: [PATCH 19/89] createDrawMode.js tests --- .../adapters/maplibre/modes/createDrawMode.js | 44 +- .../maplibre/modes/createDrawMode.test.js | 664 ++++++++++++++++++ 2 files changed, 684 insertions(+), 24 deletions(-) create mode 100644 plugins/beta/draw/src/adapters/maplibre/modes/createDrawMode.test.js diff --git a/plugins/beta/draw/src/adapters/maplibre/modes/createDrawMode.js b/plugins/beta/draw/src/adapters/maplibre/modes/createDrawMode.js index 4fba18ef2..dc8e9bcd2 100644 --- a/plugins/beta/draw/src/adapters/maplibre/modes/createDrawMode.js +++ b/plugins/beta/draw/src/adapters/maplibre/modes/createDrawMode.js @@ -146,8 +146,9 @@ export const createDrawMode = (ParentMode, config) => { // NOSONAR — factory r this.dispatchVertexChange(coords) if (!validateClick(feature)) { - // For lines: clicking same spot (like double-click) should finish the line - if (finishOnInvalidClick && coords.length > 1) { + // For lines: clicking same spot (like double-click) should finish the line. + // isValidLineClick only returns false with 2+ coords, so coords.length is always > 1 here. + if (finishOnInvalidClick) { coords.pop() this.map.fire('draw.create', { features: [feature.toGeoJSON()] }) this.changeMode('simple_select', { featureIds: [feature.id] }) @@ -157,7 +158,6 @@ export const createDrawMode = (ParentMode, config) => { // NOSONAR — factory r const snap = getSnapInstance(this.map) const snappedEvent = isSnapEnabled(state) && createSnappedClickEvent(this.map, snap) - const coordsBefore = coords.length if (snappedEvent) { ParentMode.onClick.call(this, state, snappedEvent) @@ -166,12 +166,11 @@ export const createDrawMode = (ParentMode, config) => { // NOSONAR — factory r this._simulateMouse('click', ParentMode.onClick, state) } - // Push undo and update count if a vertex was added + // Push undo and update count if a vertex was added. A validated click always + // adds one vertex via the parent mode, so this runs on every successful doClick. const newCoords = getCoords(getFeature(state)) - if (newCoords.length > coordsBefore) { - this.pushDrawUndo(state) - this.dispatchVertexChange(newCoords) - } + this.pushDrawUndo(state) + this.dispatchVertexChange(newCoords) }, dispatchVertexChange (coords) { @@ -279,11 +278,9 @@ export const createDrawMode = (ParentMode, config) => { // NOSONAR — factory r const ring = geometryType === 'Polygon' ? feature.coordinates[0] : coords ring.splice(-RUBBER_BAND_OFFSET, 1) - // Snap rubber band to new last vertex position - const newLastVertex = ring[ring.length - 2] - if (newLastVertex) { - ring[ring.length - 1] = [...newLastVertex] - } + // Snap rubber band to new last vertex position. undoVertex only calls here with + // 3+ coords, so after the splice the ring always has a preceding vertex. + ring[ring.length - 1] = [...ring[ring.length - 2]] // Keep parent mode's vertex counter in sync (min 1 for rubber band) state.currentVertexPosition = Math.max(1, state.currentVertexPosition - 1) @@ -301,19 +298,18 @@ export const createDrawMode = (ParentMode, config) => { // NOSONAR — factory r this._simulateMouse('mousemove', ParentMode.onMouseMove, state) this._ctx.store.render() } else { - // Mouse: keep rubber band at current position + // Mouse: keep rubber band at current position. After a vertex removal the + // rubber band index always resolves to a real coordinate. const rubberBandIndex = geometryType === 'Polygon' ? coords.length - 2 : coords.length - 1 const rubberBandPos = coords[rubberBandIndex] - if (rubberBandPos) { - const lngLat = { lng: rubberBandPos[0], lat: rubberBandPos[1] } - const point = this.map.project(lngLat) - ParentMode.onMouseMove.call(this, state, { - lngLat, - point, - originalEvent: new MouseEvent('mousemove', { clientX: point.x, clientY: point.y }) - }) - this._ctx.store.render() - } + const lngLat = { lng: rubberBandPos[0], lat: rubberBandPos[1] } + const point = this.map.project(lngLat) + ParentMode.onMouseMove.call(this, state, { + lngLat, + point, + originalEvent: new MouseEvent('mousemove', { clientX: point.x, clientY: point.y }) + }) + this._ctx.store.render() } this.dispatchVertexChange(coords) }, diff --git a/plugins/beta/draw/src/adapters/maplibre/modes/createDrawMode.test.js b/plugins/beta/draw/src/adapters/maplibre/modes/createDrawMode.test.js new file mode 100644 index 000000000..01471f5f5 --- /dev/null +++ b/plugins/beta/draw/src/adapters/maplibre/modes/createDrawMode.test.js @@ -0,0 +1,664 @@ +import { DrawPolygonMode } from './drawPolygonMode.js' +import { DrawLineMode } from './drawLineMode.js' +import { createUndoStack } from '../../../utils/undoStack.js' +import PolygonFeature from '../../../../../../../node_modules/@mapbox/mapbox-gl-draw/src/feature_types/polygon.js' +import LineStringFeature from '../../../../../../../node_modules/@mapbox/mapbox-gl-draw/src/feature_types/line_string.js' + +/** + * Behaviour tests for the shared draw mode factory, exercised through the real + * DrawPolygonMode / DrawLineMode objects with real mapbox-gl-draw parent modes + * and feature classes. Only the map, store and DOM are test doubles. + */ + +const CENTER = { lng: 5, lat: 5 } + +const createMap = () => { + const canvas = document.createElement('canvas') + const listeners = {} + return { + _undoInProgress: false, + _undoStack: null, + _snapInstance: null, + doubleClickZoom: { disable: jest.fn(), enable: jest.fn() }, + getCanvas: () => canvas, + getCenter: () => ({ ...CENTER }), + project: jest.fn(() => ({ x: 50, y: 50 })), + unproject: jest.fn(() => ({ ...CENTER })), + fire: jest.fn(function (type, e) { (listeners[type] ?? []).forEach((h) => h(e)) }), + on: jest.fn((type, h) => { (listeners[type] ??= []).push(h) }), + off: jest.fn((type, h) => { listeners[type] = (listeners[type] ?? []).filter((x) => x !== h) }) + } +} + +// The `this` context mapbox-gl-draw gives a mode: map, ctx accessors and the mode's own methods +const createModeContext = (mode) => { + const map = createMap() + const features = new Map() + const store = { + add: jest.fn((f) => features.set(f.id, f)), + delete: jest.fn((ids) => ids.forEach((id) => features.delete(id))), + render: jest.fn(), + featureChanged: jest.fn(), + getInitialConfigValue: jest.fn(() => true) + } + const ctx = { + map, + _ctx: { store, api: { changeMode: jest.fn(), add: jest.fn(), delete: jest.fn() }, options: {} }, + addFeature: (f) => store.add(f), + getFeature: (id) => features.get(id), + deleteFeature: jest.fn((ids) => ids.forEach((id) => features.delete(id))), + clearSelectedFeatures: jest.fn(), + updateUIClasses: jest.fn(), + activateUIButton: jest.fn(), + setActionableState: jest.fn(), + changeMode: jest.fn() + } + ctx.newFeature = (geojson) => geojson.geometry.type === 'Polygon' + ? new PolygonFeature(ctx._ctx, geojson) + : new LineStringFeature(ctx._ctx, geojson) + Object.assign(ctx, mode) + contexts.push(ctx) + return ctx +} + +// Remove window/container/map listeners registered by onSetup so tests don't leak into each other +const contexts = [] +const removeListeners = (ctx) => ctx._listeners?.forEach(([t, e, h]) => + t.removeEventListener ? t.removeEventListener(e, h) : t.off(e, h)) + +const createContainer = () => { + const container = document.createElement('div') + container.tabIndex = 0 + const marker = document.createElement('div') + marker.id = 'vertex-marker' + container.appendChild(marker) + const button = document.createElement('button') + button.id = 'add-vertex' + container.appendChild(button) + document.body.appendChild(container) + return { container, marker, button } +} + +const setup = (mode, options = {}) => { + const ctx = createModeContext(mode) + const dom = createContainer() + ctx.map._undoStack = createUndoStack(() => {}) + const state = ctx.onSetup({ + container: dom.container, + vertexMarkerId: 'vertex-marker', + addVertexButtonId: 'add-vertex', + interfaceType: 'mouse', + featureId: 'shape-1', + properties: { label: 'field' }, + ...options + }) + return { ctx, state, ...dom } +} + +const clickEvent = (ctx, lng, lat, overrides = {}) => ({ + lngLat: { lng, lat }, + point: { x: lng, y: lat }, + originalEvent: { button: 0, target: ctx.map.getCanvas(), ...overrides } +}) + +const clickAt = (ctx, state, lng, lat) => ctx.onClick(state, clickEvent(ctx, lng, lat)) + +const firedWith = (map, type) => map.fire.mock.calls.filter(([t]) => t === type).map(([, e]) => e) + +const activeSnap = () => ({ status: true, snapStatus: true, snapCoords: [9, 9], snapToClosestPoint: jest.fn() }) + +afterEach(() => { + contexts.splice(0).forEach(removeListeners) + document.body.innerHTML = '' + jest.useRealTimers() +}) + +describe('setup and crosshair', () => { + test('mouse interface hides the vertex marker, keyboard shows it', () => { + const mouse = setup(DrawPolygonMode) + expect(mouse.marker.style.display).toBe('none') + + const keyboard = setup(DrawPolygonMode, { interfaceType: 'keyboard' }) + expect(keyboard.marker.style.display).toBe('block') + }) + + test('getInterfaceType() takes precedence over the static interfaceType option', () => { + const { state, marker } = setup(DrawPolygonMode, { interfaceType: 'mouse', getInterfaceType: () => 'touch' }) + expect(state.interfaceType).toBe('touch') + expect(marker.style.display).toBe('block') + }) + + test('a crossHair object is used instead of the vertex marker when provided', () => { + const crossHair = { show: jest.fn(), hide: jest.fn() } + const { ctx, state } = setup(DrawPolygonMode, { interfaceType: 'keyboard', crossHair }) + expect(crossHair.show).toHaveBeenCalled() + ctx.pointermoveHandler({ pointerType: 'mouse' }) + expect(crossHair.hide).toHaveBeenCalled() + ctx.onBlur(state, { target: document.body }) + expect(crossHair.hide).toHaveBeenCalledTimes(2) + }) + + test('line mode creates a fresh feature even when featureId is passed', () => { + const { state } = setup(DrawLineMode) + expect(state.line).toBeDefined() + expect(state.featureId).toBe('shape-1') + }) +}) + +describe('mouse clicks (polygon)', () => { + test('each click at a new position places a vertex, fires vertexchange and pushes an undo op', () => { + const { ctx, state } = setup(DrawPolygonMode) + clickAt(ctx, state, 0, 0) + clickAt(ctx, state, 10, 0) + + // Ring is [v0, v1, rubber_band] + expect(state.polygon.coordinates[0]).toHaveLength(3) + expect(state.polygon.coordinates[0][0]).toEqual([0, 0]) + expect(state.polygon.coordinates[0][1]).toEqual([10, 0]) + expect(firedWith(ctx.map, 'draw.vertexchange').pop()).toEqual({ numVertecies: 2 }) + expect(ctx.map._undoStack.length).toBe(2) + }) + + test('clicking the same position again is rejected', () => { + const { ctx, state } = setup(DrawPolygonMode) + clickAt(ctx, state, 0, 0) + clickAt(ctx, state, 0, 0) + expect(state.polygon.coordinates[0]).toHaveLength(2) + expect(ctx.map._undoStack.length).toBe(1) + }) + + test('non-primary buttons, undo-in-progress and off-canvas clicks are ignored', () => { + const { ctx, state } = setup(DrawPolygonMode) + ctx.onClick(state, clickEvent(ctx, 0, 0, { button: 2 })) + ctx.map._undoInProgress = true + clickAt(ctx, state, 0, 0) + ctx.map._undoInProgress = false + ctx.onClick(state, clickEvent(ctx, 0, 0, { target: document.body })) + expect(state.polygon.coordinates[0]).toHaveLength(0) + }) + + test('with snap active the vertex is placed at the snapped position', () => { + const { ctx, state } = setup(DrawPolygonMode, { getSnapEnabled: () => true }) + ctx.map._snapInstance = activeSnap() + clickAt(ctx, state, 0, 0) + expect(state.polygon.coordinates[0][0]).toEqual([9, 9]) + }) + + test('onTap is disabled', () => { + const { ctx, state } = setup(DrawPolygonMode) + expect(ctx.onTap(state, clickEvent(ctx, 0, 0))).toBeUndefined() + expect(state.polygon.coordinates[0]).toHaveLength(0) + }) +}) + +describe('add-vertex button and doClick', () => { + test('clicking the add-vertex button places a vertex at map center', () => { + const { ctx, state, button } = setup(DrawPolygonMode, { interfaceType: 'keyboard' }) + ctx.vertexButtonClickHandler({ target: button }) + expect(state.polygon.coordinates[0][0]).toEqual([CENTER.lng, CENTER.lat]) + expect(ctx.map._undoStack.length).toBe(1) + }) + + test('clicks elsewhere, without a button id, or during undo do nothing', () => { + const { ctx, state } = setup(DrawPolygonMode) + ctx.vertexButtonClickHandler({ target: document.body }) + ctx.map._undoInProgress = true + ctx.doClick(state) + ctx.map._undoInProgress = false + const noButton = setup(DrawPolygonMode, { addVertexButtonId: null }) + noButton.ctx.vertexButtonClickHandler({ target: document.body }) + expect(state.polygon.coordinates[0]).toHaveLength(0) + expect(noButton.state.polygon.coordinates[0]).toHaveLength(0) + }) + + test('doClick places at the snapped position when snapping', () => { + const { ctx, state } = setup(DrawPolygonMode, { getSnapEnabled: () => true }) + ctx.map._snapInstance = activeSnap() + ctx.doClick(state) + expect(state.polygon.coordinates[0][0]).toEqual([9, 9]) + expect(ctx._ctx.store.render).toHaveBeenCalled() + }) + + test('line: activating add-vertex at the same spot twice finishes the line', () => { + const { ctx, state } = setup(DrawLineMode) + ctx.doClick(state) + ctx.doClick(state) + expect(firedWith(ctx.map, 'draw.create')).toHaveLength(1) + expect(ctx.changeMode).toHaveBeenCalledWith('simple_select', { featureIds: [state.line.id] }) + }) + + test('polygon: a repeated add-vertex at the same spot is rejected without finishing', () => { + const { ctx, state } = setup(DrawPolygonMode) + ctx.doClick(state) + ctx.doClick(state) + expect(firedWith(ctx.map, 'draw.create')).toHaveLength(0) + expect(ctx.changeMode).not.toHaveBeenCalled() + }) +}) + +describe('keyboard interface', () => { + test('arrow keys switch to keyboard interface, show the crosshair and move the rubber band to center', () => { + const { ctx, state, marker, container } = setup(DrawPolygonMode) + clickAt(ctx, state, 0, 0) + container.focus() + window.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight' })) + expect(state.interfaceType).toBe('keyboard') + expect(marker.style.display).toBe('block') + expect(state.polygon.coordinates[0].at(-1)).toEqual([CENTER.lng, CENTER.lat]) + expect(firedWith(ctx.map, 'draw.geometrychange').length).toBeGreaterThan(0) + }) + + test('other keys, unfocused container and Escape do not switch interface', () => { + const { ctx, state, marker, container } = setup(DrawPolygonMode) + ctx.keydownHandler(new KeyboardEvent('keydown', { key: 'ArrowRight' })) // container not focused + container.focus() + ctx.keydownHandler(new KeyboardEvent('keydown', { key: 'a' })) + ctx.keydownHandler(new KeyboardEvent('keydown', { key: 'Escape' })) + ctx.keyupHandler(new KeyboardEvent('keyup', { key: 'a' })) + expect(state.interfaceType).toBe('mouse') + expect(marker.style.display).toBe('none') + }) + + test('keyup without focus is ignored', () => { + const { ctx, state } = setup(DrawPolygonMode) + ctx.keyupHandler(new KeyboardEvent('keyup', { key: 'ArrowRight' })) + expect(state.interfaceType).toBe('mouse') + }) + + test('Enter down then up places a vertex at center', () => { + const { ctx, state, container } = setup(DrawPolygonMode) + container.focus() + ctx.keydownHandler(new KeyboardEvent('keydown', { key: 'Enter' })) + ctx.keyupHandler(new KeyboardEvent('keyup', { key: 'Enter' })) + expect(state.isActive).toBe(true) + expect(state.polygon.coordinates[0][0]).toEqual([CENTER.lng, CENTER.lat]) + }) + + test('Escape keyup cancels drawing for mouse/touch but not keyboard', () => { + const { ctx, state } = setup(DrawPolygonMode) + ctx.keyupHandler(new KeyboardEvent('keyup', { key: 'Escape' })) + expect(firedWith(ctx.map, 'draw.cancel')).toHaveLength(1) + + state.interfaceType = 'keyboard' + ctx.keyupHandler(new KeyboardEvent('keyup', { key: 'Escape' })) + expect(firedWith(ctx.map, 'draw.cancel')).toHaveLength(1) + }) +}) + +describe('cmd/ctrl+z undo', () => { + const undoKey = (overrides = {}) => { + const e = new KeyboardEvent('keydown', { key: 'z', metaKey: true, ...overrides }) + e.preventDefault = jest.fn() + e.stopPropagation = jest.fn() + return e + } + + test('removes the last placed vertex without switching to keyboard interface', () => { + jest.useFakeTimers() + const { ctx, state, marker } = setup(DrawPolygonMode) + clickAt(ctx, state, 0, 0) + clickAt(ctx, state, 10, 0) + clickAt(ctx, state, 10, 10) + + ctx.keydownHandler(undoKey()) + expect(state.polygon.coordinates[0].map(([x]) => x)).toEqual([0, 10, 10]) + expect(state.polygon.coordinates[0]).toHaveLength(3) + expect(state.interfaceType).toBe('mouse') + expect(marker.style.display).toBe('none') + expect(ctx.map._undoInProgress).toBe(true) + jest.advanceTimersByTime(100) + expect(ctx.map._undoInProgress).toBe(false) + }) + + test('mouse interface keeps the rubber band on the remaining last vertex', () => { + const { ctx, state } = setup(DrawPolygonMode) + clickAt(ctx, state, 0, 0) + clickAt(ctx, state, 10, 0) + clickAt(ctx, state, 10, 10) + ctx.keydownHandler(undoKey()) + const ring = state.polygon.coordinates[0] + expect(ring[ring.length - 1]).toEqual(ring[ring.length - 2]) + }) + + test('is ignored when typing in an input, when the stack is empty, or for other operations', () => { + const { ctx, state } = setup(DrawPolygonMode) + clickAt(ctx, state, 0, 0) + clickAt(ctx, state, 10, 0) + + const input = document.createElement('input') + document.body.appendChild(input) + input.focus() + ctx.keydownHandler(undoKey()) + expect(state.polygon.coordinates[0]).toHaveLength(3) + input.blur() + + ctx.map._undoStack.clear() + ctx.keydownHandler(undoKey()) + ctx.map._undoStack.push({ type: 'edit_vertex' }) + ctx.keydownHandler(undoKey()) + expect(state.polygon.coordinates[0]).toHaveLength(3) + }) + + test('cmd+shift+z is not treated as undo', () => { + const { ctx, state } = setup(DrawPolygonMode) + clickAt(ctx, state, 0, 0) + clickAt(ctx, state, 10, 0) + ctx.keydownHandler(undoKey({ shiftKey: true })) + expect(state.polygon.coordinates[0]).toHaveLength(3) + }) + + test('ctrl+z (Windows/Linux) also undoes the last vertex', () => { + const { ctx, state } = setup(DrawPolygonMode) + clickAt(ctx, state, 0, 0) + clickAt(ctx, state, 10, 0) + clickAt(ctx, state, 10, 10) + ctx.keydownHandler(undoKey({ metaKey: false, ctrlKey: true })) + expect(state.polygon.coordinates[0]).toHaveLength(3) + }) +}) + +describe('undo via draw.undo event and reinitialisation', () => { + test('draw.undo with a draw_vertex operation removes the last vertex; other types are ignored', () => { + const { ctx, state } = setup(DrawPolygonMode) + clickAt(ctx, state, 0, 0) + clickAt(ctx, state, 10, 0) + clickAt(ctx, state, 10, 10) + ctx.map.fire('draw.undo', { operation: { type: 'edit_vertex' } }) + expect(state.polygon.coordinates[0]).toHaveLength(4) + ctx.map.fire('draw.undo', { operation: { type: 'draw_vertex' } }) + expect(state.polygon.coordinates[0]).toHaveLength(3) + }) + + test('keyboard interface moves the rubber band to center after undo', () => { + const { ctx, state } = setup(DrawPolygonMode, { interfaceType: 'keyboard' }) + clickAt(ctx, state, 0, 0) + clickAt(ctx, state, 10, 0) + clickAt(ctx, state, 10, 10) + ctx.undoVertex(state) + expect(state.polygon.coordinates[0].at(-1)).toEqual([CENTER.lng, CENTER.lat]) + }) + + test('undoVertex returns false with nothing placed', () => { + const { ctx, state } = setup(DrawPolygonMode) + expect(ctx.undoVertex(state)).toBe(false) + }) + + test('undoing the only polygon vertex reinitialises the feature in place with the same id', () => { + const { ctx, state } = setup(DrawPolygonMode) + clickAt(ctx, state, 0, 0) + const id = state.polygon.id + expect(ctx.undoVertex(state)).toBe(true) + expect(ctx._ctx.store.delete).toHaveBeenCalledWith([id]) + expect(state.polygon.id).toBe(id) + expect(state.currentVertexPosition).toBe(0) + expect(firedWith(ctx.map, 'draw.vertexchange').pop()).toEqual({ numVertecies: 1 }) + }) + + test('undoing the only line vertex restarts draw_line with the same feature id', () => { + const { ctx, state } = setup(DrawLineMode) + ctx.doClick(state) + ctx.map._undoStack.push({ type: 'draw_vertex' }) + expect(ctx.undoVertex(state)).toBe(true) + expect(ctx.map._undoStack.length).toBe(0) + expect(ctx._ctx.api.changeMode).toHaveBeenCalledWith('draw_line', expect.objectContaining({ + featureId: state.line.id, + container: state.container, + properties: state.properties + })) + }) + + test('Escape (container keyUp) during keyboard drawing clears the undo stack and reinitialises', () => { + const { ctx, state, container } = setup(DrawPolygonMode, { interfaceType: 'keyboard' }) + clickAt(ctx, state, 0, 0) + container.focus() + const id = state.polygon.id + ctx.onKeyUp(state, { key: 'Escape' }) + expect(ctx.map._undoStack.length).toBe(0) + expect(state.polygon.id).toBe(id) + }) + + test('container keyUp defers to the parent mode when focus is elsewhere, and ignores focus inside the container', () => { + const { ctx, state, button, container } = setup(DrawPolygonMode) + ctx.onKeyUp(state, { key: 'Enter' }) // focus not on container → parent finishes on Enter + expect(ctx.changeMode).toHaveBeenCalledWith('simple_select', expect.anything()) + + button.focus() + ctx.changeMode.mockClear() + ctx.onKeyUp(state, { key: 'Enter' }) // focus on a child element → ignored + expect(ctx.changeMode).not.toHaveBeenCalled() + + container.focus() + ctx.onKeyUp(state, { key: 'Escape' }) // non-keyboard Escape → handled by window keyup instead + expect(ctx._ctx.store.delete).not.toHaveBeenCalled() + }) +}) + +describe('touch and pointer interface', () => { + test('touch start/end switch to touch interface and show the crosshair', () => { + const { ctx, state, marker } = setup(DrawPolygonMode) + ctx.onTouchStart(state, {}) + expect(state.interfaceType).toBe('touch') + expect(marker.style.display).toBe('block') + ctx.onTouchEnd(state, {}) + expect(state.interfaceType).toBe('touch') + }) + + test('non-touch pointerdown switches back to mouse without showing the crosshair; touch does not', () => { + const { ctx, state, marker } = setup(DrawPolygonMode, { interfaceType: 'keyboard' }) + ctx.map.fire('pointerdown', { pointerType: 'mouse' }) + expect(state.interfaceType).toBe('mouse') + expect(marker.style.display).toBe('block') // unchanged, not re-shown or hidden + + ctx.map.fire('pointerdown', { pointerType: 'touch' }) + expect(state.interfaceType).toBe('mouse') + }) + + test('mouse pointermove hides the crosshair; touch pointermove does not', () => { + const { ctx, marker } = setup(DrawPolygonMode, { interfaceType: 'keyboard' }) + ctx.pointermoveHandler({ pointerType: 'touch' }) + expect(marker.style.display).toBe('block') + ctx.pointermoveHandler({ pointerType: 'mouse' }) + expect(marker.style.display).toBe('none') + }) + + test('pointerup reports the current vertex count', () => { + const { ctx, state } = setup(DrawPolygonMode) + clickAt(ctx, state, 0, 0) + ctx.map.fire.mockClear() + ctx.pointerupHandler({}) + expect(firedWith(ctx.map, 'draw.vertexchange')).toEqual([{ numVertecies: 1 }]) + }) + + test('blur away from the container hides the crosshair; blur on the container does not', () => { + const { ctx, marker, container } = setup(DrawPolygonMode, { interfaceType: 'keyboard' }) + ctx.blurHandler({ target: container }) + expect(marker.style.display).toBe('block') + ctx.blurHandler({ target: document.body }) + expect(marker.style.display).toBe('none') + }) +}) + +describe('rubber band and snapping while moving', () => { + test('onMouseMove moves the rubber band, preferring the snapped position', () => { + const { ctx, state } = setup(DrawPolygonMode, { getSnapEnabled: () => true }) + clickAt(ctx, state, 0, 0) + ctx.map._snapInstance = activeSnap() + ctx.onMouseMove(state, { lngLat: { lng: 3, lat: 3 }, point: { x: 3, y: 3 } }) + expect(ctx.map._snapInstance.snapToClosestPoint).toHaveBeenCalled() + expect(state.polygon.coordinates[0].at(-1)).toEqual([9, 9]) + expect(firedWith(ctx.map, 'draw.geometrychange').length).toBeGreaterThan(0) + }) + + test('onMouseMove without snap uses the pointer position', () => { + const { ctx, state } = setup(DrawPolygonMode) + clickAt(ctx, state, 0, 0) + ctx.onMouseMove(state, { lngLat: { lng: 3, lat: 3 }, point: { x: 3, y: 3 } }) + expect(state.polygon.coordinates[0].at(-1)).toEqual([3, 3]) + }) + + test('map move in keyboard interface keeps the rubber band at center, or at the snap point when snapping', () => { + const { ctx, state } = setup(DrawPolygonMode, { interfaceType: 'keyboard' }) + clickAt(ctx, state, 0, 0) + ctx.map.fire('move') + expect(state.polygon.coordinates[0].at(-1)).toEqual([CENTER.lng, CENTER.lat]) + + state.getSnapEnabled = () => true + ctx.map._snapInstance = activeSnap() + ctx.map.fire('move') + expect(ctx.map._snapInstance.snapToClosestPoint).toHaveBeenCalled() + expect(state.polygon.coordinates[0].at(-1)).toEqual([9, 9]) + }) + + test('map move in mouse interface leaves the rubber band alone', () => { + const { ctx, state } = setup(DrawPolygonMode) + clickAt(ctx, state, 0, 0) + const before = [...state.polygon.coordinates[0].map((c) => [...c])] + ctx.map.fire('move') + expect(state.polygon.coordinates[0]).toEqual(before) + }) +}) + +describe('vertex marker display', () => { + const displayed = (ctx, state, geometry, id) => { + const display = jest.fn() + ctx.toDisplayFeatures(state, { type: 'Feature', properties: { id }, geometry }, display) + return display.mock.calls.map(([f]) => f).filter((f) => f.properties.meta === 'draw-vertex') + } + + test('every placed polygon vertex gets a display-only draw-vertex marker', () => { + const { ctx, state } = setup(DrawPolygonMode) + clickAt(ctx, state, 0, 0) + clickAt(ctx, state, 10, 0) + clickAt(ctx, state, 10, 10) + const ring = [[0, 0], [10, 0], [10, 10], [5, 5], [0, 0]] // placed + rubber + closing + const markers = displayed(ctx, state, { type: 'Polygon', coordinates: [ring] }, state.polygon.id) + expect(markers.map((m) => m.geometry.coordinates)).toEqual([[0, 0], [10, 0], [10, 10]]) + expect(markers[0].properties).toEqual({ meta: 'draw-vertex', parent: state.polygon.id, active: 'false' }) + }) + + test('every placed line vertex gets a marker', () => { + const { ctx, state } = setup(DrawLineMode) + ctx.doClick(state) + const coords = [[5, 5], [8, 8]] // placed + rubber + const markers = displayed(ctx, state, { type: 'LineString', coordinates: coords }, state.line.id) + expect(markers.map((m) => m.geometry.coordinates)).toEqual([[5, 5]]) + }) + + test('other features get no markers', () => { + const { ctx, state } = setup(DrawPolygonMode) + const ring = [[0, 0], [10, 0], [10, 10], [0, 0]] + expect(displayed(ctx, state, { type: 'Polygon', coordinates: [ring] }, 'other-feature')).toEqual([]) + }) +}) + +describe('create and stop', () => { + test('draw.create re-ids the created feature to the requested featureId', () => { + const { ctx } = setup(DrawPolygonMode) + const feature = { id: 'temp-id' } + ctx.map.fire('draw.create', { features: [feature] }) + expect(ctx._ctx.api.delete).toHaveBeenCalledWith('temp-id') + expect(feature.id).toBe('shape-1') + expect(ctx._ctx.api.add).toHaveBeenCalledWith(feature, { userProperties: true }) + }) + + test('onStop removes listeners, hides the crosshair and reports the final interface type', () => { + const { ctx, state, marker, container } = setup(DrawPolygonMode, { interfaceType: 'keyboard' }) + ctx.onStop(state) + expect(marker.style.display).toBe('none') + expect(firedWith(ctx.map, 'draw.interfacetypechange')).toEqual([{ interfaceType: 'keyboard' }]) + + // Window/container/map listeners are gone: keydown no longer shows the crosshair + container.focus() + window.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight' })) + expect(marker.style.display).toBe('none') + expect(ctx.map.off).toHaveBeenCalledWith('draw.create', ctx.createHandler) + }) +}) + +describe('line mode mouse interactions', () => { + test('mouse clicks place line vertices; moving then undoing keeps the rubber band on the last vertex', () => { + const { ctx, state } = setup(DrawLineMode) + clickAt(ctx, state, 0, 0) + clickAt(ctx, state, 10, 0) + clickAt(ctx, state, 10, 10) + expect(state.line.coordinates.map(([x]) => x)).toEqual([0, 10, 10, 10]) // 3 placed + rubber band + + ctx.onMouseMove(state, { lngLat: { lng: 3, lat: 3 }, point: { x: 3, y: 3 } }) + expect(state.line.coordinates.at(-1)).toEqual([3, 3]) + expect(firedWith(ctx.map, 'draw.geometrychange').length).toBeGreaterThan(0) + + ctx.map._undoStack.push({ type: 'draw_vertex' }) + ctx.undoVertex(state) + const coords = state.line.coordinates + expect(coords[coords.length - 1]).toEqual(coords[coords.length - 2]) + }) + + test('a repeated mouse click at the last vertex adds no new vertex and does not finish the line', () => { + const { ctx, state } = setup(DrawLineMode) + clickAt(ctx, state, 0, 0) + clickAt(ctx, state, 10, 0) + const before = state.line.coordinates.length + clickAt(ctx, state, 10, 0) + expect(state.line.coordinates.length).toBe(before) + expect(firedWith(ctx.map, 'draw.create')).toHaveLength(0) + }) +}) + +describe('remaining snap, undo-stack and keyboard edge branches', () => { + test('onMouseMove with snapping enabled but no snap point falls back to the pointer position', () => { + const { ctx, state } = setup(DrawPolygonMode, { getSnapEnabled: () => true }) + clickAt(ctx, state, 0, 0) + ctx.map._snapInstance = { status: true, snapStatus: false, snapCoords: null, snapToClosestPoint: jest.fn() } + ctx.onMouseMove(state, { lngLat: { lng: 3, lat: 3 }, point: { x: 3, y: 3 } }) + expect(state.polygon.coordinates[0].at(-1)).toEqual([3, 3]) + }) + + test('placing a vertex with no undo stack does not throw and still places the vertex', () => { + const { ctx, state } = setup(DrawPolygonMode) + ctx.map._undoStack = null + clickAt(ctx, state, 0, 0) + expect(state.polygon.coordinates[0][0]).toEqual([0, 0]) + }) + + test('reinitialising a polygon without properties uses an empty properties object', () => { + const { ctx, state } = setup(DrawPolygonMode, { properties: undefined }) + clickAt(ctx, state, 0, 0) + expect(ctx.undoVertex(state)).toBe(true) + expect(state.polygon.properties).toEqual({}) + }) + + test('restarting a line with no undo stack still changes mode', () => { + const { ctx, state } = setup(DrawLineMode) + ctx.doClick(state) + ctx.map._undoStack = null + expect(ctx.undoVertex(state)).toBe(true) + expect(ctx._ctx.api.changeMode).toHaveBeenCalledWith('draw_line', expect.objectContaining({ + featureId: state.line.id + })) + }) + + test('keyup of a non-Enter interface key while focused switches interface without committing a vertex', () => { + const { ctx, state, container } = setup(DrawPolygonMode) + container.focus() + ctx.keyupHandler(new KeyboardEvent('keyup', { key: 'ArrowRight' })) + expect(state.interfaceType).toBe('keyboard') + expect(ctx.map._undoStack.length).toBe(0) + }) + + test('container keyUp with the container itself focused and a non-Escape key does not defer to the parent', () => { + const { ctx, state, container } = setup(DrawPolygonMode) + container.focus() + ctx.onKeyUp(state, { key: 'a' }) + expect(ctx.changeMode).not.toHaveBeenCalled() + }) + + test('Escape during keyboard drawing with no undo stack still reinitialises the feature', () => { + const { ctx, state, container } = setup(DrawPolygonMode, { interfaceType: 'keyboard' }) + clickAt(ctx, state, 0, 0) + container.focus() + const id = state.polygon.id + ctx.map._undoStack = null + ctx.onKeyUp(state, { key: 'Escape' }) + expect(ctx._ctx.store.delete).toHaveBeenCalledWith([id]) + }) +}) From e2f966dcfc7d753120ebddd76b13ff5e02d4aa3b Mon Sep 17 00:00:00 2001 From: Dan Leech Date: Fri, 3 Jul 2026 14:41:17 +0100 Subject: [PATCH 20/89] Edit vertex mode code splitting plus tests --- jest.config.mjs | 1 + .../adapters/maplibre/modes/createDrawMode.js | 11 +- .../modes/editVertex/__helpers__/harness.js | 107 ++++++ .../modes/editVertex/geometryHelpers.test.js | 46 +++ .../maplibre/modes/editVertex/helpers.test.js | 17 + .../modes/editVertex/keyboardHandlers.js | 172 ++++++++++ .../modes/editVertex/keyboardHandlers.test.js | 123 +++++++ .../modes/editVertex/pointerHandlers.js | 161 +++++++++ .../modes/editVertex/pointerHandlers.test.js | 98 ++++++ .../modes/editVertex/touchHandlers.test.js | 92 +++++ .../maplibre/modes/editVertex/undoHandlers.js | 7 +- .../modes/editVertex/undoHandlers.test.js | 79 +++++ .../modes/editVertex/vertexOperations.test.js | 114 +++++++ .../modes/editVertex/vertexQueries.js | 2 +- .../modes/editVertex/vertexQueries.test.js | 73 ++++ .../adapters/maplibre/modes/editVertexMode.js | 319 +----------------- .../maplibre/modes/editVertexMode.test.js | 140 ++++++++ 17 files changed, 1242 insertions(+), 320 deletions(-) create mode 100644 plugins/beta/draw/src/adapters/maplibre/modes/editVertex/__helpers__/harness.js create mode 100644 plugins/beta/draw/src/adapters/maplibre/modes/editVertex/geometryHelpers.test.js create mode 100644 plugins/beta/draw/src/adapters/maplibre/modes/editVertex/helpers.test.js create mode 100644 plugins/beta/draw/src/adapters/maplibre/modes/editVertex/keyboardHandlers.js create mode 100644 plugins/beta/draw/src/adapters/maplibre/modes/editVertex/keyboardHandlers.test.js create mode 100644 plugins/beta/draw/src/adapters/maplibre/modes/editVertex/pointerHandlers.js create mode 100644 plugins/beta/draw/src/adapters/maplibre/modes/editVertex/pointerHandlers.test.js create mode 100644 plugins/beta/draw/src/adapters/maplibre/modes/editVertex/touchHandlers.test.js create mode 100644 plugins/beta/draw/src/adapters/maplibre/modes/editVertex/undoHandlers.test.js create mode 100644 plugins/beta/draw/src/adapters/maplibre/modes/editVertex/vertexOperations.test.js create mode 100644 plugins/beta/draw/src/adapters/maplibre/modes/editVertex/vertexQueries.test.js create mode 100644 plugins/beta/draw/src/adapters/maplibre/modes/editVertexMode.test.js diff --git a/jest.config.mjs b/jest.config.mjs index a33d594ec..52f8f155c 100755 --- a/jest.config.mjs +++ b/jest.config.mjs @@ -26,6 +26,7 @@ export default { '/coverage', '/demo', '/src/test-utils.js', + '__helpers__', '/plugins/beta/datasets/', '/providers/beta/', '/plugins/beta/draw-es', diff --git a/plugins/beta/draw/src/adapters/maplibre/modes/createDrawMode.js b/plugins/beta/draw/src/adapters/maplibre/modes/createDrawMode.js index dc8e9bcd2..a64df8b43 100644 --- a/plugins/beta/draw/src/adapters/maplibre/modes/createDrawMode.js +++ b/plugins/beta/draw/src/adapters/maplibre/modes/createDrawMode.js @@ -1,13 +1,4 @@ -import { - getSnapInstance, - isSnapActive, - isSnapEnabled, - getSnapLngLat, - triggerSnapAtPoint, - triggerSnapAtCenter, - createSnappedEvent, - createSnappedClickEvent -} from '../utils/snapHelpers.js' +import { getSnapInstance, isSnapActive, isSnapEnabled, getSnapLngLat, triggerSnapAtPoint, triggerSnapAtCenter, createSnappedEvent, createSnappedClickEvent } from '../utils/snapHelpers.js' /** * Factory function to create a draw mode for either polygons or lines. diff --git a/plugins/beta/draw/src/adapters/maplibre/modes/editVertex/__helpers__/harness.js b/plugins/beta/draw/src/adapters/maplibre/modes/editVertex/__helpers__/harness.js new file mode 100644 index 000000000..a9ef71f98 --- /dev/null +++ b/plugins/beta/draw/src/adapters/maplibre/modes/editVertex/__helpers__/harness.js @@ -0,0 +1,107 @@ +import { EditVertexMode } from '../../editVertexMode.js' +import { createUndoStack } from '../../../../../utils/undoStack.js' +import PolygonFeature from '../../../../../../../../../node_modules/@mapbox/mapbox-gl-draw/src/feature_types/polygon.js' +import LineStringFeature from '../../../../../../../../../node_modules/@mapbox/mapbox-gl-draw/src/feature_types/line_string.js' + +/** + * Shared test harness for the vertex-edit mode and its handler modules. Builds the real + * EditVertexMode (with the real DirectSelect parent and real mapbox-gl-draw feature + * classes) over test doubles for the map, store, api and DOM. Excluded from coverage. + */ + +// Deterministic projection: lngLat <-> pixel is a x10 scale. +const project = (p) => { + const [lng, lat] = Array.isArray(p) ? p : [p.lng, p.lat] + return { x: lng * 10, y: lat * 10 } +} +const unproject = ({ x, y }) => ({ lng: x / 10, lat: y / 10 }) + +export const POLYGON = () => ({ type: 'Polygon', coordinates: [[[0, 0], [10, 0], [10, 10], [0, 10], [0, 0]]] }) +export const LINE = () => ({ type: 'LineString', coordinates: [[0, 0], [10, 0], [10, 10]] }) +export const dragEvt = (lng, lat) => ({ originalEvent: { stopPropagation: jest.fn() }, lngLat: { lng, lat }, point: { x: lng * 10, y: lat * 10 } }) +export const svgTarget = (state) => ({ parentNode: state.touchVertexTarget }) + +const contexts = [] + +export const createHarness = (geometry = POLYGON(), setupOptions = {}) => { + const features = new Map() + const store = { + featureChanged: jest.fn(), + render: jest.fn(), + get: (id) => features.get(id) + } + const api = { + add: jest.fn((geojson) => { + const FeatureClass = geojson.geometry.type === 'Polygon' ? PolygonFeature : LineStringFeature + // Mirror Draw.add: re-create the feature so Polygon closing coords are normalised + features.set(geojson.id, new FeatureClass(_ctx, { ...geojson, id: geojson.id })) + return [geojson.id] + }), + changeMode: jest.fn(), + delete: jest.fn(), + trash: jest.fn() + } + const _ctx = { store, api, options: {} } + + const FeatureClass = geometry.type === 'Polygon' ? PolygonFeature : LineStringFeature + const feature = new FeatureClass(_ctx, { type: 'Feature', id: 'feat-1', properties: {}, geometry }) + features.set('feat-1', feature) + + const listeners = {} + const map = { + _undoStack: createUndoStack(() => {}), + _snapInstance: null, + _lastEditFeatureId: null, + _drawEditContainer: null, + _editingFeatureId: null, + _drawCurrentMapStyle: { mapColorScheme: 'light' }, + dragPan: { enable: jest.fn(), disable: jest.fn(), isEnabled: () => true }, + doubleClickZoom: { enable: jest.fn(), disable: jest.fn() }, + getSource: jest.fn(() => ({ setData: jest.fn() })), + getLayer: jest.fn(() => null), + setLayoutProperty: jest.fn(), + getCenter: () => ({ lng: 5, lat: 5 }), + project: jest.fn(project), + unproject: jest.fn(unproject), + fire: jest.fn(function (type, e) { (listeners[type] ?? []).forEach((h) => h(e)) }), + on: jest.fn((type, h) => { (listeners[type] ??= []).push(h) }), + off: jest.fn((type, h) => { listeners[type] = (listeners[type] ?? []).filter((x) => x !== h) }) + } + + const container = document.createElement('div') + container.tabIndex = 0 + document.body.appendChild(container) + + const ctx = { + map, + _ctx, + getFeature: (id) => features.get(id), + getSelected: jest.fn(() => [feature]), + setSelected: jest.fn(), + setSelectedCoordinates: jest.fn(), + clearSelectedCoordinates: jest.fn(), + setActionableState: jest.fn(), + updateUIClasses: jest.fn(), + fire: jest.fn() + } + Object.assign(ctx, EditVertexMode) + + const options = { + container, + featureId: 'feat-1', + interfaceType: 'mouse', + deleteVertexButtonId: 'delete-vertex', + undoButtonId: 'undo-vertex', + isPanEnabled: true, + ...setupOptions + } + const state = ctx.onSetup(options) + contexts.push({ ctx, state }) + return { ctx, state, feature, features, map, store, api, container, options } +} + +afterEach(() => { + contexts.splice(0).forEach(({ ctx, state }) => { try { ctx.onStop(state) } catch { /* already stopped */ } }) + document.body.innerHTML = '' + jest.useRealTimers() +}) diff --git a/plugins/beta/draw/src/adapters/maplibre/modes/editVertex/geometryHelpers.test.js b/plugins/beta/draw/src/adapters/maplibre/modes/editVertex/geometryHelpers.test.js new file mode 100644 index 000000000..1f9bc00f5 --- /dev/null +++ b/plugins/beta/draw/src/adapters/maplibre/modes/editVertex/geometryHelpers.test.js @@ -0,0 +1,46 @@ +import { + getCoords, getRingSegments, getSegmentForIndex, getModifiableCoords, coordPathToFlatIndex +} from './geometryHelpers.js' + +const POLYGON = () => ({ type: 'Polygon', coordinates: [[[0, 0], [10, 0], [10, 10], [0, 10], [0, 0]]] }) +const LINE = () => ({ type: 'LineString', coordinates: [[0, 0], [10, 0], [10, 10]] }) + +describe('geometryHelpers', () => { + test('getCoords flattens every geometry type and guards missing input', () => { + expect(getCoords(LINE())).toHaveLength(3) + expect(getCoords(POLYGON())).toHaveLength(5) + expect(getCoords({ type: 'MultiLineString', coordinates: [[[0, 0], [1, 1]], [[2, 2]]] })).toHaveLength(3) + expect(getCoords({ type: 'MultiPolygon', coordinates: [[[[0, 0], [1, 1]]], [[[2, 2]]]] })).toHaveLength(3) + expect(getCoords({ type: 'Point', coordinates: [0, 0] })).toEqual([]) + expect(getCoords(null)).toEqual([]) + }) + + test('getRingSegments describes each geometry type and guards missing input', () => { + expect(getRingSegments(LINE())).toEqual([{ start: 0, length: 3, path: [], closed: false }]) + expect(getRingSegments(POLYGON())).toEqual([{ start: 0, length: 5, path: [0], closed: true }]) + expect(getRingSegments({ type: 'MultiLineString', coordinates: [[[0, 0], [1, 1]], [[2, 2]]] })) + .toEqual([{ start: 0, length: 2, path: [0], closed: false }, { start: 2, length: 1, path: [1], closed: false }]) + expect(getRingSegments({ type: 'MultiPolygon', coordinates: [[[[0, 0], [1, 1]]], [[[2, 2]]]] })) + .toEqual([{ start: 0, length: 2, path: [0, 0], closed: true }, { start: 2, length: 1, path: [1, 0], closed: true }]) + expect(getRingSegments({ type: 'Point', coordinates: [0, 0] })).toEqual([]) + expect(getRingSegments(null)).toEqual([]) + }) + + test('getSegmentForIndex finds the owning segment or returns null', () => { + const segments = getRingSegments(POLYGON()) + expect(getSegmentForIndex(segments, 2)).toEqual({ segment: segments[0], localIdx: 2 }) + expect(getSegmentForIndex(segments, 99)).toBeNull() + }) + + test('getModifiableCoords walks the hierarchical path', () => { + const geojson = { geometry: { type: 'MultiPolygon', coordinates: [[[[0, 0]]], [[[9, 9]]]] } } + expect(getModifiableCoords(geojson, [1, 0])).toEqual([[9, 9]]) + }) + + test('coordPathToFlatIndex resolves matched paths and falls back to the last index', () => { + const poly = POLYGON() + expect(coordPathToFlatIndex(poly, '0.2')).toBe(2) + // Unmatched path falls back to the trailing number + expect(coordPathToFlatIndex(poly, '5.7')).toBe(7) + }) +}) diff --git a/plugins/beta/draw/src/adapters/maplibre/modes/editVertex/helpers.test.js b/plugins/beta/draw/src/adapters/maplibre/modes/editVertex/helpers.test.js new file mode 100644 index 000000000..40435d299 --- /dev/null +++ b/plugins/beta/draw/src/adapters/maplibre/modes/editVertex/helpers.test.js @@ -0,0 +1,17 @@ +import { scalePoint, isOnSVG } from './helpers.js' + +describe('helpers', () => { + test('scalePoint multiplies both axes by the scale', () => { + expect(scalePoint({ x: 2, y: 3 }, 4)).toEqual({ x: 8, y: 12 }) + expect(scalePoint({ x: 5, y: 5 }, 1)).toEqual({ x: 5, y: 5 }) + }) + + test('isOnSVG detects SVG elements and their descendants, not plain DOM nodes', () => { + const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg') + const circle = document.createElementNS('http://www.w3.org/2000/svg', 'circle') + svg.appendChild(circle) + expect(isOnSVG(svg)).toBeTruthy() // instanceof SVGElement + expect(isOnSVG(circle)).toBeTruthy() // nested SVG element + expect(isOnSVG(document.createElement('div'))).toBeFalsy() + }) +}) diff --git a/plugins/beta/draw/src/adapters/maplibre/modes/editVertex/keyboardHandlers.js b/plugins/beta/draw/src/adapters/maplibre/modes/editVertex/keyboardHandlers.js new file mode 100644 index 000000000..2ace22d23 --- /dev/null +++ b/plugins/beta/draw/src/adapters/maplibre/modes/editVertex/keyboardHandlers.js @@ -0,0 +1,172 @@ +import { + getSnapInstance, isSnapActive, isSnapEnabled, getSnapLngLat, + getSnapRadius, triggerSnapAtPoint, clearSnapIndicator +} from '../../utils/snapHelpers.js' +import { getCoords } from './geometryHelpers.js' + +const ARROW_KEYS = new Set(['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown']) +const ARROW_OFFSETS = { ArrowUp: [0, -1], ArrowDown: [0, 1], ArrowLeft: [-1, 0], ArrowRight: [1, 0] } +const INTERACTIVE_TAGS = new Set(['INPUT', 'TEXTAREA', 'BUTTON', 'SELECT', 'A']) + +// Keyboard shortcuts are ignored while a form control outside the map viewport has +// focus, but still work for elements inside the viewport (e.g. draw toolbar buttons). +const isInteractiveElementFocused = (state) => { + const el = document.activeElement + if (!el || el === document.body) { return false } + if (state.container?.contains(el)) { return false } + return INTERACTIVE_TAGS.has(el.tagName) || el.isContentEditable || el.hasAttribute('tabindex') +} + +const isUndoShortcut = (e) => e.key === 'z' && (e.metaKey || e.ctrlKey) && !e.shiftKey + +/** + * Keyboard interaction for the vertex-edit mode: arrow-key vertex movement/insertion, + * space-to-select, Escape, and Cmd/Ctrl+Z undo. Mixed into EditVertexMode. + */ +export const keyboardHandlers = { + onKeydown (state, e) { + if (isInteractiveElementFocused(state)) { + return + } + + state.interfaceType = 'keyboard' + this.hideTouchVertexIndicator(state) + + if (e.key === ' ') { + this.handleSpace(state, e) + return + } + if (ARROW_KEYS.has(e.key) && state.selectedVertexIndex >= 0) { + this.handleArrowKey(state, e) + return + } + if (e.key === 'Escape') { + this.changeMode(state, { isPanEnabled: true, selectedVertexIndex: -1, selectedVertexType: null }) + return + } + if (isUndoShortcut(e)) { + this.handleUndoShortcut(state, e) + } + }, + + // Space always cancels the default; with no active selection it starts keyboard editing. + handleSpace (state, e) { + e.preventDefault() + if (state.selectedVertexIndex < 0) { + this.startKeyboardSelection(state) + } + }, + + // Alt+arrow steps to the next vertex/midpoint; a plain arrow nudges the selected vertex. + handleArrowKey (state, e) { + e.preventDefault() + e.stopPropagation() + if (e.altKey) { + this.updateVertex(state, e.key) + return + } + this.moveVertexByKey(state, e) + }, + + // Space with no active selection: select the first vertex for keyboard editing. + startKeyboardSelection (state) { + const snap = getSnapInstance(this.map) + if (snap) { + clearSnapIndicator(snap, this.map) + } + if (!state.vertecies?.length) { + state.vertecies = this.getVerticies(state.featureId) + state.midpoints = this.getMidpoints(state.featureId) + } + if (!state.vertecies?.length) { + return + } + state.isPanEnabled = false + this.updateVertex(state) + }, + + // Arrow key with a selected vertex: insert (midpoint) or nudge the vertex, honouring snap. + moveVertexByKey (state, e) { + if (state.selectedVertexType === 'midpoint') { + this.insertVertex(state, e) + return + } + + const feature = this.getFeature(state.featureId) + const currentCoord = feature && getCoords(feature)?.[state.selectedVertexIndex] + if (!currentCoord) { + return + } + + // Save starting position for undo (only on first move of sequence) + if (!state._keyboardMoveStartPosition) { + state._keyboardMoveStartPosition = [...currentCoord] + state._keyboardMoveStartIndex = state.selectedVertexIndex + } + + this.moveVertex(state, this._keyboardMoveTarget(state, e, currentCoord)) + }, + + // Resolve the destination coordinate for a keyboard nudge, applying or breaking snap. + _keyboardMoveTarget (state, e, currentCoord) { + const snap = getSnapInstance(this.map) + + // Break out of an active snap by moving beyond the snap radius + if (isSnapEnabled(state) && state._isSnapped && snap) { + const offset = getSnapRadius(snap) + 1 + const pt = this.map.project(currentCoord) + const [dx, dy] = ARROW_OFFSETS[e.key].map(v => v * offset) + state._isSnapped = false + clearSnapIndicator(snap, this.map) + return this.map.unproject({ x: pt.x + dx, y: pt.y + dy }) + } + + const newCoord = this.getNewCoord(state, e) + if (isSnapEnabled(state) && snap) { + triggerSnapAtPoint(snap, this.map, this.map.project(newCoord)) + if (isSnapActive(snap)) { + state._isSnapped = true + return getSnapLngLat(snap) + } + } + state._isSnapped = false + return newCoord + }, + + // Cmd/Ctrl+Z: undo the last edit, unless the user is typing in a text field. + handleUndoShortcut (state, e) { + const tag = document.activeElement?.tagName + if (tag === 'INPUT' || tag === 'TEXTAREA') { + return + } + e.preventDefault() + e.stopPropagation() + this.handleUndo(state) + }, + + onKeyup (state, e) { + if (isInteractiveElementFocused(state)) { + return + } + + state.interfaceType = 'keyboard' + if (ARROW_KEYS.has(e.key) && state.selectedVertexIndex >= 0) { + e.stopPropagation() + + // Push undo for keyboard move sequence + if (state._keyboardMoveStartPosition && state._keyboardMoveStartIndex != null) { + this.pushUndo({ + type: 'move_vertex', + featureId: state.featureId, + vertexIndex: state._keyboardMoveStartIndex, + previousPosition: state._keyboardMoveStartPosition + }) + state._keyboardMoveStartPosition = null + state._keyboardMoveStartIndex = null + } + } + if (e.key === 'Delete') { + this.deleteVertex(state) + } + } +} diff --git a/plugins/beta/draw/src/adapters/maplibre/modes/editVertex/keyboardHandlers.test.js b/plugins/beta/draw/src/adapters/maplibre/modes/editVertex/keyboardHandlers.test.js new file mode 100644 index 000000000..58e74c7de --- /dev/null +++ b/plugins/beta/draw/src/adapters/maplibre/modes/editVertex/keyboardHandlers.test.js @@ -0,0 +1,123 @@ +import { createHarness } from './__helpers__/harness.js' + +describe('keyboardHandlers', () => { + const keydown = (ctx, state, key, extra = {}) => ctx.onKeydown(state, { key, preventDefault: jest.fn(), stopPropagation: jest.fn(), ...extra }) + const keyup = (ctx, state, key) => ctx.onKeyup(state, { key, stopPropagation: jest.fn() }) + + test('shortcuts are ignored outside the viewport (input, focusable non-input) and when the container is absent', () => { + const { ctx, state } = createHarness() + const input = document.createElement('input') + document.body.appendChild(input) + input.focus() + keydown(ctx, state, 'ArrowRight') + keyup(ctx, state, 'ArrowRight') + expect(state.interfaceType).not.toBe('keyboard') + + const focusable = document.createElement('div') + focusable.tabIndex = 0 + document.body.appendChild(focusable) + focusable.focus() + keydown(ctx, state, 'ArrowRight') + expect(state.interfaceType).not.toBe('keyboard') + + input.focus() + const s = { ...state, container: undefined, interfaceType: 'mouse' } + keydown(ctx, s, 'ArrowRight') + expect(s.interfaceType).toBe('mouse') + }) + + test('space selects the first vertex, or does nothing when a vertex is selected or there are none', () => { + const { ctx, state, map } = createHarness() + map._snapInstance = { status: true, snapStatus: true, snapCoords: [1, 1] } + const updateSpy = jest.spyOn(ctx, 'updateVertex').mockImplementation(() => {}) + keydown(ctx, state, ' ') + expect(state.isPanEnabled).toBe(false) + expect(updateSpy).toHaveBeenCalledTimes(1) + + keydown(ctx, { ...state, selectedVertexIndex: 0 }, ' ') // already selected → just cancels default + expect(updateSpy).toHaveBeenCalledTimes(1) + + map._snapInstance = null // no snap indicator to clear + const s = { ...state, featureId: 'missing', vertecies: [], selectedVertexIndex: -1, isPanEnabled: true } + keydown(ctx, s, ' ') + expect(s.isPanEnabled).toBe(true) + }) + + test('an arrow key moves the selected vertex, snapping when active and using the raw coord otherwise', () => { + const { ctx, state, map } = createHarness() + state.selectedVertexIndex = 1 + keydown(ctx, state, 'ArrowRight') + expect(state._keyboardMoveStartIndex).toBe(1) + + state.getSnapEnabled = () => true + map._snapInstance = { status: true, snapStatus: true, snapCoords: [7, 8], snapToClosestPoint: jest.fn() } + keydown(ctx, state, 'ArrowUp') + expect(state._isSnapped).toBe(true) + expect(state.vertecies[1]).toEqual([7, 8]) + + keydown(ctx, state, 'ArrowLeft') // snapped already → break out of the snap radius + expect(state._isSnapped).toBe(false) + + map._snapInstance = { status: true, snapStatus: false, snapCoords: null, snapToClosestPoint: jest.fn() } + keydown(ctx, state, 'ArrowRight') // snap enabled but inactive → raw new coord + expect(state._isSnapped).toBe(false) + }) + + test('an arrow key on a midpoint inserts a vertex; guards a missing feature or out-of-range vertex', () => { + const { ctx, state } = createHarness() + const insertSpy = jest.spyOn(ctx, 'insertVertex').mockImplementation(() => {}) + keydown(ctx, { ...state, selectedVertexIndex: state.vertecies.length, selectedVertexType: 'midpoint' }, 'ArrowRight') + expect(insertSpy).toHaveBeenCalled() + + keydown(ctx, { ...state, featureId: 'missing', selectedVertexIndex: 0 }, 'ArrowRight') + keydown(ctx, { ...state, selectedVertexIndex: 99 }, 'ArrowRight') + expect(ctx.map._undoStack.length).toBe(0) + }) + + test('alt+arrow steps the selection, Escape clears it, and Cmd/Ctrl+Z undoes (ignoring shift and text fields)', () => { + const { ctx, state, api } = createHarness() + state.selectedVertexIndex = 0 + const updateSpy = jest.spyOn(ctx, 'updateVertex').mockImplementation(() => {}) + keydown(ctx, state, 'ArrowRight', { altKey: true }) + expect(updateSpy).toHaveBeenCalledWith(state, 'ArrowRight') + + keydown(ctx, state, 'Escape') + expect(api.changeMode).toHaveBeenCalledWith('edit_vertex', expect.objectContaining({ isPanEnabled: true })) + + const undoSpy = jest.spyOn(ctx, 'handleUndo').mockImplementation(() => {}) + keydown(ctx, state, 'z', { metaKey: true }) + keydown(ctx, state, 'z', { ctrlKey: true }) + expect(undoSpy).toHaveBeenCalledTimes(2) + keydown(ctx, state, 'z', { metaKey: true, shiftKey: true }) // shift → not undo + expect(undoSpy).toHaveBeenCalledTimes(2) + + // Ignored while typing in a text field inside the viewport + const input = document.createElement('input') + state.container.appendChild(input) + input.focus() + keydown(ctx, state, 'z', { metaKey: true }) + expect(undoSpy).toHaveBeenCalledTimes(2) + }) + + test('onKeyup pushes a move undo after a sequence, deletes on Delete, no-ops otherwise, and allows viewport focus', () => { + const { ctx, state, map } = createHarness() + state.selectedVertexIndex = 1 + state._keyboardMoveStartPosition = [10, 0] + state._keyboardMoveStartIndex = 1 + keyup(ctx, state, 'ArrowRight') + expect(map._undoStack.pop()).toMatchObject({ type: 'move_vertex', vertexIndex: 1 }) + + keyup(ctx, { ...state, selectedVertexIndex: 1 }, 'ArrowRight') // no active sequence → no undo + expect(map._undoStack.length).toBe(0) + + const deleteSpy = jest.spyOn(ctx, 'deleteVertex').mockImplementation(() => {}) + keyup(ctx, state, 'Delete') + expect(deleteSpy).toHaveBeenCalled() + + const child = document.createElement('button') // focus inside the viewport is non-blocking + state.container.appendChild(child) + child.focus() + keyup(ctx, state, 'ArrowRight') + expect(state.interfaceType).toBe('keyboard') + }) +}) diff --git a/plugins/beta/draw/src/adapters/maplibre/modes/editVertex/pointerHandlers.js b/plugins/beta/draw/src/adapters/maplibre/modes/editVertex/pointerHandlers.js new file mode 100644 index 000000000..49f91813a --- /dev/null +++ b/plugins/beta/draw/src/adapters/maplibre/modes/editVertex/pointerHandlers.js @@ -0,0 +1,161 @@ +import DirectSelect from '../../../../../../../../node_modules/@mapbox/mapbox-gl-draw/src/modes/direct_select.js' // NOSONAR +import { + getSnapInstance, isSnapEnabled, getSnapLngLat, triggerSnapAtPoint, clearSnapState +} from '../../utils/snapHelpers.js' +import { getCoords, coordPathToFlatIndex } from './geometryHelpers.js' + +const EVENT_VERTEX_SELECTION = 'draw.vertexselection' + +/** + * Mouse/pointer interaction for the vertex-edit mode: vertex/midpoint mouse-down, + * click-to-commit insertions, drag (with snapping) and mouse-up undo bookkeeping. + * Wraps the parent DirectSelect handlers. Mixed into EditVertexMode. + */ +export const pointerHandlers = { + onMouseDown (state, e) { + clearSnapState(getSnapInstance(this.map)) + const meta = e.featureTarget?.properties.meta + const coordPath = e.featureTarget?.properties.coord_path + + if (['vertex', 'midpoint'].includes(meta)) { + state.dragMoveLocation = e.lngLat + state.dragMoving = false + DirectSelect.onMouseDown.call(this, state, e) + + // Update selection state for vertex clicks (so onSelectionChange has correct context) + if (meta === 'vertex' && coordPath) { + const feature = this.getFeature(state.featureId) + const vertexIndex = coordPathToFlatIndex(feature, coordPath) + state.selectedVertexIndex = vertexIndex + state.selectedVertexType = 'vertex' + state.coordPath = coordPath + const vertex = state.vertecies?.[vertexIndex] + if (vertex) { + state._moveStartPosition = [...vertex] + state._moveStartIndex = vertexIndex + } + } + } + if (meta === 'midpoint') { + // DirectSelect converts midpoint to vertex - track this as an insert + const feature = this.getFeature(state.featureId) + const insertedIndex = coordPathToFlatIndex(feature, coordPath) + + // Track this insertion for undo (will be pushed on mouseUp if drag occurred) + state._insertedVertexIndex = insertedIndex + state._isInsertingVertex = true + + state.selectedVertexIndex = this.getVertexIndexFromMidpoint(state, coordPath) + state.selectedVertexType = 'vertex' + state.coordPath = null // Clear coordPath for midpoints + this.map.fire(EVENT_VERTEX_SELECTION, { index: state.selectedVertexIndex, numVertecies: state.vertecies.length }) + } + }, + + onClick (state, e) { // NOSONAR — complexity accumulated from object-level context; single guard clause, see feedback_mgl_click_vs_mouseup.md + if (state._isInsertingVertex && state._insertedVertexIndex != null) { + const insertedIndex = state._insertedVertexIndex + this.syncVertices(state) + this.pushUndo({ type: 'insert_vertex', featureId: state.featureId, vertexIndex: insertedIndex }) + state.selectedVertexIndex = insertedIndex + state.selectedVertexType = 'vertex' + state._isInsertingVertex = false + state._insertedVertexIndex = null + this.map.fire(EVENT_VERTEX_SELECTION, { index: insertedIndex, numVertecies: state.vertecies.length }) + return + } + DirectSelect.onClick.call(this, state, e) + }, + + onMouseUp (state, e) { + clearSnapState(getSnapInstance(this.map)) + + const wasInsertion = state._isInsertingVertex && state._insertedVertexIndex != null + const vertexMoved = this._didVertexMove(state) + + if (state.dragMoving || vertexMoved || wasInsertion) { + this.syncVertices(state) + if (wasInsertion) { + this._recordInsertionUndo(state) + } else if (vertexMoved) { + this._recordMoveUndo(state) + } else { + // dragMoving without an actual move or insertion: nothing to record + } + } + + // Clean up move state + state._moveStartPosition = null + state._moveStartIndex = null + + DirectSelect.onMouseUp.call(this, state, e) + }, + + // Did the selected vertex actually change position during this interaction? + // Reads the live feature (not the cached state.vertecies) for reliability. + _didVertexMove (state) { + if (!state._moveStartPosition || state._moveStartIndex == null) { + return false + } + const feature = this.getFeature(state.featureId) + const currentVertex = feature && getCoords(feature)?.[state._moveStartIndex] + if (!currentVertex) { + return false + } + return currentVertex[0] !== state._moveStartPosition[0] || + currentVertex[1] !== state._moveStartPosition[1] + }, + + // Commit a midpoint-drag insertion: record undo, reselect the new vertex, broadcast the count. + _recordInsertionUndo (state) { + const insertedIndex = state._insertedVertexIndex + this.pushUndo({ type: 'insert_vertex', featureId: state.featureId, vertexIndex: insertedIndex }) + // selectedVertexIndex pointed at the old midpoint-range index; use the real flat index + state.selectedVertexIndex = insertedIndex + state.selectedVertexType = 'vertex' + state._isInsertingVertex = false + state._insertedVertexIndex = null + // DirectSelect.onMouseUp fires draw.update but not draw.selectionchange, so broadcast the count here + this.map.fire(EVENT_VERTEX_SELECTION, { index: insertedIndex, numVertecies: state.vertecies.length }) + }, + + _recordMoveUndo (state) { + this.pushUndo({ + type: 'move_vertex', + featureId: state.featureId, + vertexIndex: state._moveStartIndex, + previousPosition: state._moveStartPosition + }) + }, + + onDrag (state, e) { + if (state.interfaceType === 'touch') { + return + } + + this.map.fire('draw.geometrychange', state.feature) + + const snap = getSnapInstance(this.map) + if (snap) { + snap.snapStatus = false + snap.snapCoords = null + } + + if (!isSnapEnabled(state) || !snap?.status) { + DirectSelect.onDrag.call(this, state, e) + return + } + + if (!state.selectedCoordPaths?.length || !state.canDragMove) { + return + } + + state.dragMoving = true + e.originalEvent.stopPropagation() + triggerSnapAtPoint(snap, this.map, e.point) + + const finalLngLat = getSnapLngLat(snap) || e.lngLat + state.feature.updateCoordinate(state.selectedCoordPaths[0], finalLngLat.lng, finalLngLat.lat) + state.dragMoveLocation = e.lngLat + } +} diff --git a/plugins/beta/draw/src/adapters/maplibre/modes/editVertex/pointerHandlers.test.js b/plugins/beta/draw/src/adapters/maplibre/modes/editVertex/pointerHandlers.test.js new file mode 100644 index 000000000..3d0ecc01f --- /dev/null +++ b/plugins/beta/draw/src/adapters/maplibre/modes/editVertex/pointerHandlers.test.js @@ -0,0 +1,98 @@ +import { createHarness, dragEvt } from './__helpers__/harness.js' + +describe('pointerHandlers', () => { + test('onMouseDown selects a vertex and records the move start, ignoring empty/no-coord targets', () => { + const { ctx, state } = createHarness() + ctx.onMouseDown(state, { lngLat: { lng: 10, lat: 0 }, featureTarget: { properties: { meta: 'vertex', coord_path: '0.1' } } }) + expect(state.selectedVertexIndex).toBe(1) + expect(state._moveStartIndex).toBe(1) + + const h = createHarness() + h.ctx.onMouseDown(h.state, { lngLat: { lng: 0, lat: 0 }, featureTarget: undefined }) + h.ctx.onMouseDown(h.state, { lngLat: { lng: 0, lat: 0 }, featureTarget: { properties: { meta: 'vertex', coord_path: '0.9' } } }) + expect(h.state._moveStartIndex).toBeUndefined() + }) + + test('onMouseDown on a midpoint marks an insertion in progress', () => { + const { ctx, state, map } = createHarness() + ctx.onMouseDown(state, { lngLat: { lng: 5, lat: 0 }, featureTarget: { properties: { meta: 'midpoint', coord_path: '0.1', lng: 5, lat: 0 } } }) + expect(state._isInsertingVertex).toBe(true) + expect(map.fire).toHaveBeenCalledWith('draw.vertexselection', expect.any(Object)) + }) + + test('onClick commits an in-progress insertion, otherwise delegates to DirectSelect', () => { + const { ctx, state, api } = createHarness() + state._isInsertingVertex = true + state._insertedVertexIndex = 2 + ctx.onClick(state, {}) + expect(ctx.map._undoStack.pop()).toMatchObject({ type: 'insert_vertex', vertexIndex: 2 }) + expect(state._isInsertingVertex).toBe(false) + + ctx.onClick(state, { featureTarget: undefined }) // noTarget → clickNoTarget → changeMode + expect(api.changeMode).toHaveBeenCalledWith('edit_vertex', expect.objectContaining({ selectedVertexIndex: -1 })) + }) + + test('onMouseUp records a move undo, an insertion undo, or nothing', () => { + const { ctx, state, map } = createHarness() + // nothing + ctx.onMouseUp(state, {}) + expect(map._undoStack.length).toBe(0) + + // move + state._moveStartIndex = 1 + state._moveStartPosition = [10, 0] + ctx.moveVertex({ ...state, selectedVertexIndex: 1 }, { lng: 3, lat: 3 }) + ctx.onMouseUp(state, {}) + expect(map._undoStack.pop()).toMatchObject({ type: 'move_vertex', vertexIndex: 1 }) + + // insertion + state.dragMoving = true + state._isInsertingVertex = true + state._insertedVertexIndex = 2 + ctx.onMouseUp(state, {}) + expect(map._undoStack.pop()).toMatchObject({ type: 'insert_vertex', vertexIndex: 2 }) + + // In-progress drag with no move/insertion records nothing; guards missing feature/stale index; detects a vertical-only move + const h = createHarness() + h.ctx.onMouseUp({ ...h.state, dragMoving: true }, {}) + h.ctx.onMouseUp({ ...h.state, _moveStartPosition: [0, 0], _moveStartIndex: 0, featureId: 'missing' }, {}) + h.ctx.onMouseUp({ ...h.state, _moveStartPosition: [0, 0], _moveStartIndex: 99 }, {}) + expect(h.map._undoStack.length).toBe(0) + h.ctx.moveVertex({ ...h.state, selectedVertexIndex: 1 }, { lng: 10, lat: 5 }) // same lng, new lat + h.ctx.onMouseUp({ ...h.state, _moveStartPosition: [10, 0], _moveStartIndex: 1 }, {}) + expect(h.map._undoStack.pop()).toMatchObject({ type: 'move_vertex', vertexIndex: 1 }) + }) + + test('onDrag skips touch, delegates without snap, and snaps when enabled', () => { + const { ctx, state, map } = createHarness() + ctx.onDrag({ ...state, interfaceType: 'touch' }, { originalEvent: { stopPropagation: jest.fn() }, lngLat: { lng: 1, lat: 1 }, point: { x: 10, y: 10 } }) + + // no snap → DirectSelect.onDrag moves the vertex + Object.assign(state, { canDragMove: true, selectedCoordPaths: ['0.1'], dragMoveLocation: { lng: 0, lat: 0 } }) + ctx.onDrag(state, { originalEvent: { stopPropagation: jest.fn() }, lngLat: { lng: 2, lat: 2 }, point: { x: 20, y: 20 } }) + expect(map.fire).toHaveBeenCalledWith('draw.geometrychange', expect.anything()) + + // snap enabled + state.getSnapEnabled = () => true + map._snapInstance = { status: true, snapStatus: false, snapCoords: null, snapToClosestPoint: jest.fn() } + ctx.onDrag(state, { originalEvent: { stopPropagation: jest.fn() }, lngLat: { lng: 3, lat: 3 }, point: { x: 30, y: 30 } }) + expect(state.dragMoving).toBe(true) + + // Snapping enabled but nothing selected → bail out + const nosel = createHarness() + nosel.map._snapInstance = { status: true, snapStatus: true, snapCoords: [1, 1] } + const ns = { ...nosel.state, getSnapEnabled: () => true, selectedCoordPaths: [], canDragMove: false } + nosel.ctx.onDrag(ns, dragEvt(1, 1)) + expect(ns.dragMoving).not.toBe(true) + + // Snapping active → snapped point; inactive → raw point + const snapped = createHarness() + snapped.map._snapInstance = { status: true, snapStatus: false, snapCoords: null, snapToClosestPoint: jest.fn(() => { snapped.map._snapInstance.snapStatus = true; snapped.map._snapInstance.snapCoords = [7, 8] }) } + snapped.ctx.onDrag({ ...snapped.state, getSnapEnabled: () => true, selectedCoordPaths: ['0.1'], canDragMove: true }, dragEvt(2, 2)) + expect(snapped.ctx.getFeature('feat-1').getCoordinate('0.1')).toEqual([7, 8]) + const raw = createHarness() + raw.map._snapInstance = { status: true, snapStatus: false, snapCoords: null, snapToClosestPoint: jest.fn() } + raw.ctx.onDrag({ ...raw.state, getSnapEnabled: () => true, selectedCoordPaths: ['0.1'], canDragMove: true }, dragEvt(3, 3)) + expect(raw.ctx.getFeature('feat-1').getCoordinate('0.1')).toEqual([3, 3]) + }) +}) diff --git a/plugins/beta/draw/src/adapters/maplibre/modes/editVertex/touchHandlers.test.js b/plugins/beta/draw/src/adapters/maplibre/modes/editVertex/touchHandlers.test.js new file mode 100644 index 000000000..659016810 --- /dev/null +++ b/plugins/beta/draw/src/adapters/maplibre/modes/editVertex/touchHandlers.test.js @@ -0,0 +1,92 @@ +import { createHarness, svgTarget } from './__helpers__/harness.js' +import { applyTouchVertexColors } from './touchHandlers.js' + +describe('touchHandlers', () => { + test('applyTouchVertexColors returns early for a null element and defaults the colour scheme', () => { + expect(() => applyTouchVertexColors(null, {})).not.toThrow() + const { state } = createHarness() + expect(() => applyTouchVertexColors(state.touchVertexTarget, null)).not.toThrow() + }) + + test('updateTouchVertexTarget shows the target for a touch selection and hides it otherwise', () => { + const { ctx, state } = createHarness() + state.interfaceType = 'touch' + state.selectedVertexIndex = 0 + ctx.updateTouchVertexTarget(state, { x: 12, y: 34 }) + expect(state.touchVertexTarget.style.display).toBe('block') + ctx.updateTouchVertexTarget(state, null) + expect(state.touchVertexTarget.style.display).toBe('none') + }) + + test('onPointerevent deselects on touch drag off the target, and just tracks the interface for mouse', () => { + const { ctx, state, api } = createHarness() + ctx.onPointerevent(state, { pointerType: 'mouse', type: 'pointermove', target: { parentNode: document.createElement('div') } }) + expect(state.interfaceType).toBe('mouse') + expect(api.changeMode).not.toHaveBeenCalled() + + state._ignorePointermoveDeselect = false + ctx.onPointerevent(state, { pointerType: 'touch', type: 'pointermove', target: { parentNode: document.createElement('div') } }) + expect(state.interfaceType).toBe('touch') + expect(api.changeMode).toHaveBeenCalledWith('edit_vertex', expect.objectContaining({ selectedVertexIndex: -1 })) + }) + + test('onTouchend records a move undo only when a touch move occurred', () => { + const { ctx, state, map } = createHarness() + ctx.onTouchend({ }) // no featureId → no-op + ctx.onTouchend({ ...state, _touchMoved: false }) // feature but no move → no undo + expect(map._undoStack.length).toBe(0) + state._touchMoved = true + state._moveStartPosition = [0, 0] + state._moveStartIndex = 1 + ctx.onTouchend(state) + expect(map._undoStack.pop()).toMatchObject({ type: 'move_vertex', vertexIndex: 1 }) + }) + + test('onTouchstart captures move start state, ignoring taps off the vertex/target', () => { + const { ctx, state } = createHarness() + ctx.onTouchstart(state, { target: { parentNode: document.createElement('div') }, touches: [{ clientX: 1, clientY: 1 }] }) + expect(state._moveStartIndex).toBeUndefined() + + state.selectedVertexIndex = 1 + ctx.onTouchstart(state, { target: svgTarget(state), touches: [{ clientX: 20, clientY: 20 }] }) + expect(state._moveStartIndex).toBe(1) + expect(state._touchMoved).toBe(false) + }) + + test('onTouchmove moves the selected vertex, honouring snap, and ignores non-target moves', () => { + const { ctx, state, map } = createHarness() + state.selectedVertexIndex = 1 + ctx.onTouchstart(state, { target: svgTarget(state), touches: [{ clientX: 20, clientY: 20 }] }) + + ctx.onTouchmove(state, { target: { parentNode: document.createElement('div') }, touches: [{ clientX: 5, clientY: 5 }] }) + expect(state.vertecies[1]).toEqual([10, 0]) // off-target move ignored + ctx.onTouchmove(state, { target: svgTarget(state), touches: [{ clientX: 30, clientY: 40 }] }) + + state.getSnapEnabled = () => true + map._snapInstance = { status: true, snapStatus: true, snapCoords: [7, 8], snapToClosestPoint: jest.fn() } + ctx.onTouchmove(state, { target: svgTarget(state), touches: [{ clientX: 50, clientY: 60 }] }) + expect(state.vertecies[1]).toEqual([7, 8]) + + // Snap enabled but no snap point → falls back to the pointer position + map._snapInstance = { status: true, snapStatus: false, snapCoords: null, snapToClosestPoint: jest.fn() } + ctx.onTouchmove(state, { target: svgTarget(state), touches: [{ clientX: 35, clientY: 45 }] }) + expect(state._touchMoved).toBe(true) + }) + + test('onTap clears the snap indicator, then selects a vertex, inserts on a midpoint, or clears with no target', () => { + const { ctx, state, api, map } = createHarness() + map._snapInstance = { status: true, snapStatus: true, snapCoords: [1, 1] } + ctx.onTap(state, { featureTarget: { properties: { meta: 'vertex', coord_path: '0.2' } } }) + expect(map.getLayer).toHaveBeenCalledWith('snap-helper-circle') + expect(api.changeMode).toHaveBeenCalledWith('edit_vertex', expect.objectContaining({ selectedVertexType: 'vertex' })) + + api.changeMode.mockClear() + ctx.onTap(state, { featureTarget: { properties: { meta: 'midpoint', coord_path: '0.1' } } }) + expect(api.add).toHaveBeenCalled() // insertVertex path + + api.changeMode.mockClear() + map._snapInstance = null // no snap indicator to clear + ctx.onTap(state, { featureTarget: undefined }) + expect(api.changeMode).toHaveBeenCalledWith('edit_vertex', expect.objectContaining({ selectedVertexIndex: -1 })) + }) +}) diff --git a/plugins/beta/draw/src/adapters/maplibre/modes/editVertex/undoHandlers.js b/plugins/beta/draw/src/adapters/maplibre/modes/editVertex/undoHandlers.js index f87db359f..02f48a038 100644 --- a/plugins/beta/draw/src/adapters/maplibre/modes/editVertex/undoHandlers.js +++ b/plugins/beta/draw/src/adapters/maplibre/modes/editVertex/undoHandlers.js @@ -115,11 +115,8 @@ export const undoHandlers = { coords.splice(result.localIdx, 0, position) this._applyUndoAndSync(state, geojson, featureId) - // Update touch vertex target to restored vertex position - const vertex = state.vertecies[vertexIndex] - if (vertex) { - this.updateTouchVertexTarget(state, scalePoint(this.map.project(vertex), state.scale)) - } + // Re-insertion always lands the vertex back at vertexIndex, so it is guaranteed present + this.updateTouchVertexTarget(state, scalePoint(this.map.project(state.vertecies[vertexIndex]), state.scale)) this.changeMode(state, { selectedVertexIndex: vertexIndex, selectedVertexType: 'vertex', coordPath: this.getCoordPath(state, vertexIndex) }) }, diff --git a/plugins/beta/draw/src/adapters/maplibre/modes/editVertex/undoHandlers.test.js b/plugins/beta/draw/src/adapters/maplibre/modes/editVertex/undoHandlers.test.js new file mode 100644 index 000000000..f07bd40bb --- /dev/null +++ b/plugins/beta/draw/src/adapters/maplibre/modes/editVertex/undoHandlers.test.js @@ -0,0 +1,79 @@ +import { createHarness } from './__helpers__/harness.js' +import { createUndoStack } from '../../../../utils/undoStack.js' +import { getRingSegments } from './geometryHelpers.js' + +describe('undoHandlers', () => { + test('pushUndo is a no-op without a stack and pushes otherwise', () => { + const { ctx, map } = createHarness() + map._undoStack = null + expect(() => ctx.pushUndo({ type: 'move_vertex' })).not.toThrow() + map._undoStack = createUndoStack(() => {}) + ctx.pushUndo({ type: 'move_vertex' }) + expect(map._undoStack.length).toBe(1) + }) + + test('handleUndo ignores an empty stack and dispatches by operation type', () => { + const { ctx, state, map } = createHarness() + ctx.handleUndo(state) // empty → no throw + const spies = ['undoMoveVertex', 'undoInsertVertex', 'undoDeleteVertex'].map((m) => jest.spyOn(ctx, m).mockImplementation(() => {})) + map._undoStack.push({ type: 'move_vertex', featureId: 'feat-1', vertexIndex: 0, previousPosition: [0, 0] }) + ctx.handleUndo(state) + map._undoStack.push({ type: 'insert_vertex', featureId: 'feat-1', vertexIndex: 1 }) + ctx.handleUndo(state) + map._undoStack.push({ type: 'delete_vertex', featureId: 'feat-1', vertexIndex: 1, position: [0, 0] }) + ctx.handleUndo(state) + expect(spies.every((s) => s.mock.calls.length === 1)).toBe(true) + + map._undoStack.push({ type: 'unknown' }) + expect(() => ctx.handleUndo(state)).not.toThrow() + }) + + test('fireGeometryChange fires draw.update only when the feature exists', () => { + const { ctx, state, map } = createHarness() + ctx.fireGeometryChange(state) + expect(map.fire).toHaveBeenCalledWith('draw.update', expect.objectContaining({ action: 'change_coordinates' })) + map.fire.mockClear() + ctx.fireGeometryChange({ featureId: 'missing' }) + expect(map.fire).not.toHaveBeenCalled() + }) + + test('undoMoveVertex restores the previous position, guarding missing feature/segment', () => { + const { ctx, state } = createHarness() + ctx.undoMoveVertex({ ...state, featureId: 'missing' }, { vertexIndex: 0, previousPosition: [0, 0], featureId: 'missing' }) + ctx.undoMoveVertex(state, { vertexIndex: 99, previousPosition: [0, 0], featureId: 'feat-1' }) + state.selectedVertexIndex = 0 + ctx.undoMoveVertex(state, { vertexIndex: 0, previousPosition: [3, 4], featureId: 'feat-1' }) + expect(ctx.getVerticies('feat-1')[0]).toEqual([3, 4]) + + // Skips the touch target update when the selected vertex is gone + ctx.undoMoveVertex({ ...state, selectedVertexIndex: 99 }, { vertexIndex: 0, previousPosition: [1, 1], featureId: 'feat-1' }) + expect(ctx.getVerticies('feat-1')[0]).toEqual([1, 1]) + }) + + test('undoInsertVertex removes the inserted vertex and clears the selection', () => { + const { ctx, state, api } = createHarness() + ctx.undoInsertVertex(state, { vertexIndex: 1, featureId: 'feat-1' }) + expect(ctx.getVerticies('feat-1')).toHaveLength(3) + expect(api.changeMode).toHaveBeenCalledWith('edit_vertex', expect.objectContaining({ selectedVertexIndex: -1 })) + + // Guards a missing feature and an out-of-range index + ctx.undoInsertVertex({ ...state, featureId: 'missing' }, { vertexIndex: 0, featureId: 'missing' }) + ctx.undoInsertVertex(state, { vertexIndex: 999, featureId: 'feat-1' }) + expect(ctx.getVerticies('feat-1')).toHaveLength(3) + }) + + test('undoDeleteVertex re-inserts, handling boundary, fallback and missing feature', () => { + const { ctx, state } = createHarness() + ctx.undoDeleteVertex({ ...state, featureId: 'missing' }, { vertexIndex: 0, position: [1, 1], featureId: 'missing' }) + // Fallback: index far beyond any segment boundary → no result → no-op + ctx.undoDeleteVertex(state, { vertexIndex: 999, position: [1, 1], featureId: 'feat-1' }) + expect(ctx.getVerticies('feat-1')).toHaveLength(4) + // Success: re-insert at index 1 + ctx.undoDeleteVertex(state, { vertexIndex: 1, position: [1, 1], featureId: 'feat-1' }) + expect(ctx.getVerticies('feat-1')).toHaveLength(5) + // Boundary: index === segment.start + segment.length + const seg = getRingSegments(ctx.getFeature('feat-1'))[0] + ctx.undoDeleteVertex(state, { vertexIndex: seg.start + seg.length, position: [2, 2], featureId: 'feat-1' }) + expect(ctx.getVerticies('feat-1')).toHaveLength(6) + }) +}) diff --git a/plugins/beta/draw/src/adapters/maplibre/modes/editVertex/vertexOperations.test.js b/plugins/beta/draw/src/adapters/maplibre/modes/editVertex/vertexOperations.test.js new file mode 100644 index 000000000..45efe27c8 --- /dev/null +++ b/plugins/beta/draw/src/adapters/maplibre/modes/editVertex/vertexOperations.test.js @@ -0,0 +1,114 @@ +import { createHarness, LINE } from './__helpers__/harness.js' + +describe('vertexOperations', () => { + test('updateMidpoint pushes point data to the hot source', () => { + jest.useFakeTimers() + const { ctx, map } = createHarness() + ctx.updateMidpoint([1, 2]) + jest.runAllTimers() + expect(map.getSource).toHaveBeenCalledWith('mapbox-gl-draw-hot') + }) + + test('updateVertex changes mode for a valid target and no-ops otherwise', () => { + const { ctx, state, api } = createHarness() + state.selectedVertexIndex = 0 + ctx.updateVertex(state, 'ArrowRight') + expect(api.changeMode).toHaveBeenCalledWith('edit_vertex', expect.objectContaining({ + selectedVertexIndex: expect.any(Number) + })) + + api.changeMode.mockClear() + ctx.updateVertex({ featureId: 'missing', vertecies: [], selectedVertexIndex: -1 }, 'ArrowRight') + expect(api.changeMode).not.toHaveBeenCalled() + + // A vertex target carries a coordPath; a midpoint target does not + jest.spyOn(ctx, 'getVertexOrMidpoint').mockReturnValueOnce([1, 'vertex']) + ctx.updateVertex(state, 'ArrowRight') + expect(api.changeMode.mock.calls.at(-1)[1].coordPath).toBeDefined() + jest.spyOn(ctx, 'getVertexOrMidpoint').mockReturnValueOnce([5, 'midpoint']) + ctx.updateVertex(state, 'ArrowRight') + expect(api.changeMode.mock.calls.at(-1)[1].coordPath).toBeUndefined() + }) + + test('getOffset applies step/nudge amounts and returns the coord unchanged without an event', () => { + const { ctx } = createHarness() + const stepped = ctx.getOffset([5, 5], { key: 'ArrowRight', shiftKey: false }) + const nudged = ctx.getOffset([5, 5], { key: 'ArrowRight', shiftKey: true }) + expect(stepped.lng).not.toBe(5) + expect(Math.abs(nudged.lng - 5)).toBeLessThan(Math.abs(stepped.lng - 5)) + expect(ctx.getOffset([5, 5], null)).toEqual({ lng: 5, lat: 5 }) + }) + + test('getNewCoord offsets the currently selected vertex', () => { + const { ctx, state } = createHarness() + state.selectedVertexIndex = 1 + expect(ctx.getNewCoord(state, { key: 'ArrowRight', shiftKey: false })).toHaveProperty('lng') + }) + + test('insertVertex splits a midpoint into a new vertex, records undo and selects it', () => { + const { ctx, state, api } = createHarness() + ctx.insertVertex({ ...state, selectedVertexIndex: state.vertecies.length, selectedVertexType: 'midpoint' }, { key: 'ArrowRight', shiftKey: false }) + expect(api.add).toHaveBeenCalled() + expect(state.map ?? ctx.map._undoStack.length).toBeGreaterThan(0) + expect(api.changeMode).toHaveBeenCalledWith('edit_vertex', expect.objectContaining({ + selectedVertexType: 'vertex' + })) + + // Also works for an open line segment + const line = createHarness(LINE()) + line.ctx.insertVertex({ ...line.state, selectedVertexIndex: line.state.vertecies.length, selectedVertexType: 'midpoint' }, { key: 'ArrowRight', shiftKey: false }) + expect(line.api.add).toHaveBeenCalled() + }) + + test('insertVertex bails out when the midpoint maps to no segment', () => { + const { ctx, state, api } = createHarness() + // midIdx beyond the segment's midpoint count, but with a defined midpoint coord + const badState = { ...state, selectedVertexIndex: state.vertecies.length + 4, midpoints: [...state.midpoints, [5, 5]] } + api.add.mockClear() + ctx.insertVertex(badState, { key: 'ArrowRight', shiftKey: false }) + expect(api.add).not.toHaveBeenCalled() + }) + + test('moveVertex repositions a vertex, applies snap and guards an out-of-range index', () => { + const { ctx, state, map } = createHarness() + state.selectedVertexIndex = 0 + ctx.moveVertex(state, { lng: 1, lat: 2 }) + expect(state.vertecies[0]).toEqual([1, 2]) + + map._snapInstance = { snapStatus: true, snapCoords: [7, 8] } + ctx.moveVertex(state, { lng: 1, lat: 2 }, { checkSnap: true }) + expect(state.vertecies[0]).toEqual([7, 8]) + + const before = [...state.vertecies] + ctx.moveVertex({ ...state, selectedVertexIndex: 99 }, { lng: 3, lat: 3 }) + expect(ctx.getVerticies('feat-1')).toEqual(before) + + // checkSnap requested but no active snap → coordinate used as-is + const h = createHarness() + h.ctx.moveVertex({ ...h.state, selectedVertexIndex: 0 }, { lng: 1, lat: 2 }, { checkSnap: true }) + expect(h.ctx.getVerticies('feat-1')[0]).toEqual([1, 2]) + }) + + test('deleteVertex removes a vertex, or no-ops for missing feature / bad index / minimum size', () => { + const { ctx, state, api } = createHarness() + ctx.deleteVertex({ ...state, featureId: 'missing' }) + ctx.deleteVertex({ ...state, selectedVertexIndex: 99 }) + expect(api.changeMode).not.toHaveBeenCalled() + + const triangle = createHarness({ type: 'Polygon', coordinates: [[[0, 0], [10, 0], [5, 10], [0, 0]]] }) + triangle.state.selectedVertexIndex = 0 + triangle.ctx.deleteVertex(triangle.state) // 3 vertices → at minimum, rejected + expect(triangle.api.changeMode).not.toHaveBeenCalled() + + state.selectedVertexIndex = 1 + ctx.deleteVertex(state) + expect(ctx.map._undoStack.pop()).toMatchObject({ type: 'delete_vertex', vertexIndex: 1 }) + expect(api.changeMode).toHaveBeenCalledWith('edit_vertex', expect.objectContaining({ selectedVertexIndex: -1 })) + + // Also works on an open line segment + const line = createHarness(LINE()) + line.state.selectedVertexIndex = 1 + line.ctx.deleteVertex(line.state) + expect(line.ctx.getVerticies('feat-1')).toHaveLength(2) + }) +}) diff --git a/plugins/beta/draw/src/adapters/maplibre/modes/editVertex/vertexQueries.js b/plugins/beta/draw/src/adapters/maplibre/modes/editVertex/vertexQueries.js index 721ac1f60..7118b4a1a 100644 --- a/plugins/beta/draw/src/adapters/maplibre/modes/editVertex/vertexQueries.js +++ b/plugins/beta/draw/src/adapters/maplibre/modes/editVertex/vertexQueries.js @@ -45,7 +45,7 @@ export const vertexQueries = { }, getVerticies (featureId) { - return getCoords(this.getFeature(featureId)) || [] + return getCoords(this.getFeature(featureId)) }, getMidpoints (featureId) { diff --git a/plugins/beta/draw/src/adapters/maplibre/modes/editVertex/vertexQueries.test.js b/plugins/beta/draw/src/adapters/maplibre/modes/editVertex/vertexQueries.test.js new file mode 100644 index 000000000..703919771 --- /dev/null +++ b/plugins/beta/draw/src/adapters/maplibre/modes/editVertex/vertexQueries.test.js @@ -0,0 +1,73 @@ +import { createHarness, LINE } from './__helpers__/harness.js' + +describe('vertexQueries', () => { + test('findVertexIndex handles no, single and duplicate matches', () => { + const { ctx } = createHarness() + expect(ctx.findVertexIndex([[0, 0], [1, 1]], [9, 9], -1)).toBe(-1) + expect(ctx.findVertexIndex([[0, 0], [1, 1]], [1, 1], -1)).toBe(1) + expect(ctx.findVertexIndex([[0, 0], [1, 1], [0, 0]], [0, 0], 2)).toBe(2) + expect(ctx.findVertexIndex([[0, 0], [1, 1], [0, 0]], [0, 0], -1)).toBe(0) + expect(ctx.findVertexIndex([[0, 0], [1, 1], [0, 0]], [0, 0], 0)).toBe(0) + }) + + test('getCoordPath resolves an index, or returns "0" without a feature or segment', () => { + const { ctx, state } = createHarness() + expect(ctx.getCoordPath(state, 2)).toBe('0.2') + expect(ctx.getCoordPath(state, 99)).toBe('0') + expect(ctx.getCoordPath({ featureId: 'missing' }, 0)).toBe('0') + }) + + test('getVerticies / getMidpoints for closed rings, open lines and missing features', () => { + const { ctx } = createHarness() + expect(ctx.getVerticies('feat-1')).toHaveLength(4) + expect(ctx.getMidpoints('feat-1')).toHaveLength(4) + expect(ctx.getMidpoints('missing')).toEqual([]) + const line = createHarness(LINE()) + expect(line.ctx.getMidpoints('feat-1')).toHaveLength(2) + }) + + test('syncVertices refreshes the cached vertex and midpoint lists', () => { + const { ctx, state } = createHarness() + state.vertecies = [] + state.midpoints = [] + ctx.syncVertices(state) + expect(state.vertecies).toHaveLength(4) + expect(state.midpoints).toHaveLength(4) + }) + + test('getVertexOrMidpoint repopulates empty lists, navigates, and guards empty features', () => { + const { ctx, state } = createHarness() + state.vertecies = [] + state.selectedVertexIndex = 0 + const [idx, type] = ctx.getVertexOrMidpoint(state, 'ArrowRight') + expect(idx).toBeGreaterThanOrEqual(0) + expect(['vertex', 'midpoint']).toContain(type) + + expect(ctx.getVertexOrMidpoint({ featureId: 'missing', vertecies: [] }, 'ArrowRight')).toEqual([-1, null]) + expect(ctx.getVertexOrMidpoint({ ...state, vertecies: [null], midpoints: [], selectedVertexIndex: 0 }, 'ArrowRight')).toEqual([-1, null]) + + // Falls back to the map centre when the current index has no pixel, and can resolve a vertex target + const fresh = createHarness() + expect(fresh.ctx.getVertexOrMidpoint({ ...fresh.state, selectedVertexIndex: -1 }, 'ArrowRight')[0]).toBeGreaterThanOrEqual(0) + const types = new Set() + for (let s = 0; s < fresh.state.vertecies.length + fresh.state.midpoints.length; s++) { + for (const d of ['ArrowRight', 'ArrowLeft', 'ArrowUp', 'ArrowDown']) { + types.add(fresh.ctx.getVertexOrMidpoint({ ...fresh.state, selectedVertexIndex: s }, d)[1]) + } + } + expect(types.has('vertex')).toBe(true) + }) + + test('getVertexIndexFromMidpoint maps coord paths (insertion index, wrap-around and fallback)', () => { + const { ctx, state, features } = createHarness() + expect(ctx.getVertexIndexFromMidpoint(state, '0.2')).toBe(5) + expect(ctx.getVertexIndexFromMidpoint(state, '0.0')).toBe(6) + expect(ctx.getVertexIndexFromMidpoint(state, '5.0')).toBe(4) + + // Counts earlier segments for multi-part geometry (closed and open) + features.set('multipoly', { type: 'MultiPolygon', coordinates: [[[[0, 0], [1, 0], [1, 1], [0, 0]]], [[[5, 5], [6, 5], [6, 6], [5, 5]]]] }) + expect(ctx.getVertexIndexFromMidpoint({ ...state, featureId: 'multipoly', vertecies: new Array(8).fill([0, 0]) }, '1.0.1')).toBeGreaterThan(8) + features.set('multiline', { type: 'MultiLineString', coordinates: [[[0, 0], [1, 1]], [[5, 5], [6, 6]]] }) + expect(ctx.getVertexIndexFromMidpoint({ ...state, featureId: 'multiline', vertecies: new Array(4).fill([0, 0]) }, '1.1')).toBeGreaterThan(4) + }) +}) diff --git a/plugins/beta/draw/src/adapters/maplibre/modes/editVertexMode.js b/plugins/beta/draw/src/adapters/maplibre/modes/editVertexMode.js index 831e561a5..d2aef13d8 100755 --- a/plugins/beta/draw/src/adapters/maplibre/modes/editVertexMode.js +++ b/plugins/beta/draw/src/adapters/maplibre/modes/editVertexMode.js @@ -1,17 +1,15 @@ import DirectSelect from '../../../../../../../node_modules/@mapbox/mapbox-gl-draw/src/modes/direct_select.js' // NOSONAR -import { - getSnapInstance, isSnapActive, isSnapEnabled, getSnapLngLat, - getSnapRadius, triggerSnapAtPoint, clearSnapIndicator, clearSnapState -} from '../utils/snapHelpers.js' -import { getCoords, coordPathToFlatIndex } from './editVertex/geometryHelpers.js' +import { getSnapInstance, clearSnapIndicator } from '../utils/snapHelpers.js' +import { getCoords } from './editVertex/geometryHelpers.js' import { scalePoint } from './editVertex/helpers.js' import { undoHandlers } from './editVertex/undoHandlers.js' import { touchHandlers } from './editVertex/touchHandlers.js' import { vertexOperations } from './editVertex/vertexOperations.js' import { vertexQueries } from './editVertex/vertexQueries.js' +import { keyboardHandlers } from './editVertex/keyboardHandlers.js' +import { pointerHandlers } from './editVertex/pointerHandlers.js' -const ARROW_KEYS = new Set(['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown']) -const ARROW_OFFSETS = { ArrowUp: [0, -1], ArrowDown: [0, 1], ArrowLeft: [-1, 0], ArrowRight: [1, 0] } +const EVENT_VERTEX_SELECTION = 'draw.vertexselection' export const EditVertexMode = { ...DirectSelect, @@ -19,6 +17,8 @@ export const EditVertexMode = { ...touchHandlers, ...vertexOperations, ...vertexQueries, + ...keyboardHandlers, + ...pointerHandlers, onSetup (options) { const state = DirectSelect.onSetup.call(this, options) @@ -29,7 +29,7 @@ export const EditVertexMode = { undoButtonId: options.undoButtonId, isPanEnabled: options.isPanEnabled, getSnapEnabled: options.getSnapEnabled, - featureId: state.featureId || options.featureId, + featureId: state.featureId, selectedVertexIndex: options.selectedVertexIndex ?? -1, selectedVertexType: options.selectedVertexType, coordPath: options.coordPath, @@ -117,7 +117,7 @@ export const EditVertexMode = { if (options.selectedVertexType === 'midpoint') { state.selectedCoordPaths = [] this.clearSelectedCoordinates() - if (state.feature) { state.feature.changed() } + state.feature.changed() this._ctx.store.render() this.updateMidpoint(state.midpoints[options.selectedVertexIndex - state.vertecies.length]) return @@ -125,7 +125,7 @@ export const EditVertexMode = { if (options.selectedVertexIndex === -1) { state.selectedCoordPaths = [] this.clearSelectedCoordinates() - if (state.feature) { state.feature.changed() } + state.feature.changed() this._ctx.store.render() } }, @@ -148,7 +148,7 @@ export const EditVertexMode = { state.selectedVertexType ??= state.selectedVertexIndex >= 0 ? 'vertex' : null - this.map.fire('draw.vertexselection', { + this.map.fire(EVENT_VERTEX_SELECTION, { index: state.selectedVertexType === 'vertex' ? state.selectedVertexIndex : -1, numVertecies: state.vertecies.length }) @@ -173,299 +173,10 @@ export const EditVertexMode = { if (prev.size === state.vertecies.length) { return } - state.selectedVertexIndex = state.vertecies.findIndex(c => !prev.has(JSON.stringify(c))) - state.selectedVertexType ??= state.selectedVertexIndex >= 0 ? 'vertex' : null - }, - - onKeydown (state, e) { - const isInteractiveElementFocused = () => { - const el = document.activeElement - if (!el || el === document.body) return false - // Allow shortcuts even on interactive elements if they're inside the map viewport - if (state.container?.contains(el)) return false - const interactiveTags = new Set(['INPUT', 'TEXTAREA', 'BUTTON', 'SELECT', 'A']) - return interactiveTags.has(el.tagName) || el.isContentEditable || el.hasAttribute('tabindex') - } - - if (isInteractiveElementFocused()) { - return - } - - state.interfaceType = 'keyboard' - this.hideTouchVertexIndicator(state) - - if (e.key === ' ') { - e.preventDefault() - } - - if (e.key === ' ' && state.selectedVertexIndex < 0) { - // Clear snap indicator when starting keyboard selection - const snap = getSnapInstance(this.map) - if (snap) { - clearSnapIndicator(snap, this.map) - } - - // Ensure we have vertices to select - if (!state.vertecies?.length) { - state.vertecies = this.getVerticies(state.featureId) - state.midpoints = this.getMidpoints(state.featureId) - } - if (!state.vertecies?.length) { - return - } - state.isPanEnabled = false - return this.updateVertex(state) - } - - if (!e.altKey && ARROW_KEYS.has(e.key) && state.selectedVertexIndex >= 0) { - e.preventDefault() - e.stopPropagation() - if (state.selectedVertexType === 'midpoint') { - return this.insertVertex(state, e) - } - - const snap = getSnapInstance(this.map) - const feature = this.getFeature(state.featureId) - if (!feature) { - return - } - const coords = getCoords(feature) - const currentCoord = coords?.[state.selectedVertexIndex] - if (!currentCoord) { - return - } - - // Save starting position for undo (only on first move of sequence) - if (!state._keyboardMoveStartPosition) { - state._keyboardMoveStartPosition = [...currentCoord] - state._keyboardMoveStartIndex = state.selectedVertexIndex - } - - // Break out of snap by moving outside snap radius - if (isSnapEnabled(state) && state._isSnapped && snap) { - const offset = getSnapRadius(snap) + 1 - const pt = this.map.project(currentCoord) - const [dx, dy] = ARROW_OFFSETS[e.key].map(v => v * offset) - state._isSnapped = false - clearSnapIndicator(snap, this.map) - return this.moveVertex(state, this.map.unproject({ x: pt.x + dx, y: pt.y + dy })) - } - - const newCoord = this.getNewCoord(state, e) - if (isSnapEnabled(state) && snap) { - triggerSnapAtPoint(snap, this.map, this.map.project(newCoord)) - if (isSnapActive(snap)) { - state._isSnapped = true - return this.moveVertex(state, getSnapLngLat(snap)) - } - } - state._isSnapped = false - return this.moveVertex(state, newCoord) - } - - if (e.altKey && ARROW_KEYS.has(e.key) && state.selectedVertexIndex >= 0) { - e.preventDefault() - e.stopPropagation() - return this.updateVertex(state, e.key) - } - - if (e.key === 'Escape') { - this.changeMode(state, { isPanEnabled: true, selectedVertexIndex: -1, selectedVertexType: null }) - } - - // Undo with Cmd/Ctrl+Z (works without viewport focus, but not in input fields) - if (e.key === 'z' && (e.metaKey || e.ctrlKey) && !e.shiftKey) { - const tag = document.activeElement?.tagName - if (tag === 'INPUT' || tag === 'TEXTAREA') { - return - } - e.preventDefault() - e.stopPropagation() - return this.handleUndo(state) - } - }, - - onKeyup (state, e) { - const isInteractiveElementFocused = () => { - const el = document.activeElement - if (!el || el === document.body) return false - if (state.container?.contains(el)) return false - const interactiveTags = new Set(['INPUT', 'TEXTAREA', 'BUTTON', 'SELECT', 'A']) - return interactiveTags.has(el.tagName) || el.isContentEditable || el.hasAttribute('tabindex') - } - - if (isInteractiveElementFocused()) { - return - } - - state.interfaceType = 'keyboard' - if (ARROW_KEYS.has(e.key) && state.selectedVertexIndex >= 0) { - e.stopPropagation() - - // Push undo for keyboard move sequence - if (state._keyboardMoveStartPosition && state._keyboardMoveStartIndex !== undefined) { - this.pushUndo({ - type: 'move_vertex', - featureId: state.featureId, - vertexIndex: state._keyboardMoveStartIndex, - previousPosition: state._keyboardMoveStartPosition - }) - state._keyboardMoveStartPosition = null - state._keyboardMoveStartIndex = undefined - } - } - if (e.key === 'Delete') { - this.deleteVertex(state) - } - }, - - onMouseDown (state, e) { - clearSnapState(getSnapInstance(this.map)) - const meta = e.featureTarget?.properties.meta - const coordPath = e.featureTarget?.properties.coord_path - - if (['vertex', 'midpoint'].includes(meta)) { - state.dragMoveLocation = e.lngLat - state.dragMoving = false - DirectSelect.onMouseDown.call(this, state, e) - - // Update selection state for vertex clicks (so onSelectionChange has correct context) - if (meta === 'vertex' && coordPath) { - const feature = this.getFeature(state.featureId) - const vertexIndex = coordPathToFlatIndex(feature, coordPath) - state.selectedVertexIndex = vertexIndex - state.selectedVertexType = 'vertex' - state.coordPath = coordPath - const vertex = state.vertecies?.[vertexIndex] - if (vertex) { - state._moveStartPosition = [...vertex] - state._moveStartIndex = vertexIndex - } - } - } - if (meta === 'midpoint') { - // DirectSelect converts midpoint to vertex - track this as an insert - const feature = this.getFeature(state.featureId) - const insertedIndex = coordPathToFlatIndex(feature, coordPath) - - // Track this insertion for undo (will be pushed on mouseUp if drag occurred) - state._insertedVertexIndex = insertedIndex - state._isInsertingVertex = true - - state.selectedVertexIndex = this.getVertexIndexFromMidpoint(state, coordPath) - state.selectedVertexType = 'vertex' - state.coordPath = null // Clear coordPath for midpoints - this.map.fire('draw.vertexselection', { index: state.selectedVertexIndex, numVertecies: state.vertecies.length }) - } - }, - - onClick (state, e) { // NOSONAR — complexity accumulated from object-level context; single guard clause, see feedback_mgl_click_vs_mouseup.md - if (state._isInsertingVertex && state._insertedVertexIndex !== undefined) { - const insertedIndex = state._insertedVertexIndex - this.syncVertices(state) - this.pushUndo({ type: 'insert_vertex', featureId: state.featureId, vertexIndex: insertedIndex }) - state.selectedVertexIndex = insertedIndex - state.selectedVertexType = 'vertex' - state._isInsertingVertex = false - state._insertedVertexIndex = undefined - this.map.fire('draw.vertexselection', { index: insertedIndex, numVertecies: state.vertecies.length }) - return - } - DirectSelect.onClick.call(this, state, e) - }, - - onMouseUp (state, e) { - clearSnapState(getSnapInstance(this.map)) - - // Check if vertex actually moved by comparing current position to start position - // This is more robust than relying on state.dragMoving which can be inconsistent - // IMPORTANT: Get current position from the feature, not state.vertecies (which is cached) - let vertexMoved = false - if (state._moveStartPosition && state._moveStartIndex !== undefined) { - const feature = this.getFeature(state.featureId) - if (feature) { - const currentVertex = getCoords(feature)?.[state._moveStartIndex] - if (currentVertex) { - vertexMoved = currentVertex[0] !== state._moveStartPosition[0] || - currentVertex[1] !== state._moveStartPosition[1] - } - } - } - - // Also check for insertions (dragMoving is reliable for midpoint drags) - const wasInsertion = state._isInsertingVertex && state._insertedVertexIndex !== undefined - - if (state.dragMoving || vertexMoved || wasInsertion) { - this.syncVertices(state) - - // Push undo for vertex insertion (from dragging midpoint) - if (wasInsertion) { - const insertedIndex = state._insertedVertexIndex - this.pushUndo({ - type: 'insert_vertex', - featureId: state.featureId, - vertexIndex: insertedIndex - }) - // selectedVertexIndex was pointing to the old midpoint-range index; - // update it to the actual flat index of the newly inserted vertex - state.selectedVertexIndex = insertedIndex - state.selectedVertexType = 'vertex' - state._isInsertingVertex = false - state._insertedVertexIndex = undefined - // Broadcast the updated vertex count — DirectSelect.onMouseUp only fires - // draw.update (not draw.selectionchange), so onSelectionChange never runs - this.map.fire('draw.vertexselection', { - index: insertedIndex, numVertecies: state.vertecies.length - }) - } else if (vertexMoved && state._moveStartPosition && state._moveStartIndex !== undefined) { - // Push undo for the move if vertex actually moved - this.pushUndo({ - type: 'move_vertex', - featureId: state.featureId, - vertexIndex: state._moveStartIndex, - previousPosition: state._moveStartPosition - }) - } else { - // No action - } - } - - // Clean up move state - state._moveStartPosition = null - state._moveStartIndex = null - - DirectSelect.onMouseUp.call(this, state, e) - }, - - onDrag (state, e) { - if (state.interfaceType === 'touch') { - return - } - - this.map.fire('draw.geometrychange', state.feature) - - const snap = getSnapInstance(this.map) - if (snap) { - snap.snapStatus = false - snap.snapCoords = null - } - - if (!isSnapEnabled(state) || !snap?.status) { - DirectSelect.onDrag.call(this, state, e) - return - } - - if (!state.selectedCoordPaths?.length || !state.canDragMove) { - return - } - - state.dragMoving = true - e.originalEvent.stopPropagation() - triggerSnapAtPoint(snap, this.map, e.point) - - const finalLngLat = getSnapLngLat(snap) || e.lngLat - state.feature.updateCoordinate(state.selectedCoordPaths[0], finalLngLat.lng, finalLngLat.lat) - state.dragMoveLocation = e.lngLat + // Duplicate coordinates exist (e.g. a self-touching ring). Comparing the list + // against itself cannot surface a distinct new vertex, so clear the selection. + state.selectedVertexIndex = -1 + state.selectedVertexType ??= null }, onMove (state) { diff --git a/plugins/beta/draw/src/adapters/maplibre/modes/editVertexMode.test.js b/plugins/beta/draw/src/adapters/maplibre/modes/editVertexMode.test.js new file mode 100644 index 000000000..153b60d68 --- /dev/null +++ b/plugins/beta/draw/src/adapters/maplibre/modes/editVertexMode.test.js @@ -0,0 +1,140 @@ +import { createHarness, POLYGON } from './editVertex/__helpers__/harness.js' + +/** + * Tests for EditVertexMode's own methods: setup/teardown lifecycle, selection/scale/update + * events, and the small move/button/changeMode routing. The keyboard, pointer, touch, undo, + * vertex-query, vertex-operation and geometry helpers are covered in their own colocated + * test files under editVertex/. + */ + +describe('onSetup / onStop lifecycle', () => { + test('populates state, clears the undo stack for a new feature and registers listeners', () => { + const { ctx, state, map } = createHarness() + expect(state.featureId).toBe('feat-1') + // Real PolygonFeature stores rings without the duplicate closing coordinate + expect(state.vertecies).toHaveLength(4) + expect(state.midpoints).toHaveLength(4) + expect(map._lastEditFeatureId).toBe('feat-1') + expect(map._drawEditContainer).toBe(state.container) + expect(ctx.map.on).toHaveBeenCalledWith('draw.update', expect.any(Function)) + }) + + test('does not clear the undo stack when re-entering the same feature', () => { + const { ctx, map, options } = createHarness() + map._undoStack.push({ type: 'move_vertex' }) + // map._lastEditFeatureId is already 'feat-1' from the first setup + ctx.onSetup(options) + expect(map._undoStack.length).toBe(1) + }) + + test('onStop removes listeners and clears editing container; DirectSelect touch stubs are inert', () => { + const { ctx, state, map } = createHarness() + expect(() => { ctx.onTouchStart(); ctx.onTouchMove(); ctx.onTouchEnd() }).not.toThrow() + ctx.onStop(state) + expect(map._drawEditContainer).toBeNull() + expect(map.off).toHaveBeenCalledWith('draw.update', expect.any(Function)) + }) + + test('onSetup edge cases: clears an active snap indicator, positions or skips the touch target, clears an explicit -1 selection', () => { + jest.useFakeTimers() + const { ctx, map, options } = createHarness() + map._snapInstance = { status: true, snapStatus: false, snapCoords: null } + ctx.onSetup(options) + expect(map.getLayer).toHaveBeenCalledWith('snap-helper-circle') + + const touch = createHarness(POLYGON(), { interfaceType: 'touch', selectedVertexIndex: 0, selectedVertexType: 'vertex' }) + jest.runAllTimers() + expect(touch.state.touchVertexTarget.style.display).toBe('block') + + const outOfRange = createHarness(POLYGON(), { interfaceType: 'touch', selectedVertexIndex: 99, selectedVertexType: 'vertex' }) + jest.runAllTimers() + expect(outOfRange.state.touchVertexTarget.style.display).toBe('none') + + const cleared = createHarness(POLYGON(), { selectedVertexIndex: -1 }) + expect(cleared.ctx.clearSelectedCoordinates).toHaveBeenCalled() + }) +}) + +describe('selection, scale and update events', () => { + test('applyVertexSelection handles midpoint entry and cleared selection', () => { + jest.useFakeTimers() + const midpoint = createHarness(POLYGON(), { selectedVertexType: 'midpoint', selectedVertexIndex: 4 }) + jest.runAllTimers() + expect(midpoint.ctx.clearSelectedCoordinates).toHaveBeenCalled() + expect(midpoint.map.getSource).toHaveBeenCalledWith('mapbox-gl-draw-hot') + }) + + test('onSelectionChange searches by coordinate for mouse, trusts coordPath/keyboard otherwise', () => { + const { ctx, state, map } = createHarness() + const geom = ctx.getFeature('feat-1').toGeoJSON().geometry + ctx.onSelectionChange(state, { points: [{ geometry: { coordinates: [10, 0] } }], features: [{ geometry: geom }] }) + expect(state.selectedVertexIndex).toBe(1) + expect(map.fire).toHaveBeenCalledWith('draw.vertexselection', expect.objectContaining({ numVertecies: 4 })) + + state.coordPath = '0.2' + state.selectedVertexIndex = 2 + ctx.onSelectionChange(state, { points: [{ geometry: { coordinates: [10, 10] } }], features: [{ geometry: geom }] }) + expect(state.selectedVertexIndex).toBe(2) // trusted, not re-searched + + // Keyboard mode trusts the selection; with no event vertex it falls back to state, then null + const kb = createHarness() + const g2 = kb.ctx.getFeature('feat-1').toGeoJSON().geometry + kb.state.interfaceType = 'keyboard' + kb.state.selectedVertexIndex = 2 + kb.ctx.onSelectionChange(kb.state, { points: [{ geometry: { coordinates: [10, 10] } }], features: [{ geometry: g2 }] }) + expect(kb.state.selectedVertexType).toBe('vertex') + kb.ctx.onSelectionChange({ ...kb.state, interfaceType: 'mouse', selectedVertexType: 'vertex', selectedVertexIndex: 0 }, { points: [], features: [{ geometry: g2 }] }) + kb.ctx.onSelectionChange({ ...kb.state, interfaceType: 'mouse', selectedVertexType: null, selectedVertexIndex: -1 }, { points: [], features: [{ geometry: g2 }] }) + }) + + test('onScaleChange and onInterfaceTypeChange update state and the touch target', () => { + const { ctx, state } = createHarness() + ctx.onScaleChange(state, { scale: 2 }) + expect(state.scale).toBe(2) + state.selectedVertexIndex = 0 + ctx.onInterfaceTypeChange(state, { interfaceType: 'touch' }) + expect(state.interfaceType).toBe('touch') + ctx.onInterfaceTypeChange({ ...state, selectedVertexIndex: -1 }, { interfaceType: 'mouse' }) + }) + + test('onUpdate re-selects a changed vertex only when the vertex count is ambiguous', () => { + const { ctx, state } = createHarness() + ctx.onUpdate(state) // unique coords → no change + state.vertecies = [[0, 0], [0, 0], [1, 1]] + ctx.onUpdate(state) + expect(state.selectedVertexIndex).toBe(-1) + }) +}) + +describe('move, button and changeMode routing', () => { + test('onMove keeps the touch target aligned with the selected vertex, ignoring an unselected vertex', () => { + const { ctx, state } = createHarness() + state.selectedVertexIndex = 0 + state.interfaceType = 'touch' + ctx.onMove(state) + expect(state.touchVertexTarget.style.display).toBe('block') + expect(() => ctx.onMove({ ...state, selectedVertexIndex: -1 })).not.toThrow() + }) + + test('onButtonClick deletes or undoes based on the clicked control', () => { + const { ctx, state, container } = createHarness() + const del = document.createElement('button') + del.id = 'delete-vertex' + const undo = document.createElement('button') + undo.id = 'undo-vertex' + container.append(del, undo) + const deleteSpy = jest.spyOn(ctx, 'deleteVertex').mockImplementation(() => {}) + const undoSpy = jest.spyOn(ctx, 'handleUndo').mockImplementation(() => {}) + + ctx.onButtonClick({ ...state, selectedVertexType: 'vertex' }, { target: del }) + expect(deleteSpy).toHaveBeenCalled() + ctx.onButtonClick(state, { target: undo }) + expect(undoSpy).toHaveBeenCalled() + }) + + test('changeMode is a no-op without a feature id', () => { + const { ctx, api } = createHarness() + ctx.changeMode({ featureId: null }, { selectedVertexIndex: -1 }) + expect(api.changeMode).not.toHaveBeenCalled() + }) +}) From 20148bab6a1774bdc177b8bdda60d6a0d83af947 Mon Sep 17 00:00:00 2001 From: Dan Leech Date: Fri, 3 Jul 2026 15:08:39 +0100 Subject: [PATCH 21/89] Draw mode code splitting and tests --- .../adapters/maplibre/modes/createDrawMode.js | 563 +------------- .../maplibre/modes/createDrawMode.test.js | 701 ++---------------- .../modes/drawMode/__helpers__/harness.js | 115 +++ .../maplibre/modes/drawMode/clickHandlers.js | 101 +++ .../modes/drawMode/clickHandlers.test.js | 132 ++++ .../modes/drawMode/keyboardHandlers.js | 78 ++ .../modes/drawMode/keyboardHandlers.test.js | 75 ++ .../maplibre/modes/drawMode/lifecycle.js | 76 ++ .../maplibre/modes/drawMode/lifecycle.test.js | 48 ++ .../modes/drawMode/pointerHandlers.js | 85 +++ .../modes/drawMode/pointerHandlers.test.js | 94 +++ .../maplibre/modes/drawMode/renderHelpers.js | 59 ++ .../modes/drawMode/renderHelpers.test.js | 34 + .../maplibre/modes/drawMode/undoHandlers.js | 168 +++++ .../modes/drawMode/undoHandlers.test.js | 175 +++++ 15 files changed, 1316 insertions(+), 1188 deletions(-) create mode 100644 plugins/beta/draw/src/adapters/maplibre/modes/drawMode/__helpers__/harness.js create mode 100644 plugins/beta/draw/src/adapters/maplibre/modes/drawMode/clickHandlers.js create mode 100644 plugins/beta/draw/src/adapters/maplibre/modes/drawMode/clickHandlers.test.js create mode 100644 plugins/beta/draw/src/adapters/maplibre/modes/drawMode/keyboardHandlers.js create mode 100644 plugins/beta/draw/src/adapters/maplibre/modes/drawMode/keyboardHandlers.test.js create mode 100644 plugins/beta/draw/src/adapters/maplibre/modes/drawMode/lifecycle.js create mode 100644 plugins/beta/draw/src/adapters/maplibre/modes/drawMode/lifecycle.test.js create mode 100644 plugins/beta/draw/src/adapters/maplibre/modes/drawMode/pointerHandlers.js create mode 100644 plugins/beta/draw/src/adapters/maplibre/modes/drawMode/pointerHandlers.test.js create mode 100644 plugins/beta/draw/src/adapters/maplibre/modes/drawMode/renderHelpers.js create mode 100644 plugins/beta/draw/src/adapters/maplibre/modes/drawMode/renderHelpers.test.js create mode 100644 plugins/beta/draw/src/adapters/maplibre/modes/drawMode/undoHandlers.js create mode 100644 plugins/beta/draw/src/adapters/maplibre/modes/drawMode/undoHandlers.test.js diff --git a/plugins/beta/draw/src/adapters/maplibre/modes/createDrawMode.js b/plugins/beta/draw/src/adapters/maplibre/modes/createDrawMode.js index a64df8b43..f597b6151 100644 --- a/plugins/beta/draw/src/adapters/maplibre/modes/createDrawMode.js +++ b/plugins/beta/draw/src/adapters/maplibre/modes/createDrawMode.js @@ -1,8 +1,14 @@ -import { getSnapInstance, isSnapActive, isSnapEnabled, getSnapLngLat, triggerSnapAtPoint, triggerSnapAtCenter, createSnappedEvent, createSnappedClickEvent } from '../utils/snapHelpers.js' +import { createLifecycle } from './drawMode/lifecycle.js' +import { createClickHandlers } from './drawMode/clickHandlers.js' +import { createUndoHandlers } from './drawMode/undoHandlers.js' +import { createKeyboardHandlers } from './drawMode/keyboardHandlers.js' +import { createPointerHandlers } from './drawMode/pointerHandlers.js' +import { createRenderHelpers } from './drawMode/renderHelpers.js' /** * Factory function to create a draw mode for either polygons or lines. - * Reduces duplication by sharing common event handling, snap detection, etc. + * Shared behaviour (event handling, snap detection, undo, rubber band, keyboard + * drawing) is split across ./drawMode/* handler modules and composed here. * * @param {Object} ParentMode - DrawPolygon or DrawLineString from mapbox-gl-draw * @param {Object} config - Configuration for the mode @@ -12,7 +18,7 @@ import { getSnapInstance, isSnapActive, isSnapEnabled, getSnapLngLat, triggerSna * @param {Function} config.validateClick - Validation function for clicks * @param {Function} config.getPlacedCoords - Function to get placed vertex coordinates from a display geojson */ -export const createDrawMode = (ParentMode, config) => { // NOSONAR — factory returns a single cohesive mode object; splitting across files would obscure the event flow +export const createDrawMode = (ParentMode, config) => { const { featureProp, geometryType, @@ -23,537 +29,30 @@ export const createDrawMode = (ParentMode, config) => { // NOSONAR — factory r finishOnInvalidClick = false // For lines: finish when clicking same spot (like double-click) } = config - const getFeature = (state) => state[featureProp] - const RUBBER_BAND_OFFSET = 2 // ring is [...placed, last_placed, rubber_band]; splice(-2,1) removes last_placed - // Only these keys signal a switch to keyboard drawing (matches the OL adapter's - // drawInput) — modifier keys and shortcut chords like cmd+z must not show the crosshair - const INTERFACE_KEYS = new Set(['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'Enter']) + const deps = { + ParentMode, + featureProp, + geometryType, + getCoords, + validateClick, + getPlacedCoords, + excludeFeatureIdFromSetup, + finishOnInvalidClick, + getFeature: (state) => state[featureProp], + // ring is [...placed, last_placed, rubber_band]; splice(-2,1) removes last_placed + RUBBER_BAND_OFFSET: 2, + // Only these keys signal a switch to keyboard drawing (matches the OL adapter's + // drawInput) — modifier keys and shortcut chords like cmd+z must not show the crosshair + INTERFACE_KEYS: new Set(['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'Enter']) + } return { ...ParentMode, - - onSetup (options) { - const { map } = this - - // Some parent modes (DrawLineString) interpret featureId as "continue existing" - // rather than "use this ID for new feature" - const parentOptions = excludeFeatureIdFromSetup - ? { ...options, featureId: null } - : options - - const state = { - ...ParentMode.onSetup.call(this, parentOptions), - ...options - } - - // Add initial props - state[featureProp].properties = options.properties - - const { container, vertexMarkerId, getInterfaceType } = state - const currentInterfaceType = getInterfaceType ? getInterfaceType() : state.interfaceType - state.interfaceType = currentInterfaceType - const vertexMarker = container.querySelector(`#${vertexMarkerId}`) - state.vertexMarker = vertexMarker - if (['touch', 'keyboard'].includes(currentInterfaceType)) { - this._showCrossHair(state) - } else { - this._hideCrossHair(state) - } - - // Bind all handlers once - const bind = (name, fn) => (this[name] = fn.bind(this, state)) - const handlers = { - keydownHandler: this.onKeydown, - keyupHandler: this.onKeyup, - blurHandler: this.onBlur, - createHandler: this.onCreate, - moveHandler: this.onMove, - pointerdownHandler: this.onPointerdown, - pointermoveHandler: this.onPointermove, - pointerupHandler: this.onPointerup, - vertexButtonClickHandler: this.onVertexButtonClick, - undoHandler: this.onUndo - } - Object.entries(handlers).forEach(([k, fn]) => bind(k, fn)) - - // Register events - this._listeners = [ - [window, 'keydown', this.keydownHandler], - [window, 'keyup', this.keyupHandler], - [window, 'click', this.vertexButtonClickHandler], - [container, 'blur', this.blurHandler], - [container, 'pointermove', this.pointermoveHandler], - [container, 'pointerup', this.pointerupHandler], - [map, 'pointerdown', this.pointerdownHandler], - [map, 'draw.create', this.createHandler], - [map, 'move', this.moveHandler], - [map, 'draw.undo', this.undoHandler] - ] - this._listeners.forEach(([t, e, h]) => t.addEventListener ? t.addEventListener(e, h) : t.on(e, h)) - - return state - }, - - onClick (state, e) { - // Skip non-primary clicks, undo operations, or clicks outside canvas - if (e.originalEvent.button > 0 || this.map._undoInProgress || e.originalEvent.target !== this.map.getCanvas()) { - return - } - const snap = getSnapInstance(this.map) - if (isSnapEnabled(state) && isSnapActive(snap)) { - e = createSnappedEvent(e, snap) - } else { - const coords = getCoords(getFeature(state)) - if (coords.length > 0) { - coords[coords.length - 1] = [e.lngLat.lng, e.lngLat.lat] - } - // For polygon: prevent duplicate-coordinate clicks from reaching ParentMode, which - // would trigger a changeMode chain and cause a runtime error on coords.length access - if (!finishOnInvalidClick && !validateClick(getFeature(state))) { - return - } - } - const coordsBefore = getCoords(getFeature(state)).length - ParentMode.onClick.call(this, state, e) - // Push undo and update count if a vertex was added - if (getCoords(getFeature(state)).length > coordsBefore) { - this.pushDrawUndo(state) - this.dispatchVertexChange(getCoords(getFeature(state))) - } - }, - - onTap () { - - }, - - doClick (state) { - // Skip during undo operation - if (this.map._undoInProgress) { - return - } - - const feature = getFeature(state) - const coords = getCoords(feature) - this.dispatchVertexChange(coords) - - if (!validateClick(feature)) { - // For lines: clicking same spot (like double-click) should finish the line. - // isValidLineClick only returns false with 2+ coords, so coords.length is always > 1 here. - if (finishOnInvalidClick) { - coords.pop() - this.map.fire('draw.create', { features: [feature.toGeoJSON()] }) - this.changeMode('simple_select', { featureIds: [feature.id] }) - } - return - } - - const snap = getSnapInstance(this.map) - const snappedEvent = isSnapEnabled(state) && createSnappedClickEvent(this.map, snap) - - if (snappedEvent) { - ParentMode.onClick.call(this, state, snappedEvent) - this._ctx.store.render() - } else { - this._simulateMouse('click', ParentMode.onClick, state) - } - - // Push undo and update count if a vertex was added. A validated click always - // adds one vertex via the parent mode, so this runs on every successful doClick. - const newCoords = getCoords(getFeature(state)) - this.pushDrawUndo(state) - this.dispatchVertexChange(newCoords) - }, - - dispatchVertexChange (coords) { - // Both polygon ring and LineString store [v0...vN, rubber_band] during drawing — subtract 1 to get placed vertex count - this.map.fire('draw.vertexchange', { - numVertecies: Math.max(0, coords.length - 1) - }) - }, - - /** - * Push an undo operation for the last added vertex - */ - pushDrawUndo (state) { - const undoStack = this.map._undoStack - // Don't push during undo operations - if (!undoStack || this.map._undoInProgress) { - return - } - undoStack.push({ - type: 'draw_vertex', - geometryType, - featureId: getFeature(state).id - }) - }, - - /** - * Undo the last added vertex during drawing - */ - undoVertex (state) { - const feature = getFeature(state) - const coords = getCoords(feature) - - if (coords.length < 2) { - return false - } - - // Undoing last vertex requires reinitializing the feature - if (coords.length === 2) { - return this._reinitializeFeature(state, feature) - } - - this._removeLastVertex(state, feature, coords) - return true - }, - - /** - * Reinitialize feature when undoing to 0 vertices - * For Polygon: reinitialize in place - * For LineString: restart the draw mode with fresh state - */ - _reinitializeFeature (state, feature) { - const featureId = feature.id - this._ctx.store.delete([featureId]) - - // LineString: restart the draw mode with fresh state but same feature ID - if (geometryType === 'LineString') { - const undoStack = this.map._undoStack - if (undoStack) { - undoStack.clear() - } - // Restart draw with same options (excludeFeatureIdFromSetup prevents "continue" mode) - this._ctx.api.changeMode('draw_line', { - featureId, - container: state.container, - interfaceType: state.interfaceType, - crossHair: state.crossHair, - vertexMarkerId: state.vertexMarkerId, - addVertexButtonId: state.addVertexButtonId, - getSnapEnabled: state.getSnapEnabled, - properties: state.properties - }) - return true - } - - // Polygon: reinitialize in place - const center = this.map.getCenter() - const initialCoords = [[center.lng, center.lat], [center.lng, center.lat]] - const newFeature = this.newFeature({ - type: 'Feature', - properties: state.properties || {}, - geometry: { - type: geometryType, - coordinates: [initialCoords] - } - }) - newFeature.id = featureId - this._ctx.store.add(newFeature) - - state[featureProp] = newFeature - state.currentVertexPosition = 0 - - this._ctx.store.render() - this._simulateMouse('mousemove', ParentMode.onMouseMove, state) - this._ctx.store.render() - - this.dispatchVertexChange(initialCoords) - return true - }, - - /** - * Remove the last committed vertex and update rubber band - */ - _removeLastVertex (state, feature, coords) { - // Structure during drawing: [v1, v2, ..., vN, rubber_band] - const ring = geometryType === 'Polygon' ? feature.coordinates[0] : coords - ring.splice(-RUBBER_BAND_OFFSET, 1) - - // Snap rubber band to new last vertex position. undoVertex only calls here with - // 3+ coords, so after the splice the ring always has a preceding vertex. - ring[ring.length - 1] = [...ring[ring.length - 2]] - - // Keep parent mode's vertex counter in sync (min 1 for rubber band) - state.currentVertexPosition = Math.max(1, state.currentVertexPosition - 1) - - this._ctx.store.render() - this._updateRubberBand(state, getCoords(feature)) - }, - - /** - * Update rubber band position based on interface type - */ - _updateRubberBand (state, coords) { - if (['touch', 'keyboard'].includes(state.interfaceType)) { - // Touch/keyboard: move to map center for add point to work - this._simulateMouse('mousemove', ParentMode.onMouseMove, state) - this._ctx.store.render() - } else { - // Mouse: keep rubber band at current position. After a vertex removal the - // rubber band index always resolves to a real coordinate. - const rubberBandIndex = geometryType === 'Polygon' ? coords.length - 2 : coords.length - 1 - const rubberBandPos = coords[rubberBandIndex] - const lngLat = { lng: rubberBandPos[0], lat: rubberBandPos[1] } - const point = this.map.project(lngLat) - ParentMode.onMouseMove.call(this, state, { - lngLat, - point, - originalEvent: new MouseEvent('mousemove', { clientX: point.x, clientY: point.y }) - }) - this._ctx.store.render() - } - this.dispatchVertexChange(coords) - }, - - /** - * Handle draw.undo event - */ - onUndo (state, e) { - if (e.operation?.type === 'draw_vertex') { - this.undoVertex(state) - } - }, - - _simulateMouse (type, fn, state) { - const { map } = this - const center = map.getCenter() - const point = map.project(center) - fn.call(this, state, { - lngLat: center, - point, - originalEvent: new MouseEvent(type, { - clientX: point.x, - clientY: point.y, - bubbles: true, - cancelable: true - }) - }) - this._ctx.store.render() - - this.map.fire('draw.geometrychange', state.polygon || state.line) - }, - - _showCrossHair (state) { - if (state.crossHair) { state.crossHair.show() } else { state.vertexMarker.style.display = 'block' } - }, - - _hideCrossHair (state) { - if (state.crossHair) { state.crossHair.hide() } else { state.vertexMarker.style.display = 'none' } - }, - - _setInterface (state, type, show = true) { - state.interfaceType = type - if (show) { - this._showCrossHair(state) - } - }, - - onCreate (state, e) { - const draw = this._ctx.api - const feature = e.features[0] - draw.delete(feature.id) - feature.id = state.featureId - draw.add(feature, { userProperties: true }) - }, - - onVertexButtonClick (state, e) { - // Only trigger for the specific add vertex button, and skip during undo - if (state.addVertexButtonId && !this.map._undoInProgress && e.target.closest(`#${state.addVertexButtonId}`)) { - this.doClick(state) - } - }, - - onTouchStart (state, e) { - this._setInterface(state, 'touch') - this.onMove(state, e) - }, - - onTouchEnd (state, e) { - this._setInterface(state, 'touch') - this.onMove(state, e) - }, - - _handleUndoKeydown (state, e) { - const tag = document.activeElement?.tagName - if (tag === 'INPUT' || tag === 'TEXTAREA') { - return - } - e.preventDefault() - e.stopPropagation() - const undoStack = this.map._undoStack - if (undoStack && undoStack.length > 0) { - const operation = undoStack.pop() - if (operation?.type === 'draw_vertex') { - // Set flag to prevent click interference during undo - this.map._undoInProgress = true - setTimeout(() => { this.map._undoInProgress = false }, 100) - this.undoVertex(state) - } - } - }, - - onKeydown (state, e) { - if (e.key === 'z' && (e.metaKey || e.ctrlKey) && !e.shiftKey) { - this._handleUndoKeydown(state, e) - return - } - if (document.activeElement !== state.container) { - return - } - if (e.key === 'Escape') { - e.preventDefault() - return - } - if (e.key === 'Enter') { - state.isActive = true - } - if (!INTERFACE_KEYS.has(e.key)) { - return - } - this._setInterface(state, 'keyboard') - this.onMove(state, e) - }, - - onKeyup (state, e) { - if (e.key === 'Escape') { - if (state.interfaceType !== 'keyboard') { - // Mouse/touch: cancel drawing — onKeyUp (capital U) won't fire since container isn't focused - this.map.fire('draw.cancel') - } - // Keyboard: onKeyUp (capital U) handles reinitialize (container is focused, event reaches it) - return - } - if (document.activeElement !== state.container) { - return - } - if (!INTERFACE_KEYS.has(e.key)) { - return - } - this._setInterface(state, 'keyboard') - this.onMove(state, e) - if (e.key === 'Enter' && state.isActive) { - this.doClick(state) - } - }, - - // Called by mapbox-gl-draw's event system (capital U — distinct from onKeyup above). - // Registered on ctx.container, so only fires when the viewport has focus (keyboard drawing). - // 1. A UI element inside the viewport has focus (e.g. popup menu) → ignore, let React handle - // 2. Keyboard drawing (container focused, interfaceType === 'keyboard') → Escape restarts - // 3. Non-keyboard with container focused → skip (already handled by window onKeyup via draw.cancel) - onKeyUp (state, e) { - const activeEl = document.activeElement - if (activeEl && activeEl !== state.container && state.container.contains(activeEl)) { - return - } - if (e.key === 'Escape') { - if (state.interfaceType === 'keyboard') { - const undoStack = this.map._undoStack - if (undoStack) { - undoStack.clear() - } - this._reinitializeFeature(state, getFeature(state)) - } - // Non-keyboard already handled by onKeyup (window) via draw.cancel - return - } - if (activeEl !== state.container) { - ParentMode.onKeyUp.call(this, state, e) - } - }, - - onBlur (state, e) { - if (e.target !== state.container) { - this._hideCrossHair(state) - } - }, - - onMouseMove (state, e) { - if (isSnapEnabled(state)) { - const snap = getSnapInstance(this.map) - triggerSnapAtPoint(snap, this.map, e.point) - - const snappedLngLat = getSnapLngLat(snap) - if (snappedLngLat) { - e = { ...e, lngLat: snappedLngLat } - } - } - - this.map.fire('draw.geometrychange', state.polygon || state.line) - - ParentMode.onMouseMove.call(this, state, e) - }, - - onMove (state) { - if (['touch', 'keyboard'].includes(state.interfaceType)) { - if (isSnapEnabled(state)) { - triggerSnapAtCenter(getSnapInstance(this.map), this.map) - } - - const snap = getSnapInstance(this.map) - const snappedLngLat = isSnapEnabled(state) && getSnapLngLat(snap) - - if (snappedLngLat) { - const point = this.map.project([snappedLngLat.lng, snappedLngLat.lat]) - ParentMode.onMouseMove.call(this, state, { - lngLat: snappedLngLat, - point, - originalEvent: new MouseEvent('mousemove', { - clientX: point.x, - clientY: point.y, - bubbles: true, - cancelable: true - }) - }) - this._ctx.store.render() - } else { - this._simulateMouse('mousemove', ParentMode.onMouseMove, state) - } - } - }, - - onPointerdown (state, e) { - if (e.pointerType !== 'touch') { - this._setInterface(state, 'mouse', false) - } - }, - - onPointermove (state, e) { - if (e.pointerType !== 'touch') { - this._hideCrossHair(state) - } - }, - - onPointerup (state) { - this.dispatchVertexChange(getCoords(getFeature(state))) - }, - - toDisplayFeatures (state, geojson, display) { - ParentMode.toDisplayFeatures.call(this, state, geojson, display) - - // Display features carry the id in properties.id (no top-level id) - const feature = getFeature(state) - if (geojson.geometry.type === geometryType && geojson.properties.id === feature.id) { - // Parent modes render only some placed vertices (which ones varies by - // mapbox-gl-draw version) — add a marker on every placed vertex. The - // 'draw-vertex' meta is display-only: it's not in mapbox-gl-draw's - // META_TYPES, so featuresAt ignores these markers and the parent's own - // vertex click targets (click first/last to finish) keep working. - getPlacedCoords(geojson).forEach((coordinates) => display({ - type: 'Feature', - properties: { meta: 'draw-vertex', parent: feature.id, active: 'false' }, - geometry: { type: 'Point', coordinates } - })) - } - }, - - onStop (state) { - ParentMode.onStop.call(this, state) - this._listeners.forEach(([t, e, h]) => t.removeEventListener ? t.removeEventListener(e, h) : t.off(e, h)) - this._hideCrossHair(state) - // Sync the final interfaceType from draw mode back to app state so crosshair - // visibility is correct when exiting draw mode (e.g., if user switched from mouse to keyboard) - this.map.fire('draw.interfacetypechange', { interfaceType: state.interfaceType }) - } + ...createLifecycle(deps), + ...createClickHandlers(deps), + ...createUndoHandlers(deps), + ...createKeyboardHandlers(deps), + ...createPointerHandlers(deps), + ...createRenderHelpers(deps) } } diff --git a/plugins/beta/draw/src/adapters/maplibre/modes/createDrawMode.test.js b/plugins/beta/draw/src/adapters/maplibre/modes/createDrawMode.test.js index 01471f5f5..319630322 100644 --- a/plugins/beta/draw/src/adapters/maplibre/modes/createDrawMode.test.js +++ b/plugins/beta/draw/src/adapters/maplibre/modes/createDrawMode.test.js @@ -1,664 +1,53 @@ -import { DrawPolygonMode } from './drawPolygonMode.js' -import { DrawLineMode } from './drawLineMode.js' -import { createUndoStack } from '../../../utils/undoStack.js' -import PolygonFeature from '../../../../../../../node_modules/@mapbox/mapbox-gl-draw/src/feature_types/polygon.js' -import LineStringFeature from '../../../../../../../node_modules/@mapbox/mapbox-gl-draw/src/feature_types/line_string.js' +import { createDrawMode } from './createDrawMode.js' /** - * Behaviour tests for the shared draw mode factory, exercised through the real - * DrawPolygonMode / DrawLineMode objects with real mapbox-gl-draw parent modes - * and feature classes. Only the map, store and DOM are test doubles. + * Tests for the factory's own job: composing ParentMode with every handler group into a + * single mode object. The behaviour of each handler group is covered in its colocated + * test file under drawMode/ (lifecycle, click, keyboard, undo, pointer, render). */ -const CENTER = { lng: 5, lat: 5 } - -const createMap = () => { - const canvas = document.createElement('canvas') - const listeners = {} - return { - _undoInProgress: false, - _undoStack: null, - _snapInstance: null, - doubleClickZoom: { disable: jest.fn(), enable: jest.fn() }, - getCanvas: () => canvas, - getCenter: () => ({ ...CENTER }), - project: jest.fn(() => ({ x: 50, y: 50 })), - unproject: jest.fn(() => ({ ...CENTER })), - fire: jest.fn(function (type, e) { (listeners[type] ?? []).forEach((h) => h(e)) }), - on: jest.fn((type, h) => { (listeners[type] ??= []).push(h) }), - off: jest.fn((type, h) => { listeners[type] = (listeners[type] ?? []).filter((x) => x !== h) }) - } -} - -// The `this` context mapbox-gl-draw gives a mode: map, ctx accessors and the mode's own methods -const createModeContext = (mode) => { - const map = createMap() - const features = new Map() - const store = { - add: jest.fn((f) => features.set(f.id, f)), - delete: jest.fn((ids) => ids.forEach((id) => features.delete(id))), - render: jest.fn(), - featureChanged: jest.fn(), - getInitialConfigValue: jest.fn(() => true) - } - const ctx = { - map, - _ctx: { store, api: { changeMode: jest.fn(), add: jest.fn(), delete: jest.fn() }, options: {} }, - addFeature: (f) => store.add(f), - getFeature: (id) => features.get(id), - deleteFeature: jest.fn((ids) => ids.forEach((id) => features.delete(id))), - clearSelectedFeatures: jest.fn(), - updateUIClasses: jest.fn(), - activateUIButton: jest.fn(), - setActionableState: jest.fn(), - changeMode: jest.fn() - } - ctx.newFeature = (geojson) => geojson.geometry.type === 'Polygon' - ? new PolygonFeature(ctx._ctx, geojson) - : new LineStringFeature(ctx._ctx, geojson) - Object.assign(ctx, mode) - contexts.push(ctx) - return ctx -} - -// Remove window/container/map listeners registered by onSetup so tests don't leak into each other -const contexts = [] -const removeListeners = (ctx) => ctx._listeners?.forEach(([t, e, h]) => - t.removeEventListener ? t.removeEventListener(e, h) : t.off(e, h)) - -const createContainer = () => { - const container = document.createElement('div') - container.tabIndex = 0 - const marker = document.createElement('div') - marker.id = 'vertex-marker' - container.appendChild(marker) - const button = document.createElement('button') - button.id = 'add-vertex' - container.appendChild(button) - document.body.appendChild(container) - return { container, marker, button } +const POLYGON_CONFIG = { + featureProp: 'polygon', + geometryType: 'Polygon', + getCoords: (f) => f.coordinates[0], + validateClick: () => true, + getPlacedCoords: () => [] } -const setup = (mode, options = {}) => { - const ctx = createModeContext(mode) - const dom = createContainer() - ctx.map._undoStack = createUndoStack(() => {}) - const state = ctx.onSetup({ - container: dom.container, - vertexMarkerId: 'vertex-marker', - addVertexButtonId: 'add-vertex', - interfaceType: 'mouse', - featureId: 'shape-1', - properties: { label: 'field' }, - ...options - }) - return { ctx, state, ...dom } -} - -const clickEvent = (ctx, lng, lat, overrides = {}) => ({ - lngLat: { lng, lat }, - point: { x: lng, y: lat }, - originalEvent: { button: 0, target: ctx.map.getCanvas(), ...overrides } -}) - -const clickAt = (ctx, state, lng, lat) => ctx.onClick(state, clickEvent(ctx, lng, lat)) - -const firedWith = (map, type) => map.fire.mock.calls.filter(([t]) => t === type).map(([, e]) => e) - -const activeSnap = () => ({ status: true, snapStatus: true, snapCoords: [9, 9], snapToClosestPoint: jest.fn() }) - -afterEach(() => { - contexts.splice(0).forEach(removeListeners) - document.body.innerHTML = '' - jest.useRealTimers() -}) - -describe('setup and crosshair', () => { - test('mouse interface hides the vertex marker, keyboard shows it', () => { - const mouse = setup(DrawPolygonMode) - expect(mouse.marker.style.display).toBe('none') - - const keyboard = setup(DrawPolygonMode, { interfaceType: 'keyboard' }) - expect(keyboard.marker.style.display).toBe('block') - }) - - test('getInterfaceType() takes precedence over the static interfaceType option', () => { - const { state, marker } = setup(DrawPolygonMode, { interfaceType: 'mouse', getInterfaceType: () => 'touch' }) - expect(state.interfaceType).toBe('touch') - expect(marker.style.display).toBe('block') - }) - - test('a crossHair object is used instead of the vertex marker when provided', () => { - const crossHair = { show: jest.fn(), hide: jest.fn() } - const { ctx, state } = setup(DrawPolygonMode, { interfaceType: 'keyboard', crossHair }) - expect(crossHair.show).toHaveBeenCalled() - ctx.pointermoveHandler({ pointerType: 'mouse' }) - expect(crossHair.hide).toHaveBeenCalled() - ctx.onBlur(state, { target: document.body }) - expect(crossHair.hide).toHaveBeenCalledTimes(2) - }) - - test('line mode creates a fresh feature even when featureId is passed', () => { - const { state } = setup(DrawLineMode) - expect(state.line).toBeDefined() - expect(state.featureId).toBe('shape-1') - }) -}) - -describe('mouse clicks (polygon)', () => { - test('each click at a new position places a vertex, fires vertexchange and pushes an undo op', () => { - const { ctx, state } = setup(DrawPolygonMode) - clickAt(ctx, state, 0, 0) - clickAt(ctx, state, 10, 0) - - // Ring is [v0, v1, rubber_band] - expect(state.polygon.coordinates[0]).toHaveLength(3) - expect(state.polygon.coordinates[0][0]).toEqual([0, 0]) - expect(state.polygon.coordinates[0][1]).toEqual([10, 0]) - expect(firedWith(ctx.map, 'draw.vertexchange').pop()).toEqual({ numVertecies: 2 }) - expect(ctx.map._undoStack.length).toBe(2) - }) - - test('clicking the same position again is rejected', () => { - const { ctx, state } = setup(DrawPolygonMode) - clickAt(ctx, state, 0, 0) - clickAt(ctx, state, 0, 0) - expect(state.polygon.coordinates[0]).toHaveLength(2) - expect(ctx.map._undoStack.length).toBe(1) - }) - - test('non-primary buttons, undo-in-progress and off-canvas clicks are ignored', () => { - const { ctx, state } = setup(DrawPolygonMode) - ctx.onClick(state, clickEvent(ctx, 0, 0, { button: 2 })) - ctx.map._undoInProgress = true - clickAt(ctx, state, 0, 0) - ctx.map._undoInProgress = false - ctx.onClick(state, clickEvent(ctx, 0, 0, { target: document.body })) - expect(state.polygon.coordinates[0]).toHaveLength(0) - }) - - test('with snap active the vertex is placed at the snapped position', () => { - const { ctx, state } = setup(DrawPolygonMode, { getSnapEnabled: () => true }) - ctx.map._snapInstance = activeSnap() - clickAt(ctx, state, 0, 0) - expect(state.polygon.coordinates[0][0]).toEqual([9, 9]) - }) - - test('onTap is disabled', () => { - const { ctx, state } = setup(DrawPolygonMode) - expect(ctx.onTap(state, clickEvent(ctx, 0, 0))).toBeUndefined() - expect(state.polygon.coordinates[0]).toHaveLength(0) - }) -}) - -describe('add-vertex button and doClick', () => { - test('clicking the add-vertex button places a vertex at map center', () => { - const { ctx, state, button } = setup(DrawPolygonMode, { interfaceType: 'keyboard' }) - ctx.vertexButtonClickHandler({ target: button }) - expect(state.polygon.coordinates[0][0]).toEqual([CENTER.lng, CENTER.lat]) - expect(ctx.map._undoStack.length).toBe(1) - }) - - test('clicks elsewhere, without a button id, or during undo do nothing', () => { - const { ctx, state } = setup(DrawPolygonMode) - ctx.vertexButtonClickHandler({ target: document.body }) - ctx.map._undoInProgress = true - ctx.doClick(state) - ctx.map._undoInProgress = false - const noButton = setup(DrawPolygonMode, { addVertexButtonId: null }) - noButton.ctx.vertexButtonClickHandler({ target: document.body }) - expect(state.polygon.coordinates[0]).toHaveLength(0) - expect(noButton.state.polygon.coordinates[0]).toHaveLength(0) - }) - - test('doClick places at the snapped position when snapping', () => { - const { ctx, state } = setup(DrawPolygonMode, { getSnapEnabled: () => true }) - ctx.map._snapInstance = activeSnap() - ctx.doClick(state) - expect(state.polygon.coordinates[0][0]).toEqual([9, 9]) - expect(ctx._ctx.store.render).toHaveBeenCalled() - }) - - test('line: activating add-vertex at the same spot twice finishes the line', () => { - const { ctx, state } = setup(DrawLineMode) - ctx.doClick(state) - ctx.doClick(state) - expect(firedWith(ctx.map, 'draw.create')).toHaveLength(1) - expect(ctx.changeMode).toHaveBeenCalledWith('simple_select', { featureIds: [state.line.id] }) - }) - - test('polygon: a repeated add-vertex at the same spot is rejected without finishing', () => { - const { ctx, state } = setup(DrawPolygonMode) - ctx.doClick(state) - ctx.doClick(state) - expect(firedWith(ctx.map, 'draw.create')).toHaveLength(0) - expect(ctx.changeMode).not.toHaveBeenCalled() - }) -}) - -describe('keyboard interface', () => { - test('arrow keys switch to keyboard interface, show the crosshair and move the rubber band to center', () => { - const { ctx, state, marker, container } = setup(DrawPolygonMode) - clickAt(ctx, state, 0, 0) - container.focus() - window.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight' })) - expect(state.interfaceType).toBe('keyboard') - expect(marker.style.display).toBe('block') - expect(state.polygon.coordinates[0].at(-1)).toEqual([CENTER.lng, CENTER.lat]) - expect(firedWith(ctx.map, 'draw.geometrychange').length).toBeGreaterThan(0) - }) - - test('other keys, unfocused container and Escape do not switch interface', () => { - const { ctx, state, marker, container } = setup(DrawPolygonMode) - ctx.keydownHandler(new KeyboardEvent('keydown', { key: 'ArrowRight' })) // container not focused - container.focus() - ctx.keydownHandler(new KeyboardEvent('keydown', { key: 'a' })) - ctx.keydownHandler(new KeyboardEvent('keydown', { key: 'Escape' })) - ctx.keyupHandler(new KeyboardEvent('keyup', { key: 'a' })) - expect(state.interfaceType).toBe('mouse') - expect(marker.style.display).toBe('none') - }) - - test('keyup without focus is ignored', () => { - const { ctx, state } = setup(DrawPolygonMode) - ctx.keyupHandler(new KeyboardEvent('keyup', { key: 'ArrowRight' })) - expect(state.interfaceType).toBe('mouse') - }) - - test('Enter down then up places a vertex at center', () => { - const { ctx, state, container } = setup(DrawPolygonMode) - container.focus() - ctx.keydownHandler(new KeyboardEvent('keydown', { key: 'Enter' })) - ctx.keyupHandler(new KeyboardEvent('keyup', { key: 'Enter' })) - expect(state.isActive).toBe(true) - expect(state.polygon.coordinates[0][0]).toEqual([CENTER.lng, CENTER.lat]) - }) - - test('Escape keyup cancels drawing for mouse/touch but not keyboard', () => { - const { ctx, state } = setup(DrawPolygonMode) - ctx.keyupHandler(new KeyboardEvent('keyup', { key: 'Escape' })) - expect(firedWith(ctx.map, 'draw.cancel')).toHaveLength(1) - - state.interfaceType = 'keyboard' - ctx.keyupHandler(new KeyboardEvent('keyup', { key: 'Escape' })) - expect(firedWith(ctx.map, 'draw.cancel')).toHaveLength(1) - }) -}) - -describe('cmd/ctrl+z undo', () => { - const undoKey = (overrides = {}) => { - const e = new KeyboardEvent('keydown', { key: 'z', metaKey: true, ...overrides }) - e.preventDefault = jest.fn() - e.stopPropagation = jest.fn() - return e - } - - test('removes the last placed vertex without switching to keyboard interface', () => { - jest.useFakeTimers() - const { ctx, state, marker } = setup(DrawPolygonMode) - clickAt(ctx, state, 0, 0) - clickAt(ctx, state, 10, 0) - clickAt(ctx, state, 10, 10) - - ctx.keydownHandler(undoKey()) - expect(state.polygon.coordinates[0].map(([x]) => x)).toEqual([0, 10, 10]) - expect(state.polygon.coordinates[0]).toHaveLength(3) - expect(state.interfaceType).toBe('mouse') - expect(marker.style.display).toBe('none') - expect(ctx.map._undoInProgress).toBe(true) - jest.advanceTimersByTime(100) - expect(ctx.map._undoInProgress).toBe(false) - }) - - test('mouse interface keeps the rubber band on the remaining last vertex', () => { - const { ctx, state } = setup(DrawPolygonMode) - clickAt(ctx, state, 0, 0) - clickAt(ctx, state, 10, 0) - clickAt(ctx, state, 10, 10) - ctx.keydownHandler(undoKey()) - const ring = state.polygon.coordinates[0] - expect(ring[ring.length - 1]).toEqual(ring[ring.length - 2]) - }) - - test('is ignored when typing in an input, when the stack is empty, or for other operations', () => { - const { ctx, state } = setup(DrawPolygonMode) - clickAt(ctx, state, 0, 0) - clickAt(ctx, state, 10, 0) - - const input = document.createElement('input') - document.body.appendChild(input) - input.focus() - ctx.keydownHandler(undoKey()) - expect(state.polygon.coordinates[0]).toHaveLength(3) - input.blur() - - ctx.map._undoStack.clear() - ctx.keydownHandler(undoKey()) - ctx.map._undoStack.push({ type: 'edit_vertex' }) - ctx.keydownHandler(undoKey()) - expect(state.polygon.coordinates[0]).toHaveLength(3) - }) - - test('cmd+shift+z is not treated as undo', () => { - const { ctx, state } = setup(DrawPolygonMode) - clickAt(ctx, state, 0, 0) - clickAt(ctx, state, 10, 0) - ctx.keydownHandler(undoKey({ shiftKey: true })) - expect(state.polygon.coordinates[0]).toHaveLength(3) - }) - - test('ctrl+z (Windows/Linux) also undoes the last vertex', () => { - const { ctx, state } = setup(DrawPolygonMode) - clickAt(ctx, state, 0, 0) - clickAt(ctx, state, 10, 0) - clickAt(ctx, state, 10, 10) - ctx.keydownHandler(undoKey({ metaKey: false, ctrlKey: true })) - expect(state.polygon.coordinates[0]).toHaveLength(3) - }) -}) - -describe('undo via draw.undo event and reinitialisation', () => { - test('draw.undo with a draw_vertex operation removes the last vertex; other types are ignored', () => { - const { ctx, state } = setup(DrawPolygonMode) - clickAt(ctx, state, 0, 0) - clickAt(ctx, state, 10, 0) - clickAt(ctx, state, 10, 10) - ctx.map.fire('draw.undo', { operation: { type: 'edit_vertex' } }) - expect(state.polygon.coordinates[0]).toHaveLength(4) - ctx.map.fire('draw.undo', { operation: { type: 'draw_vertex' } }) - expect(state.polygon.coordinates[0]).toHaveLength(3) - }) - - test('keyboard interface moves the rubber band to center after undo', () => { - const { ctx, state } = setup(DrawPolygonMode, { interfaceType: 'keyboard' }) - clickAt(ctx, state, 0, 0) - clickAt(ctx, state, 10, 0) - clickAt(ctx, state, 10, 10) - ctx.undoVertex(state) - expect(state.polygon.coordinates[0].at(-1)).toEqual([CENTER.lng, CENTER.lat]) - }) - - test('undoVertex returns false with nothing placed', () => { - const { ctx, state } = setup(DrawPolygonMode) - expect(ctx.undoVertex(state)).toBe(false) - }) - - test('undoing the only polygon vertex reinitialises the feature in place with the same id', () => { - const { ctx, state } = setup(DrawPolygonMode) - clickAt(ctx, state, 0, 0) - const id = state.polygon.id - expect(ctx.undoVertex(state)).toBe(true) - expect(ctx._ctx.store.delete).toHaveBeenCalledWith([id]) - expect(state.polygon.id).toBe(id) - expect(state.currentVertexPosition).toBe(0) - expect(firedWith(ctx.map, 'draw.vertexchange').pop()).toEqual({ numVertecies: 1 }) - }) - - test('undoing the only line vertex restarts draw_line with the same feature id', () => { - const { ctx, state } = setup(DrawLineMode) - ctx.doClick(state) - ctx.map._undoStack.push({ type: 'draw_vertex' }) - expect(ctx.undoVertex(state)).toBe(true) - expect(ctx.map._undoStack.length).toBe(0) - expect(ctx._ctx.api.changeMode).toHaveBeenCalledWith('draw_line', expect.objectContaining({ - featureId: state.line.id, - container: state.container, - properties: state.properties - })) - }) - - test('Escape (container keyUp) during keyboard drawing clears the undo stack and reinitialises', () => { - const { ctx, state, container } = setup(DrawPolygonMode, { interfaceType: 'keyboard' }) - clickAt(ctx, state, 0, 0) - container.focus() - const id = state.polygon.id - ctx.onKeyUp(state, { key: 'Escape' }) - expect(ctx.map._undoStack.length).toBe(0) - expect(state.polygon.id).toBe(id) - }) - - test('container keyUp defers to the parent mode when focus is elsewhere, and ignores focus inside the container', () => { - const { ctx, state, button, container } = setup(DrawPolygonMode) - ctx.onKeyUp(state, { key: 'Enter' }) // focus not on container → parent finishes on Enter - expect(ctx.changeMode).toHaveBeenCalledWith('simple_select', expect.anything()) - - button.focus() - ctx.changeMode.mockClear() - ctx.onKeyUp(state, { key: 'Enter' }) // focus on a child element → ignored - expect(ctx.changeMode).not.toHaveBeenCalled() - - container.focus() - ctx.onKeyUp(state, { key: 'Escape' }) // non-keyboard Escape → handled by window keyup instead - expect(ctx._ctx.store.delete).not.toHaveBeenCalled() - }) -}) - -describe('touch and pointer interface', () => { - test('touch start/end switch to touch interface and show the crosshair', () => { - const { ctx, state, marker } = setup(DrawPolygonMode) - ctx.onTouchStart(state, {}) - expect(state.interfaceType).toBe('touch') - expect(marker.style.display).toBe('block') - ctx.onTouchEnd(state, {}) - expect(state.interfaceType).toBe('touch') - }) - - test('non-touch pointerdown switches back to mouse without showing the crosshair; touch does not', () => { - const { ctx, state, marker } = setup(DrawPolygonMode, { interfaceType: 'keyboard' }) - ctx.map.fire('pointerdown', { pointerType: 'mouse' }) - expect(state.interfaceType).toBe('mouse') - expect(marker.style.display).toBe('block') // unchanged, not re-shown or hidden - - ctx.map.fire('pointerdown', { pointerType: 'touch' }) - expect(state.interfaceType).toBe('mouse') - }) - - test('mouse pointermove hides the crosshair; touch pointermove does not', () => { - const { ctx, marker } = setup(DrawPolygonMode, { interfaceType: 'keyboard' }) - ctx.pointermoveHandler({ pointerType: 'touch' }) - expect(marker.style.display).toBe('block') - ctx.pointermoveHandler({ pointerType: 'mouse' }) - expect(marker.style.display).toBe('none') - }) - - test('pointerup reports the current vertex count', () => { - const { ctx, state } = setup(DrawPolygonMode) - clickAt(ctx, state, 0, 0) - ctx.map.fire.mockClear() - ctx.pointerupHandler({}) - expect(firedWith(ctx.map, 'draw.vertexchange')).toEqual([{ numVertecies: 1 }]) - }) - - test('blur away from the container hides the crosshair; blur on the container does not', () => { - const { ctx, marker, container } = setup(DrawPolygonMode, { interfaceType: 'keyboard' }) - ctx.blurHandler({ target: container }) - expect(marker.style.display).toBe('block') - ctx.blurHandler({ target: document.body }) - expect(marker.style.display).toBe('none') - }) -}) - -describe('rubber band and snapping while moving', () => { - test('onMouseMove moves the rubber band, preferring the snapped position', () => { - const { ctx, state } = setup(DrawPolygonMode, { getSnapEnabled: () => true }) - clickAt(ctx, state, 0, 0) - ctx.map._snapInstance = activeSnap() - ctx.onMouseMove(state, { lngLat: { lng: 3, lat: 3 }, point: { x: 3, y: 3 } }) - expect(ctx.map._snapInstance.snapToClosestPoint).toHaveBeenCalled() - expect(state.polygon.coordinates[0].at(-1)).toEqual([9, 9]) - expect(firedWith(ctx.map, 'draw.geometrychange').length).toBeGreaterThan(0) - }) - - test('onMouseMove without snap uses the pointer position', () => { - const { ctx, state } = setup(DrawPolygonMode) - clickAt(ctx, state, 0, 0) - ctx.onMouseMove(state, { lngLat: { lng: 3, lat: 3 }, point: { x: 3, y: 3 } }) - expect(state.polygon.coordinates[0].at(-1)).toEqual([3, 3]) - }) - - test('map move in keyboard interface keeps the rubber band at center, or at the snap point when snapping', () => { - const { ctx, state } = setup(DrawPolygonMode, { interfaceType: 'keyboard' }) - clickAt(ctx, state, 0, 0) - ctx.map.fire('move') - expect(state.polygon.coordinates[0].at(-1)).toEqual([CENTER.lng, CENTER.lat]) - - state.getSnapEnabled = () => true - ctx.map._snapInstance = activeSnap() - ctx.map.fire('move') - expect(ctx.map._snapInstance.snapToClosestPoint).toHaveBeenCalled() - expect(state.polygon.coordinates[0].at(-1)).toEqual([9, 9]) - }) - - test('map move in mouse interface leaves the rubber band alone', () => { - const { ctx, state } = setup(DrawPolygonMode) - clickAt(ctx, state, 0, 0) - const before = [...state.polygon.coordinates[0].map((c) => [...c])] - ctx.map.fire('move') - expect(state.polygon.coordinates[0]).toEqual(before) - }) -}) - -describe('vertex marker display', () => { - const displayed = (ctx, state, geometry, id) => { - const display = jest.fn() - ctx.toDisplayFeatures(state, { type: 'Feature', properties: { id }, geometry }, display) - return display.mock.calls.map(([f]) => f).filter((f) => f.properties.meta === 'draw-vertex') - } - - test('every placed polygon vertex gets a display-only draw-vertex marker', () => { - const { ctx, state } = setup(DrawPolygonMode) - clickAt(ctx, state, 0, 0) - clickAt(ctx, state, 10, 0) - clickAt(ctx, state, 10, 10) - const ring = [[0, 0], [10, 0], [10, 10], [5, 5], [0, 0]] // placed + rubber + closing - const markers = displayed(ctx, state, { type: 'Polygon', coordinates: [ring] }, state.polygon.id) - expect(markers.map((m) => m.geometry.coordinates)).toEqual([[0, 0], [10, 0], [10, 10]]) - expect(markers[0].properties).toEqual({ meta: 'draw-vertex', parent: state.polygon.id, active: 'false' }) - }) - - test('every placed line vertex gets a marker', () => { - const { ctx, state } = setup(DrawLineMode) - ctx.doClick(state) - const coords = [[5, 5], [8, 8]] // placed + rubber - const markers = displayed(ctx, state, { type: 'LineString', coordinates: coords }, state.line.id) - expect(markers.map((m) => m.geometry.coordinates)).toEqual([[5, 5]]) - }) - - test('other features get no markers', () => { - const { ctx, state } = setup(DrawPolygonMode) - const ring = [[0, 0], [10, 0], [10, 10], [0, 0]] - expect(displayed(ctx, state, { type: 'Polygon', coordinates: [ring] }, 'other-feature')).toEqual([]) - }) -}) - -describe('create and stop', () => { - test('draw.create re-ids the created feature to the requested featureId', () => { - const { ctx } = setup(DrawPolygonMode) - const feature = { id: 'temp-id' } - ctx.map.fire('draw.create', { features: [feature] }) - expect(ctx._ctx.api.delete).toHaveBeenCalledWith('temp-id') - expect(feature.id).toBe('shape-1') - expect(ctx._ctx.api.add).toHaveBeenCalledWith(feature, { userProperties: true }) - }) - - test('onStop removes listeners, hides the crosshair and reports the final interface type', () => { - const { ctx, state, marker, container } = setup(DrawPolygonMode, { interfaceType: 'keyboard' }) - ctx.onStop(state) - expect(marker.style.display).toBe('none') - expect(firedWith(ctx.map, 'draw.interfacetypechange')).toEqual([{ interfaceType: 'keyboard' }]) - - // Window/container/map listeners are gone: keydown no longer shows the crosshair - container.focus() - window.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight' })) - expect(marker.style.display).toBe('none') - expect(ctx.map.off).toHaveBeenCalledWith('draw.create', ctx.createHandler) - }) -}) - -describe('line mode mouse interactions', () => { - test('mouse clicks place line vertices; moving then undoing keeps the rubber band on the last vertex', () => { - const { ctx, state } = setup(DrawLineMode) - clickAt(ctx, state, 0, 0) - clickAt(ctx, state, 10, 0) - clickAt(ctx, state, 10, 10) - expect(state.line.coordinates.map(([x]) => x)).toEqual([0, 10, 10, 10]) // 3 placed + rubber band - - ctx.onMouseMove(state, { lngLat: { lng: 3, lat: 3 }, point: { x: 3, y: 3 } }) - expect(state.line.coordinates.at(-1)).toEqual([3, 3]) - expect(firedWith(ctx.map, 'draw.geometrychange').length).toBeGreaterThan(0) - - ctx.map._undoStack.push({ type: 'draw_vertex' }) - ctx.undoVertex(state) - const coords = state.line.coordinates - expect(coords[coords.length - 1]).toEqual(coords[coords.length - 2]) - }) - - test('a repeated mouse click at the last vertex adds no new vertex and does not finish the line', () => { - const { ctx, state } = setup(DrawLineMode) - clickAt(ctx, state, 0, 0) - clickAt(ctx, state, 10, 0) - const before = state.line.coordinates.length - clickAt(ctx, state, 10, 0) - expect(state.line.coordinates.length).toBe(before) - expect(firedWith(ctx.map, 'draw.create')).toHaveLength(0) - }) -}) - -describe('remaining snap, undo-stack and keyboard edge branches', () => { - test('onMouseMove with snapping enabled but no snap point falls back to the pointer position', () => { - const { ctx, state } = setup(DrawPolygonMode, { getSnapEnabled: () => true }) - clickAt(ctx, state, 0, 0) - ctx.map._snapInstance = { status: true, snapStatus: false, snapCoords: null, snapToClosestPoint: jest.fn() } - ctx.onMouseMove(state, { lngLat: { lng: 3, lat: 3 }, point: { x: 3, y: 3 } }) - expect(state.polygon.coordinates[0].at(-1)).toEqual([3, 3]) - }) - - test('placing a vertex with no undo stack does not throw and still places the vertex', () => { - const { ctx, state } = setup(DrawPolygonMode) - ctx.map._undoStack = null - clickAt(ctx, state, 0, 0) - expect(state.polygon.coordinates[0][0]).toEqual([0, 0]) - }) - - test('reinitialising a polygon without properties uses an empty properties object', () => { - const { ctx, state } = setup(DrawPolygonMode, { properties: undefined }) - clickAt(ctx, state, 0, 0) - expect(ctx.undoVertex(state)).toBe(true) - expect(state.polygon.properties).toEqual({}) - }) - - test('restarting a line with no undo stack still changes mode', () => { - const { ctx, state } = setup(DrawLineMode) - ctx.doClick(state) - ctx.map._undoStack = null - expect(ctx.undoVertex(state)).toBe(true) - expect(ctx._ctx.api.changeMode).toHaveBeenCalledWith('draw_line', expect.objectContaining({ - featureId: state.line.id - })) - }) - - test('keyup of a non-Enter interface key while focused switches interface without committing a vertex', () => { - const { ctx, state, container } = setup(DrawPolygonMode) - container.focus() - ctx.keyupHandler(new KeyboardEvent('keyup', { key: 'ArrowRight' })) - expect(state.interfaceType).toBe('keyboard') - expect(ctx.map._undoStack.length).toBe(0) - }) - - test('container keyUp with the container itself focused and a non-Escape key does not defer to the parent', () => { - const { ctx, state, container } = setup(DrawPolygonMode) - container.focus() - ctx.onKeyUp(state, { key: 'a' }) - expect(ctx.changeMode).not.toHaveBeenCalled() - }) - - test('Escape during keyboard drawing with no undo stack still reinitialises the feature', () => { - const { ctx, state, container } = setup(DrawPolygonMode, { interfaceType: 'keyboard' }) - clickAt(ctx, state, 0, 0) - container.focus() - const id = state.polygon.id - ctx.map._undoStack = null - ctx.onKeyUp(state, { key: 'Escape' }) - expect(ctx._ctx.store.delete).toHaveBeenCalledWith([id]) +const build = (parent = {}, config = POLYGON_CONFIG) => createDrawMode(parent, config) + +describe('createDrawMode composition', () => { + test('assembles a mode object with methods from every handler group', () => { + const mode = build() + const contributed = [ + 'onSetup', 'onStop', // lifecycle + 'onClick', 'onTap', 'doClick', 'onVertexButtonClick', 'onCreate', // click + 'pushDrawUndo', 'undoVertex', 'onUndo', // undo + 'onKeydown', 'onKeyup', 'onKeyUp', // keyboard + 'onMove', 'onMouseMove', 'onTouchStart', 'onBlur', // pointer + 'toDisplayFeatures', '_showCrossHair', '_simulateMouse' // render + ] + for (const method of contributed) { + expect(typeof mode[method]).toBe('function') + } + }) + + test('preserves non-overridden ParentMode members and overrides shared ones', () => { + const parent = { parentOnly () {}, onClick: 'parent-onclick' } + const mode = build(parent) + expect(mode.parentOnly).toBe(parent.parentOnly) // passed through + expect(typeof mode.onClick).toBe('function') // overridden by the click handlers + expect(mode.onClick).not.toBe(parent.onClick) + }) + + test('passes config through so the getFeature accessor reads the configured feature prop', () => { + const polygonMode = build() + const lineMode = build({}, { ...POLYGON_CONFIG, featureProp: 'line' }) + // dispatchVertexChange is config-agnostic, but pushDrawUndo reads getFeature(state).id + const map = { _undoStack: { push: jest.fn() }, _undoInProgress: false } + polygonMode.pushDrawUndo.call({ map }, { polygon: { id: 'p1' } }) + lineMode.pushDrawUndo.call({ map }, { line: { id: 'l1' } }) + expect(map._undoStack.push).toHaveBeenNthCalledWith(1, expect.objectContaining({ featureId: 'p1' })) + expect(map._undoStack.push).toHaveBeenNthCalledWith(2, expect.objectContaining({ featureId: 'l1' })) }) }) diff --git a/plugins/beta/draw/src/adapters/maplibre/modes/drawMode/__helpers__/harness.js b/plugins/beta/draw/src/adapters/maplibre/modes/drawMode/__helpers__/harness.js new file mode 100644 index 000000000..183101e76 --- /dev/null +++ b/plugins/beta/draw/src/adapters/maplibre/modes/drawMode/__helpers__/harness.js @@ -0,0 +1,115 @@ +import { DrawPolygonMode } from '../../drawPolygonMode.js' +import { DrawLineMode } from '../../drawLineMode.js' +import { createUndoStack } from '../../../../../utils/undoStack.js' +import PolygonFeature from '../../../../../../../../../node_modules/@mapbox/mapbox-gl-draw/src/feature_types/polygon.js' +import LineStringFeature from '../../../../../../../../../node_modules/@mapbox/mapbox-gl-draw/src/feature_types/line_string.js' + +/** + * Shared test harness for the createDrawMode handler modules. Exercises the real + * DrawPolygonMode / DrawLineMode (real mapbox-gl-draw parent modes and feature + * classes) over test doubles for the map, store and DOM. Excluded from coverage. + */ + +export { DrawPolygonMode, DrawLineMode } +export const CENTER = { lng: 5, lat: 5 } + +const createMap = () => { + const canvas = document.createElement('canvas') + const listeners = {} + return { + _undoInProgress: false, + _undoStack: null, + _snapInstance: null, + doubleClickZoom: { disable: jest.fn(), enable: jest.fn() }, + getCanvas: () => canvas, + getCenter: () => ({ ...CENTER }), + project: jest.fn(() => ({ x: 50, y: 50 })), + unproject: jest.fn(() => ({ ...CENTER })), + fire: jest.fn(function (type, e) { (listeners[type] ?? []).forEach((h) => h(e)) }), + on: jest.fn((type, h) => { (listeners[type] ??= []).push(h) }), + off: jest.fn((type, h) => { listeners[type] = (listeners[type] ?? []).filter((x) => x !== h) }) + } +} + +// The `this` context mapbox-gl-draw gives a mode: map, ctx accessors and the mode's own methods +const createModeContext = (mode) => { + const map = createMap() + const features = new Map() + const store = { + add: jest.fn((f) => features.set(f.id, f)), + delete: jest.fn((ids) => ids.forEach((id) => features.delete(id))), + render: jest.fn(), + featureChanged: jest.fn(), + getInitialConfigValue: jest.fn(() => true) + } + const ctx = { + map, + _ctx: { store, api: { changeMode: jest.fn(), add: jest.fn(), delete: jest.fn() }, options: {} }, + addFeature: (f) => store.add(f), + getFeature: (id) => features.get(id), + deleteFeature: jest.fn((ids) => ids.forEach((id) => features.delete(id))), + clearSelectedFeatures: jest.fn(), + updateUIClasses: jest.fn(), + activateUIButton: jest.fn(), + setActionableState: jest.fn(), + changeMode: jest.fn() + } + ctx.newFeature = (geojson) => geojson.geometry.type === 'Polygon' + ? new PolygonFeature(ctx._ctx, geojson) + : new LineStringFeature(ctx._ctx, geojson) + Object.assign(ctx, mode) + contexts.push(ctx) + return ctx +} + +// Remove window/container/map listeners registered by onSetup so tests don't leak into each other +const contexts = [] +const removeListeners = (ctx) => ctx._listeners?.forEach(([t, e, h]) => + t.removeEventListener ? t.removeEventListener(e, h) : t.off(e, h)) + +const createContainer = () => { + const container = document.createElement('div') + container.tabIndex = 0 + const marker = document.createElement('div') + marker.id = 'vertex-marker' + container.appendChild(marker) + const button = document.createElement('button') + button.id = 'add-vertex' + container.appendChild(button) + document.body.appendChild(container) + return { container, marker, button } +} + +export const setup = (mode, options = {}) => { + const ctx = createModeContext(mode) + const dom = createContainer() + ctx.map._undoStack = createUndoStack(() => {}) + const state = ctx.onSetup({ + container: dom.container, + vertexMarkerId: 'vertex-marker', + addVertexButtonId: 'add-vertex', + interfaceType: 'mouse', + featureId: 'shape-1', + properties: { label: 'field' }, + ...options + }) + return { ctx, state, ...dom } +} + +export const clickEvent = (ctx, lng, lat, overrides = {}) => ({ + lngLat: { lng, lat }, + point: { x: lng, y: lat }, + originalEvent: { button: 0, target: ctx.map.getCanvas(), ...overrides } +}) + +export const clickAt = (ctx, state, lng, lat) => ctx.onClick(state, clickEvent(ctx, lng, lat)) + +export const firedWith = (map, type) => map.fire.mock.calls.filter(([t]) => t === type).map(([, e]) => e) + +export const activeSnap = () => ({ status: true, snapStatus: true, snapCoords: [9, 9], snapToClosestPoint: jest.fn() }) + +afterEach(() => { + contexts.splice(0).forEach(removeListeners) + document.body.innerHTML = '' + jest.useRealTimers() +}) diff --git a/plugins/beta/draw/src/adapters/maplibre/modes/drawMode/clickHandlers.js b/plugins/beta/draw/src/adapters/maplibre/modes/drawMode/clickHandlers.js new file mode 100644 index 000000000..83457b057 --- /dev/null +++ b/plugins/beta/draw/src/adapters/maplibre/modes/drawMode/clickHandlers.js @@ -0,0 +1,101 @@ +import { + getSnapInstance, isSnapActive, isSnapEnabled, createSnappedEvent, createSnappedClickEvent +} from '../../utils/snapHelpers.js' + +/** + * Click / vertex-placement handling for the shared draw mode: mouse clicks, the + * add-vertex button, and the draw.create re-id step. Part of createDrawMode. + */ +export const createClickHandlers = ({ ParentMode, getFeature, getCoords, validateClick, finishOnInvalidClick }) => ({ + onClick (state, e) { + // Skip non-primary clicks, undo operations, or clicks outside canvas + if (e.originalEvent.button > 0 || this.map._undoInProgress || e.originalEvent.target !== this.map.getCanvas()) { + return + } + const snap = getSnapInstance(this.map) + if (isSnapEnabled(state) && isSnapActive(snap)) { + e = createSnappedEvent(e, snap) + } else { + const coords = getCoords(getFeature(state)) + if (coords.length > 0) { + coords[coords.length - 1] = [e.lngLat.lng, e.lngLat.lat] + } + // For polygon: prevent duplicate-coordinate clicks from reaching ParentMode, which + // would trigger a changeMode chain and cause a runtime error on coords.length access + if (!finishOnInvalidClick && !validateClick(getFeature(state))) { + return + } + } + const coordsBefore = getCoords(getFeature(state)).length + ParentMode.onClick.call(this, state, e) + // Push undo and update count if a vertex was added + if (getCoords(getFeature(state)).length > coordsBefore) { + this.pushDrawUndo(state) + this.dispatchVertexChange(getCoords(getFeature(state))) + } + }, + + onTap () { + + }, + + doClick (state) { + // Skip during undo operation + if (this.map._undoInProgress) { + return + } + + const feature = getFeature(state) + const coords = getCoords(feature) + this.dispatchVertexChange(coords) + + if (!validateClick(feature)) { + // For lines: clicking same spot (like double-click) should finish the line. + // isValidLineClick only returns false with 2+ coords, so coords.length is always > 1 here. + if (finishOnInvalidClick) { + coords.pop() + this.map.fire('draw.create', { features: [feature.toGeoJSON()] }) + this.changeMode('simple_select', { featureIds: [feature.id] }) + } + return + } + + const snap = getSnapInstance(this.map) + const snappedEvent = isSnapEnabled(state) && createSnappedClickEvent(this.map, snap) + + if (snappedEvent) { + ParentMode.onClick.call(this, state, snappedEvent) + this._ctx.store.render() + } else { + this._simulateMouse('click', ParentMode.onClick, state) + } + + // Push undo and update count if a vertex was added. A validated click always + // adds one vertex via the parent mode, so this runs on every successful doClick. + const newCoords = getCoords(getFeature(state)) + this.pushDrawUndo(state) + this.dispatchVertexChange(newCoords) + }, + + dispatchVertexChange (coords) { + // Both polygon ring and LineString store [v0...vN, rubber_band] during drawing — subtract 1 to get placed vertex count + this.map.fire('draw.vertexchange', { + numVertecies: Math.max(0, coords.length - 1) + }) + }, + + onVertexButtonClick (state, e) { + // Only trigger for the specific add vertex button, and skip during undo + if (state.addVertexButtonId && !this.map._undoInProgress && e.target.closest(`#${state.addVertexButtonId}`)) { + this.doClick(state) + } + }, + + onCreate (state, e) { + const draw = this._ctx.api + const feature = e.features[0] + draw.delete(feature.id) + feature.id = state.featureId + draw.add(feature, { userProperties: true }) + } +}) diff --git a/plugins/beta/draw/src/adapters/maplibre/modes/drawMode/clickHandlers.test.js b/plugins/beta/draw/src/adapters/maplibre/modes/drawMode/clickHandlers.test.js new file mode 100644 index 000000000..d9ddf1879 --- /dev/null +++ b/plugins/beta/draw/src/adapters/maplibre/modes/drawMode/clickHandlers.test.js @@ -0,0 +1,132 @@ +import { setup, clickAt, clickEvent, firedWith, activeSnap, DrawPolygonMode, DrawLineMode, CENTER } from './__helpers__/harness.js' + +describe('mouse clicks (polygon)', () => { + test('each click at a new position places a vertex, fires vertexchange and pushes an undo op', () => { + const { ctx, state } = setup(DrawPolygonMode) + clickAt(ctx, state, 0, 0) + clickAt(ctx, state, 10, 0) + + // Ring is [v0, v1, rubber_band] + expect(state.polygon.coordinates[0]).toHaveLength(3) + expect(state.polygon.coordinates[0][0]).toEqual([0, 0]) + expect(state.polygon.coordinates[0][1]).toEqual([10, 0]) + expect(firedWith(ctx.map, 'draw.vertexchange').pop()).toEqual({ numVertecies: 2 }) + expect(ctx.map._undoStack.length).toBe(2) + }) + + test('clicking the same position again is rejected', () => { + const { ctx, state } = setup(DrawPolygonMode) + clickAt(ctx, state, 0, 0) + clickAt(ctx, state, 0, 0) + expect(state.polygon.coordinates[0]).toHaveLength(2) + expect(ctx.map._undoStack.length).toBe(1) + }) + + test('non-primary buttons, undo-in-progress and off-canvas clicks are ignored', () => { + const { ctx, state } = setup(DrawPolygonMode) + ctx.onClick(state, clickEvent(ctx, 0, 0, { button: 2 })) + ctx.map._undoInProgress = true + clickAt(ctx, state, 0, 0) + ctx.map._undoInProgress = false + ctx.onClick(state, clickEvent(ctx, 0, 0, { target: document.body })) + expect(state.polygon.coordinates[0]).toHaveLength(0) + }) + + test('with snap active the vertex is placed at the snapped position', () => { + const { ctx, state } = setup(DrawPolygonMode, { getSnapEnabled: () => true }) + ctx.map._snapInstance = activeSnap() + clickAt(ctx, state, 0, 0) + expect(state.polygon.coordinates[0][0]).toEqual([9, 9]) + }) + + test('onTap is disabled', () => { + const { ctx, state } = setup(DrawPolygonMode) + expect(ctx.onTap(state, clickEvent(ctx, 0, 0))).toBeUndefined() + expect(state.polygon.coordinates[0]).toHaveLength(0) + }) +}) + +describe('add-vertex button and doClick', () => { + test('clicking the add-vertex button places a vertex at map center', () => { + const { ctx, state, button } = setup(DrawPolygonMode, { interfaceType: 'keyboard' }) + ctx.vertexButtonClickHandler({ target: button }) + expect(state.polygon.coordinates[0][0]).toEqual([CENTER.lng, CENTER.lat]) + expect(ctx.map._undoStack.length).toBe(1) + }) + + test('clicks elsewhere, without a button id, or during undo do nothing', () => { + const { ctx, state } = setup(DrawPolygonMode) + ctx.vertexButtonClickHandler({ target: document.body }) + ctx.map._undoInProgress = true + ctx.doClick(state) + ctx.map._undoInProgress = false + const noButton = setup(DrawPolygonMode, { addVertexButtonId: null }) + noButton.ctx.vertexButtonClickHandler({ target: document.body }) + expect(state.polygon.coordinates[0]).toHaveLength(0) + expect(noButton.state.polygon.coordinates[0]).toHaveLength(0) + }) + + test('doClick places at the snapped position when snapping', () => { + const { ctx, state } = setup(DrawPolygonMode, { getSnapEnabled: () => true }) + ctx.map._snapInstance = activeSnap() + ctx.doClick(state) + expect(state.polygon.coordinates[0][0]).toEqual([9, 9]) + expect(ctx._ctx.store.render).toHaveBeenCalled() + }) + + test('line: activating add-vertex at the same spot twice finishes the line', () => { + const { ctx, state } = setup(DrawLineMode) + ctx.doClick(state) + ctx.doClick(state) + expect(firedWith(ctx.map, 'draw.create')).toHaveLength(1) + expect(ctx.changeMode).toHaveBeenCalledWith('simple_select', { featureIds: [state.line.id] }) + }) + + test('polygon: a repeated add-vertex at the same spot is rejected without finishing', () => { + const { ctx, state } = setup(DrawPolygonMode) + ctx.doClick(state) + ctx.doClick(state) + expect(firedWith(ctx.map, 'draw.create')).toHaveLength(0) + expect(ctx.changeMode).not.toHaveBeenCalled() + }) +}) + +describe('line mode mouse interactions', () => { + test('mouse clicks place line vertices; moving then undoing keeps the rubber band on the last vertex', () => { + const { ctx, state } = setup(DrawLineMode) + clickAt(ctx, state, 0, 0) + clickAt(ctx, state, 10, 0) + clickAt(ctx, state, 10, 10) + expect(state.line.coordinates.map(([x]) => x)).toEqual([0, 10, 10, 10]) // 3 placed + rubber band + + ctx.onMouseMove(state, { lngLat: { lng: 3, lat: 3 }, point: { x: 3, y: 3 } }) + expect(state.line.coordinates.at(-1)).toEqual([3, 3]) + expect(firedWith(ctx.map, 'draw.geometrychange').length).toBeGreaterThan(0) + + ctx.map._undoStack.push({ type: 'draw_vertex' }) + ctx.undoVertex(state) + const coords = state.line.coordinates + expect(coords[coords.length - 1]).toEqual(coords[coords.length - 2]) + }) + + test('a repeated mouse click at the last vertex adds no new vertex and does not finish the line', () => { + const { ctx, state } = setup(DrawLineMode) + clickAt(ctx, state, 0, 0) + clickAt(ctx, state, 10, 0) + const before = state.line.coordinates.length + clickAt(ctx, state, 10, 0) + expect(state.line.coordinates.length).toBe(before) + expect(firedWith(ctx.map, 'draw.create')).toHaveLength(0) + }) +}) + +describe('onCreate (draw.create re-id)', () => { + test('re-ids the created feature to the requested featureId', () => { + const { ctx } = setup(DrawPolygonMode) + const feature = { id: 'temp-id' } + ctx.map.fire('draw.create', { features: [feature] }) + expect(ctx._ctx.api.delete).toHaveBeenCalledWith('temp-id') + expect(feature.id).toBe('shape-1') + expect(ctx._ctx.api.add).toHaveBeenCalledWith(feature, { userProperties: true }) + }) +}) diff --git a/plugins/beta/draw/src/adapters/maplibre/modes/drawMode/keyboardHandlers.js b/plugins/beta/draw/src/adapters/maplibre/modes/drawMode/keyboardHandlers.js new file mode 100644 index 000000000..9446fa802 --- /dev/null +++ b/plugins/beta/draw/src/adapters/maplibre/modes/drawMode/keyboardHandlers.js @@ -0,0 +1,78 @@ +/** + * Keyboard handling for the shared draw mode: cmd/ctrl+z undo, arrow/Enter keyboard + * drawing, and Escape cancel/reinitialise. Part of createDrawMode. + * + * Note the two distinct handlers: onKeyup (lowercase) is a window listener; onKeyUp + * (capital U) is mapbox-gl-draw's own, registered on the container. + */ +export const createKeyboardHandlers = ({ ParentMode, getFeature, INTERFACE_KEYS }) => ({ + onKeydown (state, e) { + if (e.key === 'z' && (e.metaKey || e.ctrlKey) && !e.shiftKey) { + this._handleUndoKeydown(state, e) + return + } + if (document.activeElement !== state.container) { + return + } + if (e.key === 'Escape') { + e.preventDefault() + return + } + if (e.key === 'Enter') { + state.isActive = true + } + if (!INTERFACE_KEYS.has(e.key)) { + return + } + this._setInterface(state, 'keyboard') + this.onMove(state, e) + }, + + onKeyup (state, e) { + if (e.key === 'Escape') { + if (state.interfaceType !== 'keyboard') { + // Mouse/touch: cancel drawing — onKeyUp (capital U) won't fire since container isn't focused + this.map.fire('draw.cancel') + } + // Keyboard: onKeyUp (capital U) handles reinitialize (container is focused, event reaches it) + return + } + if (document.activeElement !== state.container) { + return + } + if (!INTERFACE_KEYS.has(e.key)) { + return + } + this._setInterface(state, 'keyboard') + this.onMove(state, e) + if (e.key === 'Enter' && state.isActive) { + this.doClick(state) + } + }, + + // Called by mapbox-gl-draw's event system (capital U — distinct from onKeyup above). + // Registered on ctx.container, so only fires when the viewport has focus (keyboard drawing). + // 1. A UI element inside the viewport has focus (e.g. popup menu) → ignore, let React handle + // 2. Keyboard drawing (container focused, interfaceType === 'keyboard') → Escape restarts + // 3. Non-keyboard with container focused → skip (already handled by window onKeyup via draw.cancel) + onKeyUp (state, e) { + const activeEl = document.activeElement + if (activeEl && activeEl !== state.container && state.container.contains(activeEl)) { + return + } + if (e.key === 'Escape') { + if (state.interfaceType === 'keyboard') { + const undoStack = this.map._undoStack + if (undoStack) { + undoStack.clear() + } + this._reinitializeFeature(state, getFeature(state)) + } + // Non-keyboard already handled by onKeyup (window) via draw.cancel + return + } + if (activeEl !== state.container) { + ParentMode.onKeyUp.call(this, state, e) + } + } +}) diff --git a/plugins/beta/draw/src/adapters/maplibre/modes/drawMode/keyboardHandlers.test.js b/plugins/beta/draw/src/adapters/maplibre/modes/drawMode/keyboardHandlers.test.js new file mode 100644 index 000000000..fc092b34f --- /dev/null +++ b/plugins/beta/draw/src/adapters/maplibre/modes/drawMode/keyboardHandlers.test.js @@ -0,0 +1,75 @@ +import { setup, clickAt, firedWith, DrawPolygonMode } from './__helpers__/harness.js' + +describe('keyboard interface', () => { + test('arrow keys switch to keyboard interface, show the crosshair and move the rubber band to center', () => { + const { ctx, state, marker, container } = setup(DrawPolygonMode) + clickAt(ctx, state, 0, 0) + container.focus() + window.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight' })) + expect(state.interfaceType).toBe('keyboard') + expect(marker.style.display).toBe('block') + expect(state.polygon.coordinates[0].at(-1)).toEqual([5, 5]) + expect(firedWith(ctx.map, 'draw.geometrychange').length).toBeGreaterThan(0) + }) + + test('other keys, unfocused container and Escape do not switch interface', () => { + const { ctx, state, marker, container } = setup(DrawPolygonMode) + ctx.keydownHandler(new KeyboardEvent('keydown', { key: 'ArrowRight' })) // container not focused + container.focus() + ctx.keydownHandler(new KeyboardEvent('keydown', { key: 'a' })) + ctx.keydownHandler(new KeyboardEvent('keydown', { key: 'Escape' })) + ctx.keyupHandler(new KeyboardEvent('keyup', { key: 'a' })) + expect(state.interfaceType).toBe('mouse') + expect(marker.style.display).toBe('none') + }) + + test('keyup without focus is ignored', () => { + const { ctx, state } = setup(DrawPolygonMode) + ctx.keyupHandler(new KeyboardEvent('keyup', { key: 'ArrowRight' })) + expect(state.interfaceType).toBe('mouse') + }) + + test('Enter down then up places a vertex at center', () => { + const { ctx, state, container } = setup(DrawPolygonMode) + container.focus() + ctx.keydownHandler(new KeyboardEvent('keydown', { key: 'Enter' })) + ctx.keyupHandler(new KeyboardEvent('keyup', { key: 'Enter' })) + expect(state.isActive).toBe(true) + expect(state.polygon.coordinates[0][0]).toEqual([5, 5]) + }) + + test('Escape keyup cancels drawing for mouse/touch but not keyboard', () => { + const { ctx, state } = setup(DrawPolygonMode) + ctx.keyupHandler(new KeyboardEvent('keyup', { key: 'Escape' })) + expect(firedWith(ctx.map, 'draw.cancel')).toHaveLength(1) + + state.interfaceType = 'keyboard' + ctx.keyupHandler(new KeyboardEvent('keyup', { key: 'Escape' })) + expect(firedWith(ctx.map, 'draw.cancel')).toHaveLength(1) + }) + + test('keyup of a non-Enter interface key while focused switches interface without committing a vertex', () => { + const { ctx, state, container } = setup(DrawPolygonMode) + container.focus() + ctx.keyupHandler(new KeyboardEvent('keyup', { key: 'ArrowRight' })) + expect(state.interfaceType).toBe('keyboard') + expect(ctx.map._undoStack.length).toBe(0) + }) + + test('container keyUp with the container itself focused and a non-Escape key does not defer to the parent', () => { + const { ctx, state, container } = setup(DrawPolygonMode) + container.focus() + ctx.onKeyUp(state, { key: 'a' }) + expect(ctx.changeMode).not.toHaveBeenCalled() + }) + + test('Escape during keyboard drawing with no undo stack still reinitialises the feature', () => { + const { ctx, state, container } = setup(DrawPolygonMode, { interfaceType: 'keyboard' }) + clickAt(ctx, state, 0, 0) + container.focus() + const id = state.polygon.id + ctx.map._undoStack = null + ctx.onKeyUp(state, { key: 'Escape' }) + expect(ctx._ctx.store.delete).toHaveBeenCalledWith([id]) + }) +}) diff --git a/plugins/beta/draw/src/adapters/maplibre/modes/drawMode/lifecycle.js b/plugins/beta/draw/src/adapters/maplibre/modes/drawMode/lifecycle.js new file mode 100644 index 000000000..6e6379492 --- /dev/null +++ b/plugins/beta/draw/src/adapters/maplibre/modes/drawMode/lifecycle.js @@ -0,0 +1,76 @@ +/** + * Setup / teardown for the shared draw mode: binds the window/container/map event + * handlers on entry and removes them on exit. Part of createDrawMode. + */ +export const createLifecycle = ({ ParentMode, featureProp, excludeFeatureIdFromSetup }) => ({ + onSetup (options) { + const { map } = this + + // Some parent modes (DrawLineString) interpret featureId as "continue existing" + // rather than "use this ID for new feature" + const parentOptions = excludeFeatureIdFromSetup + ? { ...options, featureId: null } + : options + + const state = { + ...ParentMode.onSetup.call(this, parentOptions), + ...options + } + + // Add initial props + state[featureProp].properties = options.properties + + const { container, vertexMarkerId, getInterfaceType } = state + const currentInterfaceType = getInterfaceType ? getInterfaceType() : state.interfaceType + state.interfaceType = currentInterfaceType + const vertexMarker = container.querySelector(`#${vertexMarkerId}`) + state.vertexMarker = vertexMarker + if (['touch', 'keyboard'].includes(currentInterfaceType)) { + this._showCrossHair(state) + } else { + this._hideCrossHair(state) + } + + // Bind all handlers once + const bind = (name, fn) => (this[name] = fn.bind(this, state)) + const handlers = { + keydownHandler: this.onKeydown, + keyupHandler: this.onKeyup, + blurHandler: this.onBlur, + createHandler: this.onCreate, + moveHandler: this.onMove, + pointerdownHandler: this.onPointerdown, + pointermoveHandler: this.onPointermove, + pointerupHandler: this.onPointerup, + vertexButtonClickHandler: this.onVertexButtonClick, + undoHandler: this.onUndo + } + Object.entries(handlers).forEach(([k, fn]) => bind(k, fn)) + + // Register events + this._listeners = [ + [window, 'keydown', this.keydownHandler], + [window, 'keyup', this.keyupHandler], + [window, 'click', this.vertexButtonClickHandler], + [container, 'blur', this.blurHandler], + [container, 'pointermove', this.pointermoveHandler], + [container, 'pointerup', this.pointerupHandler], + [map, 'pointerdown', this.pointerdownHandler], + [map, 'draw.create', this.createHandler], + [map, 'move', this.moveHandler], + [map, 'draw.undo', this.undoHandler] + ] + this._listeners.forEach(([t, e, h]) => t.addEventListener ? t.addEventListener(e, h) : t.on(e, h)) + + return state + }, + + onStop (state) { + ParentMode.onStop.call(this, state) + this._listeners.forEach(([t, e, h]) => t.removeEventListener ? t.removeEventListener(e, h) : t.off(e, h)) + this._hideCrossHair(state) + // Sync the final interfaceType from draw mode back to app state so crosshair + // visibility is correct when exiting draw mode (e.g., if user switched from mouse to keyboard) + this.map.fire('draw.interfacetypechange', { interfaceType: state.interfaceType }) + } +}) diff --git a/plugins/beta/draw/src/adapters/maplibre/modes/drawMode/lifecycle.test.js b/plugins/beta/draw/src/adapters/maplibre/modes/drawMode/lifecycle.test.js new file mode 100644 index 000000000..d4c7043e7 --- /dev/null +++ b/plugins/beta/draw/src/adapters/maplibre/modes/drawMode/lifecycle.test.js @@ -0,0 +1,48 @@ +import { setup, firedWith, DrawPolygonMode, DrawLineMode } from './__helpers__/harness.js' + +describe('setup and crosshair', () => { + test('mouse interface hides the vertex marker, keyboard shows it', () => { + const mouse = setup(DrawPolygonMode) + expect(mouse.marker.style.display).toBe('none') + + const keyboard = setup(DrawPolygonMode, { interfaceType: 'keyboard' }) + expect(keyboard.marker.style.display).toBe('block') + }) + + test('getInterfaceType() takes precedence over the static interfaceType option', () => { + const { state, marker } = setup(DrawPolygonMode, { interfaceType: 'mouse', getInterfaceType: () => 'touch' }) + expect(state.interfaceType).toBe('touch') + expect(marker.style.display).toBe('block') + }) + + test('a crossHair object is used instead of the vertex marker when provided', () => { + const crossHair = { show: jest.fn(), hide: jest.fn() } + const { ctx, state } = setup(DrawPolygonMode, { interfaceType: 'keyboard', crossHair }) + expect(crossHair.show).toHaveBeenCalled() + ctx.pointermoveHandler({ pointerType: 'mouse' }) + expect(crossHair.hide).toHaveBeenCalled() + ctx.onBlur(state, { target: document.body }) + expect(crossHair.hide).toHaveBeenCalledTimes(2) + }) + + test('line mode creates a fresh feature even when featureId is passed', () => { + const { state } = setup(DrawLineMode) + expect(state.line).toBeDefined() + expect(state.featureId).toBe('shape-1') + }) +}) + +describe('onStop', () => { + test('removes listeners, hides the crosshair and reports the final interface type', () => { + const { ctx, state, marker, container } = setup(DrawPolygonMode, { interfaceType: 'keyboard' }) + ctx.onStop(state) + expect(marker.style.display).toBe('none') + expect(firedWith(ctx.map, 'draw.interfacetypechange')).toEqual([{ interfaceType: 'keyboard' }]) + + // Window/container/map listeners are gone: keydown no longer shows the crosshair + container.focus() + window.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight' })) + expect(marker.style.display).toBe('none') + expect(ctx.map.off).toHaveBeenCalledWith('draw.create', ctx.createHandler) + }) +}) diff --git a/plugins/beta/draw/src/adapters/maplibre/modes/drawMode/pointerHandlers.js b/plugins/beta/draw/src/adapters/maplibre/modes/drawMode/pointerHandlers.js new file mode 100644 index 000000000..e88a30589 --- /dev/null +++ b/plugins/beta/draw/src/adapters/maplibre/modes/drawMode/pointerHandlers.js @@ -0,0 +1,85 @@ +import { + getSnapInstance, isSnapEnabled, getSnapLngLat, triggerSnapAtPoint, triggerSnapAtCenter +} from '../../utils/snapHelpers.js' + +/** + * Pointer / touch handling for the shared draw mode: touch and mouse interface + * switching, rubber-band movement (with snapping) and blur. Part of createDrawMode. + */ +export const createPointerHandlers = ({ ParentMode, getFeature, getCoords }) => ({ + onTouchStart (state, e) { + this._setInterface(state, 'touch') + this.onMove(state, e) + }, + + onTouchEnd (state, e) { + this._setInterface(state, 'touch') + this.onMove(state, e) + }, + + onBlur (state, e) { + if (e.target !== state.container) { + this._hideCrossHair(state) + } + }, + + onMouseMove (state, e) { + if (isSnapEnabled(state)) { + const snap = getSnapInstance(this.map) + triggerSnapAtPoint(snap, this.map, e.point) + + const snappedLngLat = getSnapLngLat(snap) + if (snappedLngLat) { + e = { ...e, lngLat: snappedLngLat } + } + } + + this.map.fire('draw.geometrychange', state.polygon || state.line) + + ParentMode.onMouseMove.call(this, state, e) + }, + + onMove (state) { + if (['touch', 'keyboard'].includes(state.interfaceType)) { + if (isSnapEnabled(state)) { + triggerSnapAtCenter(getSnapInstance(this.map), this.map) + } + + const snap = getSnapInstance(this.map) + const snappedLngLat = isSnapEnabled(state) && getSnapLngLat(snap) + + if (snappedLngLat) { + const point = this.map.project([snappedLngLat.lng, snappedLngLat.lat]) + ParentMode.onMouseMove.call(this, state, { + lngLat: snappedLngLat, + point, + originalEvent: new MouseEvent('mousemove', { + clientX: point.x, + clientY: point.y, + bubbles: true, + cancelable: true + }) + }) + this._ctx.store.render() + } else { + this._simulateMouse('mousemove', ParentMode.onMouseMove, state) + } + } + }, + + onPointerdown (state, e) { + if (e.pointerType !== 'touch') { + this._setInterface(state, 'mouse', false) + } + }, + + onPointermove (state, e) { + if (e.pointerType !== 'touch') { + this._hideCrossHair(state) + } + }, + + onPointerup (state) { + this.dispatchVertexChange(getCoords(getFeature(state))) + } +}) diff --git a/plugins/beta/draw/src/adapters/maplibre/modes/drawMode/pointerHandlers.test.js b/plugins/beta/draw/src/adapters/maplibre/modes/drawMode/pointerHandlers.test.js new file mode 100644 index 000000000..a4a9786b8 --- /dev/null +++ b/plugins/beta/draw/src/adapters/maplibre/modes/drawMode/pointerHandlers.test.js @@ -0,0 +1,94 @@ +import { setup, clickAt, firedWith, activeSnap, DrawPolygonMode, CENTER } from './__helpers__/harness.js' + +describe('touch and pointer interface', () => { + test('touch start/end switch to touch interface and show the crosshair', () => { + const { ctx, state, marker } = setup(DrawPolygonMode) + ctx.onTouchStart(state, {}) + expect(state.interfaceType).toBe('touch') + expect(marker.style.display).toBe('block') + ctx.onTouchEnd(state, {}) + expect(state.interfaceType).toBe('touch') + }) + + test('non-touch pointerdown switches back to mouse without showing the crosshair; touch does not', () => { + const { ctx, state, marker } = setup(DrawPolygonMode, { interfaceType: 'keyboard' }) + ctx.map.fire('pointerdown', { pointerType: 'mouse' }) + expect(state.interfaceType).toBe('mouse') + expect(marker.style.display).toBe('block') // unchanged, not re-shown or hidden + + ctx.map.fire('pointerdown', { pointerType: 'touch' }) + expect(state.interfaceType).toBe('mouse') + }) + + test('mouse pointermove hides the crosshair; touch pointermove does not', () => { + const { ctx, marker } = setup(DrawPolygonMode, { interfaceType: 'keyboard' }) + ctx.pointermoveHandler({ pointerType: 'touch' }) + expect(marker.style.display).toBe('block') + ctx.pointermoveHandler({ pointerType: 'mouse' }) + expect(marker.style.display).toBe('none') + }) + + test('pointerup reports the current vertex count', () => { + const { ctx, state } = setup(DrawPolygonMode) + clickAt(ctx, state, 0, 0) + ctx.map.fire.mockClear() + ctx.pointerupHandler({}) + expect(firedWith(ctx.map, 'draw.vertexchange')).toEqual([{ numVertecies: 1 }]) + }) + + test('blur away from the container hides the crosshair; blur on the container does not', () => { + const { ctx, marker, container } = setup(DrawPolygonMode, { interfaceType: 'keyboard' }) + ctx.blurHandler({ target: container }) + expect(marker.style.display).toBe('block') + ctx.blurHandler({ target: document.body }) + expect(marker.style.display).toBe('none') + }) +}) + +describe('rubber band and snapping while moving', () => { + test('onMouseMove moves the rubber band, preferring the snapped position', () => { + const { ctx, state } = setup(DrawPolygonMode, { getSnapEnabled: () => true }) + clickAt(ctx, state, 0, 0) + ctx.map._snapInstance = activeSnap() + ctx.onMouseMove(state, { lngLat: { lng: 3, lat: 3 }, point: { x: 3, y: 3 } }) + expect(ctx.map._snapInstance.snapToClosestPoint).toHaveBeenCalled() + expect(state.polygon.coordinates[0].at(-1)).toEqual([9, 9]) + expect(firedWith(ctx.map, 'draw.geometrychange').length).toBeGreaterThan(0) + }) + + test('onMouseMove without snap uses the pointer position', () => { + const { ctx, state } = setup(DrawPolygonMode) + clickAt(ctx, state, 0, 0) + ctx.onMouseMove(state, { lngLat: { lng: 3, lat: 3 }, point: { x: 3, y: 3 } }) + expect(state.polygon.coordinates[0].at(-1)).toEqual([3, 3]) + }) + + test('onMouseMove with snapping enabled but no snap point falls back to the pointer position', () => { + const { ctx, state } = setup(DrawPolygonMode, { getSnapEnabled: () => true }) + clickAt(ctx, state, 0, 0) + ctx.map._snapInstance = { status: true, snapStatus: false, snapCoords: null, snapToClosestPoint: jest.fn() } + ctx.onMouseMove(state, { lngLat: { lng: 3, lat: 3 }, point: { x: 3, y: 3 } }) + expect(state.polygon.coordinates[0].at(-1)).toEqual([3, 3]) + }) + + test('map move in keyboard interface keeps the rubber band at center, or at the snap point when snapping', () => { + const { ctx, state } = setup(DrawPolygonMode, { interfaceType: 'keyboard' }) + clickAt(ctx, state, 0, 0) + ctx.map.fire('move') + expect(state.polygon.coordinates[0].at(-1)).toEqual([CENTER.lng, CENTER.lat]) + + state.getSnapEnabled = () => true + ctx.map._snapInstance = activeSnap() + ctx.map.fire('move') + expect(ctx.map._snapInstance.snapToClosestPoint).toHaveBeenCalled() + expect(state.polygon.coordinates[0].at(-1)).toEqual([9, 9]) + }) + + test('map move in mouse interface leaves the rubber band alone', () => { + const { ctx, state } = setup(DrawPolygonMode) + clickAt(ctx, state, 0, 0) + const before = [...state.polygon.coordinates[0].map((c) => [...c])] + ctx.map.fire('move') + expect(state.polygon.coordinates[0]).toEqual(before) + }) +}) diff --git a/plugins/beta/draw/src/adapters/maplibre/modes/drawMode/renderHelpers.js b/plugins/beta/draw/src/adapters/maplibre/modes/drawMode/renderHelpers.js new file mode 100644 index 000000000..c9b0726ee --- /dev/null +++ b/plugins/beta/draw/src/adapters/maplibre/modes/drawMode/renderHelpers.js @@ -0,0 +1,59 @@ +/** + * Rendering helpers for the shared draw mode: crosshair/vertex-marker visibility, + * interface switching, simulated mouse events at map centre, and the display-feature + * pass that adds a marker on every placed vertex. Part of createDrawMode. + */ +export const createRenderHelpers = ({ ParentMode, geometryType, getFeature, getPlacedCoords }) => ({ + _simulateMouse (type, fn, state) { + const { map } = this + const center = map.getCenter() + const point = map.project(center) + fn.call(this, state, { + lngLat: center, + point, + originalEvent: new MouseEvent(type, { + clientX: point.x, + clientY: point.y, + bubbles: true, + cancelable: true + }) + }) + this._ctx.store.render() + + this.map.fire('draw.geometrychange', state.polygon || state.line) + }, + + _showCrossHair (state) { + if (state.crossHair) { state.crossHair.show() } else { state.vertexMarker.style.display = 'block' } + }, + + _hideCrossHair (state) { + if (state.crossHair) { state.crossHair.hide() } else { state.vertexMarker.style.display = 'none' } + }, + + _setInterface (state, type, show = true) { + state.interfaceType = type + if (show) { + this._showCrossHair(state) + } + }, + + toDisplayFeatures (state, geojson, display) { + ParentMode.toDisplayFeatures.call(this, state, geojson, display) + + // Display features carry the id in properties.id (no top-level id) + const feature = getFeature(state) + if (geojson.geometry.type === geometryType && geojson.properties.id === feature.id) { + // Parent modes render only some placed vertices (which ones varies by + // mapbox-gl-draw version) — add a marker on every placed vertex. The + // 'draw-vertex' meta is display-only: it's not in mapbox-gl-draw's + // META_TYPES, so featuresAt ignores these markers and the parent's own + // vertex click targets (click first/last to finish) keep working. + getPlacedCoords(geojson).forEach((coordinates) => display({ + type: 'Feature', + properties: { meta: 'draw-vertex', parent: feature.id, active: 'false' }, + geometry: { type: 'Point', coordinates } + })) + } + } +}) diff --git a/plugins/beta/draw/src/adapters/maplibre/modes/drawMode/renderHelpers.test.js b/plugins/beta/draw/src/adapters/maplibre/modes/drawMode/renderHelpers.test.js new file mode 100644 index 000000000..516f01e13 --- /dev/null +++ b/plugins/beta/draw/src/adapters/maplibre/modes/drawMode/renderHelpers.test.js @@ -0,0 +1,34 @@ +import { setup, clickAt, DrawPolygonMode, DrawLineMode } from './__helpers__/harness.js' + +describe('vertex marker display', () => { + const displayed = (ctx, state, geometry, id) => { + const display = jest.fn() + ctx.toDisplayFeatures(state, { type: 'Feature', properties: { id }, geometry }, display) + return display.mock.calls.map(([f]) => f).filter((f) => f.properties.meta === 'draw-vertex') + } + + test('every placed polygon vertex gets a display-only draw-vertex marker', () => { + const { ctx, state } = setup(DrawPolygonMode) + clickAt(ctx, state, 0, 0) + clickAt(ctx, state, 10, 0) + clickAt(ctx, state, 10, 10) + const ring = [[0, 0], [10, 0], [10, 10], [5, 5], [0, 0]] // placed + rubber + closing + const markers = displayed(ctx, state, { type: 'Polygon', coordinates: [ring] }, state.polygon.id) + expect(markers.map((m) => m.geometry.coordinates)).toEqual([[0, 0], [10, 0], [10, 10]]) + expect(markers[0].properties).toEqual({ meta: 'draw-vertex', parent: state.polygon.id, active: 'false' }) + }) + + test('every placed line vertex gets a marker', () => { + const { ctx, state } = setup(DrawLineMode) + ctx.doClick(state) + const coords = [[5, 5], [8, 8]] // placed + rubber + const markers = displayed(ctx, state, { type: 'LineString', coordinates: coords }, state.line.id) + expect(markers.map((m) => m.geometry.coordinates)).toEqual([[5, 5]]) + }) + + test('other features get no markers', () => { + const { ctx, state } = setup(DrawPolygonMode) + const ring = [[0, 0], [10, 0], [10, 10], [0, 0]] + expect(displayed(ctx, state, { type: 'Polygon', coordinates: [ring] }, 'other-feature')).toEqual([]) + }) +}) diff --git a/plugins/beta/draw/src/adapters/maplibre/modes/drawMode/undoHandlers.js b/plugins/beta/draw/src/adapters/maplibre/modes/drawMode/undoHandlers.js new file mode 100644 index 000000000..9e6a00981 --- /dev/null +++ b/plugins/beta/draw/src/adapters/maplibre/modes/drawMode/undoHandlers.js @@ -0,0 +1,168 @@ +/** + * Undo handling for the shared draw mode: pushing draw_vertex operations, undoing the + * last placed vertex, reinitialising a feature, and the rubber-band update that follows. + * Part of createDrawMode. + */ +export const createUndoHandlers = ({ ParentMode, featureProp, geometryType, getCoords, getFeature, RUBBER_BAND_OFFSET }) => ({ + /** + * Push an undo operation for the last added vertex + */ + pushDrawUndo (state) { + const undoStack = this.map._undoStack + // Don't push during undo operations + if (!undoStack || this.map._undoInProgress) { + return + } + undoStack.push({ + type: 'draw_vertex', + geometryType, + featureId: getFeature(state).id + }) + }, + + /** + * Undo the last added vertex during drawing + */ + undoVertex (state) { + const feature = getFeature(state) + const coords = getCoords(feature) + + if (coords.length < 2) { + return false + } + + // Undoing last vertex requires reinitializing the feature + if (coords.length === 2) { + return this._reinitializeFeature(state, feature) + } + + this._removeLastVertex(state, feature, coords) + return true + }, + + /** + * Reinitialize feature when undoing to 0 vertices + * For Polygon: reinitialize in place + * For LineString: restart the draw mode with fresh state + */ + _reinitializeFeature (state, feature) { + const featureId = feature.id + this._ctx.store.delete([featureId]) + + // LineString: restart the draw mode with fresh state but same feature ID + if (geometryType === 'LineString') { + const undoStack = this.map._undoStack + if (undoStack) { + undoStack.clear() + } + // Restart draw with same options (excludeFeatureIdFromSetup prevents "continue" mode) + this._ctx.api.changeMode('draw_line', { + featureId, + container: state.container, + interfaceType: state.interfaceType, + crossHair: state.crossHair, + vertexMarkerId: state.vertexMarkerId, + addVertexButtonId: state.addVertexButtonId, + getSnapEnabled: state.getSnapEnabled, + properties: state.properties + }) + return true + } + + // Polygon: reinitialize in place + const center = this.map.getCenter() + const initialCoords = [[center.lng, center.lat], [center.lng, center.lat]] + const newFeature = this.newFeature({ + type: 'Feature', + properties: state.properties || {}, + geometry: { + type: geometryType, + coordinates: [initialCoords] + } + }) + newFeature.id = featureId + this._ctx.store.add(newFeature) + + state[featureProp] = newFeature + state.currentVertexPosition = 0 + + this._ctx.store.render() + this._simulateMouse('mousemove', ParentMode.onMouseMove, state) + this._ctx.store.render() + + this.dispatchVertexChange(initialCoords) + return true + }, + + /** + * Remove the last committed vertex and update rubber band + */ + _removeLastVertex (state, feature, coords) { + // Structure during drawing: [v1, v2, ..., vN, rubber_band] + const ring = geometryType === 'Polygon' ? feature.coordinates[0] : coords + ring.splice(-RUBBER_BAND_OFFSET, 1) + + // Snap rubber band to new last vertex position. undoVertex only calls here with + // 3+ coords, so after the splice the ring always has a preceding vertex. + ring[ring.length - 1] = [...ring[ring.length - 2]] + + // Keep parent mode's vertex counter in sync (min 1 for rubber band) + state.currentVertexPosition = Math.max(1, state.currentVertexPosition - 1) + + this._ctx.store.render() + this._updateRubberBand(state, getCoords(feature)) + }, + + /** + * Update rubber band position based on interface type + */ + _updateRubberBand (state, coords) { + if (['touch', 'keyboard'].includes(state.interfaceType)) { + // Touch/keyboard: move to map center for add point to work + this._simulateMouse('mousemove', ParentMode.onMouseMove, state) + this._ctx.store.render() + } else { + // Mouse: keep rubber band at current position. After a vertex removal the + // rubber band index always resolves to a real coordinate. + const rubberBandIndex = geometryType === 'Polygon' ? coords.length - 2 : coords.length - 1 + const rubberBandPos = coords[rubberBandIndex] + const lngLat = { lng: rubberBandPos[0], lat: rubberBandPos[1] } + const point = this.map.project(lngLat) + ParentMode.onMouseMove.call(this, state, { + lngLat, + point, + originalEvent: new MouseEvent('mousemove', { clientX: point.x, clientY: point.y }) + }) + this._ctx.store.render() + } + this.dispatchVertexChange(coords) + }, + + /** + * Handle draw.undo event + */ + onUndo (state, e) { + if (e.operation?.type === 'draw_vertex') { + this.undoVertex(state) + } + }, + + _handleUndoKeydown (state, e) { + const tag = document.activeElement?.tagName + if (tag === 'INPUT' || tag === 'TEXTAREA') { + return + } + e.preventDefault() + e.stopPropagation() + const undoStack = this.map._undoStack + if (undoStack && undoStack.length > 0) { + const operation = undoStack.pop() + if (operation?.type === 'draw_vertex') { + // Set flag to prevent click interference during undo + this.map._undoInProgress = true + setTimeout(() => { this.map._undoInProgress = false }, 100) + this.undoVertex(state) + } + } + } +}) diff --git a/plugins/beta/draw/src/adapters/maplibre/modes/drawMode/undoHandlers.test.js b/plugins/beta/draw/src/adapters/maplibre/modes/drawMode/undoHandlers.test.js new file mode 100644 index 000000000..d3f34f289 --- /dev/null +++ b/plugins/beta/draw/src/adapters/maplibre/modes/drawMode/undoHandlers.test.js @@ -0,0 +1,175 @@ +import { setup, clickAt, firedWith, DrawPolygonMode, DrawLineMode, CENTER } from './__helpers__/harness.js' + +describe('cmd/ctrl+z undo', () => { + const undoKey = (overrides = {}) => { + const e = new KeyboardEvent('keydown', { key: 'z', metaKey: true, ...overrides }) + e.preventDefault = jest.fn() + e.stopPropagation = jest.fn() + return e + } + + test('removes the last placed vertex without switching to keyboard interface', () => { + jest.useFakeTimers() + const { ctx, state, marker } = setup(DrawPolygonMode) + clickAt(ctx, state, 0, 0) + clickAt(ctx, state, 10, 0) + clickAt(ctx, state, 10, 10) + + ctx.keydownHandler(undoKey()) + expect(state.polygon.coordinates[0].map(([x]) => x)).toEqual([0, 10, 10]) + expect(state.polygon.coordinates[0]).toHaveLength(3) + expect(state.interfaceType).toBe('mouse') + expect(marker.style.display).toBe('none') + expect(ctx.map._undoInProgress).toBe(true) + jest.advanceTimersByTime(100) + expect(ctx.map._undoInProgress).toBe(false) + }) + + test('mouse interface keeps the rubber band on the remaining last vertex', () => { + const { ctx, state } = setup(DrawPolygonMode) + clickAt(ctx, state, 0, 0) + clickAt(ctx, state, 10, 0) + clickAt(ctx, state, 10, 10) + ctx.keydownHandler(undoKey()) + const ring = state.polygon.coordinates[0] + expect(ring[ring.length - 1]).toEqual(ring[ring.length - 2]) + }) + + test('is ignored when typing in an input, when the stack is empty, or for other operations', () => { + const { ctx, state } = setup(DrawPolygonMode) + clickAt(ctx, state, 0, 0) + clickAt(ctx, state, 10, 0) + + const input = document.createElement('input') + document.body.appendChild(input) + input.focus() + ctx.keydownHandler(undoKey()) + expect(state.polygon.coordinates[0]).toHaveLength(3) + input.blur() + + ctx.map._undoStack.clear() + ctx.keydownHandler(undoKey()) + ctx.map._undoStack.push({ type: 'edit_vertex' }) + ctx.keydownHandler(undoKey()) + expect(state.polygon.coordinates[0]).toHaveLength(3) + }) + + test('cmd+shift+z is not treated as undo', () => { + const { ctx, state } = setup(DrawPolygonMode) + clickAt(ctx, state, 0, 0) + clickAt(ctx, state, 10, 0) + ctx.keydownHandler(undoKey({ shiftKey: true })) + expect(state.polygon.coordinates[0]).toHaveLength(3) + }) + + test('ctrl+z (Windows/Linux) also undoes the last vertex', () => { + const { ctx, state } = setup(DrawPolygonMode) + clickAt(ctx, state, 0, 0) + clickAt(ctx, state, 10, 0) + clickAt(ctx, state, 10, 10) + ctx.keydownHandler(undoKey({ metaKey: false, ctrlKey: true })) + expect(state.polygon.coordinates[0]).toHaveLength(3) + }) +}) + +describe('undo via draw.undo event and reinitialisation', () => { + test('draw.undo with a draw_vertex operation removes the last vertex; other types are ignored', () => { + const { ctx, state } = setup(DrawPolygonMode) + clickAt(ctx, state, 0, 0) + clickAt(ctx, state, 10, 0) + clickAt(ctx, state, 10, 10) + ctx.map.fire('draw.undo', { operation: { type: 'edit_vertex' } }) + expect(state.polygon.coordinates[0]).toHaveLength(4) + ctx.map.fire('draw.undo', { operation: { type: 'draw_vertex' } }) + expect(state.polygon.coordinates[0]).toHaveLength(3) + }) + + test('keyboard interface moves the rubber band to center after undo', () => { + const { ctx, state } = setup(DrawPolygonMode, { interfaceType: 'keyboard' }) + clickAt(ctx, state, 0, 0) + clickAt(ctx, state, 10, 0) + clickAt(ctx, state, 10, 10) + ctx.undoVertex(state) + expect(state.polygon.coordinates[0].at(-1)).toEqual([CENTER.lng, CENTER.lat]) + }) + + test('undoVertex returns false with nothing placed', () => { + const { ctx, state } = setup(DrawPolygonMode) + expect(ctx.undoVertex(state)).toBe(false) + }) + + test('undoing the only polygon vertex reinitialises the feature in place with the same id', () => { + const { ctx, state } = setup(DrawPolygonMode) + clickAt(ctx, state, 0, 0) + const id = state.polygon.id + expect(ctx.undoVertex(state)).toBe(true) + expect(ctx._ctx.store.delete).toHaveBeenCalledWith([id]) + expect(state.polygon.id).toBe(id) + expect(state.currentVertexPosition).toBe(0) + expect(firedWith(ctx.map, 'draw.vertexchange').pop()).toEqual({ numVertecies: 1 }) + }) + + test('undoing the only line vertex restarts draw_line with the same feature id', () => { + const { ctx, state } = setup(DrawLineMode) + ctx.doClick(state) + ctx.map._undoStack.push({ type: 'draw_vertex' }) + expect(ctx.undoVertex(state)).toBe(true) + expect(ctx.map._undoStack.length).toBe(0) + expect(ctx._ctx.api.changeMode).toHaveBeenCalledWith('draw_line', expect.objectContaining({ + featureId: state.line.id, + container: state.container, + properties: state.properties + })) + }) + + test('Escape (container keyUp) during keyboard drawing clears the undo stack and reinitialises', () => { + const { ctx, state, container } = setup(DrawPolygonMode, { interfaceType: 'keyboard' }) + clickAt(ctx, state, 0, 0) + container.focus() + const id = state.polygon.id + ctx.onKeyUp(state, { key: 'Escape' }) + expect(ctx.map._undoStack.length).toBe(0) + expect(state.polygon.id).toBe(id) + }) + + test('container keyUp defers to the parent mode when focus is elsewhere, and ignores focus inside the container', () => { + const { ctx, state, button, container } = setup(DrawPolygonMode) + ctx.onKeyUp(state, { key: 'Enter' }) // focus not on container → parent finishes on Enter + expect(ctx.changeMode).toHaveBeenCalledWith('simple_select', expect.anything()) + + button.focus() + ctx.changeMode.mockClear() + ctx.onKeyUp(state, { key: 'Enter' }) // focus on a child element → ignored + expect(ctx.changeMode).not.toHaveBeenCalled() + + container.focus() + ctx.onKeyUp(state, { key: 'Escape' }) // non-keyboard Escape → handled by window keyup instead + expect(ctx._ctx.store.delete).not.toHaveBeenCalled() + }) +}) + +describe('undo-stack and reinitialisation edge cases', () => { + test('placing a vertex with no undo stack does not throw and still places the vertex', () => { + const { ctx, state } = setup(DrawPolygonMode) + ctx.map._undoStack = null + clickAt(ctx, state, 0, 0) + expect(state.polygon.coordinates[0][0]).toEqual([0, 0]) + }) + + test('reinitialising a polygon without properties uses an empty properties object', () => { + const { ctx, state } = setup(DrawPolygonMode, { properties: undefined }) + clickAt(ctx, state, 0, 0) + expect(ctx.undoVertex(state)).toBe(true) + expect(state.polygon.properties).toEqual({}) + }) + + test('restarting a line with no undo stack still changes mode', () => { + const { ctx, state } = setup(DrawLineMode) + ctx.doClick(state) + ctx.map._undoStack = null + expect(ctx.undoVertex(state)).toBe(true) + expect(ctx._ctx.api.changeMode).toHaveBeenCalledWith('draw_line', expect.objectContaining({ + featureId: state.line.id + })) + }) +}) From 13ef52cebf26a2f00782d2fc472255bf13acb5cd Mon Sep 17 00:00:00 2001 From: Dan Leech Date: Fri, 3 Jul 2026 15:25:03 +0100 Subject: [PATCH 22/89] Remaning mode tests added --- .../draw/src/adapters/maplibre/mapboxDraw.js | 2 +- .../adapters/maplibre/modes/disabledMode.js | 2 +- .../maplibre/modes/disabledMode.test.js | 21 +++++++++++ .../adapters/maplibre/modes/drawLineMode.js | 5 ++- .../maplibre/modes/drawLineMode.test.js | 37 +++++++++++++++++++ .../maplibre/modes/drawPolygonMode.js | 10 +++-- .../maplibre/modes/drawPolygonMode.test.js | 27 ++++++++++++++ .../adapters/maplibre/modes/editVertexMode.js | 22 +++++------ .../maplibre/modes/editVertexMode.test.js | 4 +- .../__helpers__/harness.js | 0 .../geometryHelpers.js | 0 .../geometryHelpers.test.js | 0 .../{editVertex => editVertexMode}/helpers.js | 0 .../helpers.test.js | 0 .../keyboardHandlers.js | 0 .../keyboardHandlers.test.js | 0 .../pointerHandlers.js | 0 .../pointerHandlers.test.js | 0 .../touchHandlers.js | 0 .../touchHandlers.test.js | 0 .../undoHandlers.js | 0 .../undoHandlers.test.js | 0 .../vertexOperations.js | 0 .../vertexOperations.test.js | 0 .../vertexQueries.js | 0 .../vertexQueries.test.js | 0 26 files changed, 110 insertions(+), 20 deletions(-) create mode 100644 plugins/beta/draw/src/adapters/maplibre/modes/disabledMode.test.js create mode 100644 plugins/beta/draw/src/adapters/maplibre/modes/drawLineMode.test.js create mode 100644 plugins/beta/draw/src/adapters/maplibre/modes/drawPolygonMode.test.js rename plugins/beta/draw/src/adapters/maplibre/modes/{editVertex => editVertexMode}/__helpers__/harness.js (100%) rename plugins/beta/draw/src/adapters/maplibre/modes/{editVertex => editVertexMode}/geometryHelpers.js (100%) rename plugins/beta/draw/src/adapters/maplibre/modes/{editVertex => editVertexMode}/geometryHelpers.test.js (100%) rename plugins/beta/draw/src/adapters/maplibre/modes/{editVertex => editVertexMode}/helpers.js (100%) rename plugins/beta/draw/src/adapters/maplibre/modes/{editVertex => editVertexMode}/helpers.test.js (100%) rename plugins/beta/draw/src/adapters/maplibre/modes/{editVertex => editVertexMode}/keyboardHandlers.js (100%) rename plugins/beta/draw/src/adapters/maplibre/modes/{editVertex => editVertexMode}/keyboardHandlers.test.js (100%) rename plugins/beta/draw/src/adapters/maplibre/modes/{editVertex => editVertexMode}/pointerHandlers.js (100%) rename plugins/beta/draw/src/adapters/maplibre/modes/{editVertex => editVertexMode}/pointerHandlers.test.js (100%) rename plugins/beta/draw/src/adapters/maplibre/modes/{editVertex => editVertexMode}/touchHandlers.js (100%) rename plugins/beta/draw/src/adapters/maplibre/modes/{editVertex => editVertexMode}/touchHandlers.test.js (100%) rename plugins/beta/draw/src/adapters/maplibre/modes/{editVertex => editVertexMode}/undoHandlers.js (100%) rename plugins/beta/draw/src/adapters/maplibre/modes/{editVertex => editVertexMode}/undoHandlers.test.js (100%) rename plugins/beta/draw/src/adapters/maplibre/modes/{editVertex => editVertexMode}/vertexOperations.js (100%) rename plugins/beta/draw/src/adapters/maplibre/modes/{editVertex => editVertexMode}/vertexOperations.test.js (100%) rename plugins/beta/draw/src/adapters/maplibre/modes/{editVertex => editVertexMode}/vertexQueries.js (100%) rename plugins/beta/draw/src/adapters/maplibre/modes/{editVertex => editVertexMode}/vertexQueries.test.js (100%) diff --git a/plugins/beta/draw/src/adapters/maplibre/mapboxDraw.js b/plugins/beta/draw/src/adapters/maplibre/mapboxDraw.js index 5255d3335..e0565e3fd 100755 --- a/plugins/beta/draw/src/adapters/maplibre/mapboxDraw.js +++ b/plugins/beta/draw/src/adapters/maplibre/mapboxDraw.js @@ -7,7 +7,7 @@ import { createDrawStyles, updateDrawStyles } from './styles.js' import { initMapLibreSnap } from './mapboxSnap.js' import { createUndoStack } from '../../utils/undoStack.js' import { setupTouchClickWorkaround } from './utils/touchClickWorkaround.js' -import { applyTouchVertexColors } from './modes/editVertex/touchHandlers.js' +import { applyTouchVertexColors } from './modes/editVertexMode/touchHandlers.js' import { TOLERANCES, MAP_SIZE_SCALES } from './defaults.js' /** diff --git a/plugins/beta/draw/src/adapters/maplibre/modes/disabledMode.js b/plugins/beta/draw/src/adapters/maplibre/modes/disabledMode.js index 7f15e5cea..1a7cbddc8 100755 --- a/plugins/beta/draw/src/adapters/maplibre/modes/disabledMode.js +++ b/plugins/beta/draw/src/adapters/maplibre/modes/disabledMode.js @@ -16,7 +16,7 @@ export const DisabledMode = { return false }, - toDisplayFeatures (state, geojson, display) { + toDisplayFeatures (_state, geojson, display) { geojson.properties.active = 'false' display(geojson) } diff --git a/plugins/beta/draw/src/adapters/maplibre/modes/disabledMode.test.js b/plugins/beta/draw/src/adapters/maplibre/modes/disabledMode.test.js new file mode 100644 index 000000000..76a4e779e --- /dev/null +++ b/plugins/beta/draw/src/adapters/maplibre/modes/disabledMode.test.js @@ -0,0 +1,21 @@ +import { DisabledMode } from './disabledMode.js' + +describe('DisabledMode', () => { + test('onSetup returns an empty state', () => { + expect(DisabledMode.onSetup()).toEqual({}) + }) + + test('interaction handlers are disabled (return false to block selection/drag/keys)', () => { + expect(DisabledMode.onClick()).toBe(false) + expect(DisabledMode.onKeyUp()).toBe(false) + expect(DisabledMode.onDrag()).toBe(false) + }) + + test('toDisplayFeatures marks the feature inactive and still displays it', () => { + const display = jest.fn() + const geojson = { properties: {} } + DisabledMode.toDisplayFeatures({}, geojson, display) + expect(geojson.properties.active).toBe('false') + expect(display).toHaveBeenCalledWith(geojson) + }) +}) diff --git a/plugins/beta/draw/src/adapters/maplibre/modes/drawLineMode.js b/plugins/beta/draw/src/adapters/maplibre/modes/drawLineMode.js index 9f013056d..f7eae94ac 100644 --- a/plugins/beta/draw/src/adapters/maplibre/modes/drawLineMode.js +++ b/plugins/beta/draw/src/adapters/maplibre/modes/drawLineMode.js @@ -1,8 +1,9 @@ -import DrawLineString from '../../../../../../../node_modules/@mapbox/mapbox-gl-draw/src/modes/draw_line_string.js' +import MapboxDraw from '@mapbox/mapbox-gl-draw' import { isValidLineClick } from '../../../utils/spatial.js' import { createDrawMode } from './createDrawMode.js' -export const DrawLineMode = createDrawMode(DrawLineString, { +// Extend the built-in mode via the package's public API (MapboxDraw.modes) rather than a deep internal import +export const DrawLineMode = createDrawMode(MapboxDraw.modes.draw_line_string, { featureProp: 'line', geometryType: 'LineString', getCoords: (feature) => feature.coordinates, diff --git a/plugins/beta/draw/src/adapters/maplibre/modes/drawLineMode.test.js b/plugins/beta/draw/src/adapters/maplibre/modes/drawLineMode.test.js new file mode 100644 index 000000000..5e3eebacf --- /dev/null +++ b/plugins/beta/draw/src/adapters/maplibre/modes/drawLineMode.test.js @@ -0,0 +1,37 @@ +import { setup, firedWith, DrawLineMode } from './drawMode/__helpers__/harness.js' + +const drawVertexMarkers = (ctx, state, geometry) => { + const display = jest.fn() + ctx.toDisplayFeatures(state, { type: 'Feature', properties: { id: state.line.id }, geometry }, display) + return display.mock.calls.map(([f]) => f) + .filter((f) => f.properties.meta === 'draw-vertex') + .map((m) => m.geometry.coordinates) +} + +describe('DrawLineMode config', () => { + test('excludeFeatureIdFromSetup starts a fresh line feature even when a featureId is supplied', () => { + const { state } = setup(DrawLineMode) + expect(state.line).toBeDefined() + expect(state.featureId).toBe('shape-1') + }) + + test('getCoords reads the line coordinates directly (not a ring)', () => { + const { ctx, state } = setup(DrawLineMode) + ctx.doClick(state) + expect(state.line.coordinates[0]).toEqual([5, 5]) // first placed vertex, at map centre + }) + + test('getPlacedCoords marks every placed vertex, excluding the rubber band', () => { + const { ctx, state } = setup(DrawLineMode) + // Display coords: [v0..vN, rubber_band] → placed = slice(0, -1) + expect(drawVertexMarkers(ctx, state, { type: 'LineString', coordinates: [[5, 5], [8, 8]] })).toEqual([[5, 5]]) + }) + + test('finishOnInvalidClick finishes the line when the same spot is clicked twice', () => { + const { ctx, state } = setup(DrawLineMode) + ctx.doClick(state) + ctx.doClick(state) // same spot → finish (like a double-click) + expect(firedWith(ctx.map, 'draw.create')).toHaveLength(1) + expect(ctx.changeMode).toHaveBeenCalledWith('simple_select', { featureIds: [state.line.id] }) + }) +}) diff --git a/plugins/beta/draw/src/adapters/maplibre/modes/drawPolygonMode.js b/plugins/beta/draw/src/adapters/maplibre/modes/drawPolygonMode.js index f98ef412a..e3048a695 100755 --- a/plugins/beta/draw/src/adapters/maplibre/modes/drawPolygonMode.js +++ b/plugins/beta/draw/src/adapters/maplibre/modes/drawPolygonMode.js @@ -1,12 +1,16 @@ -import DrawPolygon from '../../../../../../../node_modules/@mapbox/mapbox-gl-draw/src/modes/draw_polygon.js' +import MapboxDraw from '@mapbox/mapbox-gl-draw' import { isValidClick } from '../../../utils/spatial.js' import { createDrawMode } from './createDrawMode.js' -export const DrawPolygonMode = createDrawMode(DrawPolygon, { +// During drawing the ring is [v0...vN, rubber_band, v0_closing]; the last two are not placed vertices +const RUBBER_BAND_AND_CLOSING = 2 + +// Extend the built-in mode via the package's public API (MapboxDraw.modes) rather than a deep internal import +export const DrawPolygonMode = createDrawMode(MapboxDraw.modes.draw_polygon, { featureProp: 'polygon', geometryType: 'Polygon', getCoords: (feature) => feature.coordinates[0], validateClick: (feature) => isValidClick(feature.coordinates), // Display ring during drawing: [v0...vN, rubber_band, v0_closing] - getPlacedCoords: (geojson) => geojson.geometry.coordinates[0].slice(0, -2) + getPlacedCoords: (geojson) => geojson.geometry.coordinates[0].slice(0, -RUBBER_BAND_AND_CLOSING) }) diff --git a/plugins/beta/draw/src/adapters/maplibre/modes/drawPolygonMode.test.js b/plugins/beta/draw/src/adapters/maplibre/modes/drawPolygonMode.test.js new file mode 100644 index 000000000..bbc57151b --- /dev/null +++ b/plugins/beta/draw/src/adapters/maplibre/modes/drawPolygonMode.test.js @@ -0,0 +1,27 @@ +import { setup, clickAt, DrawPolygonMode } from './drawMode/__helpers__/harness.js' + +const drawVertexMarkers = (ctx, state, geometry) => { + const display = jest.fn() + ctx.toDisplayFeatures(state, { type: 'Feature', properties: { id: state.polygon.id }, geometry }, display) + return display.mock.calls.map(([f]) => f) + .filter((f) => f.properties.meta === 'draw-vertex') + .map((m) => m.geometry.coordinates) +} + +describe('DrawPolygonMode config', () => { + test('getCoords reads the polygon ring; validateClick (isValidClick) rejects a duplicate click', () => { + const { ctx, state } = setup(DrawPolygonMode) + clickAt(ctx, state, 0, 0) + clickAt(ctx, state, 10, 0) + expect(state.polygon.coordinates[0].map(([x]) => x)).toEqual([0, 10, 10]) // v0, v1, rubber band + clickAt(ctx, state, 10, 0) // same spot → rejected by isValidClick + expect(state.polygon.coordinates[0]).toHaveLength(3) + }) + + test('getPlacedCoords marks every placed vertex, excluding the rubber band and closing point', () => { + const { ctx, state } = setup(DrawPolygonMode) + // Display ring: [v0..vN, rubber_band, v0_closing] → placed = slice(0, -2) + const ring = [[0, 0], [10, 0], [10, 10], [5, 5], [0, 0]] + expect(drawVertexMarkers(ctx, state, { type: 'Polygon', coordinates: [ring] })).toEqual([[0, 0], [10, 0], [10, 10]]) + }) +}) diff --git a/plugins/beta/draw/src/adapters/maplibre/modes/editVertexMode.js b/plugins/beta/draw/src/adapters/maplibre/modes/editVertexMode.js index d2aef13d8..55df4b685 100755 --- a/plugins/beta/draw/src/adapters/maplibre/modes/editVertexMode.js +++ b/plugins/beta/draw/src/adapters/maplibre/modes/editVertexMode.js @@ -1,18 +1,18 @@ -import DirectSelect from '../../../../../../../node_modules/@mapbox/mapbox-gl-draw/src/modes/direct_select.js' // NOSONAR +import MapboxDraw from '@mapbox/mapbox-gl-draw' import { getSnapInstance, clearSnapIndicator } from '../utils/snapHelpers.js' -import { getCoords } from './editVertex/geometryHelpers.js' -import { scalePoint } from './editVertex/helpers.js' -import { undoHandlers } from './editVertex/undoHandlers.js' -import { touchHandlers } from './editVertex/touchHandlers.js' -import { vertexOperations } from './editVertex/vertexOperations.js' -import { vertexQueries } from './editVertex/vertexQueries.js' -import { keyboardHandlers } from './editVertex/keyboardHandlers.js' -import { pointerHandlers } from './editVertex/pointerHandlers.js' +import { getCoords } from './editVertexMode/geometryHelpers.js' +import { scalePoint } from './editVertexMode/helpers.js' +import { undoHandlers } from './editVertexMode/undoHandlers.js' +import { touchHandlers } from './editVertexMode/touchHandlers.js' +import { vertexOperations } from './editVertexMode/vertexOperations.js' +import { vertexQueries } from './editVertexMode/vertexQueries.js' +import { keyboardHandlers } from './editVertexMode/keyboardHandlers.js' +import { pointerHandlers } from './editVertexMode/pointerHandlers.js' const EVENT_VERTEX_SELECTION = 'draw.vertexselection' export const EditVertexMode = { - ...DirectSelect, + ...MapboxDraw.modes.direct_select, ...undoHandlers, ...touchHandlers, ...vertexOperations, @@ -21,7 +21,7 @@ export const EditVertexMode = { ...pointerHandlers, onSetup (options) { - const state = DirectSelect.onSetup.call(this, options) + const state = MapboxDraw.modes.direct_select.onSetup.call(this, options) Object.assign(state, { container: options.container, interfaceType: options.interfaceType, diff --git a/plugins/beta/draw/src/adapters/maplibre/modes/editVertexMode.test.js b/plugins/beta/draw/src/adapters/maplibre/modes/editVertexMode.test.js index 153b60d68..93bd36a0c 100644 --- a/plugins/beta/draw/src/adapters/maplibre/modes/editVertexMode.test.js +++ b/plugins/beta/draw/src/adapters/maplibre/modes/editVertexMode.test.js @@ -1,10 +1,10 @@ -import { createHarness, POLYGON } from './editVertex/__helpers__/harness.js' +import { createHarness, POLYGON } from './editVertexMode/__helpers__/harness.js' /** * Tests for EditVertexMode's own methods: setup/teardown lifecycle, selection/scale/update * events, and the small move/button/changeMode routing. The keyboard, pointer, touch, undo, * vertex-query, vertex-operation and geometry helpers are covered in their own colocated - * test files under editVertex/. + * test files under editVertexMode/. */ describe('onSetup / onStop lifecycle', () => { diff --git a/plugins/beta/draw/src/adapters/maplibre/modes/editVertex/__helpers__/harness.js b/plugins/beta/draw/src/adapters/maplibre/modes/editVertexMode/__helpers__/harness.js similarity index 100% rename from plugins/beta/draw/src/adapters/maplibre/modes/editVertex/__helpers__/harness.js rename to plugins/beta/draw/src/adapters/maplibre/modes/editVertexMode/__helpers__/harness.js diff --git a/plugins/beta/draw/src/adapters/maplibre/modes/editVertex/geometryHelpers.js b/plugins/beta/draw/src/adapters/maplibre/modes/editVertexMode/geometryHelpers.js similarity index 100% rename from plugins/beta/draw/src/adapters/maplibre/modes/editVertex/geometryHelpers.js rename to plugins/beta/draw/src/adapters/maplibre/modes/editVertexMode/geometryHelpers.js diff --git a/plugins/beta/draw/src/adapters/maplibre/modes/editVertex/geometryHelpers.test.js b/plugins/beta/draw/src/adapters/maplibre/modes/editVertexMode/geometryHelpers.test.js similarity index 100% rename from plugins/beta/draw/src/adapters/maplibre/modes/editVertex/geometryHelpers.test.js rename to plugins/beta/draw/src/adapters/maplibre/modes/editVertexMode/geometryHelpers.test.js diff --git a/plugins/beta/draw/src/adapters/maplibre/modes/editVertex/helpers.js b/plugins/beta/draw/src/adapters/maplibre/modes/editVertexMode/helpers.js similarity index 100% rename from plugins/beta/draw/src/adapters/maplibre/modes/editVertex/helpers.js rename to plugins/beta/draw/src/adapters/maplibre/modes/editVertexMode/helpers.js diff --git a/plugins/beta/draw/src/adapters/maplibre/modes/editVertex/helpers.test.js b/plugins/beta/draw/src/adapters/maplibre/modes/editVertexMode/helpers.test.js similarity index 100% rename from plugins/beta/draw/src/adapters/maplibre/modes/editVertex/helpers.test.js rename to plugins/beta/draw/src/adapters/maplibre/modes/editVertexMode/helpers.test.js diff --git a/plugins/beta/draw/src/adapters/maplibre/modes/editVertex/keyboardHandlers.js b/plugins/beta/draw/src/adapters/maplibre/modes/editVertexMode/keyboardHandlers.js similarity index 100% rename from plugins/beta/draw/src/adapters/maplibre/modes/editVertex/keyboardHandlers.js rename to plugins/beta/draw/src/adapters/maplibre/modes/editVertexMode/keyboardHandlers.js diff --git a/plugins/beta/draw/src/adapters/maplibre/modes/editVertex/keyboardHandlers.test.js b/plugins/beta/draw/src/adapters/maplibre/modes/editVertexMode/keyboardHandlers.test.js similarity index 100% rename from plugins/beta/draw/src/adapters/maplibre/modes/editVertex/keyboardHandlers.test.js rename to plugins/beta/draw/src/adapters/maplibre/modes/editVertexMode/keyboardHandlers.test.js diff --git a/plugins/beta/draw/src/adapters/maplibre/modes/editVertex/pointerHandlers.js b/plugins/beta/draw/src/adapters/maplibre/modes/editVertexMode/pointerHandlers.js similarity index 100% rename from plugins/beta/draw/src/adapters/maplibre/modes/editVertex/pointerHandlers.js rename to plugins/beta/draw/src/adapters/maplibre/modes/editVertexMode/pointerHandlers.js diff --git a/plugins/beta/draw/src/adapters/maplibre/modes/editVertex/pointerHandlers.test.js b/plugins/beta/draw/src/adapters/maplibre/modes/editVertexMode/pointerHandlers.test.js similarity index 100% rename from plugins/beta/draw/src/adapters/maplibre/modes/editVertex/pointerHandlers.test.js rename to plugins/beta/draw/src/adapters/maplibre/modes/editVertexMode/pointerHandlers.test.js diff --git a/plugins/beta/draw/src/adapters/maplibre/modes/editVertex/touchHandlers.js b/plugins/beta/draw/src/adapters/maplibre/modes/editVertexMode/touchHandlers.js similarity index 100% rename from plugins/beta/draw/src/adapters/maplibre/modes/editVertex/touchHandlers.js rename to plugins/beta/draw/src/adapters/maplibre/modes/editVertexMode/touchHandlers.js diff --git a/plugins/beta/draw/src/adapters/maplibre/modes/editVertex/touchHandlers.test.js b/plugins/beta/draw/src/adapters/maplibre/modes/editVertexMode/touchHandlers.test.js similarity index 100% rename from plugins/beta/draw/src/adapters/maplibre/modes/editVertex/touchHandlers.test.js rename to plugins/beta/draw/src/adapters/maplibre/modes/editVertexMode/touchHandlers.test.js diff --git a/plugins/beta/draw/src/adapters/maplibre/modes/editVertex/undoHandlers.js b/plugins/beta/draw/src/adapters/maplibre/modes/editVertexMode/undoHandlers.js similarity index 100% rename from plugins/beta/draw/src/adapters/maplibre/modes/editVertex/undoHandlers.js rename to plugins/beta/draw/src/adapters/maplibre/modes/editVertexMode/undoHandlers.js diff --git a/plugins/beta/draw/src/adapters/maplibre/modes/editVertex/undoHandlers.test.js b/plugins/beta/draw/src/adapters/maplibre/modes/editVertexMode/undoHandlers.test.js similarity index 100% rename from plugins/beta/draw/src/adapters/maplibre/modes/editVertex/undoHandlers.test.js rename to plugins/beta/draw/src/adapters/maplibre/modes/editVertexMode/undoHandlers.test.js diff --git a/plugins/beta/draw/src/adapters/maplibre/modes/editVertex/vertexOperations.js b/plugins/beta/draw/src/adapters/maplibre/modes/editVertexMode/vertexOperations.js similarity index 100% rename from plugins/beta/draw/src/adapters/maplibre/modes/editVertex/vertexOperations.js rename to plugins/beta/draw/src/adapters/maplibre/modes/editVertexMode/vertexOperations.js diff --git a/plugins/beta/draw/src/adapters/maplibre/modes/editVertex/vertexOperations.test.js b/plugins/beta/draw/src/adapters/maplibre/modes/editVertexMode/vertexOperations.test.js similarity index 100% rename from plugins/beta/draw/src/adapters/maplibre/modes/editVertex/vertexOperations.test.js rename to plugins/beta/draw/src/adapters/maplibre/modes/editVertexMode/vertexOperations.test.js diff --git a/plugins/beta/draw/src/adapters/maplibre/modes/editVertex/vertexQueries.js b/plugins/beta/draw/src/adapters/maplibre/modes/editVertexMode/vertexQueries.js similarity index 100% rename from plugins/beta/draw/src/adapters/maplibre/modes/editVertex/vertexQueries.js rename to plugins/beta/draw/src/adapters/maplibre/modes/editVertexMode/vertexQueries.js diff --git a/plugins/beta/draw/src/adapters/maplibre/modes/editVertex/vertexQueries.test.js b/plugins/beta/draw/src/adapters/maplibre/modes/editVertexMode/vertexQueries.test.js similarity index 100% rename from plugins/beta/draw/src/adapters/maplibre/modes/editVertex/vertexQueries.test.js rename to plugins/beta/draw/src/adapters/maplibre/modes/editVertexMode/vertexQueries.test.js From abc210ac2d8f9aa96df8077a5d9c45a0f821b202 Mon Sep 17 00:00:00 2001 From: Dan Leech Date: Fri, 3 Jul 2026 15:34:41 +0100 Subject: [PATCH 23/89] Utils tests added --- .../adapters/maplibre/utils/snapHelpers.js | 6 +- .../maplibre/utils/snapHelpers.test.js | 253 ++++++++++++++++++ .../utils/touchClickWorkaround.test.js | 106 ++++++++ 3 files changed, 363 insertions(+), 2 deletions(-) create mode 100644 plugins/beta/draw/src/adapters/maplibre/utils/snapHelpers.test.js create mode 100644 plugins/beta/draw/src/adapters/maplibre/utils/touchClickWorkaround.test.js diff --git a/plugins/beta/draw/src/adapters/maplibre/utils/snapHelpers.js b/plugins/beta/draw/src/adapters/maplibre/utils/snapHelpers.js index 03afdf796..d6c55ded5 100644 --- a/plugins/beta/draw/src/adapters/maplibre/utils/snapHelpers.js +++ b/plugins/beta/draw/src/adapters/maplibre/utils/snapHelpers.js @@ -3,6 +3,8 @@ * Provides a consistent interface for snap detection and coordinate retrieval */ +import { TOLERANCES } from '../../../defaults.js' + /** * Get the snap instance from the map * @param {maplibregl.Map} map - Map instance @@ -137,10 +139,10 @@ export function clearSnapState (snap) { /** * Get snap radius in pixels * @param {MapboxSnap} snap - Snap instance - * @returns {number} Snap radius in pixels (default 15) + * @returns {number} Snap radius in pixels (falls back to the configured default) */ export function getSnapRadius (snap) { - return snap?.options?.radius ?? 15 + return snap?.options?.radius ?? TOLERANCES.snapRadius } /** diff --git a/plugins/beta/draw/src/adapters/maplibre/utils/snapHelpers.test.js b/plugins/beta/draw/src/adapters/maplibre/utils/snapHelpers.test.js new file mode 100644 index 000000000..520944ae5 --- /dev/null +++ b/plugins/beta/draw/src/adapters/maplibre/utils/snapHelpers.test.js @@ -0,0 +1,253 @@ +import { + getSnapInstance, + isSnapActive, + getSnapLngLat, + getSnapCoords, + triggerSnapAtPoint, + triggerSnapAtCenter, + clearSnapIndicator, + clearSnapState, + getSnapRadius, + isSnapEnabled, + createSnappedEvent, + createSnappedClickEvent +} from './snapHelpers.js' +import { TOLERANCES } from '../../../defaults.js' + +const activeSnap = (overrides = {}) => ({ + status: true, + snapStatus: true, + snapCoords: [1, 2], + ...overrides +}) + +describe('getSnapInstance', () => { + test('returns the snap instance when present', () => { + const snap = {} + expect(getSnapInstance({ _snapInstance: snap })).toBe(snap) + }) + + test('returns null when the map has no instance', () => { + expect(getSnapInstance({})).toBeNull() + }) + + test('returns null when the map is nullish', () => { + expect(getSnapInstance(null)).toBeNull() + }) +}) + +describe('isSnapActive', () => { + test('returns true when status, snapStatus and >=2 coords are present', () => { + expect(isSnapActive(activeSnap())).toBe(true) + }) + + test('returns false when snap is nullish', () => { + expect(isSnapActive(null)).toBe(false) + }) + + test('returns false when status is falsy', () => { + expect(isSnapActive(activeSnap({ status: false }))).toBe(false) + }) + + test('returns false when snapStatus is falsy', () => { + expect(isSnapActive(activeSnap({ snapStatus: false }))).toBe(false) + }) + + test('returns false when coords have fewer than 2 entries', () => { + expect(isSnapActive(activeSnap({ snapCoords: [1] }))).toBe(false) + }) +}) + +describe('getSnapLngLat', () => { + test('returns lng/lat object when snap is active', () => { + expect(getSnapLngLat(activeSnap())).toEqual({ lng: 1, lat: 2 }) + }) + + test('returns null when snap is inactive', () => { + expect(getSnapLngLat(null)).toBeNull() + }) +}) + +describe('getSnapCoords', () => { + test('returns [lng, lat] array when snap is active', () => { + expect(getSnapCoords(activeSnap())).toEqual([1, 2]) + }) + + test('returns null when snap is inactive', () => { + expect(getSnapCoords(null)).toBeNull() + }) +}) + +describe('triggerSnapAtPoint', () => { + test('unprojects the point and triggers snap detection', () => { + const snap = { status: true, snapToClosestPoint: jest.fn() } + const lngLat = { lng: 5, lat: 6 } + const map = { unproject: jest.fn(() => lngLat) } + const point = { x: 10, y: 20 } + + expect(triggerSnapAtPoint(snap, map, point)).toBe(true) + expect(map.unproject).toHaveBeenCalledWith(point) + expect(snap.snapToClosestPoint).toHaveBeenCalledWith({ point, lngLat }) + }) + + test('returns false when snap is missing', () => { + expect(triggerSnapAtPoint(null, {}, {})).toBe(false) + }) + + test('returns false when map is missing', () => { + expect(triggerSnapAtPoint({ status: true }, null, {})).toBe(false) + }) + + test('returns false when snap is disabled', () => { + expect(triggerSnapAtPoint({ status: false }, {}, {})).toBe(false) + }) +}) + +describe('triggerSnapAtCenter', () => { + test('projects the map centre and triggers snap detection', () => { + const snap = { status: true, snapToClosestPoint: jest.fn() } + const center = { lng: 1, lat: 2 } + const point = { x: 3, y: 4 } + const map = { getCenter: jest.fn(() => center), project: jest.fn(() => point) } + + expect(triggerSnapAtCenter(snap, map)).toBe(true) + expect(map.project).toHaveBeenCalledWith(center) + expect(snap.snapToClosestPoint).toHaveBeenCalledWith({ point, lngLat: center }) + }) + + test('returns false when snap is missing', () => { + expect(triggerSnapAtCenter(null, {})).toBe(false) + }) + + test('returns false when map is missing', () => { + expect(triggerSnapAtCenter({ status: true }, null)).toBe(false) + }) + + test('returns false when snap is disabled', () => { + expect(triggerSnapAtCenter({ status: false }, {})).toBe(false) + }) +}) + +describe('clearSnapIndicator', () => { + test('resets snap state, empties the feature arrays and hides the layer', () => { + const snap = { + snapStatus: true, + snapCoords: [1, 2], + snappedFeatures: [{}, {}], + closeFeatures: [{}], + lines: [{}] + } + const map = { + getLayer: jest.fn(() => ({})), + setLayoutProperty: jest.fn() + } + + clearSnapIndicator(snap, map) + + expect(snap.snapStatus).toBe(false) + expect(snap.snapCoords).toBeNull() + expect(snap.snappedFeatures).toHaveLength(0) + expect(snap.closeFeatures).toHaveLength(0) + expect(snap.lines).toHaveLength(0) + expect(map.setLayoutProperty).toHaveBeenCalledWith('snap-helper-circle', 'visibility', 'none') + }) + + test('handles a snap with empty/absent arrays and no map', () => { + const snap = { snapStatus: true, snapCoords: [1, 2] } + expect(() => clearSnapIndicator(snap)).not.toThrow() + expect(snap.snapStatus).toBe(false) + expect(snap.snapCoords).toBeNull() + }) + + test('does not touch the layer when it is absent', () => { + const map = { getLayer: jest.fn(() => null), setLayoutProperty: jest.fn() } + clearSnapIndicator(null, map) + expect(map.setLayoutProperty).not.toHaveBeenCalled() + }) +}) + +describe('clearSnapState', () => { + test('returns early when snap is nullish', () => { + expect(() => clearSnapState(null)).not.toThrow() + }) + + test('resets snap state and empties the feature arrays', () => { + const snap = { + snapStatus: true, + snapCoords: [1, 2], + snappedFeatures: [{}, {}], + closeFeatures: [{}], + lines: [{}] + } + + clearSnapState(snap) + + expect(snap.snapStatus).toBe(false) + expect(snap.snapCoords).toBeNull() + expect(snap.snappedFeatures).toHaveLength(0) + expect(snap.closeFeatures).toHaveLength(0) + expect(snap.lines).toHaveLength(0) + }) + + test('handles a snap with empty/absent arrays', () => { + const snap = { snapStatus: true, snapCoords: [1, 2] } + expect(() => clearSnapState(snap)).not.toThrow() + expect(snap.snapCoords).toBeNull() + }) +}) + +describe('getSnapRadius', () => { + test('returns the configured radius', () => { + expect(getSnapRadius({ options: { radius: 25 } })).toBe(25) + }) + + test('falls back to the configured snap radius when unset', () => { + expect(getSnapRadius(null)).toBe(TOLERANCES.snapRadius) + }) +}) + +describe('isSnapEnabled', () => { + test('returns true when getSnapEnabled returns true', () => { + expect(isSnapEnabled({ getSnapEnabled: () => true })).toBe(true) + }) + + test('returns false when getSnapEnabled returns a non-true value', () => { + expect(isSnapEnabled({ getSnapEnabled: () => false })).toBe(false) + }) + + test('returns false when getSnapEnabled is not a function', () => { + expect(isSnapEnabled({})).toBe(false) + }) +}) + +describe('createSnappedEvent', () => { + test('merges the snapped lngLat into the event when snapping', () => { + const e = { type: 'click', lngLat: { lng: 0, lat: 0 } } + const result = createSnappedEvent(e, activeSnap()) + expect(result).toEqual({ type: 'click', lngLat: { lng: 1, lat: 2 } }) + }) + + test('returns the original event when there is no snap', () => { + const e = { type: 'click' } + expect(createSnappedEvent(e, null)).toBe(e) + }) +}) + +describe('createSnappedClickEvent', () => { + test('builds a synthetic click event at the snapped point', () => { + const point = { x: 12, y: 34 } + const map = { project: jest.fn(() => point) } + + const result = createSnappedClickEvent(map, activeSnap()) + + expect(map.project).toHaveBeenCalledWith([1, 2]) + expect(result.lngLat).toEqual({ lng: 1, lat: 2 }) + expect(result.point).toBe(point) + expect(result.originalEvent).toBeInstanceOf(MouseEvent) + expect(result.originalEvent.type).toBe('click') + }) + + test('returns null when there is no snap', () => { + expect(createSnappedClickEvent({}, null)).toBeNull() + }) +}) diff --git a/plugins/beta/draw/src/adapters/maplibre/utils/touchClickWorkaround.test.js b/plugins/beta/draw/src/adapters/maplibre/utils/touchClickWorkaround.test.js new file mode 100644 index 000000000..ff8031a88 --- /dev/null +++ b/plugins/beta/draw/src/adapters/maplibre/utils/touchClickWorkaround.test.js @@ -0,0 +1,106 @@ +import { setupTouchClickWorkaround } from './touchClickWorkaround.js' + +const createCanvas = () => { + const listeners = {} + return { + addEventListener: jest.fn((type, handler) => { listeners[type] = handler }), + removeEventListener: jest.fn(), + dispatchEvent: jest.fn(), + fire (type, event) { listeners[type]?.(event) } + } +} + +const touchStartEvent = (touches) => ({ touches }) +const touchEndEvent = (changedTouches) => ({ changedTouches }) + +describe('setupTouchClickWorkaround', () => { + let canvas + let map + let draw + + beforeEach(() => { + canvas = createCanvas() + map = { getCanvas: () => canvas } + draw = { getMode: jest.fn(() => 'disabled') } + jest.spyOn(Date, 'now') + }) + + afterEach(() => { + Date.now.mockRestore() + }) + + test('registers passive touchstart/touchend listeners on the canvas', () => { + setupTouchClickWorkaround(map, draw) + expect(canvas.addEventListener).toHaveBeenCalledWith('touchstart', expect.any(Function), { passive: true }) + expect(canvas.addEventListener).toHaveBeenCalledWith('touchend', expect.any(Function), { passive: true }) + }) + + test('synthesizes a click for a quick, stationary tap in disabled mode', () => { + Date.now.mockReturnValueOnce(1000).mockReturnValueOnce(1100) + setupTouchClickWorkaround(map, draw) + + canvas.fire('touchstart', touchStartEvent([{ clientX: 50, clientY: 60 }])) + canvas.fire('touchend', touchEndEvent([{ clientX: 52, clientY: 61 }])) + + expect(canvas.dispatchEvent).toHaveBeenCalledTimes(1) + const dispatched = canvas.dispatchEvent.mock.calls[0][0] + expect(dispatched).toBeInstanceOf(MouseEvent) + expect(dispatched.type).toBe('click') + expect(dispatched.clientX).toBe(52) + expect(dispatched.clientY).toBe(61) + }) + + test('does not synthesize a click when the tap is too slow', () => { + Date.now.mockReturnValueOnce(1000).mockReturnValueOnce(1400) + setupTouchClickWorkaround(map, draw) + + canvas.fire('touchstart', touchStartEvent([{ clientX: 50, clientY: 60 }])) + canvas.fire('touchend', touchEndEvent([{ clientX: 50, clientY: 60 }])) + + expect(canvas.dispatchEvent).not.toHaveBeenCalled() + }) + + test('does not synthesize a click when the finger moves too far', () => { + Date.now.mockReturnValueOnce(1000).mockReturnValueOnce(1100) + setupTouchClickWorkaround(map, draw) + + canvas.fire('touchstart', touchStartEvent([{ clientX: 50, clientY: 60 }])) + canvas.fire('touchend', touchEndEvent([{ clientX: 100, clientY: 60 }])) + + expect(canvas.dispatchEvent).not.toHaveBeenCalled() + }) + + test('ignores multi-touch gestures on touchstart', () => { + setupTouchClickWorkaround(map, draw) + + canvas.fire('touchstart', touchStartEvent([{ clientX: 1, clientY: 1 }, { clientX: 2, clientY: 2 }])) + canvas.fire('touchend', touchEndEvent([{ clientX: 1, clientY: 1 }])) + + expect(canvas.dispatchEvent).not.toHaveBeenCalled() + }) + + test('does nothing when the current mode is not disabled', () => { + draw.getMode.mockReturnValue('draw_polygon') + setupTouchClickWorkaround(map, draw) + + canvas.fire('touchstart', touchStartEvent([{ clientX: 50, clientY: 60 }])) + canvas.fire('touchend', touchEndEvent([{ clientX: 50, clientY: 60 }])) + + expect(canvas.dispatchEvent).not.toHaveBeenCalled() + }) + + test('does nothing on touchend without a preceding single-finger touchstart', () => { + setupTouchClickWorkaround(map, draw) + + canvas.fire('touchend', touchEndEvent([{ clientX: 50, clientY: 60 }])) + + expect(canvas.dispatchEvent).not.toHaveBeenCalled() + }) + + test('remove() detaches both listeners', () => { + const { remove } = setupTouchClickWorkaround(map, draw) + remove() + expect(canvas.removeEventListener).toHaveBeenCalledWith('touchstart', expect.any(Function)) + expect(canvas.removeEventListener).toHaveBeenCalledWith('touchend', expect.any(Function)) + }) +}) From 422a2fec990670d9ee28329dfd84c30acc08a775 Mon Sep 17 00:00:00 2001 From: Dan Leech Date: Fri, 3 Jul 2026 15:40:14 +0100 Subject: [PATCH 24/89] mapboxDraw.js tests added --- .../draw/src/adapters/maplibre/mapboxDraw.js | 8 +- .../src/adapters/maplibre/mapboxDraw.test.js | 228 ++++++++++++++++++ 2 files changed, 232 insertions(+), 4 deletions(-) create mode 100644 plugins/beta/draw/src/adapters/maplibre/mapboxDraw.test.js diff --git a/plugins/beta/draw/src/adapters/maplibre/mapboxDraw.js b/plugins/beta/draw/src/adapters/maplibre/mapboxDraw.js index e0565e3fd..0ecffe8b6 100755 --- a/plugins/beta/draw/src/adapters/maplibre/mapboxDraw.js +++ b/plugins/beta/draw/src/adapters/maplibre/mapboxDraw.js @@ -44,7 +44,10 @@ export const createMapboxDraw = ({ mapStyle, mapProvider, events, eventBus, snap // --- Create or reuse MapLibre Draw instance --- let draw = mapProvider._mapboxDrawInstance - if (!draw) { + if (draw) { + // Update modes on existing draw instance when adapter is recreated + Object.assign(draw.modes, modes) + } else { draw = new MapboxDraw({ modes, styles: createDrawStyles(mapStyle), @@ -54,9 +57,6 @@ export const createMapboxDraw = ({ mapStyle, mapProvider, events, eventBus, snap }) map.addControl(draw) mapProvider._mapboxDrawInstance = draw - } else { - // Update modes on existing draw instance when adapter is recreated - Object.assign(draw.modes, modes) } // mapbox-gl-draw swallows tap clicks in disabled mode — synthesize them diff --git a/plugins/beta/draw/src/adapters/maplibre/mapboxDraw.test.js b/plugins/beta/draw/src/adapters/maplibre/mapboxDraw.test.js new file mode 100644 index 000000000..372f53cf4 --- /dev/null +++ b/plugins/beta/draw/src/adapters/maplibre/mapboxDraw.test.js @@ -0,0 +1,228 @@ +import MapboxDraw from '@mapbox/mapbox-gl-draw' +import { createDrawStyles, updateDrawStyles } from './styles.js' +import { initMapLibreSnap } from './mapboxSnap.js' +import { createUndoStack } from '../../utils/undoStack.js' +import { setupTouchClickWorkaround } from './utils/touchClickWorkaround.js' +import { applyTouchVertexColors } from './modes/editVertexMode/touchHandlers.js' +import { TOLERANCES, MAP_SIZE_SCALES } from './defaults.js' +import { createMapboxDraw } from './mapboxDraw.js' + +jest.mock('@mapbox/mapbox-gl-draw', () => { + const MockDraw = jest.fn(function () { + this.modes = {} + this.changeMode = jest.fn() + }) + MockDraw.constants = { classes: {} } + MockDraw.modes = { existing_mode: { id: 'existing' } } + return { __esModule: true, default: MockDraw } +}) + +jest.mock('./modes/disabledMode.js', () => ({ DisabledMode: { id: 'disabled' } })) +jest.mock('./modes/editVertexMode.js', () => ({ EditVertexMode: { id: 'edit_vertex' } })) +jest.mock('./modes/drawPolygonMode.js', () => ({ DrawPolygonMode: { id: 'draw_polygon' } })) +jest.mock('./modes/drawLineMode.js', () => ({ DrawLineMode: { id: 'draw_line' } })) +jest.mock('./styles.js', () => ({ + createDrawStyles: jest.fn(() => ['style']), + updateDrawStyles: jest.fn() +})) +jest.mock('./mapboxSnap.js', () => ({ initMapLibreSnap: jest.fn() })) +jest.mock('../../utils/undoStack.js', () => ({ createUndoStack: jest.fn(() => ({ id: 'undo-stack' })) })) +jest.mock('./utils/touchClickWorkaround.js', () => ({ setupTouchClickWorkaround: jest.fn() })) +jest.mock('./modes/editVertexMode/touchHandlers.js', () => ({ applyTouchVertexColors: jest.fn() })) +jest.mock('./defaults.js', () => ({ + TOLERANCES: { snapRadius: 12 }, + MAP_SIZE_SCALES: { medium: 1.5 } +})) + +const EVENTS = { MAP_SET_STYLE: 'map:setstyle', MAP_SET_SIZE: 'map:setsize' } + +const handlerFor = (mockFn, eventName) => + mockFn.mock.calls.find(([name]) => name === eventName)?.[1] + +const createMap = () => ({ + addControl: jest.fn(), + once: jest.fn(), + on: jest.fn(), + off: jest.fn(), + fire: jest.fn() +}) + +const setup = ({ existingDraw, existingUndoStack } = {}) => { + const map = createMap() + const mapProvider = { + map, + _mapboxDrawInstance: existingDraw, + undoStack: existingUndoStack + } + const eventBus = { on: jest.fn(), off: jest.fn(), emit: jest.fn() } + const removeWorkaround = jest.fn() + setupTouchClickWorkaround.mockReturnValue({ remove: removeWorkaround }) + + const result = createMapboxDraw({ + mapStyle: 'light', + mapProvider, + events: EVENTS, + eventBus, + snapLayers: ['layer-a'] + }) + + return { map, mapProvider, eventBus, removeWorkaround, result } +} + +beforeEach(() => { + jest.clearAllMocks() +}) + +describe('createMapboxDraw – instance creation', () => { + test('configures the MapLibre control CSS class constants', () => { + setup() + expect(MapboxDraw.constants.classes).toMatchObject({ + CONTROL_BASE: 'maplibregl-ctrl', + CONTROL_PREFIX: 'maplibregl-ctrl-', + CONTROL_GROUP: 'maplibregl-ctrl-group' + }) + }) + + test('creates a new draw instance with the custom modes and adds it to the map', () => { + const { map, mapProvider, result } = setup() + + expect(MapboxDraw).toHaveBeenCalledTimes(1) + const options = MapboxDraw.mock.calls[0][0] + expect(options).toMatchObject({ + styles: ['style'], + displayControlsDefault: false, + userProperties: true, + defaultMode: 'disabled' + }) + expect(options.modes).toMatchObject({ + existing_mode: { id: 'existing' }, + disabled: { id: 'disabled' }, + edit_vertex: { id: 'edit_vertex' }, + draw_polygon: { id: 'draw_polygon' }, + draw_line: { id: 'draw_line' } + }) + expect(createDrawStyles).toHaveBeenCalledWith('light') + + const draw = MapboxDraw.mock.instances[0] + expect(map.addControl).toHaveBeenCalledWith(draw) + expect(mapProvider._mapboxDrawInstance).toBe(draw) + expect(mapProvider.draw).toBe(draw) + expect(result.draw).toBe(draw) + }) + + test('reuses an existing draw instance and merges modes instead of creating a new one', () => { + const existingDraw = { modes: { old: {} }, changeMode: jest.fn() } + const { map, mapProvider } = setup({ existingDraw }) + + expect(MapboxDraw).not.toHaveBeenCalled() + expect(map.addControl).not.toHaveBeenCalled() + expect(existingDraw.modes).toMatchObject({ + old: {}, + disabled: { id: 'disabled' }, + edit_vertex: { id: 'edit_vertex' } + }) + expect(mapProvider.draw).toBe(existingDraw) + }) +}) + +describe('createMapboxDraw – setup side effects', () => { + test('sets up the touch-click workaround and records provider state', () => { + const { map, mapProvider } = setup() + + expect(setupTouchClickWorkaround).toHaveBeenCalledWith(map, mapProvider.draw) + expect(map._drawCurrentMapStyle).toBe('light') + expect(mapProvider.snapEnabled).toBe(false) + }) + + test('creates an undo stack when none exists and wires it to the map', () => { + const { map, mapProvider } = setup() + + expect(createUndoStack).toHaveBeenCalledTimes(1) + expect(mapProvider.undoStack).toEqual({ id: 'undo-stack' }) + expect(map._undoStack).toBe(mapProvider.undoStack) + + // The callback passed to createUndoStack fires a draw.undochange event + const undoCallback = createUndoStack.mock.calls[0][0] + undoCallback(3) + expect(map.fire).toHaveBeenCalledWith('draw.undochange', { length: 3 }) + }) + + test('reuses an existing undo stack', () => { + const existingUndoStack = { id: 'existing-stack' } + const { map } = setup({ existingUndoStack }) + + expect(createUndoStack).not.toHaveBeenCalled() + expect(map._undoStack).toBe(existingUndoStack) + }) + + test('initializes snapping with the configured radius and rules', () => { + const { map, mapProvider } = setup() + + expect(initMapLibreSnap).toHaveBeenCalledWith(map, mapProvider.draw, { + layers: ['layer-a'], + radius: TOLERANCES.snapRadius, + rules: ['vertex', 'edge'] + }) + }) +}) + +describe('createMapboxDraw – event handlers', () => { + test('MAP_SET_STYLE updates the current style and restyles on idle', () => { + const { map, eventBus } = setup() + + const styleHandler = handlerFor(eventBus.on, EVENTS.MAP_SET_STYLE) + map._drawEditContainer = { querySelector: jest.fn(() => 'svg-el') } + styleHandler('dark') + + expect(map._drawCurrentMapStyle).toBe('dark') + + const idleCallback = handlerFor(map.once, 'idle') + idleCallback() + expect(updateDrawStyles).toHaveBeenCalledWith(map, 'dark') + expect(map._drawEditContainer.querySelector).toHaveBeenCalledWith('[data-im-draw-touch-target]') + expect(applyTouchVertexColors).toHaveBeenCalledWith('svg-el', 'dark') + }) + + test('MAP_SET_STYLE idle handler tolerates a missing edit container', () => { + const { map, eventBus } = setup() + + handlerFor(eventBus.on, EVENTS.MAP_SET_STYLE)('dark') + handlerFor(map.once, 'idle')() + + expect(applyTouchVertexColors).toHaveBeenCalledWith(undefined, 'dark') + }) + + test('draw.interfacetypechange is forwarded to the event bus', () => { + const { map, eventBus } = setup() + + const handler = handlerFor(map.on, 'draw.interfacetypechange') + handler({ interfaceType: 'keyboard' }) + + expect(eventBus.emit).toHaveBeenCalledWith('draw:interfacetypechange', { interfaceType: 'keyboard' }) + }) + + test('MAP_SET_SIZE fires a scale change using the size lookup', () => { + const { map, eventBus } = setup() + + handlerFor(eventBus.on, EVENTS.MAP_SET_SIZE)('medium') + + expect(map.fire).toHaveBeenCalledWith('draw.scalechange', { scale: MAP_SIZE_SCALES.medium }) + }) +}) + +describe('createMapboxDraw – cleanup', () => { + test('remove() detaches listeners, disables draw and clears the adapter reference', () => { + const { map, mapProvider, eventBus, removeWorkaround, result } = setup() + const draw = mapProvider.draw + + result.remove() + + expect(removeWorkaround).toHaveBeenCalledTimes(1) + expect(eventBus.off).toHaveBeenCalledWith(EVENTS.MAP_SET_STYLE, expect.any(Function)) + expect(eventBus.off).toHaveBeenCalledWith(EVENTS.MAP_SET_SIZE, expect.any(Function)) + expect(map.off).toHaveBeenCalledWith('draw.interfacetypechange', expect.any(Function)) + expect(draw.changeMode).toHaveBeenCalledWith('disabled') + expect(mapProvider.draw).toBeNull() + expect(mapProvider._mapboxDrawInstance).toBe(draw) + }) +}) From d1f7f6bc902a4c2d94ae20f53f4b833615313286 Mon Sep 17 00:00:00 2001 From: Dan Leech Date: Fri, 3 Jul 2026 16:06:38 +0100 Subject: [PATCH 25/89] mapboxSnap code splitting and tests --- .../draw/src/adapters/maplibre/mapboxSnap.js | 314 +----------------- .../src/adapters/maplibre/mapboxSnap.test.js | 91 +++++ .../src/adapters/maplibre/snap/constants.js | 2 + .../src/adapters/maplibre/snap/mapHandlers.js | 39 +++ .../maplibre/snap/mapHandlers.test.js | 106 ++++++ .../maplibre/snap/prototypePatches.js | 150 +++++++++ .../maplibre/snap/prototypePatches.test.js | 243 ++++++++++++++ .../adapters/maplibre/snap/snapInstance.js | 103 ++++++ .../maplibre/snap/snapInstance.test.js | 138 ++++++++ .../src/adapters/maplibre/snap/sourceData.js | 34 ++ .../adapters/maplibre/snap/sourceData.test.js | 62 ++++ 11 files changed, 984 insertions(+), 298 deletions(-) create mode 100644 plugins/beta/draw/src/adapters/maplibre/mapboxSnap.test.js create mode 100644 plugins/beta/draw/src/adapters/maplibre/snap/constants.js create mode 100644 plugins/beta/draw/src/adapters/maplibre/snap/mapHandlers.js create mode 100644 plugins/beta/draw/src/adapters/maplibre/snap/mapHandlers.test.js create mode 100644 plugins/beta/draw/src/adapters/maplibre/snap/prototypePatches.js create mode 100644 plugins/beta/draw/src/adapters/maplibre/snap/prototypePatches.test.js create mode 100644 plugins/beta/draw/src/adapters/maplibre/snap/snapInstance.js create mode 100644 plugins/beta/draw/src/adapters/maplibre/snap/snapInstance.test.js create mode 100644 plugins/beta/draw/src/adapters/maplibre/snap/sourceData.js create mode 100644 plugins/beta/draw/src/adapters/maplibre/snap/sourceData.test.js diff --git a/plugins/beta/draw/src/adapters/maplibre/mapboxSnap.js b/plugins/beta/draw/src/adapters/maplibre/mapboxSnap.js index cccd3a11a..ead9ea98c 100644 --- a/plugins/beta/draw/src/adapters/maplibre/mapboxSnap.js +++ b/plugins/beta/draw/src/adapters/maplibre/mapboxSnap.js @@ -1,170 +1,9 @@ -import MapboxSnap from 'mapbox-gl-snap/dist/esm/MapboxSnap.js' -import { polygon, lineString } from '@turf/helpers' -import { COLORS } from './defaults.js' - -const SNAP_HELPER_LAYER = 'snap-helper-circle' - -/** Apply patches to MapboxSnap prototype (once only) */ -function applyMapboxSnapPatches (colors) { - if (MapboxSnap.prototype.__snapPatched) { - return - } - MapboxSnap.prototype.__snapPatched = true - - const proto = MapboxSnap.prototype - const orig = { - setMapData: proto.setMapData, - drawingSnapCheck: proto.drawingSnapCheck, - getLines: proto.getLines, - getCloseFeatures: proto.getCloseFeatures, - searchInVertex: proto.searchInVertex, - searchInMidPoint: proto.searchInMidPoint, - searchInEdge: proto.searchInEdge, - snapToClosestPoint: proto.snapToClosestPoint - } - - // Disable changeSnappedPoints - we handle snap ourselves in drag handlers - proto.changeSnappedPoints = () => {} - - // Skip setMapData when disabled, ensure layer visibility when enabled - proto.setMapData = function (data) { - if (!this.status) { - return - } - const result = orig.setMapData.call(this, data) - if (data?.features?.length > 0 && this.map?.getLayer(SNAP_HELPER_LAYER)) { - this.map.setLayoutProperty(SNAP_HELPER_LAYER, 'visibility', 'visible') - } - return result - } - - // Skip drawingSnapCheck when disabled - proto.drawingSnapCheck = function () { - if (!this.status) { - return - } - return orig.drawingSnapCheck.call(this) - } - - // Fix typo: original uses 'coodinates' instead of 'coordinates' for Multi* types - // Also validate coordinates to prevent "coordinates must contain numbers" errors - proto.getLines = function (feature, mouse, radiusArg) { - const geom = feature.geometry - if (!geom || !geom.coordinates) { - return [] - } - const coords = geom.coordinates - // Validate that we have actual coordinate arrays - if (!Array.isArray(coords) || coords.length === 0) { - return [] - } - try { - if (geom.type === 'MultiPolygon') { - return coords.filter(c => Array.isArray(c) && c.length > 0).map(c => polygon(c)) - } - if (geom.type === 'MultiLineString') { - return coords.filter(c => Array.isArray(c) && c.length > 0).map(c => lineString(c)) - } - return orig.getLines.call(this, feature, mouse, radiusArg) - } catch (e) { - // Invalid geometry - skip this feature - console.log(e) - return [] - } - } - - // Query within radius bbox instead of just point, filter to existing layers - proto.getCloseFeatures = function (e, radiusInMeters) { - if (!this.status) { - return [] - } - // Use active layers (per-call override) or fall back to default layers - const activeLayers = this._activeLayers || this._defaultLayers || [] - this.options.layers = activeLayers.filter(l => this.map.getLayer(l)) - const r = this.options.radius || 15 - const origPt = e.point - e.point = [[origPt.x - r, origPt.y - r], [origPt.x + r, origPt.y + r]] - const result = orig.getCloseFeatures.call(this, e, radiusInMeters) - e.point = origPt - return result - } - - // Custom colors for snap indicators - proto.searchInVertex = function (...args) { - const r = orig.searchInVertex.apply(this, args) - if (r) { - r.color = colors.vertex - } - return r - } - proto.searchInMidPoint = function (...args) { - const r = orig.searchInMidPoint.apply(this, args) - if (r) { - r.color = colors.midpoint - } - return r - } - proto.searchInEdge = function (...args) { - const r = orig.searchInEdge.apply(this, args) - if (r) { - r.color = colors.edge - } - return r - } - - // Skip when disabled or zooming, clean up internal arrays to prevent memory accumulation - proto.snapToClosestPoint = function (e) { - if (!this.status || this.map?._isZooming) { - return - } - try { - const result = orig.snapToClosestPoint.call(this, e) - if (this.closeFeatures?.length > 100) { - this.closeFeatures.length = 0 - } - if (this.lines?.length > 100) { - this.lines.length = 0 - } - return result - } catch (err) { - // Invalid geometry encountered - clear state and continue - console.log(err) - this.snapStatus = false - this.snapCoords = null - } - } -} - -/** Poll until checkFn returns truthy, then call onSuccess with the result */ -function pollUntil (checkFn, onSuccess) { - (function poll () { - const result = checkFn() - // null signals to stop polling, falsy continues polling - if (result === null) return - result ? onSuccess(result) : requestAnimationFrame(poll) - })() -} - -/** - * Patch a GeoJSON source to expose _data for MapboxSnap compatibility - * MapboxSnap expects source._data.features but MapLibre doesn't expose this - */ -export function patchSourceData (source) { - if (!source || (source._data && Array.isArray(source._data?.features))) { - return - } - - let dataCache = { type: 'FeatureCollection', features: [] } - Object.defineProperty(source, '_data', { - get: () => dataCache, - set: (val) => { - dataCache = (val && typeof val === 'object' && Array.isArray(val.features)) - ? val - : { type: 'FeatureCollection', features: [] } - }, - configurable: true - }) -} +import { COLORS, TOLERANCES } from './defaults.js' +import { applyMapboxSnapPatches } from './snap/prototypePatches.js' +import { pollUntil } from './snap/sourceData.js' +import { createSnapInstance } from './snap/snapInstance.js' +import { registerStyleLoadHandler, registerZoomHandlers } from './snap/mapHandlers.js' +import { DRAW_HOT_SOURCE } from './snap/constants.js' /** Initialize MapboxSnap with MapLibre + MapboxDraw */ export function initMapLibreSnap (map, draw, snapOptions = {}) { @@ -176,146 +15,25 @@ export function initMapLibreSnap (map, draw, snapOptions = {}) { const { layers = [], - radius = 15, + radius = TOLERANCES.snapRadius, rules = ['vertex', 'midpoint', 'edge'], status = false, onSnapped = () => {}, colors = {} } = snapOptions + const config = { layers, radius, rules, status, onSnapped } - // Apply global patches to MapboxSnap prototype + // Apply global patches to the MapboxSnap prototype applyMapboxSnapPatches({ vertex: COLORS.snapVertex, midpoint: COLORS.snapMidpoint, edge: COLORS.snapEdge, ...colors }) - // Clean up old snap instance's source and layer - function cleanupOldSnap () { - if (map.getLayer(SNAP_HELPER_LAYER)) { - map.removeLayer(SNAP_HELPER_LAYER) - } - if (map.getSource(SNAP_HELPER_LAYER)) { - map.removeSource(SNAP_HELPER_LAYER) - } - } - - // Create snap instance once source is available - function createSnap (source) { - // Prevent duplicate creation (race condition between initial poll and style.load) - if (map._snapInstance || map._snapCreating) { - return map._snapInstance - } - - map._snapCreating = true - - // Clean up any existing layer/source before creating new instance - cleanupOldSnap() - - patchSourceData(source) - - /** @type {any} */ - const snap = new MapboxSnap({ - map, - drawing: draw, - options: { layers, radius, rules }, - status, - onSnapped - }) - - // Override the status property to prevent library from auto-setting it - // The library sets status=true on draw.modechange and draw.selectionchange - // We want external control only via setSnapStatus() - let controlledStatus = status - - Object.defineProperty(snap, 'status', { - get () { // nosonar - return controlledStatus - }, - set () { // nosonar - // intentionally empty: library writes are ignored - }, - configurable: true - }) - - // Provide a controlled method for updating status - snap.setSnapStatus = (value) => { - controlledStatus = value - } + registerStyleLoadHandler(map, draw, config) + registerZoomHandlers(map) - // Store default layers and provide method to override per-call - snap._defaultLayers = layers - snap._activeLayers = null - - // Set snap layers (overrides defaults, pass null to reset to defaults) - snap.setSnapLayers = (overrideLayers) => { - if (overrideLayers === null || overrideLayers === undefined) { - snap._activeLayers = null // Use defaults - } else if (Array.isArray(overrideLayers)) { - snap._activeLayers = overrideLayers // Override defaults - } else { - // No action - } - } - - // Apply any pending snap layers that were set before instance was ready - if (map._pendingSnapLayers !== undefined) { - snap.setSnapLayers(map._pendingSnapLayers) - delete map._pendingSnapLayers - } - - map._snapInstance = snap - - return snap - } - - // Handle style changes - re-patch source and ensure snap layer exists - map.on('style.load', () => { - pollUntil( - () => map._removed ? null : map.getSource('mapbox-gl-draw-hot'), - (source) => { - patchSourceData(source) - - // Restore snap source/layer if gone after style change - if (!map.getSource(SNAP_HELPER_LAYER)) { - map.addSource(SNAP_HELPER_LAYER, { - type: 'geojson', - data: { type: 'FeatureCollection', features: [] } - }) - } - if (!map.getLayer(SNAP_HELPER_LAYER)) { - map.addLayer({ - id: SNAP_HELPER_LAYER, - type: 'fill', - source: SNAP_HELPER_LAYER, - paint: { 'fill-color': ['get', 'color'] }, - layout: { visibility: map._snapInstance?.status ? 'visible' : 'none' } - }) - } - - if (!map._snapInstance) { - createSnap(source) - } - } - ) - }) - - // Suppress snap processing during zoom (indicator freezes in place) - map.on('zoomstart', () => { - map._isZooming = true - }) - - map.on('zoomend', () => { - map._isZooming = false - // Force hide then re-show to reset indicator at new zoom level (Safari fix) - if (map.getLayer(SNAP_HELPER_LAYER)) { - map.setLayoutProperty(SNAP_HELPER_LAYER, 'visibility', 'none') - const snap = map._snapInstance - if (snap?.status) { - map.setLayoutProperty(SNAP_HELPER_LAYER, 'visibility', 'visible') - } - } - }) - - // Initial setup - poll until draw source exists + // Initial setup - poll until the draw source exists pollUntil( - () => map._removed ? null : map.getSource('mapbox-gl-draw-hot'), - createSnap + () => map._removed ? null : map.getSource(DRAW_HOT_SOURCE), + (source) => createSnapInstance(map, draw, source, config) ) + + return map._snapInstance } diff --git a/plugins/beta/draw/src/adapters/maplibre/mapboxSnap.test.js b/plugins/beta/draw/src/adapters/maplibre/mapboxSnap.test.js new file mode 100644 index 000000000..0ae9bb66f --- /dev/null +++ b/plugins/beta/draw/src/adapters/maplibre/mapboxSnap.test.js @@ -0,0 +1,91 @@ +import { applyMapboxSnapPatches } from './snap/prototypePatches.js' +import { registerStyleLoadHandler, registerZoomHandlers } from './snap/mapHandlers.js' +import { createSnapInstance } from './snap/snapInstance.js' +import { initMapLibreSnap } from './mapboxSnap.js' + +const DRAW_SOURCE = 'mapbox-gl-draw-hot' + +jest.mock('./snap/prototypePatches.js', () => ({ applyMapboxSnapPatches: jest.fn() })) +jest.mock('./snap/mapHandlers.js', () => ({ + registerStyleLoadHandler: jest.fn(), + registerZoomHandlers: jest.fn() +})) +jest.mock('./snap/snapInstance.js', () => ({ + createSnapInstance: jest.fn((map) => { + map._snapInstance = { id: 'snap' } + return map._snapInstance + }) +})) +jest.mock('./snap/sourceData.js', () => ({ + pollUntil: jest.fn((checkFn, onSuccess) => { + const result = checkFn() + if (result) { + onSuccess(result) + } + }) +})) +jest.mock('./defaults.js', () => ({ + COLORS: { snapVertex: 'v', snapMidpoint: 'm', snapEdge: 'e' }, + TOLERANCES: { snapRadius: 12 } +})) + +const makeMap = (overrides = {}) => ({ + getSource: jest.fn(() => ({ id: 'hot' })), + ...overrides +}) + +beforeEach(() => { + jest.clearAllMocks() +}) + +describe('initMapLibreSnap', () => { + test('returns the existing instance and skips setup when already initialized', () => { + const map = { _snapInitialized: true, _snapInstance: { id: 'existing' } } + + expect(initMapLibreSnap(map, {}, {})).toEqual({ id: 'existing' }) + expect(applyMapboxSnapPatches).not.toHaveBeenCalled() + expect(registerStyleLoadHandler).not.toHaveBeenCalled() + }) + + test('applies patches, registers handlers and creates the instance', () => { + const map = makeMap() + const draw = { id: 'draw' } + + const result = initMapLibreSnap(map, draw, { layers: ['a'] }) + + expect(applyMapboxSnapPatches).toHaveBeenCalledWith({ vertex: 'v', midpoint: 'm', edge: 'e' }) + expect(registerStyleLoadHandler).toHaveBeenCalledWith(map, draw, expect.objectContaining({ layers: ['a'], radius: 12 })) + expect(registerZoomHandlers).toHaveBeenCalledWith(map) + expect(createSnapInstance).toHaveBeenCalledWith(map, draw, { id: 'hot' }, expect.any(Object)) + expect(map.getSource).toHaveBeenCalledWith(DRAW_SOURCE) + expect(result).toEqual({ id: 'snap' }) + }) + + test('merges custom snap colours over the defaults', () => { + initMapLibreSnap(makeMap(), {}, { colors: { vertex: 'custom' } }) + expect(applyMapboxSnapPatches).toHaveBeenCalledWith({ vertex: 'custom', midpoint: 'm', edge: 'e' }) + }) + + test('uses the configured default radius when none is supplied', () => { + initMapLibreSnap(makeMap(), {}, {}) + expect(registerStyleLoadHandler.mock.calls[0][2].radius).toBe(12) + }) + + test('defaults snapOptions to an empty object when omitted', () => { + const map = makeMap() + expect(() => initMapLibreSnap(map, {})).not.toThrow() + expect(createSnapInstance).toHaveBeenCalled() + }) + + test('provides a no-op default onSnapped handler', () => { + initMapLibreSnap(makeMap(), {}, {}) + const passedConfig = registerStyleLoadHandler.mock.calls[0][2] + expect(passedConfig.onSnapped()).toBeUndefined() + }) + + test('does not create an instance when the map has already been removed', () => { + const map = makeMap({ _removed: true }) + initMapLibreSnap(map, {}, {}) + expect(createSnapInstance).not.toHaveBeenCalled() + }) +}) diff --git a/plugins/beta/draw/src/adapters/maplibre/snap/constants.js b/plugins/beta/draw/src/adapters/maplibre/snap/constants.js new file mode 100644 index 000000000..f19866478 --- /dev/null +++ b/plugins/beta/draw/src/adapters/maplibre/snap/constants.js @@ -0,0 +1,2 @@ +export const SNAP_HELPER_LAYER = 'snap-helper-circle' +export const DRAW_HOT_SOURCE = 'mapbox-gl-draw-hot' diff --git a/plugins/beta/draw/src/adapters/maplibre/snap/mapHandlers.js b/plugins/beta/draw/src/adapters/maplibre/snap/mapHandlers.js new file mode 100644 index 000000000..608b72841 --- /dev/null +++ b/plugins/beta/draw/src/adapters/maplibre/snap/mapHandlers.js @@ -0,0 +1,39 @@ +import { pollUntil, patchSourceData } from './sourceData.js' +import { createSnapInstance, ensureSnapLayer } from './snapInstance.js' +import { SNAP_HELPER_LAYER, DRAW_HOT_SOURCE } from './constants.js' + +/** Re-patch the source and restore the snap layer whenever the style reloads */ +export function registerStyleLoadHandler (map, draw, config) { + map.on('style.load', () => { + pollUntil( + () => map._removed ? null : map.getSource(DRAW_HOT_SOURCE), + (source) => { + patchSourceData(source) + ensureSnapLayer(map) + if (!map._snapInstance) { + createSnapInstance(map, draw, source, config) + } + } + ) + }) +} + +/** Suppress snap processing during zoom and reset the indicator afterwards */ +export function registerZoomHandlers (map) { + // Suppress snap processing during zoom (indicator freezes in place) + map.on('zoomstart', () => { + map._isZooming = true + }) + + map.on('zoomend', () => { + map._isZooming = false + // Force hide then re-show to reset indicator at new zoom level (Safari fix) + if (map.getLayer(SNAP_HELPER_LAYER)) { + map.setLayoutProperty(SNAP_HELPER_LAYER, 'visibility', 'none') + const snap = map._snapInstance + if (snap?.status) { + map.setLayoutProperty(SNAP_HELPER_LAYER, 'visibility', 'visible') + } + } + }) +} diff --git a/plugins/beta/draw/src/adapters/maplibre/snap/mapHandlers.test.js b/plugins/beta/draw/src/adapters/maplibre/snap/mapHandlers.test.js new file mode 100644 index 000000000..ce9c54b91 --- /dev/null +++ b/plugins/beta/draw/src/adapters/maplibre/snap/mapHandlers.test.js @@ -0,0 +1,106 @@ +import { registerStyleLoadHandler, registerZoomHandlers } from './mapHandlers.js' +import { createSnapInstance, ensureSnapLayer } from './snapInstance.js' +import { patchSourceData } from './sourceData.js' + +const SNAP_LAYER = 'snap-helper-circle' +const DRAW_SOURCE = 'mapbox-gl-draw-hot' + +jest.mock('./snapInstance.js', () => ({ + createSnapInstance: jest.fn(), + ensureSnapLayer: jest.fn() +})) + +jest.mock('./sourceData.js', () => ({ + ...jest.requireActual('./sourceData.js'), + patchSourceData: jest.fn() +})) + +const config = { layers: [], radius: 12, rules: [], status: false, onSnapped: () => {} } + +const handlerFor = (map, name) => map.on.mock.calls.find(([event]) => event === name)?.[1] + +const makeMap = (overrides = {}) => ({ + on: jest.fn(), + getSource: jest.fn((id) => (id === DRAW_SOURCE ? { id: 'hot' } : null)), + getLayer: jest.fn(() => null), + setLayoutProperty: jest.fn(), + ...overrides +}) + +beforeEach(() => { + jest.clearAllMocks() + global.requestAnimationFrame = jest.fn() +}) + +describe('registerStyleLoadHandler', () => { + test('re-patches the source, ensures the layer and creates a missing instance', () => { + const map = makeMap() + const draw = { id: 'draw' } + + registerStyleLoadHandler(map, draw, config) + handlerFor(map, 'style.load')() + + expect(patchSourceData).toHaveBeenCalledWith({ id: 'hot' }) + expect(ensureSnapLayer).toHaveBeenCalledWith(map) + expect(createSnapInstance).toHaveBeenCalledWith(map, draw, { id: 'hot' }, config) + }) + + test('does not recreate an existing instance', () => { + const map = makeMap({ _snapInstance: { id: 'exists' } }) + + registerStyleLoadHandler(map, {}, config) + handlerFor(map, 'style.load')() + + expect(ensureSnapLayer).toHaveBeenCalledWith(map) + expect(createSnapInstance).not.toHaveBeenCalled() + }) + + test('stops when the map has been removed', () => { + const map = makeMap({ _removed: true }) + + registerStyleLoadHandler(map, {}, config) + handlerFor(map, 'style.load')() + + expect(ensureSnapLayer).not.toHaveBeenCalled() + expect(createSnapInstance).not.toHaveBeenCalled() + }) +}) + +describe('registerZoomHandlers', () => { + test('zoomstart sets the zooming flag', () => { + const map = makeMap() + registerZoomHandlers(map) + handlerFor(map, 'zoomstart')() + expect(map._isZooming).toBe(true) + }) + + test('zoomend clears the flag and re-shows the indicator when snapping is active', () => { + const map = makeMap({ getLayer: jest.fn(() => ({ id: 'layer' })), _snapInstance: { status: true } }) + + registerZoomHandlers(map) + handlerFor(map, 'zoomend')() + + expect(map._isZooming).toBe(false) + expect(map.setLayoutProperty).toHaveBeenCalledWith(SNAP_LAYER, 'visibility', 'none') + expect(map.setLayoutProperty).toHaveBeenCalledWith(SNAP_LAYER, 'visibility', 'visible') + }) + + test('zoomend hides but does not re-show when snapping is disabled', () => { + const map = makeMap({ getLayer: jest.fn(() => ({ id: 'layer' })), _snapInstance: { status: false } }) + + registerZoomHandlers(map) + handlerFor(map, 'zoomend')() + + expect(map.setLayoutProperty).toHaveBeenCalledTimes(1) + expect(map.setLayoutProperty).toHaveBeenCalledWith(SNAP_LAYER, 'visibility', 'none') + }) + + test('zoomend does nothing when the snap layer is absent', () => { + const map = makeMap({ getLayer: jest.fn(() => null) }) + + registerZoomHandlers(map) + handlerFor(map, 'zoomend')() + + expect(map.setLayoutProperty).not.toHaveBeenCalled() + }) +}) diff --git a/plugins/beta/draw/src/adapters/maplibre/snap/prototypePatches.js b/plugins/beta/draw/src/adapters/maplibre/snap/prototypePatches.js new file mode 100644 index 000000000..fb4597271 --- /dev/null +++ b/plugins/beta/draw/src/adapters/maplibre/snap/prototypePatches.js @@ -0,0 +1,150 @@ +import MapboxSnap from 'mapbox-gl-snap/dist/esm/MapboxSnap.js' +import { polygon, lineString } from '@turf/helpers' +import { TOLERANCES } from '../defaults.js' +import { SNAP_HELPER_LAYER } from './constants.js' + +const MAX_SNAP_FEATURE_CACHE = 100 + +// Skip setMapData/drawingSnapCheck when disabled; ensure layer visibility when enabled +function patchDataMethods (proto, orig) { + proto.setMapData = function (data) { + if (!this.status) { + return undefined + } + const result = orig.setMapData.call(this, data) + if (data?.features?.length > 0 && this.map?.getLayer(SNAP_HELPER_LAYER)) { + this.map.setLayoutProperty(SNAP_HELPER_LAYER, 'visibility', 'visible') + } + return result + } + + // Skip drawingSnapCheck when disabled + proto.drawingSnapCheck = function () { + if (!this.status) { + return undefined + } + return orig.drawingSnapCheck.call(this) + } +} + +// Fix typo ('coodinates') and validate coordinates; query within a radius bbox +function patchGeometryMethods (proto, orig) { + proto.getLines = function (feature, mouse, radiusArg) { + const geom = feature.geometry + if (!geom?.coordinates) { + return [] + } + const coords = geom.coordinates + // Validate that we have actual coordinate arrays + if (!Array.isArray(coords) || coords.length === 0) { + return [] + } + try { + if (geom.type === 'MultiPolygon') { + return coords.filter(c => Array.isArray(c) && c.length > 0).map(c => polygon(c)) + } + if (geom.type === 'MultiLineString') { + return coords.filter(c => Array.isArray(c) && c.length > 0).map(c => lineString(c)) + } + return orig.getLines.call(this, feature, mouse, radiusArg) + } catch (e) { + // Invalid geometry - skip this feature + console.log(e) + return [] + } + } + + // Query within radius bbox instead of just point, filter to existing layers + proto.getCloseFeatures = function (e, radiusInMeters) { + if (!this.status) { + return [] + } + // Use active layers (per-call override) or fall back to default layers + const activeLayers = this._activeLayers || this._defaultLayers || [] + this.options.layers = activeLayers.filter(l => this.map.getLayer(l)) + const r = this.options.radius || TOLERANCES.snapRadius + const origPt = e.point + e.point = [[origPt.x - r, origPt.y - r], [origPt.x + r, origPt.y + r]] + const result = orig.getCloseFeatures.call(this, e, radiusInMeters) + e.point = origPt + return result + } +} + +// Custom colors for snap indicators +function patchColorMethods (proto, orig, colors) { + proto.searchInVertex = function (...args) { + const r = orig.searchInVertex.apply(this, args) + if (r) { + r.color = colors.vertex + } + return r + } + proto.searchInMidPoint = function (...args) { + const r = orig.searchInMidPoint.apply(this, args) + if (r) { + r.color = colors.midpoint + } + return r + } + proto.searchInEdge = function (...args) { + const r = orig.searchInEdge.apply(this, args) + if (r) { + r.color = colors.edge + } + return r + } +} + +// Skip when disabled or zooming; clean up internal arrays to prevent memory accumulation +function patchSnapMethod (proto, orig) { + proto.snapToClosestPoint = function (e) { + if (!this.status || this.map?._isZooming) { + return undefined + } + try { + const result = orig.snapToClosestPoint.call(this, e) + if (this.closeFeatures?.length > MAX_SNAP_FEATURE_CACHE) { + this.closeFeatures.length = 0 + } + if (this.lines?.length > MAX_SNAP_FEATURE_CACHE) { + this.lines.length = 0 + } + return result + } catch (err) { + // Invalid geometry encountered - clear state and continue + console.log(err) + this.snapStatus = false + this.snapCoords = null + return undefined + } + } +} + +/** Apply patches to MapboxSnap prototype (once only) */ +export function applyMapboxSnapPatches (colors) { + if (MapboxSnap.prototype.__snapPatched) { + return + } + MapboxSnap.prototype.__snapPatched = true + + const proto = MapboxSnap.prototype + const orig = { + setMapData: proto.setMapData, + drawingSnapCheck: proto.drawingSnapCheck, + getLines: proto.getLines, + getCloseFeatures: proto.getCloseFeatures, + searchInVertex: proto.searchInVertex, + searchInMidPoint: proto.searchInMidPoint, + searchInEdge: proto.searchInEdge, + snapToClosestPoint: proto.snapToClosestPoint + } + + // Disable changeSnappedPoints - we handle snap ourselves in drag handlers + proto.changeSnappedPoints = () => {} + + patchDataMethods(proto, orig) + patchGeometryMethods(proto, orig) + patchColorMethods(proto, orig, colors) + patchSnapMethod(proto, orig) +} diff --git a/plugins/beta/draw/src/adapters/maplibre/snap/prototypePatches.test.js b/plugins/beta/draw/src/adapters/maplibre/snap/prototypePatches.test.js new file mode 100644 index 000000000..fcab15072 --- /dev/null +++ b/plugins/beta/draw/src/adapters/maplibre/snap/prototypePatches.test.js @@ -0,0 +1,243 @@ +import MapboxSnap from 'mapbox-gl-snap/dist/esm/MapboxSnap.js' +import { polygon, lineString } from '@turf/helpers' +import { applyMapboxSnapPatches } from './prototypePatches.js' + +const SNAP_LAYER = 'snap-helper-circle' +const COLORS = { vertex: 'colour-v', midpoint: 'colour-m', edge: 'colour-e' } + +jest.mock('mapbox-gl-snap/dist/esm/MapboxSnap.js', () => { + const orig = { + setMapData: jest.fn(function () { return 'orig-setMapData' }), + drawingSnapCheck: jest.fn(function () { return 'orig-drawingSnapCheck' }), + getLines: jest.fn(function () { return 'orig-getLines' }), + getCloseFeatures: jest.fn(function () { return 'orig-getCloseFeatures' }), + searchInVertex: jest.fn(function () { return { id: 'v' } }), + searchInMidPoint: jest.fn(function () { return { id: 'm' } }), + searchInEdge: jest.fn(function () { return { id: 'e' } }), + snapToClosestPoint: jest.fn(function () { return 'orig-snap' }) + } + const MockSnap = jest.fn() + Object.assign(MockSnap.prototype, orig) + MockSnap.__orig = orig + return { __esModule: true, default: MockSnap } +}) + +jest.mock('@turf/helpers', () => ({ + polygon: jest.fn((c) => ({ poly: c })), + lineString: jest.fn((c) => ({ line: c })) +})) + +jest.mock('../defaults.js', () => ({ TOLERANCES: { snapRadius: 12 } })) + +beforeAll(() => { + applyMapboxSnapPatches(COLORS) +}) + +beforeEach(() => { + jest.clearAllMocks() + jest.spyOn(console, 'log').mockImplementation(() => {}) +}) + +afterEach(() => { + console.log.mockRestore() +}) + +describe('applyMapboxSnapPatches', () => { + test('patches the prototype only once', () => { + const patched = MapboxSnap.prototype.setMapData + applyMapboxSnapPatches(COLORS) + expect(MapboxSnap.prototype.setMapData).toBe(patched) + }) + + test('replaces changeSnappedPoints with a no-op', () => { + expect(MapboxSnap.prototype.changeSnappedPoints()).toBeUndefined() + }) +}) + +describe('setMapData', () => { + test('returns undefined and skips the original when disabled', () => { + expect(MapboxSnap.prototype.setMapData.call({ status: false }, {})).toBeUndefined() + expect(MapboxSnap.__orig.setMapData).not.toHaveBeenCalled() + }) + + test('delegates and shows the layer when features exist and the layer is present', () => { + const map = { getLayer: jest.fn(() => ({})), setLayoutProperty: jest.fn() } + const data = { features: [{}] } + + const result = MapboxSnap.prototype.setMapData.call({ status: true, map }, data) + + expect(MapboxSnap.__orig.setMapData).toHaveBeenCalledWith(data) + expect(result).toBe('orig-setMapData') + expect(map.setLayoutProperty).toHaveBeenCalledWith(SNAP_LAYER, 'visibility', 'visible') + }) + + test('does not show the layer when there are no features', () => { + const map = { getLayer: jest.fn(() => ({})), setLayoutProperty: jest.fn() } + MapboxSnap.prototype.setMapData.call({ status: true, map }, { features: [] }) + expect(map.setLayoutProperty).not.toHaveBeenCalled() + }) + + test('does not show the layer when it is absent', () => { + const map = { getLayer: jest.fn(() => null), setLayoutProperty: jest.fn() } + MapboxSnap.prototype.setMapData.call({ status: true, map }, { features: [{}] }) + expect(map.setLayoutProperty).not.toHaveBeenCalled() + }) + + test('tolerates a missing map and missing data', () => { + expect(MapboxSnap.prototype.setMapData.call({ status: true }, { features: [{}] })).toBe('orig-setMapData') + expect(MapboxSnap.prototype.setMapData.call({ status: true }, undefined)).toBe('orig-setMapData') + }) +}) + +describe('drawingSnapCheck', () => { + test('returns undefined when disabled', () => { + expect(MapboxSnap.prototype.drawingSnapCheck.call({ status: false })).toBeUndefined() + }) + + test('delegates to the original when enabled', () => { + expect(MapboxSnap.prototype.drawingSnapCheck.call({ status: true })).toBe('orig-drawingSnapCheck') + expect(MapboxSnap.__orig.drawingSnapCheck).toHaveBeenCalled() + }) +}) + +describe('getLines', () => { + const call = (self, feature) => MapboxSnap.prototype.getLines.call(self, feature, 'mouse', 5) + + test('returns [] when geometry or coordinates are missing', () => { + expect(call({}, { geometry: null })).toEqual([]) + expect(call({}, { geometry: {} })).toEqual([]) + }) + + test('returns [] when coordinates are not a non-empty array', () => { + expect(call({}, { geometry: { coordinates: 'x' } })).toEqual([]) + expect(call({}, { geometry: { coordinates: [] } })).toEqual([]) + }) + + test('maps MultiPolygon coordinates via turf polygon, filtering empties', () => { + const feature = { geometry: { type: 'MultiPolygon', coordinates: [[[[0, 0]]], []] } } + expect(call({}, feature)).toEqual([{ poly: [[[0, 0]]] }]) + expect(polygon).toHaveBeenCalledWith([[[0, 0]]]) + }) + + test('maps MultiLineString coordinates via turf lineString', () => { + const feature = { geometry: { type: 'MultiLineString', coordinates: [[[0, 0], [1, 1]]] } } + expect(call({}, feature)).toEqual([{ line: [[0, 0], [1, 1]] }]) + expect(lineString).toHaveBeenCalledWith([[0, 0], [1, 1]]) + }) + + test('delegates other geometry types to the original', () => { + const feature = { geometry: { type: 'Polygon', coordinates: [[0, 0]] } } + expect(call({}, feature)).toBe('orig-getLines') + expect(MapboxSnap.__orig.getLines).toHaveBeenCalledWith(feature, 'mouse', 5) + }) + + test('returns [] when turf throws on invalid geometry', () => { + polygon.mockImplementationOnce(() => { throw new Error('bad') }) + const feature = { geometry: { type: 'MultiPolygon', coordinates: [[[[0, 0]]]] } } + expect(call({}, feature)).toEqual([]) + }) +}) + +describe('getCloseFeatures', () => { + test('returns [] when disabled', () => { + expect(MapboxSnap.prototype.getCloseFeatures.call({ status: false }, {}, 1)).toEqual([]) + }) + + test('expands the query to a radius bbox and restores the point', () => { + const map = { getLayer: jest.fn((l) => l === 'layerA') } + const self = { status: true, map, _activeLayers: ['layerA', 'missing'], options: { radius: 10 } } + const e = { point: { x: 100, y: 200 } } + + MapboxSnap.__orig.getCloseFeatures.mockImplementationOnce(function (ev) { + expect(ev.point).toEqual([[90, 190], [110, 210]]) + return 'orig-getCloseFeatures' + }) + + const result = MapboxSnap.prototype.getCloseFeatures.call(self, e, 3) + + expect(self.options.layers).toEqual(['layerA']) + expect(e.point).toEqual({ x: 100, y: 200 }) + expect(result).toBe('orig-getCloseFeatures') + }) + + test('falls back to default layers and the configured radius', () => { + const map = { getLayer: jest.fn(() => true) } + const self = { status: true, map, _activeLayers: null, _defaultLayers: ['b'], options: {} } + const e = { point: { x: 0, y: 0 } } + + MapboxSnap.__orig.getCloseFeatures.mockImplementationOnce(function (ev) { + expect(ev.point).toEqual([[-12, -12], [12, 12]]) + return [] + }) + + MapboxSnap.prototype.getCloseFeatures.call(self, e, 1) + expect(self.options.layers).toEqual(['b']) + }) + + test('handles a completely missing layer configuration', () => { + const map = { getLayer: jest.fn(() => true) } + const self = { status: true, map, options: {} } + const e = { point: { x: 0, y: 0 } } + + MapboxSnap.prototype.getCloseFeatures.call(self, e, 1) + expect(self.options.layers).toEqual([]) + }) +}) + +describe('colour methods', () => { + test('searchInVertex tints the result and passes through falsy results', () => { + expect(MapboxSnap.prototype.searchInVertex.call({}).color).toBe(COLORS.vertex) + MapboxSnap.__orig.searchInVertex.mockReturnValueOnce(null) + expect(MapboxSnap.prototype.searchInVertex.call({})).toBeNull() + }) + + test('searchInMidPoint tints the result and passes through falsy results', () => { + expect(MapboxSnap.prototype.searchInMidPoint.call({}).color).toBe(COLORS.midpoint) + MapboxSnap.__orig.searchInMidPoint.mockReturnValueOnce(null) + expect(MapboxSnap.prototype.searchInMidPoint.call({})).toBeNull() + }) + + test('searchInEdge tints the result and passes through falsy results', () => { + expect(MapboxSnap.prototype.searchInEdge.call({}).color).toBe(COLORS.edge) + MapboxSnap.__orig.searchInEdge.mockReturnValueOnce(null) + expect(MapboxSnap.prototype.searchInEdge.call({})).toBeNull() + }) +}) + +describe('snapToClosestPoint', () => { + test('skips and returns undefined when disabled', () => { + expect(MapboxSnap.prototype.snapToClosestPoint.call({ status: false })).toBeUndefined() + expect(MapboxSnap.__orig.snapToClosestPoint).not.toHaveBeenCalled() + }) + + test('skips while the map is zooming', () => { + expect(MapboxSnap.prototype.snapToClosestPoint.call({ status: true, map: { _isZooming: true } }, {})).toBeUndefined() + }) + + test('delegates and trims oversized caches', () => { + const self = { status: true, map: {}, closeFeatures: new Array(101), lines: new Array(101) } + expect(MapboxSnap.prototype.snapToClosestPoint.call(self, {})).toBe('orig-snap') + expect(self.closeFeatures).toHaveLength(0) + expect(self.lines).toHaveLength(0) + }) + + test('leaves small caches intact', () => { + const self = { status: true, map: {}, closeFeatures: [1], lines: [2] } + MapboxSnap.prototype.snapToClosestPoint.call(self, {}) + expect(self.closeFeatures).toHaveLength(1) + expect(self.lines).toHaveLength(1) + }) + + test('tolerates absent caches and a missing map', () => { + expect(MapboxSnap.prototype.snapToClosestPoint.call({ status: true }, {})).toBe('orig-snap') + }) + + test('clears snap state when the original throws', () => { + MapboxSnap.__orig.snapToClosestPoint.mockImplementationOnce(() => { throw new Error('x') }) + const self = { status: true, map: {}, snapStatus: true, snapCoords: [1, 2] } + + expect(MapboxSnap.prototype.snapToClosestPoint.call(self, {})).toBeUndefined() + expect(self.snapStatus).toBe(false) + expect(self.snapCoords).toBeNull() + }) +}) diff --git a/plugins/beta/draw/src/adapters/maplibre/snap/snapInstance.js b/plugins/beta/draw/src/adapters/maplibre/snap/snapInstance.js new file mode 100644 index 000000000..8ea7da6fd --- /dev/null +++ b/plugins/beta/draw/src/adapters/maplibre/snap/snapInstance.js @@ -0,0 +1,103 @@ +import MapboxSnap from 'mapbox-gl-snap/dist/esm/MapboxSnap.js' +import { patchSourceData } from './sourceData.js' +import { SNAP_HELPER_LAYER } from './constants.js' + +/** Remove any stale snap helper source/layer before creating a new instance */ +function cleanupOldSnap (map) { + if (map.getLayer(SNAP_HELPER_LAYER)) { + map.removeLayer(SNAP_HELPER_LAYER) + } + if (map.getSource(SNAP_HELPER_LAYER)) { + map.removeSource(SNAP_HELPER_LAYER) + } +} + +/** + * Externally-controlled status: ignore library writes so status is only changed + * via setSnapStatus(). The library otherwise sets status=true on mode/selection change. + */ +function defineControlledStatus (snap, initialStatus) { + let controlledStatus = initialStatus + + Object.defineProperty(snap, 'status', { + get () { // nosonar + return controlledStatus + }, + set () { // nosonar + // intentionally empty: library writes are ignored + }, + configurable: true + }) + + snap.setSnapStatus = (value) => { + controlledStatus = value + } +} + +/** Store default snap layers and expose a per-call override setter (null resets) */ +function configureSnapLayers (snap, layers) { + snap._defaultLayers = layers + snap._activeLayers = null + + snap.setSnapLayers = (overrideLayers) => { + if (overrideLayers === null || overrideLayers === undefined) { + snap._activeLayers = null // Use defaults + } else if (Array.isArray(overrideLayers)) { + snap._activeLayers = overrideLayers // Override defaults + } else { + // No action + } + } +} + +/** Create the snap instance once the draw source is available */ +export function createSnapInstance (map, draw, source, config) { + // Prevent duplicate creation (race between initial poll and style.load) + if (map._snapInstance || map._snapCreating) { + return map._snapInstance + } + + map._snapCreating = true + cleanupOldSnap(map) + patchSourceData(source) + + /** @type {any} */ + const snap = new MapboxSnap({ + map, + drawing: draw, + options: { layers: config.layers, radius: config.radius, rules: config.rules }, + status: config.status, + onSnapped: config.onSnapped + }) + + defineControlledStatus(snap, config.status) + configureSnapLayers(snap, config.layers) + + // Apply any pending snap layers that were set before the instance was ready + if (map._pendingSnapLayers !== undefined) { + snap.setSnapLayers(map._pendingSnapLayers) + delete map._pendingSnapLayers + } + + map._snapInstance = snap + return snap +} + +/** Ensure the snap helper source and layer exist after a style change */ +export function ensureSnapLayer (map) { + if (!map.getSource(SNAP_HELPER_LAYER)) { + map.addSource(SNAP_HELPER_LAYER, { + type: 'geojson', + data: { type: 'FeatureCollection', features: [] } + }) + } + if (!map.getLayer(SNAP_HELPER_LAYER)) { + map.addLayer({ + id: SNAP_HELPER_LAYER, + type: 'fill', + source: SNAP_HELPER_LAYER, + paint: { 'fill-color': ['get', 'color'] }, + layout: { visibility: map._snapInstance?.status ? 'visible' : 'none' } + }) + } +} diff --git a/plugins/beta/draw/src/adapters/maplibre/snap/snapInstance.test.js b/plugins/beta/draw/src/adapters/maplibre/snap/snapInstance.test.js new file mode 100644 index 000000000..78dba5496 --- /dev/null +++ b/plugins/beta/draw/src/adapters/maplibre/snap/snapInstance.test.js @@ -0,0 +1,138 @@ +import MapboxSnap from 'mapbox-gl-snap/dist/esm/MapboxSnap.js' +import { patchSourceData } from './sourceData.js' +import { createSnapInstance, ensureSnapLayer } from './snapInstance.js' + +const SNAP_LAYER = 'snap-helper-circle' + +jest.mock('mapbox-gl-snap/dist/esm/MapboxSnap.js', () => ({ + __esModule: true, + default: jest.fn(function (opts) { Object.assign(this, opts) }) +})) + +jest.mock('./sourceData.js', () => ({ patchSourceData: jest.fn() })) + +const config = { layers: ['a'], radius: 12, rules: ['vertex'], status: false, onSnapped: () => {} } + +const makeMap = (overrides = {}) => ({ + getLayer: jest.fn(() => null), + removeLayer: jest.fn(), + getSource: jest.fn(() => null), + removeSource: jest.fn(), + addSource: jest.fn(), + addLayer: jest.fn(), + ...overrides +}) + +beforeEach(() => { + jest.clearAllMocks() +}) + +describe('createSnapInstance', () => { + test('constructs the snap instance, patches the source and stores it on the map', () => { + const map = makeMap() + const draw = { id: 'draw' } + const source = { id: 'src' } + + const snap = createSnapInstance(map, draw, source, config) + + expect(patchSourceData).toHaveBeenCalledWith(source) + expect(MapboxSnap).toHaveBeenCalledWith({ + map, + drawing: draw, + options: { layers: ['a'], radius: 12, rules: ['vertex'] }, + status: false, + onSnapped: config.onSnapped + }) + expect(map._snapInstance).toBe(snap) + }) + + test('returns the existing instance without recreating', () => { + const map = makeMap({ _snapInstance: { id: 'existing' } }) + expect(createSnapInstance(map, {}, {}, config)).toEqual({ id: 'existing' }) + expect(MapboxSnap).not.toHaveBeenCalled() + }) + + test('bails out while another creation is in progress', () => { + const map = makeMap({ _snapCreating: true }) + createSnapInstance(map, {}, {}, config) + expect(MapboxSnap).not.toHaveBeenCalled() + }) + + test('removes a stale snap layer and source before creating', () => { + const map = makeMap({ getLayer: jest.fn(() => ({})), getSource: jest.fn(() => ({})) }) + createSnapInstance(map, {}, {}, config) + expect(map.removeLayer).toHaveBeenCalledWith(SNAP_LAYER) + expect(map.removeSource).toHaveBeenCalledWith(SNAP_LAYER) + }) + + test('exposes an externally-controlled status via setSnapStatus', () => { + const map = makeMap() + const snap = createSnapInstance(map, {}, {}, { ...config, status: false }) + + expect(snap.status).toBe(false) + snap.status = true // library write ignored + expect(snap.status).toBe(false) + snap.setSnapStatus(true) + expect(snap.status).toBe(true) + }) + + test('setSnapLayers overrides, resets and ignores invalid input', () => { + const map = makeMap() + const snap = createSnapInstance(map, {}, {}, { ...config, layers: ['a'] }) + + expect(snap._defaultLayers).toEqual(['a']) + expect(snap._activeLayers).toBeNull() + + snap.setSnapLayers(['b', 'c']) + expect(snap._activeLayers).toEqual(['b', 'c']) + + snap.setSnapLayers(null) + expect(snap._activeLayers).toBeNull() + + snap.setSnapLayers(['d']) + snap.setSnapLayers(undefined) + expect(snap._activeLayers).toBeNull() + + snap.setSnapLayers(['x']) + snap.setSnapLayers('not-an-array') // no action + expect(snap._activeLayers).toEqual(['x']) + }) + + test('applies pending snap layers set before the instance was ready', () => { + const map = makeMap({ _pendingSnapLayers: ['pending'] }) + const snap = createSnapInstance(map, {}, {}, { ...config, layers: ['default'] }) + + expect(snap._activeLayers).toEqual(['pending']) + expect(map._pendingSnapLayers).toBeUndefined() + }) +}) + +describe('ensureSnapLayer', () => { + test('adds the missing source and layer (hidden when snapping is disabled)', () => { + const map = makeMap() + ensureSnapLayer(map) + + expect(map.addSource).toHaveBeenCalledWith(SNAP_LAYER, expect.objectContaining({ type: 'geojson' })) + expect(map.addLayer).toHaveBeenCalledWith(expect.objectContaining({ + id: SNAP_LAYER, + layout: { visibility: 'none' } + })) + }) + + test('shows the layer when snapping is active', () => { + const map = makeMap({ _snapInstance: { status: true } }) + ensureSnapLayer(map) + + expect(map.addLayer).toHaveBeenCalledWith(expect.objectContaining({ + layout: { visibility: 'visible' } + })) + }) + + test('does not re-add an existing source or layer', () => { + const map = makeMap({ getSource: jest.fn(() => ({})), getLayer: jest.fn(() => ({})) }) + ensureSnapLayer(map) + + expect(map.addSource).not.toHaveBeenCalled() + expect(map.addLayer).not.toHaveBeenCalled() + }) +}) diff --git a/plugins/beta/draw/src/adapters/maplibre/snap/sourceData.js b/plugins/beta/draw/src/adapters/maplibre/snap/sourceData.js new file mode 100644 index 000000000..51c4c8d92 --- /dev/null +++ b/plugins/beta/draw/src/adapters/maplibre/snap/sourceData.js @@ -0,0 +1,34 @@ +/** + * Poll until checkFn returns truthy, then call onSuccess with the result. + * A `null` result signals to stop polling; any other falsy value keeps polling. + */ +export function pollUntil (checkFn, onSuccess) { + (function poll () { + const result = checkFn() + if (result === null) { + return + } + result ? onSuccess(result) : requestAnimationFrame(poll) + })() +} + +/** + * Patch a GeoJSON source to expose _data for MapboxSnap compatibility. + * MapboxSnap expects source._data.features but MapLibre doesn't expose this. + */ +export function patchSourceData (source) { + if (!source || (source._data && Array.isArray(source._data?.features))) { + return + } + + let dataCache = { type: 'FeatureCollection', features: [] } + Object.defineProperty(source, '_data', { + get: () => dataCache, + set: (val) => { + dataCache = (val && typeof val === 'object' && Array.isArray(val.features)) + ? val + : { type: 'FeatureCollection', features: [] } + }, + configurable: true + }) +} diff --git a/plugins/beta/draw/src/adapters/maplibre/snap/sourceData.test.js b/plugins/beta/draw/src/adapters/maplibre/snap/sourceData.test.js new file mode 100644 index 000000000..bbd60890f --- /dev/null +++ b/plugins/beta/draw/src/adapters/maplibre/snap/sourceData.test.js @@ -0,0 +1,62 @@ +import { pollUntil, patchSourceData } from './sourceData.js' + +describe('pollUntil', () => { + beforeEach(() => { + global.requestAnimationFrame = jest.fn() + }) + + test('stops when checkFn returns null', () => { + const onSuccess = jest.fn() + pollUntil(() => null, onSuccess) + expect(onSuccess).not.toHaveBeenCalled() + expect(global.requestAnimationFrame).not.toHaveBeenCalled() + }) + + test('calls onSuccess when checkFn returns a truthy value', () => { + const onSuccess = jest.fn() + pollUntil(() => 'ready', onSuccess) + expect(onSuccess).toHaveBeenCalledWith('ready') + }) + + test('reschedules via requestAnimationFrame until the value becomes available', () => { + const values = [undefined, 'ready'] + let i = 0 + global.requestAnimationFrame = jest.fn((cb) => cb()) + const onSuccess = jest.fn() + + pollUntil(() => values[i++], onSuccess) + + expect(global.requestAnimationFrame).toHaveBeenCalledTimes(1) + expect(onSuccess).toHaveBeenCalledWith('ready') + }) +}) + +describe('patchSourceData', () => { + test('ignores a nullish source', () => { + expect(() => patchSourceData(null)).not.toThrow() + }) + + test('ignores a source that already exposes _data.features', () => { + const existing = { features: [] } + const source = { _data: existing } + patchSourceData(source) + expect(source._data).toBe(existing) + }) + + test('installs a _data accessor that normalizes assigned values', () => { + const source = {} + patchSourceData(source) + + expect(source._data).toEqual({ type: 'FeatureCollection', features: [] }) + + const fc = { type: 'FeatureCollection', features: [{ id: 1 }] } + source._data = fc + expect(source._data).toBe(fc) + + source._data = 'nonsense' + expect(source._data).toEqual({ type: 'FeatureCollection', features: [] }) + + source._data = { features: 'not-array' } + expect(source._data).toEqual({ type: 'FeatureCollection', features: [] }) + }) +}) From 7fecf1cdb2ff579ad06ccaa1a38184abf49e7802 Mon Sep 17 00:00:00 2001 From: Dan Leech Date: Fri, 3 Jul 2026 16:29:37 +0100 Subject: [PATCH 26/89] MaplibreDrawAdpater.js tests and event consts --- .../adapters/maplibre/MaplibreDrawAdapter.js | 56 ++- .../maplibre/MaplibreDrawAdapter.test.js | 409 ++++++++++++++++++ .../draw/src/adapters/maplibre/drawEvents.js | 33 ++ 3 files changed, 474 insertions(+), 24 deletions(-) create mode 100644 plugins/beta/draw/src/adapters/maplibre/MaplibreDrawAdapter.test.js create mode 100644 plugins/beta/draw/src/adapters/maplibre/drawEvents.js diff --git a/plugins/beta/draw/src/adapters/maplibre/MaplibreDrawAdapter.js b/plugins/beta/draw/src/adapters/maplibre/MaplibreDrawAdapter.js index c67b858dd..0223a4790 100644 --- a/plugins/beta/draw/src/adapters/maplibre/MaplibreDrawAdapter.js +++ b/plugins/beta/draw/src/adapters/maplibre/MaplibreDrawAdapter.js @@ -1,6 +1,7 @@ import { createMapboxDraw } from './mapboxDraw.js' import { getSnapInstance, clearSnapState, clearSnapIndicator } from './utils/snapHelpers.js' import { createEventBus } from '../../utils/eventBus.js' +import { MAPBOX_DRAW_EVENTS, CUSTOM_DRAW_EVENTS, STYLE_DATA_EVENT } from './drawEvents.js' /** * Draw adapter for MapLibre GL. @@ -52,16 +53,16 @@ export class MaplibreDrawAdapter { styledata: () => this._handleStyleData() } - this._map.on('draw.create', this._mapHandlers.create) - this._map.on('draw.editfinish', this._mapHandlers.editfinish) - this._map.on('draw.cancel', this._mapHandlers.cancel) - this._map.on('draw.vertexselection', this._mapHandlers.vertexselection) - this._map.on('draw.vertexchange', this._mapHandlers.vertexchange) - this._map.on('draw.undochange', this._mapHandlers.undochange) - this._map.on('draw.update', this._mapHandlers.update) - this._map.on('draw.geometrychange', this._mapHandlers.geometrychange) - this._map.on('draw.modechange', this._mapHandlers.modechange) - this._map.on('styledata', this._mapHandlers.styledata) + this._map.on(MAPBOX_DRAW_EVENTS.CREATE, this._mapHandlers.create) + this._map.on(CUSTOM_DRAW_EVENTS.EDIT_FINISH, this._mapHandlers.editfinish) + this._map.on(CUSTOM_DRAW_EVENTS.CANCEL, this._mapHandlers.cancel) + this._map.on(CUSTOM_DRAW_EVENTS.VERTEX_SELECTION, this._mapHandlers.vertexselection) + this._map.on(CUSTOM_DRAW_EVENTS.VERTEX_CHANGE, this._mapHandlers.vertexchange) + this._map.on(CUSTOM_DRAW_EVENTS.UNDO_CHANGE, this._mapHandlers.undochange) + this._map.on(MAPBOX_DRAW_EVENTS.UPDATE, this._mapHandlers.update) + this._map.on(CUSTOM_DRAW_EVENTS.GEOMETRY_CHANGE, this._mapHandlers.geometrychange) + this._map.on(MAPBOX_DRAW_EVENTS.MODE_CHANGE, this._mapHandlers.modechange) + this._map.on(STYLE_DATA_EVENT, this._mapHandlers.styledata) } changeMode (name, options = {}) { @@ -74,14 +75,14 @@ export class MaplibreDrawAdapter { getMode () { return this._draw.getMode() } setInterfaceType (type) { - this._map.fire('draw.interfacetypechange', { interfaceType: type }) + this._map.fire(CUSTOM_DRAW_EVENTS.INTERFACE_TYPE_CHANGE, { interfaceType: type }) } done () { this._mapProvider.undoStack?.clear() const mode = this._draw.getMode() if (mode === 'edit_vertex' && this._editingFeatureId) { - this._map.fire('draw.editfinish', { features: [this._draw.get(this._editingFeatureId)] }) + this._map.fire(CUSTOM_DRAW_EVENTS.EDIT_FINISH, { features: [this._draw.get(this._editingFeatureId)] }) return } if (mode === 'draw_polygon' || mode === 'draw_line') { @@ -96,11 +97,16 @@ export class MaplibreDrawAdapter { } undo () { - this._map.fire('draw.undo') + this._map.fire(CUSTOM_DRAW_EVENTS.UNDO) } deleteVertex () { - // TODO: wire delete-vertex into the ML edit mode (currently keyboard-only in draw-ml) + // Intentionally a no-op on MapLibre. The shared events layer calls draw.deleteVertex() + // from the delete-point button, but the ML edit mode already handles deletion itself: + // via the keyboard (Backspace/Delete) and its own window-click listener matching + // deleteVertexButtonId (see editVertexMode.onButtonClick). Deleting here too would + // double-delete. The OL adapter implements this for real; here it only satisfies the + // shared adapter interface. } get (id) { return this._draw.get(id) } @@ -126,6 +132,8 @@ export class MaplibreDrawAdapter { snap.setSnapLayers(layers) } else if (layers) { this._map._pendingSnapLayers = layers + } else { + // No action } } @@ -155,16 +163,16 @@ export class MaplibreDrawAdapter { } remove () { - this._map.off('draw.create', this._mapHandlers.create) - this._map.off('draw.editfinish', this._mapHandlers.editfinish) - this._map.off('draw.cancel', this._mapHandlers.cancel) - this._map.off('draw.vertexselection', this._mapHandlers.vertexselection) - this._map.off('draw.vertexchange', this._mapHandlers.vertexchange) - this._map.off('draw.undochange', this._mapHandlers.undochange) - this._map.off('draw.update', this._mapHandlers.update) - this._map.off('draw.geometrychange', this._mapHandlers.geometrychange) - this._map.off('draw.modechange', this._mapHandlers.modechange) - this._map.off('styledata', this._mapHandlers.styledata) + this._map.off(MAPBOX_DRAW_EVENTS.CREATE, this._mapHandlers.create) + this._map.off(CUSTOM_DRAW_EVENTS.EDIT_FINISH, this._mapHandlers.editfinish) + this._map.off(CUSTOM_DRAW_EVENTS.CANCEL, this._mapHandlers.cancel) + this._map.off(CUSTOM_DRAW_EVENTS.VERTEX_SELECTION, this._mapHandlers.vertexselection) + this._map.off(CUSTOM_DRAW_EVENTS.VERTEX_CHANGE, this._mapHandlers.vertexchange) + this._map.off(CUSTOM_DRAW_EVENTS.UNDO_CHANGE, this._mapHandlers.undochange) + this._map.off(MAPBOX_DRAW_EVENTS.UPDATE, this._mapHandlers.update) + this._map.off(CUSTOM_DRAW_EVENTS.GEOMETRY_CHANGE, this._mapHandlers.geometrychange) + this._map.off(MAPBOX_DRAW_EVENTS.MODE_CHANGE, this._mapHandlers.modechange) + this._map.off(STYLE_DATA_EVENT, this._mapHandlers.styledata) this._cleanupDraw() } } diff --git a/plugins/beta/draw/src/adapters/maplibre/MaplibreDrawAdapter.test.js b/plugins/beta/draw/src/adapters/maplibre/MaplibreDrawAdapter.test.js new file mode 100644 index 000000000..473f87c15 --- /dev/null +++ b/plugins/beta/draw/src/adapters/maplibre/MaplibreDrawAdapter.test.js @@ -0,0 +1,409 @@ +import { createMapboxDraw } from './mapboxDraw.js' +import { getSnapInstance, clearSnapState, clearSnapIndicator } from './utils/snapHelpers.js' +import { createEventBus } from '../../utils/eventBus.js' +import { MAPBOX_DRAW_EVENTS, CUSTOM_DRAW_EVENTS, STYLE_DATA_EVENT } from './drawEvents.js' +import { MaplibreDrawAdapter } from './MaplibreDrawAdapter.js' + +jest.mock('./mapboxDraw.js', () => ({ createMapboxDraw: jest.fn() })) +jest.mock('./utils/snapHelpers.js', () => ({ + getSnapInstance: jest.fn(), + clearSnapState: jest.fn(), + clearSnapIndicator: jest.fn() +})) +jest.mock('../../utils/eventBus.js', () => ({ createEventBus: jest.fn() })) + +const SNAP_LAYER = 'snap-helper-circle' + +const onHandler = (map, event) => map.on.mock.calls.find(([name]) => name === event)?.[1] + +const setup = () => { + const map = { + on: jest.fn(), + off: jest.fn(), + fire: jest.fn(), + getLayer: jest.fn(() => null), + setLayoutProperty: jest.fn(), + getStyle: jest.fn(() => ({ layers: [] })), + moveLayer: jest.fn() + } + const undoStack = { clear: jest.fn() } + const mapProvider = { map, undoStack, snapEnabled: false } + const draw = { + changeMode: jest.fn(), + getMode: jest.fn(() => 'disabled'), + get: jest.fn((id) => ({ id })), + add: jest.fn(), + delete: jest.fn(), + deleteAll: jest.fn(), + trash: jest.fn(), + setFeatureProperty: jest.fn() + } + const removeDraw = jest.fn() + createMapboxDraw.mockReturnValue({ draw, remove: removeDraw }) + + const bus = { on: jest.fn(), off: jest.fn(), emit: jest.fn() } + createEventBus.mockReturnValue(bus) + + const options = { + mapStyle: 'light', + events: { MAP_SET_STYLE: 'mss' }, + eventBus: { on: jest.fn() }, + snapLayers: ['layer-a'] + } + const adapter = new MaplibreDrawAdapter(mapProvider, options) + + return { adapter, map, mapProvider, draw, removeDraw, bus, undoStack, options } +} + +beforeEach(() => { + jest.clearAllMocks() +}) + +describe('construction', () => { + test('creates the MapboxDraw control with the provided options', () => { + const { options } = setup() + expect(createMapboxDraw).toHaveBeenCalledWith({ + mapStyle: 'light', + mapProvider: expect.any(Object), + events: options.events, + eventBus: options.eventBus, + snapLayers: ['layer-a'] + }) + }) + + test('subscribes to every MapLibre draw event', () => { + const { map } = setup() + const subscribed = map.on.mock.calls.map(([name]) => name) + expect(subscribed).toEqual(expect.arrayContaining([ + MAPBOX_DRAW_EVENTS.CREATE, MAPBOX_DRAW_EVENTS.UPDATE, MAPBOX_DRAW_EVENTS.MODE_CHANGE, + CUSTOM_DRAW_EVENTS.EDIT_FINISH, CUSTOM_DRAW_EVENTS.CANCEL, CUSTOM_DRAW_EVENTS.VERTEX_SELECTION, + CUSTOM_DRAW_EVENTS.VERTEX_CHANGE, CUSTOM_DRAW_EVENTS.UNDO_CHANGE, CUSTOM_DRAW_EVENTS.GEOMETRY_CHANGE, + STYLE_DATA_EVENT + ])) + }) +}) + +describe('map event normalisation', () => { + test('create/editfinish/update forward the first feature', () => { + const { map, bus } = setup() + const feature = { id: 'f1' } + + onHandler(map, MAPBOX_DRAW_EVENTS.CREATE)({ features: [feature] }) + onHandler(map, CUSTOM_DRAW_EVENTS.EDIT_FINISH)({ features: [feature] }) + onHandler(map, MAPBOX_DRAW_EVENTS.UPDATE)({ features: [feature] }) + + expect(bus.emit).toHaveBeenCalledWith('create', feature) + expect(bus.emit).toHaveBeenCalledWith('editfinish', feature) + expect(bus.emit).toHaveBeenCalledWith('update', feature) + }) + + test('cancel forwards with no payload', () => { + const { map, bus } = setup() + onHandler(map, CUSTOM_DRAW_EVENTS.CANCEL)() + expect(bus.emit).toHaveBeenCalledWith('cancel') + }) + + test('vertexselection/vertexchange normalise the numVertecies typo', () => { + const { map, bus } = setup() + + onHandler(map, CUSTOM_DRAW_EVENTS.VERTEX_SELECTION)({ numVertecies: 3, index: 1 }) + onHandler(map, CUSTOM_DRAW_EVENTS.VERTEX_CHANGE)({ numVertecies: 2 }) + + expect(bus.emit).toHaveBeenCalledWith('vertexselection', expect.objectContaining({ numVertices: 3, index: 1 })) + expect(bus.emit).toHaveBeenCalledWith('vertexchange', expect.objectContaining({ numVertices: 2 })) + }) + + test('undochange forwards the stack length', () => { + const { map, bus } = setup() + onHandler(map, CUSTOM_DRAW_EVENTS.UNDO_CHANGE)({ length: 4 }) + expect(bus.emit).toHaveBeenCalledWith('undochange', 4) + }) + + test('geometrychange forwards the raw event', () => { + const { map, bus } = setup() + const e = { type: 'Polygon' } + onHandler(map, CUSTOM_DRAW_EVENTS.GEOMETRY_CHANGE)(e) + expect(bus.emit).toHaveBeenCalledWith('geometrychange', e) + }) +}) + +describe('changeMode', () => { + test('records the editing feature id when entering edit_vertex', () => { + const { adapter, draw } = setup() + adapter.changeMode('edit_vertex', { featureId: 'f9' }) + expect(draw.changeMode).toHaveBeenCalledWith('edit_vertex', { featureId: 'f9' }) + }) + + test('defaults the editing feature id to null when omitted', () => { + const { adapter, draw } = setup() + adapter.changeMode('edit_vertex', {}) + draw.getMode.mockReturnValue('edit_vertex') + adapter.done() + // no editing feature id → no editfinish fired + expect(draw.changeMode).toHaveBeenCalledWith('edit_vertex', {}) + }) + + test('passes through non-edit modes with default options', () => { + const { adapter, draw } = setup() + adapter.changeMode('draw_polygon') + expect(draw.changeMode).toHaveBeenCalledWith('draw_polygon', {}) + }) +}) + +describe('simple delegations', () => { + test('getMode delegates to the draw control', () => { + const { adapter, draw } = setup() + draw.getMode.mockReturnValue('draw_line') + expect(adapter.getMode()).toBe('draw_line') + }) + + test('setInterfaceType fires the interface-type-change event', () => { + const { adapter, map } = setup() + adapter.setInterfaceType('keyboard') + expect(map.fire).toHaveBeenCalledWith(CUSTOM_DRAW_EVENTS.INTERFACE_TYPE_CHANGE, { interfaceType: 'keyboard' }) + }) + + test('undo fires the undo event', () => { + const { adapter, map } = setup() + adapter.undo() + expect(map.fire).toHaveBeenCalledWith(CUSTOM_DRAW_EVENTS.UNDO) + }) + + test('deleteVertex is a no-op', () => { + const { adapter, draw, map } = setup() + expect(() => adapter.deleteVertex()).not.toThrow() + expect(draw.changeMode).not.toHaveBeenCalled() + expect(map.fire).not.toHaveBeenCalled() + }) + + test('feature store methods delegate to the draw control', () => { + const { adapter, draw } = setup() + adapter.get('a') + adapter.add({ id: 'b' }) + adapter.delete('c') + adapter.deleteAll() + adapter.setFeatureProperty('d', 'p', 1) + + expect(draw.get).toHaveBeenCalledWith('a') + expect(draw.add).toHaveBeenCalledWith({ id: 'b' }) + expect(draw.delete).toHaveBeenCalledWith('c') + expect(draw.deleteAll).toHaveBeenCalled() + expect(draw.setFeatureProperty).toHaveBeenCalledWith('d', 'p', 1) + }) + + test('on/off delegate to the internal event bus', () => { + const { adapter, bus } = setup() + const handler = jest.fn() + adapter.on('create', handler) + adapter.off('create', handler) + expect(bus.on).toHaveBeenCalledWith('create', handler) + expect(bus.off).toHaveBeenCalledWith('create', handler) + }) + + test('isSnapEnabled reflects the provider flag', () => { + const { adapter, mapProvider } = setup() + expect(adapter.isSnapEnabled()).toBe(false) + mapProvider.snapEnabled = true + expect(adapter.isSnapEnabled()).toBe(true) + }) +}) + +describe('done', () => { + test('clears the undo stack and fires editfinish when editing a vertex', () => { + const { adapter, map, draw, undoStack } = setup() + adapter.changeMode('edit_vertex', { featureId: 'f1' }) + draw.getMode.mockReturnValue('edit_vertex') + + adapter.done() + + expect(undoStack.clear).toHaveBeenCalled() + expect(map.fire).toHaveBeenCalledWith(CUSTOM_DRAW_EVENTS.EDIT_FINISH, { features: [{ id: 'f1' }] }) + expect(draw.changeMode).not.toHaveBeenCalledWith('disabled') + }) + + test('disables the control when finishing a draw mode', () => { + const { adapter, draw } = setup() + draw.getMode.mockReturnValue('draw_polygon') + adapter.done() + expect(draw.changeMode).toHaveBeenCalledWith('disabled') + + draw.getMode.mockReturnValue('draw_line') + adapter.done() + expect(draw.changeMode).toHaveBeenCalledWith('disabled') + }) + + test('does nothing further for edit_vertex without an editing feature id', () => { + const { adapter, draw, map } = setup() + draw.getMode.mockReturnValue('edit_vertex') + adapter.done() + expect(map.fire).not.toHaveBeenCalled() + expect(draw.changeMode).not.toHaveBeenCalled() + }) +}) + +describe('cancel', () => { + test('clears the undo stack, trashes and disables the control', () => { + const { adapter, draw, undoStack } = setup() + adapter.cancel() + expect(undoStack.clear).toHaveBeenCalled() + expect(draw.trash).toHaveBeenCalled() + expect(draw.changeMode).toHaveBeenCalledWith('disabled') + }) +}) + +describe('setSnapEnabled', () => { + test('enables snapping via the snap instance', () => { + const { adapter, mapProvider } = setup() + const snap = { setSnapStatus: jest.fn() } + getSnapInstance.mockReturnValue(snap) + + adapter.setSnapEnabled(true) + + expect(mapProvider.snapEnabled).toBe(true) + expect(snap.setSnapStatus).toHaveBeenCalledWith(true) + expect(clearSnapState).not.toHaveBeenCalled() + }) + + test('disabling clears snap state and hides the indicator when present', () => { + const { adapter, map } = setup() + const snap = { setSnapStatus: jest.fn() } + getSnapInstance.mockReturnValue(snap) + map.getLayer.mockReturnValue({ id: SNAP_LAYER }) + + adapter.setSnapEnabled(false) + + expect(snap.setSnapStatus).toHaveBeenCalledWith(false) + expect(clearSnapState).toHaveBeenCalledWith(snap) + expect(map.setLayoutProperty).toHaveBeenCalledWith(SNAP_LAYER, 'visibility', 'none') + }) + + test('disabling without the indicator layer skips the layout update', () => { + const { adapter, map } = setup() + const snap = { setSnapStatus: jest.fn() } + getSnapInstance.mockReturnValue(snap) + map.getLayer.mockReturnValue(null) + + adapter.setSnapEnabled(false) + + expect(clearSnapState).toHaveBeenCalledWith(snap) + expect(map.setLayoutProperty).not.toHaveBeenCalled() + }) + + test('tolerates a missing snap instance', () => { + const { adapter, mapProvider } = setup() + getSnapInstance.mockReturnValue(null) + + adapter.setSnapEnabled(false) + + expect(mapProvider.snapEnabled).toBe(false) + expect(clearSnapState).not.toHaveBeenCalled() + }) + + test('tolerates a snap instance without setSnapStatus', () => { + const { adapter } = setup() + getSnapInstance.mockReturnValue({}) + expect(() => adapter.setSnapEnabled(true)).not.toThrow() + }) +}) + +describe('setSnapLayers', () => { + test('forwards layers to the snap instance when available', () => { + const { adapter } = setup() + const snap = { setSnapLayers: jest.fn() } + getSnapInstance.mockReturnValue(snap) + + adapter.setSnapLayers(['a', 'b']) + expect(snap.setSnapLayers).toHaveBeenCalledWith(['a', 'b']) + }) + + test('stashes pending layers when the instance is not ready', () => { + const { adapter, map } = setup() + getSnapInstance.mockReturnValue(null) + + adapter.setSnapLayers(['a']) + expect(map._pendingSnapLayers).toEqual(['a']) + }) + + test('does nothing when there is no instance and no layers', () => { + const { adapter, map } = setup() + getSnapInstance.mockReturnValue(null) + + adapter.setSnapLayers(null) + expect(map._pendingSnapLayers).toBeUndefined() + }) +}) + +describe('_handleModeChange', () => { + test('clears the snap indicator when leaving a draw mode', () => { + const { map } = setup() + const snap = { id: 'snap' } + getSnapInstance.mockReturnValue(snap) + + onHandler(map, MAPBOX_DRAW_EVENTS.MODE_CHANGE)({ mode: 'edit_vertex' }) + + expect(clearSnapIndicator).toHaveBeenCalledWith(snap, map) + }) + + test('keeps the snap indicator while in a draw mode', () => { + const { map } = setup() + onHandler(map, MAPBOX_DRAW_EVENTS.MODE_CHANGE)({ mode: 'draw_polygon' }) + expect(clearSnapIndicator).not.toHaveBeenCalled() + }) +}) + +describe('_handleStyleData', () => { + test('does nothing when there are no layers', () => { + const { map } = setup() + map.getStyle.mockReturnValue({ layers: undefined }) + onHandler(map, STYLE_DATA_EVENT)() + expect(map.moveLayer).not.toHaveBeenCalled() + }) + + test('does nothing when a draw layer is already on top', () => { + const { map } = setup() + map.getStyle.mockReturnValue({ layers: [{ id: 'a', source: 'bg' }, { id: 'd', source: 'mapbox-gl-draw-hot' }] }) + onHandler(map, STYLE_DATA_EVENT)() + expect(map.moveLayer).not.toHaveBeenCalled() + }) + + test('moves draw layers back to the top when covered', () => { + const { map } = setup() + map.getStyle.mockReturnValue({ + layers: [ + { id: 'd1', source: 'mapbox-gl-draw-hot' }, + { id: 'd2', source: 'mapbox-gl-draw-cold' }, + { id: 'top', source: 'other' } + ] + }) + + onHandler(map, STYLE_DATA_EVENT)() + + expect(map.moveLayer).toHaveBeenCalledWith('d1') + expect(map.moveLayer).toHaveBeenCalledWith('d2') + expect(map.moveLayer).not.toHaveBeenCalledWith('top') + }) + + test('tolerates layers without a source', () => { + const { map } = setup() + map.getStyle.mockReturnValue({ layers: [{ id: 'd', source: 'mapbox-gl-draw-hot' }, { id: 'nosrc' }] }) + onHandler(map, STYLE_DATA_EVENT)() + expect(map.moveLayer).toHaveBeenCalledWith('d') + }) +}) + +describe('remove', () => { + test('unsubscribes from every event and cleans up the draw control', () => { + const { adapter, map, removeDraw } = setup() + + adapter.remove() + + const unsubscribed = map.off.mock.calls.map(([name]) => name) + expect(unsubscribed).toEqual(expect.arrayContaining([ + MAPBOX_DRAW_EVENTS.CREATE, MAPBOX_DRAW_EVENTS.UPDATE, MAPBOX_DRAW_EVENTS.MODE_CHANGE, + CUSTOM_DRAW_EVENTS.EDIT_FINISH, CUSTOM_DRAW_EVENTS.CANCEL, CUSTOM_DRAW_EVENTS.VERTEX_SELECTION, + CUSTOM_DRAW_EVENTS.VERTEX_CHANGE, CUSTOM_DRAW_EVENTS.UNDO_CHANGE, CUSTOM_DRAW_EVENTS.GEOMETRY_CHANGE, + STYLE_DATA_EVENT + ])) + expect(removeDraw).toHaveBeenCalled() + }) +}) diff --git a/plugins/beta/draw/src/adapters/maplibre/drawEvents.js b/plugins/beta/draw/src/adapters/maplibre/drawEvents.js new file mode 100644 index 000000000..e0e927e58 --- /dev/null +++ b/plugins/beta/draw/src/adapters/maplibre/drawEvents.js @@ -0,0 +1,33 @@ +/** + * Event names used by the MapLibre draw adapter, grouped by origin. + * + * - MAPBOX_DRAW_EVENTS: stock events fired by @mapbox/mapbox-gl-draw itself. + * - CUSTOM_DRAW_EVENTS: events this codebase's custom modes / adapter dispatch on the map + * (also under the `draw.*` namespace). + * - STYLE_DATA_EVENT: the one native MapLibre map event the adapter listens to (to keep draw + * layers on top after a style reload). + * + * The adapter normalises all of these onto the shared, framework-agnostic event bus. + */ + +// Stock @mapbox/mapbox-gl-draw events, fired by the library itself. +export const MAPBOX_DRAW_EVENTS = { + CREATE: 'draw.create', + UPDATE: 'draw.update', + MODE_CHANGE: 'draw.modechange' +} + +// Custom draw events dispatched by this codebase's modes / adapter. +export const CUSTOM_DRAW_EVENTS = { + EDIT_FINISH: 'draw.editfinish', + CANCEL: 'draw.cancel', + VERTEX_SELECTION: 'draw.vertexselection', + VERTEX_CHANGE: 'draw.vertexchange', + UNDO_CHANGE: 'draw.undochange', + UNDO: 'draw.undo', + GEOMETRY_CHANGE: 'draw.geometrychange', + INTERFACE_TYPE_CHANGE: 'draw.interfacetypechange' +} + +// Native MapLibre map event (not a draw event) — fires whenever the map style data changes. +export const STYLE_DATA_EVENT = 'styledata' From c5bfcd3e62318126f4319039f380961f5fc3001e Mon Sep 17 00:00:00 2001 From: Dan Leech Date: Fri, 3 Jul 2026 16:34:54 +0100 Subject: [PATCH 27/89] styles.js tests added --- .../beta/draw/src/adapters/maplibre/styles.js | 6 +- .../draw/src/adapters/maplibre/styles.test.js | 114 ++++++++++++++++++ 2 files changed, 117 insertions(+), 3 deletions(-) create mode 100644 plugins/beta/draw/src/adapters/maplibre/styles.test.js diff --git a/plugins/beta/draw/src/adapters/maplibre/styles.js b/plugins/beta/draw/src/adapters/maplibre/styles.js index ce4cebfa8..d6102769f 100755 --- a/plugins/beta/draw/src/adapters/maplibre/styles.js +++ b/plugins/beta/draw/src/adapters/maplibre/styles.js @@ -4,7 +4,7 @@ import { getValueForStyle } from '../../utils/getValueForStyle.js' const getColorScheme = (mapStyle) => mapStyle.mapColorScheme ?? 'light' -const getUserProp = (mapStyle, prop, defaultsKey = prop) => [ +const getUserProp = (mapStyle, prop, defaultsKey) => [ 'coalesce', ['get', `user_${prop}${mapStyle.id.charAt(0).toUpperCase() + mapStyle.id.slice(1)}`], ['get', `user_${prop}`], @@ -55,7 +55,7 @@ const drawInvalidSplitter = (splitInvalidColor) => ({ paint: { 'line-color': splitInvalidColor, 'line-width': 2, - 'line-dasharray': [0.2, 2], + 'line-dasharray': [0.2, 2], // NOSONAR 'line-opacity': 1 } }) @@ -78,7 +78,7 @@ const drawPreviewLine = (editStrokeColor) => ({ type: 'line', filter: ['all', ['==', '$type', 'LineString'], ['==', 'active', 'true'], ['!has', 'user_splitter']], layout: { 'line-cap': 'round', 'line-join': 'round' }, - paint: { 'line-color': editStrokeColor, 'line-width': 2, 'line-dasharray': [0.2, 2], 'line-opacity': 1 } + paint: { 'line-color': editStrokeColor, 'line-width': 2, 'line-dasharray': [0.2, 2], 'line-opacity': 1 } // NOSONAR }) // Vertex layers ('draw-vertex' = display-only markers on placed vertices while drawing) diff --git a/plugins/beta/draw/src/adapters/maplibre/styles.test.js b/plugins/beta/draw/src/adapters/maplibre/styles.test.js new file mode 100644 index 000000000..f6bdf0e83 --- /dev/null +++ b/plugins/beta/draw/src/adapters/maplibre/styles.test.js @@ -0,0 +1,114 @@ +import { createDrawStyles, updateDrawStyles } from './styles.js' +import { COLORS, SIZES } from './defaults.js' +import { getValueForStyle } from '../../utils/getValueForStyle.js' + +const findLayer = (layers, id) => layers.find((l) => l.id === id) + +describe('createDrawStyles', () => { + const mapStyle = { id: 'outdoor', mapColorScheme: 'light' } + + test('returns every draw layer in order', () => { + const layers = createDrawStyles(mapStyle) + expect(layers.map((l) => l.id)).toEqual([ + 'fill-inactive', 'fill-active', 'stroke-active', 'stroke-inactive', + 'stroke-invalid-splitter', 'stroke-valid-splitter', 'stroke-preview-line', + 'midpoint', 'midpoint-halo', 'midpoint-active', + 'vertex', 'vertex-halo', 'vertex-active', 'circle', 'touch-vertex-indicator' + ]) + }) + + test('resolves light-scheme colours', () => { + const layers = createDrawStyles({ id: 'outdoor', mapColorScheme: 'light' }) + expect(findLayer(layers, 'stroke-active').paint['line-color']).toBe(getValueForStyle(COLORS.editStroke, 'light')) + expect(findLayer(layers, 'fill-active').paint['fill-color']).toBe(getValueForStyle(COLORS.editFill, 'light')) + expect(findLayer(layers, 'vertex').paint['circle-color']).toBe(getValueForStyle(COLORS.editVertex, 'light')) + expect(findLayer(layers, 'midpoint').paint['circle-color']).toBe(getValueForStyle(COLORS.editMidpoint, 'light')) + }) + + test('resolves dark-scheme colours', () => { + const layers = createDrawStyles({ id: 'night', mapColorScheme: 'dark' }) + expect(findLayer(layers, 'stroke-active').paint['line-color']).toBe(getValueForStyle(COLORS.editStroke, 'dark')) + expect(findLayer(layers, 'vertex-halo').paint['circle-color']).toBe(getValueForStyle(COLORS.editHalo, 'dark')) + expect(findLayer(layers, 'vertex-halo').paint['circle-stroke-color']).toBe(getValueForStyle(COLORS.editActive, 'dark')) + }) + + test('defaults to the light scheme when none is provided', () => { + const layers = createDrawStyles({ id: 'outdoor' }) + expect(findLayer(layers, 'stroke-active').paint['line-color']).toBe(getValueForStyle(COLORS.editStroke, 'light')) + }) + + test('applies the configured sizes', () => { + const layers = createDrawStyles(mapStyle) + expect(findLayer(layers, 'vertex').paint['circle-radius']).toBe(SIZES.vertexRadius) + expect(findLayer(layers, 'vertex-halo').paint['circle-radius']).toBe(SIZES.vertexHaloRadius) + expect(findLayer(layers, 'midpoint').paint['circle-radius']).toBe(SIZES.midpointRadius) + expect(findLayer(layers, 'midpoint-halo').paint['circle-radius']).toBe(SIZES.midpointHaloRadius) + expect(findLayer(layers, 'stroke-inactive').paint['line-width']).toBe(SIZES.strokeWidth) + }) + + test('builds per-style user-property coalesce expressions for inactive fill and stroke', () => { + const layers = createDrawStyles({ id: 'outdoor', mapColorScheme: 'light' }) + + expect(findLayer(layers, 'fill-inactive').paint['fill-color']).toEqual([ + 'coalesce', + ['get', 'user_fillOutdoor'], + ['get', 'user_fill'], + COLORS.shapeFill + ]) + expect(findLayer(layers, 'stroke-inactive').paint['line-color']).toEqual([ + 'coalesce', + ['get', 'user_strokeOutdoor'], + ['get', 'user_stroke'], + COLORS.shapeStroke + ]) + }) + + test('capitalises the style id in the user-property key', () => { + const layers = createDrawStyles({ id: 'satellite', mapColorScheme: 'light' }) + expect(findLayer(layers, 'fill-inactive').paint['fill-color'][1]).toEqual(['get', 'user_fillSatellite']) + }) + + test('colours the splitter and touch-indicator layers', () => { + const layers = createDrawStyles(mapStyle) + expect(findLayer(layers, 'stroke-invalid-splitter').paint['line-color']).toBe(getValueForStyle(COLORS.splitInvalid, 'light')) + expect(findLayer(layers, 'stroke-valid-splitter').paint['line-color']).toBe(getValueForStyle(COLORS.splitValid, 'light')) + expect(findLayer(layers, 'touch-vertex-indicator').paint['circle-color']).toBe('#3bb2d0') + }) +}) + +describe('updateDrawStyles', () => { + const mapStyle = { id: 'outdoor', mapColorScheme: 'light' } + + test('applies paint properties to both the cold and hot copies of each layer', () => { + const map = { getLayer: jest.fn(() => true), setPaintProperty: jest.fn() } + + updateDrawStyles(map, mapStyle) + + expect(map.setPaintProperty).toHaveBeenCalledWith('fill-inactive.cold', 'fill-color', expect.anything()) + expect(map.setPaintProperty).toHaveBeenCalledWith('fill-inactive.hot', 'fill-color', expect.anything()) + expect(map.setPaintProperty).toHaveBeenCalledWith('vertex.cold', 'circle-radius', SIZES.vertexRadius) + expect(map.setPaintProperty).toHaveBeenCalledWith('vertex.hot', 'circle-color', expect.anything()) + }) + + test('skips layers that are not present on the map', () => { + const map = { getLayer: jest.fn(() => false), setPaintProperty: jest.fn() } + + updateDrawStyles(map, mapStyle) + + expect(map.setPaintProperty).not.toHaveBeenCalled() + }) + + test('updates only the cold layer when the hot copy is absent', () => { + const map = { + getLayer: jest.fn((id) => id.endsWith('.cold')), + setPaintProperty: jest.fn() + } + + updateDrawStyles(map, mapStyle) + + const targets = map.setPaintProperty.mock.calls.map(([id]) => id) + expect(targets.length).toBeGreaterThan(0) + expect(targets.every((id) => id.endsWith('.cold'))).toBe(true) + expect(targets.some((id) => id.endsWith('.hot'))).toBe(false) + }) +}) From 346d5741d6690c2ee0827c1843ffbcaa64812355 Mon Sep 17 00:00:00 2001 From: Dan Leech Date: Fri, 3 Jul 2026 16:36:57 +0100 Subject: [PATCH 28/89] loadDrawAdapter.js tests added --- .../draw/src/adapters/loadDrawAdapter.test.js | 53 +++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 plugins/beta/draw/src/adapters/loadDrawAdapter.test.js diff --git a/plugins/beta/draw/src/adapters/loadDrawAdapter.test.js b/plugins/beta/draw/src/adapters/loadDrawAdapter.test.js new file mode 100644 index 000000000..3ca6117c1 --- /dev/null +++ b/plugins/beta/draw/src/adapters/loadDrawAdapter.test.js @@ -0,0 +1,53 @@ +import { loadDrawAdapter } from './loadDrawAdapter.js' +import { MaplibreDrawAdapter } from './maplibre/MaplibreDrawAdapter.js' +import { OLDrawAdapter } from './openlayers/OLDrawAdapter.js' + +jest.mock('./maplibre/MaplibreDrawAdapter.js', () => ({ + MaplibreDrawAdapter: jest.fn(function (mapProvider, options) { + this.mapProvider = mapProvider + this.options = options + }) +})) + +jest.mock('./openlayers/OLDrawAdapter.js', () => ({ + OLDrawAdapter: jest.fn(function (mapProvider, options) { + this.mapProvider = mapProvider + this.options = options + }) +})) + +beforeEach(() => { + jest.clearAllMocks() +}) + +describe('loadDrawAdapter', () => { + test('loads the MapLibre adapter for a MapLibreProvider', async () => { + const mapProvider = { name: 'MapLibreProvider' } + const options = { mapStyle: 'light' } + + const adapter = await loadDrawAdapter(mapProvider, options) + + expect(MaplibreDrawAdapter).toHaveBeenCalledWith(mapProvider, options) + expect(OLDrawAdapter).not.toHaveBeenCalled() + expect(adapter).toBeInstanceOf(MaplibreDrawAdapter) + }) + + test('loads the OpenLayers adapter for an OpenLayersProvider', async () => { + const mapProvider = { name: 'OpenLayersProvider' } + const options = { mapStyle: 'dark' } + + const adapter = await loadDrawAdapter(mapProvider, options) + + expect(OLDrawAdapter).toHaveBeenCalledWith(mapProvider, options) + expect(MaplibreDrawAdapter).not.toHaveBeenCalled() + expect(adapter).toBeInstanceOf(OLDrawAdapter) + }) + + test('throws for an unsupported map provider', async () => { + await expect(loadDrawAdapter({ name: 'SomethingElse' }, {})) + .rejects.toThrow('No draw adapter available for map provider "SomethingElse"') + + expect(MaplibreDrawAdapter).not.toHaveBeenCalled() + expect(OLDrawAdapter).not.toHaveBeenCalled() + }) +}) From 0cbc07c5fa24960cf8d60044c867a0d284588f0d Mon Sep 17 00:00:00 2001 From: Dan Leech Date: Fri, 3 Jul 2026 16:45:38 +0100 Subject: [PATCH 29/89] API method tests added --- plugins/beta/draw/src/api/addFeature.test.js | 41 ++++++ .../beta/draw/src/api/deleteFeature.test.js | 24 ++++ plugins/beta/draw/src/api/editFeature.js | 2 +- plugins/beta/draw/src/api/editFeature.test.js | 98 +++++++++++++ plugins/beta/draw/src/api/merge.js | 2 +- plugins/beta/draw/src/api/merge.test.js | 13 ++ plugins/beta/draw/src/api/newLine.js | 2 +- plugins/beta/draw/src/api/newLine.test.js | 78 ++++++++++ plugins/beta/draw/src/api/newPolygon.js | 2 +- plugins/beta/draw/src/api/newPolygon.test.js | 78 ++++++++++ plugins/beta/draw/src/api/split.test.js | 136 ++++++++++++++++++ 11 files changed, 472 insertions(+), 4 deletions(-) create mode 100644 plugins/beta/draw/src/api/addFeature.test.js create mode 100644 plugins/beta/draw/src/api/deleteFeature.test.js create mode 100644 plugins/beta/draw/src/api/editFeature.test.js create mode 100644 plugins/beta/draw/src/api/merge.test.js create mode 100644 plugins/beta/draw/src/api/newLine.test.js create mode 100644 plugins/beta/draw/src/api/newPolygon.test.js create mode 100644 plugins/beta/draw/src/api/split.test.js diff --git a/plugins/beta/draw/src/api/addFeature.test.js b/plugins/beta/draw/src/api/addFeature.test.js new file mode 100644 index 000000000..92de83eb2 --- /dev/null +++ b/plugins/beta/draw/src/api/addFeature.test.js @@ -0,0 +1,41 @@ +import { addFeature } from './addFeature.js' +import { flattenStyleProperties } from '../utils/flattenStyleProperties.js' + +jest.mock('../utils/flattenStyleProperties.js', () => ({ + flattenStyleProperties: jest.fn(() => ({ _flat: true })) +})) + +const setup = (draw) => { + const eventBus = { emit: jest.fn() } + return { context: { mapProvider: { draw }, services: { eventBus } }, eventBus } +} + +beforeEach(() => jest.clearAllMocks()) + +describe('addFeature', () => { + test('does nothing when there is no draw instance', () => { + const { context, eventBus } = setup(undefined) + addFeature(context, { id: 'a' }) + expect(eventBus.emit).not.toHaveBeenCalled() + }) + + test('flattens style properties and adds the feature', () => { + const draw = { add: jest.fn() } + const { context, eventBus } = setup(draw) + const feature = { id: 'a', geometry: {}, stroke: 'red', fill: 'blue', strokeWidth: 2, properties: { name: 'x' } } + + addFeature(context, feature) + + expect(flattenStyleProperties).toHaveBeenCalledWith({ stroke: 'red', fill: 'blue', strokeWidth: 2 }) + const expected = { id: 'a', geometry: {}, properties: { name: 'x', _flat: true } } + expect(draw.add).toHaveBeenCalledWith(expected) + expect(eventBus.emit).toHaveBeenCalledWith('draw:add', expected) + }) + + test('handles a feature without existing properties', () => { + const draw = { add: jest.fn() } + const { context } = setup(draw) + addFeature(context, { id: 'b', geometry: {} }) + expect(draw.add).toHaveBeenCalledWith({ id: 'b', geometry: {}, properties: { _flat: true } }) + }) +}) diff --git a/plugins/beta/draw/src/api/deleteFeature.test.js b/plugins/beta/draw/src/api/deleteFeature.test.js new file mode 100644 index 000000000..948c43345 --- /dev/null +++ b/plugins/beta/draw/src/api/deleteFeature.test.js @@ -0,0 +1,24 @@ +import { deleteFeature } from './deleteFeature.js' + +const setup = (draw) => { + const eventBus = { emit: jest.fn() } + return { context: { mapProvider: { draw }, services: { eventBus } }, eventBus } +} + +describe('deleteFeature', () => { + test('does nothing when there is no draw instance', () => { + const { context, eventBus } = setup(undefined) + deleteFeature(context, 'id1') + expect(eventBus.emit).not.toHaveBeenCalled() + }) + + test('deletes the feature and emits the delete event', () => { + const draw = { delete: jest.fn() } + const { context, eventBus } = setup(draw) + + deleteFeature(context, 'id1') + + expect(draw.delete).toHaveBeenCalledWith('id1') + expect(eventBus.emit).toHaveBeenCalledWith('draw:delete', { featureId: 'id1' }) + }) +}) diff --git a/plugins/beta/draw/src/api/editFeature.js b/plugins/beta/draw/src/api/editFeature.js index 243f2381a..88e87a17a 100644 --- a/plugins/beta/draw/src/api/editFeature.js +++ b/plugins/beta/draw/src/api/editFeature.js @@ -17,7 +17,7 @@ export const editFeature = ({ appState, appConfig, mapState, pluginConfig, plugi const editModeMap = { LineString: 'edit_line', Polygon: 'edit_polygon' } eventBus.emit('draw:editstart', { mode: editModeMap[existingFeature.geometry.type] }) - const snapLayers = options.snapLayers !== undefined ? options.snapLayers : (pluginConfig.snapLayers ?? null) + const snapLayers = options.snapLayers === undefined ? (pluginConfig.snapLayers ?? null) : options.snapLayers draw.setSnapLayers(snapLayers) dispatch({ type: 'SET_HAS_SNAP_LAYERS', payload: snapLayers?.length > 0 }) diff --git a/plugins/beta/draw/src/api/editFeature.test.js b/plugins/beta/draw/src/api/editFeature.test.js new file mode 100644 index 000000000..e1c06f918 --- /dev/null +++ b/plugins/beta/draw/src/api/editFeature.test.js @@ -0,0 +1,98 @@ +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() } + const draw = { + get: jest.fn(() => ({ id: 'f1', geometry: { type: 'Polygon' } })), + setSnapLayers: jest.fn(), + changeMode: jest.fn(), + isSnapEnabled: jest.fn(() => true) + } + const context = { + appState: { layoutRefs: { viewportRef: { current: 'viewport' } }, interfaceType: 'mouse' }, + appConfig: { id: 'app' }, + mapState: { mapSize: 'medium' }, + pluginConfig: { snapLayers: ['pc-layer'] }, + pluginState: { dispatch }, + mapProvider: { draw }, + services: { eventBus }, + ...overrides + } + return { context, dispatch, eventBus, draw } +} + +beforeEach(() => jest.clearAllMocks()) + +describe('editFeature', () => { + test('returns false when there is no draw instance', () => { + const { context } = makeContext({ mapProvider: { draw: null } }) + expect(editFeature(context, 'f1')).toBe(false) + }) + + test('returns false when the feature does not exist', () => { + const { context, draw, eventBus } = makeContext() + draw.get.mockReturnValue(undefined) + expect(editFeature(context, 'missing')).toBe(false) + expect(eventBus.emit).not.toHaveBeenCalled() + }) + + test('enters edit mode for a polygon and wires all options', () => { + const { context, dispatch, eventBus, draw } = makeContext() + + const result = editFeature(context, 'f1') + + expect(result).toBe(true) + expect(eventBus.emit).toHaveBeenCalledWith('draw:editstart', { mode: 'edit_polygon' }) + expect(draw.setSnapLayers).toHaveBeenCalledWith(['pc-layer']) + expect(dispatch).toHaveBeenCalledWith({ type: 'SET_HAS_SNAP_LAYERS', payload: true }) + + expect(draw.changeMode).toHaveBeenCalledWith('edit_vertex', expect.objectContaining({ + container: 'viewport', + deleteVertexButtonId: 'app-draw-delete-point', + undoButtonId: 'app-draw-undo', + isPanEnabled: true, + interfaceType: 'mouse', + scale: 1.5, + featureId: 'f1' + })) + + const opts = draw.changeMode.mock.calls[0][1] + expect(opts.getSnapEnabled()).toBe(true) + expect(draw.isSnapEnabled).toHaveBeenCalled() + + expect(dispatch).toHaveBeenCalledWith({ type: 'SET_FEATURE', payload: { feature: { id: 'f1', geometry: { type: 'Polygon' } }, tempFeature: { id: 'f1', geometry: { type: 'Polygon' } } } }) + expect(dispatch).toHaveBeenCalledWith({ type: 'SET_MODE', payload: 'edit_vertex' }) + }) + + test('uses edit_line mode for a line string', () => { + const { context, eventBus, draw } = makeContext() + draw.get.mockReturnValue({ id: 'f1', geometry: { type: 'LineString' } }) + editFeature(context, 'f1') + expect(eventBus.emit).toHaveBeenCalledWith('draw:editstart', { mode: 'edit_line' }) + }) + + test('disables panning for the keyboard interface', () => { + const { context, draw } = makeContext({ + appState: { layoutRefs: { viewportRef: { current: 'viewport' } }, interfaceType: 'keyboard' } + }) + editFeature(context, 'f1') + expect(draw.changeMode.mock.calls[0][1].isPanEnabled).toBe(false) + }) + + test('prefers explicit option snapLayers over the plugin config', () => { + const { context, draw, dispatch } = makeContext() + editFeature(context, 'f1', { snapLayers: ['opt-layer'] }) + expect(draw.setSnapLayers).toHaveBeenCalledWith(['opt-layer']) + expect(dispatch).toHaveBeenCalledWith({ type: 'SET_HAS_SNAP_LAYERS', payload: true }) + }) + + test('falls back to null snapLayers when neither option nor config is set', () => { + const { context, draw, dispatch } = makeContext({ pluginConfig: {} }) + editFeature(context, 'f1') + expect(draw.setSnapLayers).toHaveBeenCalledWith(null) + expect(dispatch).toHaveBeenCalledWith({ type: 'SET_HAS_SNAP_LAYERS', payload: false }) + }) +}) diff --git a/plugins/beta/draw/src/api/merge.js b/plugins/beta/draw/src/api/merge.js index 3a8ed2f90..6e3eb5374 100644 --- a/plugins/beta/draw/src/api/merge.js +++ b/plugins/beta/draw/src/api/merge.js @@ -6,6 +6,6 @@ * @param {object} context - plugin context * @param {Array} polygons - array of GeoJSON polygon features to merge */ -export const merge = ({ services }, polygons) => { +export const merge = (_context, polygons) => { console.warn('draw: merge is not yet implemented', polygons) } diff --git a/plugins/beta/draw/src/api/merge.test.js b/plugins/beta/draw/src/api/merge.test.js new file mode 100644 index 000000000..a7c4a90a7 --- /dev/null +++ b/plugins/beta/draw/src/api/merge.test.js @@ -0,0 +1,13 @@ +import { merge } from './merge.js' + +describe('merge', () => { + test('warns that it is not yet implemented and passes through the polygons', () => { + const spy = jest.spyOn(console, 'warn').mockImplementation(() => {}) + const polygons = [{ id: 'p1' }, { id: 'p2' }] + + merge({}, polygons) + + expect(spy).toHaveBeenCalledWith('draw: merge is not yet implemented', polygons) + spy.mockRestore() + }) +}) diff --git a/plugins/beta/draw/src/api/newLine.js b/plugins/beta/draw/src/api/newLine.js index 8734c86de..8d4424a45 100644 --- a/plugins/beta/draw/src/api/newLine.js +++ b/plugins/beta/draw/src/api/newLine.js @@ -11,7 +11,7 @@ export const newLine = ({ appState, appConfig, pluginConfig, pluginState, mapSta eventBus.emit('draw:started', { mode: 'draw_line' }) - const snapLayers = options.snapLayers !== undefined ? options.snapLayers : (pluginConfig.snapLayers ?? null) + const snapLayers = options.snapLayers === undefined ? (pluginConfig.snapLayers ?? null) : options.snapLayers draw.setSnapLayers(snapLayers) dispatch({ type: 'SET_HAS_SNAP_LAYERS', payload: snapLayers?.length > 0 }) diff --git a/plugins/beta/draw/src/api/newLine.test.js b/plugins/beta/draw/src/api/newLine.test.js new file mode 100644 index 000000000..b302a7ccd --- /dev/null +++ b/plugins/beta/draw/src/api/newLine.test.js @@ -0,0 +1,78 @@ +import { newLine } from './newLine.js' +import { flattenStyleProperties } from '../utils/flattenStyleProperties.js' + +jest.mock('../utils/flattenStyleProperties.js', () => ({ + flattenStyleProperties: jest.fn(() => ({ _flat: true })) +})) + +const makeContext = (overrides = {}) => { + const dispatch = jest.fn() + const eventBus = { emit: jest.fn() } + const draw = { setSnapLayers: jest.fn(), changeMode: jest.fn(), isSnapEnabled: jest.fn(() => false) } + const context = { + appState: { layoutRefs: { viewportRef: { current: 'viewport' } }, interfaceType: 'mouse' }, + appConfig: { id: 'app' }, + pluginConfig: { snapLayers: ['pc-layer'] }, + pluginState: { dispatch }, + mapState: { crossHair: true }, + mapProvider: { draw }, + services: { eventBus }, + ...overrides + } + return { context, dispatch, eventBus, draw } +} + +beforeEach(() => jest.clearAllMocks()) + +describe('newLine', () => { + test('does nothing when there is no draw instance', () => { + const { context, eventBus } = makeContext({ mapProvider: { draw: null } }) + newLine(context, 'f1') + expect(eventBus.emit).not.toHaveBeenCalled() + }) + + test('starts line drawing with flattened style properties', () => { + const { context, dispatch, eventBus, draw } = makeContext() + + newLine(context, 'f1', { stroke: 'red', fill: 'blue', strokeWidth: 3, properties: { name: 'x' }, extra: 'opt' }) + + expect(eventBus.emit).toHaveBeenCalledWith('draw:started', { mode: 'draw_line' }) + expect(flattenStyleProperties).toHaveBeenCalledWith({ stroke: 'red', fill: 'blue', strokeWidth: 3 }) + + expect(draw.changeMode).toHaveBeenCalledWith('draw_line', expect.objectContaining({ + container: 'viewport', + addVertexButtonId: 'app-draw-add-point', + interfaceType: 'mouse', + crossHair: true, + featureId: 'f1', + extra: 'opt', + properties: { name: 'x', _flat: true } + })) + + const opts = draw.changeMode.mock.calls[0][1] + expect(opts.getSnapEnabled()).toBe(false) + expect(draw.isSnapEnabled).toHaveBeenCalled() + + expect(dispatch).toHaveBeenCalledWith({ type: 'SET_MODE', payload: 'draw_line' }) + }) + + test('prefers explicit option snapLayers and flags them', () => { + const { context, draw, dispatch } = makeContext() + newLine(context, 'f1', { snapLayers: ['opt'] }) + expect(draw.setSnapLayers).toHaveBeenCalledWith(['opt']) + expect(dispatch).toHaveBeenCalledWith({ type: 'SET_HAS_SNAP_LAYERS', payload: true }) + }) + + test('falls back to the plugin config snapLayers', () => { + const { context, draw } = makeContext() + newLine(context, 'f1') + expect(draw.setSnapLayers).toHaveBeenCalledWith(['pc-layer']) + }) + + test('falls back to null when neither option nor config is set', () => { + const { context, draw, dispatch } = makeContext({ pluginConfig: {} }) + newLine(context, 'f1') + expect(draw.setSnapLayers).toHaveBeenCalledWith(null) + expect(dispatch).toHaveBeenCalledWith({ type: 'SET_HAS_SNAP_LAYERS', payload: false }) + }) +}) diff --git a/plugins/beta/draw/src/api/newPolygon.js b/plugins/beta/draw/src/api/newPolygon.js index eae356b8a..a99658bc0 100644 --- a/plugins/beta/draw/src/api/newPolygon.js +++ b/plugins/beta/draw/src/api/newPolygon.js @@ -11,7 +11,7 @@ export const newPolygon = ({ appState, appConfig, pluginConfig, pluginState, map eventBus.emit('draw:started', { mode: 'draw_polygon' }) - const snapLayers = options.snapLayers !== undefined ? options.snapLayers : (pluginConfig.snapLayers ?? null) + const snapLayers = options.snapLayers === undefined ? (pluginConfig.snapLayers ?? null) : options.snapLayers draw.setSnapLayers(snapLayers) dispatch({ type: 'SET_HAS_SNAP_LAYERS', payload: snapLayers?.length > 0 }) diff --git a/plugins/beta/draw/src/api/newPolygon.test.js b/plugins/beta/draw/src/api/newPolygon.test.js new file mode 100644 index 000000000..5f8ee6bef --- /dev/null +++ b/plugins/beta/draw/src/api/newPolygon.test.js @@ -0,0 +1,78 @@ +import { newPolygon } from './newPolygon.js' +import { flattenStyleProperties } from '../utils/flattenStyleProperties.js' + +jest.mock('../utils/flattenStyleProperties.js', () => ({ + flattenStyleProperties: jest.fn(() => ({ _flat: true })) +})) + +const makeContext = (overrides = {}) => { + const dispatch = jest.fn() + const eventBus = { emit: jest.fn() } + const draw = { setSnapLayers: jest.fn(), changeMode: jest.fn(), isSnapEnabled: jest.fn(() => true) } + const context = { + appState: { layoutRefs: { viewportRef: { current: 'viewport' } }, interfaceType: 'mouse' }, + appConfig: { id: 'app' }, + pluginConfig: { snapLayers: ['pc-layer'] }, + pluginState: { dispatch }, + mapState: { crossHair: false }, + mapProvider: { draw }, + services: { eventBus }, + ...overrides + } + return { context, dispatch, eventBus, draw } +} + +beforeEach(() => jest.clearAllMocks()) + +describe('newPolygon', () => { + test('does nothing when there is no draw instance', () => { + const { context, eventBus } = makeContext({ mapProvider: { draw: null } }) + newPolygon(context, 'f1') + expect(eventBus.emit).not.toHaveBeenCalled() + }) + + test('starts polygon drawing with flattened style properties', () => { + const { context, dispatch, eventBus, draw } = makeContext() + + newPolygon(context, 'f1', { stroke: 'red', fill: 'blue', strokeWidth: 3, properties: { name: 'x' }, extra: 'opt' }) + + expect(eventBus.emit).toHaveBeenCalledWith('draw:started', { mode: 'draw_polygon' }) + expect(flattenStyleProperties).toHaveBeenCalledWith({ stroke: 'red', fill: 'blue', strokeWidth: 3 }) + + expect(draw.changeMode).toHaveBeenCalledWith('draw_polygon', expect.objectContaining({ + container: 'viewport', + addVertexButtonId: 'app-draw-add-point', + interfaceType: 'mouse', + crossHair: false, + featureId: 'f1', + extra: 'opt', + properties: { name: 'x', _flat: true } + })) + + const opts = draw.changeMode.mock.calls[0][1] + expect(opts.getSnapEnabled()).toBe(true) + expect(draw.isSnapEnabled).toHaveBeenCalled() + + expect(dispatch).toHaveBeenCalledWith({ type: 'SET_MODE', payload: 'draw_polygon' }) + }) + + test('prefers explicit option snapLayers and flags them', () => { + const { context, draw, dispatch } = makeContext() + newPolygon(context, 'f1', { snapLayers: ['opt'] }) + expect(draw.setSnapLayers).toHaveBeenCalledWith(['opt']) + expect(dispatch).toHaveBeenCalledWith({ type: 'SET_HAS_SNAP_LAYERS', payload: true }) + }) + + test('falls back to the plugin config snapLayers', () => { + const { context, draw } = makeContext() + newPolygon(context, 'f1') + expect(draw.setSnapLayers).toHaveBeenCalledWith(['pc-layer']) + }) + + test('falls back to null when neither option nor config is set', () => { + const { context, draw, dispatch } = makeContext({ pluginConfig: {} }) + newPolygon(context, 'f1') + expect(draw.setSnapLayers).toHaveBeenCalledWith(null) + expect(dispatch).toHaveBeenCalledWith({ type: 'SET_HAS_SNAP_LAYERS', payload: false }) + }) +}) diff --git a/plugins/beta/draw/src/api/split.test.js b/plugins/beta/draw/src/api/split.test.js new file mode 100644 index 000000000..8c2c03b18 --- /dev/null +++ b/plugins/beta/draw/src/api/split.test.js @@ -0,0 +1,136 @@ +import { split } from './split.js' +import { splitPolygon } from '../utils/spatial.js' + +jest.mock('../utils/spatial.js', () => ({ splitPolygon: jest.fn() })) +jest.mock('../utils/debounce.js', () => ({ debounce: jest.fn((fn) => fn) })) + +const makeContext = (overrides = {}) => { + const dispatch = jest.fn() + const draw = { + get: jest.fn(() => ({ id: 'poly' })), + setSnapLayers: jest.fn(), + changeMode: jest.fn(), + on: jest.fn(), + off: jest.fn(), + setFeatureProperty: jest.fn(), + isSnapEnabled: jest.fn(() => true) + } + const context = { + appState: { layoutRefs: { viewportRef: { current: 'viewport' } }, interfaceType: 'mouse' }, + appConfig: { id: 'app' }, + pluginState: { dispatch }, + mapState: { crossHair: true }, + mapProvider: { draw }, + ...overrides + } + return { context, dispatch, draw } +} + +const handlerFor = (draw, event) => draw.on.mock.calls.find(([name]) => name === event)?.[1] + +beforeEach(() => jest.clearAllMocks()) + +describe('split', () => { + test('does nothing when there is no draw instance', () => { + const { context, draw } = makeContext({ mapProvider: { draw: null } }) + split(context, 'poly') + expect(draw.changeMode).not.toHaveBeenCalled() + }) + + test('sets up the splitter line drawing and registers listeners', () => { + const { context, dispatch, draw } = makeContext() + + split(context, 'poly', {}) + + expect(draw.setSnapLayers).toHaveBeenCalledWith(['stroke-inactive.cold']) + expect(dispatch).toHaveBeenCalledWith({ type: 'SET_HAS_SNAP_LAYERS', payload: true }) + expect(draw.changeMode).toHaveBeenCalledWith('draw_line', expect.objectContaining({ + container: 'viewport', + addVertexButtonId: 'app-draw-add-point', + interfaceType: 'mouse', + crossHair: true, + featureId: '_splitter', + properties: { splitter: 'invalid' } + })) + expect(draw.on).toHaveBeenCalledWith('create', expect.any(Function)) + expect(draw.on).toHaveBeenCalledWith('geometrychange', expect.any(Function)) + expect(dispatch).toHaveBeenCalledWith({ type: 'SET_MODE', payload: 'draw_line' }) + expect(dispatch).toHaveBeenCalledWith({ type: 'SET_ACTION', payload: { name: 'split' } }) + + const opts = draw.changeMode.mock.calls[0][1] + expect(opts.getSnapEnabled()).toBe(true) + expect(draw.isSnapEnabled).toHaveBeenCalled() + }) + + test('merges option snapLayers with the outline layer', () => { + const { context, draw } = makeContext() + split(context, 'poly', { snapLayers: ['extra'] }) + expect(draw.setSnapLayers).toHaveBeenCalledWith(['stroke-inactive.cold', 'extra']) + }) + + test('finalising the line computes a valid split', () => { + const { context, dispatch, draw } = makeContext() + const polygonFeature = { id: 'poly' } + draw.get.mockReturnValue(polygonFeature) + splitPolygon.mockReturnValue({ type: 'FeatureCollection' }) + + split(context, 'poly', {}) + const onCreate = handlerFor(draw, 'create') + const geojson = { id: 'line' } + onCreate(geojson) + + expect(draw.off).toHaveBeenCalledWith('create', onCreate) + expect(splitPolygon).toHaveBeenCalledWith(polygonFeature, geojson) + expect(draw.setFeatureProperty).toHaveBeenCalledWith('_splitter', 'splitter', 'valid') + expect(dispatch).toHaveBeenCalledWith({ type: 'SET_ACTION', payload: { name: 'split', isValid: true } }) + }) + + test('finalising the line computes an invalid split', () => { + const { context, dispatch, draw } = makeContext() + splitPolygon.mockReturnValue(null) + + split(context, 'poly', {}) + handlerFor(draw, 'create')({ id: 'line' }) + + expect(draw.setFeatureProperty).toHaveBeenCalledWith('_splitter', 'splitter', 'invalid') + expect(dispatch).toHaveBeenCalledWith({ type: 'SET_ACTION', payload: { name: 'split', isValid: false } }) + }) + + test('geometry change updates validity and re-renders', () => { + const { context, dispatch, draw } = makeContext() + splitPolygon.mockReturnValue({ type: 'FeatureCollection' }) + + split(context, 'poly', {}) + const onGeometryChange = handlerFor(draw, 'geometrychange') + const render = jest.fn() + const e = { coordinates: [[0, 0], [1, 1]], properties: {}, ctx: { store: { render } } } + onGeometryChange(e) + + expect(e.properties.splitter).toBe('valid') + expect(render).toHaveBeenCalled() + expect(dispatch).toHaveBeenCalledWith({ type: 'SET_ACTION', payload: { name: 'split', isValid: true } }) + }) + + test('geometry change ignores lines with fewer than two coordinates', () => { + const { context, draw } = makeContext() + split(context, 'poly', {}) + splitPolygon.mockClear() + + handlerFor(draw, 'geometrychange')({ coordinates: [[0, 0]], properties: {} }) + + expect(splitPolygon).not.toHaveBeenCalled() + }) + + test('geometry change marks invalid splits and tolerates a missing render context', () => { + const { context, dispatch, draw } = makeContext() + splitPolygon.mockReturnValue(null) + + split(context, 'poly', {}) + const onGeometryChange = handlerFor(draw, 'geometrychange') + const e = { coordinates: [[0, 0], [1, 1]], properties: {} } + + expect(() => onGeometryChange(e)).not.toThrow() + expect(e.properties.splitter).toBe('invalid') + expect(dispatch).toHaveBeenCalledWith({ type: 'SET_ACTION', payload: { name: 'split', isValid: false } }) + }) +}) From d0492877208281ebdf880edab40faab449306e9c Mon Sep 17 00:00:00 2001 From: Dan Leech Date: Fri, 3 Jul 2026 17:14:54 +0100 Subject: [PATCH 30/89] spatial.js utils tests added --- plugins/beta/draw/src/utils/spatial.js | 93 ++++------- plugins/beta/draw/src/utils/spatial.test.js | 175 ++++++++++++++++++++ 2 files changed, 204 insertions(+), 64 deletions(-) create mode 100644 plugins/beta/draw/src/utils/spatial.test.js diff --git a/plugins/beta/draw/src/utils/spatial.js b/plugins/beta/draw/src/utils/spatial.js index 28939bb7d..24709ca13 100755 --- a/plugins/beta/draw/src/utils/spatial.js +++ b/plugins/beta/draw/src/utils/spatial.js @@ -1,8 +1,6 @@ import polygonSplitter from 'polygon-splitter' import turfBearing from '@turf/bearing' import turfDestination from '@turf/destination' -import turfBooleanValid from '@turf/boolean-valid' -import turfArea from '@turf/area' import { featureCollection as turfFeatureCollection, polygon as turfPolygon, @@ -129,8 +127,10 @@ const toTurfGeometry = (featureOrGeom) => { } } +const DEGREES_PER_HALF_TURN = 180 + const haversine = ([lon1, lat1], [lon2, lat2]) => { - const toRad = deg => deg * Math.PI / 180 + const toRad = deg => deg * Math.PI / DEGREES_PER_HALF_TURN const R = 6371000 // meters const dLat = toRad(lat2 - lat1) const dLon = toRad(lon2 - lon1) @@ -138,88 +138,53 @@ const haversine = ([lon1, lat1], [lon2, lat2]) => { return 2 * R * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)) } -const isNewCoordinate = (coords, tolerance = 0.01) => { - // First coord - if (coords[0].length <= 1) { - return true - } - // Subsequent coordsmust be different - if (coords[0].length <= 3) { - for (let i = 0; i < coords[0].length; i++) { - for (let j = i + 1; j < coords[0].length; j++) { - if (haversine(coords[0][i], coords[0][j]) < tolerance) { - return false - } +const SMALL_RING_LENGTH = 3 + +// True if any two points in the ring are within `tolerance` of each other +const hasDuplicatePair = (ring, tolerance) => { + for (let i = 0; i < ring.length; i++) { + for (let j = i + 1; j < ring.length; j++) { + if (haversine(ring[i], ring[j]) < tolerance) { + return true } } } - return true + return false } -const isNewLineCoordinate = (coords, tolerance = 0.01) => { - // First coord is always valid - if (coords.length <= 1) { +const isNewCoordinate = (coords, tolerance = 0.01) => { + const ring = coords[0] + // First coord is always new + if (ring.length <= 1) { return true } - // Check last two coords are different - if (coords.length >= 2) { - const last = coords[coords.length - 1] - const secondLast = coords[coords.length - 2] - if (haversine(last, secondLast) < tolerance) { - return false - } + // For small rings, reject if any two points coincide + if (ring.length <= SMALL_RING_LENGTH && hasDuplicatePair(ring, tolerance)) { + return false } return true } -const isValidLineClick = (coords) => { +const isValidLineClick = (coords, tolerance = 0.01) => { // First coord is always valid if (coords.length <= 1) { return true } - // Check that the new coordinate is different from the previous one - return isNewLineCoordinate(coords) + // The new coordinate must differ from the previous one + const last = coords[coords.length - 1] + const secondLast = coords[coords.length - 2] + return haversine(last, secondLast) >= tolerance } const isValidClick = (coords) => { - // Less than 4 and new coordinates - if (coords[0].length <= 1 || isNewCoordinate(coords)) { - return true - } - - // Basic checks - if (!Array.isArray(coords) || coords.length < 4) { - return false - } - - // Check if ring is closed - const first = coords[0] - const last = coords[coords.length - 1] - const isClosed = first[0] === last[0] && first[1] === last[1] - if (!isClosed) { - return false - } - - // Create a turf polygon - const turfPoly = turfPolygon([coords]) - - // Check if geometry is valid (non-self-intersecting) - const valid = turfBooleanValid(turfPoly) - if (!valid) { - return false - } - - // Check if area is positive - const polyArea = turfArea(turfPoly) - if (polyArea <= 0) { - return false - } - - return true + // Valid when it's the very first point, or a genuinely new (non-duplicate) coordinate. + // Callers only pass single-ring polygon coordinates while drawing, so no ring-closure / + // self-intersection checks are needed here. + return coords[0].length <= 1 || isNewCoordinate(coords) } const spatialNavigate = (start, pixels, direction) => { - const quadrant = pixels.filter((p, i) => { + const quadrant = pixels.filter((p) => { const offsetX = Math.abs(p[0] - start[0]) const offsetY = Math.abs(p[1] - start[1]) let isQuadrant = false diff --git a/plugins/beta/draw/src/utils/spatial.test.js b/plugins/beta/draw/src/utils/spatial.test.js new file mode 100644 index 000000000..e13d7c9f1 --- /dev/null +++ b/plugins/beta/draw/src/utils/spatial.test.js @@ -0,0 +1,175 @@ +import polygonSplitter from 'polygon-splitter' +import { + toTurfGeometry, + splitPolygon, + extendLine, + isNewCoordinate, + isValidClick, + isValidLineClick, + spatialNavigate +} from './spatial.js' + +jest.mock('polygon-splitter', () => jest.fn()) + +beforeEach(() => jest.clearAllMocks()) + +describe('toTurfGeometry', () => { + test.each([ + ['Polygon', { type: 'Polygon', coordinates: [[[0, 0], [1, 0], [1, 1], [0, 0]]] }], + ['MultiPolygon', { type: 'MultiPolygon', coordinates: [[[[0, 0], [1, 0], [1, 1], [0, 0]]]] }], + ['LineString', { type: 'LineString', coordinates: [[0, 0], [1, 1]] }], + ['MultiLineString', { type: 'MultiLineString', coordinates: [[[0, 0], [1, 1]]] }], + ['Point', { type: 'Point', coordinates: [0, 0] }], + ['MultiPoint', { type: 'MultiPoint', coordinates: [[0, 0], [1, 1]] }] + ])('converts a raw %s geometry', (type, geom) => { + expect(toTurfGeometry(geom).geometry.type).toBe(type) + }) + + test('unwraps a Feature via its .geometry property', () => { + const feature = { geometry: { type: 'Point', coordinates: [0, 0] } } + expect(toTurfGeometry(feature).geometry.type).toBe('Point') + }) + + test('throws for unsupported geometry types', () => { + expect(() => toTurfGeometry({ type: 'GeometryCollection' })) + .toThrow('Unsupported geometry type: GeometryCollection') + }) +}) + +describe('extendLine', () => { + test('extends a two-point line at both ends', () => { + const line = { geometry: { coordinates: [[0, 0], [0, 1]] } } + const result = extendLine(line) + expect(result.geometry.type).toBe('LineString') + expect(result.geometry.coordinates).toHaveLength(4) + }) + + test('adds a spike at each intermediate vertex', () => { + const line = { geometry: { coordinates: [[0, 0], [0, 1], [0, 2]] } } + expect(extendLine(line).geometry.coordinates).toHaveLength(6) + }) +}) + +describe('splitPolygon', () => { + const polygon = { + id: 'sq', + properties: { id: 'sq', name: 'field' }, + geometry: { type: 'Polygon', coordinates: [[[0, 0], [4, 0], [4, 4], [0, 4], [0, 0]]] } + } + const line = { geometry: { type: 'LineString', coordinates: [[2, -1], [2, 5]] } } + + const twoPolygonResult = { + geometry: { + type: 'MultiPolygon', + coordinates: [ + [[[0, 0], [2, 0], [2, 4], [0, 4], [0, 0]]], + [[[2, 0], [4, 0], [4, 4], [2, 4], [2, 0]]] + ] + } + } + + test('returns a two-feature collection for a valid split', () => { + polygonSplitter.mockReturnValue(twoPolygonResult) + + const result = splitPolygon(polygon, line) + + expect(result.type).toBe('FeatureCollection') + expect(result.features).toHaveLength(2) + expect(result.features[0].id).toBe('sq-1') + expect(result.features[1].id).toBe('sq-2') + expect(result.features[0].properties).toMatchObject({ id: 'sq', name: 'field' }) + }) + + test('returns null when the splitter throws', () => { + polygonSplitter.mockImplementation(() => { throw new Error('bad geometry') }) + expect(splitPolygon(polygon, line)).toBeNull() + }) + + test('returns null when the result is not a MultiPolygon', () => { + polygonSplitter.mockReturnValue({ geometry: { type: 'Polygon', coordinates: [] } }) + expect(splitPolygon(polygon, line)).toBeNull() + }) + + test('returns null when the split does not produce exactly two polygons', () => { + polygonSplitter.mockReturnValue({ geometry: { type: 'MultiPolygon', coordinates: [[[[0, 0], [1, 0], [1, 1], [0, 0]]]] } }) + expect(splitPolygon(polygon, line)).toBeNull() + }) + + test('derives the base id from properties.id then a default', () => { + polygonSplitter.mockReturnValue(twoPolygonResult) + + const noTopLevelId = { properties: { id: 'pid' }, geometry: polygon.geometry } + expect(splitPolygon(noTopLevelId, line).features[0].id).toBe('pid-1') + + const anonymous = { properties: {}, geometry: polygon.geometry } + expect(splitPolygon(anonymous, line).features[0].id).toBe('poly-1') + }) +}) + +describe('isNewCoordinate', () => { + test('is true for a ring with a single point', () => { + expect(isNewCoordinate([[[0, 0]]])).toBe(true) + }) + + test('is false when a small ring has coincident points', () => { + expect(isNewCoordinate([[[0, 0], [0, 0]]])).toBe(false) + }) + + test('is true when a small ring has distinct points', () => { + expect(isNewCoordinate([[[0, 0], [10, 10]]])).toBe(true) + }) + + test('skips the duplicate check for larger rings', () => { + expect(isNewCoordinate([[[0, 0], [0, 0], [1, 1], [2, 2], [3, 3]]])).toBe(true) + }) +}) + +describe('isValidClick', () => { + test('is true when the ring has a single point', () => { + expect(isValidClick([[[0, 0]]])).toBe(true) + }) + + test('is true for a new (non-duplicate) coordinate', () => { + expect(isValidClick([[[0, 0], [1, 0], [1, 1], [0, 1], [0, 0]]])).toBe(true) + }) + + test('is false when a small ring repeats a coordinate', () => { + expect(isValidClick([[[0, 0], [0, 0]]])).toBe(false) + }) +}) + +describe('isValidLineClick', () => { + test('is true for the first coordinate', () => { + expect(isValidLineClick([[0, 0]])).toBe(true) + }) + + test('is true when the last two coordinates differ', () => { + expect(isValidLineClick([[0, 0], [10, 10]])).toBe(true) + }) + + test('is false when the last two coordinates coincide', () => { + expect(isValidLineClick([[0, 0], [0, 0]])).toBe(false) + }) +}) + +describe('spatialNavigate', () => { + const start = [0, 0] + const pixels = [[0, 0], [0, -10], [0, 10], [-10, 0], [10, 0]] + + test.each([ + ['ArrowUp', 1], + ['ArrowDown', 2], + ['ArrowLeft', 3], + ['ArrowRight', 4] + ])('finds the nearest pixel for %s', (direction, expectedIndex) => { + expect(spatialNavigate(start, pixels, direction)).toBe(expectedIndex) + }) + + test('considers all pixels for an unrecognised direction', () => { + expect(spatialNavigate(start, pixels, 'Tab')).toBe(1) + }) + + test('falls back to the start point when no pixel is in the quadrant', () => { + expect(spatialNavigate(start, [[0, 0]], 'ArrowUp')).toBe(0) + }) +}) From d15b5044f79ac68159311180b69c5c1d2d985544 Mon Sep 17 00:00:00 2001 From: Dan Leech Date: Fri, 3 Jul 2026 17:20:58 +0100 Subject: [PATCH 31/89] Remaining utils tests added --- plugins/beta/draw/src/utils/debounce.test.js | 40 +++++++ plugins/beta/draw/src/utils/eventBus.test.js | 47 ++++++++ .../draw/src/utils/flattenStyleProperties.js | 4 +- .../src/utils/flattenStyleProperties.test.js | 33 ++++++ .../draw/src/utils/getValueForStyle.test.js | 32 ++++++ .../beta/draw/src/utils/touchTarget.test.js | 107 ++++++++++++++++++ plugins/beta/draw/src/utils/undoStack.test.js | 49 ++++++++ 7 files changed, 310 insertions(+), 2 deletions(-) create mode 100644 plugins/beta/draw/src/utils/debounce.test.js create mode 100644 plugins/beta/draw/src/utils/eventBus.test.js create mode 100644 plugins/beta/draw/src/utils/flattenStyleProperties.test.js create mode 100644 plugins/beta/draw/src/utils/getValueForStyle.test.js create mode 100644 plugins/beta/draw/src/utils/touchTarget.test.js create mode 100644 plugins/beta/draw/src/utils/undoStack.test.js diff --git a/plugins/beta/draw/src/utils/debounce.test.js b/plugins/beta/draw/src/utils/debounce.test.js new file mode 100644 index 000000000..4cc1f3b21 --- /dev/null +++ b/plugins/beta/draw/src/utils/debounce.test.js @@ -0,0 +1,40 @@ +import { debounce } from './debounce.js' + +jest.useFakeTimers() + +describe('debounce', () => { + test('invokes the function after the delay with the latest arguments', () => { + const fn = jest.fn() + const debounced = debounce(fn, 100) + + debounced('a') + expect(fn).not.toHaveBeenCalled() + + jest.advanceTimersByTime(100) + expect(fn).toHaveBeenCalledWith('a') + }) + + test('collapses rapid calls into a single trailing invocation', () => { + const fn = jest.fn() + const debounced = debounce(fn, 100) + + debounced(1) + debounced(2) + debounced(3) + jest.advanceTimersByTime(100) + + expect(fn).toHaveBeenCalledTimes(1) + expect(fn).toHaveBeenCalledWith(3) + }) + + test('cancel prevents a pending invocation', () => { + const fn = jest.fn() + const debounced = debounce(fn, 100) + + debounced('x') + debounced.cancel() + jest.advanceTimersByTime(100) + + expect(fn).not.toHaveBeenCalled() + }) +}) diff --git a/plugins/beta/draw/src/utils/eventBus.test.js b/plugins/beta/draw/src/utils/eventBus.test.js new file mode 100644 index 000000000..fe346b838 --- /dev/null +++ b/plugins/beta/draw/src/utils/eventBus.test.js @@ -0,0 +1,47 @@ +import { createEventBus } from './eventBus.js' + +describe('createEventBus', () => { + test('emits to a registered handler with the supplied arguments', () => { + const bus = createEventBus() + const handler = jest.fn() + bus.on('thing', handler) + + bus.emit('thing', 1, 2) + + expect(handler).toHaveBeenCalledWith(1, 2) + }) + + test('supports multiple handlers for the same type', () => { + const bus = createEventBus() + const a = jest.fn() + const b = jest.fn() + bus.on('thing', a) + bus.on('thing', b) + + bus.emit('thing') + + expect(a).toHaveBeenCalled() + expect(b).toHaveBeenCalled() + }) + + test('off removes a handler so it no longer fires', () => { + const bus = createEventBus() + const handler = jest.fn() + bus.on('thing', handler) + bus.off('thing', handler) + + bus.emit('thing') + + expect(handler).not.toHaveBeenCalled() + }) + + test('emitting a type with no listeners is a no-op', () => { + const bus = createEventBus() + expect(() => bus.emit('nothing')).not.toThrow() + }) + + test('off on an unknown type does not throw', () => { + const bus = createEventBus() + expect(() => bus.off('unknown', jest.fn())).not.toThrow() + }) +}) diff --git a/plugins/beta/draw/src/utils/flattenStyleProperties.js b/plugins/beta/draw/src/utils/flattenStyleProperties.js index 55ebbb9a3..d0099115c 100644 --- a/plugins/beta/draw/src/utils/flattenStyleProperties.js +++ b/plugins/beta/draw/src/utils/flattenStyleProperties.js @@ -1,4 +1,4 @@ -const STYLE_PROPS = ['stroke', 'fill', 'strokeWidth'] +const STYLE_PROPS = new Set(['stroke', 'fill', 'strokeWidth']) export const flattenStyleProperties = (props) => { if (!props) { @@ -8,7 +8,7 @@ export const flattenStyleProperties = (props) => { const result = {} for (const [key, value] of Object.entries(props)) { - if (STYLE_PROPS.includes(key) && typeof value === 'object' && value !== null) { + if (STYLE_PROPS.has(key) && typeof value === 'object' && value !== null) { const entries = Object.entries(value) if (entries.length > 0) { result[key] = entries[0][1] diff --git a/plugins/beta/draw/src/utils/flattenStyleProperties.test.js b/plugins/beta/draw/src/utils/flattenStyleProperties.test.js new file mode 100644 index 000000000..0727cc562 --- /dev/null +++ b/plugins/beta/draw/src/utils/flattenStyleProperties.test.js @@ -0,0 +1,33 @@ +import { flattenStyleProperties } from './flattenStyleProperties.js' + +describe('flattenStyleProperties', () => { + test('returns an empty object for nullish input', () => { + expect(flattenStyleProperties(null)).toEqual({}) + expect(flattenStyleProperties(undefined)).toEqual({}) + }) + + test('flattens a style-variant object into base + per-style keys', () => { + const result = flattenStyleProperties({ stroke: { light: 'red', dark: 'blue' } }) + expect(result).toEqual({ + stroke: 'red', // base = first variant value + strokeLight: 'red', + strokeDark: 'blue' + }) + }) + + test('passes through a scalar style value unchanged', () => { + expect(flattenStyleProperties({ stroke: 'red' })).toEqual({ stroke: 'red' }) + }) + + test('passes through a null style value unchanged', () => { + expect(flattenStyleProperties({ fill: null })).toEqual({ fill: null }) + }) + + test('passes through non-style properties untouched', () => { + expect(flattenStyleProperties({ name: 'field', count: 3 })).toEqual({ name: 'field', count: 3 }) + }) + + test('ignores an empty style-variant object (no base, no variants)', () => { + expect(flattenStyleProperties({ fill: {} })).toEqual({}) + }) +}) diff --git a/plugins/beta/draw/src/utils/getValueForStyle.test.js b/plugins/beta/draw/src/utils/getValueForStyle.test.js new file mode 100644 index 000000000..cf743fb4a --- /dev/null +++ b/plugins/beta/draw/src/utils/getValueForStyle.test.js @@ -0,0 +1,32 @@ +import { getValueForStyle } from './getValueForStyle.js' + +describe('getValueForStyle', () => { + test('returns primitive values unchanged', () => { + expect(getValueForStyle('red', 'light')).toBe('red') + expect(getValueForStyle(5, 'light')).toBe(5) + }) + + test('returns null unchanged', () => { + expect(getValueForStyle(null, 'light')).toBeNull() + }) + + test('prefers an exact style-id match', () => { + expect(getValueForStyle({ outdoor: 'green', light: 'red' }, 'light', 'outdoor')).toBe('green') + }) + + test('falls back to the scheme match when the style id is absent', () => { + expect(getValueForStyle({ light: 'red', dark: 'blue' }, 'dark', 'outdoor')).toBe('blue') + }) + + test('uses the scheme match when no style id is provided', () => { + expect(getValueForStyle({ light: 'red', dark: 'blue' }, 'dark')).toBe('blue') + }) + + test('falls back to the light property when the scheme is missing', () => { + expect(getValueForStyle({ light: 'red', dark: 'blue' }, 'sepia')).toBe('red') + }) + + test('falls back to the first value when neither scheme nor light exist', () => { + expect(getValueForStyle({ outdoor: 'green', satellite: 'grey' }, 'dark')).toBe('green') + }) +}) diff --git a/plugins/beta/draw/src/utils/touchTarget.test.js b/plugins/beta/draw/src/utils/touchTarget.test.js new file mode 100644 index 000000000..ad36988b2 --- /dev/null +++ b/plugins/beta/draw/src/utils/touchTarget.test.js @@ -0,0 +1,107 @@ +import { + createTouchTarget, + applyTouchTargetColors, + showTouchTarget, + hideTouchTarget, + isOnTouchTarget +} from './touchTarget.js' + +const SVG_NS = 'http://www.w3.org/2000/svg' + +describe('createTouchTarget', () => { + test('inserts the SVG target once and returns it', () => { + const container = document.createElement('div') + + const el = createTouchTarget(container) + + expect(el).not.toBeNull() + expect(el.hasAttribute('data-im-draw-touch-target')).toBe(true) + expect(container.querySelectorAll('[data-im-draw-touch-target]')).toHaveLength(1) + }) + + test('returns the existing target without inserting a second one', () => { + const container = document.createElement('div') + const first = createTouchTarget(container) + const second = createTouchTarget(container) + + expect(second).toBe(first) + expect(container.querySelectorAll('[data-im-draw-touch-target]')).toHaveLength(1) + }) +}) + +describe('applyTouchTargetColors', () => { + test('sets the CSS custom properties from the colours', () => { + const el = document.createElement('div') + + applyTouchTargetColors(el, { editActive: 'a', editHalo: 'h', editVertex: 'v' }) + + expect(el.style.getPropertyValue('--draw-halo')).toBe('a') + expect(el.style.getPropertyValue('--draw-bg')).toBe('h') + expect(el.style.getPropertyValue('--draw-primary')).toBe('v') + }) + + test('does nothing for a null element', () => { + expect(() => applyTouchTargetColors(null, {})).not.toThrow() + }) +}) + +describe('showTouchTarget', () => { + test('positions and shows the element', () => { + const el = document.createElement('div') + + showTouchTarget(el, { x: 10, y: 20 }) + + expect(el.style.left).toBe('10px') + expect(el.style.top).toBe('20px') + expect(el.style.display).toBe('block') + }) + + test('does nothing without a pixel or an element', () => { + const el = document.createElement('div') + showTouchTarget(el, null) + expect(el.style.display).toBe('') + + expect(() => showTouchTarget(null, { x: 0, y: 0 })).not.toThrow() + }) +}) + +describe('hideTouchTarget', () => { + test('hides the element', () => { + const el = document.createElement('div') + el.style.display = 'block' + + hideTouchTarget(el) + + expect(el.style.display).toBe('none') + }) + + test('does nothing for a null element', () => { + expect(() => hideTouchTarget(null)).not.toThrow() + }) +}) + +describe('isOnTouchTarget', () => { + test('returns false for a null element', () => { + expect(isOnTouchTarget(null)).toBe(false) + }) + + test('returns false for an element without a parent', () => { + expect(isOnTouchTarget(document.createElement('div'))).toBe(false) + }) + + test('returns true when the parent is an SVG element', () => { + const svg = document.createElementNS(SVG_NS, 'svg') + const circle = document.createElementNS(SVG_NS, 'circle') + svg.appendChild(circle) + + expect(isOnTouchTarget(circle)).toBe(true) + }) + + test('returns false when the parent is a plain HTML element', () => { + const div = document.createElement('div') + const span = document.createElement('span') + div.appendChild(span) + + expect(isOnTouchTarget(span)).toBe(false) + }) +}) diff --git a/plugins/beta/draw/src/utils/undoStack.test.js b/plugins/beta/draw/src/utils/undoStack.test.js new file mode 100644 index 000000000..cfe74ea09 --- /dev/null +++ b/plugins/beta/draw/src/utils/undoStack.test.js @@ -0,0 +1,49 @@ +import { createUndoStack } from './undoStack.js' + +describe('createUndoStack', () => { + test('push adds an operation and reports the new length', () => { + const onChange = jest.fn() + const stack = createUndoStack(onChange) + + stack.push({ id: 1 }) + stack.push({ id: 2 }) + + expect(stack.length).toBe(2) + expect(onChange).toHaveBeenNthCalledWith(1, 1) + expect(onChange).toHaveBeenNthCalledWith(2, 2) + }) + + test('pop returns and removes the last operation, reporting the length', () => { + const onChange = jest.fn() + const stack = createUndoStack(onChange) + stack.push({ id: 1 }) + onChange.mockClear() + + const op = stack.pop() + + expect(op).toEqual({ id: 1 }) + expect(stack.length).toBe(0) + expect(onChange).toHaveBeenCalledWith(0) + }) + + test('pop on an empty stack returns undefined and reports zero', () => { + const onChange = jest.fn() + const stack = createUndoStack(onChange) + + expect(stack.pop()).toBeUndefined() + expect(onChange).toHaveBeenCalledWith(0) + }) + + test('clear empties the stack and reports zero', () => { + const onChange = jest.fn() + const stack = createUndoStack(onChange) + stack.push({ id: 1 }) + stack.push({ id: 2 }) + onChange.mockClear() + + stack.clear() + + expect(stack.length).toBe(0) + expect(onChange).toHaveBeenCalledWith(0) + }) +}) From ed25203cc2d541dcd7a53d2e4e50d979629b37e9 Mon Sep 17 00:00:00 2001 From: Dan Leech Date: Fri, 3 Jul 2026 17:35:48 +0100 Subject: [PATCH 32/89] Remaining route files tests added --- plugins/beta/draw/src/DrawInit.jsx | 2 +- plugins/beta/draw/src/DrawInit.test.jsx | 166 ++++++++++++++++++++++ plugins/beta/draw/src/defaults.test.js | 18 +++ plugins/beta/draw/src/events.test.js | 177 ++++++++++++++++++++++++ plugins/beta/draw/src/index.test.js | 24 ++++ plugins/beta/draw/src/manifest.js | 8 +- plugins/beta/draw/src/manifest.test.js | 106 ++++++++++++++ plugins/beta/draw/src/reducer.test.js | 77 +++++++++++ 8 files changed, 573 insertions(+), 5 deletions(-) create mode 100644 plugins/beta/draw/src/DrawInit.test.jsx create mode 100644 plugins/beta/draw/src/defaults.test.js create mode 100644 plugins/beta/draw/src/events.test.js create mode 100644 plugins/beta/draw/src/index.test.js create mode 100644 plugins/beta/draw/src/manifest.test.js create mode 100644 plugins/beta/draw/src/reducer.test.js diff --git a/plugins/beta/draw/src/DrawInit.jsx b/plugins/beta/draw/src/DrawInit.jsx index 4f9da30d4..ca07e55ed 100644 --- a/plugins/beta/draw/src/DrawInit.jsx +++ b/plugins/beta/draw/src/DrawInit.jsx @@ -24,7 +24,7 @@ export const DrawInit = ({ appState, appConfig, mapState, pluginConfig, pluginSt events: EVENTS, eventBus }).then(adapter => { - if (!isMounted) return + if (!isMounted) { return } mapProvider.draw = adapter pluginState.dispatch({ type: 'SET_HAS_SNAP_LAYERS', payload: pluginConfig.snapLayers?.length > 0 }) eventBus.emit('draw:ready') diff --git a/plugins/beta/draw/src/DrawInit.test.jsx b/plugins/beta/draw/src/DrawInit.test.jsx new file mode 100644 index 000000000..9e6b8b07b --- /dev/null +++ b/plugins/beta/draw/src/DrawInit.test.jsx @@ -0,0 +1,166 @@ +import { render, act } from '@testing-library/react' +import { EVENTS } from '../../../../src/config/events.js' +import { DrawInit } from './DrawInit.jsx' +import { loadDrawAdapter } from './adapters/loadDrawAdapter.js' +import { attachEvents } from './events.js' + +jest.mock('./adapters/loadDrawAdapter.js', () => ({ loadDrawAdapter: jest.fn() })) +jest.mock('./events.js', () => ({ attachEvents: jest.fn(() => jest.fn()) })) + +const makeProps = (overrides = {}) => { + const adapter = { remove: jest.fn(), setInterfaceType: jest.fn() } + loadDrawAdapter.mockResolvedValue(adapter) + + const props = { + appState: { interfaceType: 'mouse', mode: null }, + appConfig: { id: 'app' }, + mapState: { + isMapReady: true, + mapStyle: { id: 'outdoor' }, + crossHair: { isVisible: false, fixAtCenter: jest.fn(), hide: jest.fn() } + }, + pluginConfig: { snapLayers: ['a'] }, + pluginState: { dispatch: jest.fn(), mode: null }, + services: { eventBus: { emit: jest.fn() } }, + mapProvider: { draw: null }, + buttonConfig: {}, + ...overrides + } + return { props, adapter } +} + +const renderInit = async (props) => { + let result + await act(async () => { result = render() }) + return result +} + +beforeEach(() => jest.clearAllMocks()) + +describe('adapter lifecycle', () => { + test('loads the adapter and announces readiness when the map is ready', async () => { + const { props, adapter } = makeProps() + + await renderInit(props) + + expect(loadDrawAdapter).toHaveBeenCalledWith(props.mapProvider, expect.objectContaining({ + mapStyle: props.mapState.mapStyle, + snapLayers: props.pluginConfig.snapLayers, + events: EVENTS, + eventBus: props.services.eventBus + })) + expect(props.mapProvider.draw).toBe(adapter) + expect(props.pluginState.dispatch).toHaveBeenCalledWith({ type: 'SET_HAS_SNAP_LAYERS', payload: true }) + expect(props.services.eventBus.emit).toHaveBeenCalledWith('draw:ready') + }) + + test('does not load when the map is not ready', async () => { + const { props } = makeProps({ + mapState: { isMapReady: false, mapStyle: {}, crossHair: { isVisible: false, fixAtCenter: jest.fn(), hide: jest.fn() } } + }) + await renderInit(props) + expect(loadDrawAdapter).not.toHaveBeenCalled() + }) + + test('does not load when the app mode is excluded', async () => { + const { props } = makeProps({ + appState: { interfaceType: 'mouse', mode: 'measure' }, + pluginConfig: { snapLayers: [], excludeModes: ['measure'] } + }) + await renderInit(props) + expect(loadDrawAdapter).not.toHaveBeenCalled() + }) + + test('does not load when the app mode is outside the include list', async () => { + const { props } = makeProps({ + appState: { interfaceType: 'mouse', mode: 'other' }, + pluginConfig: { snapLayers: [], includeModes: ['draw'] } + }) + await renderInit(props) + expect(loadDrawAdapter).not.toHaveBeenCalled() + }) + + test('removes the adapter and clears the reference on unmount', async () => { + const { props, adapter } = makeProps() + const result = await renderInit(props) + expect(props.mapProvider.draw).toBe(adapter) + + await act(async () => { result.unmount() }) + + expect(adapter.remove).toHaveBeenCalled() + expect(props.mapProvider.draw).toBeNull() + }) + + test('ignores a late-resolving adapter after unmount', async () => { + const { props, adapter } = makeProps() + let resolveAdapter + loadDrawAdapter.mockReturnValue(new Promise((resolve) => { resolveAdapter = resolve })) + + let result + await act(async () => { result = render() }) + await act(async () => { result.unmount() }) + await act(async () => { resolveAdapter(adapter); await Promise.resolve() }) + + expect(props.mapProvider.draw).toBeNull() + expect(props.services.eventBus.emit).not.toHaveBeenCalledWith('draw:ready') + }) +}) + +describe('crosshair', () => { + test('fixes the crosshair at centre while drawing on a touch interface', async () => { + const { props } = makeProps({ + appState: { interfaceType: 'touch', mode: null }, + pluginState: { dispatch: jest.fn(), mode: 'draw_polygon' } + }) + await renderInit(props) + expect(props.mapState.crossHair.fixAtCenter).toHaveBeenCalled() + }) + + test('leaves the crosshair alone when not drawing', async () => { + const { props } = makeProps({ + appState: { interfaceType: 'touch', mode: null }, + pluginState: { dispatch: jest.fn(), mode: 'edit_vertex' } + }) + await renderInit(props) + expect(props.mapState.crossHair.fixAtCenter).not.toHaveBeenCalled() + }) +}) + +describe('interface type sync', () => { + test('pushes the interface type to the adapter in edit mode', async () => { + const { props, adapter } = makeProps({ pluginState: { dispatch: jest.fn(), mode: 'edit_vertex' } }) + props.mapProvider.draw = adapter + await renderInit(props) + expect(adapter.setInterfaceType).toHaveBeenCalledWith('mouse') + }) + + test('does nothing outside edit mode', async () => { + const { props, adapter } = makeProps({ pluginState: { dispatch: jest.fn(), mode: 'draw_line' } }) + props.mapProvider.draw = adapter + await renderInit(props) + expect(adapter.setInterfaceType).not.toHaveBeenCalled() + }) +}) + +describe('event attachment', () => { + test('attaches events when a draw adapter is present', async () => { + const { props, adapter } = makeProps() + props.mapProvider.draw = adapter + await renderInit(props) + expect(attachEvents).toHaveBeenCalledWith(expect.objectContaining({ + mapProvider: props.mapProvider, + buttonConfig: props.buttonConfig, + pluginState: props.pluginState, + eventBus: props.services.eventBus, + events: EVENTS + })) + }) + + test('does not attach events without a draw adapter', async () => { + const { props } = makeProps({ + mapState: { isMapReady: false, mapStyle: {}, crossHair: { isVisible: false, fixAtCenter: jest.fn(), hide: jest.fn() } } + }) + await renderInit(props) + expect(attachEvents).not.toHaveBeenCalled() + }) +}) diff --git a/plugins/beta/draw/src/defaults.test.js b/plugins/beta/draw/src/defaults.test.js new file mode 100644 index 000000000..d4f973ebb --- /dev/null +++ b/plugins/beta/draw/src/defaults.test.js @@ -0,0 +1,18 @@ +import { COLORS, SIZES, TOLERANCES, KEYBOARD, MAP_SIZE_SCALES } from './defaults.js' + +describe('draw defaults', () => { + test('exposes colour variants for the edit palette', () => { + expect(COLORS.editStroke).toEqual(expect.objectContaining({ light: expect.any(String), dark: expect.any(String) })) + expect(COLORS.shapeStroke).toEqual(expect.any(String)) + }) + + test('exposes numeric sizes and tolerances', () => { + expect(SIZES.touchTargetSize).toBe(48) + expect(TOLERANCES.snapRadius).toBe(12) + expect(KEYBOARD).toEqual({ nudgeAmount: 1, stepAmount: 5 }) + }) + + test('maps app map sizes to scale factors', () => { + expect(MAP_SIZE_SCALES).toEqual({ small: 1, medium: 1.5, large: 2 }) + }) +}) diff --git a/plugins/beta/draw/src/events.test.js b/plugins/beta/draw/src/events.test.js new file mode 100644 index 000000000..a1105e6d5 --- /dev/null +++ b/plugins/beta/draw/src/events.test.js @@ -0,0 +1,177 @@ +import { attachEvents } from './events.js' + +jest.useFakeTimers() + +const DRAW_EVENTS = ['create', 'editfinish', 'cancel', 'vertexselection', 'vertexchange', 'undochange', 'update'] + +const setup = (overrides = {}) => { + const draw = { + getMode: jest.fn(() => 'draw_polygon'), + done: jest.fn(), + cancel: jest.fn(), + add: jest.fn(), + undo: jest.fn(), + deleteVertex: jest.fn(), + setSnapEnabled: jest.fn(), + changeMode: jest.fn(), + on: jest.fn(), + off: jest.fn() + } + const dispatch = jest.fn() + const pluginState = { dispatch, feature: { id: 'F' }, tempFeature: { id: 'T' }, snap: false, ...overrides.pluginState } + const mapProvider = { draw } + const eventBus = { emit: jest.fn() } + const buttonConfig = { drawDone: {}, drawCancel: {}, drawUndo: {}, drawDeletePoint: {}, drawSnap: {}, ...overrides.buttonConfig } + + const detach = attachEvents({ pluginState, mapProvider, buttonConfig, eventBus }) + return { draw, dispatch, pluginState, mapProvider, eventBus, buttonConfig, detach } +} + +const drawHandler = (draw, event) => draw.on.mock.calls.find(([name]) => name === event)[1] + +beforeEach(() => jest.clearAllTimers()) + +describe('attachEvents – wiring', () => { + test('assigns onClick to each configured button', () => { + const { buttonConfig } = setup() + expect(typeof buttonConfig.drawDone.onClick).toBe('function') + expect(typeof buttonConfig.drawCancel.onClick).toBe('function') + expect(typeof buttonConfig.drawUndo.onClick).toBe('function') + expect(typeof buttonConfig.drawDeletePoint.onClick).toBe('function') + expect(typeof buttonConfig.drawSnap.onClick).toBe('function') + }) + + test('subscribes to every draw event', () => { + const { draw } = setup() + DRAW_EVENTS.forEach((event) => { + expect(draw.on).toHaveBeenCalledWith(event, expect.any(Function)) + }) + }) + + test('tolerates missing optional buttons', () => { + const { buttonConfig, detach } = setup({ buttonConfig: { drawDeletePoint: undefined, drawSnap: undefined } }) + expect(typeof buttonConfig.drawDone.onClick).toBe('function') + expect(() => detach()).not.toThrow() + }) +}) + +describe('button handlers', () => { + test('done disables snap and finishes', () => { + const { buttonConfig, draw, dispatch } = setup() + buttonConfig.drawDone.onClick() + expect(dispatch).toHaveBeenCalledWith({ type: 'SET_SNAP', payload: false }) + expect(draw.setSnapEnabled).toHaveBeenCalledWith(false) + expect(draw.done).toHaveBeenCalled() + }) + + test('cancel re-adds the feature when cancelling a vertex edit', () => { + const { buttonConfig, draw, dispatch, eventBus } = setup() + draw.getMode.mockReturnValue('edit_vertex') + + buttonConfig.drawCancel.onClick() + + expect(draw.add).toHaveBeenCalledWith({ id: 'F' }) + expect(draw.cancel).toHaveBeenCalled() + expect(dispatch).toHaveBeenCalledWith({ type: 'SET_MODE', payload: null }) + expect(dispatch).toHaveBeenCalledWith({ type: 'SET_FEATURE', payload: { feature: null, tempFeature: null } }) + expect(eventBus.emit).toHaveBeenCalledWith('draw:cancelled', { id: 'F' }) + }) + + test('cancel does not re-add outside a vertex edit', () => { + const { buttonConfig, draw } = setup() + draw.getMode.mockReturnValue('draw_polygon') + buttonConfig.drawCancel.onClick() + expect(draw.add).not.toHaveBeenCalled() + }) + + test('cancel does not re-add a vertex edit without a temp feature', () => { + const { buttonConfig, draw } = setup({ pluginState: { tempFeature: null } }) + draw.getMode.mockReturnValue('edit_vertex') + buttonConfig.drawCancel.onClick() + expect(draw.add).not.toHaveBeenCalled() + }) + + test('undo and delete-vertex delegate to the adapter', () => { + const { buttonConfig, draw } = setup() + buttonConfig.drawUndo.onClick() + buttonConfig.drawDeletePoint.onClick() + expect(draw.undo).toHaveBeenCalled() + expect(draw.deleteVertex).toHaveBeenCalled() + }) + + test('snap toggles state and syncs the adapter', () => { + const { buttonConfig, draw, dispatch } = setup({ pluginState: { snap: false } }) + buttonConfig.drawSnap.onClick() + expect(dispatch).toHaveBeenCalledWith({ type: 'TOGGLE_SNAP' }) + expect(draw.setSnapEnabled).toHaveBeenCalledWith(true) + }) +}) + +describe('draw event handlers', () => { + test('create resets state, disables mode asynchronously and emits', () => { + const { draw, dispatch, eventBus } = setup() + drawHandler(draw, 'create')({ id: 'new' }) + + expect(dispatch).toHaveBeenCalledWith({ type: 'SET_SNAP', payload: false }) + expect(dispatch).toHaveBeenCalledWith({ type: 'SET_MODE', payload: null }) + expect(eventBus.emit).toHaveBeenCalledWith('draw:created', { id: 'new' }) + + jest.runAllTimers() + expect(draw.changeMode).toHaveBeenCalledWith('disabled') + }) + + test('editfinish resets state and emits draw:edited', () => { + const { draw, eventBus } = setup() + drawHandler(draw, 'editfinish')({ id: 'edited' }) + expect(eventBus.emit).toHaveBeenCalledWith('draw:edited', { id: 'edited' }) + jest.runAllTimers() + expect(draw.changeMode).toHaveBeenCalledWith('disabled') + }) + + test('cancel handler is a no-op', () => { + const { draw } = setup() + expect(() => drawHandler(draw, 'cancel')()).not.toThrow() + }) + + test('vertexselection dispatches and emits', () => { + const { draw, dispatch, eventBus } = setup() + drawHandler(draw, 'vertexselection')({ index: 2 }) + expect(dispatch).toHaveBeenCalledWith({ type: 'SET_SELECTED_VERTEX_INDEX', payload: { index: 2 } }) + expect(eventBus.emit).toHaveBeenCalledWith('draw:vertexselection', { index: 2 }) + }) + + test('vertexchange resets the selected index with the new count', () => { + const { draw, dispatch } = setup() + drawHandler(draw, 'vertexchange')({ numVertices: 5 }) + expect(dispatch).toHaveBeenCalledWith({ type: 'SET_SELECTED_VERTEX_INDEX', payload: { index: -1, numVertices: 5 } }) + }) + + test('undochange dispatches the stack length', () => { + const { draw, dispatch } = setup() + drawHandler(draw, 'undochange')(4) + expect(dispatch).toHaveBeenCalledWith({ type: 'SET_UNDO_STACK_LENGTH', payload: 4 }) + }) + + test('update emits draw:updated', () => { + const { draw, eventBus } = setup() + drawHandler(draw, 'update')({ id: 'u' }) + expect(eventBus.emit).toHaveBeenCalledWith('draw:updated', { id: 'u' }) + }) +}) + +describe('detach', () => { + test('clears button handlers and unsubscribes from draw events', () => { + const { detach, buttonConfig, draw } = setup() + + detach() + + expect(buttonConfig.drawDone.onClick).toBeNull() + expect(buttonConfig.drawCancel.onClick).toBeNull() + expect(buttonConfig.drawUndo.onClick).toBeNull() + expect(buttonConfig.drawDeletePoint.onClick).toBeNull() + expect(buttonConfig.drawSnap.onClick).toBeNull() + DRAW_EVENTS.forEach((event) => { + expect(draw.off).toHaveBeenCalledWith(event, expect.any(Function)) + }) + }) +}) diff --git a/plugins/beta/draw/src/index.test.js b/plugins/beta/draw/src/index.test.js new file mode 100644 index 000000000..01cb9d0e8 --- /dev/null +++ b/plugins/beta/draw/src/index.test.js @@ -0,0 +1,24 @@ +import createPlugin from './index.js' +import { manifest } from './manifest.js' + +jest.mock('./manifest.js', () => ({ manifest: { id: 'draw-manifest' } })) + +describe('createPlugin', () => { + test('returns a plugin descriptor with a fixed id and passes options through', () => { + const plugin = createPlugin({ foo: 'bar' }) + expect(plugin).toMatchObject({ id: 'draw', foo: 'bar' }) + expect(typeof plugin.load).toBe('function') + }) + + test('the fixed id cannot be overridden by options', () => { + expect(createPlugin({ id: 'other' }).id).toBe('draw') + }) + + test('defaults options to an empty object', () => { + expect(() => createPlugin()).not.toThrow() + }) + + test('load dynamically imports the manifest', async () => { + await expect(createPlugin().load()).resolves.toBe(manifest) + }) +}) diff --git a/plugins/beta/draw/src/manifest.js b/plugins/beta/draw/src/manifest.js index 917a10d80..bf7ffc9df 100644 --- a/plugins/beta/draw/src/manifest.js +++ b/plugins/beta/draw/src/manifest.js @@ -47,10 +47,10 @@ export const manifest = { exclusiveSlot: true, hiddenWhen: ({ pluginState }) => !['draw_polygon', 'draw_line', 'edit_vertex'].includes(pluginState.mode), enableWhen: ({ pluginState }) => { - if (pluginState.mode === 'draw_polygon') { return pluginState.numVertices >= 3 } // NOSONAR - if (pluginState.mode === 'draw_line') { return pluginState.numVertices >= 2 } // NOSONAR - if (pluginState.mode === 'edit_vertex') { return true } - return false + const { mode, numVertices } = pluginState + return (mode === 'draw_polygon' && numVertices >= 3) || // NOSONAR + (mode === 'draw_line' && numVertices >= 2) || // NOSONAR + mode === 'edit_vertex' }, ...createButtonSlots(true) }, diff --git a/plugins/beta/draw/src/manifest.test.js b/plugins/beta/draw/src/manifest.test.js new file mode 100644 index 000000000..b14feb4db --- /dev/null +++ b/plugins/beta/draw/src/manifest.test.js @@ -0,0 +1,106 @@ +import { manifest } from './manifest.js' + +const findButton = (id) => manifest.buttons.find((b) => b.id === id) +const findMenuItem = (menuId, itemId) => findButton(menuId).menuItems.find((m) => m.id === itemId) + +describe('manifest structure', () => { + test('exposes the reducer, init component and api surface', () => { + expect(manifest.reducer).toHaveProperty('initialState') + expect(manifest.reducer).toHaveProperty('actions') + expect(manifest.InitComponent).toBeDefined() + expect(Object.keys(manifest.api)).toEqual(expect.arrayContaining([ + 'newPolygon', 'newLine', 'editFeature', 'addFeature', 'deleteFeature', 'split', 'merge' + ])) + }) +}) + +describe('drawCancel', () => { + test('is hidden only when there is no active mode', () => { + expect(findButton('drawCancel').hiddenWhen({ pluginState: { mode: null } })).toBe(true) + expect(findButton('drawCancel').hiddenWhen({ pluginState: { mode: 'draw_line' } })).toBe(false) + }) +}) + +describe('drawAddPoint', () => { + const hidden = (interfaceType, mode) => + findButton('drawAddPoint').hiddenWhen({ appState: { interfaceType }, pluginState: { mode } }) + + test('is shown only while drawing on a touch interface', () => { + expect(hidden('touch', 'draw_polygon')).toBe(false) + expect(hidden('mouse', 'draw_polygon')).toBe(true) + expect(hidden('touch', 'edit_vertex')).toBe(true) + }) +}) + +describe('drawDone', () => { + const btn = () => findButton('drawDone') + + test('is hidden outside draw/edit modes', () => { + expect(btn().hiddenWhen({ pluginState: { mode: null } })).toBe(true) + expect(btn().hiddenWhen({ pluginState: { mode: 'draw_polygon' } })).toBe(false) + }) + + test('enables per mode and vertex count', () => { + expect(btn().enableWhen({ pluginState: { mode: 'draw_polygon', numVertices: 3 } })).toBe(true) + expect(btn().enableWhen({ pluginState: { mode: 'draw_polygon', numVertices: 2 } })).toBe(false) + expect(btn().enableWhen({ pluginState: { mode: 'draw_line', numVertices: 2 } })).toBe(true) + expect(btn().enableWhen({ pluginState: { mode: 'draw_line', numVertices: 1 } })).toBe(false) + expect(btn().enableWhen({ pluginState: { mode: 'edit_vertex' } })).toBe(true) + expect(btn().enableWhen({ pluginState: { mode: 'disabled' } })).toBe(false) + }) +}) + +describe('drawMenu', () => { + test('is hidden outside draw/edit modes', () => { + expect(findButton('drawMenu').hiddenWhen({ pluginState: { mode: null } })).toBe(true) + expect(findButton('drawMenu').hiddenWhen({ pluginState: { mode: 'edit_vertex' } })).toBe(false) + }) + + describe('drawUndo', () => { + const item = () => findMenuItem('drawMenu', 'drawUndo') + + test('is hidden outside draw/edit modes', () => { + expect(item().hiddenWhen({ pluginState: { mode: null } })).toBe(true) + expect(item().hiddenWhen({ pluginState: { mode: 'draw_line' } })).toBe(false) + }) + + test('enables from vertex count while drawing and from the undo stack while editing', () => { + expect(item().enableWhen({ pluginState: { mode: 'draw_polygon', numVertices: 1 } })).toBe(true) + expect(item().enableWhen({ pluginState: { mode: 'draw_polygon', numVertices: 0 } })).toBe(false) + expect(item().enableWhen({ pluginState: { mode: 'edit_vertex', undoStackLength: 2 } })).toBe(true) + expect(item().enableWhen({ pluginState: { mode: 'edit_vertex', undoStackLength: 0 } })).toBe(false) + }) + }) + + describe('drawSnap', () => { + const item = () => findMenuItem('drawMenu', 'drawSnap') + + test('is hidden without a mode or snap layers', () => { + expect(item().hiddenWhen({ pluginState: { mode: null, hasSnapLayers: true } })).toBe(true) + expect(item().hiddenWhen({ pluginState: { mode: 'draw_line', hasSnapLayers: false } })).toBe(true) + expect(item().hiddenWhen({ pluginState: { mode: 'draw_line', hasSnapLayers: true } })).toBe(false) + }) + + test('is pressed when snapping is enabled', () => { + expect(item().pressedWhen({ pluginState: { snap: true } })).toBe(true) + expect(item().pressedWhen({ pluginState: { snap: false } })).toBe(false) + }) + }) + + describe('drawDeletePoint', () => { + const item = () => findMenuItem('drawMenu', 'drawDeletePoint') + + test('is hidden outside edit mode', () => { + expect(item().hiddenWhen({ pluginState: { mode: 'draw_polygon' } })).toBe(true) + expect(item().hiddenWhen({ pluginState: { mode: 'edit_vertex' } })).toBe(false) + }) + + test('enables only with a selection and enough vertices (polygon vs line)', () => { + expect(item().enableWhen({ pluginState: { selectedVertexIndex: -1 } })).toBe(false) + expect(item().enableWhen({ pluginState: { selectedVertexIndex: 0, feature: { geometry: { type: 'Polygon' } }, numVertices: 4 } })).toBe(true) + expect(item().enableWhen({ pluginState: { selectedVertexIndex: 0, feature: { geometry: { type: 'Polygon' } }, numVertices: 3 } })).toBe(false) + expect(item().enableWhen({ pluginState: { selectedVertexIndex: 0, feature: { geometry: { type: 'LineString' } }, numVertices: 3 } })).toBe(true) + expect(item().enableWhen({ pluginState: { selectedVertexIndex: 0, feature: { geometry: { type: 'LineString' } }, numVertices: 2 } })).toBe(false) + }) + }) +}) diff --git a/plugins/beta/draw/src/reducer.test.js b/plugins/beta/draw/src/reducer.test.js new file mode 100644 index 000000000..e9a332367 --- /dev/null +++ b/plugins/beta/draw/src/reducer.test.js @@ -0,0 +1,77 @@ +import { initialState, actions } from './reducer.js' + +describe('initialState', () => { + test('has sensible defaults', () => { + expect(initialState).toMatchObject({ + mode: null, + action: null, + actionValid: false, + feature: null, + tempFeature: null, + selectedVertexIndex: -1, + numVertices: null, + snap: false, + hasSnapLayers: false, + undoStackLength: 0 + }) + }) +}) + +describe('SET_MODE', () => { + test('resets numVertices to 0 for draw modes', () => { + expect(actions.SET_MODE(initialState, 'draw_polygon')).toMatchObject({ mode: 'draw_polygon', numVertices: 0 }) + expect(actions.SET_MODE(initialState, 'draw_line')).toMatchObject({ mode: 'draw_line', numVertices: 0 }) + }) + + test('preserves numVertices for non-draw modes', () => { + const state = { ...initialState, numVertices: 5 } + expect(actions.SET_MODE(state, 'edit_vertex')).toMatchObject({ mode: 'edit_vertex', numVertices: 5 }) + expect(actions.SET_MODE(state, null)).toMatchObject({ mode: null, numVertices: 5 }) + }) +}) + +describe('SET_ACTION', () => { + test('sets the action name and validity', () => { + expect(actions.SET_ACTION(initialState, { name: 'split', isValid: true })) + .toMatchObject({ action: 'split', actionValid: true }) + }) +}) + +describe('SET_FEATURE', () => { + test('updates provided fields and preserves undefined ones', () => { + const state = { ...initialState, feature: 'F', tempFeature: 'T' } + expect(actions.SET_FEATURE(state, { feature: 'F2' })).toMatchObject({ feature: 'F2', tempFeature: 'T' }) + expect(actions.SET_FEATURE(state, { tempFeature: null })).toMatchObject({ feature: 'F', tempFeature: null }) + expect(actions.SET_FEATURE(state, { feature: null, tempFeature: null })).toMatchObject({ feature: null, tempFeature: null }) + }) +}) + +describe('SET_SELECTED_VERTEX_INDEX', () => { + test('sets the index and numVertices', () => { + expect(actions.SET_SELECTED_VERTEX_INDEX(initialState, { index: 2, numVertices: 4 })) + .toMatchObject({ selectedVertexIndex: 2, numVertices: 4 }) + }) +}) + +describe('snap actions', () => { + test('TOGGLE_SNAP flips the snap flag', () => { + expect(actions.TOGGLE_SNAP({ ...initialState, snap: false }).snap).toBe(true) + expect(actions.TOGGLE_SNAP({ ...initialState, snap: true }).snap).toBe(false) + }) + + test('SET_SNAP coerces the payload to a boolean', () => { + expect(actions.SET_SNAP(initialState, 1).snap).toBe(true) + expect(actions.SET_SNAP(initialState, 0).snap).toBe(false) + }) + + test('SET_HAS_SNAP_LAYERS coerces the payload to a boolean', () => { + expect(actions.SET_HAS_SNAP_LAYERS(initialState, ['x']).hasSnapLayers).toBe(true) + expect(actions.SET_HAS_SNAP_LAYERS(initialState, null).hasSnapLayers).toBe(false) + }) +}) + +describe('SET_UNDO_STACK_LENGTH', () => { + test('sets the undo stack length', () => { + expect(actions.SET_UNDO_STACK_LENGTH(initialState, 3).undoStackLength).toBe(3) + }) +}) From d1818edba44b8fa1f80a4e9d4d8ffa97b186c01c Mon Sep 17 00:00:00 2001 From: Dan Leech Date: Sun, 5 Jul 2026 15:13:49 +0100 Subject: [PATCH 33/89] Shared adapter events and OpenLayers refactor plus tests --- plugins/beta/draw/src/adapterEvents.js | 34 ++ plugins/beta/draw/src/adapterEvents.test.js | 17 + .../adapters/maplibre/MaplibreDrawAdapter.js | 23 +- .../openlayers/__helpers__/harness.js | 88 ++++ .../adapters/openlayers/core/OLDrawManager.js | 6 +- .../openlayers/core/internalEvents.js | 8 + .../src/adapters/openlayers/draw/DrawMode.js | 14 +- .../src/adapters/openlayers/draw/drawInput.js | 147 +----- .../openlayers/draw/drawInput.test.js | 111 ++++ .../openlayers/draw/vertexPlacement.js | 141 ++++++ .../openlayers/draw/vertexPlacement.test.js | 155 ++++++ .../src/adapters/openlayers/edit/EditMode.js | 478 +++++++----------- .../adapters/openlayers/edit/EditMode.test.js | 195 +++++++ .../openlayers/edit/activeVertexLayer.js | 55 ++ .../openlayers/edit/activeVertexLayer.test.js | 53 ++ .../openlayers/edit/keyboardHandler.js | 168 ++---- .../openlayers/edit/keyboardHandler.test.js | 135 +++++ .../openlayers/edit/modifyInteraction.js | 83 +++ .../openlayers/edit/modifyInteraction.test.js | 80 +++ .../src/adapters/openlayers/edit/nudge.js | 91 ++++ .../adapters/openlayers/edit/nudge.test.js | 116 +++++ .../openlayers/edit/pointerHandlers.js | 79 +++ .../openlayers/edit/pointerHandlers.test.js | 78 +++ .../openlayers/edit/selectionState.js | 100 ++++ .../openlayers/edit/selectionState.test.js | 81 +++ .../adapters/openlayers/edit/vertexHitTest.js | 4 +- plugins/beta/draw/src/api/split.js | 7 +- plugins/beta/draw/src/events.js | 29 +- 28 files changed, 1990 insertions(+), 586 deletions(-) create mode 100644 plugins/beta/draw/src/adapterEvents.js create mode 100644 plugins/beta/draw/src/adapterEvents.test.js create mode 100644 plugins/beta/draw/src/adapters/openlayers/__helpers__/harness.js create mode 100644 plugins/beta/draw/src/adapters/openlayers/core/internalEvents.js create mode 100644 plugins/beta/draw/src/adapters/openlayers/draw/drawInput.test.js create mode 100644 plugins/beta/draw/src/adapters/openlayers/draw/vertexPlacement.js create mode 100644 plugins/beta/draw/src/adapters/openlayers/draw/vertexPlacement.test.js create mode 100644 plugins/beta/draw/src/adapters/openlayers/edit/EditMode.test.js create mode 100644 plugins/beta/draw/src/adapters/openlayers/edit/activeVertexLayer.js create mode 100644 plugins/beta/draw/src/adapters/openlayers/edit/activeVertexLayer.test.js create mode 100644 plugins/beta/draw/src/adapters/openlayers/edit/keyboardHandler.test.js create mode 100644 plugins/beta/draw/src/adapters/openlayers/edit/modifyInteraction.js create mode 100644 plugins/beta/draw/src/adapters/openlayers/edit/modifyInteraction.test.js create mode 100644 plugins/beta/draw/src/adapters/openlayers/edit/nudge.js create mode 100644 plugins/beta/draw/src/adapters/openlayers/edit/nudge.test.js create mode 100644 plugins/beta/draw/src/adapters/openlayers/edit/pointerHandlers.js create mode 100644 plugins/beta/draw/src/adapters/openlayers/edit/pointerHandlers.test.js create mode 100644 plugins/beta/draw/src/adapters/openlayers/edit/selectionState.js create mode 100644 plugins/beta/draw/src/adapters/openlayers/edit/selectionState.test.js diff --git a/plugins/beta/draw/src/adapterEvents.js b/plugins/beta/draw/src/adapterEvents.js new file mode 100644 index 000000000..b68c63a35 --- /dev/null +++ b/plugins/beta/draw/src/adapterEvents.js @@ -0,0 +1,34 @@ +/** + * Shared adapter event contract. + * + * Both draw adapters (MaplibreDrawAdapter, OLDrawAdapter) expose an on/off bus + * that emits these events. events.js and the api entry points consume them, so + * the names and payload shapes below are the contract between the adapters and + * the framework-agnostic plugin layer. + * + * Engine-specific event names stay with their adapter — e.g. the map-level + * `draw.*` events mapbox-gl-draw fires live in adapters/maplibre/drawEvents.js, + * and the MapLibre adapter normalises them onto this contract. + * + * Payloads: + * CREATE GeoJSON feature that was drawn + * EDIT_FINISH GeoJSON feature after editing + * CANCEL (none) + * VERTEX_SELECTION { index, numVertices } + * VERTEX_CHANGE { numVertices } + * UNDO_CHANGE number — current undo stack length + * UPDATE GeoJSON feature after a vertex operation + * GEOMETRY_CHANGE in-progress geometry (real-time preview, e.g. split) + * INTERFACE_TYPE_CHANGE { interfaceType: 'mouse' | 'touch' | 'keyboard' } + */ +export const ADAPTER_EVENTS = { + CREATE: 'create', + EDIT_FINISH: 'editfinish', + CANCEL: 'cancel', + VERTEX_SELECTION: 'vertexselection', + VERTEX_CHANGE: 'vertexchange', + UNDO_CHANGE: 'undochange', + UPDATE: 'update', + GEOMETRY_CHANGE: 'geometrychange', + INTERFACE_TYPE_CHANGE: 'interfacetypechange' +} diff --git a/plugins/beta/draw/src/adapterEvents.test.js b/plugins/beta/draw/src/adapterEvents.test.js new file mode 100644 index 000000000..0ece7508a --- /dev/null +++ b/plugins/beta/draw/src/adapterEvents.test.js @@ -0,0 +1,17 @@ +import { ADAPTER_EVENTS } from './adapterEvents.js' + +describe('adapter event contract', () => { + test('event names are stable — renaming a value breaks adapter consumers', () => { + expect(ADAPTER_EVENTS).toEqual({ + CREATE: 'create', + EDIT_FINISH: 'editfinish', + CANCEL: 'cancel', + VERTEX_SELECTION: 'vertexselection', + VERTEX_CHANGE: 'vertexchange', + UNDO_CHANGE: 'undochange', + UPDATE: 'update', + GEOMETRY_CHANGE: 'geometrychange', + INTERFACE_TYPE_CHANGE: 'interfacetypechange' + }) + }) +}) diff --git a/plugins/beta/draw/src/adapters/maplibre/MaplibreDrawAdapter.js b/plugins/beta/draw/src/adapters/maplibre/MaplibreDrawAdapter.js index 0223a4790..4eb30ef9b 100644 --- a/plugins/beta/draw/src/adapters/maplibre/MaplibreDrawAdapter.js +++ b/plugins/beta/draw/src/adapters/maplibre/MaplibreDrawAdapter.js @@ -2,6 +2,7 @@ import { createMapboxDraw } from './mapboxDraw.js' import { getSnapInstance, clearSnapState, clearSnapIndicator } from './utils/snapHelpers.js' import { createEventBus } from '../../utils/eventBus.js' import { MAPBOX_DRAW_EVENTS, CUSTOM_DRAW_EVENTS, STYLE_DATA_EVENT } from './drawEvents.js' +import { ADAPTER_EVENTS } from '../../adapterEvents.js' /** * Draw adapter for MapLibre GL. @@ -37,18 +38,18 @@ export class MaplibreDrawAdapter { this._draw = draw this._cleanupDraw = remove - // Normalise ML map events → shared adapter event bus. - // draw-ol emits these same event names directly from OLDrawManager. + // Normalise ML map events → the shared adapter event contract (adapterEvents.js). + // The OL adapter emits the same contract directly from OLDrawManager. this._mapHandlers = { - create: (e) => this._bus.emit('create', e.features[0]), - editfinish: (e) => this._bus.emit('editfinish', e.features[0]), - cancel: () => this._bus.emit('cancel'), - // Normalise typo: draw-ml fires numVertecies, shared interface uses numVertices - vertexselection: (e) => this._bus.emit('vertexselection', { ...e, numVertices: e.numVertecies }), - vertexchange: (e) => this._bus.emit('vertexchange', { ...e, numVertices: e.numVertecies }), - undochange: (e) => this._bus.emit('undochange', e.length), - update: (e) => this._bus.emit('update', e.features[0]), - geometrychange: (e) => this._bus.emit('geometrychange', e), + create: (e) => this._bus.emit(ADAPTER_EVENTS.CREATE, e.features[0]), + editfinish: (e) => this._bus.emit(ADAPTER_EVENTS.EDIT_FINISH, e.features[0]), + cancel: () => this._bus.emit(ADAPTER_EVENTS.CANCEL), + // Normalise typo: the ML modes fire numVertecies, the contract uses numVertices + vertexselection: (e) => this._bus.emit(ADAPTER_EVENTS.VERTEX_SELECTION, { ...e, numVertices: e.numVertecies }), + vertexchange: (e) => this._bus.emit(ADAPTER_EVENTS.VERTEX_CHANGE, { ...e, numVertices: e.numVertecies }), + undochange: (e) => this._bus.emit(ADAPTER_EVENTS.UNDO_CHANGE, e.length), + update: (e) => this._bus.emit(ADAPTER_EVENTS.UPDATE, e.features[0]), + geometrychange: (e) => this._bus.emit(ADAPTER_EVENTS.GEOMETRY_CHANGE, e), modechange: (e) => this._handleModeChange(e), styledata: () => this._handleStyleData() } diff --git a/plugins/beta/draw/src/adapters/openlayers/__helpers__/harness.js b/plugins/beta/draw/src/adapters/openlayers/__helpers__/harness.js new file mode 100644 index 000000000..966a8372a --- /dev/null +++ b/plugins/beta/draw/src/adapters/openlayers/__helpers__/harness.js @@ -0,0 +1,88 @@ +import Feature from 'ol/Feature.js' +import Polygon from 'ol/geom/Polygon.js' +import LineString from 'ol/geom/LineString.js' +import Style from 'ol/style/Style.js' +import { createUndoStack } from '../../../utils/undoStack.js' + +/** + * Shared test doubles for the OL adapter. The fake map uses an identity + * mapping between map coordinates and screen pixels so hit-test assertions + * read naturally: a vertex at [10, 20] sits at pixel (10, 20). + */ + +export const createEmitter = () => { + const listeners = {} + return { + on: jest.fn((type, h) => { (listeners[type] ??= []).push(h) }), + un: jest.fn((type, h) => { listeners[type] = (listeners[type] ?? []).filter(x => x !== h) }), + once: jest.fn((type, h) => { (listeners[`once:${type}`] ??= []).push(h) }), + emit (type, e) { + (listeners[type] ?? []).forEach(h => h(e)); + (listeners[`once:${type}`] ?? []).splice(0).forEach(h => h(e)) + } + } +} + +export const createFakeMap = ({ center = [50, 50] } = {}) => { + const emitter = createEmitter() + const viewport = document.createElement('div') + document.body.appendChild(viewport) + const view = { ...createEmitter(), getCenter: () => center, getAnimating: jest.fn(() => false) } + return { + ...emitter, + interactions: [], + layers: [], + addInteraction (i) { this.interactions.push(i) }, + removeInteraction: jest.fn(function (i) { this.interactions = this.interactions.filter(x => x !== i) }), + addLayer (l) { this.layers.push(l) }, + removeLayer: jest.fn(function (l) { this.layers = this.layers.filter(x => x !== l) }), + getViewport: () => viewport, + getPixelFromCoordinate: (c) => [c[0], c[1]], + getCoordinateFromPixel: (p) => [p[0], p[1]], + getEventPixel: (e) => [e.clientX, e.clientY], + getView: () => view, + render: jest.fn() + } +} + +// Manager double matching the OLDrawManager surface EditMode/DrawMode consume +export const createFakeManager = () => { + const bus = createEmitter() + return { + on: bus.on, + off: bus.un, + emit: jest.fn(bus.emit), + // Real Style instances — OL asserts on setStyle args; identify them by reference + styles: { + editFeatureStyle: new Style({}), + vertexStyle: new Style({}), + midpointStyle: new Style({}), + selectedVertexStyle: new Style({}), + selectedMidpointStyle: new Style({}) + }, + colors: { editVertex: 'rgba(29,112,184,1)' }, + undoStack: createUndoStack(() => {}) + } +} + +export const polygonFeature = (ring, id = 'f1') => { + const feature = new Feature(new Polygon([ring])) + feature.setId(id) + return feature +} + +export const lineFeature = (coords, id = 'f1') => { + const feature = new Feature(new LineString(coords)) + feature.setId(id) + return feature +} + +export const createContainer = () => { + const container = document.createElement('div') + container.tabIndex = 0 + document.body.appendChild(container) + return container +} + +// jsdom has no PointerEvent/TouchEvent constructors — build plain events with the fields the handlers read +export const domEvent = (type, props) => Object.assign(new Event(type, { bubbles: true }), props) diff --git a/plugins/beta/draw/src/adapters/openlayers/core/OLDrawManager.js b/plugins/beta/draw/src/adapters/openlayers/core/OLDrawManager.js index 78a642c6a..325235729 100644 --- a/plugins/beta/draw/src/adapters/openlayers/core/OLDrawManager.js +++ b/plugins/beta/draw/src/adapters/openlayers/core/OLDrawManager.js @@ -7,6 +7,8 @@ import { createSnapManager } from '../snap/snapManager.js' import { createDrawMode } from '../draw/DrawMode.js' import { createEditMode } from '../edit/EditMode.js' import { TOLERANCES } from '../defaults.js' +import { ADAPTER_EVENTS } from '../../../adapterEvents.js' +import { STYLES_CHANGED_EVENT } from './internalEvents.js' /** * Mode machine for the OL draw plugin. @@ -27,7 +29,7 @@ export class OLDrawManager { this._listeners = new Map() this.store = createFeatureStore() - this.undoStack = createUndoStack((length) => this.emit('undochange', length)) + this.undoStack = createUndoStack((length) => this.emit(ADAPTER_EVENTS.UNDO_CHANGE, length)) this.colors = resolveColors(null, pluginConfig) this.styles = createStyles(this.colors) @@ -50,7 +52,7 @@ export class OLDrawManager { this._layer.setStyle(this.styles.createFeatureStyle()) this.store.source.changed() this.snap?.updateColors(this.colors) - this.emit('styleschanged', this.styles) + this.emit(STYLES_CHANGED_EVENT, this.styles) } // --- Internal event bus --- diff --git a/plugins/beta/draw/src/adapters/openlayers/core/internalEvents.js b/plugins/beta/draw/src/adapters/openlayers/core/internalEvents.js new file mode 100644 index 000000000..739f9dc58 --- /dev/null +++ b/plugins/beta/draw/src/adapters/openlayers/core/internalEvents.js @@ -0,0 +1,8 @@ +/** + * Manager-internal event names (these never cross the adapter boundary — + * the shared contract lives in src/adapterEvents.js). + */ + +// Fired by OLDrawManager when the resolved colour set / style instances are +// rebuilt after a map style change; modes re-style their layers in response. +export const STYLES_CHANGED_EVENT = 'styleschanged' diff --git a/plugins/beta/draw/src/adapters/openlayers/draw/DrawMode.js b/plugins/beta/draw/src/adapters/openlayers/draw/DrawMode.js index 14961f8e6..95125d0f0 100644 --- a/plugins/beta/draw/src/adapters/openlayers/draw/DrawMode.js +++ b/plugins/beta/draw/src/adapters/openlayers/draw/DrawMode.js @@ -3,6 +3,8 @@ import { noModifierKeys } from 'ol/events/condition.js' import { createDrawInput } from './drawInput.js' import { getPlacedSketchCoords, getLastPlacedSketchCoord } from '../utils/sketchHelpers.js' import { TOLERANCES } from '../defaults.js' +import { ADAPTER_EVENTS } from '../../../adapterEvents.js' +import { STYLES_CHANGED_EVENT } from '../core/internalEvents.js' const MIN_VERTICES = { Polygon: 3, LineString: 2 } const canFinish = (geometryType, sketchFeature) => { @@ -53,7 +55,7 @@ export const createDrawMode = ({ map, manager, options }) => { currentSketchStyle = manager.styles.createSketchStyle(geometryType) drawInteraction.overlay_.changed() } - manager.on('styleschanged', onStylesChanged) + manager.on(STYLES_CHANGED_EVENT, onStylesChanged) // OL internal: overlay_ is the private VectorLayer used for the sketch geometry. // updateWhileAnimating_ forces per-frame redraws during view animations (keyboard pan). // Without this, geom.setCoordinates() calls are ignored while the ANIMATING hint is set. @@ -62,7 +64,7 @@ export const createDrawMode = ({ map, manager, options }) => { const updateVertexCount = () => { if (!sketchFeature) { return } - manager.emit('vertexchange', { numVertices: getPlacedSketchCoords(sketchFeature.getGeometry()).length }) + manager.emit(ADAPTER_EVENTS.VERTEX_CHANGE, { numVertices: getPlacedSketchCoords(sketchFeature.getGeometry()).length }) } drawInteraction.on('drawstart', (e) => { @@ -75,11 +77,11 @@ export const createDrawMode = ({ map, manager, options }) => { olFeature.setId(String(featureId)) olFeature.setProperties(properties) manager.store.source.addFeature(olFeature) - manager.emit('create', manager.store.toGeoJSON(olFeature)) + manager.emit(ADAPTER_EVENTS.CREATE, manager.store.toGeoJSON(olFeature)) // Mode switches to disabled in events.js after receiving 'create' }) - drawInteraction.on('drawabort', () => { manager.emit('cancel') }) + drawInteraction.on('drawabort', () => { manager.emit(ADAPTER_EVENTS.CANCEL) }) const input = createDrawInput({ drawInteraction, @@ -102,10 +104,10 @@ export const createDrawMode = ({ map, manager, options }) => { cancel () { drawInteraction.abortDrawing() }, undo () { drawInteraction.removeLastPoint(); updateVertexCount() }, destroy () { - manager.off('styleschanged', onStylesChanged) + manager.off(STYLES_CHANGED_EVENT, onStylesChanged) // Emit the final interfaceType from draw mode so it's synced back to appState // This ensures crosshair visibility is correct when exiting draw mode - manager.emit('interfacetypechange', { interfaceType: input.getInterfaceType() }) + manager.emit(ADAPTER_EVENTS.INTERFACE_TYPE_CHANGE, { interfaceType: input.getInterfaceType() }) input.destroy() map.removeInteraction(drawInteraction) sketchFeature = null diff --git a/plugins/beta/draw/src/adapters/openlayers/draw/drawInput.js b/plugins/beta/draw/src/adapters/openlayers/draw/drawInput.js index 0d3159706..6a68c6693 100644 --- a/plugins/beta/draw/src/adapters/openlayers/draw/drawInput.js +++ b/plugins/beta/draw/src/adapters/openlayers/draw/drawInput.js @@ -1,45 +1,7 @@ -import { coordToPixel, pixelDist } from '../utils/olCoords.js' -import { getLastPlacedSketchCoord } from '../utils/sketchHelpers.js' +import { createVertexPlacement } from './vertexPlacement.js' -const SNAP_TOLERANCE = 12 // pixels -// Minimum ring length to allow snap-to-close (placed vertices + rubber-band) -const MIN_SKETCH_COORDS = { Polygon: 4, LineString: 3 } -const DUPLICATE_TOLERANCE_PX = 2 const ARROW_KEYS = new Set(['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown']) -const isCloseToFirstVertex = (map, coord, sketchCoords, geometryType) => { - if (geometryType !== 'Polygon' || sketchCoords.length < MIN_SKETCH_COORDS.Polygon) { - return false - } - const firstCoord = sketchCoords[0] - const currentPixel = coordToPixel(map, coord) - const firstPixel = coordToPixel(map, firstCoord) - if (!currentPixel || !firstPixel) { - return false - } - return pixelDist(currentPixel, firstPixel) < SNAP_TOLERANCE -} - -const applyRubberbanding = (geom, centerCoord) => { - if (geom.getType() === 'LineString') { - const updated = [...geom.getCoordinates()] - updated[updated.length - 1] = centerCoord - geom.setCoordinates(updated) - } else if (geom.getType() === 'Polygon') { - const updated = geom.getCoordinates().map((ring, i) => { - if (i !== 0) { - return ring - } - const r = [...ring] - r[r.length - 1] = centerCoord - return r - }) - geom.setCoordinates(updated) - } else { - // No action - } -} - const wireInputEvents = ({ container, addVertexButtonId, olView, onUndo, getInterfaceType, setInterfaceType, clearLastCoord, @@ -113,91 +75,26 @@ const wireInputEvents = ({ } } +/** + * Touch/keyboard input wiring for draw mode: crosshair vertex placement via the + * add-vertex button or Enter, keyboard undo, interface-type tracking, and + * rubber-band updates while the map pans under the crosshair. + * + * @returns {{ getInterfaceType: () => string, destroy: () => void }} + */ export const createDrawInput = ({ drawInteraction, options }) => { const { container, addVertexButtonId, mapProvider, snap, onUndo, canFinish } = options let interfaceType = options.interfaceType ?? 'mouse' - let sketchFeature = null - let lastPlacedCoord = null - - drawInteraction.on('drawstart', (e) => { - sketchFeature = e.feature - lastPlacedCoord = null - }) - drawInteraction.on('drawend', () => { - sketchFeature = null - lastPlacedCoord = null - }) - drawInteraction.on('drawabort', () => { - sketchFeature = null - lastPlacedCoord = null + const getInterfaceType = () => interfaceType + + const placement = createVertexPlacement({ + drawInteraction, + mapProvider, + snap, + canFinish, + getInterfaceType }) - const updateRubberbanding = () => { - if (!sketchFeature) { - // No sketch yet — update snap indicator at crosshair position so targets are - // visible before the first vertex is placed (touch/keyboard only; mouse uses - // the OL snap interaction's pointermove handler instead). - if (interfaceType !== 'mouse' && snap) { - snap.apply(mapProvider.getCenter()) - } - return - } - const geom = sketchFeature.getGeometry() - const coords = geom.getCoordinates() - if (!coords.length) { - return - } - const raw = mapProvider.getCenter() - const centerCoord = (interfaceType !== 'mouse' && snap) ? snap.apply(raw) : raw - applyRubberbanding(geom, centerCoord) - } - - // Returns true if the vertex was handled as a close/finish attempt (caller should not append). - const tryClose = (geom, sketchCoords, coord) => { - if (lastPlacedCoord && lastPlacedCoord[0] === coord[0] && lastPlacedCoord[1] === coord[1]) { - // Same position as last placed: don't duplicate. Close only if enough real vertices exist. - if (canFinish?.()) { drawInteraction.finishDrawing() } - lastPlacedCoord = null - return true - } - if (isCloseToFirstVertex(drawInteraction.getMap(), coord, sketchCoords, geom.getType())) { - drawInteraction.finishDrawing() - return true - } - // When the add-vertex button overlays the map (touch UI), OL's native pointer handler - // and this button click handler both fire for the same tap. Detect that OL already - // committed a vertex at coord's position and skip the duplicate appendCoordinates, - // but register coord as lastPlacedCoord so a second tap at the same position can close. - const map = drawInteraction.getMap() - const lastCommitted = getLastPlacedSketchCoord(geom) - if (lastCommitted) { - const p1 = map.getPixelFromCoordinate(lastCommitted) - const p2 = map.getPixelFromCoordinate(coord) - if (p1 && p2) { - const dx = p1[0] - p2[0]; const dy = p1[1] - p2[1] - if (dx * dx + dy * dy < DUPLICATE_TOLERANCE_PX * DUPLICATE_TOLERANCE_PX) { - lastPlacedCoord = coord - return true - } - } - } - return false - } - - const placeVertex = () => { - const raw = mapProvider.getCenter() - const coord = (interfaceType !== 'mouse' && snap) ? snap.apply(raw) : raw - snap?.hideIndicator() - if (sketchFeature) { - const geom = sketchFeature.getGeometry() - const rawCoords = geom.getCoordinates() - const sketchCoords = geom.getType() === 'Polygon' ? (rawCoords[0] || []) : rawCoords - if (tryClose(geom, sketchCoords, coord)) { return } - } - drawInteraction.appendCoordinates([coord]) - lastPlacedCoord = coord - } - const map = drawInteraction.getMap() const olView = map?.getView() @@ -206,23 +103,23 @@ export const createDrawInput = ({ drawInteraction, options }) => { addVertexButtonId, olView, onUndo, - getInterfaceType: () => interfaceType, + getInterfaceType, setInterfaceType: (t) => { interfaceType = t }, - clearLastCoord: () => { lastPlacedCoord = null }, - updateRubberbanding, - placeVertex + clearLastCoord: placement.clearLastCoord, + updateRubberbanding: placement.updateRubberbanding, + placeVertex: placement.placeVertex }) // change:center fires once when a keyboard pan animation starts; postrender tracks each frame. const onMapRender = () => { if (interfaceType !== 'mouse' && olView?.getAnimating()) { - updateRubberbanding() + placement.updateRubberbanding() } } map?.on('postrender', onMapRender) return { - getInterfaceType: () => interfaceType, + getInterfaceType, destroy () { events.destroy() map?.un('postrender', onMapRender) diff --git a/plugins/beta/draw/src/adapters/openlayers/draw/drawInput.test.js b/plugins/beta/draw/src/adapters/openlayers/draw/drawInput.test.js new file mode 100644 index 000000000..6e48afdb1 --- /dev/null +++ b/plugins/beta/draw/src/adapters/openlayers/draw/drawInput.test.js @@ -0,0 +1,111 @@ +import { createDrawInput } from './drawInput.js' +import { createVertexPlacement } from './vertexPlacement.js' +import { createFakeMap, createContainer } from '../__helpers__/harness.js' + +jest.mock('./vertexPlacement.js', () => ({ + createVertexPlacement: jest.fn(() => ({ + placeVertex: jest.fn(), + updateRubberbanding: jest.fn(), + clearLastCoord: jest.fn() + })) +})) + +const setup = (interfaceType = 'mouse') => { + const map = createFakeMap() + const container = createContainer() + const button = document.createElement('button') + button.id = 'add-pt' + document.body.appendChild(button) + const onUndo = jest.fn() + const input = createDrawInput({ + drawInteraction: { getMap: () => map }, + options: { container, addVertexButtonId: 'add-pt', interfaceType, mapProvider: {}, snap: null, onUndo } + }) + const placement = createVertexPlacement.mock.results.at(-1).value + liveInputs.push(input) + return { map, view: map.getView(), container, button, input, placement, onUndo } +} + +const liveInputs = [] +afterEach(() => { + liveInputs.splice(0).forEach((i) => i.destroy()) + document.body.innerHTML = '' + jest.clearAllMocks() +}) + +const key = (props) => window.dispatchEvent(new KeyboardEvent('keydown', { cancelable: true, ...props })) + +test('Enter places a vertex and switches to keyboard — only while focus is inside the viewport', () => { + const { container, input, placement } = setup() + key({ key: 'Enter' }) + expect(placement.placeVertex).not.toHaveBeenCalled() + + container.focus() + key({ key: 'Enter' }) + expect(placement.placeVertex).toHaveBeenCalled() + expect(input.getInterfaceType()).toBe('keyboard') +}) + +test('arrow keys switch to the keyboard interface without placing', () => { + const { container, input, placement } = setup() + container.focus() + key({ key: 'ArrowUp' }) + expect(input.getInterfaceType()).toBe('keyboard') + expect(placement.placeVertex).not.toHaveBeenCalled() +}) + +test('ctrl/cmd+z triggers undo', () => { + const { container, onUndo } = setup() + container.focus() + key({ key: 'z', ctrlKey: true }) + expect(onUndo).toHaveBeenCalled() +}) + +test('the add-vertex button places a vertex; other clicks do not', () => { + const { button, placement } = setup() + button.click() + expect(placement.placeVertex).toHaveBeenCalledTimes(1) + document.body.click() + expect(placement.placeVertex).toHaveBeenCalledTimes(1) +}) + +test('pointer and touch input switch the interface type', () => { + const { container, input, placement } = setup('keyboard') + container.dispatchEvent(Object.assign(new Event('pointerdown'), { pointerType: 'mouse' })) + expect(input.getInterfaceType()).toBe('mouse') + expect(placement.clearLastCoord).toHaveBeenCalled() + + container.dispatchEvent(new Event('touchstart')) + expect(input.getInterfaceType()).toBe('touch') +}) + +test('pointer moves and map pans update the rubber band except for the mouse interface', () => { + const { container, view, placement } = setup('touch') + container.dispatchEvent(new Event('pointermove')) + view.emit('change:center') + expect(placement.updateRubberbanding).toHaveBeenCalledTimes(2) + + const mouse = setup('mouse') + mouse.container.dispatchEvent(new Event('pointermove')) + mouse.view.emit('change:center') + expect(mouse.placement.updateRubberbanding).not.toHaveBeenCalled() +}) + +test('keyboard pan animations keep the rubber band anchored via postrender', () => { + const { map, view, placement } = setup('keyboard') + view.getAnimating.mockReturnValue(true) + map.emit('postrender') + expect(placement.updateRubberbanding).toHaveBeenCalledTimes(1) + view.getAnimating.mockReturnValue(false) + map.emit('postrender') + expect(placement.updateRubberbanding).toHaveBeenCalledTimes(1) +}) + +test('destroy removes all listeners', () => { + const { container, button, input, placement } = setup() + input.destroy() + container.focus() + key({ key: 'Enter' }) + button.click() + expect(placement.placeVertex).not.toHaveBeenCalled() +}) diff --git a/plugins/beta/draw/src/adapters/openlayers/draw/vertexPlacement.js b/plugins/beta/draw/src/adapters/openlayers/draw/vertexPlacement.js new file mode 100644 index 000000000..3514da0f9 --- /dev/null +++ b/plugins/beta/draw/src/adapters/openlayers/draw/vertexPlacement.js @@ -0,0 +1,141 @@ +import { coordToPixel, pixelDist } from '../utils/olCoords.js' +import { getLastPlacedSketchCoord } from '../utils/sketchHelpers.js' + +const SNAP_TOLERANCE = 12 // pixels +// Minimum ring length to allow snap-to-close (placed vertices + rubber-band) +const MIN_SKETCH_COORDS = { Polygon: 4, LineString: 3 } +const DUPLICATE_TOLERANCE_PX = 2 + +export const isCloseToFirstVertex = (map, coord, sketchCoords, geometryType) => { + if (geometryType !== 'Polygon' || sketchCoords.length < MIN_SKETCH_COORDS.Polygon) { + return false + } + const currentPixel = coordToPixel(map, coord) + const firstPixel = coordToPixel(map, sketchCoords[0]) + if (!currentPixel || !firstPixel) { + return false + } + return pixelDist(currentPixel, firstPixel) < SNAP_TOLERANCE +} + +export const applyRubberbanding = (geom, centerCoord) => { + if (geom.getType() === 'LineString') { + const updated = [...geom.getCoordinates()] + updated[updated.length - 1] = centerCoord + geom.setCoordinates(updated) + } else if (geom.getType() === 'Polygon') { + const updated = geom.getCoordinates().map((ring, i) => { + if (i !== 0) { + return ring + } + const r = [...ring] + r[r.length - 1] = centerCoord + return r + }) + geom.setCoordinates(updated) + } else { + // No action + } +} + +/** + * Close/finish handling for a placed coordinate. Pure with respect to the + * lastPlacedCoord bookkeeping: returns the updated value alongside whether the + * coordinate was consumed as a close/finish attempt (caller must not append). + * + * @returns {{ handled: boolean, lastPlacedCoord: number[] | null }} + */ +const tryClose = ({ drawInteraction, canFinish, geom, sketchCoords, coord, lastPlacedCoord }) => { + if (lastPlacedCoord && lastPlacedCoord[0] === coord[0] && lastPlacedCoord[1] === coord[1]) { + // Same position as last placed: don't duplicate. Close only if enough real vertices exist. + if (canFinish?.()) { drawInteraction.finishDrawing() } + return { handled: true, lastPlacedCoord: null } + } + const map = drawInteraction.getMap() + if (isCloseToFirstVertex(map, coord, sketchCoords, geom.getType())) { + drawInteraction.finishDrawing() + return { handled: true, lastPlacedCoord } + } + // When the add-vertex button overlays the map (touch UI), OL's native pointer handler + // and this button click handler both fire for the same tap. Detect that OL already + // committed a vertex at coord's position and skip the duplicate appendCoordinates, + // but register coord as lastPlacedCoord so a second tap at the same position can close. + const lastCommitted = getLastPlacedSketchCoord(geom) + if (lastCommitted) { + const p1 = map.getPixelFromCoordinate(lastCommitted) + const p2 = map.getPixelFromCoordinate(coord) + if (p1 && p2) { + const dx = p1[0] - p2[0]; const dy = p1[1] - p2[1] + if (dx * dx + dy * dy < DUPLICATE_TOLERANCE_PX * DUPLICATE_TOLERANCE_PX) { + return { handled: true, lastPlacedCoord: coord } + } + } + } + return { handled: false, lastPlacedCoord } +} + +/** + * Crosshair-driven vertex placement for touch/keyboard drawing. + * + * Owns the sketch-feature and last-placed-coordinate bookkeeping via the Draw + * interaction's lifecycle events, and implements placing a vertex at the map + * center: rubber-band updates, duplicate suppression when OL already committed + * the same tap, close-on-repeat and close-near-first-vertex behaviour. + * + * @returns {{ placeVertex, updateRubberbanding, clearLastCoord }} + */ +export const createVertexPlacement = ({ drawInteraction, mapProvider, snap, canFinish, getInterfaceType }) => { + let sketchFeature = null + let lastPlacedCoord = null + + const resetSketch = (feature = null) => { + sketchFeature = feature + lastPlacedCoord = null + } + drawInteraction.on('drawstart', (e) => resetSketch(e.feature)) + drawInteraction.on('drawend', () => resetSketch()) + drawInteraction.on('drawabort', () => resetSketch()) + + const snappedCenter = () => { + const raw = mapProvider.getCenter() + return (getInterfaceType() !== 'mouse' && snap) ? snap.apply(raw) : raw + } + + const updateRubberbanding = () => { + if (!sketchFeature) { + // No sketch yet — update snap indicator at crosshair position so targets are + // visible before the first vertex is placed (touch/keyboard only; mouse uses + // the OL snap interaction's pointermove handler instead). + if (getInterfaceType() !== 'mouse' && snap) { + snap.apply(mapProvider.getCenter()) + } + return + } + const geom = sketchFeature.getGeometry() + if (!geom.getCoordinates().length) { + return + } + applyRubberbanding(geom, snappedCenter()) + } + + const placeVertex = () => { + const coord = snappedCenter() + snap?.hideIndicator() + if (sketchFeature) { + const geom = sketchFeature.getGeometry() + const rawCoords = geom.getCoordinates() + const sketchCoords = geom.getType() === 'Polygon' ? (rawCoords[0] || []) : rawCoords + const result = tryClose({ drawInteraction, canFinish, geom, sketchCoords, coord, lastPlacedCoord }) + lastPlacedCoord = result.lastPlacedCoord + if (result.handled) { return } + } + drawInteraction.appendCoordinates([coord]) + lastPlacedCoord = coord + } + + return { + placeVertex, + updateRubberbanding, + clearLastCoord () { lastPlacedCoord = null } + } +} diff --git a/plugins/beta/draw/src/adapters/openlayers/draw/vertexPlacement.test.js b/plugins/beta/draw/src/adapters/openlayers/draw/vertexPlacement.test.js new file mode 100644 index 000000000..6dd0e6df5 --- /dev/null +++ b/plugins/beta/draw/src/adapters/openlayers/draw/vertexPlacement.test.js @@ -0,0 +1,155 @@ +import Point from 'ol/geom/Point.js' +import Polygon from 'ol/geom/Polygon.js' +import Feature from 'ol/Feature.js' +import { createVertexPlacement, applyRubberbanding, isCloseToFirstVertex } from './vertexPlacement.js' +import { createFakeMap, createEmitter, polygonFeature, lineFeature } from '../__helpers__/harness.js' + +const CENTER = [5, 5] + +const setup = ({ interfaceType = 'touch', snap = null, canFinish = () => true } = {}) => { + const map = createFakeMap() + const bus = createEmitter() + const drawInteraction = { + on: bus.on, + emit: bus.emit, + getMap: () => map, + appendCoordinates: jest.fn(), + finishDrawing: jest.fn() + } + const placement = createVertexPlacement({ + drawInteraction, + mapProvider: { getCenter: () => CENTER }, + snap, + canFinish, + getInterfaceType: () => interfaceType + }) + const startSketch = (feature) => { bus.emit('drawstart', { feature }); return feature } + return { drawInteraction, placement, startSketch, bus } +} + +describe('placing vertices', () => { + test('appends the crosshair position, snapped when a snap manager is active', () => { + const { placement, drawInteraction } = setup() + placement.placeVertex() + expect(drawInteraction.appendCoordinates).toHaveBeenCalledWith([CENTER]) + + const snap = { apply: jest.fn(() => [9, 9]), hideIndicator: jest.fn() } + const snapped = setup({ snap }) + snapped.placement.placeVertex() + expect(snapped.drawInteraction.appendCoordinates).toHaveBeenCalledWith([[9, 9]]) + expect(snap.hideIndicator).toHaveBeenCalled() + }) + + test('the mouse interface ignores snapping (the OL snap interaction covers it)', () => { + const snap = { apply: jest.fn(() => [9, 9]), hideIndicator: jest.fn() } + const { placement, drawInteraction } = setup({ interfaceType: 'mouse', snap }) + placement.placeVertex() + expect(drawInteraction.appendCoordinates).toHaveBeenCalledWith([CENTER]) + }) + + test('a second tap at the same spot finishes the shape instead of duplicating — when finishable', () => { + const { placement, drawInteraction, startSketch } = setup() + startSketch(lineFeature([[0, 0], [50, 50]])) + placement.placeVertex() + placement.placeVertex() + expect(drawInteraction.appendCoordinates).toHaveBeenCalledTimes(1) + expect(drawInteraction.finishDrawing).toHaveBeenCalledTimes(1) + + const unfinishable = setup({ canFinish: () => false }) + unfinishable.startSketch(lineFeature([[0, 0], [50, 50]])) + unfinishable.placement.placeVertex() + unfinishable.placement.placeVertex() + unfinishable.placement.placeVertex() // duplicate reset the bookkeeping — placed again + expect(unfinishable.drawInteraction.finishDrawing).not.toHaveBeenCalled() + expect(unfinishable.drawInteraction.appendCoordinates).toHaveBeenCalledTimes(2) + }) + + test('placing near the first polygon vertex closes the ring', () => { + const { placement, drawInteraction, startSketch } = setup() + startSketch(polygonFeature([[0, 0], [30, 0], [30, 30], [8, 8], [0, 0]])) // center [5,5] within 12px of [0,0] + placement.placeVertex() + expect(drawInteraction.finishDrawing).toHaveBeenCalled() + expect(drawInteraction.appendCoordinates).not.toHaveBeenCalled() + }) + + test('a vertex OL already committed at the same position is not appended twice', () => { + const { placement, drawInteraction, startSketch } = setup() + startSketch(lineFeature([[0, 0], [5, 6], [50, 50]])) // last committed [5,6] within 2px of center + placement.placeVertex() + expect(drawInteraction.appendCoordinates).not.toHaveBeenCalled() + placement.placeVertex() // now registered as last placed — same spot again closes + expect(drawInteraction.finishDrawing).toHaveBeenCalled() + }) + + test('clearLastCoord and sketch end/abort reset the duplicate bookkeeping', () => { + const { placement, drawInteraction, startSketch, bus } = setup() + startSketch(lineFeature([[0, 0], [50, 50]])) + placement.placeVertex() + placement.clearLastCoord() + placement.placeVertex() + expect(drawInteraction.appendCoordinates).toHaveBeenCalledTimes(2) + + bus.emit('drawend') + placement.placeVertex() // no sketch — plain append + expect(drawInteraction.appendCoordinates).toHaveBeenCalledTimes(3) + bus.emit('drawabort') + }) +}) + +describe('rubber-banding', () => { + test('moves the trailing coordinate of a line or polygon ring to the crosshair', () => { + const { placement, startSketch } = setup() + const line = startSketch(lineFeature([[0, 0], [50, 50]])) + placement.updateRubberbanding() + expect(line.getGeometry().getCoordinates()).toEqual([[0, 0], CENTER]) + + const poly = setup() + const hole = [[10, 10], [12, 10], [12, 12], [10, 10]] + const feature = poly.startSketch(new Feature(new Polygon([[[0, 0], [30, 0], [50, 50], [0, 0]], hole]))) + poly.placement.updateRubberbanding() + expect(feature.getGeometry().getCoordinates()[0].at(-1)).toEqual(CENTER) + expect(feature.getGeometry().getCoordinates()[1]).toEqual(hole) // inner rings untouched + }) + + test('without a sketch it only refreshes the snap indicator at the crosshair (non-mouse)', () => { + const snap = { apply: jest.fn((c) => c), hideIndicator: jest.fn() } + const { placement } = setup({ snap }) + placement.updateRubberbanding() + expect(snap.apply).toHaveBeenCalledWith(CENTER) + + const mouse = setup({ interfaceType: 'mouse', snap }) + snap.apply.mockClear() + mouse.placement.updateRubberbanding() + expect(snap.apply).not.toHaveBeenCalled() + }) + + test('an empty sketch geometry is left alone', () => { + const { placement, startSketch } = setup() + const line = startSketch(lineFeature([])) + placement.updateRubberbanding() // must not throw + expect(line.getGeometry().getCoordinates()).toEqual([]) + }) + + test('applyRubberbanding ignores unsupported geometry types', () => { + const point = new Point([1, 1]) + applyRubberbanding(point, CENTER) + expect(point.getCoordinates()).toEqual([1, 1]) + }) +}) + +describe('isCloseToFirstVertex', () => { + const map = createFakeMap() + + test('only closes polygons with enough vertices, within the snap tolerance', () => { + const ring = [[0, 0], [30, 0], [30, 30], [8, 8], [0, 0]] + expect(isCloseToFirstVertex(map, [5, 5], ring, 'Polygon')).toBe(true) + expect(isCloseToFirstVertex(map, [20, 20], ring, 'Polygon')).toBe(false) + expect(isCloseToFirstVertex(map, [5, 5], ring, 'LineString')).toBe(false) + expect(isCloseToFirstVertex(map, [5, 5], [[0, 0], [30, 0]], 'Polygon')).toBe(false) + }) + + test('is false when a coordinate cannot be projected to a pixel', () => { + const nullMap = { getPixelFromCoordinate: () => null } + expect(isCloseToFirstVertex(nullMap, [5, 5], [[0, 0], [1, 1], [2, 2], [3, 3]], 'Polygon')).toBe(false) + }) +}) diff --git a/plugins/beta/draw/src/adapters/openlayers/edit/EditMode.js b/plugins/beta/draw/src/adapters/openlayers/edit/EditMode.js index f756770d9..042e2f668 100644 --- a/plugins/beta/draw/src/adapters/openlayers/edit/EditMode.js +++ b/plugins/beta/draw/src/adapters/openlayers/edit/EditMode.js @@ -1,271 +1,35 @@ -import Modify from 'ol/interaction/Modify.js' -import Collection from 'ol/Collection.js' -import VectorSource from 'ol/source/Vector.js' -import VectorLayer from 'ol/layer/Vector.js' -import Feature from 'ol/Feature.js' -import Point from 'ol/geom/Point.js' import { createMidpointLayer } from './midpointLayer.js' import { createVertexLayer } from './vertexLayer.js' +import { createActiveVertexLayer } from './activeVertexLayer.js' +import { createSelectionState } from './selectionState.js' +import { createModifyInteraction, deriveModifyOp } from './modifyInteraction.js' +import { createPointerHandlers } from './pointerHandlers.js' import { createTouchHandler } from './touchHandler.js' import { createKeyboardHandler } from './keyboardHandler.js' -import { findNearest } from './vertexHitTest.js' import { deleteVertex, insertAtMidpoint } from './vertexOps.js' import { applyUndo } from './undoOps.js' -import { getCoords, getMidpoints } from '../utils/geometryHelpers.js' +import { ADAPTER_EVENTS } from '../../../adapterEvents.js' +import { STYLES_CHANGED_EVENT } from '../core/internalEvents.js' + +const TOUCH_INTERFACE = 'touch' +const VERTEX_TYPE = 'vertex' /** * Edit vertex mode — handles edit_vertex. * - * OL Modify handles pointer/mouse vertex dragging natively. - * touchHandler covers touch drag via the SVG offset target. - * keyboardHandler covers keyboard navigation and nudging. - * - * @returns {{ done, cancel, undo, deleteVertex: fn, destroy }} + * createEditMode composes the OL pieces along their natural seams: + * - selectionState: shared mutable state + layer/event sync + * - modifyInteraction: OL Modify (pointer drag / midpoint insert) + undo-op derivation + * - pointerHandlers: mouse selection + delete-vertex button + * - touchHandler / keyboardHandler: touch drag and keyboard nudge input */ -export const createEditMode = ({ map, manager, options }) => { - const { featureId, container, interfaceType, deleteVertexButtonId, snap } = options - const { store, undoStack } = manager - - const olFeature = store.getOL(featureId) - if (!olFeature) { - return null - } - const originalFeatureStyle = olFeature.getStyle() - olFeature.setStyle(manager.styles.editFeatureStyle) - - // Mutable state shared across sub-handlers - const state = { - olFeature, - selectedVertexIndex: -1, - selectedVertexType: null, - vertices: [], - midpoints: [], - interfaceType: interfaceType ?? 'mouse' - } - - const getState = () => state - let onDeselect = null // set after touchHandler is created; hides offset target on any deselect - let onUpdate = null // set after touchHandler is created; repositions offset target when vertex coords change - - const setState = (updates) => { - Object.assign(state, updates) - if (updates.selectedVertexIndex !== undefined) { - vertexLayer.setSelected(state.selectedVertexType === 'vertex' ? state.selectedVertexIndex : -1) - midpointLayer.setSelected( - state.selectedVertexType === 'midpoint' ? state.selectedVertexIndex - state.vertices.length : -1 - ) - if (state.selectedVertexIndex < 0) { - onDeselect?.() - } - updateActiveLayer() - manager.emit('vertexselection', { - index: state.selectedVertexType === 'vertex' ? state.selectedVertexIndex : -1, - numVertices: state.vertices.length - }) - } - if (updates.vertices !== undefined) { - const plainGeom = { - type: olFeature.getGeometry().getType(), - coordinates: olFeature.getGeometry().getCoordinates() - } - midpointLayer.update(plainGeom) - vertexLayer.update(plainGeom) - state.midpoints = midpointLayer.getCoords() - updateActiveLayer() - onUpdate?.() - map.render() - } - } - - // Lightweight per-frame update during drag — updates layers without emitting events - const updateLayersFromGeom = () => { - const geom = olFeature.getGeometry() - const plainGeom = { type: geom.getType(), coordinates: geom.getCoordinates() } - state.vertices = getCoords(plainGeom) - state.midpoints = getMidpoints(plainGeom) - midpointLayer.update(plainGeom) - vertexLayer.update(plainGeom) - updateActiveLayer() - } - - const syncGeom = () => { - updateLayersFromGeom() - manager.emit('vertexchange', { numVertices: state.vertices.length }) - manager.emit('update', store.toGeoJSON(olFeature)) - } - - // Keep overlay layers in sync on every geometry change (e.g. during pointer drag) - const onGeometryChange = () => updateLayersFromGeom() - olFeature.getGeometry().on('change', onGeometryChange) - - // --- OL Modify (handles pointer vertex drag + midpoint insertion natively) --- - const collection = new Collection([olFeature]) - const modifyCondition = (mapBrowserEvent) => { - if (state.interfaceType === 'touch') { - return false - } - const olPixel = map.getEventPixel(mapBrowserEvent.originalEvent) - return findNearest(map, state.vertices, state.midpoints, { x: olPixel[0], y: olPixel[1] }) !== null - } - const modifyInteraction = new Modify({ - features: collection, - style: () => [], // vertex circles rendered by vertexLayer instead - pixelTolerance: 12, - // Only activate when clicking on a vertex or midpoint circle, not anywhere on a segment. - // Touch drags are handled by touchHandler; returning false here lets them pass through to - // DragPan (touchHandler uses preventDefault on the offset target to stop unwanted panning). - condition: modifyCondition - }) - map.addInteraction(modifyInteraction) - - // Track move start for undo - let modifyStartCoords = null - - modifyInteraction.on('modifystart', () => { - if (state.interfaceType === 'touch') { - return - } - modifyStartCoords = state.vertices.map(c => [...c]) - }) - - modifyInteraction.on('modifyend', () => { - if (state.interfaceType === 'touch') { - return - } - const prevCoords = modifyStartCoords - syncGeom() - if (!prevCoords) { - return - } - - const newCoords = state.vertices - if (newCoords.length > prevCoords.length) { - // Midpoint drag inserted a vertex — find it and select it - const insertedIdx = newCoords.findIndex((c, i) => !prevCoords[i] || c[0] !== prevCoords[i][0]) - const idx = Math.max(0, insertedIdx) - undoStack.push({ type: 'insert_vertex', vertexIndex: idx }) - setState({ selectedVertexIndex: idx, selectedVertexType: 'vertex' }) - } else if (newCoords.length === prevCoords.length) { - const movedIdx = newCoords.findIndex((c, i) => c[0] !== prevCoords[i][0] || c[1] !== prevCoords[i][1]) - if (movedIdx >= 0) { - undoStack.push({ type: 'move_vertex', vertexIndex: movedIdx, previousCoord: prevCoords[movedIdx] }) - setState({ selectedVertexIndex: movedIdx, selectedVertexType: 'vertex' }) - } - } else { - // no change in vertex count (shouldn't happen, but satisfies linter) - } - modifyStartCoords = null - }) - - // --- Vertex + midpoint layers (always-visible handles) --- - const midpointLayer = createMidpointLayer(map, manager.styles.midpointStyle) - const vertexLayer = createVertexLayer(map, manager.styles.vertexStyle) - - // --- Active selection overlay — always on top of vertex and midpoint layers --- - const activeSource = new VectorSource() - const activeLayer = new VectorLayer({ source: activeSource, zIndex: 103 }) - map.addLayer(activeLayer) - - const updateActiveLayer = () => { - activeSource.clear() - const { selectedVertexIndex, selectedVertexType, vertices, midpoints } = state - if (selectedVertexIndex < 0) { - return - } - let coord, style - if (selectedVertexType === 'vertex') { - coord = vertices[selectedVertexIndex] - style = manager.styles.selectedVertexStyle - } else if (selectedVertexType === 'midpoint') { - coord = midpoints[selectedVertexIndex - vertices.length] - style = manager.styles.selectedMidpointStyle - } else { - return - } - if (!coord) { - return - } - const f = new Feature({ geometry: new Point(coord) }) - f.setStyle(style) - activeSource.addFeature(f) - } - - syncGeom() // initial populate - - // --- Style hot-swap when map style changes --- - const onStylesChanged = (styles) => { - olFeature.setStyle(styles.editFeatureStyle) - vertexLayer.updateStyle(styles.vertexStyle) - midpointLayer.updateStyle(styles.midpointStyle) - updateActiveLayer() - touchHandler.updateColors(manager.colors) - } - manager.on('styleschanged', onStylesChanged) - - // --- Pointer hit detection --- - const onPointerdown = (e) => { - if (e.pointerType === 'touch') { - state.interfaceType = 'touch' - touchHandler.updateTargetPosition() - return - } - state.interfaceType = 'mouse' - - const olPixel = map.getEventPixel(e) - const pixel = { x: olPixel[0], y: olPixel[1] } - const hit = findNearest(map, state.vertices, state.midpoints, pixel) - if (hit?.type === 'vertex') { - setState({ selectedVertexIndex: hit.index, selectedVertexType: 'vertex' }) - } - } - - // click fires after OL Modify finishes, so state.vertices reflects any insertions/moves - const onContainerClick = (e) => { - if (state.interfaceType === 'touch') { - return - } - const olPixel = map.getEventPixel(e) - const pixel = { x: olPixel[0], y: olPixel[1] } - const hit = findNearest(map, state.vertices, state.midpoints, pixel) - if (hit?.type === 'vertex') { - setState({ selectedVertexIndex: hit.index, selectedVertexType: 'vertex' }) - } else if (hit?.type === 'midpoint') { - // modifyend already selected the inserted vertex — nothing to do here - } else { - setState({ selectedVertexIndex: -1, selectedVertexType: null }) - } - } - - // Switch to pointer mode and hide the touch target as soon as the mouse moves. - const onPointerMove = (e) => { - if (e.pointerType !== 'mouse') { - return - } - if (state.interfaceType === 'mouse') { - return - } - state.interfaceType = 'mouse' - touchHandler.hide() - } - - container.addEventListener('pointerdown', onPointerdown) - container.addEventListener('pointerenter', onPointerMove) - container.addEventListener('pointermove', onPointerMove) - container.addEventListener('click', onContainerClick) - - // --- Button click (delete vertex) --- - const onButtonClick = (e) => { - if (deleteVertexButtonId && e.target.closest(`#${deleteVertexButtonId}`)) { - doDeleteVertex() - } - } - globalThis.addEventListener('click', onButtonClick) - - // --- Operations --- +// Delete-selected-vertex and undo operations, shared by pointer, touch and keyboard input +const createVertexActions = ({ olFeature, undoStack, selection, getTouchHandler }) => { + const { state, setState, syncGeom } = selection const doDeleteVertex = () => { - if (state.selectedVertexType !== 'vertex' || state.selectedVertexIndex < 0) { + if (state.selectedVertexType !== VERTEX_TYPE || state.selectedVertexIndex < 0) { return } const result = deleteVertex(olFeature, state.selectedVertexIndex) @@ -289,14 +53,20 @@ export const createEditMode = ({ map, manager, options }) => { const newIndex = previousIndex >= 0 ? restoredIndex : -1 setState({ selectedVertexIndex: newIndex, - selectedVertexType: newIndex >= 0 ? 'vertex' : null + selectedVertexType: newIndex >= 0 ? VERTEX_TYPE : null }) - if (previousIndex >= 0 && newIndex >= 0) { - onUpdate?.() + if (previousIndex >= 0 && newIndex >= 0 && state.interfaceType === TOUCH_INTERFACE) { + getTouchHandler().updateTargetPosition() } } - // --- Touch handler --- + return { doDeleteVertex, doUndo } +} + +const wireTouchHandler = ({ map, container, manager, snap, olFeature, undoStack, selection }) => { + const { state, getState, setState, syncGeom } = selection + const selectVertex = (index) => setState({ selectedVertexIndex: index, selectedVertexType: VERTEX_TYPE }) + const touchHandler = createTouchHandler({ map, container, @@ -307,7 +77,7 @@ export const createEditMode = ({ map, manager, options }) => { onVertexMoved ({ vertexIndex, previousCoord }) { undoStack.push({ type: 'move_vertex', vertexIndex, previousCoord }) syncGeom() - setState({ selectedVertexIndex: vertexIndex, selectedVertexType: 'vertex' }) + selectVertex(vertexIndex) touchHandler.updateTargetPosition() }, onTap (hit) { @@ -315,8 +85,8 @@ export const createEditMode = ({ map, manager, options }) => { setState({ selectedVertexIndex: -1, selectedVertexType: null }) return } - if (hit.type === 'vertex') { - setState({ selectedVertexIndex: hit.index, selectedVertexType: 'vertex' }) + if (hit.type === VERTEX_TYPE) { + selectVertex(hit.index) touchHandler.updateTargetPosition() return } @@ -327,31 +97,28 @@ export const createEditMode = ({ map, manager, options }) => { } undoStack.push({ type: 'insert_vertex', vertexIndex: result.insertedIndex }) syncGeom() - setState({ selectedVertexIndex: result.insertedIndex, selectedVertexType: 'vertex' }) + selectVertex(result.insertedIndex) touchHandler.updateTargetPosition() } } }) - onDeselect = () => touchHandler.hide() - onUpdate = () => { - if (state.interfaceType === 'touch') { - touchHandler.updateTargetPosition() - } - } - // Reposition the touch target after OL re-renders with the new size. - // change:size fires before the render, so we wait for postrender to get - // correct pixel coords from getPixelFromCoordinate. - const onMapSizeChange = () => { - if (state.interfaceType !== 'touch' || state.selectedVertexIndex < 0) { - return + selection.setHooks({ + onDeselect: () => touchHandler.hide(), + onUpdate () { + if (state.interfaceType === TOUCH_INTERFACE) { + touchHandler.updateTargetPosition() + } } - map.once('postrender', () => touchHandler.updateTargetPosition()) - } - map.on('change:size', onMapSizeChange) + }) - // --- Keyboard handler --- - const keyboardHandler = createKeyboardHandler({ + return touchHandler +} + +const wireKeyboardHandler = ({ map, container, snap, undoStack, selection, touchHandler, actions }) => { + const { state, getState, setState, syncGeom } = selection + + return createKeyboardHandler({ map, getState, setState, @@ -359,14 +126,14 @@ export const createEditMode = ({ map, manager, options }) => { onVertexMoved ({ vertexIndex, previousCoord }) { undoStack.push({ type: 'move_vertex', vertexIndex, previousCoord }) syncGeom() - setState({ selectedVertexIndex: vertexIndex, selectedVertexType: 'vertex' }) + setState({ selectedVertexIndex: vertexIndex, selectedVertexType: VERTEX_TYPE }) }, onInserted ({ insertedIndex }) { undoStack.push({ type: 'insert_vertex', vertexIndex: insertedIndex }) syncGeom() }, - onDeleted: doDeleteVertex, - onUndo: doUndo, + onDeleted: actions.doDeleteVertex, + onUndo: actions.doUndo, onKeyboardActive () { if (state.interfaceType === 'keyboard') { return @@ -376,6 +143,44 @@ export const createEditMode = ({ map, manager, options }) => { container.focus({ preventScroll: true }) } }) +} + +// Style hot-swap on map style change + touch-target reposition on map resize +const wireMapSync = ({ map, manager, olFeature, layers, selection, touchHandler }) => { + const { state } = selection + + const onStylesChanged = (styles) => { + olFeature.setStyle(styles.editFeatureStyle) + layers.vertexLayer.updateStyle(styles.vertexStyle) + layers.midpointLayer.updateStyle(styles.midpointStyle) + layers.activeLayer.update(state) + touchHandler.updateColors(manager.colors) + } + manager.on(STYLES_CHANGED_EVENT, onStylesChanged) + + // Reposition the touch target after OL re-renders with the new size. + // change:size fires before the render, so we wait for postrender to get + // correct pixel coords from getPixelFromCoordinate. + const onMapSizeChange = () => { + if (state.interfaceType !== TOUCH_INTERFACE || state.selectedVertexIndex < 0) { + return + } + map.once('postrender', () => touchHandler.updateTargetPosition()) + } + map.on('change:size', onMapSizeChange) + + return { + destroy () { + manager.off(STYLES_CHANGED_EVENT, onStylesChanged) + map.un('change:size', onMapSizeChange) + } + } +} + +// The mode interface consumed by OLDrawManager +const buildModeApi = ({ manager, store, olFeature, originalFeatureStyle, selection, actions, parts }) => { + const { state } = selection + const { touchHandler } = parts return { setInterfaceType (type) { @@ -383,7 +188,7 @@ export const createEditMode = ({ map, manager, options }) => { return } state.interfaceType = type - if (type === 'touch') { + if (type === TOUCH_INTERFACE) { touchHandler.updateTargetPosition() } else { touchHandler.hide() @@ -391,34 +196,99 @@ export const createEditMode = ({ map, manager, options }) => { }, done () { - manager.emit('editfinish', store.toGeoJSON(olFeature)) + manager.emit(ADAPTER_EVENTS.EDIT_FINISH, store.toGeoJSON(olFeature)) }, - cancel () { - // Restore original feature from store (re-read from initial state) - // The original was stored as tempFeature in reducer — events.js handles restore - }, + // Nothing to restore here: the pre-edit feature is kept as tempFeature in the + // reducer and events.js re-adds it on cancel + cancel () {}, - undo: doUndo, - deleteVertex: doDeleteVertex, + undo: actions.doUndo, + deleteVertex: actions.doDeleteVertex, destroy () { olFeature.setStyle(originalFeatureStyle) - olFeature.getGeometry().un('change', onGeometryChange) - manager.off('styleschanged', onStylesChanged) - container.removeEventListener('pointerdown', onPointerdown) - container.removeEventListener('pointerenter', onPointerMove) - container.removeEventListener('pointermove', onPointerMove) - container.removeEventListener('click', onContainerClick) - globalThis.removeEventListener('click', onButtonClick) - map.un('change:size', onMapSizeChange) - map.removeInteraction(modifyInteraction) - activeSource.clear() - map.removeLayer(activeLayer) - midpointLayer.remove() - vertexLayer.remove() + selection.destroy() + parts.mapSync.destroy() + parts.pointerHandlers.destroy() + parts.modify.destroy() + parts.layers.activeLayer.remove() + parts.layers.midpointLayer.remove() + parts.layers.vertexLayer.remove() touchHandler.destroy() - keyboardHandler.destroy() + parts.keyboardHandler.destroy() } } } + +/** + * @returns {{ setInterfaceType, done, cancel, undo, deleteVertex, destroy } | null} + */ +export const createEditMode = ({ map, manager, options }) => { + const { featureId, container, interfaceType, deleteVertexButtonId, snap } = options + const { store, undoStack } = manager + + const olFeature = store.getOL(featureId) + if (!olFeature) { + return null + } + + const originalFeatureStyle = olFeature.getStyle() + olFeature.setStyle(manager.styles.editFeatureStyle) + + const midpointLayer = createMidpointLayer(map, manager.styles.midpointStyle) + const vertexLayer = createVertexLayer(map, manager.styles.vertexStyle) + const activeLayer = createActiveVertexLayer(map, () => manager.styles) + + const selection = createSelectionState({ + map, + manager, + store, + olFeature, + interfaceType, + layers: { vertexLayer, midpointLayer, activeLayer } + }) + const { state, getState, setState, syncGeom } = selection + + const modify = createModifyInteraction({ + map, + olFeature, + getState, + onModifyEnd (prevCoords) { + syncGeom() + const op = prevCoords && deriveModifyOp(prevCoords, state.vertices) + if (!op) { + return + } + undoStack.push(op) + setState({ selectedVertexIndex: op.vertexIndex, selectedVertexType: VERTEX_TYPE }) + } + }) + + syncGeom() // initial populate + + const layers = { vertexLayer, midpointLayer, activeLayer } + const touchHandler = wireTouchHandler({ map, container, manager, snap, olFeature, undoStack, selection }) + const actions = createVertexActions({ olFeature, undoStack, selection, getTouchHandler: () => touchHandler }) + const keyboardHandler = wireKeyboardHandler({ map, container, snap, undoStack, selection, touchHandler, actions }) + const pointerHandlers = createPointerHandlers({ + map, + container, + getState, + setState, + touchHandler, + deleteVertexButtonId, + onDeleteVertex: actions.doDeleteVertex + }) + const mapSync = wireMapSync({ map, manager, olFeature, layers, selection, touchHandler }) + + return buildModeApi({ + manager, + store, + olFeature, + originalFeatureStyle, + selection, + actions, + parts: { touchHandler, keyboardHandler, pointerHandlers, modify, mapSync, layers } + }) +} diff --git a/plugins/beta/draw/src/adapters/openlayers/edit/EditMode.test.js b/plugins/beta/draw/src/adapters/openlayers/edit/EditMode.test.js new file mode 100644 index 000000000..59a730b45 --- /dev/null +++ b/plugins/beta/draw/src/adapters/openlayers/edit/EditMode.test.js @@ -0,0 +1,195 @@ +import { createEditMode } from './EditMode.js' +import { createFeatureStore } from '../core/featureStore.js' +import { STYLES_CHANGED_EVENT } from '../core/internalEvents.js' +import { ADAPTER_EVENTS } from '../../../adapterEvents.js' +import { createFakeMap, createFakeManager, createContainer, domEvent } from '../__helpers__/harness.js' +import Style from 'ol/style/Style.js' + +const RING = [[0, 0], [100, 0], [100, 100], [0, 100], [0, 0]] // square: 4 vertices, deletable + +const setup = (ring = RING) => { + const map = createFakeMap() + const manager = createFakeManager() + manager.store = createFeatureStore() + manager.store.add({ type: 'Feature', id: 'f1', properties: {}, geometry: { type: 'Polygon', coordinates: [ring] } }) + const container = createContainer() + map.getViewport().appendChild(container) // keyboard handler treats focus inside the viewport as map focus + const mode = createEditMode({ + map, + manager, + options: { featureId: 'f1', container, interfaceType: 'mouse', deleteVertexButtonId: 'del-v', snap: null } + }) + liveModes.push(mode) + const olFeature = manager.store.getOL('f1') + const tapAt = (x, y) => { + container.dispatchEvent(domEvent('touchstart', { touches: [{ clientX: x, clientY: y }] })) + container.dispatchEvent(domEvent('touchend', { changedTouches: [{ clientX: x, clientY: y }] })) + } + return { map, manager, container, mode, olFeature, ring: () => olFeature.getGeometry().getCoordinates()[0], tapAt } +} + +const liveModes = [] +afterEach(() => { + liveModes.splice(0).forEach((m) => m?.destroy()) + document.body.innerHTML = '' +}) + +const key = (type, props) => window.dispatchEvent(new KeyboardEvent(type, { cancelable: true, ...props })) + +test('returns null for an unknown feature id', () => { + const { map, manager } = setup() + expect(createEditMode({ map, manager, options: { featureId: 'nope', container: createContainer() } })).toBeNull() +}) + +test('entering edit mode swaps the feature style and reports the initial vertex state', () => { + const { manager, olFeature } = setup() + expect(olFeature.getStyle()).toBe(manager.styles.editFeatureStyle) + expect(manager.emit).toHaveBeenCalledWith(ADAPTER_EVENTS.VERTEX_CHANGE, { numVertices: 4 }) + expect(manager.emit).toHaveBeenCalledWith(ADAPTER_EVENTS.UPDATE, expect.objectContaining({ id: 'f1' })) +}) + +test('done() emits the edited feature; cancel() is a no-op', () => { + const { manager, mode } = setup() + mode.done() + expect(manager.emit).toHaveBeenCalledWith(ADAPTER_EVENTS.EDIT_FINISH, expect.objectContaining({ id: 'f1' })) + expect(mode.cancel()).toBeUndefined() +}) + +test('select via pointer, delete the vertex, then undo restores it', () => { + const { container, manager, mode, ring } = setup() + container.dispatchEvent(domEvent('pointerdown', { pointerType: 'mouse', clientX: 100, clientY: 0 })) + expect(manager.emit).toHaveBeenCalledWith(ADAPTER_EVENTS.VERTEX_SELECTION, expect.objectContaining({ index: 1 })) + + mode.deleteVertex() + expect(ring()).toHaveLength(4) // one vertex fewer (closed ring keeps closing coord) + expect(manager.undoStack.length).toBe(1) + + mode.undo() + expect(ring()).toHaveLength(5) + expect(ring()[1]).toEqual([100, 0]) + mode.undo() // empty stack — no-op +}) + +test('a Modify drag pushes a move op derived from the before/after vertices', () => { + const { map, manager, olFeature } = setup() + const interaction = map.interactions[0] + interaction.dispatchEvent({ type: 'modifystart' }) + olFeature.getGeometry().setCoordinates([[[0, 0], [120, 5], [100, 100], [0, 100], [0, 0]]]) + interaction.dispatchEvent({ type: 'modifyend' }) + expect(manager.undoStack.pop()).toEqual({ type: 'move_vertex', vertexIndex: 1, previousCoord: [100, 0] }) + expect(manager.emit).toHaveBeenCalledWith(ADAPTER_EVENTS.VERTEX_SELECTION, expect.objectContaining({ index: 1 })) + + interaction.dispatchEvent({ type: 'modifystart' }) + interaction.dispatchEvent({ type: 'modifyend' }) // no coordinate change — nothing pushed + expect(manager.undoStack.length).toBe(0) +}) + +test('deleting needs a selected vertex and respects the minimum ring size', () => { + const { mode, manager } = setup() + mode.deleteVertex() // nothing selected + expect(manager.undoStack.length).toBe(0) + + const triangle = setup([[0, 0], [100, 0], [100, 100], [0, 0]]) + triangle.container.dispatchEvent(domEvent('pointerdown', { pointerType: 'mouse', clientX: 100, clientY: 0 })) + triangle.mode.deleteVertex() // 3 vertices is the minimum for a ring + expect(triangle.ring()).toHaveLength(4) + expect(triangle.manager.undoStack.length).toBe(0) +}) + +test('keyboard: arrows nudge the selected vertex and commit one undo op on keyup', () => { + const { container, manager, ring } = setup() + container.dispatchEvent(domEvent('pointerdown', { pointerType: 'mouse', clientX: 100, clientY: 0 })) + key('keydown', { key: 'ArrowRight' }) + key('keyup', { key: 'ArrowRight' }) + expect(ring()[1]).toEqual([105, 0]) + expect(manager.undoStack.pop()).toEqual({ type: 'move_vertex', vertexIndex: 1, previousCoord: [100, 0] }) +}) + +test('keyboard: Space selects the midpoint nearest the crosshair; nudging inserts it as a vertex', () => { + const { manager, ring } = setup() + key('keydown', { key: ' ' }) // nearest handle to center [50,50] is the midpoint [50,0]... all midpoints are at 50 + key('keydown', { key: 'ArrowRight' }) + key('keyup', { key: 'ArrowRight' }) + expect(ring()).toHaveLength(6) + expect(ring()[1]).toEqual([55, 0]) // inserted at the midpoint, nudged one step right + expect(manager.undoStack.pop()).toEqual({ type: 'move_vertex', vertexIndex: 1, previousCoord: [50, 0] }) + expect(manager.undoStack.pop()).toEqual({ type: 'insert_vertex', vertexIndex: 1 }) +}) + +test('touch: dragging the offset target moves the vertex and records one move op', () => { + const { container, manager, mode, tapAt, ring } = setup() + tapAt(100, 0) + mode.setInterfaceType('touch') + // The finger lands on the SVG's inner circle, not the SVG element itself + const grip = container.querySelector('[data-im-draw-touch-target] circle') + grip.dispatchEvent(domEvent('touchstart', { touches: [{ clientX: 100, clientY: 0 }] })) + grip.dispatchEvent(domEvent('touchmove', { touches: [{ clientX: 120, clientY: 10 }] })) + grip.dispatchEvent(domEvent('touchend', { changedTouches: [{ clientX: 120, clientY: 10 }] })) + expect(ring()[1]).toEqual([120, 10]) + expect(manager.undoStack.pop()).toEqual({ type: 'move_vertex', vertexIndex: 1, previousCoord: [100, 0] }) +}) + +test('touch: tapping a midpoint inserts a vertex there and selects it', () => { + const { manager, tapAt, ring } = setup() + tapAt(50, 0) // midpoint between [0,0] and [100,0] + expect(ring()).toHaveLength(6) + expect(ring()[1]).toEqual([50, 0]) + expect(manager.undoStack.pop()).toEqual({ type: 'insert_vertex', vertexIndex: 1 }) +}) + +test('undo in touch mode repositions the offset target on the restored vertex', () => { + const { container, manager, mode, tapAt } = setup() + tapAt(100, 0) + key('keydown', { key: 'ArrowRight' }) // keyboard nudge (switches interface to keyboard) + key('keyup', { key: 'ArrowRight' }) + mode.setInterfaceType('touch') + mode.undo() + const target = container.querySelector('[data-im-draw-touch-target]') + expect(target.style.display).toBe('block') + expect(manager.undoStack.length).toBe(0) +}) + +test('touch tap selects a vertex and interface switching shows/hides the touch target', () => { + const { container, manager, mode, tapAt } = setup() + tapAt(100, 0) + expect(manager.emit).toHaveBeenCalledWith(ADAPTER_EVENTS.VERTEX_SELECTION, expect.objectContaining({ index: 1 })) + + const target = container.querySelector('[data-im-draw-touch-target]') + mode.setInterfaceType('touch') + expect(target.style.display).toBe('block') + mode.setInterfaceType('touch') // same type — no change + mode.setInterfaceType('mouse') + expect(target.style.display).toBe('none') +}) + +test('a tap on empty map deselects', () => { + const { manager, tapAt } = setup() + tapAt(100, 0) + tapAt(500, 500) + expect(manager.emit).toHaveBeenCalledWith(ADAPTER_EVENTS.VERTEX_SELECTION, expect.objectContaining({ index: -1 })) +}) + +test('style changes re-style the feature and handles', () => { + const { manager, olFeature } = setup() + const newStyles = { ...manager.styles, editFeatureStyle: new Style({}) } + manager.emit(STYLES_CHANGED_EVENT, newStyles) + expect(olFeature.getStyle()).toBe(newStyles.editFeatureStyle) +}) + +test('map resize repositions the touch target after the next render — touch with a selection only', () => { + const { map, mode, tapAt } = setup() + map.emit('change:size') // mouse interface — EditMode's own resize hook ignores it + tapAt(100, 0) + mode.setInterfaceType('touch') + map.emit('change:size') + map.emit('postrender') // once-listener fires; must not throw + expect(map.once).toHaveBeenCalledWith('postrender', expect.any(Function)) +}) + +test('destroy removes interactions, layers and listeners, and restores the feature style', () => { + const { map, mode, olFeature } = setup() + mode.destroy() + expect(map.interactions).toHaveLength(0) + expect(map.layers).toHaveLength(0) + expect(olFeature.getStyle()).toBeNull() +}) diff --git a/plugins/beta/draw/src/adapters/openlayers/edit/activeVertexLayer.js b/plugins/beta/draw/src/adapters/openlayers/edit/activeVertexLayer.js new file mode 100644 index 000000000..2c808d800 --- /dev/null +++ b/plugins/beta/draw/src/adapters/openlayers/edit/activeVertexLayer.js @@ -0,0 +1,55 @@ +import VectorSource from 'ol/source/Vector.js' +import VectorLayer from 'ol/layer/Vector.js' +import Feature from 'ol/Feature.js' +import Point from 'ol/geom/Point.js' + +// Above the vertex (102) and midpoint (101) handle layers +const ACTIVE_LAYER_Z_INDEX = 103 + +/** + * Overlay layer rendering the currently selected vertex or midpoint handle. + * The vertex/midpoint layers hide their own circle at the selected index and + * this layer draws the highlighted version on top. + * + * @param {import('ol/Map').default} map + * @param {() => object} getStyles - returns the manager's current style set + * (read at render time so style hot-swaps apply without rewiring) + * @returns {{ update: (state) => void, remove: () => void }} + */ +export const createActiveVertexLayer = (map, getStyles) => { + const source = new VectorSource() + const layer = new VectorLayer({ source, zIndex: ACTIVE_LAYER_Z_INDEX }) + map.addLayer(layer) + + const selectedCoordAndStyle = ({ selectedVertexIndex, selectedVertexType, vertices, midpoints }) => { + const styles = getStyles() + if (selectedVertexType === 'vertex') { + return { coord: vertices[selectedVertexIndex], style: styles.selectedVertexStyle } + } + if (selectedVertexType === 'midpoint') { + return { coord: midpoints[selectedVertexIndex - vertices.length], style: styles.selectedMidpointStyle } + } + return { coord: null, style: null } + } + + return { + update (state) { + source.clear() + if (state.selectedVertexIndex < 0) { + return + } + const { coord, style } = selectedCoordAndStyle(state) + if (!coord) { + return + } + const feature = new Feature({ geometry: new Point(coord) }) + feature.setStyle(style) + source.addFeature(feature) + }, + + remove () { + source.clear() + map.removeLayer(layer) + } + } +} diff --git a/plugins/beta/draw/src/adapters/openlayers/edit/activeVertexLayer.test.js b/plugins/beta/draw/src/adapters/openlayers/edit/activeVertexLayer.test.js new file mode 100644 index 000000000..b30f8bac7 --- /dev/null +++ b/plugins/beta/draw/src/adapters/openlayers/edit/activeVertexLayer.test.js @@ -0,0 +1,53 @@ +import { createActiveVertexLayer } from './activeVertexLayer.js' +import { createFakeMap, createFakeManager } from '../__helpers__/harness.js' + +const setup = () => { + const map = createFakeMap() + const manager = createFakeManager() + const activeLayer = createActiveVertexLayer(map, () => manager.styles) + const source = map.layers[0].getSource() + const stateWith = (overrides) => ({ + selectedVertexIndex: -1, + selectedVertexType: null, + vertices: [[0, 0], [10, 0], [10, 10]], + midpoints: [[5, 0], [10, 5], [5, 5]], + ...overrides + }) + return { map, manager, activeLayer, source, stateWith } +} + +test('renders the selected vertex with the selected-vertex style', () => { + const { activeLayer, source, manager, stateWith } = setup() + activeLayer.update(stateWith({ selectedVertexIndex: 1, selectedVertexType: 'vertex' })) + const features = source.getFeatures() + expect(features).toHaveLength(1) + expect(features[0].getGeometry().getCoordinates()).toEqual([10, 0]) + expect(features[0].getStyle()).toBe(manager.styles.selectedVertexStyle) +}) + +test('renders a selected midpoint by its local index with the midpoint style', () => { + const { activeLayer, source, manager, stateWith } = setup() + activeLayer.update(stateWith({ selectedVertexIndex: 4, selectedVertexType: 'midpoint' })) + const features = source.getFeatures() + expect(features[0].getGeometry().getCoordinates()).toEqual([10, 5]) + expect(features[0].getStyle()).toBe(manager.styles.selectedMidpointStyle) +}) + +test('renders nothing without a selection, for an unknown type, or for a missing coordinate', () => { + const { activeLayer, source, stateWith } = setup() + activeLayer.update(stateWith({ selectedVertexIndex: 1, selectedVertexType: 'vertex' })) + activeLayer.update(stateWith({})) + expect(source.getFeatures()).toHaveLength(0) + activeLayer.update(stateWith({ selectedVertexIndex: 0, selectedVertexType: 'segment' })) + expect(source.getFeatures()).toHaveLength(0) + activeLayer.update(stateWith({ selectedVertexIndex: 99, selectedVertexType: 'vertex' })) + expect(source.getFeatures()).toHaveLength(0) +}) + +test('remove clears the source and detaches the layer', () => { + const { map, activeLayer, source, stateWith } = setup() + activeLayer.update(stateWith({ selectedVertexIndex: 0, selectedVertexType: 'vertex' })) + activeLayer.remove() + expect(source.getFeatures()).toHaveLength(0) + expect(map.removeLayer).toHaveBeenCalled() +}) diff --git a/plugins/beta/draw/src/adapters/openlayers/edit/keyboardHandler.js b/plugins/beta/draw/src/adapters/openlayers/edit/keyboardHandler.js index 335efd1d9..9a7a20052 100644 --- a/plugins/beta/draw/src/adapters/openlayers/edit/keyboardHandler.js +++ b/plugins/beta/draw/src/adapters/openlayers/edit/keyboardHandler.js @@ -1,7 +1,6 @@ -import { coordToPixel, nudgeCoord } from '../utils/olCoords.js' +import { coordToPixel } from '../utils/olCoords.js' import { spatialNavigate } from '../../../utils/spatial.js' -import { moveVertex, insertAtMidpoint } from './vertexOps.js' -import { KEYBOARD } from '../defaults.js' +import { wireNudge } from './nudge.js' const ARROW_KEYS = new Set(['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown']) const INTERACTIVE_TAGS = new Set(['INPUT', 'TEXTAREA', 'BUTTON', 'SELECT', 'A']) @@ -11,8 +10,7 @@ const selectNearest = (map, getState, setState) => { if (!vertices.length) { return } - const centerCoord = map.getView().getCenter() - const centerPx = coordToPixel(map, centerCoord) + const centerPx = coordToPixel(map, map.getView().getCenter()) if (!centerPx) { return } @@ -44,81 +42,6 @@ const navigateTo = (direction, map, getState, setState) => { setState({ selectedVertexIndex: idx, selectedVertexType: idx < vertices.length ? 'vertex' : 'midpoint' }) } -// Returns snappedCoord, but escapes snap if it prevents sufficient progress in the nudge direction. -// Covers vertex-stuck (snap holds position) and edge-hugging (vertex slides along edge). -const resolveSnappedCoord = (snap, map, current, nudgedCoord, snappedCoord, dx, dy) => { - if (!snap) { - return snappedCoord - } - const nudgeVec = [nudgedCoord[0] - current[0], nudgedCoord[1] - current[1]] - const actualVec = [snappedCoord[0] - current[0], snappedCoord[1] - current[1]] - const nudgeLenSq = nudgeVec[0] ** 2 + nudgeVec[1] ** 2 - const dot = actualVec[0] * nudgeVec[0] + actualVec[1] * nudgeVec[1] - if (nudgeLenSq > 0 && dot / nudgeLenSq < 0.5) { - const escapePx = snap.snapRadius + 1 - return nudgeCoord(map, current, dx === 0 ? 0 : Math.sign(dx) * escapePx, dy === 0 ? 0 : Math.sign(dy) * escapePx) - } - return snappedCoord -} - -const wireNudge = ({ map, snap, getState, setState, onInserted }) => { - const keyMove = { start: null, index: null } - - // Insert the midpoint as a vertex then move it — midpoints stay as midpoints until actually moved. - const nudgeMidpoint = (olFeature, midpoints, selectedVertexIndex, vertices, dx, dy) => { - const result = insertAtMidpoint(olFeature, midpoints, selectedVertexIndex, vertices.length) - if (!result) { - return - } - onInserted({ insertedIndex: result.insertedIndex }) // pushes insert_vertex undo + syncGeom - const updatedVertices = getState().vertices - const insertedCoord = updatedVertices[result.insertedIndex] - if (!insertedCoord) { - return - } - keyMove.start = [...insertedCoord] - keyMove.index = result.insertedIndex - const nudgedCoord = nudgeCoord(map, insertedCoord, dx, dy) - const movedCoord = snap ? snap.apply(nudgedCoord) : nudgedCoord - moveVertex(olFeature, result.insertedIndex, movedCoord) - setState({ - selectedVertexIndex: result.insertedIndex, - selectedVertexType: 'vertex', - vertices: updatedVertices.map((c, i) => i === result.insertedIndex ? movedCoord : c) - }) - } - - const nudge = (e) => { - const { selectedVertexIndex, selectedVertexType, vertices, midpoints, olFeature } = getState() - if (!olFeature) { - return - } - const step = e.shiftKey ? KEYBOARD.nudgeAmount : KEYBOARD.stepAmount - const offsets = { ArrowUp: [0, -step], ArrowDown: [0, step], ArrowLeft: [-step, 0], ArrowRight: [step, 0] } - const [dx, dy] = offsets[e.key] - if (selectedVertexType === 'midpoint') { - nudgeMidpoint(olFeature, midpoints, selectedVertexIndex, vertices, dx, dy) - return - } - if (selectedVertexIndex < 0 || !vertices[selectedVertexIndex]) { - return - } - const current = vertices[selectedVertexIndex] - if (!keyMove.start) { - keyMove.start = [...current] - keyMove.index = selectedVertexIndex - } - const nudgedCoord = nudgeCoord(map, current, dx, dy) - const snappedCoord = snap ? snap.apply(nudgedCoord) : nudgedCoord - snap?.hideIndicator() - const newCoord = resolveSnappedCoord(snap, map, current, nudgedCoord, snappedCoord, dx, dy) - moveVertex(olFeature, selectedVertexIndex, newCoord) - setState({ vertices: vertices.map((c, i) => i === selectedVertexIndex ? newCoord : c) }) - } - - return { nudge, keyMove } -} - const isInteractiveElementFocused = (appViewport) => { const el = document.activeElement if (!el || el === document.body) { @@ -127,15 +50,10 @@ const isInteractiveElementFocused = (appViewport) => { if (appViewport.contains(el)) { return false } - const tag = el.tagName - return INTERACTIVE_TAGS.has(tag) || el.isContentEditable || el.hasAttribute('tabindex') + return INTERACTIVE_TAGS.has(el.tagName) || el.isContentEditable || el.hasAttribute('tabindex') } -const wireKeyboardEvents = ({ map, snap, getState, setState, onVertexMoved, onInserted, onDeleted, onUndo, onKeyboardActive }) => { - const { nudge, keyMove } = wireNudge({ map, snap, getState, setState, onInserted }) - const appViewport = map.getViewport().closest('[role="application"]') ?? map.getViewport() - const isFocused = () => isInteractiveElementFocused(appViewport) - +const buildKeydownHandler = ({ map, getState, setState, nudge, keyMove, onUndo, onKeyboardActive, isFocused }) => { const handleArrowKey = (e) => { if (e.altKey) { e.preventDefault() @@ -171,41 +89,33 @@ const wireKeyboardEvents = ({ map, snap, getState, setState, onVertexMoved, onIn } } - const onKeydown = (e) => { - if (!isFocused()) { - if (e.key === 'Escape' && getState().selectedVertexIndex >= 0) { - e.preventDefault() - keyMove.start = null - keyMove.index = null - setState({ selectedVertexIndex: -1, selectedVertexType: null }) - } else { - handleKey(e) - } + return (e) => { + if (isFocused()) { + return } - } - - const onKeyup = (e) => { - if (!isFocused()) { - if (ARROW_KEYS.has(e.key) && keyMove.start && keyMove.index != null) { - snap?.hideIndicator() - onVertexMoved({ vertexIndex: keyMove.index, previousCoord: keyMove.start }) - keyMove.start = null - keyMove.index = null - } - if (e.key === 'Delete') { - onDeleted() - } + if (e.key === 'Escape' && getState().selectedVertexIndex >= 0) { + e.preventDefault() + keyMove.start = null + keyMove.index = null + setState({ selectedVertexIndex: -1, selectedVertexType: null }) + } else { + handleKey(e) } } +} - window.addEventListener('keydown', onKeydown, { capture: true }) - window.addEventListener('keyup', onKeyup, { capture: true }) - - return { - destroy () { - window.removeEventListener('keydown', onKeydown, { capture: true }) - window.removeEventListener('keyup', onKeyup, { capture: true }) - } +const buildKeyupHandler = ({ snap, keyMove, onVertexMoved, onDeleted, isFocused }) => (e) => { + if (isFocused()) { + return + } + if (ARROW_KEYS.has(e.key) && keyMove.start && keyMove.index != null) { + snap?.hideIndicator() + onVertexMoved({ vertexIndex: keyMove.index, previousCoord: keyMove.start }) + keyMove.start = null + keyMove.index = null + } + if (e.key === 'Delete') { + onDeleted() } } @@ -222,7 +132,25 @@ const wireKeyboardEvents = ({ map, snap, getState, setState, onVertexMoved, onIn * Midpoints remain midpoints until moved — navigating to a midpoint (Space/Alt+Arrow) does not * convert it. Only pressing a plain/Shift arrow converts it. * - * @param {{ map, container, getState, setState, onVertexMoved, onInserted, onDeleted, onUndo }} + * @param {{ map, getState, setState, snap, onVertexMoved, onInserted, onDeleted, onUndo, onKeyboardActive }} options * @returns {{ destroy }} */ -export const createKeyboardHandler = (options) => wireKeyboardEvents(options) +export const createKeyboardHandler = (options) => { + const { map, snap, getState, setState, onVertexMoved, onInserted, onDeleted, onUndo, onKeyboardActive } = options + const { nudge, keyMove } = wireNudge({ map, snap, getState, setState, onInserted }) + const appViewport = map.getViewport().closest('[role="application"]') ?? map.getViewport() + const isFocused = () => isInteractiveElementFocused(appViewport) + + const onKeydown = buildKeydownHandler({ map, getState, setState, nudge, keyMove, onUndo, onKeyboardActive, isFocused }) + const onKeyup = buildKeyupHandler({ snap, keyMove, onVertexMoved, onDeleted, isFocused }) + + globalThis.addEventListener('keydown', onKeydown, { capture: true }) + globalThis.addEventListener('keyup', onKeyup, { capture: true }) + + return { + destroy () { + globalThis.removeEventListener('keydown', onKeydown, { capture: true }) + globalThis.removeEventListener('keyup', onKeyup, { capture: true }) + } + } +} diff --git a/plugins/beta/draw/src/adapters/openlayers/edit/keyboardHandler.test.js b/plugins/beta/draw/src/adapters/openlayers/edit/keyboardHandler.test.js new file mode 100644 index 000000000..abf803fa9 --- /dev/null +++ b/plugins/beta/draw/src/adapters/openlayers/edit/keyboardHandler.test.js @@ -0,0 +1,135 @@ +import { createKeyboardHandler } from './keyboardHandler.js' +import { createFakeMap, polygonFeature } from '../__helpers__/harness.js' + +const RING = [[0, 0], [100, 0], [100, 100], [0, 0]] + +const setup = () => { + const map = createFakeMap({ center: [98, 98] }) // crosshair near vertex [100, 100] + const state = { + olFeature: polygonFeature(RING), + selectedVertexIndex: -1, + selectedVertexType: null, + vertices: [[0, 0], [100, 0], [100, 100]], + midpoints: [[50, 0], [100, 50], [50, 50]] + } + const setState = jest.fn((updates) => Object.assign(state, updates)) + const callbacks = { + onVertexMoved: jest.fn(), + onInserted: jest.fn(), + onDeleted: jest.fn(), + onUndo: jest.fn(), + onKeyboardActive: jest.fn() + } + const handler = createKeyboardHandler({ map, getState: () => state, setState, snap: null, ...callbacks }) + liveHandlers.push(handler) + return { map, state, setState, handler, ...callbacks } +} + +const liveHandlers = [] +afterEach(() => { + liveHandlers.splice(0).forEach((h) => h.destroy()) + document.body.innerHTML = '' +}) + +const key = (type, props) => window.dispatchEvent(new KeyboardEvent(type, { cancelable: true, ...props })) + +test('Space selects the vertex or midpoint nearest the crosshair, only when nothing is selected', () => { + const { state, setState, onKeyboardActive } = setup() + key('keydown', { key: ' ' }) + expect(state.selectedVertexIndex).toBe(2) // [100, 100] is nearest [98, 98] + expect(state.selectedVertexType).toBe('vertex') + expect(onKeyboardActive).toHaveBeenCalled() + setState.mockClear() + key('keydown', { key: ' ' }) + expect(setState).not.toHaveBeenCalled() +}) + +test('Alt+Arrow navigates the selection to the nearest handle in that direction', () => { + const { state } = setup() + Object.assign(state, { selectedVertexIndex: 2, selectedVertexType: 'vertex' }) + key('keydown', { key: 'ArrowUp', altKey: true }) + expect(state.selectedVertexIndex).toBe(4) // midpoint [100, 50] is nearest above [100, 100] + expect(state.selectedVertexType).toBe('midpoint') +}) + +test('a plain arrow nudges the selected vertex, and keyup commits a single move op', () => { + const { state, onVertexMoved } = setup() + Object.assign(state, { selectedVertexIndex: 1, selectedVertexType: 'vertex' }) + key('keydown', { key: 'ArrowRight' }) + key('keydown', { key: 'ArrowRight' }) + expect(state.vertices[1]).toEqual([110, 0]) + + key('keyup', { key: 'ArrowRight' }) + expect(onVertexMoved).toHaveBeenCalledWith({ vertexIndex: 1, previousCoord: [100, 0] }) + key('keyup', { key: 'ArrowRight' }) // no pending move — not committed twice + expect(onVertexMoved).toHaveBeenCalledTimes(1) +}) + +test('arrows without a selection do nothing', () => { + const { setState } = setup() + key('keydown', { key: 'ArrowRight' }) + expect(setState).not.toHaveBeenCalled() +}) + +test('Escape clears the selection and any pending nudge', () => { + const { state, setState, onVertexMoved } = setup() + Object.assign(state, { selectedVertexIndex: 1, selectedVertexType: 'vertex' }) + key('keydown', { key: 'ArrowRight' }) + key('keydown', { key: 'Escape' }) + expect(setState).toHaveBeenLastCalledWith({ selectedVertexIndex: -1, selectedVertexType: null }) + key('keyup', { key: 'ArrowRight' }) + expect(onVertexMoved).not.toHaveBeenCalled() +}) + +test('Delete deletes, ctrl/cmd+z undoes — but not while typing in an input inside the viewport', () => { + const { map, onDeleted, onUndo } = setup() + key('keyup', { key: 'Delete' }) + expect(onDeleted).toHaveBeenCalled() + key('keydown', { key: 'z', ctrlKey: true }) + expect(onUndo).toHaveBeenCalledTimes(1) + + const input = document.createElement('input') + map.getViewport().appendChild(input) + input.focus() + key('keydown', { key: 'z', ctrlKey: true }) + expect(onUndo).toHaveBeenCalledTimes(1) +}) + +test('keys are ignored while an interactive element outside the viewport has focus', () => { + const { onDeleted, onKeyboardActive } = setup() + const button = document.createElement('button') + document.body.appendChild(button) + button.focus() + key('keyup', { key: 'Delete' }) + key('keydown', { key: ' ' }) + expect(onDeleted).not.toHaveBeenCalled() + expect(onKeyboardActive).not.toHaveBeenCalled() +}) + +test('unhandled keys do nothing', () => { + const { setState } = setup() + key('keydown', { key: 'a' }) + expect(setState).not.toHaveBeenCalled() +}) + +test('destroy removes the window listeners', () => { + const { handler, onDeleted } = setup() + handler.destroy() + key('keyup', { key: 'Delete' }) + expect(onDeleted).not.toHaveBeenCalled() +}) + +test('selection and navigation are no-ops without handles or without projectable pixels', () => { + const { state, setState } = setup() + state.vertices = [] + key('keydown', { key: ' ' }) + key('keydown', { key: 'ArrowUp', altKey: true }) + expect(setState).not.toHaveBeenCalled() + + const { state: s2, setState: set2, map } = setup() + map.getPixelFromCoordinate = () => null // e.g. mid view transition + key('keydown', { key: ' ' }) + key('keydown', { key: 'ArrowUp', altKey: true }) // no selection → start px unprojectable + expect(set2).not.toHaveBeenCalled() + expect(s2.selectedVertexIndex).toBe(-1) +}) diff --git a/plugins/beta/draw/src/adapters/openlayers/edit/modifyInteraction.js b/plugins/beta/draw/src/adapters/openlayers/edit/modifyInteraction.js new file mode 100644 index 000000000..0c7af489d --- /dev/null +++ b/plugins/beta/draw/src/adapters/openlayers/edit/modifyInteraction.js @@ -0,0 +1,83 @@ +import Modify from 'ol/interaction/Modify.js' +import Collection from 'ol/Collection.js' +import { findNearest, PIXEL_TOLERANCE } from './vertexHitTest.js' + +/** + * Derive the undo operation from the vertex arrays before and after an OL + * Modify drag. A grown array means a midpoint drag inserted a vertex; an + * equal-length array with a changed coordinate means a vertex move. + * + * @param {number[][]} prevCoords - vertices snapshotted at modifystart + * @param {number[][]} newCoords - vertices after modifyend + * @returns {{ type: string, vertexIndex: number, previousCoord?: number[] } | null} + */ +export const deriveModifyOp = (prevCoords, newCoords) => { + if (newCoords.length > prevCoords.length) { + const insertedIdx = newCoords.findIndex((c, i) => c[0] !== prevCoords[i]?.[0]) + return { type: 'insert_vertex', vertexIndex: Math.max(0, insertedIdx) } + } + if (newCoords.length === prevCoords.length) { + const movedIdx = newCoords.findIndex((c, i) => c[0] !== prevCoords[i][0] || c[1] !== prevCoords[i][1]) + if (movedIdx >= 0) { + return { type: 'move_vertex', vertexIndex: movedIdx, previousCoord: prevCoords[movedIdx] } + } + } + return null +} + +// Only activate Modify when clicking on a vertex/midpoint handle, never for the +// touch interface (touchHandler covers touch drags via the SVG offset target) +export const buildModifyCondition = ({ map, getState }) => (mapBrowserEvent) => { + const { interfaceType, vertices, midpoints } = getState() + if (interfaceType === 'touch') { + return false + } + const olPixel = map.getEventPixel(mapBrowserEvent.originalEvent) + return findNearest(map, vertices, midpoints, { x: olPixel[0], y: olPixel[1] }) !== null +} + +/** + * OL Modify interaction wiring for edit mode. + * + * Modify handles pointer vertex dragging and midpoint insertion natively; this + * wrapper restricts it to clicks on a handle (buildModifyCondition) and reports + * each completed drag via `onModifyEnd(prevCoords)` with the vertices + * snapshotted at drag start. + * + * @returns {{ destroy: () => void }} + */ +export const createModifyInteraction = ({ map, olFeature, getState, onModifyEnd }) => { + const condition = buildModifyCondition({ map, getState }) + + const modifyInteraction = new Modify({ + features: new Collection([olFeature]), + style: () => [], // vertex circles rendered by vertexLayer instead + pixelTolerance: PIXEL_TOLERANCE, + condition + }) + map.addInteraction(modifyInteraction) + + let startCoords = null + + modifyInteraction.on('modifystart', () => { + if (getState().interfaceType === 'touch') { + return + } + startCoords = getState().vertices.map(c => [...c]) + }) + + modifyInteraction.on('modifyend', () => { + if (getState().interfaceType === 'touch') { + return + } + const prevCoords = startCoords + startCoords = null + onModifyEnd(prevCoords) + }) + + return { + destroy () { + map.removeInteraction(modifyInteraction) + } + } +} diff --git a/plugins/beta/draw/src/adapters/openlayers/edit/modifyInteraction.test.js b/plugins/beta/draw/src/adapters/openlayers/edit/modifyInteraction.test.js new file mode 100644 index 000000000..7a4f675f1 --- /dev/null +++ b/plugins/beta/draw/src/adapters/openlayers/edit/modifyInteraction.test.js @@ -0,0 +1,80 @@ +import { deriveModifyOp, buildModifyCondition, createModifyInteraction } from './modifyInteraction.js' +import { createFakeMap, polygonFeature } from '../__helpers__/harness.js' + +const RING = [[0, 0], [10, 0], [10, 10], [0, 0]] + +describe('deriveModifyOp', () => { + const prev = [[0, 0], [10, 0], [10, 10]] + + test('a moved vertex becomes a move_vertex op with its previous coordinate', () => { + expect(deriveModifyOp(prev, [[0, 0], [12, 2], [10, 10]])) + .toEqual({ type: 'move_vertex', vertexIndex: 1, previousCoord: [10, 0] }) + }) + + test('a grown array becomes an insert_vertex op at the first differing index', () => { + expect(deriveModifyOp(prev, [[0, 0], [5, 5], [10, 0], [10, 10]])) + .toEqual({ type: 'insert_vertex', vertexIndex: 1 }) + expect(deriveModifyOp(prev, [...prev, [9, 9]])) + .toEqual({ type: 'insert_vertex', vertexIndex: 3 }) + }) + + test('no coordinate change or a shrunk array yields no op', () => { + expect(deriveModifyOp(prev, prev.map(c => [...c]))).toBeNull() + expect(deriveModifyOp(prev, prev.slice(0, 2))).toBeNull() + }) +}) + +describe('buildModifyCondition', () => { + const event = (x, y) => ({ originalEvent: { clientX: x, clientY: y } }) + const conditionFor = (interfaceType) => buildModifyCondition({ + map: createFakeMap(), + getState: () => ({ interfaceType, vertices: [[10, 10]], midpoints: [[50, 50]] }) + }) + + test('activates on a vertex or midpoint handle but not on empty map', () => { + const condition = conditionFor('mouse') + expect(condition(event(11, 11))).toBe(true) + expect(condition(event(51, 49))).toBe(true) + expect(condition(event(200, 200))).toBe(false) + }) + + test('never activates for the touch interface', () => { + expect(conditionFor('touch')(event(11, 11))).toBe(false) + }) +}) + +describe('createModifyInteraction', () => { + const setup = (interfaceType = 'mouse') => { + const map = createFakeMap() + const state = { interfaceType, vertices: [[0, 0], [10, 0]], midpoints: [] } + const onModifyEnd = jest.fn() + const modify = createModifyInteraction({ map, olFeature: polygonFeature(RING), getState: () => state, onModifyEnd }) + return { map, state, onModifyEnd, modify, interaction: map.interactions[0] } + } + + test('reports a completed drag with the vertices snapshotted at drag start', () => { + const { state, onModifyEnd, interaction } = setup() + interaction.dispatchEvent({ type: 'modifystart' }) + state.vertices = [[5, 5], [10, 0]] // drag mutates state via geometry change + interaction.dispatchEvent({ type: 'modifyend' }) + expect(onModifyEnd).toHaveBeenCalledWith([[0, 0], [10, 0]]) + }) + + test('touch drags are ignored entirely', () => { + const { onModifyEnd, interaction } = setup('touch') + interaction.dispatchEvent({ type: 'modifystart' }) + interaction.dispatchEvent({ type: 'modifyend' }) + expect(onModifyEnd).not.toHaveBeenCalled() + }) + + test('vertex handles are rendered by the vertex layer, not by Modify itself', () => { + const { interaction } = setup() + expect(interaction.getOverlay().getStyleFunction()()).toEqual([]) + }) + + test('destroy removes the interaction from the map', () => { + const { map, modify, interaction } = setup() + modify.destroy() + expect(map.removeInteraction).toHaveBeenCalledWith(interaction) + }) +}) diff --git a/plugins/beta/draw/src/adapters/openlayers/edit/nudge.js b/plugins/beta/draw/src/adapters/openlayers/edit/nudge.js new file mode 100644 index 000000000..60d850077 --- /dev/null +++ b/plugins/beta/draw/src/adapters/openlayers/edit/nudge.js @@ -0,0 +1,91 @@ +import { nudgeCoord } from '../utils/olCoords.js' +import { moveVertex, insertAtMidpoint } from './vertexOps.js' +import { KEYBOARD } from '../defaults.js' + +const SNAP_PROGRESS_RATIO = 0.5 + +/** + * Returns snappedCoord, but escapes snap if it prevents sufficient progress in the + * nudge direction. Covers vertex-stuck (snap holds position) and edge-hugging + * (vertex slides along edge). + */ +export const resolveSnappedCoord = (snap, map, current, nudgedCoord, snappedCoord, dx, dy) => { + if (!snap) { + return snappedCoord + } + const nudgeVec = [nudgedCoord[0] - current[0], nudgedCoord[1] - current[1]] + const actualVec = [snappedCoord[0] - current[0], snappedCoord[1] - current[1]] + const nudgeLenSq = nudgeVec[0] ** 2 + nudgeVec[1] ** 2 + const dot = actualVec[0] * nudgeVec[0] + actualVec[1] * nudgeVec[1] + if (nudgeLenSq > 0 && dot / nudgeLenSq < SNAP_PROGRESS_RATIO) { + const escapePx = snap.snapRadius + 1 + return nudgeCoord(map, current, dx === 0 ? 0 : Math.sign(dx) * escapePx, dy === 0 ? 0 : Math.sign(dy) * escapePx) + } + return snappedCoord +} + +/** + * Arrow-key vertex nudging for edit mode. + * + * `keyMove` tracks the coordinate at the start of a nudge sequence so a single + * move_vertex undo op can be pushed on keyup (see keyboardHandler). + * + * @returns {{ nudge: (e: KeyboardEvent) => void, keyMove: { start, index } }} + */ +export const wireNudge = ({ map, snap, getState, setState, onInserted }) => { + const keyMove = { start: null, index: null } + + // Insert the midpoint as a vertex then move it — midpoints stay as midpoints until actually moved. + const nudgeMidpoint = (olFeature, midpoints, selectedVertexIndex, vertices, dx, dy) => { + const result = insertAtMidpoint(olFeature, midpoints, selectedVertexIndex, vertices.length) + if (!result) { + return + } + onInserted({ insertedIndex: result.insertedIndex }) // pushes insert_vertex undo + syncGeom + const updatedVertices = getState().vertices + const insertedCoord = updatedVertices[result.insertedIndex] + if (!insertedCoord) { + return + } + keyMove.start = [...insertedCoord] + keyMove.index = result.insertedIndex + const nudgedCoord = nudgeCoord(map, insertedCoord, dx, dy) + const movedCoord = snap ? snap.apply(nudgedCoord) : nudgedCoord + moveVertex(olFeature, result.insertedIndex, movedCoord) + setState({ + selectedVertexIndex: result.insertedIndex, + selectedVertexType: 'vertex', + vertices: updatedVertices.map((c, i) => i === result.insertedIndex ? movedCoord : c) + }) + } + + const nudge = (e) => { + const { selectedVertexIndex, selectedVertexType, vertices, midpoints, olFeature } = getState() + if (!olFeature) { + return + } + const step = e.shiftKey ? KEYBOARD.nudgeAmount : KEYBOARD.stepAmount + const offsets = { ArrowUp: [0, -step], ArrowDown: [0, step], ArrowLeft: [-step, 0], ArrowRight: [step, 0] } + const [dx, dy] = offsets[e.key] + if (selectedVertexType === 'midpoint') { + nudgeMidpoint(olFeature, midpoints, selectedVertexIndex, vertices, dx, dy) + return + } + if (selectedVertexIndex < 0 || !vertices[selectedVertexIndex]) { + return + } + const current = vertices[selectedVertexIndex] + if (!keyMove.start) { + keyMove.start = [...current] + keyMove.index = selectedVertexIndex + } + const nudgedCoord = nudgeCoord(map, current, dx, dy) + const snappedCoord = snap ? snap.apply(nudgedCoord) : nudgedCoord + snap?.hideIndicator() + const newCoord = resolveSnappedCoord(snap, map, current, nudgedCoord, snappedCoord, dx, dy) + moveVertex(olFeature, selectedVertexIndex, newCoord) + setState({ vertices: vertices.map((c, i) => i === selectedVertexIndex ? newCoord : c) }) + } + + return { nudge, keyMove } +} diff --git a/plugins/beta/draw/src/adapters/openlayers/edit/nudge.test.js b/plugins/beta/draw/src/adapters/openlayers/edit/nudge.test.js new file mode 100644 index 000000000..a89914ac9 --- /dev/null +++ b/plugins/beta/draw/src/adapters/openlayers/edit/nudge.test.js @@ -0,0 +1,116 @@ +import { wireNudge, resolveSnappedCoord } from './nudge.js' +import { getCoords } from '../utils/geometryHelpers.js' +import { createFakeMap, polygonFeature } from '../__helpers__/harness.js' + +const RING = [[0, 0], [10, 0], [10, 10], [0, 0]] + +const setup = ({ snap = null } = {}) => { + const map = createFakeMap() + const olFeature = polygonFeature(RING) + const state = { + olFeature, + selectedVertexIndex: -1, + selectedVertexType: null, + vertices: [[0, 0], [10, 0], [10, 10]], + midpoints: [[5, 0], [10, 5], [5, 5]] + } + const setState = jest.fn((updates) => Object.assign(state, updates)) + const onInserted = jest.fn(() => { + // EditMode's onInserted runs syncGeom, refreshing vertices from the geometry + state.vertices = getCoords({ type: 'Polygon', coordinates: olFeature.getGeometry().getCoordinates() }) + }) + const { nudge, keyMove } = wireNudge({ map, snap, getState: () => state, setState, onInserted }) + return { olFeature, state, setState, onInserted, nudge, keyMove } +} + +const ring = (olFeature) => olFeature.getGeometry().getCoordinates()[0] + +describe('nudging a vertex', () => { + test('a plain arrow moves the selected vertex by the step amount, shift by the fine amount', () => { + const { state, nudge, olFeature } = setup() + Object.assign(state, { selectedVertexIndex: 1, selectedVertexType: 'vertex' }) + nudge({ key: 'ArrowRight', shiftKey: false }) + expect(ring(olFeature)[1]).toEqual([15, 0]) + nudge({ key: 'ArrowDown', shiftKey: true }) + expect(ring(olFeature)[1]).toEqual([15, 1]) + }) + + test('the start coordinate is captured once for the whole nudge sequence (single undo op)', () => { + const { state, nudge, keyMove } = setup() + Object.assign(state, { selectedVertexIndex: 1, selectedVertexType: 'vertex' }) + nudge({ key: 'ArrowRight' }) + nudge({ key: 'ArrowRight' }) + expect(keyMove).toEqual({ start: [10, 0], index: 1 }) + }) + + test('does nothing without a feature or a valid selection', () => { + const { state, nudge, setState } = setup() + nudge({ key: 'ArrowRight' }) // nothing selected + state.olFeature = null + nudge({ key: 'ArrowRight' }) + expect(setState).not.toHaveBeenCalled() + }) +}) + +describe('nudging a midpoint', () => { + test('inserts the midpoint as a vertex, then moves and selects it', () => { + const { state, nudge, onInserted, olFeature, setState } = setup() + Object.assign(state, { selectedVertexIndex: 4, selectedVertexType: 'midpoint' }) // midpoint [10, 5] + nudge({ key: 'ArrowRight', shiftKey: false }) + expect(onInserted).toHaveBeenCalledWith({ insertedIndex: 2 }) + expect(ring(olFeature)[2]).toEqual([15, 5]) + expect(setState).toHaveBeenLastCalledWith(expect.objectContaining({ + selectedVertexIndex: 2, selectedVertexType: 'vertex' + })) + }) + + test('an invalid midpoint index is a no-op', () => { + const { state, nudge, onInserted } = setup() + Object.assign(state, { selectedVertexIndex: 99, selectedVertexType: 'midpoint' }) + nudge({ key: 'ArrowRight' }) + expect(onInserted).not.toHaveBeenCalled() + }) + + test('stops safely if the inserted vertex is not reflected in state', () => { + const { state, nudge, onInserted, setState } = setup() + onInserted.mockImplementation(() => {}) // consumer failed to sync vertices + Object.assign(state, { selectedVertexIndex: 1, selectedVertexType: 'midpoint', vertices: [] }) + nudge({ key: 'ArrowRight' }) // insert succeeds but the inserted coord is missing from state + expect(onInserted).toHaveBeenCalled() + expect(setState).not.toHaveBeenCalled() + }) +}) + +describe('snapping while nudging', () => { + const snapReturning = (impl) => ({ apply: jest.fn(impl), hideIndicator: jest.fn(), snapRadius: 12 }) + + test('the snapped coordinate is used when it makes progress in the nudge direction', () => { + const snap = snapReturning((c) => [c[0] + 1, c[1]]) + const { state, nudge, olFeature } = setup({ snap }) + Object.assign(state, { selectedVertexIndex: 1, selectedVertexType: 'vertex' }) + nudge({ key: 'ArrowRight' }) + expect(ring(olFeature)[1]).toEqual([16, 0]) + expect(snap.hideIndicator).toHaveBeenCalled() + }) + + test('escapes the snap radius when snapping blocks the nudge', () => { + const snap = snapReturning(() => [10, 0]) // snap holds the vertex in place + const { state, nudge, olFeature } = setup({ snap }) + Object.assign(state, { selectedVertexIndex: 1, selectedVertexType: 'vertex' }) + nudge({ key: 'ArrowRight' }) + expect(ring(olFeature)[1]).toEqual([23, 0]) // snapRadius + 1 past the original position + }) +}) + +describe('resolveSnappedCoord', () => { + test('returns the snapped coordinate without a snap manager or when progress is sufficient', () => { + const map = createFakeMap() + expect(resolveSnappedCoord(null, map, [0, 0], [5, 0], [4, 0], 5, 0)).toEqual([4, 0]) + expect(resolveSnappedCoord({ snapRadius: 12 }, map, [0, 0], [5, 0], [4, 0], 5, 0)).toEqual([4, 0]) + }) + + test('escapes along both axes of a diagonal nudge when blocked', () => { + const map = createFakeMap() + expect(resolveSnappedCoord({ snapRadius: 12 }, map, [0, 0], [5, 5], [0, 0], 5, 5)).toEqual([13, 13]) + }) +}) diff --git a/plugins/beta/draw/src/adapters/openlayers/edit/pointerHandlers.js b/plugins/beta/draw/src/adapters/openlayers/edit/pointerHandlers.js new file mode 100644 index 000000000..e882e668f --- /dev/null +++ b/plugins/beta/draw/src/adapters/openlayers/edit/pointerHandlers.js @@ -0,0 +1,79 @@ +import { findNearest } from './vertexHitTest.js' + +/** + * Pointer/mouse selection handlers for edit mode, plus the global + * delete-vertex button listener. + * + * Interface-type flips here mutate state directly (not via setState) because + * switching device must not touch the current selection. + * + * @returns {{ destroy: () => void }} + */ +export const createPointerHandlers = ({ map, container, getState, setState, touchHandler, deleteVertexButtonId, onDeleteVertex }) => { + const hitAt = (e) => { + const { vertices, midpoints } = getState() + const olPixel = map.getEventPixel(e) + return findNearest(map, vertices, midpoints, { x: olPixel[0], y: olPixel[1] }) + } + + const onPointerdown = (e) => { + const state = getState() + if (e.pointerType === 'touch') { + state.interfaceType = 'touch' + touchHandler.updateTargetPosition() + return + } + state.interfaceType = 'mouse' + const hit = hitAt(e) + if (hit?.type === 'vertex') { + setState({ selectedVertexIndex: hit.index, selectedVertexType: 'vertex' }) + } + } + + // click fires after OL Modify finishes, so state.vertices reflects any insertions/moves + const onContainerClick = (e) => { + if (getState().interfaceType === 'touch') { + return + } + const hit = hitAt(e) + if (hit?.type === 'vertex') { + setState({ selectedVertexIndex: hit.index, selectedVertexType: 'vertex' }) + } else if (hit?.type === 'midpoint') { + // modifyend already selected the inserted vertex — nothing to do here + } else { + setState({ selectedVertexIndex: -1, selectedVertexType: null }) + } + } + + // Switch to pointer mode and hide the touch target as soon as the mouse moves + const onPointerMove = (e) => { + const state = getState() + if (e.pointerType !== 'mouse' || state.interfaceType === 'mouse') { + return + } + state.interfaceType = 'mouse' + touchHandler.hide() + } + + const onButtonClick = (e) => { + if (deleteVertexButtonId && e.target.closest(`#${deleteVertexButtonId}`)) { + onDeleteVertex() + } + } + + container.addEventListener('pointerdown', onPointerdown) + container.addEventListener('pointerenter', onPointerMove) + container.addEventListener('pointermove', onPointerMove) + container.addEventListener('click', onContainerClick) + globalThis.addEventListener('click', onButtonClick) + + return { + destroy () { + container.removeEventListener('pointerdown', onPointerdown) + container.removeEventListener('pointerenter', onPointerMove) + container.removeEventListener('pointermove', onPointerMove) + container.removeEventListener('click', onContainerClick) + globalThis.removeEventListener('click', onButtonClick) + } + } +} diff --git a/plugins/beta/draw/src/adapters/openlayers/edit/pointerHandlers.test.js b/plugins/beta/draw/src/adapters/openlayers/edit/pointerHandlers.test.js new file mode 100644 index 000000000..215519826 --- /dev/null +++ b/plugins/beta/draw/src/adapters/openlayers/edit/pointerHandlers.test.js @@ -0,0 +1,78 @@ +import { createPointerHandlers } from './pointerHandlers.js' +import { createFakeMap, createContainer, domEvent } from '../__helpers__/harness.js' + +const setup = (interfaceType = 'mouse') => { + const map = createFakeMap() + const container = createContainer() + const button = document.createElement('button') + button.id = 'delete-vertex' + document.body.appendChild(button) + const state = { interfaceType, vertices: [[10, 10]], midpoints: [[50, 50]] } + const setState = jest.fn((updates) => Object.assign(state, updates)) + const touchHandler = { updateTargetPosition: jest.fn(), hide: jest.fn() } + const onDeleteVertex = jest.fn() + const handlers = createPointerHandlers({ + map, container, getState: () => state, setState, touchHandler, deleteVertexButtonId: 'delete-vertex', onDeleteVertex + }) + return { container, button, state, setState, touchHandler, onDeleteVertex, handlers } +} + +afterEach(() => { document.body.innerHTML = '' }) + +const pointer = (type, pointerType, x = 0, y = 0) => domEvent(type, { pointerType, clientX: x, clientY: y }) + +test('touch pointerdown switches to touch interface and repositions the touch target', () => { + const { container, state, touchHandler, setState } = setup() + container.dispatchEvent(pointer('pointerdown', 'touch')) + expect(state.interfaceType).toBe('touch') + expect(touchHandler.updateTargetPosition).toHaveBeenCalled() + expect(setState).not.toHaveBeenCalled() // device switch must not touch the selection +}) + +test('mouse pointerdown selects a vertex under the pointer, but nothing elsewhere', () => { + const { container, state, setState } = setup('touch') + container.dispatchEvent(pointer('pointerdown', 'mouse', 11, 11)) + expect(state.interfaceType).toBe('mouse') + expect(setState).toHaveBeenCalledWith({ selectedVertexIndex: 0, selectedVertexType: 'vertex' }) + setState.mockClear() + container.dispatchEvent(pointer('pointerdown', 'mouse', 200, 200)) + expect(setState).not.toHaveBeenCalled() +}) + +test('click selects a vertex, deselects on empty map, leaves midpoints to Modify, and ignores touch', () => { + const { container, state, setState } = setup() + container.dispatchEvent(pointer('click', 'mouse', 11, 11)) + expect(setState).toHaveBeenCalledWith({ selectedVertexIndex: 0, selectedVertexType: 'vertex' }) + container.dispatchEvent(pointer('click', 'mouse', 51, 49)) + expect(setState).toHaveBeenCalledTimes(1) // midpoint click: modifyend already handled selection + container.dispatchEvent(pointer('click', 'mouse', 200, 200)) + expect(setState).toHaveBeenLastCalledWith({ selectedVertexIndex: -1, selectedVertexType: null }) + + state.interfaceType = 'touch' + setState.mockClear() + container.dispatchEvent(pointer('click', 'mouse', 11, 11)) + expect(setState).not.toHaveBeenCalled() +}) + +test('mouse movement switches the interface back to mouse and hides the touch target once', () => { + const { container, state, touchHandler } = setup('touch') + container.dispatchEvent(pointer('pointermove', 'pen')) + expect(state.interfaceType).toBe('touch') + container.dispatchEvent(pointer('pointermove', 'mouse')) + expect(state.interfaceType).toBe('mouse') + container.dispatchEvent(pointer('pointermove', 'mouse')) + expect(touchHandler.hide).toHaveBeenCalledTimes(1) +}) + +test('the delete-vertex button triggers deletion; other clicks and destroy() do not', () => { + const { button, container, onDeleteVertex, handlers } = setup() + button.dispatchEvent(domEvent('click', {})) + expect(onDeleteVertex).toHaveBeenCalledTimes(1) + document.body.dispatchEvent(domEvent('click', {})) + expect(onDeleteVertex).toHaveBeenCalledTimes(1) + + handlers.destroy() + button.dispatchEvent(domEvent('click', {})) + container.dispatchEvent(pointer('pointerdown', 'touch')) + expect(onDeleteVertex).toHaveBeenCalledTimes(1) +}) diff --git a/plugins/beta/draw/src/adapters/openlayers/edit/selectionState.js b/plugins/beta/draw/src/adapters/openlayers/edit/selectionState.js new file mode 100644 index 000000000..5a1dc69f1 --- /dev/null +++ b/plugins/beta/draw/src/adapters/openlayers/edit/selectionState.js @@ -0,0 +1,100 @@ +import { getCoords, getMidpoints } from '../utils/geometryHelpers.js' +import { ADAPTER_EVENTS } from '../../../adapterEvents.js' + +/** + * Mutable edit-mode state shared by the Modify interaction, pointer, touch and + * keyboard handlers, plus the setState/sync helpers that keep the handle + * layers, the active-selection overlay and the adapter events in step with it. + * + * Hooks (`onDeselect`, `onUpdate`) are bound late via `setHooks` because the + * touch handler both consumes this state and needs to react to its changes. + * + * @returns {{ state, getState, setState, syncGeom, updateLayersFromGeom, setHooks, destroy }} + */ +export const createSelectionState = ({ map, manager, store, olFeature, interfaceType, layers }) => { + const { vertexLayer, midpointLayer, activeLayer } = layers + + const state = { + olFeature, + selectedVertexIndex: -1, + selectedVertexType: null, + vertices: [], + midpoints: [], + interfaceType: interfaceType ?? 'mouse' + } + + const hooks = { onDeselect: null, onUpdate: null } + const setHooks = ({ onDeselect, onUpdate }) => Object.assign(hooks, { onDeselect, onUpdate }) + + const plainGeom = () => { + const geom = olFeature.getGeometry() + return { type: geom.getType(), coordinates: geom.getCoordinates() } + } + + const applySelectionChange = () => { + vertexLayer.setSelected(state.selectedVertexType === 'vertex' ? state.selectedVertexIndex : -1) + midpointLayer.setSelected( + state.selectedVertexType === 'midpoint' ? state.selectedVertexIndex - state.vertices.length : -1 + ) + if (state.selectedVertexIndex < 0) { + hooks.onDeselect?.() + } + activeLayer.update(state) + manager.emit(ADAPTER_EVENTS.VERTEX_SELECTION, { + index: state.selectedVertexType === 'vertex' ? state.selectedVertexIndex : -1, + numVertices: state.vertices.length + }) + } + + const applyVertexChange = () => { + const geom = plainGeom() + midpointLayer.update(geom) + vertexLayer.update(geom) + state.midpoints = midpointLayer.getCoords() + activeLayer.update(state) + hooks.onUpdate?.() + map.render() + } + + const setState = (updates) => { + Object.assign(state, updates) + if (updates.selectedVertexIndex !== undefined) { + applySelectionChange() + } + if (updates.vertices !== undefined) { + applyVertexChange() + } + } + + // Lightweight per-frame update during drag — refreshes layers without emitting events + const updateLayersFromGeom = () => { + const geom = plainGeom() + state.vertices = getCoords(geom) + state.midpoints = getMidpoints(geom) + midpointLayer.update(geom) + vertexLayer.update(geom) + activeLayer.update(state) + } + + const syncGeom = () => { + updateLayersFromGeom() + manager.emit(ADAPTER_EVENTS.VERTEX_CHANGE, { numVertices: state.vertices.length }) + manager.emit(ADAPTER_EVENTS.UPDATE, store.toGeoJSON(olFeature)) + } + + // Keep overlay layers in sync on every geometry change (e.g. during pointer drag) + const onGeometryChange = () => updateLayersFromGeom() + olFeature.getGeometry().on('change', onGeometryChange) + + return { + state, + getState: () => state, + setState, + syncGeom, + updateLayersFromGeom, + setHooks, + destroy () { + olFeature.getGeometry().un('change', onGeometryChange) + } + } +} diff --git a/plugins/beta/draw/src/adapters/openlayers/edit/selectionState.test.js b/plugins/beta/draw/src/adapters/openlayers/edit/selectionState.test.js new file mode 100644 index 000000000..d339e1f95 --- /dev/null +++ b/plugins/beta/draw/src/adapters/openlayers/edit/selectionState.test.js @@ -0,0 +1,81 @@ +import { createSelectionState } from './selectionState.js' +import { createFakeManager, polygonFeature } from '../__helpers__/harness.js' +import { ADAPTER_EVENTS } from '../../../adapterEvents.js' + +const RING = [[0, 0], [10, 0], [10, 10], [0, 0]] + +const setup = (interfaceType) => { + const manager = createFakeManager() + const olFeature = polygonFeature(RING) + const layers = { + vertexLayer: { update: jest.fn(), setSelected: jest.fn() }, + midpointLayer: { update: jest.fn(), setSelected: jest.fn(), getCoords: jest.fn(() => [[5, 0], [10, 5], [5, 5]]) }, + activeLayer: { update: jest.fn() } + } + const map = { render: jest.fn() } + const store = { toGeoJSON: jest.fn(() => ({ id: 'f1' })) } + const selection = createSelectionState({ map, manager, store, olFeature, interfaceType, layers }) + return { manager, olFeature, layers, map, store, selection } +} + +test('defaults to mouse interface when none is given', () => { + expect(setup().selection.state.interfaceType).toBe('mouse') + expect(setup('touch').selection.state.interfaceType).toBe('touch') +}) + +test('selecting a vertex highlights it, clears midpoint selection and reports the selection', () => { + const { selection, layers, manager } = setup() + selection.state.vertices = [[0, 0], [10, 0], [10, 10]] + selection.setState({ selectedVertexIndex: 1, selectedVertexType: 'vertex' }) + expect(layers.vertexLayer.setSelected).toHaveBeenCalledWith(1) + expect(layers.midpointLayer.setSelected).toHaveBeenCalledWith(-1) + expect(layers.activeLayer.update).toHaveBeenCalledWith(selection.state) + expect(manager.emit).toHaveBeenCalledWith(ADAPTER_EVENTS.VERTEX_SELECTION, { index: 1, numVertices: 3 }) +}) + +test('selecting a midpoint highlights it by local index and reports index -1', () => { + const { selection, layers, manager } = setup() + selection.state.vertices = [[0, 0], [10, 0], [10, 10]] + selection.setState({ selectedVertexIndex: 4, selectedVertexType: 'midpoint' }) + expect(layers.midpointLayer.setSelected).toHaveBeenCalledWith(1) + expect(layers.vertexLayer.setSelected).toHaveBeenCalledWith(-1) + expect(manager.emit).toHaveBeenCalledWith(ADAPTER_EVENTS.VERTEX_SELECTION, { index: -1, numVertices: 3 }) +}) + +test('deselecting fires the onDeselect hook', () => { + const { selection } = setup() + const onDeselect = jest.fn() + selection.setHooks({ onDeselect }) + selection.setState({ selectedVertexIndex: -1, selectedVertexType: null }) + expect(onDeselect).toHaveBeenCalled() +}) + +test('a vertices update refreshes the handle layers, midpoints and the map', () => { + const { selection, layers, map } = setup() + const onUpdate = jest.fn() + selection.setHooks({ onUpdate }) + selection.setState({ vertices: [[0, 0], [10, 0], [10, 10]] }) + expect(layers.vertexLayer.update).toHaveBeenCalledWith({ type: 'Polygon', coordinates: [RING] }) + expect(selection.state.midpoints).toEqual([[5, 0], [10, 5], [5, 5]]) + expect(onUpdate).toHaveBeenCalled() + expect(map.render).toHaveBeenCalled() +}) + +test('syncGeom derives state from the geometry and emits vertexchange + update', () => { + const { selection, manager, store } = setup() + selection.syncGeom() + expect(selection.state.vertices).toEqual([[0, 0], [10, 0], [10, 10]]) + expect(selection.state.midpoints).toHaveLength(3) + expect(manager.emit).toHaveBeenCalledWith(ADAPTER_EVENTS.VERTEX_CHANGE, { numVertices: 3 }) + expect(manager.emit).toHaveBeenCalledWith(ADAPTER_EVENTS.UPDATE, store.toGeoJSON()) +}) + +test('geometry changes refresh the layers until destroy unbinds the listener', () => { + const { selection, olFeature, layers } = setup() + olFeature.getGeometry().setCoordinates([[[0, 0], [20, 0], [20, 20], [0, 0]]]) + expect(selection.state.vertices).toEqual([[0, 0], [20, 0], [20, 20]]) + const calls = layers.vertexLayer.update.mock.calls.length + selection.destroy() + olFeature.getGeometry().setCoordinates([[[0, 0], [30, 0], [30, 30], [0, 0]]]) + expect(layers.vertexLayer.update.mock.calls.length).toBe(calls) +}) diff --git a/plugins/beta/draw/src/adapters/openlayers/edit/vertexHitTest.js b/plugins/beta/draw/src/adapters/openlayers/edit/vertexHitTest.js index 34a991d47..a1780cf7f 100644 --- a/plugins/beta/draw/src/adapters/openlayers/edit/vertexHitTest.js +++ b/plugins/beta/draw/src/adapters/openlayers/edit/vertexHitTest.js @@ -1,6 +1,8 @@ import { coordToPixel, pixelDist } from '../utils/olCoords.js' -const PIXEL_TOLERANCE = 12 +// Also used as the Modify interaction's pixelTolerance so pointer hit +// detection and OL's drag activation agree on what "on a handle" means +export const PIXEL_TOLERANCE = 12 /** * Find the nearest vertex to a screen pixel within tolerance. diff --git a/plugins/beta/draw/src/api/split.js b/plugins/beta/draw/src/api/split.js index 18e5da051..144b5bf78 100644 --- a/plugins/beta/draw/src/api/split.js +++ b/plugins/beta/draw/src/api/split.js @@ -1,5 +1,6 @@ import { splitPolygon } from '../utils/spatial.js' import { debounce } from '../utils/debounce.js' +import { ADAPTER_EVENTS } from '../adapterEvents.js' /** * Start drawing a split line for a polygon. @@ -39,12 +40,12 @@ export const split = ({ appState, appConfig, pluginState, mapState, mapProvider // One-shot: compute split result once the line is finalised const onSplitCreate = (geojsonFeature) => { - draw.off('create', onSplitCreate) + draw.off(ADAPTER_EVENTS.CREATE, onSplitCreate) const featureCollection = splitPolygon(polygonFeature, geojsonFeature) draw.setFeatureProperty('_splitter', 'splitter', featureCollection ? 'valid' : 'invalid') dispatch({ type: 'SET_ACTION', payload: { name: 'split', isValid: !!featureCollection } }) } - draw.on('create', onSplitCreate) + draw.on(ADAPTER_EVENTS.CREATE, onSplitCreate) // Real-time preview: update split validity as vertices are placed (ML only) const DEBOUNCE_MS = 50 @@ -59,7 +60,7 @@ export const split = ({ appState, appConfig, pluginState, mapState, mapProvider e.ctx?.store?.render() dispatch({ type: 'SET_ACTION', payload: { name: 'split', isValid } }) }, DEBOUNCE_MS) - draw.on('geometrychange', onGeometryChange) + draw.on(ADAPTER_EVENTS.GEOMETRY_CHANGE, onGeometryChange) dispatch({ type: 'SET_MODE', payload: 'draw_line' }) dispatch({ type: 'SET_ACTION', payload: { name: 'split' } }) diff --git a/plugins/beta/draw/src/events.js b/plugins/beta/draw/src/events.js index 5e8224cd9..a8e33c110 100644 --- a/plugins/beta/draw/src/events.js +++ b/plugins/beta/draw/src/events.js @@ -5,6 +5,7 @@ * draw.setSnapEnabled(), etc.) so this file is map-framework-agnostic. * All MapLibre / OL specifics live in the adapter. */ +import { ADAPTER_EVENTS } from './adapterEvents.js' function createHandlers ({ pluginState, mapProvider, eventBus, resetState, disableSnap }) { const { draw } = mapProvider @@ -43,13 +44,13 @@ function attachButtonHandlers (buttonConfig, handlers) { } function attachDrawEvents (draw, handlers) { - draw.on('create', handlers.onCreate) - draw.on('editfinish', handlers.onEditFinish) - draw.on('cancel', handlers.onCancel) - draw.on('vertexselection', handlers.onVertexSelection) - draw.on('vertexchange', handlers.onVertexChange) - draw.on('undochange', handlers.onUndoChange) - draw.on('update', handlers.onUpdate) + draw.on(ADAPTER_EVENTS.CREATE, handlers.onCreate) + draw.on(ADAPTER_EVENTS.EDIT_FINISH, handlers.onEditFinish) + draw.on(ADAPTER_EVENTS.CANCEL, handlers.onCancel) + draw.on(ADAPTER_EVENTS.VERTEX_SELECTION, handlers.onVertexSelection) + draw.on(ADAPTER_EVENTS.VERTEX_CHANGE, handlers.onVertexChange) + draw.on(ADAPTER_EVENTS.UNDO_CHANGE, handlers.onUndoChange) + draw.on(ADAPTER_EVENTS.UPDATE, handlers.onUpdate) } function detachButtonHandlers (buttonConfig) { @@ -62,13 +63,13 @@ function detachButtonHandlers (buttonConfig) { } function detachDrawEvents (draw, handlers) { - draw.off('create', handlers.onCreate) - draw.off('editfinish', handlers.onEditFinish) - draw.off('cancel', handlers.onCancel) - draw.off('vertexselection', handlers.onVertexSelection) - draw.off('vertexchange', handlers.onVertexChange) - draw.off('undochange', handlers.onUndoChange) - draw.off('update', handlers.onUpdate) + draw.off(ADAPTER_EVENTS.CREATE, handlers.onCreate) + draw.off(ADAPTER_EVENTS.EDIT_FINISH, handlers.onEditFinish) + draw.off(ADAPTER_EVENTS.CANCEL, handlers.onCancel) + draw.off(ADAPTER_EVENTS.VERTEX_SELECTION, handlers.onVertexSelection) + draw.off(ADAPTER_EVENTS.VERTEX_CHANGE, handlers.onVertexChange) + draw.off(ADAPTER_EVENTS.UNDO_CHANGE, handlers.onUndoChange) + draw.off(ADAPTER_EVENTS.UPDATE, handlers.onUpdate) } export function attachEvents ({ pluginState, mapProvider, buttonConfig, eventBus }) { From f1dddd86473654db2cb1bf5d673d6db0a387ba17 Mon Sep 17 00:00:00 2001 From: Dan Leech Date: Mon, 6 Jul 2026 09:05:50 +0100 Subject: [PATCH 34/89] Edit and utils tests --- .../openlayers/core/featureStore.test.js | 48 +++++++ .../adapters/openlayers/core/styles.test.js | 112 +++++++++++++++ .../openlayers/edit/midpointLayer.test.js | 42 ++++++ .../adapters/openlayers/edit/undoOps.test.js | 58 ++++++++ .../openlayers/edit/vertexHitTest.test.js | 28 ++++ .../openlayers/edit/vertexLayer.test.js | 42 ++++++ .../openlayers/edit/vertexOps.test.js | 68 +++++++++ .../openlayers/snap/snapGeometry.test.js | 130 ++++++++++++++++++ .../openlayers/utils/geometryHelpers.test.js | 76 ++++++++++ .../openlayers/utils/olCoords.test.js | 22 +++ .../openlayers/utils/resolveColors.test.js | 28 ++++ .../openlayers/utils/sketchHelpers.test.js | 18 +++ sonar-project.properties | 4 +- 13 files changed, 674 insertions(+), 2 deletions(-) create mode 100644 plugins/beta/draw/src/adapters/openlayers/core/featureStore.test.js create mode 100644 plugins/beta/draw/src/adapters/openlayers/core/styles.test.js create mode 100644 plugins/beta/draw/src/adapters/openlayers/edit/midpointLayer.test.js create mode 100644 plugins/beta/draw/src/adapters/openlayers/edit/undoOps.test.js create mode 100644 plugins/beta/draw/src/adapters/openlayers/edit/vertexHitTest.test.js create mode 100644 plugins/beta/draw/src/adapters/openlayers/edit/vertexLayer.test.js create mode 100644 plugins/beta/draw/src/adapters/openlayers/edit/vertexOps.test.js create mode 100644 plugins/beta/draw/src/adapters/openlayers/snap/snapGeometry.test.js create mode 100644 plugins/beta/draw/src/adapters/openlayers/utils/geometryHelpers.test.js create mode 100644 plugins/beta/draw/src/adapters/openlayers/utils/olCoords.test.js create mode 100644 plugins/beta/draw/src/adapters/openlayers/utils/resolveColors.test.js create mode 100644 plugins/beta/draw/src/adapters/openlayers/utils/sketchHelpers.test.js diff --git a/plugins/beta/draw/src/adapters/openlayers/core/featureStore.test.js b/plugins/beta/draw/src/adapters/openlayers/core/featureStore.test.js new file mode 100644 index 000000000..fd59d3781 --- /dev/null +++ b/plugins/beta/draw/src/adapters/openlayers/core/featureStore.test.js @@ -0,0 +1,48 @@ +import { createFeatureStore } from './featureStore.js' + +const geojson = (id, coords = [[[0, 0], [10, 0], [10, 10], [0, 0]]]) => ({ + type: 'Feature', + id, + properties: { label: 'field' }, + geometry: { type: 'Polygon', coordinates: coords } +}) + +test('add returns the OL feature, retrievable by id in both OL and GeoJSON form', () => { + const store = createFeatureStore() + const olFeature = store.add(geojson('f1')) + expect(store.getOL('f1')).toBe(olFeature) + expect(store.get('f1')).toMatchObject({ id: 'f1', properties: { label: 'field' } }) + expect(store.get('f1').geometry.coordinates[0]).toHaveLength(4) +}) + +test('adding the same id replaces the existing feature instead of duplicating', () => { + const store = createFeatureStore() + store.add(geojson('f1')) + store.add(geojson('f1', [[[0, 0], [99, 0], [99, 99], [0, 0]]])) + expect(store.source.getFeatures()).toHaveLength(1) + expect(store.get('f1').geometry.coordinates[0][1]).toEqual([99, 0]) +}) + +test('unknown ids come back null', () => { + const store = createFeatureStore() + expect(store.getOL('missing')).toBeNull() + expect(store.get('missing')).toBeNull() +}) + +test('remove deletes by id and tolerates unknown ids; clear empties the source', () => { + const store = createFeatureStore() + store.add(geojson('f1')) + store.add(geojson('f2')) + store.remove('f1') + store.remove('missing') // no throw + expect(store.source.getFeatures()).toHaveLength(1) + store.clear() + expect(store.source.getFeatures()).toHaveLength(0) +}) + +test('toGeoJSON / fromGeoJSON round-trip without touching the source', () => { + const store = createFeatureStore() + const olFeature = store.fromGeoJSON(geojson('f9')) + expect(store.source.getFeatures()).toHaveLength(0) + expect(store.toGeoJSON(olFeature)).toMatchObject({ id: 'f9', geometry: { type: 'Polygon' } }) +}) diff --git a/plugins/beta/draw/src/adapters/openlayers/core/styles.test.js b/plugins/beta/draw/src/adapters/openlayers/core/styles.test.js new file mode 100644 index 000000000..3ff6a33b4 --- /dev/null +++ b/plugins/beta/draw/src/adapters/openlayers/core/styles.test.js @@ -0,0 +1,112 @@ +import Point from 'ol/geom/Point.js' +import LineString from 'ol/geom/LineString.js' +import Feature from 'ol/Feature.js' +import { createStyles } from './styles.js' +import { SIZES } from '../defaults.js' +import { polygonFeature, lineFeature } from '../__helpers__/harness.js' + +const colors = { + editStroke: '#e5', + editFill: '#ef', + editVertex: '#ev', + editMidpoint: '#em', + editActive: '#ea', + editHalo: '#eh', + shapeStroke: '#ss', + shapeFill: '#sf', + strokeWidth: 3, + mapStyleId: 'road' +} + +const styles = createStyles(colors) + +// Records each canvas fill as { radius, fillStyle } so ring order/colours can be asserted +const fakeCanvas = () => { + const fills = [] + const ctx = { + fillStyle: null, + save: jest.fn(), + restore: jest.fn(), + beginPath: jest.fn(), + arc: jest.fn((cx, cy, radius) => { ctx.lastArc = { cx, cy, radius } }), + fill: jest.fn(() => fills.push({ ...ctx.lastArc, fillStyle: ctx.fillStyle })) + } + return { ctx, fills } +} + +describe('handle styles', () => { + test('vertex and midpoint handles use the configured radii and colours', () => { + expect(styles.vertexStyle.getImage().getRadius()).toBe(SIZES.vertexRadius) + expect(styles.vertexStyle.getImage().getFill().getColor()).toBe(colors.editVertex) + expect(styles.midpointStyle.getImage().getRadius()).toBe(SIZES.midpointRadius) + expect(styles.midpointStyle.getImage().getFill().getColor()).toBe(colors.editMidpoint) + }) + + test('selected handles render three concentric rings in one canvas pass, scaled by pixelRatio', () => { + const { ctx, fills } = fakeCanvas() + styles.selectedVertexStyle.getRenderer()([10, 20], { context: ctx, pixelRatio: 2 }) + expect(fills).toEqual([ + { cx: 10, cy: 20, radius: (SIZES.vertexHaloRadius + 3) * 2, fillStyle: colors.editActive }, + { cx: 10, cy: 20, radius: SIZES.vertexHaloRadius * 2, fillStyle: colors.editHalo }, + { cx: 10, cy: 20, radius: SIZES.vertexRadius * 2, fillStyle: colors.editVertex } + ]) + + const midpoint = fakeCanvas() + styles.selectedMidpointStyle.getRenderer()([0, 0], { context: midpoint.ctx, pixelRatio: 1 }) + expect(midpoint.fills.map(f => f.radius)).toEqual( + [SIZES.midpointHaloRadius + 3, SIZES.midpointHaloRadius, SIZES.midpointRadius]) + expect(midpoint.fills[2].fillStyle).toBe(colors.editMidpoint) + }) + + test('the edited feature gets the edit stroke and fill', () => { + expect(styles.editFeatureStyle.getStroke().getColor()).toBe(colors.editStroke) + expect(styles.editFeatureStyle.getFill().getColor()).toBe(colors.editFill) + }) +}) + +describe('sketch styles while drawing', () => { + const polygonStyleFn = styles.createSketchStyle('Polygon') + + test('the cursor-follow point renders nothing; the companion line gets stroke only', () => { + expect(polygonStyleFn(new Feature(new Point([0, 0])))).toEqual([]) + expect(polygonStyleFn(new Feature(new LineString([[0, 0], [1, 1]])))).toHaveLength(1) + }) + + test('placed vertices render with the shared vertex image on a reused MultiPoint', () => { + const sketch = polygonFeature([[0, 0], [10, 0], [5, 5], [0, 0]]) // 2 placed + rubber + closing + const [, vertexStyle] = polygonStyleFn(sketch) + expect(vertexStyle.getImage()).toBe(styles.vertexStyle.getImage()) + const geometry = vertexStyle.getGeometry()(sketch) + expect(geometry.getCoordinates()).toEqual([[0, 0], [10, 0]]) + expect(vertexStyle.getGeometry()(sketch)).toBe(geometry) // instance reused per render + + const empty = lineFeature([[9, 9]]) // rubber band only — nothing placed + expect(styles.createSketchStyle('LineString')(empty)[1].getGeometry()(empty)).toBeNull() + }) +}) + +describe('createFeatureStyle (inactive shapes)', () => { + const styleFor = (properties) => styles.createFeatureStyle()({ getProperties: () => properties })[0] + + test('falls back to the configured shape colours', () => { + const style = styleFor({}) + expect(style.getStroke().getColor()).toBe(colors.shapeStroke) + expect(style.getStroke().getWidth()).toBe(colors.strokeWidth) + expect(style.getFill().getColor()).toBe(colors.shapeFill) + }) + + test('per-feature properties override, and map-style-specific properties win overall', () => { + const style = styleFor({ stroke: '#f1', fill: '#f2', strokeWidth: 7, strokeRoad: '#r1', fillRoad: '#r2' }) + expect(style.getStroke().getColor()).toBe('#r1') + expect(style.getFill().getColor()).toBe('#r2') + expect(style.getStroke().getWidth()).toBe(7) + + expect(styleFor({ stroke: '#f1' }).getStroke().getColor()).toBe('#f1') + }) + + test('without a map style id only the generic properties apply', () => { + const plain = createStyles({ ...colors, mapStyleId: null }) + const style = plain.createFeatureStyle()({ getProperties: () => ({ strokeRoad: '#r1' }) })[0] + expect(style.getStroke().getColor()).toBe(colors.shapeStroke) + }) +}) diff --git a/plugins/beta/draw/src/adapters/openlayers/edit/midpointLayer.test.js b/plugins/beta/draw/src/adapters/openlayers/edit/midpointLayer.test.js new file mode 100644 index 000000000..34c60b445 --- /dev/null +++ b/plugins/beta/draw/src/adapters/openlayers/edit/midpointLayer.test.js @@ -0,0 +1,42 @@ +import Style from 'ol/style/Style.js' +import { createMidpointLayer } from './midpointLayer.js' +import { createFakeMap } from '../__helpers__/harness.js' + +const GEOM = { type: 'Polygon', coordinates: [[[0, 0], [10, 0], [10, 10], [0, 0]]] } + +const setup = () => { + const map = createFakeMap() + const style = new Style({}) + const handles = createMidpointLayer(map, style) + const layer = map.layers[0] + return { map, style, handles, layer, source: layer.getSource() } +} + +test('update renders one handle per segment midpoint, retrievable in index order', () => { + const { handles, source } = setup() + handles.update(GEOM) + expect(source.getFeatures()).toHaveLength(3) + expect(handles.getCoords()).toEqual([[5, 0], [10, 5], [5, 5]]) +}) + +test('the selected midpoint is hidden here and styles hot-swap', () => { + const { handles, layer, style, source } = setup() + handles.update(GEOM) + handles.setSelected(0) + const styleFn = layer.getStyle() + const featureAt = (i) => source.getFeatures().find(f => f.get('midpointIndex') === i) + expect(styleFn(featureAt(0))).toBeNull() + expect(styleFn(featureAt(1))).toEqual([style]) + + const newStyle = new Style({}) + handles.updateStyle(newStyle) + expect(styleFn(featureAt(1))).toEqual([newStyle]) +}) + +test('remove clears and detaches the layer', () => { + const { map, handles, source } = setup() + handles.update(GEOM) + handles.remove() + expect(source.getFeatures()).toHaveLength(0) + expect(map.layers).toHaveLength(0) +}) diff --git a/plugins/beta/draw/src/adapters/openlayers/edit/undoOps.test.js b/plugins/beta/draw/src/adapters/openlayers/edit/undoOps.test.js new file mode 100644 index 000000000..22b8c9ba0 --- /dev/null +++ b/plugins/beta/draw/src/adapters/openlayers/edit/undoOps.test.js @@ -0,0 +1,58 @@ +import { applyUndo, undoMoveVertex, undoInsertVertex, undoDeleteVertex } from './undoOps.js' +import { polygonFeature, lineFeature } from '../__helpers__/harness.js' + +const SQUARE = [[0, 0], [100, 0], [100, 100], [0, 100], [0, 0]] +const ring = (feature) => feature.getGeometry().getCoordinates()[0] + +describe('undoMoveVertex', () => { + test('restores the previous coordinate, syncing the closing coord for ring vertex 0', () => { + const feature = polygonFeature(SQUARE) + expect(undoMoveVertex(feature, { vertexIndex: 0, previousCoord: [5, 5] })).toBe(0) + expect(ring(feature)[0]).toEqual([5, 5]) + expect(ring(feature).at(-1)).toEqual([5, 5]) + }) + + test('returns -1 for an index outside the geometry', () => { + expect(undoMoveVertex(polygonFeature(SQUARE), { vertexIndex: 99, previousCoord: [0, 0] })).toBe(-1) + }) +}) + +describe('undoInsertVertex', () => { + test('removes the inserted vertex and keeps the ring closed', () => { + const feature = polygonFeature([[0, 0], [50, 0], [100, 0], [100, 100], [0, 100], [0, 0]]) + expect(undoInsertVertex(feature, { vertexIndex: 1 })).toBe(-1) + expect(ring(feature)).toEqual(SQUARE) + }) + + test('returns -1 for an index outside the geometry', () => { + expect(undoInsertVertex(polygonFeature(SQUARE), { vertexIndex: 99 })).toBe(-1) + }) +}) + +describe('undoDeleteVertex', () => { + test('re-inserts the deleted coordinate and re-selects it', () => { + const feature = polygonFeature([[0, 0], [100, 0], [0, 100], [0, 0]]) // square minus [100,100] + expect(undoDeleteVertex(feature, { vertexIndex: 2, deletedCoord: [100, 100] })).toBe(2) + expect(ring(feature)).toEqual([[0, 0], [100, 0], [100, 100], [0, 100], [0, 0]]) + }) + + test('re-inserts at a segment boundary (the deleted vertex was the last of its part)', () => { + const feature = lineFeature([[0, 0], [10, 0]]) // [20,0] was deleted from the end + expect(undoDeleteVertex(feature, { vertexIndex: 2, deletedCoord: [20, 0] })).toBe(2) + expect(feature.getGeometry().getCoordinates()).toEqual([[0, 0], [10, 0], [20, 0]]) + }) + + test('returns -1 when the index fits no segment at all', () => { + expect(undoDeleteVertex(lineFeature([[0, 0], [10, 0]]), { vertexIndex: 99, deletedCoord: [0, 0] })).toBe(-1) + }) +}) + +describe('applyUndo dispatch', () => { + test('routes each operation type and ignores unknown ones', () => { + const feature = polygonFeature(SQUARE) + expect(applyUndo(feature, { type: 'move_vertex', vertexIndex: 1, previousCoord: [90, 0] })).toBe(1) + expect(applyUndo(feature, { type: 'delete_vertex', vertexIndex: 1, deletedCoord: [95, 0] })).toBe(1) + expect(applyUndo(feature, { type: 'insert_vertex', vertexIndex: 1 })).toBe(-1) + expect(applyUndo(feature, { type: 'resize' })).toBe(-1) + }) +}) diff --git a/plugins/beta/draw/src/adapters/openlayers/edit/vertexHitTest.test.js b/plugins/beta/draw/src/adapters/openlayers/edit/vertexHitTest.test.js new file mode 100644 index 000000000..9585d63f0 --- /dev/null +++ b/plugins/beta/draw/src/adapters/openlayers/edit/vertexHitTest.test.js @@ -0,0 +1,28 @@ +import { findNearest, findNearestVertex, findNearestMidpoint } from './vertexHitTest.js' +import { createFakeMap } from '../__helpers__/harness.js' + +const map = createFakeMap() +const vertices = [[0, 0], [100, 0]] +const midpoints = [[50, 0]] + +test('finds the nearest vertex within the pixel tolerance', () => { + expect(findNearestVertex(map, vertices, { x: 98, y: 5 })).toEqual({ index: 1, type: 'vertex' }) + expect(findNearestVertex(map, vertices, { x: 50, y: 50 })).toBeNull() +}) + +test('midpoint hits are offset by the vertex count', () => { + expect(findNearestMidpoint(map, midpoints, { x: 51, y: 2 }, 2)).toEqual({ index: 2, type: 'midpoint' }) + expect(findNearestMidpoint(map, midpoints, { x: 200, y: 0 }, 2)).toBeNull() +}) + +test('vertices take priority over midpoints; nothing in range gives null', () => { + expect(findNearest(map, [[50, 4]], midpoints, { x: 50, y: 2 })).toEqual({ index: 0, type: 'vertex' }) + expect(findNearest(map, vertices, midpoints, { x: 51, y: 2 })).toEqual({ index: 2, type: 'midpoint' }) + expect(findNearest(map, vertices, midpoints, { x: 500, y: 500 })).toBeNull() +}) + +test('coordinates that cannot be projected are skipped', () => { + const blindMap = { getPixelFromCoordinate: () => null } + expect(findNearestVertex(blindMap, vertices, { x: 0, y: 0 })).toBeNull() + expect(findNearestMidpoint(blindMap, midpoints, { x: 50, y: 0 }, 2)).toBeNull() +}) diff --git a/plugins/beta/draw/src/adapters/openlayers/edit/vertexLayer.test.js b/plugins/beta/draw/src/adapters/openlayers/edit/vertexLayer.test.js new file mode 100644 index 000000000..124610d56 --- /dev/null +++ b/plugins/beta/draw/src/adapters/openlayers/edit/vertexLayer.test.js @@ -0,0 +1,42 @@ +import Style from 'ol/style/Style.js' +import { createVertexLayer } from './vertexLayer.js' +import { createFakeMap } from '../__helpers__/harness.js' + +const GEOM = { type: 'Polygon', coordinates: [[[0, 0], [10, 0], [10, 10], [0, 0]]] } + +const setup = () => { + const map = createFakeMap() + const style = new Style({}) + const handles = createVertexLayer(map, style) + const layer = map.layers[0] + return { map, style, handles, layer, source: layer.getSource() } +} + +test('update renders one handle per editable vertex, indexed', () => { + const { handles, source } = setup() + handles.update(GEOM) + expect(source.getFeatures()).toHaveLength(3) + expect(source.getFeatures().map(f => f.get('vertexIndex')).sort()).toEqual([0, 1, 2]) +}) + +test('the selected vertex is hidden here (the active layer draws it) and styles hot-swap', () => { + const { handles, layer, style, source } = setup() + handles.update(GEOM) + handles.setSelected(1) + const styleFn = layer.getStyle() + const featureAt = (i) => source.getFeatures().find(f => f.get('vertexIndex') === i) + expect(styleFn(featureAt(1))).toBeNull() + expect(styleFn(featureAt(0))).toEqual([style]) + + const newStyle = new Style({}) + handles.updateStyle(newStyle) + expect(styleFn(featureAt(0))).toEqual([newStyle]) +}) + +test('remove clears and detaches the layer', () => { + const { map, handles, source } = setup() + handles.update(GEOM) + handles.remove() + expect(source.getFeatures()).toHaveLength(0) + expect(map.layers).toHaveLength(0) +}) diff --git a/plugins/beta/draw/src/adapters/openlayers/edit/vertexOps.test.js b/plugins/beta/draw/src/adapters/openlayers/edit/vertexOps.test.js new file mode 100644 index 000000000..134dc3ada --- /dev/null +++ b/plugins/beta/draw/src/adapters/openlayers/edit/vertexOps.test.js @@ -0,0 +1,68 @@ +import Feature from 'ol/Feature.js' +import Polygon from 'ol/geom/Polygon.js' +import { deleteVertex, insertAtMidpoint, moveVertex } from './vertexOps.js' +import { polygonFeature, lineFeature } from '../__helpers__/harness.js' + +const SQUARE = [[0, 0], [10, 0], [10, 10], [0, 10], [0, 0]] +const HOLE = [[2, 2], [4, 2], [4, 4], [2, 2]] +const ring = (feature, i = 0) => feature.getGeometry().getCoordinates()[i] + +describe('deleteVertex', () => { + test('removes the vertex and keeps the ring closed', () => { + const feature = polygonFeature(SQUARE) + expect(deleteVertex(feature, 1)).toEqual({ deletedIndex: 1, deletedCoord: [10, 0] }) + expect(ring(feature)).toEqual([[0, 0], [10, 10], [0, 10], [0, 0]]) + }) + + test('deleting ring vertex 0 syncs the closing coordinate', () => { + const feature = polygonFeature(SQUARE) + deleteVertex(feature, 0) + expect(ring(feature)).toEqual([[10, 0], [10, 10], [0, 10], [10, 0]]) + }) + + test('refuses to shrink below the minimum (3 for rings, 2 for lines) or for bad indices', () => { + expect(deleteVertex(polygonFeature([[0, 0], [10, 0], [10, 10], [0, 0]]), 1)).toBeNull() + expect(deleteVertex(lineFeature([[0, 0], [10, 0]]), 0)).toBeNull() + expect(deleteVertex(polygonFeature(SQUARE), 99)).toBeNull() + }) +}) + +describe('insertAtMidpoint', () => { + test('inserts after the midpoint position in the first ring', () => { + const feature = polygonFeature(SQUARE) // 4 vertices; midpoint 0 sits between v0 and v1 + expect(insertAtMidpoint(feature, [[5, 0]], 4, 4)).toEqual({ insertedIndex: 1 }) + expect(ring(feature)[1]).toEqual([5, 0]) + }) + + test('maps midpoints in later rings to their global insert position', () => { + const feature = new Feature(new Polygon([SQUARE, HOLE])) + // 7 vertices; outer ring owns midpoints 0-3, hole owns 4-6. Hole midpoint 1 = between hole v1 and v2. + const midpoints = [[5, 0], [10, 5], [5, 10], [0, 5], [3, 2], [4, 3], [3, 3]] + expect(insertAtMidpoint(feature, midpoints, 7 + 5, 7)).toEqual({ insertedIndex: 6 }) + expect(ring(feature, 1)[2]).toEqual([4, 3]) + }) + + test('unknown midpoints are a no-op', () => { + expect(insertAtMidpoint(polygonFeature(SQUARE), [[5, 0]], 99, 4)).toBeNull() + // A midpoint index past every segment's midpoints (stale midpoint array) is also refused + const extra = [[5, 0], [10, 5], [5, 10], [0, 5], [9, 9]] + expect(insertAtMidpoint(polygonFeature(SQUARE), extra, 4 + 4, 4)).toBeNull() + }) +}) + +describe('moveVertex', () => { + test('moves the vertex, syncing the closing coordinate for ring vertex 0', () => { + const feature = polygonFeature(SQUARE) + moveVertex(feature, 0, [1, 1]) + expect(ring(feature)[0]).toEqual([1, 1]) + expect(ring(feature).at(-1)).toEqual([1, 1]) + moveVertex(feature, 2, [20, 20]) + expect(ring(feature)[2]).toEqual([20, 20]) + }) + + test('an invalid index leaves the geometry untouched', () => { + const feature = polygonFeature(SQUARE) + moveVertex(feature, 99, [1, 1]) + expect(ring(feature)).toEqual(SQUARE) + }) +}) diff --git a/plugins/beta/draw/src/adapters/openlayers/snap/snapGeometry.test.js b/plugins/beta/draw/src/adapters/openlayers/snap/snapGeometry.test.js new file mode 100644 index 000000000..c90261365 --- /dev/null +++ b/plugins/beta/draw/src/adapters/openlayers/snap/snapGeometry.test.js @@ -0,0 +1,130 @@ +import Feature from 'ol/Feature.js' +import Point from 'ol/geom/Point.js' +import LineString from 'ol/geom/LineString.js' +import LinearRing from 'ol/geom/LinearRing.js' +import Polygon from 'ol/geom/Polygon.js' +import MultiLineString from 'ol/geom/MultiLineString.js' +import MultiPolygon from 'ol/geom/MultiPolygon.js' +import MultiPoint from 'ol/geom/MultiPoint.js' +import { bestOf, testOLFeature, testRenderFeature } from './snapGeometry.js' + +const TOL = 100 // 10px tolerance, squared + +describe('bestOf candidate ranking', () => { + const vertex = (distSq) => ({ type: 'vertex', distSq }) + const edge = (distSq) => ({ type: 'edge', distSq }) + + test('a vertex always beats an edge, regardless of distance', () => { + expect(bestOf(edge(1), vertex(99))).toEqual(vertex(99)) + expect(bestOf(vertex(99), edge(1))).toEqual(vertex(99)) + }) + + test('within the same type the closer candidate wins', () => { + expect(bestOf(vertex(50), vertex(10))).toEqual(vertex(10)) + expect(bestOf(edge(10), edge(50))).toEqual(edge(10)) + }) + + test('null candidates never win', () => { + expect(bestOf(null, vertex(50))).toEqual(vertex(50)) + expect(bestOf(vertex(50), null)).toEqual(vertex(50)) + expect(bestOf(null, null)).toBeNull() + }) +}) + +describe('testOLFeature', () => { + test('points snap as vertices within tolerance only', () => { + const feature = new Feature(new Point([100, 100])) + expect(testOLFeature(feature, [103, 104], TOL)).toEqual({ type: 'vertex', coord: [100, 100], distSq: 25 }) + expect(testOLFeature(feature, [200, 200], TOL)).toBeNull() + }) + + test('lines snap to the nearest vertex, or to the closest point on an edge', () => { + const feature = new Feature(new LineString([[0, 0], [100, 0]])) + expect(testOLFeature(feature, [2, 2], TOL)).toMatchObject({ type: 'vertex', coord: [0, 0] }) + expect(testOLFeature(feature, [50, 5], TOL)).toMatchObject({ type: 'edge', coord: [50, 0] }) + }) + + test('a query beyond the segment end clamps to the endpoint', () => { + const feature = new Feature(new LineString([[0, 0], [100, 0]])) + expect(testOLFeature(feature, [106, 0], TOL)).toMatchObject({ coord: [100, 0] }) + }) + + test('polygons snap on the closing edge and skip the duplicated closing vertex', () => { + const feature = new Feature(new Polygon([[[0, 0], [100, 0], [100, 100], [0, 100], [0, 0]]])) + // Closing edge [0,100] → [0,0] + expect(testOLFeature(feature, [5, 50], TOL)).toMatchObject({ type: 'edge', coord: [0, 50] }) + // Exactly on the first/closing vertex — one vertex candidate, not two + expect(testOLFeature(feature, [0, 0], TOL)).toMatchObject({ type: 'vertex', coord: [0, 0], distSq: 0 }) + }) + + test('multi-part geometries test every part and ring', () => { + const multiLine = new Feature(new MultiLineString([[[0, 0], [10, 0]], [[200, 200], [300, 200]]])) + expect(testOLFeature(multiLine, [201, 201], TOL)).toMatchObject({ type: 'vertex', coord: [200, 200] }) + + const ring = new Feature(new LinearRing([[0, 0], [50, 0], [50, 50], [0, 0]])) + expect(testOLFeature(ring, [25, 25], TOL)).toMatchObject({ type: 'edge', coord: [25, 25] }) + + const multiPolygon = new Feature(new MultiPolygon([ + [[[0, 0], [10, 0], [10, 10], [0, 0]]], + [[[200, 200], [300, 200], [300, 300], [200, 200]]] + ])) + expect(testOLFeature(multiPolygon, [202, 201], TOL)).toMatchObject({ type: 'vertex', coord: [200, 200] }) + }) + + test('a zero-length segment degrades to its start point', () => { + const feature = new Feature(new LineString([[10, 10], [10, 10]])) + expect(testOLFeature(feature, [12, 10], TOL)).toMatchObject({ type: 'vertex', coord: [10, 10] }) + }) + + test('unsupported or missing geometries yield no candidate', () => { + expect(testOLFeature(new Feature(new MultiPoint([[0, 0]])), [0, 0], TOL)).toBeNull() + expect(testOLFeature(new Feature(), [0, 0], TOL)).toBeNull() + }) +}) + +describe('testRenderFeature (vector-tile render features)', () => { + const renderFeature = (type, flat, ends = null) => ({ + getType: () => type, + getFlatCoordinates: () => flat, + getEnds: () => ends + }) + + test('points return a single vertex candidate within tolerance', () => { + expect(testRenderFeature(renderFeature('Point', [10, 10]), [12, 10], TOL)) + .toEqual([{ type: 'vertex', coord: [10, 10], distSq: 4 }]) + expect(testRenderFeature(renderFeature('Point', [10, 10]), [100, 100], TOL)).toEqual([]) + }) + + test('lines return best vertex AND best edge, with adjacency/segment metadata', () => { + const line = renderFeature('LineString', [0, 0, 100, 0, 100, 100]) + const [vertex, edge] = testRenderFeature(line, [98, 4], TOL) + expect(vertex).toMatchObject({ type: 'vertex', coord: [100, 0], adjacent: [[0, 0], [100, 100]] }) + expect(edge).toMatchObject({ type: 'edge', seg: [[100, 0], [100, 100]] }) + }) + + test('open line ends have null adjacency on the open side', () => { + const line = renderFeature('LineString', [0, 0, 100, 0]) + const [vertex] = testRenderFeature(line, [1, 1], TOL) + expect(vertex).toMatchObject({ coord: [0, 0], adjacent: [null, [100, 0]] }) + }) + + test('polygon rings (no duplicated closing coord in tiles) wrap edges and adjacency', () => { + const square = renderFeature('Polygon', [0, 0, 100, 0, 100, 100, 0, 100], [8]) + const candidates = testRenderFeature(square, [2, 52], TOL) + expect(candidates).toEqual([expect.objectContaining({ type: 'edge', coord: [0, 52], seg: [[0, 100], [0, 0]] })]) + + const [vertex] = testRenderFeature(square, [1, 1], TOL) + expect(vertex).toMatchObject({ coord: [0, 0], adjacent: [[0, 100], [100, 0]] }) // wraps to last vertex + }) + + test('multi-line render features honour their part boundaries', () => { + const twoParts = renderFeature('MultiLineString', [0, 0, 10, 0, 200, 200, 300, 200], [4, 8]) + // No phantom edge between [10,0] and [200,200] — a query midway finds nothing + expect(testRenderFeature(twoParts, [105, 100], TOL)).toEqual([]) + expect(testRenderFeature(twoParts, [201, 200], TOL)[0]).toMatchObject({ coord: [200, 200] }) + }) + + test('unsupported render feature types return no candidates', () => { + expect(testRenderFeature(renderFeature('MultiPoint', [0, 0]), [0, 0], TOL)).toEqual([]) + }) +}) diff --git a/plugins/beta/draw/src/adapters/openlayers/utils/geometryHelpers.test.js b/plugins/beta/draw/src/adapters/openlayers/utils/geometryHelpers.test.js new file mode 100644 index 000000000..caccbe7cb --- /dev/null +++ b/plugins/beta/draw/src/adapters/openlayers/utils/geometryHelpers.test.js @@ -0,0 +1,76 @@ +import { getCoords, getRingSegments, getSegmentForIndex, getModifiableCoords, getMidpoints } from './geometryHelpers.js' + +const SQUARE = [[0, 0], [10, 0], [10, 10], [0, 10], [0, 0]] +const HOLE = [[2, 2], [4, 2], [4, 4], [2, 2]] +const LINE = [[0, 0], [10, 0], [20, 0]] + +describe('getCoords flattens editable vertices (closing coords stripped)', () => { + test.each([ + ['LineString', { type: 'LineString', coordinates: LINE }, 3], + ['Polygon with hole', { type: 'Polygon', coordinates: [SQUARE, HOLE] }, 7], + ['MultiLineString', { type: 'MultiLineString', coordinates: [LINE, LINE] }, 6], + ['MultiPolygon', { type: 'MultiPolygon', coordinates: [[SQUARE], [SQUARE, HOLE]] }, 11] + ])('%s', (_, geom, expected) => { + expect(getCoords(geom)).toHaveLength(expected) + }) + + test('missing or unsupported geometry yields an empty array', () => { + expect(getCoords(null)).toEqual([]) + expect(getCoords({ type: 'Point' })).toEqual([]) + expect(getCoords({ type: 'Point', coordinates: [0, 0] })).toEqual([]) + }) +}) + +describe('getRingSegments maps flat indices back to parts', () => { + test('describes each ring/part with its offset, length, path and closure', () => { + expect(getRingSegments({ type: 'LineString', coordinates: LINE })) + .toEqual([{ start: 0, length: 3, path: [], closed: false }]) + expect(getRingSegments({ type: 'Polygon', coordinates: [SQUARE, HOLE] })) + .toEqual([ + { start: 0, length: 4, path: [0], closed: true }, + { start: 4, length: 3, path: [1], closed: true } + ]) + expect(getRingSegments({ type: 'MultiLineString', coordinates: [LINE, LINE] })[1]) + .toEqual({ start: 3, length: 3, path: [1], closed: false }) + expect(getRingSegments({ type: 'MultiPolygon', coordinates: [[SQUARE], [HOLE]] })[1]) + .toEqual({ start: 4, length: 3, path: [1, 0], closed: true }) + }) + + test('missing or unsupported geometry yields no segments', () => { + expect(getRingSegments(null)).toEqual([]) + expect(getRingSegments({ type: 'Point', coordinates: [0, 0] })).toEqual([]) + }) +}) + +describe('getSegmentForIndex', () => { + const segments = getRingSegments({ type: 'Polygon', coordinates: [SQUARE, HOLE] }) + + test('finds the owning segment and the local index within it', () => { + expect(getSegmentForIndex(segments, 5)).toEqual({ segment: segments[1], localIdx: 1 }) + }) + + test('returns null beyond the last segment', () => { + expect(getSegmentForIndex(segments, 7)).toBeNull() + }) +}) + +test('getModifiableCoords returns a live reference at a hierarchical path', () => { + const geom = { type: 'MultiPolygon', coordinates: [[SQUARE], [HOLE]] } + const ring = getModifiableCoords(geom, [1, 0]) + ring[0] = [3, 3] + expect(geom.coordinates[1][0][0]).toEqual([3, 3]) + expect(getModifiableCoords(geom, [])).toBe(geom.coordinates) +}) + +describe('getMidpoints', () => { + test('closed rings wrap (one midpoint per vertex); open lines do not', () => { + expect(getMidpoints({ type: 'Polygon', coordinates: [SQUARE] })) + .toEqual([[5, 0], [10, 5], [5, 10], [0, 5]]) + expect(getMidpoints({ type: 'LineString', coordinates: LINE })) + .toEqual([[5, 0], [15, 0]]) + }) + + test('empty geometry yields no midpoints', () => { + expect(getMidpoints({ type: 'LineString', coordinates: [] })).toEqual([]) + }) +}) diff --git a/plugins/beta/draw/src/adapters/openlayers/utils/olCoords.test.js b/plugins/beta/draw/src/adapters/openlayers/utils/olCoords.test.js new file mode 100644 index 000000000..ee7576387 --- /dev/null +++ b/plugins/beta/draw/src/adapters/openlayers/utils/olCoords.test.js @@ -0,0 +1,22 @@ +import { coordToPixel, pixelToCoord, pixelDist, arrayToPixel, pixelToArray, nudgeCoord } from './olCoords.js' +import { createFakeMap } from '../__helpers__/harness.js' + +const map = createFakeMap() + +test('converts between OL arrays and {x, y} pixel objects', () => { + expect(coordToPixel(map, [10, 20])).toEqual({ x: 10, y: 20 }) + expect(pixelToCoord(map, { x: 10, y: 20 })).toEqual([10, 20]) + expect(arrayToPixel([1, 2])).toEqual({ x: 1, y: 2 }) + expect(pixelToArray({ x: 1, y: 2 })).toEqual([1, 2]) + expect(pixelDist({ x: 0, y: 0 }, { x: 3, y: 4 })).toBe(5) +}) + +test('nudgeCoord offsets a coordinate by screen pixels', () => { + expect(nudgeCoord(map, [10, 20], 5, -3)).toEqual([15, 17]) +}) + +test('unprojectable coordinates return null / stay put', () => { + const blindMap = { getPixelFromCoordinate: () => null } + expect(coordToPixel(blindMap, [1, 1])).toBeNull() + expect(nudgeCoord(blindMap, [1, 1], 5, 5)).toEqual([1, 1]) +}) diff --git a/plugins/beta/draw/src/adapters/openlayers/utils/resolveColors.test.js b/plugins/beta/draw/src/adapters/openlayers/utils/resolveColors.test.js new file mode 100644 index 000000000..6e6c00f54 --- /dev/null +++ b/plugins/beta/draw/src/adapters/openlayers/utils/resolveColors.test.js @@ -0,0 +1,28 @@ +import { resolveColors } from './resolveColors.js' +import { COLORS, SIZES } from '../defaults.js' + +test('without a map style, colours resolve to their light variants and defaults', () => { + const colors = resolveColors(null) + expect(colors.editStroke).toBe(COLORS.editStroke.light) + expect(colors.shapeStroke).toBe(COLORS.shapeStroke) // plain value — no variants + expect(colors.strokeWidth).toBe(SIZES.strokeWidth) + expect(colors.mapStyleId).toBeNull() +}) + +test('a dark map style resolves dark variants and carries its id through', () => { + const colors = resolveColors({ id: 'dark', mapColorScheme: 'dark' }) + expect(colors.editStroke).toBe(COLORS.editStroke.dark) + expect(colors.mapStyleId).toBe('dark') +}) + +test('plugin config overrides beat the defaults, as strings or scheme variants', () => { + const colors = resolveColors({ id: 'road', mapColorScheme: 'dark' }, { + editStroke: '#custom', + editFill: { light: '#l', dark: '#d' }, + strokeWidth: 9 + }) + expect(colors.editStroke).toBe('#custom') + expect(colors.editFill).toBe('#d') + expect(colors.strokeWidth).toBe(9) + expect(colors.editVertex).toBe(COLORS.editVertex.dark) // untouched keys still resolve +}) diff --git a/plugins/beta/draw/src/adapters/openlayers/utils/sketchHelpers.test.js b/plugins/beta/draw/src/adapters/openlayers/utils/sketchHelpers.test.js new file mode 100644 index 000000000..7124e33a3 --- /dev/null +++ b/plugins/beta/draw/src/adapters/openlayers/utils/sketchHelpers.test.js @@ -0,0 +1,18 @@ +import Polygon from 'ol/geom/Polygon.js' +import LineString from 'ol/geom/LineString.js' +import { getPlacedSketchCoords, getLastPlacedSketchCoord } from './sketchHelpers.js' + +test('strips the rubber band (and the closing coord for polygons) to expose placed vertices', () => { + const poly = new Polygon([[[0, 0], [10, 0], [5, 5], [0, 0]]]) // 2 placed + rubber + closing + expect(getPlacedSketchCoords(poly)).toEqual([[0, 0], [10, 0]]) + expect(getLastPlacedSketchCoord(poly)).toEqual([10, 0]) + + const line = new LineString([[0, 0], [10, 0], [5, 5]]) // 2 placed + rubber + expect(getPlacedSketchCoords(line)).toEqual([[0, 0], [10, 0]]) + expect(getLastPlacedSketchCoord(line)).toEqual([10, 0]) +}) + +test('empty or rubber-band-only sketches have no placed vertices', () => { + expect(getPlacedSketchCoords(new Polygon([]))).toEqual([]) + expect(getLastPlacedSketchCoord(new LineString([]))).toBeNull() +}) diff --git a/sonar-project.properties b/sonar-project.properties index fe09fd311..f122bd864 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -11,10 +11,10 @@ sonar.javascript.lcov.reportPaths=coverage/lcov.info sonar.sourceEncoding=UTF-8 sonar.sources=src,plugins,providers -sonar.exclusions=**/*.test.*,**/__mocks__/**,**/__stubs__/**,**/dist/**,**/node_modules/**,plugins/beta/**,providers/beta/** +sonar.exclusions=**/*.test.*,**/__mocks__/**,**/__stubs__/**,**/__helpers__/**,**/dist/**,**/node_modules/**,plugins/beta/**,providers/beta/** sonar.tests=src,plugins,providers sonar.test.inclusions=**/*.test.*,**/__mocks__/**,**/__stubs__/** -sonar.cpd.exclusions=**/*.test.*,**/__mocks__/**,**/__stubs__/** +sonar.cpd.exclusions=**/*.test.*,**/__mocks__/**,**/__stubs__/**,**/__helpers__/** # Ignored rules sonar.issue.ignore.multicriteria=reactPropsJs,reactPropsJsx,preferGlobalThisJs,preferGlobalThisJsx,preferAtJs,preferAtJsx,replaceAllJs,replaceAllJsx,stringReplaceAllJs,stringReplaceAllJsx,pascalCaseFunctionsJs,pascalCaseFunctionsJsx,pascalCaseFunctionsTs,pascalCaseFunctionsTsx From 4e2be409b75907101f224e3babbfebf1a9040117 Mon Sep 17 00:00:00 2001 From: Dan Leech Date: Mon, 6 Jul 2026 10:22:17 +0100 Subject: [PATCH 35/89] OpenLayers snap geometry, core and orchestration tests added --- .../adapters/openlayers/OLDrawAdapter.test.js | 111 +++++++++++++ .../openlayers/__helpers__/harness.js | 3 +- .../openlayers/core/OLDrawManager.test.js | 131 ++++++++++++++++ .../src/adapters/openlayers/draw/DrawMode.js | 5 +- .../adapters/openlayers/draw/DrawMode.test.js | 148 ++++++++++++++++++ .../src/adapters/openlayers/olDraw.test.js | 59 +++++++ 6 files changed, 455 insertions(+), 2 deletions(-) create mode 100644 plugins/beta/draw/src/adapters/openlayers/OLDrawAdapter.test.js create mode 100644 plugins/beta/draw/src/adapters/openlayers/core/OLDrawManager.test.js create mode 100644 plugins/beta/draw/src/adapters/openlayers/draw/DrawMode.test.js create mode 100644 plugins/beta/draw/src/adapters/openlayers/olDraw.test.js diff --git a/plugins/beta/draw/src/adapters/openlayers/OLDrawAdapter.test.js b/plugins/beta/draw/src/adapters/openlayers/OLDrawAdapter.test.js new file mode 100644 index 000000000..9ec8b3410 --- /dev/null +++ b/plugins/beta/draw/src/adapters/openlayers/OLDrawAdapter.test.js @@ -0,0 +1,111 @@ +import { OLDrawAdapter } from './OLDrawAdapter.js' +import { createOLDraw } from './olDraw.js' + +const fakeManager = () => ({ + changeMode: jest.fn(), + getMode: jest.fn(() => 'disabled'), + setInterfaceType: jest.fn(), + done: jest.fn(), + cancel: jest.fn(), + undo: jest.fn(), + deleteVertex: jest.fn(), + get: jest.fn(() => 'feature'), + add: jest.fn(), + delete: jest.fn(), + deleteAll: jest.fn(), + on: jest.fn(), + off: jest.fn(), + undoStack: { clear: jest.fn() }, + snap: { setActive: jest.fn(), setSnapLayers: jest.fn() } +}) + +jest.mock('./olDraw.js', () => ({ + createOLDraw: jest.fn(({ mapProvider }) => { + mapProvider.draw = mapProvider._testManager + return { remove: jest.fn() } + }) +})) + +const setup = () => { + const manager = fakeManager() + const mapProvider = { _testManager: manager } + const adapter = new OLDrawAdapter(mapProvider, { + events: { MAP_SET_STYLE: 's' }, + eventBus: {}, + snapLayers: ['boundaries'], + mapStyle: { id: 'default' } + }) + return { manager, mapProvider, adapter } +} + +afterEach(() => jest.clearAllMocks()) + +test('wires olDraw with the plugin options and keeps the manager before DrawInit overwrites it', () => { + const { manager, adapter } = setup() + expect(createOLDraw).toHaveBeenCalledWith(expect.objectContaining({ + pluginConfig: { snapLayers: ['boundaries'] }, + mapStyle: { id: 'default' } + })) + expect(adapter.getMode()).toBe('disabled') + expect(manager.getMode).toHaveBeenCalled() +}) + +test('changeMode injects the mapProvider and the OL geometry type per draw mode', () => { + const { manager, mapProvider, adapter } = setup() + adapter.changeMode('draw_polygon', { featureId: 'f1' }) + expect(manager.changeMode).toHaveBeenCalledWith('draw_polygon', { featureId: 'f1', mapProvider, geometryType: 'Polygon' }) + adapter.changeMode('draw_line') + expect(manager.changeMode).toHaveBeenCalledWith('draw_line', { mapProvider, geometryType: 'LineString' }) + adapter.changeMode('edit_vertex', { featureId: 'f1' }) + expect(manager.changeMode).toHaveBeenCalledWith('edit_vertex', { featureId: 'f1', mapProvider }) +}) + +test('done and cancel clear the undo stack before delegating', () => { + const { manager, adapter } = setup() + adapter.done() + adapter.cancel() + expect(manager.undoStack.clear).toHaveBeenCalledTimes(2) + expect(manager.done).toHaveBeenCalled() + expect(manager.cancel).toHaveBeenCalled() +}) + +test('snap state is tracked locally and forwarded, tolerating a missing snap manager', () => { + const { manager, adapter } = setup() + expect(adapter.isSnapEnabled()).toBe(false) + adapter.setSnapEnabled(true) + expect(adapter.isSnapEnabled()).toBe(true) + expect(manager.snap.setActive).toHaveBeenCalledWith(true) + adapter.setSnapLayers(['x']) + expect(manager.snap.setSnapLayers).toHaveBeenCalledWith(['x']) + + manager.snap = null + adapter.setSnapEnabled(false) + adapter.setSnapLayers(['y']) // no throw + expect(adapter.isSnapEnabled()).toBe(false) +}) + +test('remaining calls delegate straight through; setFeatureProperty is a deliberate no-op', () => { + const { manager, adapter } = setup() + adapter.setInterfaceType('touch') + expect(adapter.get('f1')).toBe('feature') + adapter.add({ id: 'f2' }) + adapter.delete('f1') + adapter.deleteAll() + adapter.undo() + adapter.deleteVertex() + const handler = () => {} + adapter.on('create', handler) + adapter.off('create', handler) + expect(adapter.setFeatureProperty('f1', 'stroke', '#000')).toBeUndefined() + expect(manager.setInterfaceType).toHaveBeenCalledWith('touch') + expect(manager.on).toHaveBeenCalledWith('create', handler) + expect(manager.off).toHaveBeenCalledWith('create', handler) + expect(manager.undo).toHaveBeenCalled() + expect(manager.deleteVertex).toHaveBeenCalled() +}) + +test('remove runs the olDraw cleanup', () => { + const { adapter } = setup() + adapter.remove() + expect(createOLDraw.mock.results.at(-1).value.remove).toHaveBeenCalled() +}) diff --git a/plugins/beta/draw/src/adapters/openlayers/__helpers__/harness.js b/plugins/beta/draw/src/adapters/openlayers/__helpers__/harness.js index 966a8372a..1c32bacd0 100644 --- a/plugins/beta/draw/src/adapters/openlayers/__helpers__/harness.js +++ b/plugins/beta/draw/src/adapters/openlayers/__helpers__/harness.js @@ -58,7 +58,8 @@ export const createFakeManager = () => { vertexStyle: new Style({}), midpointStyle: new Style({}), selectedVertexStyle: new Style({}), - selectedMidpointStyle: new Style({}) + selectedMidpointStyle: new Style({}), + createSketchStyle: jest.fn(() => () => []) }, colors: { editVertex: 'rgba(29,112,184,1)' }, undoStack: createUndoStack(() => {}) diff --git a/plugins/beta/draw/src/adapters/openlayers/core/OLDrawManager.test.js b/plugins/beta/draw/src/adapters/openlayers/core/OLDrawManager.test.js new file mode 100644 index 000000000..82e3166e3 --- /dev/null +++ b/plugins/beta/draw/src/adapters/openlayers/core/OLDrawManager.test.js @@ -0,0 +1,131 @@ +import { OLDrawManager } from './OLDrawManager.js' +import { STYLES_CHANGED_EVENT } from './internalEvents.js' +import { createDrawMode } from '../draw/DrawMode.js' +import { createEditMode } from '../edit/EditMode.js' +import { createSnapManager } from '../snap/snapManager.js' +import { ADAPTER_EVENTS } from '../../../adapterEvents.js' +import { createFakeMap } from '../__helpers__/harness.js' +import { TOLERANCES } from '../defaults.js' + +jest.mock('../draw/DrawMode.js', () => ({ + createDrawMode: jest.fn(() => ({ destroy: jest.fn(), done: jest.fn(), cancel: jest.fn(), undo: jest.fn(), deleteVertex: jest.fn(), setInterfaceType: jest.fn() })) +})) +jest.mock('../edit/EditMode.js', () => ({ + createEditMode: jest.fn(() => ({ destroy: jest.fn(), done: jest.fn(), cancel: jest.fn(), undo: jest.fn(), deleteVertex: jest.fn(), setInterfaceType: jest.fn() })) +})) +jest.mock('../snap/snapManager.js', () => ({ + createSnapManager: jest.fn(() => ({ setIndicatorActive: jest.fn(), reattach: jest.fn(), updateColors: jest.fn(), destroy: jest.fn() })) +})) + +const geojson = { type: 'Feature', id: 'f1', properties: {}, geometry: { type: 'Polygon', coordinates: [[[0, 0], [10, 0], [10, 10], [0, 0]]] } } + +const setup = (pluginConfig) => { + const map = createFakeMap() + const manager = new OLDrawManager(map, pluginConfig) + return { map, manager } +} + +afterEach(() => jest.clearAllMocks()) + +test('sets up the draw layer and the snap manager from the plugin config', () => { + const { map } = setup({ snapLayers: ['boundaries'], snapRadius: 20 }) + expect(map.layers[0].get('layerId')).toBe('draw') + expect(createSnapManager).toHaveBeenCalledWith(map, ['boundaries'], expect.any(Object), 20) + + setup() // defaults + expect(createSnapManager).toHaveBeenLastCalledWith(expect.anything(), null, expect.any(Object), TOLERANCES.snapRadius) +}) + +test('map style changes rebuild the styles and notify modes and snap', () => { + const { map, manager } = setup() + const onStyles = jest.fn() + manager.on(STYLES_CHANGED_EVENT, onStyles) + const before = manager.styles + manager.setMapStyle({ id: 'dark', mapColorScheme: 'dark' }) + expect(manager.styles).not.toBe(before) + expect(map.layers[0].getStyle()).toEqual(expect.any(Function)) + expect(manager.snap.updateColors).toHaveBeenCalledWith(manager.colors) + expect(onStyles).toHaveBeenCalledWith(manager.styles) +}) + +describe('mode machine', () => { + test.each([ + ['draw_polygon', createDrawMode], + ['draw_line', createDrawMode], + ['edit_vertex', createEditMode] + ])('%s creates its mode with snap injected, activates the indicator and reattaches snap last', async (name, factory) => { + const { map, manager } = setup() + await manager.changeMode(name, { featureId: 'f1' }) + expect(manager.getMode()).toBe(name) + expect(factory).toHaveBeenCalledWith({ map, manager, options: { featureId: 'f1', snap: manager.snap } }) + expect(manager.snap.setIndicatorActive).toHaveBeenCalledWith(true) + expect(manager.snap.reattach).toHaveBeenCalled() + }) + + test('disabled destroys the previous mode and deactivates the indicator', async () => { + const { manager } = setup() + await manager.changeMode('draw_polygon') + const instance = createDrawMode.mock.results[0].value + await manager.changeMode('disabled') + expect(instance.destroy).toHaveBeenCalled() + expect(manager.getMode()).toBe('disabled') + expect(manager.snap.setIndicatorActive).toHaveBeenLastCalledWith(false) + }) + + test('operations delegate to the current mode instance and are safe without one', async () => { + const { manager } = setup() + manager.done(); manager.undo(); manager.deleteVertex(); manager.setInterfaceType('touch') // no mode — no throw + + await manager.changeMode('draw_polygon') + const instance = createDrawMode.mock.results[0].value + manager.done() + manager.undo() + manager.deleteVertex() + manager.setInterfaceType('touch') + expect(instance.done).toHaveBeenCalled() + expect(instance.undo).toHaveBeenCalled() + expect(instance.deleteVertex).toHaveBeenCalled() + expect(instance.setInterfaceType).toHaveBeenCalledWith('touch') + + manager.cancel() + expect(instance.cancel).toHaveBeenCalled() + expect(manager.getMode()).toBe('disabled') + }) +}) + +test('undo stack changes are published on the adapter bus; off unsubscribes', () => { + const { manager } = setup() + const onUndoChange = jest.fn() + manager.on(ADAPTER_EVENTS.UNDO_CHANGE, onUndoChange) + manager.undoStack.push({ type: 'draw_vertex' }) + expect(onUndoChange).toHaveBeenCalledWith(1) + manager.off(ADAPTER_EVENTS.UNDO_CHANGE, onUndoChange) + manager.undoStack.push({ type: 'draw_vertex' }) + expect(onUndoChange).toHaveBeenCalledTimes(1) +}) + +test('feature store delegation works with GeoJSON in and out', () => { + const { manager } = setup() + manager.add(geojson) + expect(manager.get('f1')).toMatchObject({ id: 'f1' }) + manager.delete('f1') + expect(manager.get('f1')).toBeNull() + manager.add(geojson) + manager.deleteAll() + expect(manager.get('f1')).toBeNull() +}) + +test('remove tears everything down and silences the bus', async () => { + const { map, manager } = setup() + await manager.changeMode('draw_polygon') + const instance = createDrawMode.mock.results[0].value + const snap = manager.snap + const listener = jest.fn() + manager.on(STYLES_CHANGED_EVENT, listener) + manager.remove() + expect(instance.destroy).toHaveBeenCalled() + expect(snap.destroy).toHaveBeenCalled() + expect(map.removeLayer).toHaveBeenCalled() + manager.emit(STYLES_CHANGED_EVENT, {}) + expect(listener).not.toHaveBeenCalled() +}) diff --git a/plugins/beta/draw/src/adapters/openlayers/draw/DrawMode.js b/plugins/beta/draw/src/adapters/openlayers/draw/DrawMode.js index 95125d0f0..80a71f267 100644 --- a/plugins/beta/draw/src/adapters/openlayers/draw/DrawMode.js +++ b/plugins/beta/draw/src/adapters/openlayers/draw/DrawMode.js @@ -14,7 +14,10 @@ const canFinish = (geometryType, sketchFeature) => { const DUPLICATE_TOLERANCE_PX = 2 -const buildCondition = (map, geometryType, getSketchFeature) => (e) => { +// Blocks clicks with modifier keys, and duplicate clicks on the last placed +// vertex while the shape is not yet finishable (once finishable, clicking the +// last vertex is OL's finish gesture and must go through) +export const buildCondition = (map, geometryType, getSketchFeature) => (e) => { if (!noModifierKeys(e)) { return false } const sf = getSketchFeature() if (!sf || canFinish(geometryType, sf)) { return true } diff --git a/plugins/beta/draw/src/adapters/openlayers/draw/DrawMode.test.js b/plugins/beta/draw/src/adapters/openlayers/draw/DrawMode.test.js new file mode 100644 index 000000000..934c7476b --- /dev/null +++ b/plugins/beta/draw/src/adapters/openlayers/draw/DrawMode.test.js @@ -0,0 +1,148 @@ +import { createDrawMode, buildCondition } from './DrawMode.js' +import { createDrawInput } from './drawInput.js' +import { createFeatureStore } from '../core/featureStore.js' +import { ADAPTER_EVENTS } from '../../../adapterEvents.js' +import { STYLES_CHANGED_EVENT } from '../core/internalEvents.js' +import { createFakeMap, createFakeManager, polygonFeature, lineFeature } from '../__helpers__/harness.js' + +jest.mock('./drawInput.js', () => ({ + createDrawInput: jest.fn(() => ({ + getInterfaceType: jest.fn(() => 'keyboard'), + destroy: jest.fn() + })) +})) + +const setup = (geometryType = 'Polygon') => { + const map = createFakeMap() + const manager = createFakeManager() + manager.store = createFeatureStore() + const emitted = () => manager.emit.mock.calls.map(([type, payload]) => ({ type, payload })) + const mode = createDrawMode({ + map, + manager, + options: { geometryType, featureId: 'shape-1', properties: { label: 'field' }, container: null, snap: null } + }) + const interaction = map.interactions[0] + const input = createDrawInput.mock.results.at(-1).value + const inputOptions = createDrawInput.mock.calls.at(-1)[0].options + return { map, manager, emitted, mode, interaction, input, inputOptions } +} + +afterEach(() => jest.clearAllMocks()) + +describe('buildCondition (duplicate-click suppression)', () => { + const map = createFakeMap() + const click = (x, y, originalEvent = {}) => ({ pixel: [x, y], originalEvent }) + const conditionFor = (sketch) => buildCondition(map, 'LineString', () => sketch) + + test('modifier-key clicks never draw', () => { + expect(conditionFor(null)(click(0, 0, { shiftKey: true }))).toBe(false) + }) + + test('clicks always draw before the sketch exists or once the shape is finishable', () => { + expect(conditionFor(null)(click(0, 0))).toBe(true) + const finishable = lineFeature([[0, 0], [10, 0], [50, 50]]) // 2 placed ≥ line minimum + expect(conditionFor(finishable)(click(10, 1))).toBe(true) + }) + + test('while unfinishable, clicks on the last placed vertex are suppressed', () => { + const oneVertex = lineFeature([[10, 10], [50, 50]]) // 1 placed + rubber + expect(conditionFor(oneVertex)(click(11, 11))).toBe(false) + expect(conditionFor(oneVertex)(click(20, 20))).toBe(true) + }) + + test('degenerate sketches or unprojectable vertices do not block drawing', () => { + expect(conditionFor(lineFeature([[10, 10]]))(click(10, 10))).toBe(true) // nothing placed yet + const blindMap = { getPixelFromCoordinate: () => null } + const condition = buildCondition(blindMap, 'LineString', () => lineFeature([[10, 10], [50, 50]])) + expect(condition(click(10, 10))).toBe(true) + }) +}) + +describe('drawing lifecycle', () => { + test('the sketch reports its placed vertex count on every geometry change', () => { + const { emitted, interaction } = setup() + const sketch = polygonFeature([[0, 0], [5, 5], [0, 0]]) + interaction.dispatchEvent({ type: 'drawstart', feature: sketch }) + sketch.getGeometry().setCoordinates([[[0, 0], [10, 0], [5, 5], [0, 0]]]) + expect(emitted().at(-1)).toEqual({ type: ADAPTER_EVENTS.VERTEX_CHANGE, payload: { numVertices: 2 } }) + }) + + test('finishing hands the feature over with the requested id and properties', () => { + const { manager, emitted, interaction } = setup() + interaction.dispatchEvent({ type: 'drawend', feature: polygonFeature([[0, 0], [10, 0], [10, 10], [0, 0]], null) }) + const created = emitted().find((e) => e.type === ADAPTER_EVENTS.CREATE) + expect(created.payload).toMatchObject({ id: 'shape-1', properties: { label: 'field' } }) + expect(manager.store.getOL('shape-1')).not.toBeNull() + }) + + test('aborting cancels', () => { + const { emitted, interaction } = setup() + interaction.dispatchEvent({ type: 'drawabort' }) + expect(emitted()).toContainEqual({ type: ADAPTER_EVENTS.CANCEL, payload: undefined }) + }) + + test('the interaction consults the duplicate-suppression condition against the live sketch', () => { + const { interaction } = setup('LineString') + expect(interaction.condition_({ pixel: [11, 11], originalEvent: {} })).toBe(true) // no sketch yet + interaction.dispatchEvent({ type: 'drawstart', feature: lineFeature([[11, 11], [50, 50]]) }) + expect(interaction.condition_({ pixel: [11, 11], originalEvent: {} })).toBe(false) // on last placed + }) + + test('done() finishes only when the shape has enough vertices', () => { + const { mode, interaction } = setup() + const finish = jest.spyOn(interaction, 'finishDrawing').mockImplementation(() => {}) + mode.done() // no sketch at all + interaction.dispatchEvent({ type: 'drawstart', feature: polygonFeature([[0, 0], [10, 0], [5, 5], [0, 0]]) }) + mode.done() // 2 placed < polygon minimum of 3 + expect(finish).not.toHaveBeenCalled() + + interaction.dispatchEvent({ type: 'drawstart', feature: polygonFeature([[0, 0], [10, 0], [10, 10], [5, 5], [0, 0]]) }) + mode.done() + expect(finish).toHaveBeenCalled() + }) + + test('cancel() aborts the sketch; undo() removes the last point and re-reports the count', () => { + const { mode, emitted, interaction } = setup() + const abort = jest.spyOn(interaction, 'abortDrawing').mockImplementation(() => {}) + const removeLast = jest.spyOn(interaction, 'removeLastPoint').mockImplementation(() => {}) + mode.cancel() + expect(abort).toHaveBeenCalled() + + interaction.dispatchEvent({ type: 'drawstart', feature: polygonFeature([[0, 0], [10, 0], [5, 5], [0, 0]]) }) + mode.undo() + expect(removeLast).toHaveBeenCalled() + expect(emitted().at(-1)).toEqual({ type: ADAPTER_EVENTS.VERTEX_CHANGE, payload: { numVertices: 2 } }) + }) +}) + +describe('wiring', () => { + test('the drawInput gets working undo and canFinish callbacks', () => { + const { interaction, inputOptions } = setup('LineString') + const removeLast = jest.spyOn(interaction, 'removeLastPoint').mockImplementation(() => {}) + expect(inputOptions.canFinish()).toBe(false) // no sketch yet + interaction.dispatchEvent({ type: 'drawstart', feature: lineFeature([[0, 0], [10, 0], [5, 5]]) }) + expect(inputOptions.canFinish()).toBe(true) + inputOptions.onUndo() + expect(removeLast).toHaveBeenCalled() + }) + + test('map style changes rebuild the sketch style for the current geometry type', () => { + const { manager, interaction } = setup() + expect(manager.styles.createSketchStyle).toHaveBeenCalledWith('Polygon') + manager.emit(STYLES_CHANGED_EVENT, manager.styles) + expect(manager.styles.createSketchStyle).toHaveBeenCalledTimes(2) + expect(interaction.getOverlay().getStyleFunction()({ getGeometry: () => ({ getType: () => 'Point' }) })).toEqual([]) + }) + + test('destroy reports the final interface type, tears down input and interaction', () => { + const { map, mode, emitted, input, interaction } = setup() + mode.destroy() + expect(emitted()).toContainEqual({ + type: ADAPTER_EVENTS.INTERFACE_TYPE_CHANGE, + payload: { interfaceType: 'keyboard' } + }) + expect(input.destroy).toHaveBeenCalled() + expect(map.removeInteraction).toHaveBeenCalledWith(interaction) + }) +}) diff --git a/plugins/beta/draw/src/adapters/openlayers/olDraw.test.js b/plugins/beta/draw/src/adapters/openlayers/olDraw.test.js new file mode 100644 index 000000000..eff4115e6 --- /dev/null +++ b/plugins/beta/draw/src/adapters/openlayers/olDraw.test.js @@ -0,0 +1,59 @@ +import { createOLDraw } from './olDraw.js' +import { OLDrawManager } from './core/OLDrawManager.js' +import { MAP_SIZE_SCALES } from './defaults.js' + +jest.mock('./core/OLDrawManager.js', () => ({ + OLDrawManager: jest.fn(function () { + this.setMapStyle = jest.fn() + this.remove = jest.fn() + }) +})) + +const events = { MAP_SET_SIZE: 'app:size', MAP_SET_STYLE: 'app:style' } + +const setup = (mapStyle = null) => { + const listeners = {} + const eventBus = { + on: jest.fn((type, handler) => { listeners[type] = handler }), + off: jest.fn(), + emit: (type, payload) => listeners[type]?.(payload) + } + const mapProvider = { map: { id: 'ol-map' } } + const olDraw = createOLDraw({ mapProvider, events, eventBus, pluginConfig: { snapRadius: 5 }, mapStyle }) + const manager = OLDrawManager.mock.instances.at(-1) + return { eventBus, mapProvider, olDraw, manager } +} + +afterEach(() => jest.clearAllMocks()) + +test('creates the manager for the map, exposes it as mapProvider.draw and applies an initial style', () => { + const { mapProvider, manager } = setup({ id: 'dark' }) + expect(OLDrawManager).toHaveBeenCalledWith(mapProvider.map, { snapRadius: 5 }) + expect(mapProvider.draw).toBe(manager) + expect(manager.setMapStyle).toHaveBeenCalledWith({ id: 'dark' }) + + expect(setup().manager.setMapStyle).not.toHaveBeenCalled() // no initial style +}) + +test('map size changes update the draw UI scale, defaulting to 1 for unknown sizes', () => { + const { eventBus, mapProvider } = setup() + eventBus.emit(events.MAP_SET_SIZE, 'large') + expect(mapProvider.drawScale).toBe(MAP_SIZE_SCALES.large) + eventBus.emit(events.MAP_SET_SIZE, 'enormous') + expect(mapProvider.drawScale).toBe(1) +}) + +test('map style changes are forwarded to the manager', () => { + const { eventBus, manager } = setup() + eventBus.emit(events.MAP_SET_STYLE, { id: 'dark' }) + expect(manager.setMapStyle).toHaveBeenCalledWith({ id: 'dark' }) +}) + +test('remove unsubscribes, destroys the manager and clears mapProvider.draw', () => { + const { eventBus, mapProvider, olDraw, manager } = setup() + olDraw.remove() + expect(eventBus.off).toHaveBeenCalledWith(events.MAP_SET_SIZE, expect.any(Function)) + expect(eventBus.off).toHaveBeenCalledWith(events.MAP_SET_STYLE, expect.any(Function)) + expect(manager.remove).toHaveBeenCalled() + expect(mapProvider.draw).toBeNull() +}) From 47fdc8ecf45b0605217652efb793d50fa29506c9 Mon Sep 17 00:00:00 2001 From: Dan Leech Date: Mon, 6 Jul 2026 10:32:19 +0100 Subject: [PATCH 36/89] Snap tests added --- .../openlayers/edit/touchHandler.test.js | 71 +++++++ .../openlayers/snap/snapEngine.test.js | 174 ++++++++++++++++++ .../openlayers/snap/snapIndicator.test.js | 65 +++++++ .../openlayers/snap/snapInteraction.test.js | 68 +++++++ .../openlayers/snap/snapManager.test.js | 99 ++++++++++ 5 files changed, 477 insertions(+) create mode 100644 plugins/beta/draw/src/adapters/openlayers/edit/touchHandler.test.js create mode 100644 plugins/beta/draw/src/adapters/openlayers/snap/snapEngine.test.js create mode 100644 plugins/beta/draw/src/adapters/openlayers/snap/snapIndicator.test.js create mode 100644 plugins/beta/draw/src/adapters/openlayers/snap/snapInteraction.test.js create mode 100644 plugins/beta/draw/src/adapters/openlayers/snap/snapManager.test.js diff --git a/plugins/beta/draw/src/adapters/openlayers/edit/touchHandler.test.js b/plugins/beta/draw/src/adapters/openlayers/edit/touchHandler.test.js new file mode 100644 index 000000000..af40982ee --- /dev/null +++ b/plugins/beta/draw/src/adapters/openlayers/edit/touchHandler.test.js @@ -0,0 +1,71 @@ +import { createTouchHandler } from './touchHandler.js' +import { createFakeMap, createContainer, polygonFeature, domEvent } from '../__helpers__/harness.js' + +const RING = [[0, 0], [100, 0], [100, 100], [0, 100], [0, 0]] + +// Guard-path tests only — the happy touch-drag and tap flows are covered through EditMode +const setup = (stateOverrides = {}) => { + const map = createFakeMap() + const container = createContainer() + const state = { + olFeature: polygonFeature(RING), + selectedVertexIndex: 1, + selectedVertexType: 'vertex', + vertices: [[0, 0], [100, 0], [100, 100], [0, 100]], + midpoints: [], + interfaceType: 'touch', + ...stateOverrides + } + const setState = jest.fn((updates) => Object.assign(state, updates)) + const onVertexMoved = jest.fn() + const onTap = jest.fn() + const handler = createTouchHandler({ map, container, getState: () => state, setState, onVertexMoved, onTap, colors: {}, snap: null }) + const grip = container.querySelector('[data-im-draw-touch-target] circle') + const touch = (type, x, y) => grip.dispatchEvent(domEvent(type, { + touches: [{ clientX: x, clientY: y }], + changedTouches: [{ clientX: x, clientY: y }] + })) + return { map, container, state, handler, onVertexMoved, onTap, touch, grip } +} + +afterEach(() => { document.body.innerHTML = '' }) + +test('touching the target with nothing selected starts neither a drag nor a tap', () => { + const { state, handler, touch, onVertexMoved, onTap } = setup({ selectedVertexIndex: -1 }) + touch('touchstart', 100, 0) + touch('touchmove', 120, 10) + touch('touchend', 120, 10) + expect(onVertexMoved).not.toHaveBeenCalled() + expect(onTap).not.toHaveBeenCalled() // target touches never count as taps + expect(state.olFeature.getGeometry().getCoordinates()[0]).toEqual(RING) + handler.destroy() +}) + +test('a stray touchmove without an active drag is ignored', () => { + const { state, handler, touch } = setup() + touch('touchmove', 120, 10) + expect(state.olFeature.getGeometry().getCoordinates()[0]).toEqual(RING) + handler.destroy() +}) + +test('a drag halts safely if the feature disappears mid-gesture', () => { + const { state, handler, touch } = setup() + handler.updateTargetPosition() // target visible over vertex 1 + touch('touchstart', 100, 0) + const feature = state.olFeature + state.olFeature = null + touch('touchmove', 120, 10) + expect(feature.getGeometry().getCoordinates()[0]).toEqual(RING) + handler.destroy() +}) + +test('the offset target hides when its vertex cannot be projected', () => { + const { map, container, handler } = setup() + handler.updateTargetPosition() + const target = container.querySelector('[data-im-draw-touch-target]') + expect(target.style.display).toBe('block') + map.getPixelFromCoordinate = () => null // e.g. mid view transition + handler.updateTargetPosition() + expect(target.style.display).toBe('none') + handler.destroy() +}) diff --git a/plugins/beta/draw/src/adapters/openlayers/snap/snapEngine.test.js b/plugins/beta/draw/src/adapters/openlayers/snap/snapEngine.test.js new file mode 100644 index 000000000..5365d7576 --- /dev/null +++ b/plugins/beta/draw/src/adapters/openlayers/snap/snapEngine.test.js @@ -0,0 +1,174 @@ +import VectorLayer from 'ol/layer/Vector.js' +import VectorTileLayer from 'ol/layer/VectorTile.js' +import VectorSource from 'ol/source/Vector.js' +import { createSnapEngine } from './snapEngine.js' +import { polygonFeature } from '../__helpers__/harness.js' + +// One tile covering [0,0]–[4096,4096]: band for clip artefacts = 256 units from an +// edge, axis-alignment tolerance = 2 units. Resolution 1 → 12px radius = 12 units. +const tileGrid = { + getZForResolution: jest.fn(() => 10), + getTileCoordForCoordAndZ: jest.fn(() => [10, 0, 0]), + getTileCoordExtent: jest.fn(() => [0, 0, 4096, 4096]) +} + +const createEngineMap = () => { + const layers = [] + return { + layers, + getLayers: () => ({ forEach: (cb) => layers.forEach(cb) }), + getView: () => ({ getResolution: () => 1, getProjection: () => 'EPSG:3857' }), + getPixelFromCoordinate: jest.fn((c) => [c[0], c[1]]), + forEachFeatureAtPixel: jest.fn() + } +} + +const renderFeature = (type, flat, ends = null, mapboxLayer = { id: 'boundaries', type: 'line' }) => ({ + getType: () => type, + getFlatCoordinates: () => flat, + getEnds: () => ends, + get: (key) => (key === 'mapbox-layer' ? mapboxLayer : undefined) +}) + +// map.forEachFeatureAtPixel serves two callers: candidate iteration (hitTolerance = +// snap radius) and the invisible-fill probe (hitTolerance 2, returns the callback result) +const setupVT = ({ features, withTileGrid = true, fillNeighbour = false }) => { + const map = createEngineMap() + const vtLayer = new VectorTileLayer({}) + if (withTileGrid) { + jest.spyOn(vtLayer, 'getSource').mockReturnValue({ getTileGrid: () => tileGrid, getProjection: () => null }) + } + map.layers.push(vtLayer) + map.forEachFeatureAtPixel.mockImplementation((pixel, visit, opts) => { + // Honour the layerFilter the way OL does — only tile layers are consulted + if (!opts.layerFilter(vtLayer)) { + return undefined + } + if (opts.hitTolerance === 2) { + return fillNeighbour ? visit({ get: () => ({ id: 'fills' }) }) : undefined + } + features.forEach((f) => visit(f, vtLayer)) + return undefined + }) + return { map, engine: createSnapEngine(map, ['boundaries', 'fills']) } +} + +afterEach(() => jest.clearAllMocks()) + +describe('plain vector layers', () => { + test('snaps to features near the query, skipping layers without a source', () => { + const map = createEngineMap() + const source = new VectorSource() + source.addFeature(polygonFeature([[0, 0], [100, 0], [100, 100], [0, 0]])) + const engine = createSnapEngine(map, [new VectorLayer({ source }), new VectorLayer({})]) + expect(engine.query([98, 2], 12)).toEqual({ type: 'vertex', coord: [100, 0] }) + expect(engine.query([500, 500], 12)).toBeNull() + }) + + test('setLayers swaps targets at runtime and ignores unsupported entries', () => { + const map = createEngineMap() + const source = new VectorSource() + source.addFeature(polygonFeature([[0, 0], [100, 0], [100, 100], [0, 0]])) + const engine = createSnapEngine(map, [new VectorLayer({ source }), 42]) + expect(engine.query([98, 2], 12)).not.toBeNull() + engine.setLayers([]) + expect(engine.query([98, 2], 12)).toBeNull() + }) + + test('no resolution (map not ready) means no snapping', () => { + const map = createEngineMap() + map.getView = () => ({ getResolution: () => undefined }) + expect(createSnapEngine(map, ['boundaries']).query([0, 0], 12)).toBeNull() + }) +}) + +describe('vector-tile layers', () => { + test('snaps to configured tile layers only, mid-tile axis alignment is fine', () => { + // Horizontal segment mid-tile: axis-aligned but nowhere near a tile edge → real geometry + const { engine } = setupVT({ + features: [ + renderFeature('LineString', [500, 2000, 1500, 2000]), + renderFeature('LineString', [590, 2000, 610, 2000], null, { id: 'not-configured', type: 'line' }) + ] + }) + expect(engine.query([600, 2005], 12)).toEqual({ type: 'edge', coord: [600, 2000] }) + }) + + test('without any tile layer on the map, tile snapping is skipped entirely', () => { + const map = createEngineMap() + const engine = createSnapEngine(map, ['boundaries']) + expect(engine.query([600, 2005], 12)).toBeNull() + expect(map.forEachFeatureAtPixel).not.toHaveBeenCalled() + }) + + test('clip artefacts are rejected: axis-aligned segments hugging a tile edge', () => { + // Vertical segment at x=1, well inside the 256-unit band of the tile's left edge + const { engine } = setupVT({ features: [renderFeature('LineString', [1, 500, 1, 1500])] }) + expect(engine.query([5, 1000], 12)).toBeNull() + }) + + test('clip-cut vertices are rejected while their real edges survive', () => { + // The vertex at [1,1000] ends a clip segment (vertical along x=1); the horizontal + // edge leaving it at y=1000 is real geometry. The vertex would normally beat the + // edge — filtering it lets the edge win. + const { engine } = setupVT({ features: [renderFeature('LineString', [1, 500, 1, 1000, 800, 1000])] }) + expect(engine.query([6, 1004], 12)).toMatchObject({ type: 'edge' }) + }) + + test('without a tile grid the clip filter degrades gracefully and keeps candidates', () => { + const { engine } = setupVT({ + features: [renderFeature('LineString', [1, 500, 1, 1500])], + withTileGrid: false + }) + expect(engine.query([5, 1000], 12)).toEqual({ type: 'edge', coord: [1, 1000] }) + }) + + test('the tile boundary state is computed once per layer per query', () => { + const { engine } = setupVT({ + features: [ + renderFeature('LineString', [500, 2000, 1500, 2000]), + renderFeature('LineString', [500, 2020, 1500, 2020]) + ] + }) + engine.query([600, 2005], 12) + expect(tileGrid.getZForResolution).toHaveBeenCalledTimes(1) + }) +}) + +describe('invisible shared fill boundaries', () => { + const fillSquare = renderFeature( + 'Polygon', [1000, 1000, 3000, 1000, 3000, 3000, 1000, 3000], [8], { id: 'fills', type: 'fill' }) + + test('an edge with the same fill on both sides is skipped; a visible outer edge snaps', () => { + const invisible = setupVT({ features: [fillSquare], fillNeighbour: true }) + expect(invisible.engine.query([2000, 995], 12)).toBeNull() + + const visible = setupVT({ features: [fillSquare], fillNeighbour: false }) + expect(visible.engine.query([2000, 995], 12)).toEqual({ type: 'edge', coord: [2000, 1000] }) + }) + + test('a cursor already on the edge, or an unprojectable probe, skips the direction check', () => { + const onEdge = setupVT({ features: [fillSquare], fillNeighbour: true }) + expect(onEdge.engine.query([2000, 1000], 12)).toEqual({ type: 'edge', coord: [2000, 1000] }) + + const blindProbe = setupVT({ features: [fillSquare], fillNeighbour: true }) + blindProbe.map.getPixelFromCoordinate.mockReturnValueOnce([2000, 995]).mockReturnValueOnce(null) + expect(blindProbe.engine.query([2000, 995], 12)).toEqual({ type: 'edge', coord: [2000, 1000] }) + }) +}) + +test('debug logging traces queries, candidates and filtering when enabled', () => { + const log = jest.spyOn(console, 'log').mockImplementation(() => {}) + globalThis.DEBUG_SNAP_VISIBILITY = true + try { + const { engine } = setupVT({ features: [renderFeature('LineString', [1, 500, 1, 1500])] }) + engine.query([5, 1000], 12) + const topics = log.mock.calls.map(([topic]) => topic) + expect(topics).toContain('[snap-query]') + expect(topics).toContain('[snap-candidate-found]') + expect(topics).toContain('[snap-filtered] tile clip artefact') + } finally { + delete globalThis.DEBUG_SNAP_VISIBILITY + log.mockRestore() + } +}) diff --git a/plugins/beta/draw/src/adapters/openlayers/snap/snapIndicator.test.js b/plugins/beta/draw/src/adapters/openlayers/snap/snapIndicator.test.js new file mode 100644 index 000000000..74dac84f0 --- /dev/null +++ b/plugins/beta/draw/src/adapters/openlayers/snap/snapIndicator.test.js @@ -0,0 +1,65 @@ +import { createSnapIndicator } from './snapIndicator.js' +import { createFakeMap } from '../__helpers__/harness.js' + +const colors = { snapVertex: '#sv', snapEdge: '#se' } + +const setup = () => { + const map = createFakeMap() + const indicator = createSnapIndicator(map, colors) + const layer = map.layers[0] + return { map, indicator, layer, source: layer.getSource() } +} + +const renderedColor = (layer, feature) => { + const ctx = { beginPath: jest.fn(), arc: jest.fn(), fill: jest.fn(), fillStyle: null } + layer.getStyle()(feature).getRenderer()([5, 6], { context: ctx, pixelRatio: 2 }) + return { color: ctx.fillStyle, arc: ctx.arc.mock.calls[0] } +} + +test('show places a single reused feature; repeated shows move it rather than duplicate', () => { + const { indicator, source } = setup() + indicator.show([10, 10], 'vertex') + indicator.show([20, 20], 'edge') + expect(source.getFeatures()).toHaveLength(1) + expect(source.getFeatures()[0].getGeometry().getCoordinates()).toEqual([20, 20]) + expect(source.getFeatures()[0].get('snapType')).toBe('edge') +}) + +test('vertex and edge snaps render circles in their own colours, scaled by pixelRatio', () => { + const { indicator, layer, source } = setup() + indicator.show([10, 10], 'vertex') + const feature = source.getFeatures()[0] + expect(renderedColor(layer, feature)).toEqual({ color: colors.snapVertex, arc: [5, 6, 20, 0, Math.PI * 2] }) + + feature.set('snapType', 'edge', true) + expect(renderedColor(layer, feature).color).toBe(colors.snapEdge) + + feature.set('snapType', 'mystery', true) + expect(layer.getStyle()(feature)).toBeNull() +}) + +test('hide clears the circle and is safe to call when nothing is showing', () => { + const { indicator, source } = setup() + indicator.hide() // nothing showing — no-op + indicator.show([10, 10], 'vertex') + indicator.hide() + expect(source.getFeatures()).toHaveLength(0) + indicator.show([1, 1], 'vertex') // can show again after hiding + expect(source.getFeatures()).toHaveLength(1) +}) + +test('colour updates apply to subsequent renders, refreshing a visible circle', () => { + const { indicator, layer, source } = setup() + indicator.show([10, 10], 'vertex') + indicator.updateColors({ snapVertex: '#new', snapEdge: '#se' }) + expect(renderedColor(layer, source.getFeatures()[0]).color).toBe('#new') + indicator.updateColors(colors) // also fine while hidden +}) + +test('remove clears the source and detaches the layer', () => { + const { map, indicator, source } = setup() + indicator.show([10, 10], 'vertex') + indicator.remove() + expect(source.getFeatures()).toHaveLength(0) + expect(map.removeLayer).toHaveBeenCalled() +}) diff --git a/plugins/beta/draw/src/adapters/openlayers/snap/snapInteraction.test.js b/plugins/beta/draw/src/adapters/openlayers/snap/snapInteraction.test.js new file mode 100644 index 000000000..c0d83c9f0 --- /dev/null +++ b/plugins/beta/draw/src/adapters/openlayers/snap/snapInteraction.test.js @@ -0,0 +1,68 @@ +import { createSnapInteraction } from './snapInteraction.js' + +const setup = ({ indicatorActive = true } = {}) => { + const engine = { query: jest.fn(() => ({ type: 'vertex', coord: [9, 9] })) } + const indicator = { show: jest.fn(), hide: jest.fn() } + const interaction = createSnapInteraction(engine, indicator, 12, () => indicatorActive) + const fire = (type, coordinate = [1, 1]) => { + const event = { type, coordinate } + const result = interaction.handleEvent(event) + return { event, result } + } + return { engine, indicator, interaction, fire } +} + +test('always lets the event continue to other interactions', () => { + const { fire } = setup() + expect(fire('pointermove').result).toBe(true) + expect(fire('wheel').result).toBe(true) +}) + +test('does nothing while inactive', () => { + const { engine, interaction, fire } = setup() + interaction.setActive(false) + const { event } = fire('pointermove') + expect(engine.query).not.toHaveBeenCalled() + expect(event.coordinate).toEqual([1, 1]) +}) + +test('rewrites the event coordinate to a copy of the snap candidate for all pointer gestures', () => { + const { engine, fire } = setup() + for (const type of ['pointermove', 'pointerdrag', 'pointerdown', 'pointerup', 'singleclick', 'click']) { + const { event } = fire(type) + expect(event.coordinate).toEqual([9, 9]) + expect(event.coordinate).not.toBe(engine.query.mock.results.at(-1).value.coord) // copy, not shared + } +}) + +test('the indicator follows free mouse movement only', () => { + const { engine, indicator, fire } = setup() + fire('pointermove') + expect(indicator.show).toHaveBeenCalledWith([9, 9], 'vertex') + + engine.query.mockReturnValue(null) + fire('pointermove') + expect(indicator.hide).toHaveBeenCalledTimes(1) + + engine.query.mockReturnValue({ type: 'edge', coord: [5, 5] }) + fire('pointerdrag') + expect(indicator.hide).toHaveBeenCalledTimes(2) // hidden during drags + fire('singleclick') + expect(indicator.show).toHaveBeenCalledTimes(1) // clicks never touch the indicator +}) + +test('the indicator stays untouched during pointermove when the indicator is gated off', () => { + const { indicator, fire } = setup({ indicatorActive: false }) + const { event } = fire('pointermove') + expect(event.coordinate).toEqual([9, 9]) // snapping still applies + expect(indicator.show).not.toHaveBeenCalled() + expect(indicator.hide).not.toHaveBeenCalled() +}) + +test('leaving the map hides the indicator without querying; unrelated events are ignored', () => { + const { engine, indicator, fire } = setup() + fire('pointerout') + expect(indicator.hide).toHaveBeenCalled() + fire('wheel') + expect(engine.query).not.toHaveBeenCalled() +}) diff --git a/plugins/beta/draw/src/adapters/openlayers/snap/snapManager.test.js b/plugins/beta/draw/src/adapters/openlayers/snap/snapManager.test.js new file mode 100644 index 000000000..048919e87 --- /dev/null +++ b/plugins/beta/draw/src/adapters/openlayers/snap/snapManager.test.js @@ -0,0 +1,99 @@ +import { createSnapManager } from './snapManager.js' +import { createSnapEngine } from './snapEngine.js' +import { createSnapIndicator } from './snapIndicator.js' +import { createSnapInteraction } from './snapInteraction.js' +import { createFakeMap } from '../__helpers__/harness.js' + +jest.mock('./snapEngine.js', () => ({ + createSnapEngine: jest.fn(() => ({ query: jest.fn(() => null), setLayers: jest.fn() })) +})) +jest.mock('./snapIndicator.js', () => ({ + createSnapIndicator: jest.fn(() => ({ show: jest.fn(), hide: jest.fn(), updateColors: jest.fn(), remove: jest.fn() })) +})) +jest.mock('./snapInteraction.js', () => ({ + createSnapInteraction: jest.fn(() => ({ setActive: jest.fn() })) +})) + +const setup = () => { + const map = createFakeMap() + const snap = createSnapManager(map, ['boundaries'], { snapVertex: '#sv' }, 12) + const engine = createSnapEngine.mock.results.at(-1).value + const indicator = createSnapIndicator.mock.results.at(-1).value + const interaction = createSnapInteraction.mock.results.at(-1).value + return { map, snap, engine, indicator, interaction } +} + +afterEach(() => jest.clearAllMocks()) + +test('no snap layers configured means no snap manager at all', () => { + expect(createSnapManager(createFakeMap(), null, {}, 12)).toBeNull() + expect(createSnapManager(createFakeMap(), [], {}, 12)).toBeNull() +}) + +test('starts attached to the map but inactive, matching the initial snap-off UI state', () => { + const { map, snap, interaction } = setup() + expect(map.interactions).toContain(interaction) + expect(interaction.setActive).toHaveBeenCalledWith(false) + expect(snap.snapRadius).toBe(12) +}) + +describe('apply', () => { + test('returns the coordinate untouched while snap is off', () => { + const { snap, engine } = setup() + expect(snap.apply([1, 2])).toEqual([1, 2]) + expect(engine.query).not.toHaveBeenCalled() + }) + + test('snaps to a candidate and shows the indicator, or hides it on a miss', () => { + const { snap, engine, indicator } = setup() + snap.setActive(true) + engine.query.mockReturnValue({ type: 'vertex', coord: [9, 9] }) + expect(snap.apply([8, 8])).toEqual([9, 9]) + expect(indicator.show).toHaveBeenCalledWith([9, 9], 'vertex') + + engine.query.mockReturnValue(null) + expect(snap.apply([1, 2])).toEqual([1, 2]) + expect(indicator.hide).toHaveBeenCalled() + }) +}) + +test('deactivating snap or the indicator hides the circle; the interaction reads indicator state live', () => { + const { snap, indicator, interaction } = setup() + const isIndicatorActive = createSnapInteraction.mock.calls.at(-1)[3] + snap.setIndicatorActive(true) + expect(isIndicatorActive()).toBe(true) + snap.setIndicatorActive(false) + expect(isIndicatorActive()).toBe(false) + expect(indicator.hide).toHaveBeenCalledTimes(1) + + snap.setActive(false) + expect(interaction.setActive).toHaveBeenLastCalledWith(false) + expect(indicator.hide).toHaveBeenCalledTimes(2) +}) + +test('setSnapLayers forwards new layers, falling back to the configured set when cleared', () => { + const { snap, engine } = setup() + snap.setSnapLayers(['other']) + expect(engine.setLayers).toHaveBeenCalledWith(['other']) + snap.setSnapLayers(null) + expect(engine.setLayers).toHaveBeenCalledWith(['boundaries']) +}) + +test('reattach re-adds the interaction so it processes pointer events first', () => { + const { map, snap, interaction } = setup() + map.interactions.push({ id: 'draw' }) // a mode was just added + snap.reattach() + expect(map.removeInteraction).toHaveBeenCalledWith(interaction) + expect(map.interactions.at(-1)).toBe(interaction) +}) + +test('colour updates and explicit hides reach the indicator; destroy removes interaction and indicator', () => { + const { map, snap, indicator, interaction } = setup() + snap.hideIndicator() + expect(indicator.hide).toHaveBeenCalled() + snap.updateColors({ snapVertex: '#new' }) + expect(indicator.updateColors).toHaveBeenCalledWith({ snapVertex: '#new' }) + snap.destroy() + expect(map.removeInteraction).toHaveBeenCalledWith(interaction) + expect(indicator.remove).toHaveBeenCalled() +}) From 29050c9eb8a79517ead048b1b2b23104c358a252 Mon Sep 17 00:00:00 2001 From: Dan Leech Date: Mon, 6 Jul 2026 10:50:10 +0100 Subject: [PATCH 37/89] EditMode line 96 test added --- .../src/adapters/openlayers/edit/EditMode.test.js | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/plugins/beta/draw/src/adapters/openlayers/edit/EditMode.test.js b/plugins/beta/draw/src/adapters/openlayers/edit/EditMode.test.js index 59a730b45..351b8c426 100644 --- a/plugins/beta/draw/src/adapters/openlayers/edit/EditMode.test.js +++ b/plugins/beta/draw/src/adapters/openlayers/edit/EditMode.test.js @@ -4,6 +4,7 @@ import { STYLES_CHANGED_EVENT } from '../core/internalEvents.js' import { ADAPTER_EVENTS } from '../../../adapterEvents.js' import { createFakeMap, createFakeManager, createContainer, domEvent } from '../__helpers__/harness.js' import Style from 'ol/style/Style.js' +import Polygon from 'ol/geom/Polygon.js' const RING = [[0, 0], [100, 0], [100, 100], [0, 100], [0, 0]] // square: 4 vertices, deletable @@ -137,6 +138,18 @@ test('touch: tapping a midpoint inserts a vertex there and selects it', () => { expect(manager.undoStack.pop()).toEqual({ type: 'insert_vertex', vertexIndex: 1 }) }) +test('touch: tapping a stale midpoint the geometry can no longer place is a no-op', () => { + const { manager, olFeature, ring, tapAt } = setup() + // Desync state from geometry: swap in a smaller geometry object so the + // selection-state change listener (bound to the previous geometry) never + // fires. state.midpoints still describes the 4-vertex square (4 midpoints), + // while the live geometry is a 3-vertex triangle that has only 3 midpoints. + olFeature.setGeometry(new Polygon([[[0, 0], [100, 0], [100, 100], [0, 0]]])) + tapAt(0, 50) // the stale square's left-edge midpoint (index 3) — the triangle can't place it + expect(manager.undoStack.length).toBe(0) + expect(ring()).toHaveLength(4) // triangle geometry left untouched +}) + test('undo in touch mode repositions the offset target on the restored vertex', () => { const { container, manager, mode, tapAt } = setup() tapAt(100, 0) From 4c17095248a30599cf417f0922df9be6cac41c3f Mon Sep 17 00:00:00 2001 From: Dan Leech Date: Mon, 6 Jul 2026 11:04:01 +0100 Subject: [PATCH 38/89] Edit mode partial coverage tests added --- .../openlayers/edit/keyboardHandler.test.js | 52 +++++++++++++++ .../adapters/openlayers/edit/nudge.test.js | 14 ++++ .../openlayers/edit/touchHandler.test.js | 66 ++++++++++++++++++- .../adapters/openlayers/edit/undoOps.test.js | 6 ++ .../openlayers/edit/vertexOps.test.js | 12 ++++ 5 files changed, 148 insertions(+), 2 deletions(-) diff --git a/plugins/beta/draw/src/adapters/openlayers/edit/keyboardHandler.test.js b/plugins/beta/draw/src/adapters/openlayers/edit/keyboardHandler.test.js index abf803fa9..4fa0e3317 100644 --- a/plugins/beta/draw/src/adapters/openlayers/edit/keyboardHandler.test.js +++ b/plugins/beta/draw/src/adapters/openlayers/edit/keyboardHandler.test.js @@ -44,6 +44,33 @@ test('Space selects the vertex or midpoint nearest the crosshair, only when noth expect(setState).not.toHaveBeenCalled() }) +test('Space selects a midpoint when it is the handle nearest the crosshair', () => { + const map = createFakeMap({ center: [100, 48] }) // crosshair beside midpoint [100, 50] + const state = { + olFeature: polygonFeature(RING), + selectedVertexIndex: -1, + selectedVertexType: null, + vertices: [[0, 0], [100, 0], [100, 100]], + midpoints: [[50, 0], [100, 50], [50, 50]] + } + const setState = jest.fn((updates) => Object.assign(state, updates)) + const handler = createKeyboardHandler({ + map, + getState: () => state, + setState, + snap: null, + onVertexMoved: jest.fn(), + onInserted: jest.fn(), + onDeleted: jest.fn(), + onUndo: jest.fn(), + onKeyboardActive: jest.fn() + }) + liveHandlers.push(handler) + key('keydown', { key: ' ' }) + expect(state.selectedVertexIndex).toBe(4) // midpoint [100, 50] + expect(state.selectedVertexType).toBe('midpoint') +}) + test('Alt+Arrow navigates the selection to the nearest handle in that direction', () => { const { state } = setup() Object.assign(state, { selectedVertexIndex: 2, selectedVertexType: 'vertex' }) @@ -52,6 +79,21 @@ test('Alt+Arrow navigates the selection to the nearest handle in that direction' expect(state.selectedVertexType).toBe('midpoint') }) +test('Alt+Arrow with nothing selected navigates relative to the crosshair', () => { + const { state } = setup() + key('keydown', { key: 'ArrowUp', altKey: true }) // no selection → start from crosshair [98, 98] + expect(state.selectedVertexIndex).toBe(4) // midpoint [100, 50] above the crosshair + expect(state.selectedVertexType).toBe('midpoint') +}) + +test('Alt+Arrow can land the selection on a vertex', () => { + const { state } = setup() + Object.assign(state, { selectedVertexIndex: 4, selectedVertexType: 'midpoint' }) // midpoint [100, 50] + key('keydown', { key: 'ArrowUp', altKey: true }) + expect(state.selectedVertexIndex).toBe(1) // vertex [100, 0] directly above + expect(state.selectedVertexType).toBe('vertex') +}) + test('a plain arrow nudges the selected vertex, and keyup commits a single move op', () => { const { state, onVertexMoved } = setup() Object.assign(state, { selectedVertexIndex: 1, selectedVertexType: 'vertex' }) @@ -106,6 +148,16 @@ test('keys are ignored while an interactive element outside the viewport has foc expect(onKeyboardActive).not.toHaveBeenCalled() }) +test('keys are ignored while a tabindex-focusable non-interactive element outside the viewport has focus', () => { + const { onDeleted } = setup() + const div = document.createElement('div') + div.tabIndex = 0 // focusable via tabindex, not one of the interactive tags + document.body.appendChild(div) + div.focus() + key('keyup', { key: 'Delete' }) + expect(onDeleted).not.toHaveBeenCalled() +}) + test('unhandled keys do nothing', () => { const { setState } = setup() key('keydown', { key: 'a' }) diff --git a/plugins/beta/draw/src/adapters/openlayers/edit/nudge.test.js b/plugins/beta/draw/src/adapters/openlayers/edit/nudge.test.js index a89914ac9..5537c3dc5 100644 --- a/plugins/beta/draw/src/adapters/openlayers/edit/nudge.test.js +++ b/plugins/beta/draw/src/adapters/openlayers/edit/nudge.test.js @@ -71,6 +71,15 @@ describe('nudging a midpoint', () => { expect(onInserted).not.toHaveBeenCalled() }) + test('snaps the inserted-then-moved coordinate when a snap manager is active', () => { + const snap = { apply: jest.fn((c) => [c[0] + 2, c[1]]), hideIndicator: jest.fn(), snapRadius: 12 } + const { state, nudge, olFeature } = setup({ snap }) + Object.assign(state, { selectedVertexIndex: 4, selectedVertexType: 'midpoint' }) // midpoint [10, 5] + nudge({ key: 'ArrowRight', shiftKey: false }) + expect(snap.apply).toHaveBeenCalled() + expect(ring(olFeature)[2]).toEqual([17, 5]) // inserted [10,5] nudged +5 -> [15,5], snapped +2 -> [17,5] + }) + test('stops safely if the inserted vertex is not reflected in state', () => { const { state, nudge, onInserted, setState } = setup() onInserted.mockImplementation(() => {}) // consumer failed to sync vertices @@ -113,4 +122,9 @@ describe('resolveSnappedCoord', () => { const map = createFakeMap() expect(resolveSnappedCoord({ snapRadius: 12 }, map, [0, 0], [5, 5], [0, 0], 5, 5)).toEqual([13, 13]) }) + + test('escapes only the vertical axis when the horizontal delta is zero', () => { + const map = createFakeMap() + expect(resolveSnappedCoord({ snapRadius: 12 }, map, [0, 0], [0, 5], [0, 0], 0, 5)).toEqual([0, 13]) + }) }) diff --git a/plugins/beta/draw/src/adapters/openlayers/edit/touchHandler.test.js b/plugins/beta/draw/src/adapters/openlayers/edit/touchHandler.test.js index af40982ee..c5d61b9fa 100644 --- a/plugins/beta/draw/src/adapters/openlayers/edit/touchHandler.test.js +++ b/plugins/beta/draw/src/adapters/openlayers/edit/touchHandler.test.js @@ -4,7 +4,7 @@ import { createFakeMap, createContainer, polygonFeature, domEvent } from '../__h const RING = [[0, 0], [100, 0], [100, 100], [0, 100], [0, 0]] // Guard-path tests only — the happy touch-drag and tap flows are covered through EditMode -const setup = (stateOverrides = {}) => { +const setup = (stateOverrides = {}, { snap = null } = {}) => { const map = createFakeMap() const container = createContainer() const state = { @@ -19,7 +19,7 @@ const setup = (stateOverrides = {}) => { const setState = jest.fn((updates) => Object.assign(state, updates)) const onVertexMoved = jest.fn() const onTap = jest.fn() - const handler = createTouchHandler({ map, container, getState: () => state, setState, onVertexMoved, onTap, colors: {}, snap: null }) + const handler = createTouchHandler({ map, container, getState: () => state, setState, onVertexMoved, onTap, colors: {}, snap }) const grip = container.querySelector('[data-im-draw-touch-target] circle') const touch = (type, x, y) => grip.dispatchEvent(domEvent(type, { touches: [{ clientX: x, clientY: y }], @@ -69,3 +69,65 @@ test('the offset target hides when its vertex cannot be projected', () => { expect(target.style.display).toBe('none') handler.destroy() }) + +test('a drag applies snapping to the moved coordinate when a snap manager is active', () => { + const snap = { apply: jest.fn((c) => [c[0] + 5, c[1]]), hideIndicator: jest.fn() } + const { state, handler, touch } = setup({}, { snap }) + handler.updateTargetPosition() + touch('touchstart', 100, 0) + touch('touchmove', 120, 10) + expect(snap.apply).toHaveBeenCalled() + expect(snap.hideIndicator).toHaveBeenCalled() + expect(state.vertices[1]).toEqual([125, 10]) // dragged to [120,10] then snapped +5 on x + handler.destroy() +}) + +test('a touch that drifts too far before lifting is not treated as a tap', () => { + const { container, handler, onTap } = setup({ selectedVertexIndex: -1 }) + container.dispatchEvent(domEvent('touchstart', { touches: [{ clientX: 10, clientY: 10 }] })) + container.dispatchEvent(domEvent('touchend', { changedTouches: [{ clientX: 60, clientY: 60 }] })) // moved past the tap threshold + expect(onTap).not.toHaveBeenCalled() + handler.destroy() +}) + +test('a drag that ends after its vertex has vanished commits nothing', () => { + const { state, handler, touch, onVertexMoved } = setup() + handler.updateTargetPosition() + touch('touchstart', 100, 0) + touch('touchmove', 120, 10) + state.vertices = [] // the vertex is gone by the time the finger lifts + touch('touchend', 120, 10) + expect(onVertexMoved).not.toHaveBeenCalled() + handler.destroy() +}) + +test('a postrender with nothing selected leaves the target hidden', () => { + const { map, container, handler } = setup({ selectedVertexIndex: -1 }) + const target = container.querySelector('[data-im-draw-touch-target]') + map.emit('postrender') + expect(target.style.display).toBe('none') + handler.destroy() +}) + +test('the CSS transform reflects a scaled viewport when element widths are measurable', () => { + const map = createFakeMap() + const container = createContainer() + const vp = map.getViewport() + Object.defineProperty(vp, 'offsetWidth', { value: 200, configurable: true }) + Object.defineProperty(container, 'offsetWidth', { value: 100, configurable: true }) + vp.getBoundingClientRect = () => ({ width: 300, height: 300, left: 0, top: 0 }) + container.getBoundingClientRect = () => ({ width: 100, height: 100, left: 0, top: 0 }) + const state = { + olFeature: polygonFeature(RING), + selectedVertexIndex: 1, + selectedVertexType: 'vertex', + vertices: [[0, 0], [100, 0], [100, 100], [0, 100]], + midpoints: [], + interfaceType: 'touch' + } + const handler = createTouchHandler({ map, container, getState: () => state, setState: jest.fn(), onVertexMoved: jest.fn(), onTap: jest.fn(), colors: {}, snap: null }) + handler.updateTargetPosition() // vertex [100,0] -> pixel {100,0} -> scale 1.5 -> css left 150px + const target = container.querySelector('[data-im-draw-touch-target]') + expect(target.style.left).toBe('150px') + handler.destroy() +}) diff --git a/plugins/beta/draw/src/adapters/openlayers/edit/undoOps.test.js b/plugins/beta/draw/src/adapters/openlayers/edit/undoOps.test.js index 22b8c9ba0..9f95afae9 100644 --- a/plugins/beta/draw/src/adapters/openlayers/edit/undoOps.test.js +++ b/plugins/beta/draw/src/adapters/openlayers/edit/undoOps.test.js @@ -27,6 +27,12 @@ describe('undoInsertVertex', () => { test('returns -1 for an index outside the geometry', () => { expect(undoInsertVertex(polygonFeature(SQUARE), { vertexIndex: 99 })).toBe(-1) }) + + test('removes the inserted vertex from an open line without a closing-coordinate sync', () => { + const feature = lineFeature([[0, 0], [50, 0], [100, 0]]) + expect(undoInsertVertex(feature, { vertexIndex: 1 })).toBe(-1) + expect(feature.getGeometry().getCoordinates()).toEqual([[0, 0], [100, 0]]) + }) }) describe('undoDeleteVertex', () => { diff --git a/plugins/beta/draw/src/adapters/openlayers/edit/vertexOps.test.js b/plugins/beta/draw/src/adapters/openlayers/edit/vertexOps.test.js index 134dc3ada..c090653a1 100644 --- a/plugins/beta/draw/src/adapters/openlayers/edit/vertexOps.test.js +++ b/plugins/beta/draw/src/adapters/openlayers/edit/vertexOps.test.js @@ -20,6 +20,12 @@ describe('deleteVertex', () => { expect(ring(feature)).toEqual([[10, 0], [10, 10], [0, 10], [10, 0]]) }) + test('deleting from an open line needs no closing-coordinate sync', () => { + const feature = lineFeature([[0, 0], [10, 0], [20, 0]]) + expect(deleteVertex(feature, 1)).toEqual({ deletedIndex: 1, deletedCoord: [10, 0] }) + expect(feature.getGeometry().getCoordinates()).toEqual([[0, 0], [20, 0]]) + }) + test('refuses to shrink below the minimum (3 for rings, 2 for lines) or for bad indices', () => { expect(deleteVertex(polygonFeature([[0, 0], [10, 0], [10, 10], [0, 0]]), 1)).toBeNull() expect(deleteVertex(lineFeature([[0, 0], [10, 0]]), 0)).toBeNull() @@ -48,6 +54,12 @@ describe('insertAtMidpoint', () => { const extra = [[5, 0], [10, 5], [5, 10], [0, 5], [9, 9]] expect(insertAtMidpoint(polygonFeature(SQUARE), extra, 4 + 4, 4)).toBeNull() }) + + test('inserts a midpoint into an open line segment', () => { + const feature = lineFeature([[0, 0], [10, 0]]) // 2 vertices, 1 midpoint at flat index 2 + expect(insertAtMidpoint(feature, [[5, 0]], 2, 2)).toEqual({ insertedIndex: 1 }) + expect(feature.getGeometry().getCoordinates()).toEqual([[0, 0], [5, 0], [10, 0]]) + }) }) describe('moveVertex', () => { From 81fcac1d356d1c1ef7c8d784ac8c38b6390cd775 Mon Sep 17 00:00:00 2001 From: Dan Leech Date: Mon, 6 Jul 2026 11:18:24 +0100 Subject: [PATCH 39/89] Snap mode remaining partial tests added --- .../src/adapters/openlayers/edit/EditMode.js | 17 +++--- .../adapters/openlayers/snap/snapEngine.js | 2 +- .../openlayers/snap/snapEngine.test.js | 58 +++++++++++++++++++ .../openlayers/snap/snapGeometry.test.js | 6 ++ .../openlayers/snap/snapIndicator.test.js | 7 +++ .../adapters/openlayers/snap/snapManager.js | 2 +- .../openlayers/snap/snapManager.test.js | 2 + 7 files changed, 83 insertions(+), 11 deletions(-) diff --git a/plugins/beta/draw/src/adapters/openlayers/edit/EditMode.js b/plugins/beta/draw/src/adapters/openlayers/edit/EditMode.js index 042e2f668..c6f20cb52 100644 --- a/plugins/beta/draw/src/adapters/openlayers/edit/EditMode.js +++ b/plugins/beta/draw/src/adapters/openlayers/edit/EditMode.js @@ -90,16 +90,15 @@ const wireTouchHandler = ({ map, container, manager, snap, olFeature, undoStack, touchHandler.updateTargetPosition() return } - if (hit.type === 'midpoint') { - const result = insertAtMidpoint(olFeature, state.midpoints, hit.index, state.vertices.length) - if (!result) { - return - } - undoStack.push({ type: 'insert_vertex', vertexIndex: result.insertedIndex }) - syncGeom() - selectVertex(result.insertedIndex) - touchHandler.updateTargetPosition() + // Only vertex and midpoint hits reach here, and the vertex case returned above + const result = insertAtMidpoint(olFeature, state.midpoints, hit.index, state.vertices.length) + if (!result) { + return } + undoStack.push({ type: 'insert_vertex', vertexIndex: result.insertedIndex }) + syncGeom() + selectVertex(result.insertedIndex) + touchHandler.updateTargetPosition() } }) diff --git a/plugins/beta/draw/src/adapters/openlayers/snap/snapEngine.js b/plugins/beta/draw/src/adapters/openlayers/snap/snapEngine.js index bb277a27c..a6a058e4e 100644 --- a/plugins/beta/draw/src/adapters/openlayers/snap/snapEngine.js +++ b/plugins/beta/draw/src/adapters/openlayers/snap/snapEngine.js @@ -63,7 +63,7 @@ const isClipArtefact = (state, candidate) => { const ctx = getClipContext(state, candidate.coord) if (!ctx) { return false } if (candidate.type === 'edge') { - const [a, b] = candidate.seg ?? [] + const [a, b] = candidate.seg return isArtefactSegment(ctx, ctx.toSource(a), ctx.toSource(b)) } const [prev, next] = candidate.adjacent ?? [] diff --git a/plugins/beta/draw/src/adapters/openlayers/snap/snapEngine.test.js b/plugins/beta/draw/src/adapters/openlayers/snap/snapEngine.test.js index 5365d7576..0cb420ef8 100644 --- a/plugins/beta/draw/src/adapters/openlayers/snap/snapEngine.test.js +++ b/plugins/beta/draw/src/adapters/openlayers/snap/snapEngine.test.js @@ -80,6 +80,21 @@ describe('plain vector layers', () => { map.getView = () => ({ getResolution: () => undefined }) expect(createSnapEngine(map, ['boundaries']).query([0, 0], 12)).toBeNull() }) + + test('defaults to an empty snap-layer set when none are configured', () => { + const map = createEngineMap() + expect(createSnapEngine(map).query([0, 0], 12)).toBeNull() + }) + + test('setLayers tolerates a null layer list', () => { + const map = createEngineMap() + const source = new VectorSource() + source.addFeature(polygonFeature([[0, 0], [100, 0], [100, 100], [0, 0]])) + const engine = createSnapEngine(map, [new VectorLayer({ source })]) + expect(engine.query([98, 2], 12)).not.toBeNull() + engine.setLayers(null) + expect(engine.query([98, 2], 12)).toBeNull() + }) }) describe('vector-tile layers', () => { @@ -133,6 +148,34 @@ describe('vector-tile layers', () => { engine.query([600, 2005], 12) expect(tileGrid.getZForResolution).toHaveBeenCalledTimes(1) }) + + test('the boundary state is reused across multiple candidates from the same tile layer', () => { + const { engine } = setupVT({ + features: [ + renderFeature('LineString', [500, 2000, 1500, 2000]), + renderFeature('LineString', [500, 2005, 1500, 2005]) + ] + }) + expect(engine.query([600, 2002], 12)).not.toBeNull() // both features yield a candidate + expect(tileGrid.getZForResolution).toHaveBeenCalledTimes(1) + }) + + test('open-line endpoints test only the neighbour that exists', () => { + // Endpoint [2000,2000] has a null previous neighbour; the clip filter must skip it + const { engine } = setupVT({ features: [renderFeature('LineString', [2000, 2000, 3000, 2000])] }) + expect(engine.query([2002, 2001], 12)).toEqual({ type: 'vertex', coord: [2000, 2000] }) + }) + + test('a tile point candidate without adjacency data snaps as a vertex', () => { + const { engine } = setupVT({ features: [renderFeature('Point', [2000, 2000])] }) + expect(engine.query([2001, 2000], 12)).toEqual({ type: 'vertex', coord: [2000, 2000] }) + }) + + test('non-tile layers present on the map are ignored when gathering tile layers', () => { + const { engine, map } = setupVT({ features: [renderFeature('LineString', [500, 2000, 1500, 2000])] }) + map.layers.unshift(new VectorLayer({})) // a non-tile layer sits in the stack + expect(engine.query([600, 2005], 12)).toEqual({ type: 'edge', coord: [600, 2000] }) + }) }) describe('invisible shared fill boundaries', () => { @@ -172,3 +215,18 @@ test('debug logging traces queries, candidates and filtering when enabled', () = log.mockRestore() } }) + +test('debug logging traces invisible-fill filtering when enabled', () => { + const log = jest.spyOn(console, 'log').mockImplementation(() => {}) + globalThis.DEBUG_SNAP_VISIBILITY = true + try { + const fillSquare = renderFeature( + 'Polygon', [1000, 1000, 3000, 1000, 3000, 3000, 1000, 3000], [8], { id: 'fills', type: 'fill' }) + const { engine } = setupVT({ features: [fillSquare], fillNeighbour: true }) + engine.query([2000, 995], 12) + expect(log.mock.calls.map(([topic]) => topic)).toContain('[snap-filtered] invisible fill boundary') + } finally { + delete globalThis.DEBUG_SNAP_VISIBILITY + log.mockRestore() + } +}) diff --git a/plugins/beta/draw/src/adapters/openlayers/snap/snapGeometry.test.js b/plugins/beta/draw/src/adapters/openlayers/snap/snapGeometry.test.js index c90261365..63fe92f73 100644 --- a/plugins/beta/draw/src/adapters/openlayers/snap/snapGeometry.test.js +++ b/plugins/beta/draw/src/adapters/openlayers/snap/snapGeometry.test.js @@ -108,6 +108,12 @@ describe('testRenderFeature (vector-tile render features)', () => { expect(vertex).toMatchObject({ coord: [0, 0], adjacent: [null, [100, 0]] }) }) + test('the far end of an open line also reports a null neighbour on its open side', () => { + const line = renderFeature('LineString', [0, 0, 100, 0]) + const [vertex] = testRenderFeature(line, [99, 1], TOL) + expect(vertex).toMatchObject({ coord: [100, 0], adjacent: [[0, 0], null] }) + }) + test('polygon rings (no duplicated closing coord in tiles) wrap edges and adjacency', () => { const square = renderFeature('Polygon', [0, 0, 100, 0, 100, 100, 0, 100], [8]) const candidates = testRenderFeature(square, [2, 52], TOL) diff --git a/plugins/beta/draw/src/adapters/openlayers/snap/snapIndicator.test.js b/plugins/beta/draw/src/adapters/openlayers/snap/snapIndicator.test.js index 74dac84f0..4a75acf53 100644 --- a/plugins/beta/draw/src/adapters/openlayers/snap/snapIndicator.test.js +++ b/plugins/beta/draw/src/adapters/openlayers/snap/snapIndicator.test.js @@ -56,6 +56,13 @@ test('colour updates apply to subsequent renders, refreshing a visible circle', indicator.updateColors(colors) // also fine while hidden }) +test('colour updates while hidden skip the redraw', () => { + const { indicator, source } = setup() + const changed = jest.spyOn(source, 'changed') + indicator.updateColors({ snapVertex: '#x', snapEdge: '#y' }) // nothing showing yet + expect(changed).not.toHaveBeenCalled() +}) + test('remove clears the source and detaches the layer', () => { const { map, indicator, source } = setup() indicator.show([10, 10], 'vertex') diff --git a/plugins/beta/draw/src/adapters/openlayers/snap/snapManager.js b/plugins/beta/draw/src/adapters/openlayers/snap/snapManager.js index 767776abe..ed5963478 100644 --- a/plugins/beta/draw/src/adapters/openlayers/snap/snapManager.js +++ b/plugins/beta/draw/src/adapters/openlayers/snap/snapManager.js @@ -79,7 +79,7 @@ export const createSnapManager = (map, snapLayers, colors, snapRadius) => { * the newly added Draw or Modify interaction. */ setSnapLayers (layers) { - engine.setLayers(layers === null || layers === undefined ? (snapLayers ?? []) : layers) + engine.setLayers(layers === null || layers === undefined ? snapLayers : layers) }, reattach () { diff --git a/plugins/beta/draw/src/adapters/openlayers/snap/snapManager.test.js b/plugins/beta/draw/src/adapters/openlayers/snap/snapManager.test.js index 048919e87..0367fa052 100644 --- a/plugins/beta/draw/src/adapters/openlayers/snap/snapManager.test.js +++ b/plugins/beta/draw/src/adapters/openlayers/snap/snapManager.test.js @@ -77,6 +77,8 @@ test('setSnapLayers forwards new layers, falling back to the configured set when expect(engine.setLayers).toHaveBeenCalledWith(['other']) snap.setSnapLayers(null) expect(engine.setLayers).toHaveBeenCalledWith(['boundaries']) + snap.setSnapLayers(undefined) + expect(engine.setLayers).toHaveBeenLastCalledWith(['boundaries']) }) test('reattach re-adds the interaction so it processes pointer events first', () => { From c0c65ffd87d2f862ae26c8272b19bfa5309dc5fc Mon Sep 17 00:00:00 2001 From: Dan Leech Date: Mon, 6 Jul 2026 11:25:26 +0100 Subject: [PATCH 40/89] Remaing partial coverage added for draw functionlaity --- .../adapters/openlayers/draw/DrawMode.test.js | 18 +++++++++++++++ .../openlayers/draw/drawInput.test.js | 18 +++++++++++++++ .../openlayers/draw/vertexPlacement.test.js | 22 +++++++++++++++++++ 3 files changed, 58 insertions(+) diff --git a/plugins/beta/draw/src/adapters/openlayers/draw/DrawMode.test.js b/plugins/beta/draw/src/adapters/openlayers/draw/DrawMode.test.js index 934c7476b..eb096a892 100644 --- a/plugins/beta/draw/src/adapters/openlayers/draw/DrawMode.test.js +++ b/plugins/beta/draw/src/adapters/openlayers/draw/DrawMode.test.js @@ -114,6 +114,13 @@ describe('drawing lifecycle', () => { expect(removeLast).toHaveBeenCalled() expect(emitted().at(-1)).toEqual({ type: ADAPTER_EVENTS.VERTEX_CHANGE, payload: { numVertices: 2 } }) }) + + test('undo before any sketch exists re-reports nothing', () => { + const { mode, emitted, interaction } = setup() + jest.spyOn(interaction, 'removeLastPoint').mockImplementation(() => {}) + mode.undo() // no sketch yet → updateVertexCount returns early + expect(emitted().some((e) => e.type === ADAPTER_EVENTS.VERTEX_CHANGE)).toBe(false) + }) }) describe('wiring', () => { @@ -127,6 +134,17 @@ describe('wiring', () => { expect(removeLast).toHaveBeenCalled() }) + test('properties default to an empty object when omitted from options', () => { + const map = createFakeMap() + const manager = createFakeManager() + manager.store = createFeatureStore() + createDrawMode({ map, manager, options: { geometryType: 'Polygon', featureId: 'shape-2', container: null, snap: null } }) + map.interactions[0].dispatchEvent({ type: 'drawend', feature: polygonFeature([[0, 0], [10, 0], [10, 10], [0, 0]], null) }) + const created = manager.store.getOL('shape-2') + expect(created).not.toBeNull() + expect(created.get('label')).toBeUndefined() + }) + test('map style changes rebuild the sketch style for the current geometry type', () => { const { manager, interaction } = setup() expect(manager.styles.createSketchStyle).toHaveBeenCalledWith('Polygon') diff --git a/plugins/beta/draw/src/adapters/openlayers/draw/drawInput.test.js b/plugins/beta/draw/src/adapters/openlayers/draw/drawInput.test.js index 6e48afdb1..8f3d31d1b 100644 --- a/plugins/beta/draw/src/adapters/openlayers/draw/drawInput.test.js +++ b/plugins/beta/draw/src/adapters/openlayers/draw/drawInput.test.js @@ -79,6 +79,24 @@ test('pointer and touch input switch the interface type', () => { expect(input.getInterfaceType()).toBe('touch') }) +test('a touch pointerdown is left for the touchstart handler to classify', () => { + const { container, input, placement } = setup('keyboard') + container.dispatchEvent(Object.assign(new Event('pointerdown'), { pointerType: 'touch' })) + expect(input.getInterfaceType()).toBe('keyboard') // onPointerdown ignored the touch pointer + expect(placement.clearLastCoord).not.toHaveBeenCalled() +}) + +test('the interface type defaults to mouse when none is supplied', () => { + const map = createFakeMap() + const container = createContainer() + const input = createDrawInput({ + drawInteraction: { getMap: () => map }, + options: { container, addVertexButtonId: 'add-pt', mapProvider: {}, snap: null, onUndo: jest.fn() } + }) + liveInputs.push(input) + expect(input.getInterfaceType()).toBe('mouse') +}) + test('pointer moves and map pans update the rubber band except for the mouse interface', () => { const { container, view, placement } = setup('touch') container.dispatchEvent(new Event('pointermove')) diff --git a/plugins/beta/draw/src/adapters/openlayers/draw/vertexPlacement.test.js b/plugins/beta/draw/src/adapters/openlayers/draw/vertexPlacement.test.js index 6dd0e6df5..de4e0c13d 100644 --- a/plugins/beta/draw/src/adapters/openlayers/draw/vertexPlacement.test.js +++ b/plugins/beta/draw/src/adapters/openlayers/draw/vertexPlacement.test.js @@ -81,6 +81,28 @@ describe('placing vertices', () => { expect(drawInteraction.finishDrawing).toHaveBeenCalled() }) + test('the first vertex of a fresh sketch appends — nothing is committed yet', () => { + const { placement, drawInteraction, startSketch } = setup() + startSketch(lineFeature([[0, 0]])) // only the rubber-band coord, no committed vertex + placement.placeVertex() + expect(drawInteraction.appendCoordinates).toHaveBeenCalledWith([CENTER]) + }) + + test('an unprojectable committed vertex skips the duplicate-tolerance check', () => { + const { placement, drawInteraction, startSketch } = setup() + startSketch(lineFeature([[0, 0], [5, 6], [50, 50]])) + drawInteraction.getMap().getPixelFromCoordinate = () => null // mid view transition + placement.placeVertex() + expect(drawInteraction.appendCoordinates).toHaveBeenCalledWith([CENTER]) + }) + + test('placing into an empty polygon sketch (no ring yet) appends without error', () => { + const { placement, drawInteraction, startSketch } = setup() + startSketch(new Feature(new Polygon([]))) // ring not created yet + placement.placeVertex() + expect(drawInteraction.appendCoordinates).toHaveBeenCalledWith([CENTER]) + }) + test('clearLastCoord and sketch end/abort reset the duplicate bookkeeping', () => { const { placement, drawInteraction, startSketch, bus } = setup() startSketch(lineFeature([[0, 0], [50, 50]])) From e3d19382887fe66dee4ee0d70915dc60717fe20e Mon Sep 17 00:00:00 2001 From: Dan Leech Date: Mon, 6 Jul 2026 11:30:10 +0100 Subject: [PATCH 41/89] Remaining partial test coverage added --- .../adapters/openlayers/core/OLDrawManager.test.js | 11 +++++++++++ .../beta/draw/src/adapters/openlayers/olDraw.test.js | 11 +++++++++++ 2 files changed, 22 insertions(+) diff --git a/plugins/beta/draw/src/adapters/openlayers/core/OLDrawManager.test.js b/plugins/beta/draw/src/adapters/openlayers/core/OLDrawManager.test.js index 82e3166e3..e1bdbb852 100644 --- a/plugins/beta/draw/src/adapters/openlayers/core/OLDrawManager.test.js +++ b/plugins/beta/draw/src/adapters/openlayers/core/OLDrawManager.test.js @@ -104,6 +104,17 @@ test('undo stack changes are published on the adapter bus; off unsubscribes', () expect(onUndoChange).toHaveBeenCalledTimes(1) }) +test('several handlers can subscribe to the same event type and all fire', () => { + const { manager } = setup() + const h1 = jest.fn() + const h2 = jest.fn() + manager.on(ADAPTER_EVENTS.UNDO_CHANGE, h1) + manager.on(ADAPTER_EVENTS.UNDO_CHANGE, h2) // same type reuses the existing handler set + manager.undoStack.push({ type: 'draw_vertex' }) + expect(h1).toHaveBeenCalledWith(1) + expect(h2).toHaveBeenCalledWith(1) +}) + test('feature store delegation works with GeoJSON in and out', () => { const { manager } = setup() manager.add(geojson) diff --git a/plugins/beta/draw/src/adapters/openlayers/olDraw.test.js b/plugins/beta/draw/src/adapters/openlayers/olDraw.test.js index eff4115e6..472f9bfd3 100644 --- a/plugins/beta/draw/src/adapters/openlayers/olDraw.test.js +++ b/plugins/beta/draw/src/adapters/openlayers/olDraw.test.js @@ -43,6 +43,17 @@ test('map size changes update the draw UI scale, defaulting to 1 for unknown siz expect(mapProvider.drawScale).toBe(1) }) +test('pluginConfig and mapStyle are optional, defaulting to {} and no initial style', () => { + const eventBus = { on: jest.fn(), off: jest.fn() } + const mapProvider = { map: { id: 'ol-map' } } + const olDraw = createOLDraw({ mapProvider, events, eventBus }) // no pluginConfig, no mapStyle + const manager = OLDrawManager.mock.instances.at(-1) + expect(OLDrawManager).toHaveBeenCalledWith(mapProvider.map, {}) + expect(manager.setMapStyle).not.toHaveBeenCalled() + expect(mapProvider.draw).toBe(manager) + olDraw.remove() +}) + test('map style changes are forwarded to the manager', () => { const { eventBus, manager } = setup() eventBus.emit(events.MAP_SET_STYLE, { id: 'dark' }) From 399cf9aee87c40ce591b11d6c7528a2d43001db3 Mon Sep 17 00:00:00 2001 From: Dan Leech Date: Mon, 6 Jul 2026 11:51:38 +0100 Subject: [PATCH 42/89] Draw plugin moved out of beta folder --- demo/js/draw-ol.js | 2 +- demo/js/draw.js | 2 +- plugins/{beta => }/draw/src/DrawInit.jsx | 2 +- plugins/{beta => }/draw/src/DrawInit.test.jsx | 2 +- plugins/{beta => }/draw/src/adapterEvents.js | 0 plugins/{beta => }/draw/src/adapterEvents.test.js | 0 plugins/{beta => }/draw/src/adapters/loadDrawAdapter.js | 0 plugins/{beta => }/draw/src/adapters/loadDrawAdapter.test.js | 0 .../draw/src/adapters/maplibre/MaplibreDrawAdapter.js | 0 .../draw/src/adapters/maplibre/MaplibreDrawAdapter.test.js | 0 plugins/{beta => }/draw/src/adapters/maplibre/defaults.js | 0 plugins/{beta => }/draw/src/adapters/maplibre/drawEvents.js | 0 plugins/{beta => }/draw/src/adapters/maplibre/mapboxDraw.js | 0 .../{beta => }/draw/src/adapters/maplibre/mapboxDraw.test.js | 0 plugins/{beta => }/draw/src/adapters/maplibre/mapboxSnap.js | 0 .../{beta => }/draw/src/adapters/maplibre/mapboxSnap.test.js | 0 .../draw/src/adapters/maplibre/modes/createDrawMode.js | 0 .../draw/src/adapters/maplibre/modes/createDrawMode.test.js | 0 .../draw/src/adapters/maplibre/modes/disabledMode.js | 0 .../draw/src/adapters/maplibre/modes/disabledMode.test.js | 0 .../draw/src/adapters/maplibre/modes/drawLineMode.js | 0 .../draw/src/adapters/maplibre/modes/drawLineMode.test.js | 0 .../adapters/maplibre/modes/drawMode/__helpers__/harness.js | 4 ++-- .../src/adapters/maplibre/modes/drawMode/clickHandlers.js | 0 .../adapters/maplibre/modes/drawMode/clickHandlers.test.js | 0 .../src/adapters/maplibre/modes/drawMode/keyboardHandlers.js | 0 .../adapters/maplibre/modes/drawMode/keyboardHandlers.test.js | 0 .../draw/src/adapters/maplibre/modes/drawMode/lifecycle.js | 0 .../src/adapters/maplibre/modes/drawMode/lifecycle.test.js | 0 .../src/adapters/maplibre/modes/drawMode/pointerHandlers.js | 0 .../adapters/maplibre/modes/drawMode/pointerHandlers.test.js | 0 .../src/adapters/maplibre/modes/drawMode/renderHelpers.js | 0 .../adapters/maplibre/modes/drawMode/renderHelpers.test.js | 0 .../draw/src/adapters/maplibre/modes/drawMode/undoHandlers.js | 0 .../src/adapters/maplibre/modes/drawMode/undoHandlers.test.js | 0 .../draw/src/adapters/maplibre/modes/drawPolygonMode.js | 0 .../draw/src/adapters/maplibre/modes/drawPolygonMode.test.js | 0 .../draw/src/adapters/maplibre/modes/editVertexMode.js | 0 .../draw/src/adapters/maplibre/modes/editVertexMode.test.js | 0 .../maplibre/modes/editVertexMode/__helpers__/harness.js | 4 ++-- .../adapters/maplibre/modes/editVertexMode/geometryHelpers.js | 0 .../maplibre/modes/editVertexMode/geometryHelpers.test.js | 0 .../src/adapters/maplibre/modes/editVertexMode/helpers.js | 0 .../adapters/maplibre/modes/editVertexMode/helpers.test.js | 0 .../maplibre/modes/editVertexMode/keyboardHandlers.js | 0 .../maplibre/modes/editVertexMode/keyboardHandlers.test.js | 0 .../adapters/maplibre/modes/editVertexMode/pointerHandlers.js | 2 +- .../maplibre/modes/editVertexMode/pointerHandlers.test.js | 0 .../adapters/maplibre/modes/editVertexMode/touchHandlers.js | 0 .../maplibre/modes/editVertexMode/touchHandlers.test.js | 0 .../adapters/maplibre/modes/editVertexMode/undoHandlers.js | 0 .../maplibre/modes/editVertexMode/undoHandlers.test.js | 0 .../maplibre/modes/editVertexMode/vertexOperations.js | 0 .../maplibre/modes/editVertexMode/vertexOperations.test.js | 0 .../adapters/maplibre/modes/editVertexMode/vertexQueries.js | 0 .../maplibre/modes/editVertexMode/vertexQueries.test.js | 0 .../{beta => }/draw/src/adapters/maplibre/snap/constants.js | 0 .../{beta => }/draw/src/adapters/maplibre/snap/mapHandlers.js | 0 .../draw/src/adapters/maplibre/snap/mapHandlers.test.js | 0 .../draw/src/adapters/maplibre/snap/prototypePatches.js | 0 .../draw/src/adapters/maplibre/snap/prototypePatches.test.js | 0 .../draw/src/adapters/maplibre/snap/snapInstance.js | 0 .../draw/src/adapters/maplibre/snap/snapInstance.test.js | 0 .../{beta => }/draw/src/adapters/maplibre/snap/sourceData.js | 0 .../draw/src/adapters/maplibre/snap/sourceData.test.js | 0 plugins/{beta => }/draw/src/adapters/maplibre/styles.js | 0 plugins/{beta => }/draw/src/adapters/maplibre/styles.test.js | 0 .../draw/src/adapters/maplibre/utils/snapHelpers.js | 0 .../draw/src/adapters/maplibre/utils/snapHelpers.test.js | 0 .../draw/src/adapters/maplibre/utils/touchClickWorkaround.js | 0 .../src/adapters/maplibre/utils/touchClickWorkaround.test.js | 0 .../{beta => }/draw/src/adapters/openlayers/OLDrawAdapter.js | 0 .../draw/src/adapters/openlayers/OLDrawAdapter.test.js | 0 .../draw/src/adapters/openlayers/__helpers__/harness.js | 0 .../draw/src/adapters/openlayers/core/OLDrawManager.js | 0 .../draw/src/adapters/openlayers/core/OLDrawManager.test.js | 0 .../draw/src/adapters/openlayers/core/featureStore.js | 0 .../draw/src/adapters/openlayers/core/featureStore.test.js | 0 .../draw/src/adapters/openlayers/core/internalEvents.js | 0 .../{beta => }/draw/src/adapters/openlayers/core/styles.js | 0 .../draw/src/adapters/openlayers/core/styles.test.js | 0 plugins/{beta => }/draw/src/adapters/openlayers/defaults.js | 0 .../{beta => }/draw/src/adapters/openlayers/draw/DrawMode.js | 0 .../draw/src/adapters/openlayers/draw/DrawMode.test.js | 0 .../{beta => }/draw/src/adapters/openlayers/draw/drawInput.js | 0 .../draw/src/adapters/openlayers/draw/drawInput.test.js | 0 .../draw/src/adapters/openlayers/draw/vertexPlacement.js | 0 .../draw/src/adapters/openlayers/draw/vertexPlacement.test.js | 0 .../{beta => }/draw/src/adapters/openlayers/edit/EditMode.js | 0 .../draw/src/adapters/openlayers/edit/EditMode.test.js | 0 .../draw/src/adapters/openlayers/edit/activeVertexLayer.js | 0 .../src/adapters/openlayers/edit/activeVertexLayer.test.js | 0 .../draw/src/adapters/openlayers/edit/keyboardHandler.js | 0 .../draw/src/adapters/openlayers/edit/keyboardHandler.test.js | 0 .../draw/src/adapters/openlayers/edit/midpointLayer.js | 0 .../draw/src/adapters/openlayers/edit/midpointLayer.test.js | 0 .../draw/src/adapters/openlayers/edit/modifyInteraction.js | 0 .../src/adapters/openlayers/edit/modifyInteraction.test.js | 0 plugins/{beta => }/draw/src/adapters/openlayers/edit/nudge.js | 0 .../draw/src/adapters/openlayers/edit/nudge.test.js | 0 .../draw/src/adapters/openlayers/edit/pointerHandlers.js | 0 .../draw/src/adapters/openlayers/edit/pointerHandlers.test.js | 0 .../draw/src/adapters/openlayers/edit/selectionState.js | 0 .../draw/src/adapters/openlayers/edit/selectionState.test.js | 0 .../draw/src/adapters/openlayers/edit/touchHandler.js | 0 .../draw/src/adapters/openlayers/edit/touchHandler.test.js | 0 .../{beta => }/draw/src/adapters/openlayers/edit/undoOps.js | 0 .../draw/src/adapters/openlayers/edit/undoOps.test.js | 0 .../draw/src/adapters/openlayers/edit/vertexHitTest.js | 0 .../draw/src/adapters/openlayers/edit/vertexHitTest.test.js | 0 .../draw/src/adapters/openlayers/edit/vertexLayer.js | 0 .../draw/src/adapters/openlayers/edit/vertexLayer.test.js | 0 .../{beta => }/draw/src/adapters/openlayers/edit/vertexOps.js | 0 .../draw/src/adapters/openlayers/edit/vertexOps.test.js | 0 plugins/{beta => }/draw/src/adapters/openlayers/olDraw.js | 0 .../{beta => }/draw/src/adapters/openlayers/olDraw.test.js | 0 .../draw/src/adapters/openlayers/snap/snapEngine.js | 0 .../draw/src/adapters/openlayers/snap/snapEngine.test.js | 0 .../draw/src/adapters/openlayers/snap/snapGeometry.js | 0 .../draw/src/adapters/openlayers/snap/snapGeometry.test.js | 0 .../draw/src/adapters/openlayers/snap/snapIndicator.js | 0 .../draw/src/adapters/openlayers/snap/snapIndicator.test.js | 0 .../draw/src/adapters/openlayers/snap/snapInteraction.js | 0 .../draw/src/adapters/openlayers/snap/snapInteraction.test.js | 0 .../draw/src/adapters/openlayers/snap/snapManager.js | 0 .../draw/src/adapters/openlayers/snap/snapManager.test.js | 0 .../draw/src/adapters/openlayers/utils/geometryHelpers.js | 0 .../src/adapters/openlayers/utils/geometryHelpers.test.js | 0 .../{beta => }/draw/src/adapters/openlayers/utils/olCoords.js | 0 .../draw/src/adapters/openlayers/utils/olCoords.test.js | 0 .../draw/src/adapters/openlayers/utils/resolveColors.js | 0 .../draw/src/adapters/openlayers/utils/resolveColors.test.js | 0 .../draw/src/adapters/openlayers/utils/sketchHelpers.js | 0 .../draw/src/adapters/openlayers/utils/sketchHelpers.test.js | 0 .../draw/src/adapters/openlayers/utils/touchTarget.js | 0 plugins/{beta => }/draw/src/api/addFeature.js | 0 plugins/{beta => }/draw/src/api/addFeature.test.js | 0 plugins/{beta => }/draw/src/api/deleteFeature.js | 0 plugins/{beta => }/draw/src/api/deleteFeature.test.js | 0 plugins/{beta => }/draw/src/api/editFeature.js | 0 plugins/{beta => }/draw/src/api/editFeature.test.js | 0 plugins/{beta => }/draw/src/api/merge.js | 0 plugins/{beta => }/draw/src/api/merge.test.js | 0 plugins/{beta => }/draw/src/api/newLine.js | 0 plugins/{beta => }/draw/src/api/newLine.test.js | 0 plugins/{beta => }/draw/src/api/newPolygon.js | 0 plugins/{beta => }/draw/src/api/newPolygon.test.js | 0 plugins/{beta => }/draw/src/api/split.js | 0 plugins/{beta => }/draw/src/api/split.test.js | 0 plugins/{beta => }/draw/src/defaults.js | 0 plugins/{beta => }/draw/src/defaults.test.js | 0 plugins/{beta => }/draw/src/draw.scss | 0 plugins/{beta => }/draw/src/events.js | 0 plugins/{beta => }/draw/src/events.test.js | 0 plugins/{beta => }/draw/src/index.js | 0 plugins/{beta => }/draw/src/index.test.js | 0 plugins/{beta => }/draw/src/manifest.js | 0 plugins/{beta => }/draw/src/manifest.test.js | 0 plugins/{beta => }/draw/src/reducer.js | 0 plugins/{beta => }/draw/src/reducer.test.js | 0 plugins/{beta => }/draw/src/utils/debounce.js | 0 plugins/{beta => }/draw/src/utils/debounce.test.js | 0 plugins/{beta => }/draw/src/utils/eventBus.js | 0 plugins/{beta => }/draw/src/utils/eventBus.test.js | 0 plugins/{beta => }/draw/src/utils/flattenStyleProperties.js | 0 .../{beta => }/draw/src/utils/flattenStyleProperties.test.js | 0 plugins/{beta => }/draw/src/utils/getValueForStyle.js | 0 plugins/{beta => }/draw/src/utils/getValueForStyle.test.js | 0 plugins/{beta => }/draw/src/utils/spatial.js | 0 plugins/{beta => }/draw/src/utils/spatial.test.js | 0 plugins/{beta => }/draw/src/utils/touchTarget.js | 0 plugins/{beta => }/draw/src/utils/touchTarget.test.js | 0 plugins/{beta => }/draw/src/utils/undoStack.js | 0 plugins/{beta => }/draw/src/utils/undoStack.test.js | 0 rollup.esm.mjs | 4 ++-- webpack.umd.mjs | 2 +- 176 files changed, 12 insertions(+), 12 deletions(-) rename plugins/{beta => }/draw/src/DrawInit.jsx (97%) rename plugins/{beta => }/draw/src/DrawInit.test.jsx (99%) rename plugins/{beta => }/draw/src/adapterEvents.js (100%) rename plugins/{beta => }/draw/src/adapterEvents.test.js (100%) rename plugins/{beta => }/draw/src/adapters/loadDrawAdapter.js (100%) rename plugins/{beta => }/draw/src/adapters/loadDrawAdapter.test.js (100%) rename plugins/{beta => }/draw/src/adapters/maplibre/MaplibreDrawAdapter.js (100%) rename plugins/{beta => }/draw/src/adapters/maplibre/MaplibreDrawAdapter.test.js (100%) rename plugins/{beta => }/draw/src/adapters/maplibre/defaults.js (100%) rename plugins/{beta => }/draw/src/adapters/maplibre/drawEvents.js (100%) rename plugins/{beta => }/draw/src/adapters/maplibre/mapboxDraw.js (100%) rename plugins/{beta => }/draw/src/adapters/maplibre/mapboxDraw.test.js (100%) rename plugins/{beta => }/draw/src/adapters/maplibre/mapboxSnap.js (100%) rename plugins/{beta => }/draw/src/adapters/maplibre/mapboxSnap.test.js (100%) rename plugins/{beta => }/draw/src/adapters/maplibre/modes/createDrawMode.js (100%) rename plugins/{beta => }/draw/src/adapters/maplibre/modes/createDrawMode.test.js (100%) rename plugins/{beta => }/draw/src/adapters/maplibre/modes/disabledMode.js (100%) rename plugins/{beta => }/draw/src/adapters/maplibre/modes/disabledMode.test.js (100%) rename plugins/{beta => }/draw/src/adapters/maplibre/modes/drawLineMode.js (100%) rename plugins/{beta => }/draw/src/adapters/maplibre/modes/drawLineMode.test.js (100%) rename plugins/{beta => }/draw/src/adapters/maplibre/modes/drawMode/__helpers__/harness.js (94%) rename plugins/{beta => }/draw/src/adapters/maplibre/modes/drawMode/clickHandlers.js (100%) rename plugins/{beta => }/draw/src/adapters/maplibre/modes/drawMode/clickHandlers.test.js (100%) rename plugins/{beta => }/draw/src/adapters/maplibre/modes/drawMode/keyboardHandlers.js (100%) rename plugins/{beta => }/draw/src/adapters/maplibre/modes/drawMode/keyboardHandlers.test.js (100%) rename plugins/{beta => }/draw/src/adapters/maplibre/modes/drawMode/lifecycle.js (100%) rename plugins/{beta => }/draw/src/adapters/maplibre/modes/drawMode/lifecycle.test.js (100%) rename plugins/{beta => }/draw/src/adapters/maplibre/modes/drawMode/pointerHandlers.js (100%) rename plugins/{beta => }/draw/src/adapters/maplibre/modes/drawMode/pointerHandlers.test.js (100%) rename plugins/{beta => }/draw/src/adapters/maplibre/modes/drawMode/renderHelpers.js (100%) rename plugins/{beta => }/draw/src/adapters/maplibre/modes/drawMode/renderHelpers.test.js (100%) rename plugins/{beta => }/draw/src/adapters/maplibre/modes/drawMode/undoHandlers.js (100%) rename plugins/{beta => }/draw/src/adapters/maplibre/modes/drawMode/undoHandlers.test.js (100%) rename plugins/{beta => }/draw/src/adapters/maplibre/modes/drawPolygonMode.js (100%) rename plugins/{beta => }/draw/src/adapters/maplibre/modes/drawPolygonMode.test.js (100%) rename plugins/{beta => }/draw/src/adapters/maplibre/modes/editVertexMode.js (100%) rename plugins/{beta => }/draw/src/adapters/maplibre/modes/editVertexMode.test.js (100%) rename plugins/{beta => }/draw/src/adapters/maplibre/modes/editVertexMode/__helpers__/harness.js (94%) rename plugins/{beta => }/draw/src/adapters/maplibre/modes/editVertexMode/geometryHelpers.js (100%) rename plugins/{beta => }/draw/src/adapters/maplibre/modes/editVertexMode/geometryHelpers.test.js (100%) rename plugins/{beta => }/draw/src/adapters/maplibre/modes/editVertexMode/helpers.js (100%) rename plugins/{beta => }/draw/src/adapters/maplibre/modes/editVertexMode/helpers.test.js (100%) rename plugins/{beta => }/draw/src/adapters/maplibre/modes/editVertexMode/keyboardHandlers.js (100%) rename plugins/{beta => }/draw/src/adapters/maplibre/modes/editVertexMode/keyboardHandlers.test.js (100%) rename plugins/{beta => }/draw/src/adapters/maplibre/modes/editVertexMode/pointerHandlers.js (97%) rename plugins/{beta => }/draw/src/adapters/maplibre/modes/editVertexMode/pointerHandlers.test.js (100%) rename plugins/{beta => }/draw/src/adapters/maplibre/modes/editVertexMode/touchHandlers.js (100%) rename plugins/{beta => }/draw/src/adapters/maplibre/modes/editVertexMode/touchHandlers.test.js (100%) rename plugins/{beta => }/draw/src/adapters/maplibre/modes/editVertexMode/undoHandlers.js (100%) rename plugins/{beta => }/draw/src/adapters/maplibre/modes/editVertexMode/undoHandlers.test.js (100%) rename plugins/{beta => }/draw/src/adapters/maplibre/modes/editVertexMode/vertexOperations.js (100%) rename plugins/{beta => }/draw/src/adapters/maplibre/modes/editVertexMode/vertexOperations.test.js (100%) rename plugins/{beta => }/draw/src/adapters/maplibre/modes/editVertexMode/vertexQueries.js (100%) rename plugins/{beta => }/draw/src/adapters/maplibre/modes/editVertexMode/vertexQueries.test.js (100%) rename plugins/{beta => }/draw/src/adapters/maplibre/snap/constants.js (100%) rename plugins/{beta => }/draw/src/adapters/maplibre/snap/mapHandlers.js (100%) rename plugins/{beta => }/draw/src/adapters/maplibre/snap/mapHandlers.test.js (100%) rename plugins/{beta => }/draw/src/adapters/maplibre/snap/prototypePatches.js (100%) rename plugins/{beta => }/draw/src/adapters/maplibre/snap/prototypePatches.test.js (100%) rename plugins/{beta => }/draw/src/adapters/maplibre/snap/snapInstance.js (100%) rename plugins/{beta => }/draw/src/adapters/maplibre/snap/snapInstance.test.js (100%) rename plugins/{beta => }/draw/src/adapters/maplibre/snap/sourceData.js (100%) rename plugins/{beta => }/draw/src/adapters/maplibre/snap/sourceData.test.js (100%) rename plugins/{beta => }/draw/src/adapters/maplibre/styles.js (100%) rename plugins/{beta => }/draw/src/adapters/maplibre/styles.test.js (100%) rename plugins/{beta => }/draw/src/adapters/maplibre/utils/snapHelpers.js (100%) rename plugins/{beta => }/draw/src/adapters/maplibre/utils/snapHelpers.test.js (100%) rename plugins/{beta => }/draw/src/adapters/maplibre/utils/touchClickWorkaround.js (100%) rename plugins/{beta => }/draw/src/adapters/maplibre/utils/touchClickWorkaround.test.js (100%) rename plugins/{beta => }/draw/src/adapters/openlayers/OLDrawAdapter.js (100%) rename plugins/{beta => }/draw/src/adapters/openlayers/OLDrawAdapter.test.js (100%) rename plugins/{beta => }/draw/src/adapters/openlayers/__helpers__/harness.js (100%) rename plugins/{beta => }/draw/src/adapters/openlayers/core/OLDrawManager.js (100%) rename plugins/{beta => }/draw/src/adapters/openlayers/core/OLDrawManager.test.js (100%) rename plugins/{beta => }/draw/src/adapters/openlayers/core/featureStore.js (100%) rename plugins/{beta => }/draw/src/adapters/openlayers/core/featureStore.test.js (100%) rename plugins/{beta => }/draw/src/adapters/openlayers/core/internalEvents.js (100%) rename plugins/{beta => }/draw/src/adapters/openlayers/core/styles.js (100%) rename plugins/{beta => }/draw/src/adapters/openlayers/core/styles.test.js (100%) rename plugins/{beta => }/draw/src/adapters/openlayers/defaults.js (100%) rename plugins/{beta => }/draw/src/adapters/openlayers/draw/DrawMode.js (100%) rename plugins/{beta => }/draw/src/adapters/openlayers/draw/DrawMode.test.js (100%) rename plugins/{beta => }/draw/src/adapters/openlayers/draw/drawInput.js (100%) rename plugins/{beta => }/draw/src/adapters/openlayers/draw/drawInput.test.js (100%) rename plugins/{beta => }/draw/src/adapters/openlayers/draw/vertexPlacement.js (100%) rename plugins/{beta => }/draw/src/adapters/openlayers/draw/vertexPlacement.test.js (100%) rename plugins/{beta => }/draw/src/adapters/openlayers/edit/EditMode.js (100%) rename plugins/{beta => }/draw/src/adapters/openlayers/edit/EditMode.test.js (100%) rename plugins/{beta => }/draw/src/adapters/openlayers/edit/activeVertexLayer.js (100%) rename plugins/{beta => }/draw/src/adapters/openlayers/edit/activeVertexLayer.test.js (100%) rename plugins/{beta => }/draw/src/adapters/openlayers/edit/keyboardHandler.js (100%) rename plugins/{beta => }/draw/src/adapters/openlayers/edit/keyboardHandler.test.js (100%) rename plugins/{beta => }/draw/src/adapters/openlayers/edit/midpointLayer.js (100%) rename plugins/{beta => }/draw/src/adapters/openlayers/edit/midpointLayer.test.js (100%) rename plugins/{beta => }/draw/src/adapters/openlayers/edit/modifyInteraction.js (100%) rename plugins/{beta => }/draw/src/adapters/openlayers/edit/modifyInteraction.test.js (100%) rename plugins/{beta => }/draw/src/adapters/openlayers/edit/nudge.js (100%) rename plugins/{beta => }/draw/src/adapters/openlayers/edit/nudge.test.js (100%) rename plugins/{beta => }/draw/src/adapters/openlayers/edit/pointerHandlers.js (100%) rename plugins/{beta => }/draw/src/adapters/openlayers/edit/pointerHandlers.test.js (100%) rename plugins/{beta => }/draw/src/adapters/openlayers/edit/selectionState.js (100%) rename plugins/{beta => }/draw/src/adapters/openlayers/edit/selectionState.test.js (100%) rename plugins/{beta => }/draw/src/adapters/openlayers/edit/touchHandler.js (100%) rename plugins/{beta => }/draw/src/adapters/openlayers/edit/touchHandler.test.js (100%) rename plugins/{beta => }/draw/src/adapters/openlayers/edit/undoOps.js (100%) rename plugins/{beta => }/draw/src/adapters/openlayers/edit/undoOps.test.js (100%) rename plugins/{beta => }/draw/src/adapters/openlayers/edit/vertexHitTest.js (100%) rename plugins/{beta => }/draw/src/adapters/openlayers/edit/vertexHitTest.test.js (100%) rename plugins/{beta => }/draw/src/adapters/openlayers/edit/vertexLayer.js (100%) rename plugins/{beta => }/draw/src/adapters/openlayers/edit/vertexLayer.test.js (100%) rename plugins/{beta => }/draw/src/adapters/openlayers/edit/vertexOps.js (100%) rename plugins/{beta => }/draw/src/adapters/openlayers/edit/vertexOps.test.js (100%) rename plugins/{beta => }/draw/src/adapters/openlayers/olDraw.js (100%) rename plugins/{beta => }/draw/src/adapters/openlayers/olDraw.test.js (100%) rename plugins/{beta => }/draw/src/adapters/openlayers/snap/snapEngine.js (100%) rename plugins/{beta => }/draw/src/adapters/openlayers/snap/snapEngine.test.js (100%) rename plugins/{beta => }/draw/src/adapters/openlayers/snap/snapGeometry.js (100%) rename plugins/{beta => }/draw/src/adapters/openlayers/snap/snapGeometry.test.js (100%) rename plugins/{beta => }/draw/src/adapters/openlayers/snap/snapIndicator.js (100%) rename plugins/{beta => }/draw/src/adapters/openlayers/snap/snapIndicator.test.js (100%) rename plugins/{beta => }/draw/src/adapters/openlayers/snap/snapInteraction.js (100%) rename plugins/{beta => }/draw/src/adapters/openlayers/snap/snapInteraction.test.js (100%) rename plugins/{beta => }/draw/src/adapters/openlayers/snap/snapManager.js (100%) rename plugins/{beta => }/draw/src/adapters/openlayers/snap/snapManager.test.js (100%) rename plugins/{beta => }/draw/src/adapters/openlayers/utils/geometryHelpers.js (100%) rename plugins/{beta => }/draw/src/adapters/openlayers/utils/geometryHelpers.test.js (100%) rename plugins/{beta => }/draw/src/adapters/openlayers/utils/olCoords.js (100%) rename plugins/{beta => }/draw/src/adapters/openlayers/utils/olCoords.test.js (100%) rename plugins/{beta => }/draw/src/adapters/openlayers/utils/resolveColors.js (100%) rename plugins/{beta => }/draw/src/adapters/openlayers/utils/resolveColors.test.js (100%) rename plugins/{beta => }/draw/src/adapters/openlayers/utils/sketchHelpers.js (100%) rename plugins/{beta => }/draw/src/adapters/openlayers/utils/sketchHelpers.test.js (100%) rename plugins/{beta => }/draw/src/adapters/openlayers/utils/touchTarget.js (100%) rename plugins/{beta => }/draw/src/api/addFeature.js (100%) rename plugins/{beta => }/draw/src/api/addFeature.test.js (100%) rename plugins/{beta => }/draw/src/api/deleteFeature.js (100%) rename plugins/{beta => }/draw/src/api/deleteFeature.test.js (100%) rename plugins/{beta => }/draw/src/api/editFeature.js (100%) rename plugins/{beta => }/draw/src/api/editFeature.test.js (100%) rename plugins/{beta => }/draw/src/api/merge.js (100%) rename plugins/{beta => }/draw/src/api/merge.test.js (100%) rename plugins/{beta => }/draw/src/api/newLine.js (100%) rename plugins/{beta => }/draw/src/api/newLine.test.js (100%) rename plugins/{beta => }/draw/src/api/newPolygon.js (100%) rename plugins/{beta => }/draw/src/api/newPolygon.test.js (100%) rename plugins/{beta => }/draw/src/api/split.js (100%) rename plugins/{beta => }/draw/src/api/split.test.js (100%) rename plugins/{beta => }/draw/src/defaults.js (100%) rename plugins/{beta => }/draw/src/defaults.test.js (100%) rename plugins/{beta => }/draw/src/draw.scss (100%) rename plugins/{beta => }/draw/src/events.js (100%) rename plugins/{beta => }/draw/src/events.test.js (100%) rename plugins/{beta => }/draw/src/index.js (100%) rename plugins/{beta => }/draw/src/index.test.js (100%) rename plugins/{beta => }/draw/src/manifest.js (100%) rename plugins/{beta => }/draw/src/manifest.test.js (100%) rename plugins/{beta => }/draw/src/reducer.js (100%) rename plugins/{beta => }/draw/src/reducer.test.js (100%) rename plugins/{beta => }/draw/src/utils/debounce.js (100%) rename plugins/{beta => }/draw/src/utils/debounce.test.js (100%) rename plugins/{beta => }/draw/src/utils/eventBus.js (100%) rename plugins/{beta => }/draw/src/utils/eventBus.test.js (100%) rename plugins/{beta => }/draw/src/utils/flattenStyleProperties.js (100%) rename plugins/{beta => }/draw/src/utils/flattenStyleProperties.test.js (100%) rename plugins/{beta => }/draw/src/utils/getValueForStyle.js (100%) rename plugins/{beta => }/draw/src/utils/getValueForStyle.test.js (100%) rename plugins/{beta => }/draw/src/utils/spatial.js (100%) rename plugins/{beta => }/draw/src/utils/spatial.test.js (100%) rename plugins/{beta => }/draw/src/utils/touchTarget.js (100%) rename plugins/{beta => }/draw/src/utils/touchTarget.test.js (100%) rename plugins/{beta => }/draw/src/utils/undoStack.js (100%) rename plugins/{beta => }/draw/src/utils/undoStack.test.js (100%) diff --git a/demo/js/draw-ol.js b/demo/js/draw-ol.js index 57dc6220e..94c71fb22 100644 --- a/demo/js/draw-ol.js +++ b/demo/js/draw-ol.js @@ -7,7 +7,7 @@ import openLayersProvider from '/providers/beta/openlayers/src/index.js' import openNamesProvider from '/providers/beta/open-names/src/index.js' // Plugins import mapStylesPlugin from '/plugins/beta/map-styles/src/index.js' -import createDrawPlugin from '/plugins/beta/draw/src/index.js' +import createDrawPlugin from '/plugins/draw/src/index.js' import searchPlugin from '/plugins/search/src/index.js' import createInteractPlugin from '/plugins/interact/src/index.js' diff --git a/demo/js/draw.js b/demo/js/draw.js index 67a2fa65b..3af47277a 100755 --- a/demo/js/draw.js +++ b/demo/js/draw.js @@ -9,7 +9,7 @@ import openNamesProvider from '/providers/beta/open-names/src/index.js' // Plugins import mapStylesPlugin from '/plugins/beta/map-styles/src/index.js' import createDatasetsPlugin from '/plugins/beta/datasets/src/index.js' -import createDrawPlugin from '/plugins/beta/draw/src/index.js' +import createDrawPlugin from '/plugins/draw/src/index.js' import scaleBarPlugin from '/plugins/beta/scale-bar/src/index.js' import searchPlugin from '/plugins/search/src/index.js' import createInteractPlugin from '/plugins/interact/src/index.js' diff --git a/plugins/beta/draw/src/DrawInit.jsx b/plugins/draw/src/DrawInit.jsx similarity index 97% rename from plugins/beta/draw/src/DrawInit.jsx rename to plugins/draw/src/DrawInit.jsx index ca07e55ed..8342f06ec 100644 --- a/plugins/beta/draw/src/DrawInit.jsx +++ b/plugins/draw/src/DrawInit.jsx @@ -1,5 +1,5 @@ import { useEffect } from 'react' -import { EVENTS } from '../../../../src/config/events.js' +import { EVENTS } from '../../../src/config/events.js' import { loadDrawAdapter } from './adapters/loadDrawAdapter.js' import { attachEvents } from './events.js' diff --git a/plugins/beta/draw/src/DrawInit.test.jsx b/plugins/draw/src/DrawInit.test.jsx similarity index 99% rename from plugins/beta/draw/src/DrawInit.test.jsx rename to plugins/draw/src/DrawInit.test.jsx index 9e6b8b07b..1a7548b28 100644 --- a/plugins/beta/draw/src/DrawInit.test.jsx +++ b/plugins/draw/src/DrawInit.test.jsx @@ -1,5 +1,5 @@ import { render, act } from '@testing-library/react' -import { EVENTS } from '../../../../src/config/events.js' +import { EVENTS } from '../../../src/config/events.js' import { DrawInit } from './DrawInit.jsx' import { loadDrawAdapter } from './adapters/loadDrawAdapter.js' import { attachEvents } from './events.js' diff --git a/plugins/beta/draw/src/adapterEvents.js b/plugins/draw/src/adapterEvents.js similarity index 100% rename from plugins/beta/draw/src/adapterEvents.js rename to plugins/draw/src/adapterEvents.js diff --git a/plugins/beta/draw/src/adapterEvents.test.js b/plugins/draw/src/adapterEvents.test.js similarity index 100% rename from plugins/beta/draw/src/adapterEvents.test.js rename to plugins/draw/src/adapterEvents.test.js diff --git a/plugins/beta/draw/src/adapters/loadDrawAdapter.js b/plugins/draw/src/adapters/loadDrawAdapter.js similarity index 100% rename from plugins/beta/draw/src/adapters/loadDrawAdapter.js rename to plugins/draw/src/adapters/loadDrawAdapter.js diff --git a/plugins/beta/draw/src/adapters/loadDrawAdapter.test.js b/plugins/draw/src/adapters/loadDrawAdapter.test.js similarity index 100% rename from plugins/beta/draw/src/adapters/loadDrawAdapter.test.js rename to plugins/draw/src/adapters/loadDrawAdapter.test.js diff --git a/plugins/beta/draw/src/adapters/maplibre/MaplibreDrawAdapter.js b/plugins/draw/src/adapters/maplibre/MaplibreDrawAdapter.js similarity index 100% rename from plugins/beta/draw/src/adapters/maplibre/MaplibreDrawAdapter.js rename to plugins/draw/src/adapters/maplibre/MaplibreDrawAdapter.js diff --git a/plugins/beta/draw/src/adapters/maplibre/MaplibreDrawAdapter.test.js b/plugins/draw/src/adapters/maplibre/MaplibreDrawAdapter.test.js similarity index 100% rename from plugins/beta/draw/src/adapters/maplibre/MaplibreDrawAdapter.test.js rename to plugins/draw/src/adapters/maplibre/MaplibreDrawAdapter.test.js diff --git a/plugins/beta/draw/src/adapters/maplibre/defaults.js b/plugins/draw/src/adapters/maplibre/defaults.js similarity index 100% rename from plugins/beta/draw/src/adapters/maplibre/defaults.js rename to plugins/draw/src/adapters/maplibre/defaults.js diff --git a/plugins/beta/draw/src/adapters/maplibre/drawEvents.js b/plugins/draw/src/adapters/maplibre/drawEvents.js similarity index 100% rename from plugins/beta/draw/src/adapters/maplibre/drawEvents.js rename to plugins/draw/src/adapters/maplibre/drawEvents.js diff --git a/plugins/beta/draw/src/adapters/maplibre/mapboxDraw.js b/plugins/draw/src/adapters/maplibre/mapboxDraw.js similarity index 100% rename from plugins/beta/draw/src/adapters/maplibre/mapboxDraw.js rename to plugins/draw/src/adapters/maplibre/mapboxDraw.js diff --git a/plugins/beta/draw/src/adapters/maplibre/mapboxDraw.test.js b/plugins/draw/src/adapters/maplibre/mapboxDraw.test.js similarity index 100% rename from plugins/beta/draw/src/adapters/maplibre/mapboxDraw.test.js rename to plugins/draw/src/adapters/maplibre/mapboxDraw.test.js diff --git a/plugins/beta/draw/src/adapters/maplibre/mapboxSnap.js b/plugins/draw/src/adapters/maplibre/mapboxSnap.js similarity index 100% rename from plugins/beta/draw/src/adapters/maplibre/mapboxSnap.js rename to plugins/draw/src/adapters/maplibre/mapboxSnap.js diff --git a/plugins/beta/draw/src/adapters/maplibre/mapboxSnap.test.js b/plugins/draw/src/adapters/maplibre/mapboxSnap.test.js similarity index 100% rename from plugins/beta/draw/src/adapters/maplibre/mapboxSnap.test.js rename to plugins/draw/src/adapters/maplibre/mapboxSnap.test.js diff --git a/plugins/beta/draw/src/adapters/maplibre/modes/createDrawMode.js b/plugins/draw/src/adapters/maplibre/modes/createDrawMode.js similarity index 100% rename from plugins/beta/draw/src/adapters/maplibre/modes/createDrawMode.js rename to plugins/draw/src/adapters/maplibre/modes/createDrawMode.js diff --git a/plugins/beta/draw/src/adapters/maplibre/modes/createDrawMode.test.js b/plugins/draw/src/adapters/maplibre/modes/createDrawMode.test.js similarity index 100% rename from plugins/beta/draw/src/adapters/maplibre/modes/createDrawMode.test.js rename to plugins/draw/src/adapters/maplibre/modes/createDrawMode.test.js diff --git a/plugins/beta/draw/src/adapters/maplibre/modes/disabledMode.js b/plugins/draw/src/adapters/maplibre/modes/disabledMode.js similarity index 100% rename from plugins/beta/draw/src/adapters/maplibre/modes/disabledMode.js rename to plugins/draw/src/adapters/maplibre/modes/disabledMode.js diff --git a/plugins/beta/draw/src/adapters/maplibre/modes/disabledMode.test.js b/plugins/draw/src/adapters/maplibre/modes/disabledMode.test.js similarity index 100% rename from plugins/beta/draw/src/adapters/maplibre/modes/disabledMode.test.js rename to plugins/draw/src/adapters/maplibre/modes/disabledMode.test.js diff --git a/plugins/beta/draw/src/adapters/maplibre/modes/drawLineMode.js b/plugins/draw/src/adapters/maplibre/modes/drawLineMode.js similarity index 100% rename from plugins/beta/draw/src/adapters/maplibre/modes/drawLineMode.js rename to plugins/draw/src/adapters/maplibre/modes/drawLineMode.js diff --git a/plugins/beta/draw/src/adapters/maplibre/modes/drawLineMode.test.js b/plugins/draw/src/adapters/maplibre/modes/drawLineMode.test.js similarity index 100% rename from plugins/beta/draw/src/adapters/maplibre/modes/drawLineMode.test.js rename to plugins/draw/src/adapters/maplibre/modes/drawLineMode.test.js diff --git a/plugins/beta/draw/src/adapters/maplibre/modes/drawMode/__helpers__/harness.js b/plugins/draw/src/adapters/maplibre/modes/drawMode/__helpers__/harness.js similarity index 94% rename from plugins/beta/draw/src/adapters/maplibre/modes/drawMode/__helpers__/harness.js rename to plugins/draw/src/adapters/maplibre/modes/drawMode/__helpers__/harness.js index 183101e76..5c21e1ae8 100644 --- a/plugins/beta/draw/src/adapters/maplibre/modes/drawMode/__helpers__/harness.js +++ b/plugins/draw/src/adapters/maplibre/modes/drawMode/__helpers__/harness.js @@ -1,8 +1,8 @@ import { DrawPolygonMode } from '../../drawPolygonMode.js' import { DrawLineMode } from '../../drawLineMode.js' import { createUndoStack } from '../../../../../utils/undoStack.js' -import PolygonFeature from '../../../../../../../../../node_modules/@mapbox/mapbox-gl-draw/src/feature_types/polygon.js' -import LineStringFeature from '../../../../../../../../../node_modules/@mapbox/mapbox-gl-draw/src/feature_types/line_string.js' +import PolygonFeature from '../../../../../../../../node_modules/@mapbox/mapbox-gl-draw/src/feature_types/polygon.js' +import LineStringFeature from '../../../../../../../../node_modules/@mapbox/mapbox-gl-draw/src/feature_types/line_string.js' /** * Shared test harness for the createDrawMode handler modules. Exercises the real diff --git a/plugins/beta/draw/src/adapters/maplibre/modes/drawMode/clickHandlers.js b/plugins/draw/src/adapters/maplibre/modes/drawMode/clickHandlers.js similarity index 100% rename from plugins/beta/draw/src/adapters/maplibre/modes/drawMode/clickHandlers.js rename to plugins/draw/src/adapters/maplibre/modes/drawMode/clickHandlers.js diff --git a/plugins/beta/draw/src/adapters/maplibre/modes/drawMode/clickHandlers.test.js b/plugins/draw/src/adapters/maplibre/modes/drawMode/clickHandlers.test.js similarity index 100% rename from plugins/beta/draw/src/adapters/maplibre/modes/drawMode/clickHandlers.test.js rename to plugins/draw/src/adapters/maplibre/modes/drawMode/clickHandlers.test.js diff --git a/plugins/beta/draw/src/adapters/maplibre/modes/drawMode/keyboardHandlers.js b/plugins/draw/src/adapters/maplibre/modes/drawMode/keyboardHandlers.js similarity index 100% rename from plugins/beta/draw/src/adapters/maplibre/modes/drawMode/keyboardHandlers.js rename to plugins/draw/src/adapters/maplibre/modes/drawMode/keyboardHandlers.js diff --git a/plugins/beta/draw/src/adapters/maplibre/modes/drawMode/keyboardHandlers.test.js b/plugins/draw/src/adapters/maplibre/modes/drawMode/keyboardHandlers.test.js similarity index 100% rename from plugins/beta/draw/src/adapters/maplibre/modes/drawMode/keyboardHandlers.test.js rename to plugins/draw/src/adapters/maplibre/modes/drawMode/keyboardHandlers.test.js diff --git a/plugins/beta/draw/src/adapters/maplibre/modes/drawMode/lifecycle.js b/plugins/draw/src/adapters/maplibre/modes/drawMode/lifecycle.js similarity index 100% rename from plugins/beta/draw/src/adapters/maplibre/modes/drawMode/lifecycle.js rename to plugins/draw/src/adapters/maplibre/modes/drawMode/lifecycle.js diff --git a/plugins/beta/draw/src/adapters/maplibre/modes/drawMode/lifecycle.test.js b/plugins/draw/src/adapters/maplibre/modes/drawMode/lifecycle.test.js similarity index 100% rename from plugins/beta/draw/src/adapters/maplibre/modes/drawMode/lifecycle.test.js rename to plugins/draw/src/adapters/maplibre/modes/drawMode/lifecycle.test.js diff --git a/plugins/beta/draw/src/adapters/maplibre/modes/drawMode/pointerHandlers.js b/plugins/draw/src/adapters/maplibre/modes/drawMode/pointerHandlers.js similarity index 100% rename from plugins/beta/draw/src/adapters/maplibre/modes/drawMode/pointerHandlers.js rename to plugins/draw/src/adapters/maplibre/modes/drawMode/pointerHandlers.js diff --git a/plugins/beta/draw/src/adapters/maplibre/modes/drawMode/pointerHandlers.test.js b/plugins/draw/src/adapters/maplibre/modes/drawMode/pointerHandlers.test.js similarity index 100% rename from plugins/beta/draw/src/adapters/maplibre/modes/drawMode/pointerHandlers.test.js rename to plugins/draw/src/adapters/maplibre/modes/drawMode/pointerHandlers.test.js diff --git a/plugins/beta/draw/src/adapters/maplibre/modes/drawMode/renderHelpers.js b/plugins/draw/src/adapters/maplibre/modes/drawMode/renderHelpers.js similarity index 100% rename from plugins/beta/draw/src/adapters/maplibre/modes/drawMode/renderHelpers.js rename to plugins/draw/src/adapters/maplibre/modes/drawMode/renderHelpers.js diff --git a/plugins/beta/draw/src/adapters/maplibre/modes/drawMode/renderHelpers.test.js b/plugins/draw/src/adapters/maplibre/modes/drawMode/renderHelpers.test.js similarity index 100% rename from plugins/beta/draw/src/adapters/maplibre/modes/drawMode/renderHelpers.test.js rename to plugins/draw/src/adapters/maplibre/modes/drawMode/renderHelpers.test.js diff --git a/plugins/beta/draw/src/adapters/maplibre/modes/drawMode/undoHandlers.js b/plugins/draw/src/adapters/maplibre/modes/drawMode/undoHandlers.js similarity index 100% rename from plugins/beta/draw/src/adapters/maplibre/modes/drawMode/undoHandlers.js rename to plugins/draw/src/adapters/maplibre/modes/drawMode/undoHandlers.js diff --git a/plugins/beta/draw/src/adapters/maplibre/modes/drawMode/undoHandlers.test.js b/plugins/draw/src/adapters/maplibre/modes/drawMode/undoHandlers.test.js similarity index 100% rename from plugins/beta/draw/src/adapters/maplibre/modes/drawMode/undoHandlers.test.js rename to plugins/draw/src/adapters/maplibre/modes/drawMode/undoHandlers.test.js diff --git a/plugins/beta/draw/src/adapters/maplibre/modes/drawPolygonMode.js b/plugins/draw/src/adapters/maplibre/modes/drawPolygonMode.js similarity index 100% rename from plugins/beta/draw/src/adapters/maplibre/modes/drawPolygonMode.js rename to plugins/draw/src/adapters/maplibre/modes/drawPolygonMode.js diff --git a/plugins/beta/draw/src/adapters/maplibre/modes/drawPolygonMode.test.js b/plugins/draw/src/adapters/maplibre/modes/drawPolygonMode.test.js similarity index 100% rename from plugins/beta/draw/src/adapters/maplibre/modes/drawPolygonMode.test.js rename to plugins/draw/src/adapters/maplibre/modes/drawPolygonMode.test.js diff --git a/plugins/beta/draw/src/adapters/maplibre/modes/editVertexMode.js b/plugins/draw/src/adapters/maplibre/modes/editVertexMode.js similarity index 100% rename from plugins/beta/draw/src/adapters/maplibre/modes/editVertexMode.js rename to plugins/draw/src/adapters/maplibre/modes/editVertexMode.js diff --git a/plugins/beta/draw/src/adapters/maplibre/modes/editVertexMode.test.js b/plugins/draw/src/adapters/maplibre/modes/editVertexMode.test.js similarity index 100% rename from plugins/beta/draw/src/adapters/maplibre/modes/editVertexMode.test.js rename to plugins/draw/src/adapters/maplibre/modes/editVertexMode.test.js diff --git a/plugins/beta/draw/src/adapters/maplibre/modes/editVertexMode/__helpers__/harness.js b/plugins/draw/src/adapters/maplibre/modes/editVertexMode/__helpers__/harness.js similarity index 94% rename from plugins/beta/draw/src/adapters/maplibre/modes/editVertexMode/__helpers__/harness.js rename to plugins/draw/src/adapters/maplibre/modes/editVertexMode/__helpers__/harness.js index a9ef71f98..697bb0b5d 100644 --- a/plugins/beta/draw/src/adapters/maplibre/modes/editVertexMode/__helpers__/harness.js +++ b/plugins/draw/src/adapters/maplibre/modes/editVertexMode/__helpers__/harness.js @@ -1,7 +1,7 @@ import { EditVertexMode } from '../../editVertexMode.js' import { createUndoStack } from '../../../../../utils/undoStack.js' -import PolygonFeature from '../../../../../../../../../node_modules/@mapbox/mapbox-gl-draw/src/feature_types/polygon.js' -import LineStringFeature from '../../../../../../../../../node_modules/@mapbox/mapbox-gl-draw/src/feature_types/line_string.js' +import PolygonFeature from '../../../../../../../../node_modules/@mapbox/mapbox-gl-draw/src/feature_types/polygon.js' +import LineStringFeature from '../../../../../../../../node_modules/@mapbox/mapbox-gl-draw/src/feature_types/line_string.js' /** * Shared test harness for the vertex-edit mode and its handler modules. Builds the real diff --git a/plugins/beta/draw/src/adapters/maplibre/modes/editVertexMode/geometryHelpers.js b/plugins/draw/src/adapters/maplibre/modes/editVertexMode/geometryHelpers.js similarity index 100% rename from plugins/beta/draw/src/adapters/maplibre/modes/editVertexMode/geometryHelpers.js rename to plugins/draw/src/adapters/maplibre/modes/editVertexMode/geometryHelpers.js diff --git a/plugins/beta/draw/src/adapters/maplibre/modes/editVertexMode/geometryHelpers.test.js b/plugins/draw/src/adapters/maplibre/modes/editVertexMode/geometryHelpers.test.js similarity index 100% rename from plugins/beta/draw/src/adapters/maplibre/modes/editVertexMode/geometryHelpers.test.js rename to plugins/draw/src/adapters/maplibre/modes/editVertexMode/geometryHelpers.test.js diff --git a/plugins/beta/draw/src/adapters/maplibre/modes/editVertexMode/helpers.js b/plugins/draw/src/adapters/maplibre/modes/editVertexMode/helpers.js similarity index 100% rename from plugins/beta/draw/src/adapters/maplibre/modes/editVertexMode/helpers.js rename to plugins/draw/src/adapters/maplibre/modes/editVertexMode/helpers.js diff --git a/plugins/beta/draw/src/adapters/maplibre/modes/editVertexMode/helpers.test.js b/plugins/draw/src/adapters/maplibre/modes/editVertexMode/helpers.test.js similarity index 100% rename from plugins/beta/draw/src/adapters/maplibre/modes/editVertexMode/helpers.test.js rename to plugins/draw/src/adapters/maplibre/modes/editVertexMode/helpers.test.js diff --git a/plugins/beta/draw/src/adapters/maplibre/modes/editVertexMode/keyboardHandlers.js b/plugins/draw/src/adapters/maplibre/modes/editVertexMode/keyboardHandlers.js similarity index 100% rename from plugins/beta/draw/src/adapters/maplibre/modes/editVertexMode/keyboardHandlers.js rename to plugins/draw/src/adapters/maplibre/modes/editVertexMode/keyboardHandlers.js diff --git a/plugins/beta/draw/src/adapters/maplibre/modes/editVertexMode/keyboardHandlers.test.js b/plugins/draw/src/adapters/maplibre/modes/editVertexMode/keyboardHandlers.test.js similarity index 100% rename from plugins/beta/draw/src/adapters/maplibre/modes/editVertexMode/keyboardHandlers.test.js rename to plugins/draw/src/adapters/maplibre/modes/editVertexMode/keyboardHandlers.test.js diff --git a/plugins/beta/draw/src/adapters/maplibre/modes/editVertexMode/pointerHandlers.js b/plugins/draw/src/adapters/maplibre/modes/editVertexMode/pointerHandlers.js similarity index 97% rename from plugins/beta/draw/src/adapters/maplibre/modes/editVertexMode/pointerHandlers.js rename to plugins/draw/src/adapters/maplibre/modes/editVertexMode/pointerHandlers.js index 49f91813a..254eba080 100644 --- a/plugins/beta/draw/src/adapters/maplibre/modes/editVertexMode/pointerHandlers.js +++ b/plugins/draw/src/adapters/maplibre/modes/editVertexMode/pointerHandlers.js @@ -1,4 +1,4 @@ -import DirectSelect from '../../../../../../../../node_modules/@mapbox/mapbox-gl-draw/src/modes/direct_select.js' // NOSONAR +import DirectSelect from '../../../../../../../node_modules/@mapbox/mapbox-gl-draw/src/modes/direct_select.js' // NOSONAR import { getSnapInstance, isSnapEnabled, getSnapLngLat, triggerSnapAtPoint, clearSnapState } from '../../utils/snapHelpers.js' diff --git a/plugins/beta/draw/src/adapters/maplibre/modes/editVertexMode/pointerHandlers.test.js b/plugins/draw/src/adapters/maplibre/modes/editVertexMode/pointerHandlers.test.js similarity index 100% rename from plugins/beta/draw/src/adapters/maplibre/modes/editVertexMode/pointerHandlers.test.js rename to plugins/draw/src/adapters/maplibre/modes/editVertexMode/pointerHandlers.test.js diff --git a/plugins/beta/draw/src/adapters/maplibre/modes/editVertexMode/touchHandlers.js b/plugins/draw/src/adapters/maplibre/modes/editVertexMode/touchHandlers.js similarity index 100% rename from plugins/beta/draw/src/adapters/maplibre/modes/editVertexMode/touchHandlers.js rename to plugins/draw/src/adapters/maplibre/modes/editVertexMode/touchHandlers.js diff --git a/plugins/beta/draw/src/adapters/maplibre/modes/editVertexMode/touchHandlers.test.js b/plugins/draw/src/adapters/maplibre/modes/editVertexMode/touchHandlers.test.js similarity index 100% rename from plugins/beta/draw/src/adapters/maplibre/modes/editVertexMode/touchHandlers.test.js rename to plugins/draw/src/adapters/maplibre/modes/editVertexMode/touchHandlers.test.js diff --git a/plugins/beta/draw/src/adapters/maplibre/modes/editVertexMode/undoHandlers.js b/plugins/draw/src/adapters/maplibre/modes/editVertexMode/undoHandlers.js similarity index 100% rename from plugins/beta/draw/src/adapters/maplibre/modes/editVertexMode/undoHandlers.js rename to plugins/draw/src/adapters/maplibre/modes/editVertexMode/undoHandlers.js diff --git a/plugins/beta/draw/src/adapters/maplibre/modes/editVertexMode/undoHandlers.test.js b/plugins/draw/src/adapters/maplibre/modes/editVertexMode/undoHandlers.test.js similarity index 100% rename from plugins/beta/draw/src/adapters/maplibre/modes/editVertexMode/undoHandlers.test.js rename to plugins/draw/src/adapters/maplibre/modes/editVertexMode/undoHandlers.test.js diff --git a/plugins/beta/draw/src/adapters/maplibre/modes/editVertexMode/vertexOperations.js b/plugins/draw/src/adapters/maplibre/modes/editVertexMode/vertexOperations.js similarity index 100% rename from plugins/beta/draw/src/adapters/maplibre/modes/editVertexMode/vertexOperations.js rename to plugins/draw/src/adapters/maplibre/modes/editVertexMode/vertexOperations.js diff --git a/plugins/beta/draw/src/adapters/maplibre/modes/editVertexMode/vertexOperations.test.js b/plugins/draw/src/adapters/maplibre/modes/editVertexMode/vertexOperations.test.js similarity index 100% rename from plugins/beta/draw/src/adapters/maplibre/modes/editVertexMode/vertexOperations.test.js rename to plugins/draw/src/adapters/maplibre/modes/editVertexMode/vertexOperations.test.js diff --git a/plugins/beta/draw/src/adapters/maplibre/modes/editVertexMode/vertexQueries.js b/plugins/draw/src/adapters/maplibre/modes/editVertexMode/vertexQueries.js similarity index 100% rename from plugins/beta/draw/src/adapters/maplibre/modes/editVertexMode/vertexQueries.js rename to plugins/draw/src/adapters/maplibre/modes/editVertexMode/vertexQueries.js diff --git a/plugins/beta/draw/src/adapters/maplibre/modes/editVertexMode/vertexQueries.test.js b/plugins/draw/src/adapters/maplibre/modes/editVertexMode/vertexQueries.test.js similarity index 100% rename from plugins/beta/draw/src/adapters/maplibre/modes/editVertexMode/vertexQueries.test.js rename to plugins/draw/src/adapters/maplibre/modes/editVertexMode/vertexQueries.test.js diff --git a/plugins/beta/draw/src/adapters/maplibre/snap/constants.js b/plugins/draw/src/adapters/maplibre/snap/constants.js similarity index 100% rename from plugins/beta/draw/src/adapters/maplibre/snap/constants.js rename to plugins/draw/src/adapters/maplibre/snap/constants.js diff --git a/plugins/beta/draw/src/adapters/maplibre/snap/mapHandlers.js b/plugins/draw/src/adapters/maplibre/snap/mapHandlers.js similarity index 100% rename from plugins/beta/draw/src/adapters/maplibre/snap/mapHandlers.js rename to plugins/draw/src/adapters/maplibre/snap/mapHandlers.js diff --git a/plugins/beta/draw/src/adapters/maplibre/snap/mapHandlers.test.js b/plugins/draw/src/adapters/maplibre/snap/mapHandlers.test.js similarity index 100% rename from plugins/beta/draw/src/adapters/maplibre/snap/mapHandlers.test.js rename to plugins/draw/src/adapters/maplibre/snap/mapHandlers.test.js diff --git a/plugins/beta/draw/src/adapters/maplibre/snap/prototypePatches.js b/plugins/draw/src/adapters/maplibre/snap/prototypePatches.js similarity index 100% rename from plugins/beta/draw/src/adapters/maplibre/snap/prototypePatches.js rename to plugins/draw/src/adapters/maplibre/snap/prototypePatches.js diff --git a/plugins/beta/draw/src/adapters/maplibre/snap/prototypePatches.test.js b/plugins/draw/src/adapters/maplibre/snap/prototypePatches.test.js similarity index 100% rename from plugins/beta/draw/src/adapters/maplibre/snap/prototypePatches.test.js rename to plugins/draw/src/adapters/maplibre/snap/prototypePatches.test.js diff --git a/plugins/beta/draw/src/adapters/maplibre/snap/snapInstance.js b/plugins/draw/src/adapters/maplibre/snap/snapInstance.js similarity index 100% rename from plugins/beta/draw/src/adapters/maplibre/snap/snapInstance.js rename to plugins/draw/src/adapters/maplibre/snap/snapInstance.js diff --git a/plugins/beta/draw/src/adapters/maplibre/snap/snapInstance.test.js b/plugins/draw/src/adapters/maplibre/snap/snapInstance.test.js similarity index 100% rename from plugins/beta/draw/src/adapters/maplibre/snap/snapInstance.test.js rename to plugins/draw/src/adapters/maplibre/snap/snapInstance.test.js diff --git a/plugins/beta/draw/src/adapters/maplibre/snap/sourceData.js b/plugins/draw/src/adapters/maplibre/snap/sourceData.js similarity index 100% rename from plugins/beta/draw/src/adapters/maplibre/snap/sourceData.js rename to plugins/draw/src/adapters/maplibre/snap/sourceData.js diff --git a/plugins/beta/draw/src/adapters/maplibre/snap/sourceData.test.js b/plugins/draw/src/adapters/maplibre/snap/sourceData.test.js similarity index 100% rename from plugins/beta/draw/src/adapters/maplibre/snap/sourceData.test.js rename to plugins/draw/src/adapters/maplibre/snap/sourceData.test.js diff --git a/plugins/beta/draw/src/adapters/maplibre/styles.js b/plugins/draw/src/adapters/maplibre/styles.js similarity index 100% rename from plugins/beta/draw/src/adapters/maplibre/styles.js rename to plugins/draw/src/adapters/maplibre/styles.js diff --git a/plugins/beta/draw/src/adapters/maplibre/styles.test.js b/plugins/draw/src/adapters/maplibre/styles.test.js similarity index 100% rename from plugins/beta/draw/src/adapters/maplibre/styles.test.js rename to plugins/draw/src/adapters/maplibre/styles.test.js diff --git a/plugins/beta/draw/src/adapters/maplibre/utils/snapHelpers.js b/plugins/draw/src/adapters/maplibre/utils/snapHelpers.js similarity index 100% rename from plugins/beta/draw/src/adapters/maplibre/utils/snapHelpers.js rename to plugins/draw/src/adapters/maplibre/utils/snapHelpers.js diff --git a/plugins/beta/draw/src/adapters/maplibre/utils/snapHelpers.test.js b/plugins/draw/src/adapters/maplibre/utils/snapHelpers.test.js similarity index 100% rename from plugins/beta/draw/src/adapters/maplibre/utils/snapHelpers.test.js rename to plugins/draw/src/adapters/maplibre/utils/snapHelpers.test.js diff --git a/plugins/beta/draw/src/adapters/maplibre/utils/touchClickWorkaround.js b/plugins/draw/src/adapters/maplibre/utils/touchClickWorkaround.js similarity index 100% rename from plugins/beta/draw/src/adapters/maplibre/utils/touchClickWorkaround.js rename to plugins/draw/src/adapters/maplibre/utils/touchClickWorkaround.js diff --git a/plugins/beta/draw/src/adapters/maplibre/utils/touchClickWorkaround.test.js b/plugins/draw/src/adapters/maplibre/utils/touchClickWorkaround.test.js similarity index 100% rename from plugins/beta/draw/src/adapters/maplibre/utils/touchClickWorkaround.test.js rename to plugins/draw/src/adapters/maplibre/utils/touchClickWorkaround.test.js diff --git a/plugins/beta/draw/src/adapters/openlayers/OLDrawAdapter.js b/plugins/draw/src/adapters/openlayers/OLDrawAdapter.js similarity index 100% rename from plugins/beta/draw/src/adapters/openlayers/OLDrawAdapter.js rename to plugins/draw/src/adapters/openlayers/OLDrawAdapter.js diff --git a/plugins/beta/draw/src/adapters/openlayers/OLDrawAdapter.test.js b/plugins/draw/src/adapters/openlayers/OLDrawAdapter.test.js similarity index 100% rename from plugins/beta/draw/src/adapters/openlayers/OLDrawAdapter.test.js rename to plugins/draw/src/adapters/openlayers/OLDrawAdapter.test.js diff --git a/plugins/beta/draw/src/adapters/openlayers/__helpers__/harness.js b/plugins/draw/src/adapters/openlayers/__helpers__/harness.js similarity index 100% rename from plugins/beta/draw/src/adapters/openlayers/__helpers__/harness.js rename to plugins/draw/src/adapters/openlayers/__helpers__/harness.js diff --git a/plugins/beta/draw/src/adapters/openlayers/core/OLDrawManager.js b/plugins/draw/src/adapters/openlayers/core/OLDrawManager.js similarity index 100% rename from plugins/beta/draw/src/adapters/openlayers/core/OLDrawManager.js rename to plugins/draw/src/adapters/openlayers/core/OLDrawManager.js diff --git a/plugins/beta/draw/src/adapters/openlayers/core/OLDrawManager.test.js b/plugins/draw/src/adapters/openlayers/core/OLDrawManager.test.js similarity index 100% rename from plugins/beta/draw/src/adapters/openlayers/core/OLDrawManager.test.js rename to plugins/draw/src/adapters/openlayers/core/OLDrawManager.test.js diff --git a/plugins/beta/draw/src/adapters/openlayers/core/featureStore.js b/plugins/draw/src/adapters/openlayers/core/featureStore.js similarity index 100% rename from plugins/beta/draw/src/adapters/openlayers/core/featureStore.js rename to plugins/draw/src/adapters/openlayers/core/featureStore.js diff --git a/plugins/beta/draw/src/adapters/openlayers/core/featureStore.test.js b/plugins/draw/src/adapters/openlayers/core/featureStore.test.js similarity index 100% rename from plugins/beta/draw/src/adapters/openlayers/core/featureStore.test.js rename to plugins/draw/src/adapters/openlayers/core/featureStore.test.js diff --git a/plugins/beta/draw/src/adapters/openlayers/core/internalEvents.js b/plugins/draw/src/adapters/openlayers/core/internalEvents.js similarity index 100% rename from plugins/beta/draw/src/adapters/openlayers/core/internalEvents.js rename to plugins/draw/src/adapters/openlayers/core/internalEvents.js diff --git a/plugins/beta/draw/src/adapters/openlayers/core/styles.js b/plugins/draw/src/adapters/openlayers/core/styles.js similarity index 100% rename from plugins/beta/draw/src/adapters/openlayers/core/styles.js rename to plugins/draw/src/adapters/openlayers/core/styles.js diff --git a/plugins/beta/draw/src/adapters/openlayers/core/styles.test.js b/plugins/draw/src/adapters/openlayers/core/styles.test.js similarity index 100% rename from plugins/beta/draw/src/adapters/openlayers/core/styles.test.js rename to plugins/draw/src/adapters/openlayers/core/styles.test.js diff --git a/plugins/beta/draw/src/adapters/openlayers/defaults.js b/plugins/draw/src/adapters/openlayers/defaults.js similarity index 100% rename from plugins/beta/draw/src/adapters/openlayers/defaults.js rename to plugins/draw/src/adapters/openlayers/defaults.js diff --git a/plugins/beta/draw/src/adapters/openlayers/draw/DrawMode.js b/plugins/draw/src/adapters/openlayers/draw/DrawMode.js similarity index 100% rename from plugins/beta/draw/src/adapters/openlayers/draw/DrawMode.js rename to plugins/draw/src/adapters/openlayers/draw/DrawMode.js diff --git a/plugins/beta/draw/src/adapters/openlayers/draw/DrawMode.test.js b/plugins/draw/src/adapters/openlayers/draw/DrawMode.test.js similarity index 100% rename from plugins/beta/draw/src/adapters/openlayers/draw/DrawMode.test.js rename to plugins/draw/src/adapters/openlayers/draw/DrawMode.test.js diff --git a/plugins/beta/draw/src/adapters/openlayers/draw/drawInput.js b/plugins/draw/src/adapters/openlayers/draw/drawInput.js similarity index 100% rename from plugins/beta/draw/src/adapters/openlayers/draw/drawInput.js rename to plugins/draw/src/adapters/openlayers/draw/drawInput.js diff --git a/plugins/beta/draw/src/adapters/openlayers/draw/drawInput.test.js b/plugins/draw/src/adapters/openlayers/draw/drawInput.test.js similarity index 100% rename from plugins/beta/draw/src/adapters/openlayers/draw/drawInput.test.js rename to plugins/draw/src/adapters/openlayers/draw/drawInput.test.js diff --git a/plugins/beta/draw/src/adapters/openlayers/draw/vertexPlacement.js b/plugins/draw/src/adapters/openlayers/draw/vertexPlacement.js similarity index 100% rename from plugins/beta/draw/src/adapters/openlayers/draw/vertexPlacement.js rename to plugins/draw/src/adapters/openlayers/draw/vertexPlacement.js diff --git a/plugins/beta/draw/src/adapters/openlayers/draw/vertexPlacement.test.js b/plugins/draw/src/adapters/openlayers/draw/vertexPlacement.test.js similarity index 100% rename from plugins/beta/draw/src/adapters/openlayers/draw/vertexPlacement.test.js rename to plugins/draw/src/adapters/openlayers/draw/vertexPlacement.test.js diff --git a/plugins/beta/draw/src/adapters/openlayers/edit/EditMode.js b/plugins/draw/src/adapters/openlayers/edit/EditMode.js similarity index 100% rename from plugins/beta/draw/src/adapters/openlayers/edit/EditMode.js rename to plugins/draw/src/adapters/openlayers/edit/EditMode.js diff --git a/plugins/beta/draw/src/adapters/openlayers/edit/EditMode.test.js b/plugins/draw/src/adapters/openlayers/edit/EditMode.test.js similarity index 100% rename from plugins/beta/draw/src/adapters/openlayers/edit/EditMode.test.js rename to plugins/draw/src/adapters/openlayers/edit/EditMode.test.js diff --git a/plugins/beta/draw/src/adapters/openlayers/edit/activeVertexLayer.js b/plugins/draw/src/adapters/openlayers/edit/activeVertexLayer.js similarity index 100% rename from plugins/beta/draw/src/adapters/openlayers/edit/activeVertexLayer.js rename to plugins/draw/src/adapters/openlayers/edit/activeVertexLayer.js diff --git a/plugins/beta/draw/src/adapters/openlayers/edit/activeVertexLayer.test.js b/plugins/draw/src/adapters/openlayers/edit/activeVertexLayer.test.js similarity index 100% rename from plugins/beta/draw/src/adapters/openlayers/edit/activeVertexLayer.test.js rename to plugins/draw/src/adapters/openlayers/edit/activeVertexLayer.test.js diff --git a/plugins/beta/draw/src/adapters/openlayers/edit/keyboardHandler.js b/plugins/draw/src/adapters/openlayers/edit/keyboardHandler.js similarity index 100% rename from plugins/beta/draw/src/adapters/openlayers/edit/keyboardHandler.js rename to plugins/draw/src/adapters/openlayers/edit/keyboardHandler.js diff --git a/plugins/beta/draw/src/adapters/openlayers/edit/keyboardHandler.test.js b/plugins/draw/src/adapters/openlayers/edit/keyboardHandler.test.js similarity index 100% rename from plugins/beta/draw/src/adapters/openlayers/edit/keyboardHandler.test.js rename to plugins/draw/src/adapters/openlayers/edit/keyboardHandler.test.js diff --git a/plugins/beta/draw/src/adapters/openlayers/edit/midpointLayer.js b/plugins/draw/src/adapters/openlayers/edit/midpointLayer.js similarity index 100% rename from plugins/beta/draw/src/adapters/openlayers/edit/midpointLayer.js rename to plugins/draw/src/adapters/openlayers/edit/midpointLayer.js diff --git a/plugins/beta/draw/src/adapters/openlayers/edit/midpointLayer.test.js b/plugins/draw/src/adapters/openlayers/edit/midpointLayer.test.js similarity index 100% rename from plugins/beta/draw/src/adapters/openlayers/edit/midpointLayer.test.js rename to plugins/draw/src/adapters/openlayers/edit/midpointLayer.test.js diff --git a/plugins/beta/draw/src/adapters/openlayers/edit/modifyInteraction.js b/plugins/draw/src/adapters/openlayers/edit/modifyInteraction.js similarity index 100% rename from plugins/beta/draw/src/adapters/openlayers/edit/modifyInteraction.js rename to plugins/draw/src/adapters/openlayers/edit/modifyInteraction.js diff --git a/plugins/beta/draw/src/adapters/openlayers/edit/modifyInteraction.test.js b/plugins/draw/src/adapters/openlayers/edit/modifyInteraction.test.js similarity index 100% rename from plugins/beta/draw/src/adapters/openlayers/edit/modifyInteraction.test.js rename to plugins/draw/src/adapters/openlayers/edit/modifyInteraction.test.js diff --git a/plugins/beta/draw/src/adapters/openlayers/edit/nudge.js b/plugins/draw/src/adapters/openlayers/edit/nudge.js similarity index 100% rename from plugins/beta/draw/src/adapters/openlayers/edit/nudge.js rename to plugins/draw/src/adapters/openlayers/edit/nudge.js diff --git a/plugins/beta/draw/src/adapters/openlayers/edit/nudge.test.js b/plugins/draw/src/adapters/openlayers/edit/nudge.test.js similarity index 100% rename from plugins/beta/draw/src/adapters/openlayers/edit/nudge.test.js rename to plugins/draw/src/adapters/openlayers/edit/nudge.test.js diff --git a/plugins/beta/draw/src/adapters/openlayers/edit/pointerHandlers.js b/plugins/draw/src/adapters/openlayers/edit/pointerHandlers.js similarity index 100% rename from plugins/beta/draw/src/adapters/openlayers/edit/pointerHandlers.js rename to plugins/draw/src/adapters/openlayers/edit/pointerHandlers.js diff --git a/plugins/beta/draw/src/adapters/openlayers/edit/pointerHandlers.test.js b/plugins/draw/src/adapters/openlayers/edit/pointerHandlers.test.js similarity index 100% rename from plugins/beta/draw/src/adapters/openlayers/edit/pointerHandlers.test.js rename to plugins/draw/src/adapters/openlayers/edit/pointerHandlers.test.js diff --git a/plugins/beta/draw/src/adapters/openlayers/edit/selectionState.js b/plugins/draw/src/adapters/openlayers/edit/selectionState.js similarity index 100% rename from plugins/beta/draw/src/adapters/openlayers/edit/selectionState.js rename to plugins/draw/src/adapters/openlayers/edit/selectionState.js diff --git a/plugins/beta/draw/src/adapters/openlayers/edit/selectionState.test.js b/plugins/draw/src/adapters/openlayers/edit/selectionState.test.js similarity index 100% rename from plugins/beta/draw/src/adapters/openlayers/edit/selectionState.test.js rename to plugins/draw/src/adapters/openlayers/edit/selectionState.test.js diff --git a/plugins/beta/draw/src/adapters/openlayers/edit/touchHandler.js b/plugins/draw/src/adapters/openlayers/edit/touchHandler.js similarity index 100% rename from plugins/beta/draw/src/adapters/openlayers/edit/touchHandler.js rename to plugins/draw/src/adapters/openlayers/edit/touchHandler.js diff --git a/plugins/beta/draw/src/adapters/openlayers/edit/touchHandler.test.js b/plugins/draw/src/adapters/openlayers/edit/touchHandler.test.js similarity index 100% rename from plugins/beta/draw/src/adapters/openlayers/edit/touchHandler.test.js rename to plugins/draw/src/adapters/openlayers/edit/touchHandler.test.js diff --git a/plugins/beta/draw/src/adapters/openlayers/edit/undoOps.js b/plugins/draw/src/adapters/openlayers/edit/undoOps.js similarity index 100% rename from plugins/beta/draw/src/adapters/openlayers/edit/undoOps.js rename to plugins/draw/src/adapters/openlayers/edit/undoOps.js diff --git a/plugins/beta/draw/src/adapters/openlayers/edit/undoOps.test.js b/plugins/draw/src/adapters/openlayers/edit/undoOps.test.js similarity index 100% rename from plugins/beta/draw/src/adapters/openlayers/edit/undoOps.test.js rename to plugins/draw/src/adapters/openlayers/edit/undoOps.test.js diff --git a/plugins/beta/draw/src/adapters/openlayers/edit/vertexHitTest.js b/plugins/draw/src/adapters/openlayers/edit/vertexHitTest.js similarity index 100% rename from plugins/beta/draw/src/adapters/openlayers/edit/vertexHitTest.js rename to plugins/draw/src/adapters/openlayers/edit/vertexHitTest.js diff --git a/plugins/beta/draw/src/adapters/openlayers/edit/vertexHitTest.test.js b/plugins/draw/src/adapters/openlayers/edit/vertexHitTest.test.js similarity index 100% rename from plugins/beta/draw/src/adapters/openlayers/edit/vertexHitTest.test.js rename to plugins/draw/src/adapters/openlayers/edit/vertexHitTest.test.js diff --git a/plugins/beta/draw/src/adapters/openlayers/edit/vertexLayer.js b/plugins/draw/src/adapters/openlayers/edit/vertexLayer.js similarity index 100% rename from plugins/beta/draw/src/adapters/openlayers/edit/vertexLayer.js rename to plugins/draw/src/adapters/openlayers/edit/vertexLayer.js diff --git a/plugins/beta/draw/src/adapters/openlayers/edit/vertexLayer.test.js b/plugins/draw/src/adapters/openlayers/edit/vertexLayer.test.js similarity index 100% rename from plugins/beta/draw/src/adapters/openlayers/edit/vertexLayer.test.js rename to plugins/draw/src/adapters/openlayers/edit/vertexLayer.test.js diff --git a/plugins/beta/draw/src/adapters/openlayers/edit/vertexOps.js b/plugins/draw/src/adapters/openlayers/edit/vertexOps.js similarity index 100% rename from plugins/beta/draw/src/adapters/openlayers/edit/vertexOps.js rename to plugins/draw/src/adapters/openlayers/edit/vertexOps.js diff --git a/plugins/beta/draw/src/adapters/openlayers/edit/vertexOps.test.js b/plugins/draw/src/adapters/openlayers/edit/vertexOps.test.js similarity index 100% rename from plugins/beta/draw/src/adapters/openlayers/edit/vertexOps.test.js rename to plugins/draw/src/adapters/openlayers/edit/vertexOps.test.js diff --git a/plugins/beta/draw/src/adapters/openlayers/olDraw.js b/plugins/draw/src/adapters/openlayers/olDraw.js similarity index 100% rename from plugins/beta/draw/src/adapters/openlayers/olDraw.js rename to plugins/draw/src/adapters/openlayers/olDraw.js diff --git a/plugins/beta/draw/src/adapters/openlayers/olDraw.test.js b/plugins/draw/src/adapters/openlayers/olDraw.test.js similarity index 100% rename from plugins/beta/draw/src/adapters/openlayers/olDraw.test.js rename to plugins/draw/src/adapters/openlayers/olDraw.test.js diff --git a/plugins/beta/draw/src/adapters/openlayers/snap/snapEngine.js b/plugins/draw/src/adapters/openlayers/snap/snapEngine.js similarity index 100% rename from plugins/beta/draw/src/adapters/openlayers/snap/snapEngine.js rename to plugins/draw/src/adapters/openlayers/snap/snapEngine.js diff --git a/plugins/beta/draw/src/adapters/openlayers/snap/snapEngine.test.js b/plugins/draw/src/adapters/openlayers/snap/snapEngine.test.js similarity index 100% rename from plugins/beta/draw/src/adapters/openlayers/snap/snapEngine.test.js rename to plugins/draw/src/adapters/openlayers/snap/snapEngine.test.js diff --git a/plugins/beta/draw/src/adapters/openlayers/snap/snapGeometry.js b/plugins/draw/src/adapters/openlayers/snap/snapGeometry.js similarity index 100% rename from plugins/beta/draw/src/adapters/openlayers/snap/snapGeometry.js rename to plugins/draw/src/adapters/openlayers/snap/snapGeometry.js diff --git a/plugins/beta/draw/src/adapters/openlayers/snap/snapGeometry.test.js b/plugins/draw/src/adapters/openlayers/snap/snapGeometry.test.js similarity index 100% rename from plugins/beta/draw/src/adapters/openlayers/snap/snapGeometry.test.js rename to plugins/draw/src/adapters/openlayers/snap/snapGeometry.test.js diff --git a/plugins/beta/draw/src/adapters/openlayers/snap/snapIndicator.js b/plugins/draw/src/adapters/openlayers/snap/snapIndicator.js similarity index 100% rename from plugins/beta/draw/src/adapters/openlayers/snap/snapIndicator.js rename to plugins/draw/src/adapters/openlayers/snap/snapIndicator.js diff --git a/plugins/beta/draw/src/adapters/openlayers/snap/snapIndicator.test.js b/plugins/draw/src/adapters/openlayers/snap/snapIndicator.test.js similarity index 100% rename from plugins/beta/draw/src/adapters/openlayers/snap/snapIndicator.test.js rename to plugins/draw/src/adapters/openlayers/snap/snapIndicator.test.js diff --git a/plugins/beta/draw/src/adapters/openlayers/snap/snapInteraction.js b/plugins/draw/src/adapters/openlayers/snap/snapInteraction.js similarity index 100% rename from plugins/beta/draw/src/adapters/openlayers/snap/snapInteraction.js rename to plugins/draw/src/adapters/openlayers/snap/snapInteraction.js diff --git a/plugins/beta/draw/src/adapters/openlayers/snap/snapInteraction.test.js b/plugins/draw/src/adapters/openlayers/snap/snapInteraction.test.js similarity index 100% rename from plugins/beta/draw/src/adapters/openlayers/snap/snapInteraction.test.js rename to plugins/draw/src/adapters/openlayers/snap/snapInteraction.test.js diff --git a/plugins/beta/draw/src/adapters/openlayers/snap/snapManager.js b/plugins/draw/src/adapters/openlayers/snap/snapManager.js similarity index 100% rename from plugins/beta/draw/src/adapters/openlayers/snap/snapManager.js rename to plugins/draw/src/adapters/openlayers/snap/snapManager.js diff --git a/plugins/beta/draw/src/adapters/openlayers/snap/snapManager.test.js b/plugins/draw/src/adapters/openlayers/snap/snapManager.test.js similarity index 100% rename from plugins/beta/draw/src/adapters/openlayers/snap/snapManager.test.js rename to plugins/draw/src/adapters/openlayers/snap/snapManager.test.js diff --git a/plugins/beta/draw/src/adapters/openlayers/utils/geometryHelpers.js b/plugins/draw/src/adapters/openlayers/utils/geometryHelpers.js similarity index 100% rename from plugins/beta/draw/src/adapters/openlayers/utils/geometryHelpers.js rename to plugins/draw/src/adapters/openlayers/utils/geometryHelpers.js diff --git a/plugins/beta/draw/src/adapters/openlayers/utils/geometryHelpers.test.js b/plugins/draw/src/adapters/openlayers/utils/geometryHelpers.test.js similarity index 100% rename from plugins/beta/draw/src/adapters/openlayers/utils/geometryHelpers.test.js rename to plugins/draw/src/adapters/openlayers/utils/geometryHelpers.test.js diff --git a/plugins/beta/draw/src/adapters/openlayers/utils/olCoords.js b/plugins/draw/src/adapters/openlayers/utils/olCoords.js similarity index 100% rename from plugins/beta/draw/src/adapters/openlayers/utils/olCoords.js rename to plugins/draw/src/adapters/openlayers/utils/olCoords.js diff --git a/plugins/beta/draw/src/adapters/openlayers/utils/olCoords.test.js b/plugins/draw/src/adapters/openlayers/utils/olCoords.test.js similarity index 100% rename from plugins/beta/draw/src/adapters/openlayers/utils/olCoords.test.js rename to plugins/draw/src/adapters/openlayers/utils/olCoords.test.js diff --git a/plugins/beta/draw/src/adapters/openlayers/utils/resolveColors.js b/plugins/draw/src/adapters/openlayers/utils/resolveColors.js similarity index 100% rename from plugins/beta/draw/src/adapters/openlayers/utils/resolveColors.js rename to plugins/draw/src/adapters/openlayers/utils/resolveColors.js diff --git a/plugins/beta/draw/src/adapters/openlayers/utils/resolveColors.test.js b/plugins/draw/src/adapters/openlayers/utils/resolveColors.test.js similarity index 100% rename from plugins/beta/draw/src/adapters/openlayers/utils/resolveColors.test.js rename to plugins/draw/src/adapters/openlayers/utils/resolveColors.test.js diff --git a/plugins/beta/draw/src/adapters/openlayers/utils/sketchHelpers.js b/plugins/draw/src/adapters/openlayers/utils/sketchHelpers.js similarity index 100% rename from plugins/beta/draw/src/adapters/openlayers/utils/sketchHelpers.js rename to plugins/draw/src/adapters/openlayers/utils/sketchHelpers.js diff --git a/plugins/beta/draw/src/adapters/openlayers/utils/sketchHelpers.test.js b/plugins/draw/src/adapters/openlayers/utils/sketchHelpers.test.js similarity index 100% rename from plugins/beta/draw/src/adapters/openlayers/utils/sketchHelpers.test.js rename to plugins/draw/src/adapters/openlayers/utils/sketchHelpers.test.js diff --git a/plugins/beta/draw/src/adapters/openlayers/utils/touchTarget.js b/plugins/draw/src/adapters/openlayers/utils/touchTarget.js similarity index 100% rename from plugins/beta/draw/src/adapters/openlayers/utils/touchTarget.js rename to plugins/draw/src/adapters/openlayers/utils/touchTarget.js diff --git a/plugins/beta/draw/src/api/addFeature.js b/plugins/draw/src/api/addFeature.js similarity index 100% rename from plugins/beta/draw/src/api/addFeature.js rename to plugins/draw/src/api/addFeature.js diff --git a/plugins/beta/draw/src/api/addFeature.test.js b/plugins/draw/src/api/addFeature.test.js similarity index 100% rename from plugins/beta/draw/src/api/addFeature.test.js rename to plugins/draw/src/api/addFeature.test.js diff --git a/plugins/beta/draw/src/api/deleteFeature.js b/plugins/draw/src/api/deleteFeature.js similarity index 100% rename from plugins/beta/draw/src/api/deleteFeature.js rename to plugins/draw/src/api/deleteFeature.js diff --git a/plugins/beta/draw/src/api/deleteFeature.test.js b/plugins/draw/src/api/deleteFeature.test.js similarity index 100% rename from plugins/beta/draw/src/api/deleteFeature.test.js rename to plugins/draw/src/api/deleteFeature.test.js diff --git a/plugins/beta/draw/src/api/editFeature.js b/plugins/draw/src/api/editFeature.js similarity index 100% rename from plugins/beta/draw/src/api/editFeature.js rename to plugins/draw/src/api/editFeature.js diff --git a/plugins/beta/draw/src/api/editFeature.test.js b/plugins/draw/src/api/editFeature.test.js similarity index 100% rename from plugins/beta/draw/src/api/editFeature.test.js rename to plugins/draw/src/api/editFeature.test.js diff --git a/plugins/beta/draw/src/api/merge.js b/plugins/draw/src/api/merge.js similarity index 100% rename from plugins/beta/draw/src/api/merge.js rename to plugins/draw/src/api/merge.js diff --git a/plugins/beta/draw/src/api/merge.test.js b/plugins/draw/src/api/merge.test.js similarity index 100% rename from plugins/beta/draw/src/api/merge.test.js rename to plugins/draw/src/api/merge.test.js diff --git a/plugins/beta/draw/src/api/newLine.js b/plugins/draw/src/api/newLine.js similarity index 100% rename from plugins/beta/draw/src/api/newLine.js rename to plugins/draw/src/api/newLine.js diff --git a/plugins/beta/draw/src/api/newLine.test.js b/plugins/draw/src/api/newLine.test.js similarity index 100% rename from plugins/beta/draw/src/api/newLine.test.js rename to plugins/draw/src/api/newLine.test.js diff --git a/plugins/beta/draw/src/api/newPolygon.js b/plugins/draw/src/api/newPolygon.js similarity index 100% rename from plugins/beta/draw/src/api/newPolygon.js rename to plugins/draw/src/api/newPolygon.js diff --git a/plugins/beta/draw/src/api/newPolygon.test.js b/plugins/draw/src/api/newPolygon.test.js similarity index 100% rename from plugins/beta/draw/src/api/newPolygon.test.js rename to plugins/draw/src/api/newPolygon.test.js diff --git a/plugins/beta/draw/src/api/split.js b/plugins/draw/src/api/split.js similarity index 100% rename from plugins/beta/draw/src/api/split.js rename to plugins/draw/src/api/split.js diff --git a/plugins/beta/draw/src/api/split.test.js b/plugins/draw/src/api/split.test.js similarity index 100% rename from plugins/beta/draw/src/api/split.test.js rename to plugins/draw/src/api/split.test.js diff --git a/plugins/beta/draw/src/defaults.js b/plugins/draw/src/defaults.js similarity index 100% rename from plugins/beta/draw/src/defaults.js rename to plugins/draw/src/defaults.js diff --git a/plugins/beta/draw/src/defaults.test.js b/plugins/draw/src/defaults.test.js similarity index 100% rename from plugins/beta/draw/src/defaults.test.js rename to plugins/draw/src/defaults.test.js diff --git a/plugins/beta/draw/src/draw.scss b/plugins/draw/src/draw.scss similarity index 100% rename from plugins/beta/draw/src/draw.scss rename to plugins/draw/src/draw.scss diff --git a/plugins/beta/draw/src/events.js b/plugins/draw/src/events.js similarity index 100% rename from plugins/beta/draw/src/events.js rename to plugins/draw/src/events.js diff --git a/plugins/beta/draw/src/events.test.js b/plugins/draw/src/events.test.js similarity index 100% rename from plugins/beta/draw/src/events.test.js rename to plugins/draw/src/events.test.js diff --git a/plugins/beta/draw/src/index.js b/plugins/draw/src/index.js similarity index 100% rename from plugins/beta/draw/src/index.js rename to plugins/draw/src/index.js diff --git a/plugins/beta/draw/src/index.test.js b/plugins/draw/src/index.test.js similarity index 100% rename from plugins/beta/draw/src/index.test.js rename to plugins/draw/src/index.test.js diff --git a/plugins/beta/draw/src/manifest.js b/plugins/draw/src/manifest.js similarity index 100% rename from plugins/beta/draw/src/manifest.js rename to plugins/draw/src/manifest.js diff --git a/plugins/beta/draw/src/manifest.test.js b/plugins/draw/src/manifest.test.js similarity index 100% rename from plugins/beta/draw/src/manifest.test.js rename to plugins/draw/src/manifest.test.js diff --git a/plugins/beta/draw/src/reducer.js b/plugins/draw/src/reducer.js similarity index 100% rename from plugins/beta/draw/src/reducer.js rename to plugins/draw/src/reducer.js diff --git a/plugins/beta/draw/src/reducer.test.js b/plugins/draw/src/reducer.test.js similarity index 100% rename from plugins/beta/draw/src/reducer.test.js rename to plugins/draw/src/reducer.test.js diff --git a/plugins/beta/draw/src/utils/debounce.js b/plugins/draw/src/utils/debounce.js similarity index 100% rename from plugins/beta/draw/src/utils/debounce.js rename to plugins/draw/src/utils/debounce.js diff --git a/plugins/beta/draw/src/utils/debounce.test.js b/plugins/draw/src/utils/debounce.test.js similarity index 100% rename from plugins/beta/draw/src/utils/debounce.test.js rename to plugins/draw/src/utils/debounce.test.js diff --git a/plugins/beta/draw/src/utils/eventBus.js b/plugins/draw/src/utils/eventBus.js similarity index 100% rename from plugins/beta/draw/src/utils/eventBus.js rename to plugins/draw/src/utils/eventBus.js diff --git a/plugins/beta/draw/src/utils/eventBus.test.js b/plugins/draw/src/utils/eventBus.test.js similarity index 100% rename from plugins/beta/draw/src/utils/eventBus.test.js rename to plugins/draw/src/utils/eventBus.test.js diff --git a/plugins/beta/draw/src/utils/flattenStyleProperties.js b/plugins/draw/src/utils/flattenStyleProperties.js similarity index 100% rename from plugins/beta/draw/src/utils/flattenStyleProperties.js rename to plugins/draw/src/utils/flattenStyleProperties.js diff --git a/plugins/beta/draw/src/utils/flattenStyleProperties.test.js b/plugins/draw/src/utils/flattenStyleProperties.test.js similarity index 100% rename from plugins/beta/draw/src/utils/flattenStyleProperties.test.js rename to plugins/draw/src/utils/flattenStyleProperties.test.js diff --git a/plugins/beta/draw/src/utils/getValueForStyle.js b/plugins/draw/src/utils/getValueForStyle.js similarity index 100% rename from plugins/beta/draw/src/utils/getValueForStyle.js rename to plugins/draw/src/utils/getValueForStyle.js diff --git a/plugins/beta/draw/src/utils/getValueForStyle.test.js b/plugins/draw/src/utils/getValueForStyle.test.js similarity index 100% rename from plugins/beta/draw/src/utils/getValueForStyle.test.js rename to plugins/draw/src/utils/getValueForStyle.test.js diff --git a/plugins/beta/draw/src/utils/spatial.js b/plugins/draw/src/utils/spatial.js similarity index 100% rename from plugins/beta/draw/src/utils/spatial.js rename to plugins/draw/src/utils/spatial.js diff --git a/plugins/beta/draw/src/utils/spatial.test.js b/plugins/draw/src/utils/spatial.test.js similarity index 100% rename from plugins/beta/draw/src/utils/spatial.test.js rename to plugins/draw/src/utils/spatial.test.js diff --git a/plugins/beta/draw/src/utils/touchTarget.js b/plugins/draw/src/utils/touchTarget.js similarity index 100% rename from plugins/beta/draw/src/utils/touchTarget.js rename to plugins/draw/src/utils/touchTarget.js diff --git a/plugins/beta/draw/src/utils/touchTarget.test.js b/plugins/draw/src/utils/touchTarget.test.js similarity index 100% rename from plugins/beta/draw/src/utils/touchTarget.test.js rename to plugins/draw/src/utils/touchTarget.test.js diff --git a/plugins/beta/draw/src/utils/undoStack.js b/plugins/draw/src/utils/undoStack.js similarity index 100% rename from plugins/beta/draw/src/utils/undoStack.js rename to plugins/draw/src/utils/undoStack.js diff --git a/plugins/beta/draw/src/utils/undoStack.test.js b/plugins/draw/src/utils/undoStack.test.js similarity index 100% rename from plugins/beta/draw/src/utils/undoStack.test.js rename to plugins/draw/src/utils/undoStack.test.js diff --git a/rollup.esm.mjs b/rollup.esm.mjs index c7e0b870a..d0f8bf0b3 100644 --- a/rollup.esm.mjs +++ b/rollup.esm.mjs @@ -272,8 +272,8 @@ const ALL_BUILDS = [ manualChunks: (id) => id.includes('/manifest') ? 'im-map-styles-plugin' : undefined }, { - entryPath: './plugins/beta/draw/src/index.js', - outDir: 'plugins/beta/draw/dist/esm', + entryPath: './plugins/draw/src/index.js', + outDir: 'plugins/draw/dist/esm', extraExternals: [/^ol\//], manualChunks: (id) => { if (id.includes('/manifest')) { return 'im-draw-plugin' } diff --git a/webpack.umd.mjs b/webpack.umd.mjs index 649eacbf9..7f87eb86b 100755 --- a/webpack.umd.mjs +++ b/webpack.umd.mjs @@ -146,7 +146,7 @@ const ALL_BUILDS = [ { entryPath: './plugins/interact/src/index.js', libraryPath: 'interactPlugin', outDir: 'plugins/interact/dist/umd' }, { entryPath: './plugins/beta/datasets/src/index.js', libraryPath: 'datasetsPlugin', outDir: 'plugins/beta/datasets/dist/umd', cssOutDir: 'plugins/beta/datasets/dist' }, { entryPath: './plugins/beta/map-styles/src/index.js', libraryPath: 'mapStylesPlugin', outDir: 'plugins/beta/map-styles/dist/umd' }, - { entryPath: './plugins/beta/draw/src/index.js', libraryPath: 'drawPlugin', outDir: 'plugins/beta/draw/dist/umd' }, + { entryPath: './plugins/draw/src/index.js', libraryPath: 'drawPlugin', outDir: 'plugins/draw/dist/umd' }, { entryPath: './plugins/beta/draw-ml/src/index.js', libraryPath: 'drawMLPlugin', outDir: 'plugins/beta/draw-ml/dist/umd' }, { entryPath: './plugins/beta/frame/src/index.js', libraryPath: 'framePlugin', outDir: 'plugins/beta/frame/dist/umd' } ] From f8508224db32ddae23f406ada35d871fa0335b66 Mon Sep 17 00:00:00 2001 From: Dan Leech Date: Mon, 6 Jul 2026 12:06:13 +0100 Subject: [PATCH 43/89] DrawInit.jsx extra crossHair test added --- plugins/draw/src/DrawInit.test.jsx | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/plugins/draw/src/DrawInit.test.jsx b/plugins/draw/src/DrawInit.test.jsx index 1a7548b28..87ea9495d 100644 --- a/plugins/draw/src/DrawInit.test.jsx +++ b/plugins/draw/src/DrawInit.test.jsx @@ -124,6 +124,22 @@ describe('crosshair', () => { await renderInit(props) expect(props.mapState.crossHair.fixAtCenter).not.toHaveBeenCalled() }) + + test('hides the crosshair on cleanup when it was hidden before and the interface has left touch/keyboard', async () => { + const { props } = makeProps({ + appState: { interfaceType: 'touch', mode: null }, + pluginState: { dispatch: jest.fn(), mode: 'draw_polygon' } + }) + const result = await renderInit(props) + expect(props.mapState.crossHair.fixAtCenter).toHaveBeenCalled() + + // Switch away from touch/keyboard, then re-render so the crosshair effect's + // cleanup runs — it reads the (mutated) interface type and hides the crosshair. + props.appState.interfaceType = 'mouse' + await act(async () => { result.rerender() }) + + expect(props.mapState.crossHair.hide).toHaveBeenCalled() + }) }) describe('interface type sync', () => { From df7eaf6a694aa4c5e10ebeb82b24bccfcf1fbbc9 Mon Sep 17 00:00:00 2001 From: Dan Leech Date: Mon, 6 Jul 2026 12:17:05 +0100 Subject: [PATCH 44/89] Sonar cloud fixes --- .../modes/editVertexMode/geometryHelpers.js | 12 ++++++++---- .../modes/editVertexMode/undoHandlers.js | 10 ++++++---- .../modes/editVertexMode/vertexOperations.js | 6 +++--- .../src/adapters/openlayers/edit/undoOps.js | 17 ++++++++--------- 4 files changed, 25 insertions(+), 20 deletions(-) diff --git a/plugins/draw/src/adapters/maplibre/modes/editVertexMode/geometryHelpers.js b/plugins/draw/src/adapters/maplibre/modes/editVertexMode/geometryHelpers.js index 47e9e3305..7479e4037 100644 --- a/plugins/draw/src/adapters/maplibre/modes/editVertexMode/geometryHelpers.js +++ b/plugins/draw/src/adapters/maplibre/modes/editVertexMode/geometryHelpers.js @@ -12,7 +12,9 @@ * @returns {Array<[number, number]>} Flat array of all coordinates */ export const getCoords = (feature) => { - if (!feature?.coordinates) return [] + if (!feature?.coordinates) { + return [] + } switch (feature.type) { case 'LineString': return feature.coordinates @@ -40,7 +42,9 @@ export const getCoords = (feature) => { * - closed: Whether this segment is closed (true for Polygon rings) */ export const getRingSegments = (feature) => { - if (!feature?.coordinates) return [] + if (!feature?.coordinates) { + return [] + } const segments = [] let start = 0 @@ -125,11 +129,11 @@ export const coordPathToFlatIndex = (feature, coordPath) => { // Check if path matches (compare all but last element which is the local vertex index) const pathMatches = seg.path.every((val, idx) => val === parts[idx]) if (pathMatches && parts.length === seg.path.length + 1) { - const localIdx = parts[parts.length - 1] + const localIdx = parts[parts.length - 1] // NOSONAR, .length greater borwser support return seg.start + localIdx } } // Fallback: just use the last number (works for simple geometries) - return parts[parts.length - 1] + return parts[parts.length - 1] // NOSONAR, .length greater borwser support } diff --git a/plugins/draw/src/adapters/maplibre/modes/editVertexMode/undoHandlers.js b/plugins/draw/src/adapters/maplibre/modes/editVertexMode/undoHandlers.js index 02f48a038..72a1e25cb 100644 --- a/plugins/draw/src/adapters/maplibre/modes/editVertexMode/undoHandlers.js +++ b/plugins/draw/src/adapters/maplibre/modes/editVertexMode/undoHandlers.js @@ -40,18 +40,20 @@ export const undoHandlers = { this.undoInsertVertex(state, op) } else if (op.type === 'delete_vertex') { this.undoDeleteVertex(state, op) + } else { + // No action } }, undoMoveVertex (state, op) { const { vertexIndex, previousPosition, featureId } = op const feature = this.getFeature(featureId) - if (!feature) return + if (!feature) { return } const geojson = feature.toGeoJSON() const segments = getRingSegments(feature) const result = getSegmentForIndex(segments, vertexIndex) - if (!result) return + if (!result) { return } const coords = getModifiableCoords(geojson, result.segment.path) coords[result.localIdx] = previousPosition @@ -67,12 +69,12 @@ export const undoHandlers = { undoInsertVertex (state, op) { const { vertexIndex, featureId } = op const feature = this.getFeature(featureId) - if (!feature) return + if (!feature) { return } const geojson = feature.toGeoJSON() const segments = getRingSegments(feature) const result = getSegmentForIndex(segments, vertexIndex) - if (!result) return + if (!result) { return } const coords = getModifiableCoords(geojson, result.segment.path) coords.splice(result.localIdx, 1) diff --git a/plugins/draw/src/adapters/maplibre/modes/editVertexMode/vertexOperations.js b/plugins/draw/src/adapters/maplibre/modes/editVertexMode/vertexOperations.js index d4d782bff..c2de734e6 100644 --- a/plugins/draw/src/adapters/maplibre/modes/editVertexMode/vertexOperations.js +++ b/plugins/draw/src/adapters/maplibre/modes/editVertexMode/vertexOperations.js @@ -64,7 +64,7 @@ export const vertexOperations = { midpointCounter += segMidpoints } - if (!insertSegment) return + if (!insertSegment) { return } const coords = getModifiableCoords(geojson, insertSegment.path) coords.splice(localInsertIdx, 0, [newCoord.lng, newCoord.lat]) @@ -86,7 +86,7 @@ export const vertexOperations = { const geojson = feature.toGeoJSON() const segments = getRingSegments(feature) const result = getSegmentForIndex(segments, state.selectedVertexIndex) - if (!result) return + if (!result) { return } const coords = getModifiableCoords(geojson, result.segment.path) coords[result.localIdx] = [coord.lng, coord.lat] @@ -110,7 +110,7 @@ export const vertexOperations = { const { segment } = result // Minimum vertices per segment: 3 for closed rings (mapbox-gl-draw's internal representation), 2 for lines - const minVertices = segment.closed ? 3 : 2 + const minVertices = segment.closed ? 3 : 2 // NOSONAR, min vertices for closed ring if (segment.length <= minVertices) { return } diff --git a/plugins/draw/src/adapters/openlayers/edit/undoOps.js b/plugins/draw/src/adapters/openlayers/edit/undoOps.js index 0ae197ad8..a9e59d0c1 100644 --- a/plugins/draw/src/adapters/openlayers/edit/undoOps.js +++ b/plugins/draw/src/adapters/openlayers/edit/undoOps.js @@ -35,16 +35,15 @@ export const undoInsertVertex = (olFeature, op) => { const geojsonGeom = { type: geom.getType(), coordinates: geom.getCoordinates() } const segments = getRingSegments(geojsonGeom) const result = getSegmentForIndex(segments, vertexIndex) - if (!result) { - return -1 - } - - const ring = getModifiableCoords(geojsonGeom, result.segment.path) - ring.splice(result.localIdx, 1) - if (result.segment.closed) { - ring[ring.length - 1] = [...ring[0]] + if (result) { + const ring = getModifiableCoords(geojsonGeom, result.segment.path) + ring.splice(result.localIdx, 1) + if (result.segment.closed) { + ring[ring.length - 1] = [...ring[0]] + } + geom.setCoordinates(geojsonGeom.coordinates) } - geom.setCoordinates(geojsonGeom.coordinates) + // Undoing an insert removes the vertex, so there is never one to re-select return -1 } From c6c6f908ed88b5cbe28ce970e843e90ac24a2406 Mon Sep 17 00:00:00 2001 From: Dan Leech Date: Mon, 6 Jul 2026 12:30:01 +0100 Subject: [PATCH 45/89] Persist snap state between modes --- .../maplibre/modes/editVertexMode/vertexQueries.js | 8 ++++---- plugins/draw/src/api/newLine.js | 4 ++++ plugins/draw/src/api/newLine.test.js | 9 ++++++++- plugins/draw/src/api/newPolygon.js | 4 ++++ plugins/draw/src/api/newPolygon.test.js | 9 ++++++++- plugins/draw/src/events.js | 6 +++--- plugins/draw/src/events.test.js | 10 +++++----- 7 files changed, 36 insertions(+), 14 deletions(-) diff --git a/plugins/draw/src/adapters/maplibre/modes/editVertexMode/vertexQueries.js b/plugins/draw/src/adapters/maplibre/modes/editVertexMode/vertexQueries.js index 7118b4a1a..173ead373 100644 --- a/plugins/draw/src/adapters/maplibre/modes/editVertexMode/vertexQueries.js +++ b/plugins/draw/src/adapters/maplibre/modes/editVertexMode/vertexQueries.js @@ -15,8 +15,8 @@ export const vertexQueries = { } }) - if (matches.length === 0) return -1 - if (matches.length === 1) return matches[0] + if (matches.length === 0) { return -1 } + if (matches.length === 1) { return matches[0] } // Multiple matches - pick closest to current selection if (currentIdx >= 0) { @@ -29,11 +29,11 @@ export const vertexQueries = { getCoordPath (state, idx) { const feature = this.getFeature(state.featureId) - if (!feature) return '0' + if (!feature) { return '0' } const segments = getRingSegments(feature) const result = getSegmentForIndex(segments, idx) - if (!result) return '0' + if (!result) { return '0' } const { segment, localIdx } = result return [...segment.path, localIdx].join('.') diff --git a/plugins/draw/src/api/newLine.js b/plugins/draw/src/api/newLine.js index 8d4424a45..14db9ecee 100644 --- a/plugins/draw/src/api/newLine.js +++ b/plugins/draw/src/api/newLine.js @@ -15,6 +15,10 @@ export const newLine = ({ appState, appConfig, pluginConfig, pluginState, mapSta draw.setSnapLayers(snapLayers) dispatch({ type: 'SET_HAS_SNAP_LAYERS', payload: snapLayers?.length > 0 }) + // A brand-new drawing always starts with snapping off. + dispatch({ type: 'SET_SNAP', payload: false }) + draw.setSnapEnabled(false) + const { stroke, fill, strokeWidth, properties: customProperties, ...modeOptions } = options const properties = { ...customProperties, diff --git a/plugins/draw/src/api/newLine.test.js b/plugins/draw/src/api/newLine.test.js index b302a7ccd..53afd45d7 100644 --- a/plugins/draw/src/api/newLine.test.js +++ b/plugins/draw/src/api/newLine.test.js @@ -8,7 +8,7 @@ jest.mock('../utils/flattenStyleProperties.js', () => ({ const makeContext = (overrides = {}) => { const dispatch = jest.fn() const eventBus = { emit: jest.fn() } - const draw = { setSnapLayers: jest.fn(), changeMode: jest.fn(), isSnapEnabled: jest.fn(() => false) } + const draw = { setSnapLayers: jest.fn(), setSnapEnabled: jest.fn(), changeMode: jest.fn(), isSnapEnabled: jest.fn(() => false) } const context = { appState: { layoutRefs: { viewportRef: { current: 'viewport' } }, interfaceType: 'mouse' }, appConfig: { id: 'app' }, @@ -56,6 +56,13 @@ describe('newLine', () => { expect(dispatch).toHaveBeenCalledWith({ type: 'SET_MODE', payload: 'draw_line' }) }) + test('resets snap to off when starting a fresh drawing', () => { + const { context, draw, dispatch } = makeContext() + newLine(context, 'f1') + expect(dispatch).toHaveBeenCalledWith({ type: 'SET_SNAP', payload: false }) + expect(draw.setSnapEnabled).toHaveBeenCalledWith(false) + }) + test('prefers explicit option snapLayers and flags them', () => { const { context, draw, dispatch } = makeContext() newLine(context, 'f1', { snapLayers: ['opt'] }) diff --git a/plugins/draw/src/api/newPolygon.js b/plugins/draw/src/api/newPolygon.js index a99658bc0..b96436290 100644 --- a/plugins/draw/src/api/newPolygon.js +++ b/plugins/draw/src/api/newPolygon.js @@ -15,6 +15,10 @@ export const newPolygon = ({ appState, appConfig, pluginConfig, pluginState, map draw.setSnapLayers(snapLayers) dispatch({ type: 'SET_HAS_SNAP_LAYERS', payload: snapLayers?.length > 0 }) + // A brand-new drawing always starts with snapping off. + dispatch({ type: 'SET_SNAP', payload: false }) + draw.setSnapEnabled(false) + const { stroke, fill, strokeWidth, properties: customProperties, ...modeOptions } = options const properties = { ...customProperties, diff --git a/plugins/draw/src/api/newPolygon.test.js b/plugins/draw/src/api/newPolygon.test.js index 5f8ee6bef..d507949c0 100644 --- a/plugins/draw/src/api/newPolygon.test.js +++ b/plugins/draw/src/api/newPolygon.test.js @@ -8,7 +8,7 @@ jest.mock('../utils/flattenStyleProperties.js', () => ({ const makeContext = (overrides = {}) => { const dispatch = jest.fn() const eventBus = { emit: jest.fn() } - const draw = { setSnapLayers: jest.fn(), changeMode: jest.fn(), isSnapEnabled: jest.fn(() => true) } + const draw = { setSnapLayers: jest.fn(), setSnapEnabled: jest.fn(), changeMode: jest.fn(), isSnapEnabled: jest.fn(() => true) } const context = { appState: { layoutRefs: { viewportRef: { current: 'viewport' } }, interfaceType: 'mouse' }, appConfig: { id: 'app' }, @@ -56,6 +56,13 @@ describe('newPolygon', () => { expect(dispatch).toHaveBeenCalledWith({ type: 'SET_MODE', payload: 'draw_polygon' }) }) + test('resets snap to off when starting a fresh drawing', () => { + const { context, draw, dispatch } = makeContext() + newPolygon(context, 'f1') + expect(dispatch).toHaveBeenCalledWith({ type: 'SET_SNAP', payload: false }) + expect(draw.setSnapEnabled).toHaveBeenCalledWith(false) + }) + test('prefers explicit option snapLayers and flags them', () => { const { context, draw, dispatch } = makeContext() newPolygon(context, 'f1', { snapLayers: ['opt'] }) diff --git a/plugins/draw/src/events.js b/plugins/draw/src/events.js index a8e33c110..7829c0fdf 100644 --- a/plugins/draw/src/events.js +++ b/plugins/draw/src/events.js @@ -11,7 +11,7 @@ function createHandlers ({ pluginState, mapProvider, eventBus, resetState, disab const { draw } = mapProvider const { feature, tempFeature } = pluginState return { - handleDone: () => { disableSnap(); draw.done() }, + handleDone: () => { draw.done() }, handleCancel: () => { const mode = draw.getMode() if (mode === 'edit_vertex' && tempFeature?.id) { draw.add(feature) } @@ -24,8 +24,8 @@ function createHandlers ({ pluginState, mapProvider, eventBus, resetState, disab pluginState.dispatch({ type: 'TOGGLE_SNAP' }) draw.setSnapEnabled(!pluginState.snap) }, - onCreate: (f) => { disableSnap(); resetState(); setTimeout(() => draw.changeMode('disabled'), 0); eventBus.emit('draw:created', f) }, - onEditFinish: (f) => { disableSnap(); resetState(); setTimeout(() => draw.changeMode('disabled'), 0); eventBus.emit('draw:edited', f) }, + onCreate: (f) => { resetState(); setTimeout(() => draw.changeMode('disabled'), 0); eventBus.emit('draw:created', f) }, + onEditFinish: (f) => { resetState(); setTimeout(() => draw.changeMode('disabled'), 0); eventBus.emit('draw:edited', f) }, onCancel: () => {}, onVertexSelection: (e) => { pluginState.dispatch({ type: 'SET_SELECTED_VERTEX_INDEX', payload: e }); eventBus.emit('draw:vertexselection', e) }, onVertexChange: (e) => { pluginState.dispatch({ type: 'SET_SELECTED_VERTEX_INDEX', payload: { index: -1, numVertices: e.numVertices } }) }, diff --git a/plugins/draw/src/events.test.js b/plugins/draw/src/events.test.js index a1105e6d5..fc25467ef 100644 --- a/plugins/draw/src/events.test.js +++ b/plugins/draw/src/events.test.js @@ -56,11 +56,11 @@ describe('attachEvents – wiring', () => { }) describe('button handlers', () => { - test('done disables snap and finishes', () => { + test('done finishes without resetting snap', () => { const { buttonConfig, draw, dispatch } = setup() buttonConfig.drawDone.onClick() - expect(dispatch).toHaveBeenCalledWith({ type: 'SET_SNAP', payload: false }) - expect(draw.setSnapEnabled).toHaveBeenCalledWith(false) + expect(dispatch).not.toHaveBeenCalledWith({ type: 'SET_SNAP', payload: false }) + expect(draw.setSnapEnabled).not.toHaveBeenCalledWith(false) expect(draw.done).toHaveBeenCalled() }) @@ -108,11 +108,11 @@ describe('button handlers', () => { }) describe('draw event handlers', () => { - test('create resets state, disables mode asynchronously and emits', () => { + test('create resets state, preserves snap, disables mode asynchronously and emits', () => { const { draw, dispatch, eventBus } = setup() drawHandler(draw, 'create')({ id: 'new' }) - expect(dispatch).toHaveBeenCalledWith({ type: 'SET_SNAP', payload: false }) + expect(dispatch).not.toHaveBeenCalledWith({ type: 'SET_SNAP', payload: false }) expect(dispatch).toHaveBeenCalledWith({ type: 'SET_MODE', payload: null }) expect(eventBus.emit).toHaveBeenCalledWith('draw:created', { id: 'new' }) From 2d4966c7b857c91d6dbf09fbb4ae50e485fe10e7 Mon Sep 17 00:00:00 2001 From: Dan Leech Date: Mon, 6 Jul 2026 12:59:12 +0100 Subject: [PATCH 46/89] Persist snap between all draw modes --- plugins/draw/src/api/newLine.js | 4 ---- plugins/draw/src/api/newLine.test.js | 9 +-------- plugins/draw/src/api/newPolygon.js | 4 ---- plugins/draw/src/api/newPolygon.test.js | 9 +-------- plugins/draw/src/events.js | 10 +++------- 5 files changed, 5 insertions(+), 31 deletions(-) diff --git a/plugins/draw/src/api/newLine.js b/plugins/draw/src/api/newLine.js index 14db9ecee..8d4424a45 100644 --- a/plugins/draw/src/api/newLine.js +++ b/plugins/draw/src/api/newLine.js @@ -15,10 +15,6 @@ export const newLine = ({ appState, appConfig, pluginConfig, pluginState, mapSta draw.setSnapLayers(snapLayers) dispatch({ type: 'SET_HAS_SNAP_LAYERS', payload: snapLayers?.length > 0 }) - // A brand-new drawing always starts with snapping off. - dispatch({ type: 'SET_SNAP', payload: false }) - draw.setSnapEnabled(false) - const { stroke, fill, strokeWidth, properties: customProperties, ...modeOptions } = options const properties = { ...customProperties, diff --git a/plugins/draw/src/api/newLine.test.js b/plugins/draw/src/api/newLine.test.js index 53afd45d7..b302a7ccd 100644 --- a/plugins/draw/src/api/newLine.test.js +++ b/plugins/draw/src/api/newLine.test.js @@ -8,7 +8,7 @@ jest.mock('../utils/flattenStyleProperties.js', () => ({ const makeContext = (overrides = {}) => { const dispatch = jest.fn() const eventBus = { emit: jest.fn() } - const draw = { setSnapLayers: jest.fn(), setSnapEnabled: jest.fn(), changeMode: jest.fn(), isSnapEnabled: jest.fn(() => false) } + const draw = { setSnapLayers: jest.fn(), changeMode: jest.fn(), isSnapEnabled: jest.fn(() => false) } const context = { appState: { layoutRefs: { viewportRef: { current: 'viewport' } }, interfaceType: 'mouse' }, appConfig: { id: 'app' }, @@ -56,13 +56,6 @@ describe('newLine', () => { expect(dispatch).toHaveBeenCalledWith({ type: 'SET_MODE', payload: 'draw_line' }) }) - test('resets snap to off when starting a fresh drawing', () => { - const { context, draw, dispatch } = makeContext() - newLine(context, 'f1') - expect(dispatch).toHaveBeenCalledWith({ type: 'SET_SNAP', payload: false }) - expect(draw.setSnapEnabled).toHaveBeenCalledWith(false) - }) - test('prefers explicit option snapLayers and flags them', () => { const { context, draw, dispatch } = makeContext() newLine(context, 'f1', { snapLayers: ['opt'] }) diff --git a/plugins/draw/src/api/newPolygon.js b/plugins/draw/src/api/newPolygon.js index b96436290..a99658bc0 100644 --- a/plugins/draw/src/api/newPolygon.js +++ b/plugins/draw/src/api/newPolygon.js @@ -15,10 +15,6 @@ export const newPolygon = ({ appState, appConfig, pluginConfig, pluginState, map draw.setSnapLayers(snapLayers) dispatch({ type: 'SET_HAS_SNAP_LAYERS', payload: snapLayers?.length > 0 }) - // A brand-new drawing always starts with snapping off. - dispatch({ type: 'SET_SNAP', payload: false }) - draw.setSnapEnabled(false) - const { stroke, fill, strokeWidth, properties: customProperties, ...modeOptions } = options const properties = { ...customProperties, diff --git a/plugins/draw/src/api/newPolygon.test.js b/plugins/draw/src/api/newPolygon.test.js index d507949c0..5f8ee6bef 100644 --- a/plugins/draw/src/api/newPolygon.test.js +++ b/plugins/draw/src/api/newPolygon.test.js @@ -8,7 +8,7 @@ jest.mock('../utils/flattenStyleProperties.js', () => ({ const makeContext = (overrides = {}) => { const dispatch = jest.fn() const eventBus = { emit: jest.fn() } - const draw = { setSnapLayers: jest.fn(), setSnapEnabled: jest.fn(), changeMode: jest.fn(), isSnapEnabled: jest.fn(() => true) } + const draw = { setSnapLayers: jest.fn(), changeMode: jest.fn(), isSnapEnabled: jest.fn(() => true) } const context = { appState: { layoutRefs: { viewportRef: { current: 'viewport' } }, interfaceType: 'mouse' }, appConfig: { id: 'app' }, @@ -56,13 +56,6 @@ describe('newPolygon', () => { expect(dispatch).toHaveBeenCalledWith({ type: 'SET_MODE', payload: 'draw_polygon' }) }) - test('resets snap to off when starting a fresh drawing', () => { - const { context, draw, dispatch } = makeContext() - newPolygon(context, 'f1') - expect(dispatch).toHaveBeenCalledWith({ type: 'SET_SNAP', payload: false }) - expect(draw.setSnapEnabled).toHaveBeenCalledWith(false) - }) - test('prefers explicit option snapLayers and flags them', () => { const { context, draw, dispatch } = makeContext() newPolygon(context, 'f1', { snapLayers: ['opt'] }) diff --git a/plugins/draw/src/events.js b/plugins/draw/src/events.js index 7829c0fdf..9588bc98f 100644 --- a/plugins/draw/src/events.js +++ b/plugins/draw/src/events.js @@ -7,7 +7,7 @@ */ import { ADAPTER_EVENTS } from './adapterEvents.js' -function createHandlers ({ pluginState, mapProvider, eventBus, resetState, disableSnap }) { +function createHandlers ({ pluginState, mapProvider, eventBus, resetState }) { const { draw } = mapProvider const { feature, tempFeature } = pluginState return { @@ -15,7 +15,7 @@ function createHandlers ({ pluginState, mapProvider, eventBus, resetState, disab handleCancel: () => { const mode = draw.getMode() if (mode === 'edit_vertex' && tempFeature?.id) { draw.add(feature) } - disableSnap(); draw.cancel(); resetState() + draw.cancel(); resetState() eventBus.emit('draw:cancelled', feature) }, handleUndo: () => draw.undo(), @@ -78,11 +78,7 @@ export function attachEvents ({ pluginState, mapProvider, buttonConfig, eventBus pluginState.dispatch({ type: 'SET_MODE', payload: null }) pluginState.dispatch({ type: 'SET_FEATURE', payload: { feature: null, tempFeature: null } }) } - const disableSnap = () => { - pluginState.dispatch({ type: 'SET_SNAP', payload: false }) - draw.setSnapEnabled(false) - } - const handlers = createHandlers({ pluginState, mapProvider, eventBus, resetState, disableSnap }) + const handlers = createHandlers({ pluginState, mapProvider, eventBus, resetState }) attachButtonHandlers(buttonConfig, handlers) attachDrawEvents(draw, handlers) return () => { detachButtonHandlers(buttonConfig); detachDrawEvents(draw, handlers) } From 54b35492151725f0fee23e064be02dd5e0f795c9 Mon Sep 17 00:00:00 2001 From: Dan Leech Date: Mon, 6 Jul 2026 13:12:15 +0100 Subject: [PATCH 47/89] Sonar cloud simplification warnings for two functions --- .../maplibre/modes/drawMode/undoHandlers.js | 169 ++++++++++-------- .../modes/editVertexMode/vertexQueries.js | 2 +- .../src/adapters/openlayers/core/styles.js | 6 +- .../adapters/openlayers/edit/touchHandler.js | 149 ++++++++------- .../src/adapters/openlayers/edit/vertexOps.js | 2 +- 5 files changed, 181 insertions(+), 147 deletions(-) diff --git a/plugins/draw/src/adapters/maplibre/modes/drawMode/undoHandlers.js b/plugins/draw/src/adapters/maplibre/modes/drawMode/undoHandlers.js index 9e6a00981..ec32b1377 100644 --- a/plugins/draw/src/adapters/maplibre/modes/drawMode/undoHandlers.js +++ b/plugins/draw/src/adapters/maplibre/modes/drawMode/undoHandlers.js @@ -3,7 +3,9 @@ * last placed vertex, reinitialising a feature, and the rubber-band update that follows. * Part of createDrawMode. */ -export const createUndoHandlers = ({ ParentMode, featureProp, geometryType, getCoords, getFeature, RUBBER_BAND_OFFSET }) => ({ + +// Undo-stack and event wiring: pushing operations and reacting to undo triggers. +const createUndoStackHandlers = ({ geometryType, getFeature }) => ({ /** * Push an undo operation for the last added vertex */ @@ -20,6 +22,37 @@ export const createUndoHandlers = ({ ParentMode, featureProp, geometryType, getC }) }, + /** + * Handle draw.undo event + */ + onUndo (state, e) { + if (e.operation?.type === 'draw_vertex') { + this.undoVertex(state) + } + }, + + _handleUndoKeydown (state, e) { + const tag = document.activeElement?.tagName + if (tag === 'INPUT' || tag === 'TEXTAREA') { + return + } + e.preventDefault() + e.stopPropagation() + const undoStack = this.map._undoStack + if (undoStack && undoStack.length > 0) { + const operation = undoStack.pop() + if (operation?.type === 'draw_vertex') { + // Set flag to prevent click interference during undo + this.map._undoInProgress = true + setTimeout(() => { this.map._undoInProgress = false }, 100) + this.undoVertex(state) + } + } + } +}) + +// Vertex-level undo: removing the last vertex and keeping the rubber band in sync. +const createVertexUndoHandlers = ({ ParentMode, geometryType, getCoords, getFeature, RUBBER_BAND_OFFSET }) => ({ /** * Undo the last added vertex during drawing */ @@ -40,60 +73,6 @@ export const createUndoHandlers = ({ ParentMode, featureProp, geometryType, getC return true }, - /** - * Reinitialize feature when undoing to 0 vertices - * For Polygon: reinitialize in place - * For LineString: restart the draw mode with fresh state - */ - _reinitializeFeature (state, feature) { - const featureId = feature.id - this._ctx.store.delete([featureId]) - - // LineString: restart the draw mode with fresh state but same feature ID - if (geometryType === 'LineString') { - const undoStack = this.map._undoStack - if (undoStack) { - undoStack.clear() - } - // Restart draw with same options (excludeFeatureIdFromSetup prevents "continue" mode) - this._ctx.api.changeMode('draw_line', { - featureId, - container: state.container, - interfaceType: state.interfaceType, - crossHair: state.crossHair, - vertexMarkerId: state.vertexMarkerId, - addVertexButtonId: state.addVertexButtonId, - getSnapEnabled: state.getSnapEnabled, - properties: state.properties - }) - return true - } - - // Polygon: reinitialize in place - const center = this.map.getCenter() - const initialCoords = [[center.lng, center.lat], [center.lng, center.lat]] - const newFeature = this.newFeature({ - type: 'Feature', - properties: state.properties || {}, - geometry: { - type: geometryType, - coordinates: [initialCoords] - } - }) - newFeature.id = featureId - this._ctx.store.add(newFeature) - - state[featureProp] = newFeature - state.currentVertexPosition = 0 - - this._ctx.store.render() - this._simulateMouse('mousemove', ParentMode.onMouseMove, state) - this._ctx.store.render() - - this.dispatchVertexChange(initialCoords) - return true - }, - /** * Remove the last committed vertex and update rubber band */ @@ -136,33 +115,73 @@ export const createUndoHandlers = ({ ParentMode, featureProp, geometryType, getC this._ctx.store.render() } this.dispatchVertexChange(coords) - }, + } +}) +// Reinitialising back to zero vertices: reinitialize Polygon in place, restart LineString. +const createFeatureReinitHandlers = ({ ParentMode, featureProp, geometryType }) => ({ /** - * Handle draw.undo event + * Reinitialize feature when undoing to 0 vertices */ - onUndo (state, e) { - if (e.operation?.type === 'draw_vertex') { - this.undoVertex(state) + _reinitializeFeature (state, feature) { + const featureId = feature.id + this._ctx.store.delete([featureId]) + + // LineString: restart the draw mode with fresh state but same feature ID + if (geometryType === 'LineString') { + return this._restartLineStringDraw(state, featureId) } + + // Polygon: reinitialize in place + const center = this.map.getCenter() + const initialCoords = [[center.lng, center.lat], [center.lng, center.lat]] + const newFeature = this.newFeature({ + type: 'Feature', + properties: state.properties || {}, + geometry: { + type: geometryType, + coordinates: [initialCoords] + } + }) + newFeature.id = featureId + this._ctx.store.add(newFeature) + + state[featureProp] = newFeature + state.currentVertexPosition = 0 + + this._ctx.store.render() + this._simulateMouse('mousemove', ParentMode.onMouseMove, state) + this._ctx.store.render() + + this.dispatchVertexChange(initialCoords) + return true }, - _handleUndoKeydown (state, e) { - const tag = document.activeElement?.tagName - if (tag === 'INPUT' || tag === 'TEXTAREA') { - return - } - e.preventDefault() - e.stopPropagation() + /** + * Restart the LineString draw mode with fresh state but the same feature ID + */ + _restartLineStringDraw (state, featureId) { const undoStack = this.map._undoStack - if (undoStack && undoStack.length > 0) { - const operation = undoStack.pop() - if (operation?.type === 'draw_vertex') { - // Set flag to prevent click interference during undo - this.map._undoInProgress = true - setTimeout(() => { this.map._undoInProgress = false }, 100) - this.undoVertex(state) - } + if (undoStack) { + undoStack.clear() } + // Restart draw with same options (excludeFeatureIdFromSetup prevents "continue" mode) + this._ctx.api.changeMode('draw_line', { + featureId, + container: state.container, + interfaceType: state.interfaceType, + crossHair: state.crossHair, + vertexMarkerId: state.vertexMarkerId, + addVertexButtonId: state.addVertexButtonId, + getSnapEnabled: state.getSnapEnabled, + properties: state.properties + }) + return true } }) + +export const createUndoHandlers = (deps) => ({ + ...createUndoStackHandlers(deps), + ...createVertexUndoHandlers(deps), + ...createFeatureReinitHandlers(deps) +}) diff --git a/plugins/draw/src/adapters/maplibre/modes/editVertexMode/vertexQueries.js b/plugins/draw/src/adapters/maplibre/modes/editVertexMode/vertexQueries.js index 173ead373..8f0660c0c 100644 --- a/plugins/draw/src/adapters/maplibre/modes/editVertexMode/vertexQueries.js +++ b/plugins/draw/src/adapters/maplibre/modes/editVertexMode/vertexQueries.js @@ -22,7 +22,7 @@ export const vertexQueries = { if (currentIdx >= 0) { return matches.reduce((best, idx) => Math.abs(idx - currentIdx) < Math.abs(best - currentIdx) ? idx : best - ) + , matches[0]) } return matches[0] }, diff --git a/plugins/draw/src/adapters/openlayers/core/styles.js b/plugins/draw/src/adapters/openlayers/core/styles.js index 73ee58281..294844521 100644 --- a/plugins/draw/src/adapters/openlayers/core/styles.js +++ b/plugins/draw/src/adapters/openlayers/core/styles.js @@ -6,8 +6,10 @@ import MultiPoint from 'ol/geom/MultiPoint.js' import { SIZES } from '../defaults.js' import { getPlacedSketchCoords } from '../utils/sketchHelpers.js' -const selectedVertexRadii = { outer: SIZES.vertexHaloRadius + 3, mid: SIZES.vertexHaloRadius, inner: SIZES.vertexRadius } -const selectedMidpointRadii = { outer: SIZES.midpointHaloRadius + 3, mid: SIZES.midpointHaloRadius, inner: SIZES.midpointRadius } +const HALO_RADIUS_OFFSET = 3 + +const selectedVertexRadii = { outer: SIZES.vertexHaloRadius + HALO_RADIUS_OFFSET, mid: SIZES.vertexHaloRadius, inner: SIZES.vertexRadius } +const selectedMidpointRadii = { outer: SIZES.midpointHaloRadius + HALO_RADIUS_OFFSET, mid: SIZES.midpointHaloRadius, inner: SIZES.midpointRadius } const fillArc = (ctx, cx, cy, radius, fillStyle) => { ctx.beginPath() diff --git a/plugins/draw/src/adapters/openlayers/edit/touchHandler.js b/plugins/draw/src/adapters/openlayers/edit/touchHandler.js index e7b41ca2f..d362dd80a 100644 --- a/plugins/draw/src/adapters/openlayers/edit/touchHandler.js +++ b/plugins/draw/src/adapters/openlayers/edit/touchHandler.js @@ -7,86 +7,99 @@ const TAP_MOVE_THRESHOLD = 10 const TAP_TIME_THRESHOLD = 400 const TOUCH_TOLERANCE = 24 -const wireTouchEvents = ({ container, map, targetEl, olToCSS, cssToOl, getState, setState, onVertexMoved, onTap, snap }) => { - let dragStartCoord = null - let dragStartIndex = null - let vertexTouchDelta = null - let targetTouchDelta = null - let tapStart = null - - const onTouchstart = (e) => { - const touch = e.touches[0] - const onTarget = isOnTouchTarget(e.target) - tapStart = { x: touch.clientX, y: touch.clientY, time: Date.now(), onTarget } - if (!onTarget) { - return - } - const { selectedVertexIndex, vertices } = getState() - const vertex = vertices[selectedVertexIndex] - if (!vertex) { - return - } - const tOl = map.getEventPixel({ clientX: touch.clientX, clientY: touch.clientY }) - const vertexPx = coordToPixel(map, vertex) - const style = getComputedStyle(targetEl) - const svgOlPx = cssToOl({ x: Number.parseFloat(style.left), y: Number.parseFloat(style.top) }) - dragStartCoord = [...vertex] - dragStartIndex = selectedVertexIndex - vertexTouchDelta = { x: tOl[0] - vertexPx.x, y: tOl[1] - vertexPx.y } - targetTouchDelta = { x: tOl[0] - svgOlPx.x, y: tOl[1] - svgOlPx.y } - e.preventDefault() +const handleTouchStart = (e, { map, targetEl, cssToOl, getState, drag }) => { + const touch = e.touches[0] + const onTarget = isOnTouchTarget(e.target) + drag.tapStart = { x: touch.clientX, y: touch.clientY, time: Date.now(), onTarget } + if (!onTarget) { + return + } + const { selectedVertexIndex, vertices } = getState() + const vertex = vertices[selectedVertexIndex] + if (!vertex) { + return } + const tOl = map.getEventPixel({ clientX: touch.clientX, clientY: touch.clientY }) + const vertexPx = coordToPixel(map, vertex) + const style = getComputedStyle(targetEl) + const svgOlPx = cssToOl({ x: Number.parseFloat(style.left), y: Number.parseFloat(style.top) }) + drag.dragStartCoord = [...vertex] + drag.dragStartIndex = selectedVertexIndex + drag.vertexTouchDelta = { x: tOl[0] - vertexPx.x, y: tOl[1] - vertexPx.y } + drag.targetTouchDelta = { x: tOl[0] - svgOlPx.x, y: tOl[1] - svgOlPx.y } + e.preventDefault() +} - const onTouchmove = (e) => { - if (!isOnTouchTarget(e.target) || dragStartIndex == null) { - return - } - e.preventDefault() - const tOl = map.getEventPixel({ clientX: e.touches[0].clientX, clientY: e.touches[0].clientY }) - const rawCoord = pixelToCoord(map, { x: tOl[0] - vertexTouchDelta.x, y: tOl[1] - vertexTouchDelta.y }) - const newCoord = snap ? snap.apply(rawCoord) : rawCoord - snap?.hideIndicator() - const { olFeature, vertices } = getState() - if (!olFeature) { - return - } - moveVertex(olFeature, dragStartIndex, newCoord) - setState({ vertices: vertices.map((c, i) => i === dragStartIndex ? newCoord : c) }) - showTouchTarget(targetEl, olToCSS({ x: tOl[0] - targetTouchDelta.x, y: tOl[1] - targetTouchDelta.y })) +const handleTouchMove = (e, { map, targetEl, olToCSS, getState, setState, snap, drag }) => { + if (!isOnTouchTarget(e.target) || drag.dragStartIndex == null) { + return + } + e.preventDefault() + const tOl = map.getEventPixel({ clientX: e.touches[0].clientX, clientY: e.touches[0].clientY }) + const rawCoord = pixelToCoord(map, { x: tOl[0] - drag.vertexTouchDelta.x, y: tOl[1] - drag.vertexTouchDelta.y }) + const newCoord = snap ? snap.apply(rawCoord) : rawCoord + snap?.hideIndicator() + const { olFeature, vertices } = getState() + if (!olFeature) { + return } + moveVertex(olFeature, drag.dragStartIndex, newCoord) + setState({ vertices: vertices.map((c, i) => i === drag.dragStartIndex ? newCoord : c) }) + showTouchTarget(targetEl, olToCSS({ x: tOl[0] - drag.targetTouchDelta.x, y: tOl[1] - drag.targetTouchDelta.y })) +} - const onTouchend = (e) => { - const wasDragging = dragStartIndex != null - if (!wasDragging) { - if (tapStart && !tapStart.onTarget && e.changedTouches.length > 0) { - const t = e.changedTouches[0] - const dt = Date.now() - tapStart.time - if (Math.hypot(t.clientX - tapStart.x, t.clientY - tapStart.y) < TAP_MOVE_THRESHOLD && dt < TAP_TIME_THRESHOLD) { - const tOl = map.getEventPixel({ clientX: t.clientX, clientY: t.clientY }) - const tapState = getState() - onTap?.(findNearest(map, tapState.vertices, tapState.midpoints, { x: tOl[0], y: tOl[1] }, TOUCH_TOLERANCE)) - e.preventDefault() - } - } - tapStart = null - return - } - tapStart = null - const { vertices } = getState() - if (vertices[dragStartIndex] && dragStartCoord) { - onVertexMoved({ vertexIndex: dragStartIndex, previousCoord: dragStartCoord }) - } - snap?.hideIndicator() - dragStartCoord = null; dragStartIndex = null; vertexTouchDelta = null; targetTouchDelta = null +const handleTap = (e, { map, getState, onTap, drag }) => { + if (!drag.tapStart || drag.tapStart.onTarget || e.changedTouches.length === 0) { + return + } + const t = e.changedTouches[0] + const dt = Date.now() - drag.tapStart.time + if (Math.hypot(t.clientX - drag.tapStart.x, t.clientY - drag.tapStart.y) < TAP_MOVE_THRESHOLD && dt < TAP_TIME_THRESHOLD) { + const tOl = map.getEventPixel({ clientX: t.clientX, clientY: t.clientY }) + const tapState = getState() + onTap?.(findNearest(map, tapState.vertices, tapState.midpoints, { x: tOl[0], y: tOl[1] }, TOUCH_TOLERANCE)) e.preventDefault() } +} + +const handleTouchEnd = (e, ctx) => { + const { getState, onVertexMoved, snap, drag } = ctx + if (drag.dragStartIndex == null) { + handleTap(e, ctx) + drag.tapStart = null + return + } + drag.tapStart = null + const { vertices } = getState() + if (vertices[drag.dragStartIndex] && drag.dragStartCoord) { + onVertexMoved({ vertexIndex: drag.dragStartIndex, previousCoord: drag.dragStartCoord }) + } + snap?.hideIndicator() + drag.dragStartCoord = null; drag.dragStartIndex = null; drag.vertexTouchDelta = null; drag.targetTouchDelta = null + e.preventDefault() +} + +const wireTouchEvents = (deps) => { + const { container } = deps + const drag = { + dragStartCoord: null, + dragStartIndex: null, + vertexTouchDelta: null, + targetTouchDelta: null, + tapStart: null + } + const ctx = { ...deps, drag } + + const onTouchstart = (e) => handleTouchStart(e, ctx) + const onTouchmove = (e) => handleTouchMove(e, ctx) + const onTouchend = (e) => handleTouchEnd(e, ctx) container.addEventListener('touchstart', onTouchstart, { passive: false }) container.addEventListener('touchmove', onTouchmove, { passive: false }) container.addEventListener('touchend', onTouchend, { passive: false }) return { - isDragging: () => dragStartIndex != null, + isDragging: () => drag.dragStartIndex != null, destroy () { container.removeEventListener('touchstart', onTouchstart) container.removeEventListener('touchmove', onTouchmove) diff --git a/plugins/draw/src/adapters/openlayers/edit/vertexOps.js b/plugins/draw/src/adapters/openlayers/edit/vertexOps.js index e35b9cdb6..fde9ea04f 100644 --- a/plugins/draw/src/adapters/openlayers/edit/vertexOps.js +++ b/plugins/draw/src/adapters/openlayers/edit/vertexOps.js @@ -22,7 +22,7 @@ export const deleteVertex = (olFeature, selectedIndex) => { } const { segment } = result - const minVertices = segment.closed ? 3 : 2 + const minVertices = segment.closed ? 3 : 2 // NOSONAR, min vertecies in ring if (segment.length <= minVertices) { return null } From 71d0255588fd785c2f9436c096e06eb6faa4057a Mon Sep 17 00:00:00 2001 From: Dan Leech Date: Mon, 6 Jul 2026 13:51:28 +0100 Subject: [PATCH 48/89] Keyboard shortcuts for drawing added --- plugins/draw/src/manifest.js | 35 ++++++++++++++++-- plugins/draw/src/manifest.test.js | 25 +++++++++++++ .../components/KeyboardHelp/KeyboardHelp.jsx | 2 +- .../KeyboardHelp/KeyboardHelp.test.jsx | 11 ++++++ src/utils/isMac.js | 15 ++++++++ src/utils/isMac.test.js | 37 +++++++++++++++++++ 6 files changed, 121 insertions(+), 4 deletions(-) create mode 100644 src/utils/isMac.js create mode 100644 src/utils/isMac.test.js diff --git a/plugins/draw/src/manifest.js b/plugins/draw/src/manifest.js index bf7ffc9df..2fa282d1c 100644 --- a/plugins/draw/src/manifest.js +++ b/plugins/draw/src/manifest.js @@ -7,6 +7,10 @@ import { addFeature } from './api/addFeature.js' import { deleteFeature } from './api/deleteFeature.js' import { split } from './api/split.js' import { merge } from './api/merge.js' +import { isMac } from '../../../src/utils/isMac.js' + +// Show the platform-appropriate undo modifier (⌘ on macOS, Ctrl elsewhere). +const undoCommand = isMac() ? 'Command + Z' : 'Ctrl + Z' const createButtonSlots = (showLabel) => ({ mobile: { slot: 'actions', showLabel }, @@ -99,10 +103,35 @@ export const manifest = { ], keyboardShortcuts: [{ - id: 'drawStart', + id: 'drawAddPoint', + group: 'Drawing', + title: 'Add new point', + command: 'Enter' + }, { + id: 'drawSelectPoint', + group: 'Drawing', + title: 'Select point closest to centre', + command: 'Spacebar' + }, { + id: 'drawMovePoint', + group: 'Drawing', + title: 'Move point', + command: ' or ' + }, { + id: 'drawNudgePoint', + group: 'Drawing', + title: 'Nudge point', + command: 'Shift + or ' + }, { + id: 'drawDeletePoint', + group: 'Drawing', + title: 'Delete point', + command: 'Delete' + }, { + id: 'drawUndo', group: 'Drawing', - title: 'Edit vertex', - command: 'Spacebar' + title: 'Undo', + command: undoCommand }], icons: [{ diff --git a/plugins/draw/src/manifest.test.js b/plugins/draw/src/manifest.test.js index b14feb4db..2c3acdf26 100644 --- a/plugins/draw/src/manifest.test.js +++ b/plugins/draw/src/manifest.test.js @@ -104,3 +104,28 @@ describe('drawMenu', () => { }) }) }) + +describe('drawUndo keyboard shortcut (platform-specific command)', () => { + const loadUndoCommand = (mac) => { + let command + jest.isolateModules(() => { + jest.doMock('../../../src/utils/isMac.js', () => ({ isMac: () => mac })) + const { manifest: reloaded } = require('./manifest.js') + command = reloaded.keyboardShortcuts.find((s) => s.id === 'drawUndo').command + }) + return command + } + + afterEach(() => { + jest.dontMock('../../../src/utils/isMac.js') + jest.resetModules() + }) + + test('uses Command on macOS', () => { + expect(loadUndoCommand(true)).toBe('Command + Z') + }) + + test('uses Ctrl on non-mac platforms', () => { + expect(loadUndoCommand(false)).toBe('Ctrl + Z') + }) +}) diff --git a/src/App/components/KeyboardHelp/KeyboardHelp.jsx b/src/App/components/KeyboardHelp/KeyboardHelp.jsx index e35c927a4..00091a405 100755 --- a/src/App/components/KeyboardHelp/KeyboardHelp.jsx +++ b/src/App/components/KeyboardHelp/KeyboardHelp.jsx @@ -46,7 +46,7 @@ export const KeyboardHelp = ({ context = 'viewport' }) => { // NOSONAR: project const allShortcuts = getKeyboardShortcuts(appConfig) const shortcuts = listboxIsActive ? allShortcuts - : allShortcuts.filter(s => !s.group && s.context !== 'listbox') + : allShortcuts.filter(s => s.context !== 'listbox') const groupMap = buildGroupMap(shortcuts) const groupEntries = Object.entries(groupMap) diff --git a/src/App/components/KeyboardHelp/KeyboardHelp.test.jsx b/src/App/components/KeyboardHelp/KeyboardHelp.test.jsx index cb1b73ccd..81a829e45 100755 --- a/src/App/components/KeyboardHelp/KeyboardHelp.test.jsx +++ b/src/App/components/KeyboardHelp/KeyboardHelp.test.jsx @@ -163,6 +163,17 @@ describe('KeyboardHelp — listboxIsActive filtering', () => { expect(screen.getByText('Move')).toBeInTheDocument() }) + it('shows non-listbox grouped shortcuts (e.g. Drawing) when listboxIsActive is false', () => { + getKeyboardShortcuts.mockReturnValue([ + ...VIEWPORT_SHORTCUTS, + { id: 'd1', group: 'Drawing', title: 'Add new point', command: 'Enter' }, + ...LISTBOX_SHORTCUTS + ]) + render() + expect(screen.getByRole('tab', { name: 'Drawing' })).toBeInTheDocument() + expect(screen.queryByRole('tab', { name: SELECT_FEATURES_GROUP })).not.toBeInTheDocument() + }) + it('renders as a flat list when only ungrouped shortcuts remain after filtering', () => { getKeyboardShortcuts.mockReturnValue([...VIEWPORT_SHORTCUTS, ...LISTBOX_SHORTCUTS]) render() diff --git a/src/utils/isMac.js b/src/utils/isMac.js new file mode 100644 index 000000000..df5211645 --- /dev/null +++ b/src/utils/isMac.js @@ -0,0 +1,15 @@ +/** + * Detects whether the current platform is macOS. + * + * Prefers the modern User-Agent Client Hints API and falls back to the legacy + * `navigator.platform`. Guarded so it is safe to call in non-browser + * environments (Node/SSR/tests), where it returns `false`. + * + * @returns {boolean} True when running on macOS. + */ +export const isMac = () => { + if (typeof navigator === 'undefined') { + return false + } + return /mac/i.test(navigator.userAgentData?.platform || navigator.platform || '') +} diff --git a/src/utils/isMac.test.js b/src/utils/isMac.test.js new file mode 100644 index 000000000..8f7345b9a --- /dev/null +++ b/src/utils/isMac.test.js @@ -0,0 +1,37 @@ +import { isMac } from './isMac.js' + +describe('isMac', () => { + const original = Object.getOwnPropertyDescriptor(global, 'navigator') + + afterEach(() => { + if (original) { + Object.defineProperty(global, 'navigator', original) + } else { + delete global.navigator + } + }) + + const setNavigator = (value) => { + Object.defineProperty(global, 'navigator', { value, configurable: true, writable: true }) + } + + test('returns false when navigator is undefined', () => { + setNavigator(undefined) + expect(isMac()).toBe(false) + }) + + test('detects macOS via userAgentData.platform', () => { + setNavigator({ userAgentData: { platform: 'macOS' }, platform: '' }) + expect(isMac()).toBe(true) + }) + + test('falls back to navigator.platform', () => { + setNavigator({ platform: 'MacIntel' }) + expect(isMac()).toBe(true) + }) + + test('returns false on non-mac platforms', () => { + setNavigator({ userAgentData: { platform: 'Windows' }, platform: 'Win32' }) + expect(isMac()).toBe(false) + }) +}) From 8429f867568e0c33d70bce7b76c5437a679fa562 Mon Sep 17 00:00:00 2001 From: Dan Leech Date: Mon, 6 Jul 2026 14:03:12 +0100 Subject: [PATCH 49/89] Draw keyboard controls registered --- plugins/draw/src/manifest.js | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/plugins/draw/src/manifest.js b/plugins/draw/src/manifest.js index 2fa282d1c..1d09ef85a 100644 --- a/plugins/draw/src/manifest.js +++ b/plugins/draw/src/manifest.js @@ -105,27 +105,32 @@ export const manifest = { keyboardShortcuts: [{ id: 'drawAddPoint', group: 'Drawing', - title: 'Add new point', + title: 'Add point (draw)', command: 'Enter' }, { id: 'drawSelectPoint', group: 'Drawing', - title: 'Select point closest to centre', + title: 'Select nearest point (edit)', command: 'Spacebar' + }, { + id: 'drawSelectAdjacentPoint', + group: 'Drawing', + title: 'Select adjacent point (edit)', + command: 'Alt + or ' }, { id: 'drawMovePoint', group: 'Drawing', - title: 'Move point', + title: 'Move point (edit)', command: ' or ' }, { id: 'drawNudgePoint', group: 'Drawing', - title: 'Nudge point', + title: 'Nudge point (edit)', command: 'Shift + or ' }, { id: 'drawDeletePoint', group: 'Drawing', - title: 'Delete point', + title: 'Delete point (edit)', command: 'Delete' }, { id: 'drawUndo', From 69d144305c97ecd129040baacbfb858d61311f6c Mon Sep 17 00:00:00 2001 From: Dan Leech Date: Tue, 7 Jul 2026 11:02:08 +0100 Subject: [PATCH 50/89] Geometry validation basics --- plugins/draw/src/adapterEvents.js | 15 +- plugins/draw/src/adapterEvents.test.js | 3 +- .../adapters/maplibre/MaplibreDrawAdapter.js | 75 ++++- .../maplibre/MaplibreDrawAdapter.test.js | 126 +++++++++ .../draw/src/adapters/maplibre/drawEvents.js | 3 +- .../maplibre/modes/drawMode/clickHandlers.js | 159 ++++++++--- .../modes/drawMode/clickHandlers.test.js | 109 ++++++++ .../modes/drawMode/pointerHandlers.js | 9 +- .../maplibre/modes/drawMode/undoHandlers.js | 32 ++- .../modes/drawMode/undoHandlers.test.js | 26 +- .../modes/editVertexMode/undoHandlers.js | 34 +++ .../modes/editVertexMode/undoHandlers.test.js | 54 ++++ plugins/draw/src/adapters/maplibre/styles.js | 18 ++ .../draw/src/adapters/maplibre/styles.test.js | 11 +- .../src/adapters/openlayers/OLDrawAdapter.js | 16 +- .../adapters/openlayers/OLDrawAdapter.test.js | 13 + .../openlayers/__helpers__/harness.js | 1 + .../adapters/openlayers/core/OLDrawManager.js | 5 + .../openlayers/core/OLDrawManager.test.js | 8 +- .../src/adapters/openlayers/core/styles.js | 19 +- .../adapters/openlayers/core/styles.test.js | 14 + .../src/adapters/openlayers/draw/DrawMode.js | 259 +++++++++++++----- .../adapters/openlayers/draw/DrawMode.test.js | 197 ++++++++++++- .../src/adapters/openlayers/draw/drawInput.js | 3 +- .../openlayers/draw/vertexPlacement.js | 5 +- .../openlayers/draw/vertexPlacement.test.js | 21 +- .../src/adapters/openlayers/edit/EditMode.js | 37 ++- .../adapters/openlayers/edit/EditMode.test.js | 25 ++ .../openlayers/edit/selectionState.js | 46 +++- .../openlayers/edit/selectionState.test.js | 28 ++ .../openlayers/utils/resolveColors.js | 1 + plugins/draw/src/api/editFeature.js | 10 + plugins/draw/src/api/editFeature.test.js | 36 +++ plugins/draw/src/api/newLine.js | 4 +- plugins/draw/src/api/newLine.test.js | 15 + plugins/draw/src/api/newPolygon.js | 4 +- plugins/draw/src/api/newPolygon.test.js | 15 + plugins/draw/src/api/split.js | 6 +- plugins/draw/src/defaults.js | 1 + plugins/draw/src/events.js | 98 ++++++- plugins/draw/src/events.test.js | 124 ++++++++- plugins/draw/src/manifest.js | 8 +- plugins/draw/src/manifest.test.js | 18 +- plugins/draw/src/reducer.js | 11 +- plugins/draw/src/validation/liveStroke.js | 75 +++++ .../src/validation/rules.areaError.test.js | 12 + plugins/draw/src/validation/rules.js | 175 ++++++++++++ plugins/draw/src/validation/rules.test.js | 120 ++++++++ .../draw/src/validation/validateGeometry.js | 92 +++++++ .../src/validation/validateGeometry.test.js | 97 +++++++ 50 files changed, 2116 insertions(+), 177 deletions(-) create mode 100644 plugins/draw/src/validation/liveStroke.js create mode 100644 plugins/draw/src/validation/rules.areaError.test.js create mode 100644 plugins/draw/src/validation/rules.js create mode 100644 plugins/draw/src/validation/rules.test.js create mode 100644 plugins/draw/src/validation/validateGeometry.js create mode 100644 plugins/draw/src/validation/validateGeometry.test.js diff --git a/plugins/draw/src/adapterEvents.js b/plugins/draw/src/adapterEvents.js index b68c63a35..cadbefc78 100644 --- a/plugins/draw/src/adapterEvents.js +++ b/plugins/draw/src/adapterEvents.js @@ -18,8 +18,18 @@ * VERTEX_CHANGE { numVertices } * UNDO_CHANGE number — current undo stack length * UPDATE GeoJSON feature after a vertex operation - * GEOMETRY_CHANGE in-progress geometry (real-time preview, e.g. split) + * GEOMETRY_CHANGE Two payload shapes share this event: + * - Preview (draw/split live update): the in-progress + * feature itself (has `coordinates`, no `kind`). + * - Commit-level validation: `{ feature, kind, vertexIndex }` + * where kind ∈ 'add' | 'move' | 'insert' | 'delete'. + * events.js validates these and reverts invalid ones. + * Listeners must guard for the shape they expect. * INTERFACE_TYPE_CHANGE { interfaceType: 'mouse' | 'touch' | 'keyboard' } + * PLACEMENT_BLOCKED { feature, reason, kind: 'place', mode, vertexIndex } — + * a vertex placement was rejected by validatePlacement + * (hard rule or user callback); feature is the candidate + * geometry that was refused. */ export const ADAPTER_EVENTS = { CREATE: 'create', @@ -30,5 +40,6 @@ export const ADAPTER_EVENTS = { UNDO_CHANGE: 'undochange', UPDATE: 'update', GEOMETRY_CHANGE: 'geometrychange', - INTERFACE_TYPE_CHANGE: 'interfacetypechange' + INTERFACE_TYPE_CHANGE: 'interfacetypechange', + PLACEMENT_BLOCKED: 'placementblocked' } diff --git a/plugins/draw/src/adapterEvents.test.js b/plugins/draw/src/adapterEvents.test.js index 0ece7508a..0fa1ac679 100644 --- a/plugins/draw/src/adapterEvents.test.js +++ b/plugins/draw/src/adapterEvents.test.js @@ -11,7 +11,8 @@ describe('adapter event contract', () => { UNDO_CHANGE: 'undochange', UPDATE: 'update', GEOMETRY_CHANGE: 'geometrychange', - INTERFACE_TYPE_CHANGE: 'interfacetypechange' + INTERFACE_TYPE_CHANGE: 'interfacetypechange', + PLACEMENT_BLOCKED: 'placementblocked' }) }) }) diff --git a/plugins/draw/src/adapters/maplibre/MaplibreDrawAdapter.js b/plugins/draw/src/adapters/maplibre/MaplibreDrawAdapter.js index 4eb30ef9b..6bae1f6b2 100644 --- a/plugins/draw/src/adapters/maplibre/MaplibreDrawAdapter.js +++ b/plugins/draw/src/adapters/maplibre/MaplibreDrawAdapter.js @@ -3,6 +3,7 @@ import { getSnapInstance, clearSnapState, clearSnapIndicator } from './utils/sna import { createEventBus } from '../../utils/eventBus.js' import { MAPBOX_DRAW_EVENTS, CUSTOM_DRAW_EVENTS, STYLE_DATA_EVENT } from './drawEvents.js' import { ADAPTER_EVENTS } from '../../adapterEvents.js' +import { createLiveStroke } from '../../validation/liveStroke.js' /** * Draw adapter for MapLibre GL. @@ -38,6 +39,10 @@ export class MaplibreDrawAdapter { this._draw = draw this._cleanupDraw = remove + // Drives the live invalid stroke from rubber-band moves (default rules sync, + // user callback throttled). onChange toggles the dashed stroke layers. + this._liveStroke = createLiveStroke({ onChange: (invalid) => this.setInvalid(invalid) }) + // Normalise ML map events → the shared adapter event contract (adapterEvents.js). // The OL adapter emits the same contract directly from OLDrawManager. this._mapHandlers = { @@ -49,7 +54,13 @@ export class MaplibreDrawAdapter { vertexchange: (e) => this._bus.emit(ADAPTER_EVENTS.VERTEX_CHANGE, { ...e, numVertices: e.numVertecies }), undochange: (e) => this._bus.emit(ADAPTER_EVENTS.UNDO_CHANGE, e.length), update: (e) => this._bus.emit(ADAPTER_EVENTS.UPDATE, e.features[0]), - geometrychange: (e) => this._bus.emit(ADAPTER_EVENTS.GEOMETRY_CHANGE, e), + geometrychange: (e) => { + // Kind-less events are rubber-band moves carrying the displayed feature + // (placed vertices + cursor) — they drive the live invalid stroke. + if (!e?.kind) { this._updateLiveStroke(e) } + this._bus.emit(ADAPTER_EVENTS.GEOMETRY_CHANGE, e) + }, + placementblocked: (e) => this._bus.emit(ADAPTER_EVENTS.PLACEMENT_BLOCKED, e), modechange: (e) => this._handleModeChange(e), styledata: () => this._handleStyleData() } @@ -62,6 +73,7 @@ export class MaplibreDrawAdapter { this._map.on(CUSTOM_DRAW_EVENTS.UNDO_CHANGE, this._mapHandlers.undochange) this._map.on(MAPBOX_DRAW_EVENTS.UPDATE, this._mapHandlers.update) this._map.on(CUSTOM_DRAW_EVENTS.GEOMETRY_CHANGE, this._mapHandlers.geometrychange) + this._map.on(CUSTOM_DRAW_EVENTS.PLACEMENT_BLOCKED, this._mapHandlers.placementblocked) this._map.on(MAPBOX_DRAW_EVENTS.MODE_CHANGE, this._mapHandlers.modechange) this._map.on(STYLE_DATA_EVENT, this._mapHandlers.styledata) } @@ -70,9 +82,41 @@ export class MaplibreDrawAdapter { if (name === 'edit_vertex') { this._editingFeatureId = options.featureId ?? null } + // A fresh draw always starts with a solid stroke; the live check owns it from here. + if (name === 'draw_polygon' || name === 'draw_line') { + this._liveStroke.reset() + this.setInvalid(false) + } this._draw.changeMode(name, options) } + // Live invalid-stroke driver for draw mode: called on every rubber-band move with + // the displayed feature (placed vertices + cursor). Delegates to the shared + // live-stroke controller, which runs the default rules synchronously and the user + // callback throttled, toggling the dashed stroke only when validity flips. Edit + // mode is driven from events.js on committed changes instead. + // + // MapLibre's fire() copies the payload onto an Event whose `type` is the event + // name, so the feature's geometry type is clobbered — rebuild the geometry from + // the coordinates using the active draw mode. + _updateLiveStroke (e) { + if (!e?.coordinates) { return } + const mode = this._draw.getMode() + // eslint-disable-next-line no-console -- TEMP live-stroke diagnostics + console.log('[live-stroke ML] _updateLiveStroke', { mode, hasCoords: !!e?.coordinates }) + let feature, placedCount + if (mode === 'draw_polygon') { + feature = { type: 'Feature', geometry: { type: 'Polygon', coordinates: e.coordinates } } + placedCount = (e.coordinates[0]?.length ?? 1) - 1 + } else if (mode === 'draw_line') { + feature = { type: 'Feature', geometry: { type: 'LineString', coordinates: e.coordinates } } + placedCount = (e.coordinates?.length ?? 1) - 1 + } else { + return + } + this._liveStroke.update({ feature, context: { mode }, placedCount, onGeometryChange: this._geometryValidator }) + } + getMode () { return this._draw.getMode() } setInterfaceType (type) { @@ -101,6 +145,33 @@ export class MaplibreDrawAdapter { this._map.fire(CUSTOM_DRAW_EVENTS.UNDO) } + // Record the current geometry validity so the draw mode can block finish gestures + // (double-click / click-to-close) while the in-progress shape is invalid. + setGeometryValid (valid) { + this._map._drawGeometryValid = valid + } + + // The api entry points assign the active user validator to the adapter; store it + // on the map (like _drawGeometryValid) so modes can veto placements synchronously. + set _geometryValidator (fn) { this._map._drawGeometryValidator = fn } + get _geometryValidator () { return this._map._drawGeometryValidator } + + // Toggle the active shape's stroke between solid (valid) and dashed (invalid) by + // swapping which of the two overlaid stroke layers is visible. + setInvalid (invalid) { + this._setLayerVisibility('stroke-active', !invalid) + this._setLayerVisibility('stroke-active-invalid', invalid) + } + + _setLayerVisibility (id, visible) { + ['hot', 'cold'].forEach((suffix) => { + const layerId = `${id}.${suffix}` + if (this._map.getLayer(layerId)) { + this._map.setLayoutProperty(layerId, 'visibility', visible ? 'visible' : 'none') + } + }) + } + deleteVertex () { // Intentionally a no-op on MapLibre. The shared events layer calls draw.deleteVertex() // from the delete-point button, but the ML edit mode already handles deletion itself: @@ -172,8 +243,10 @@ export class MaplibreDrawAdapter { this._map.off(CUSTOM_DRAW_EVENTS.UNDO_CHANGE, this._mapHandlers.undochange) this._map.off(MAPBOX_DRAW_EVENTS.UPDATE, this._mapHandlers.update) this._map.off(CUSTOM_DRAW_EVENTS.GEOMETRY_CHANGE, this._mapHandlers.geometrychange) + this._map.off(CUSTOM_DRAW_EVENTS.PLACEMENT_BLOCKED, this._mapHandlers.placementblocked) this._map.off(MAPBOX_DRAW_EVENTS.MODE_CHANGE, this._mapHandlers.modechange) this._map.off(STYLE_DATA_EVENT, this._mapHandlers.styledata) + this._liveStroke.destroy() this._cleanupDraw() } } diff --git a/plugins/draw/src/adapters/maplibre/MaplibreDrawAdapter.test.js b/plugins/draw/src/adapters/maplibre/MaplibreDrawAdapter.test.js index 473f87c15..537a47c62 100644 --- a/plugins/draw/src/adapters/maplibre/MaplibreDrawAdapter.test.js +++ b/plugins/draw/src/adapters/maplibre/MaplibreDrawAdapter.test.js @@ -125,6 +125,95 @@ describe('map event normalisation', () => { onHandler(map, CUSTOM_DRAW_EVENTS.GEOMETRY_CHANGE)(e) expect(bus.emit).toHaveBeenCalledWith('geometrychange', e) }) + + test('placementblocked forwards the raw event', () => { + const { map, bus } = setup() + const e = { kind: 'place', reason: 'outside region' } + onHandler(map, CUSTOM_DRAW_EVENTS.PLACEMENT_BLOCKED)(e) + expect(bus.emit).toHaveBeenCalledWith('placementblocked', e) + }) +}) + +describe('live invalid stroke (draw mode)', () => { + // Displayed rings (placed vertices + cursor) as the listener really receives them: + // MapLibre's fire() wraps the gl-draw feature in an Event whose `type` is the + // event name — the geometry type is clobbered, only `coordinates` survives. + const bowtie = { type: 'draw.geometrychange', coordinates: [[[0, 0], [10, 10], [10, 0], [0, 10]]] } + const square = { type: 'draw.geometrychange', coordinates: [[[0, 0], [10, 0], [10, 10], [0, 10]]] } + + const drawPolygonSetup = () => { + const fixture = setup() + fixture.draw.getMode.mockReturnValue('draw_polygon') + fixture.map.getLayer.mockReturnValue({}) + return fixture + } + + test('a self-intersecting displayed ring turns the stroke dashed; simple again restores it', () => { + const { map } = drawPolygonSetup() + const fire = onHandler(map, CUSTOM_DRAW_EVENTS.GEOMETRY_CHANGE) + + fire(bowtie) + expect(map.setLayoutProperty).toHaveBeenCalledWith('stroke-active-invalid.hot', 'visibility', 'visible') + + map.setLayoutProperty.mockClear() + fire(square) + expect(map.setLayoutProperty).toHaveBeenCalledWith('stroke-active.hot', 'visibility', 'visible') + expect(map.setLayoutProperty).toHaveBeenCalledWith('stroke-active-invalid.hot', 'visibility', 'none') + }) + + test('only restyles when the invalid state flips, not on every move', () => { + const { map } = drawPolygonSetup() + const fire = onHandler(map, CUSTOM_DRAW_EVENTS.GEOMETRY_CHANGE) + fire(bowtie) + const callsAfterFlip = map.setLayoutProperty.mock.calls.length + fire(bowtie) + fire(bowtie) + expect(map.setLayoutProperty.mock.calls.length).toBe(callsAfterFlip) + }) + + test('commit-level (kind-ful) events do not drive the stroke; events.js owns those', () => { + const { map } = drawPolygonSetup() + onHandler(map, CUSTOM_DRAW_EVENTS.GEOMETRY_CHANGE)({ feature: bowtie, kind: 'add', vertexIndex: 3 }) + expect(map.setLayoutProperty).not.toHaveBeenCalled() + }) + + test('lines never go dashed from the live check', () => { + const { map, draw } = drawPolygonSetup() + draw.getMode.mockReturnValue('draw_line') + onHandler(map, CUSTOM_DRAW_EVENTS.GEOMETRY_CHANGE)({ type: 'draw.geometrychange', coordinates: [[0, 0], [10, 10], [10, 0], [0, 10]] }) + expect(map.setLayoutProperty).not.toHaveBeenCalled() + }) + + test('the rubber band sitting on the just-placed vertex never reads as a crossing', () => { + const { map } = drawPolygonSetup() + // 3 placed + rubber band duplicating the last placed vertex. + const justPlaced = { type: 'draw.geometrychange', coordinates: [[[0, 0], [10, 0], [10, 10], [10, 10]]] } + onHandler(map, CUSTOM_DRAW_EVENTS.GEOMETRY_CHANGE)(justPlaced) + expect(map.setLayoutProperty).not.toHaveBeenCalledWith('stroke-active-invalid.hot', 'visibility', 'visible') + }) + + test('entering a draw mode resets the stroke to solid', () => { + const { adapter, map } = drawPolygonSetup() + onHandler(map, CUSTOM_DRAW_EVENTS.GEOMETRY_CHANGE)(bowtie) // dashed + map.setLayoutProperty.mockClear() + adapter.changeMode('draw_polygon') + expect(map.setLayoutProperty).toHaveBeenCalledWith('stroke-active.hot', 'visibility', 'visible') + expect(map.setLayoutProperty).toHaveBeenCalledWith('stroke-active-invalid.hot', 'visibility', 'none') + // ...and the flip guard is reset with it, so the next crossing restyles again. + map.setLayoutProperty.mockClear() + onHandler(map, CUSTOM_DRAW_EVENTS.GEOMETRY_CHANGE)(bowtie) + expect(map.setLayoutProperty).toHaveBeenCalledWith('stroke-active-invalid.hot', 'visibility', 'visible') + }) +}) + +describe('_geometryValidator accessor', () => { + test('stores the validator on the map for modes to read, and reads it back', () => { + const { adapter, map } = setup() + const validator = () => true + adapter._geometryValidator = validator + expect(map._drawGeometryValidator).toBe(validator) + expect(adapter._geometryValidator).toBe(validator) + }) }) describe('changeMode', () => { @@ -150,6 +239,43 @@ describe('changeMode', () => { }) }) +describe('setGeometryValid', () => { + test('records validity on the map for the draw mode to read', () => { + const { adapter, map } = setup() + adapter.setGeometryValid(false) + expect(map._drawGeometryValid).toBe(false) + adapter.setGeometryValid(true) + expect(map._drawGeometryValid).toBe(true) + }) +}) + +describe('setInvalid', () => { + test('shows the dashed stroke and hides the solid stroke when invalid', () => { + const { adapter, map } = setup() + map.getLayer.mockReturnValue({}) // every layer exists + adapter.setInvalid(true) + expect(map.setLayoutProperty).toHaveBeenCalledWith('stroke-active.hot', 'visibility', 'none') + expect(map.setLayoutProperty).toHaveBeenCalledWith('stroke-active.cold', 'visibility', 'none') + expect(map.setLayoutProperty).toHaveBeenCalledWith('stroke-active-invalid.hot', 'visibility', 'visible') + expect(map.setLayoutProperty).toHaveBeenCalledWith('stroke-active-invalid.cold', 'visibility', 'visible') + }) + + test('restores the solid stroke when valid again', () => { + const { adapter, map } = setup() + map.getLayer.mockReturnValue({}) + adapter.setInvalid(false) + expect(map.setLayoutProperty).toHaveBeenCalledWith('stroke-active.hot', 'visibility', 'visible') + expect(map.setLayoutProperty).toHaveBeenCalledWith('stroke-active-invalid.hot', 'visibility', 'none') + }) + + test('skips layers that are not present on the map', () => { + const { adapter, map } = setup() + map.getLayer.mockReturnValue(null) + adapter.setInvalid(true) + expect(map.setLayoutProperty).not.toHaveBeenCalled() + }) +}) + describe('simple delegations', () => { test('getMode delegates to the draw control', () => { const { adapter, draw } = setup() diff --git a/plugins/draw/src/adapters/maplibre/drawEvents.js b/plugins/draw/src/adapters/maplibre/drawEvents.js index e0e927e58..c28c48d9d 100644 --- a/plugins/draw/src/adapters/maplibre/drawEvents.js +++ b/plugins/draw/src/adapters/maplibre/drawEvents.js @@ -26,7 +26,8 @@ export const CUSTOM_DRAW_EVENTS = { UNDO_CHANGE: 'draw.undochange', UNDO: 'draw.undo', GEOMETRY_CHANGE: 'draw.geometrychange', - INTERFACE_TYPE_CHANGE: 'draw.interfacetypechange' + INTERFACE_TYPE_CHANGE: 'draw.interfacetypechange', + PLACEMENT_BLOCKED: 'draw.placementblocked' } // Native MapLibre map event (not a draw event) — fires whenever the map style data changes. diff --git a/plugins/draw/src/adapters/maplibre/modes/drawMode/clickHandlers.js b/plugins/draw/src/adapters/maplibre/modes/drawMode/clickHandlers.js index 83457b057..a1608bbeb 100644 --- a/plugins/draw/src/adapters/maplibre/modes/drawMode/clickHandlers.js +++ b/plugins/draw/src/adapters/maplibre/modes/drawMode/clickHandlers.js @@ -1,15 +1,108 @@ import { getSnapInstance, isSnapActive, isSnapEnabled, createSnappedEvent, createSnappedClickEvent } from '../../utils/snapHelpers.js' +import { validatePlacement } from '../../../../validation/validateGeometry.js' -/** - * Click / vertex-placement handling for the shared draw mode: mouse clicks, the - * add-vertex button, and the draw.create re-id step. Part of createDrawMode. - */ -export const createClickHandlers = ({ ParentMode, getFeature, getCoords, validateClick, finishOnInvalidClick }) => ({ +// The commit-level geometrychange payload for a vertex commit: the placed-only +// geometry (trailing rubber-band point dropped so validation tests the committed shape). +const placedDrawGeometryChange = (feature, getCoords, kind) => { + const placed = getCoords(feature).slice(0, -1) + const type = feature.toGeoJSON().geometry.type + const geometry = type === 'Polygon' + ? { type: 'Polygon', coordinates: [placed] } + : { type: 'LineString', coordinates: placed } + return { feature: { type: 'Feature', geometry, properties: {} }, kind, vertexIndex: Math.max(0, placed.length - 1) } +} + +// Fire a commit-level geometrychange for validation, deferred a tick so it runs after +// the current click settles. +const scheduleDrawValidation = (map, getFeature, getCoords, state, kind) => { + setTimeout(() => { + const feature = getFeature(state) + if (!feature) { return } + map.fire('draw.geometrychange', placedDrawGeometryChange(feature, getCoords, kind)) + }, 0) +} + +// Candidate GeoJSON for a would-be placement: the placed path (trailing rubber-band +// coord dropped) plus the point about to be placed. +const candidatePlacement = (feature, getCoords, geometryType, point) => { + const placed = getCoords(feature).slice(0, -1) + const candidate = [...placed, point] + const geometry = geometryType === 'Polygon' + ? { type: 'Polygon', coordinates: [candidate] } + : { type: 'LineString', coordinates: candidate } + return { candidateFeature: { type: 'Feature', geometry, properties: {} }, vertexIndex: placed.length } +} + +// Re-id a freshly created feature to the caller's requested id. +const reidCreatedFeature = (api, feature, featureId) => { + api.delete(feature.id) + feature.id = featureId + api.add(feature, { userProperties: true }) +} + +// Guards, notifications, and small wiring handlers shared by the click paths. +const createClickHelpers = ({ geometryType, getFeature, getCoords }) => ({ + // Non-drawing clicks: secondary buttons, clicks during an undo, or off-canvas. + _isIgnorableClick (e) { + return e.originalEvent.button > 0 || this.map._undoInProgress || e.originalEvent.target !== this.map.getCanvas() + }, + + // Gate a would-be placement through the hard rules + user callback + // (validatePlacement). On a veto the vertex never appears and a + // draw.placementblocked event carries the reason. + _canPlaceVertex (state, point) { + const feature = getFeature(state) + if (!feature || !point) { return true } + const { candidateFeature, vertexIndex } = candidatePlacement(feature, getCoords, geometryType, point) + const mode = geometryType === 'Polygon' ? 'draw_polygon' : 'draw_line' + const result = validatePlacement(candidateFeature, { mode, vertexIndex }, { onGeometryChange: this.map._drawGeometryValidator }) + if (!result.valid) { + this.map.fire('draw.placementblocked', { feature: candidateFeature, reason: result.reason ?? null, kind: 'place', mode, vertexIndex }) + } + return result.valid + }, + + dispatchVertexChange (coords) { + // Both polygon ring and LineString store [v0...vN, rubber_band] during drawing — subtract 1 to get placed vertex count + this.map.fire('draw.vertexchange', { + numVertecies: Math.max(0, coords.length - 1) + }) + }, + + // Emit a commit-level geometrychange after a vertex commit (placement or undo) + // so the validation layer can gate the Done button. + emitDrawValidation (state, kind = 'add') { + scheduleDrawValidation(this.map, getFeature, getCoords, state, kind) + }, + + onTap () { + + }, + + onVertexButtonClick (state, e) { + // Only trigger for the specific add vertex button, and skip during undo + if (state.addVertexButtonId && !this.map._undoInProgress && e.target.closest(`#${state.addVertexButtonId}`)) { + this.doClick(state) + } + }, + + onCreate (state, e) { + reidCreatedFeature(this._ctx.api, e.features[0], state.featureId) + } +}) + +// The click paths themselves: mouse clicks and the simulated crosshair click +// (touch / keyboard / add-vertex button). +const createClickActions = ({ ParentMode, getFeature, getCoords, validateClick, finishOnInvalidClick }) => ({ onClick (state, e) { // Skip non-primary clicks, undo operations, or clicks outside canvas - if (e.originalEvent.button > 0 || this.map._undoInProgress || e.originalEvent.target !== this.map.getCanvas()) { + if (this._isIgnorableClick(e)) { + return + } + // Block a finish/close gesture (clicking a placed vertex) while the shape is invalid. + if (this.map._drawGeometryValid === false && e.featureTarget?.properties?.meta === 'vertex') { return } const snap = getSnapInstance(this.map) @@ -26,19 +119,21 @@ export const createClickHandlers = ({ ParentMode, getFeature, getCoords, validat return } } + // Hard gate: a placement the rules or the user callback veto never appears, so + // an unrecoverable state (e.g. a self-crossing path) can't be drawn forward. + if (!this._canPlaceVertex(state, [e.lngLat.lng, e.lngLat.lat])) { + return + } const coordsBefore = getCoords(getFeature(state)).length ParentMode.onClick.call(this, state, e) // Push undo and update count if a vertex was added if (getCoords(getFeature(state)).length > coordsBefore) { this.pushDrawUndo(state) this.dispatchVertexChange(getCoords(getFeature(state))) + this.emitDrawValidation(state) } }, - onTap () { - - }, - doClick (state) { // Skip during undo operation if (this.map._undoInProgress) { @@ -50,9 +145,10 @@ export const createClickHandlers = ({ ParentMode, getFeature, getCoords, validat this.dispatchVertexChange(coords) if (!validateClick(feature)) { - // For lines: clicking same spot (like double-click) should finish the line. + // For lines: clicking same spot (like double-click) should finish the line — but + // only when the geometry is valid, so an invalid line can't be completed. // isValidLineClick only returns false with 2+ coords, so coords.length is always > 1 here. - if (finishOnInvalidClick) { + if (finishOnInvalidClick && this.map._drawGeometryValid !== false) { coords.pop() this.map.fire('draw.create', { features: [feature.toGeoJSON()] }) this.changeMode('simple_select', { featureIds: [feature.id] }) @@ -63,6 +159,13 @@ export const createClickHandlers = ({ ParentMode, getFeature, getCoords, validat const snap = getSnapInstance(this.map) const snappedEvent = isSnapEnabled(state) && createSnappedClickEvent(this.map, snap) + // Hard-gate parity with onClick: touch/keyboard/add-button placements are vetoed + // at the point that would actually be committed (snapped or map centre). + const target = snappedEvent ? snappedEvent.lngLat : this.map.getCenter() + if (!this._canPlaceVertex(state, [target.lng, target.lat])) { + return + } + if (snappedEvent) { ParentMode.onClick.call(this, state, snappedEvent) this._ctx.store.render() @@ -75,27 +178,15 @@ export const createClickHandlers = ({ ParentMode, getFeature, getCoords, validat const newCoords = getCoords(getFeature(state)) this.pushDrawUndo(state) this.dispatchVertexChange(newCoords) - }, - - dispatchVertexChange (coords) { - // Both polygon ring and LineString store [v0...vN, rubber_band] during drawing — subtract 1 to get placed vertex count - this.map.fire('draw.vertexchange', { - numVertecies: Math.max(0, coords.length - 1) - }) - }, - - onVertexButtonClick (state, e) { - // Only trigger for the specific add vertex button, and skip during undo - if (state.addVertexButtonId && !this.map._undoInProgress && e.target.closest(`#${state.addVertexButtonId}`)) { - this.doClick(state) - } - }, - - onCreate (state, e) { - const draw = this._ctx.api - const feature = e.features[0] - draw.delete(feature.id) - feature.id = state.featureId - draw.add(feature, { userProperties: true }) + this.emitDrawValidation(state) } }) + +/** + * Click / vertex-placement handling for the shared draw mode: mouse clicks, the + * add-vertex button, and the draw.create re-id step. Part of createDrawMode. + */ +export const createClickHandlers = (deps) => ({ + ...createClickHelpers(deps), + ...createClickActions(deps) +}) diff --git a/plugins/draw/src/adapters/maplibre/modes/drawMode/clickHandlers.test.js b/plugins/draw/src/adapters/maplibre/modes/drawMode/clickHandlers.test.js index d9ddf1879..234cbedfd 100644 --- a/plugins/draw/src/adapters/maplibre/modes/drawMode/clickHandlers.test.js +++ b/plugins/draw/src/adapters/maplibre/modes/drawMode/clickHandlers.test.js @@ -22,6 +22,41 @@ describe('mouse clicks (polygon)', () => { expect(ctx.map._undoStack.length).toBe(1) }) + test('rejects a click that would make the drawn path cross itself', () => { + const { ctx, state } = setup(DrawPolygonMode) + clickAt(ctx, state, 0, 0) + clickAt(ctx, state, 10, 10) + clickAt(ctx, state, 10, 0) + const lenBefore = state.polygon.coordinates[0].length + clickAt(ctx, state, 0, 10) // the new edge would cross the (0,0)-(10,10) edge + expect(state.polygon.coordinates[0].length).toBe(lenBefore) // vertex never placed + }) + + test('a rejected placement fires draw.placementblocked with the reason', () => { + const { ctx, state } = setup(DrawPolygonMode) + clickAt(ctx, state, 0, 0) + clickAt(ctx, state, 10, 10) + clickAt(ctx, state, 10, 0) + clickAt(ctx, state, 0, 10) + expect(firedWith(ctx.map, 'draw.placementblocked').pop()).toEqual(expect.objectContaining({ + kind: 'place', + mode: 'draw_polygon', + vertexIndex: 3, + reason: expect.any(String), + feature: expect.objectContaining({ type: 'Feature' }) + })) + }) + + test('the user callback can veto a mouse placement (and receives kind "place")', () => { + const { ctx, state } = setup(DrawPolygonMode) + ctx.map._drawGeometryValidator = jest.fn((feature, context) => + context.kind === 'place' ? { valid: false, reason: 'outside region' } : { valid: true }) + clickAt(ctx, state, 0, 0) + expect(state.polygon.coordinates[0]).toHaveLength(0) // first vertex vetoed + expect(firedWith(ctx.map, 'draw.placementblocked').pop()).toEqual( + expect.objectContaining({ reason: 'outside region', vertexIndex: 0 })) + }) + test('non-primary buttons, undo-in-progress and off-canvas clicks are ignored', () => { const { ctx, state } = setup(DrawPolygonMode) ctx.onClick(state, clickEvent(ctx, 0, 0, { button: 2 })) @@ -44,6 +79,49 @@ describe('mouse clicks (polygon)', () => { expect(ctx.onTap(state, clickEvent(ctx, 0, 0))).toBeUndefined() expect(state.polygon.coordinates[0]).toHaveLength(0) }) + + test('placing a vertex emits a deferred commit-level geometrychange for validation', () => { + jest.useFakeTimers() + const { ctx, state } = setup(DrawPolygonMode) + clickAt(ctx, state, 0, 0) + clickAt(ctx, state, 10, 0) + jest.runAllTimers() + const geomChange = firedWith(ctx.map, 'draw.geometrychange').pop() + expect(geomChange).toEqual(expect.objectContaining({ kind: 'add', feature: expect.any(Object) })) + jest.useRealTimers() + }) + + test('emitDrawValidation does not fire without a feature', () => { + jest.useFakeTimers() + const { ctx } = setup(DrawPolygonMode) + ctx.emitDrawValidation({}) // no polygon on the state + jest.runAllTimers() + expect(firedWith(ctx.map, 'draw.geometrychange')).toHaveLength(0) + jest.useRealTimers() + }) + + test('a line placement emits a LineString geometrychange', () => { + jest.useFakeTimers() + const { ctx, state } = setup(DrawLineMode) + clickAt(ctx, state, 0, 0) + clickAt(ctx, state, 10, 0) + jest.runAllTimers() + const geom = firedWith(ctx.map, 'draw.geometrychange').pop() + expect(geom.feature.geometry.type).toBe('LineString') + jest.useRealTimers() + }) + + test('blocks a close gesture (vertex click) while the geometry is invalid', () => { + const { ctx, state } = setup(DrawPolygonMode) + clickAt(ctx, state, 0, 0) + clickAt(ctx, state, 10, 0) + clickAt(ctx, state, 5, 10) + ctx.map._drawGeometryValid = false + const lenBefore = state.polygon.coordinates[0].length + ctx.onClick(state, { ...clickEvent(ctx, 0, 0), featureTarget: { properties: { meta: 'vertex' } } }) + expect(state.polygon.coordinates[0].length).toBe(lenBefore) // finish gesture ignored + expect(firedWith(ctx.map, 'draw.create')).toHaveLength(0) + }) }) describe('add-vertex button and doClick', () => { @@ -54,6 +132,17 @@ describe('add-vertex button and doClick', () => { expect(ctx.map._undoStack.length).toBe(1) }) + test('does not finish a line via doClick while the geometry is invalid', () => { + const { ctx, state } = setup(DrawLineMode) + clickAt(ctx, state, 0, 0) + clickAt(ctx, state, 10, 0) + ctx.map._drawGeometryValid = false + // Rubber-band duplicates the last placed vertex → a finish gesture. + state.line.setCoordinates([[0, 0], [10, 0], [10, 0]]) + ctx.doClick(state) + expect(firedWith(ctx.map, 'draw.create')).toHaveLength(0) + }) + test('clicks elsewhere, without a button id, or during undo do nothing', () => { const { ctx, state } = setup(DrawPolygonMode) ctx.vertexButtonClickHandler({ target: document.body }) @@ -66,6 +155,26 @@ describe('add-vertex button and doClick', () => { expect(noButton.state.polygon.coordinates[0]).toHaveLength(0) }) + test('doClick rejects a placement that would make the drawn path cross itself', () => { + const { ctx, state } = setup(DrawPolygonMode) + clickAt(ctx, state, 0, 0) + clickAt(ctx, state, 10, 0) + clickAt(ctx, state, 10, -10) + const lenBefore = state.polygon.coordinates[0].length + ctx.doClick(state) // map centre is (5,5): the new edge would cross (0,0)-(10,0) + expect(state.polygon.coordinates[0].length).toBe(lenBefore) + expect(firedWith(ctx.map, 'draw.placementblocked').length).toBeGreaterThan(0) + }) + + test('the user callback can veto a doClick placement', () => { + const { ctx, state } = setup(DrawPolygonMode) + ctx.map._drawGeometryValidator = () => ({ valid: false, reason: 'outside region' }) + ctx.doClick(state) + expect(state.polygon.coordinates[0]).toHaveLength(0) + expect(firedWith(ctx.map, 'draw.placementblocked').pop()).toEqual( + expect.objectContaining({ reason: 'outside region', kind: 'place' })) + }) + test('doClick places at the snapped position when snapping', () => { const { ctx, state } = setup(DrawPolygonMode, { getSnapEnabled: () => true }) ctx.map._snapInstance = activeSnap() diff --git a/plugins/draw/src/adapters/maplibre/modes/drawMode/pointerHandlers.js b/plugins/draw/src/adapters/maplibre/modes/drawMode/pointerHandlers.js index e88a30589..9c8f9e89c 100644 --- a/plugins/draw/src/adapters/maplibre/modes/drawMode/pointerHandlers.js +++ b/plugins/draw/src/adapters/maplibre/modes/drawMode/pointerHandlers.js @@ -34,9 +34,11 @@ export const createPointerHandlers = ({ ParentMode, getFeature, getCoords }) => } } - this.map.fire('draw.geometrychange', state.polygon || state.line) - ParentMode.onMouseMove.call(this, state, e) + + // Fired after the parent updates the rubber band so the payload (and the live + // invalid-stroke check driven by it) reflects the current cursor position. + this.map.fire('draw.geometrychange', state.polygon || state.line) }, onMove (state) { @@ -61,6 +63,9 @@ export const createPointerHandlers = ({ ParentMode, getFeature, getCoords }) => }) }) this._ctx.store.render() + // Parity with _simulateMouse: report the rubber-band move so the live + // invalid-stroke check sees snapped touch/keyboard moves too. + this.map.fire('draw.geometrychange', state.polygon || state.line) } else { this._simulateMouse('mousemove', ParentMode.onMouseMove, state) } diff --git a/plugins/draw/src/adapters/maplibre/modes/drawMode/undoHandlers.js b/plugins/draw/src/adapters/maplibre/modes/drawMode/undoHandlers.js index ec32b1377..f14d2bb52 100644 --- a/plugins/draw/src/adapters/maplibre/modes/drawMode/undoHandlers.js +++ b/plugins/draw/src/adapters/maplibre/modes/drawMode/undoHandlers.js @@ -23,11 +23,23 @@ const createUndoStackHandlers = ({ geometryType, getFeature }) => ({ }, /** - * Handle draw.undo event + * Handle the draw.undo event (fired by the undo button via the adapter). Pops the + * undo stack itself so it works regardless of whether the caller passes an operation. */ - onUndo (state, e) { - if (e.operation?.type === 'draw_vertex') { + onUndo (state) { + const undoStack = this.map._undoStack + if (!undoStack || undoStack.length === 0) { + return + } + const operation = undoStack.pop() + if (operation?.type === 'draw_vertex') { + // Prevent click interference during undo + this.map._undoInProgress = true + setTimeout(() => { this.map._undoInProgress = false }, 100) this.undoVertex(state) + // An undo commits a vertex removal, so it must re-validate like any other + // commit — otherwise the Done gate goes stale. + this.emitDrawValidation(state, 'delete') } }, @@ -38,16 +50,7 @@ const createUndoStackHandlers = ({ geometryType, getFeature }) => ({ } e.preventDefault() e.stopPropagation() - const undoStack = this.map._undoStack - if (undoStack && undoStack.length > 0) { - const operation = undoStack.pop() - if (operation?.type === 'draw_vertex') { - // Set flag to prevent click interference during undo - this.map._undoInProgress = true - setTimeout(() => { this.map._undoInProgress = false }, 100) - this.undoVertex(state) - } - } + this.onUndo(state) } }) @@ -113,6 +116,9 @@ const createVertexUndoHandlers = ({ ParentMode, geometryType, getCoords, getFeat originalEvent: new MouseEvent('mousemove', { clientX: point.x, clientY: point.y }) }) this._ctx.store.render() + // Parity with _simulateMouse: an undo can add/remove a crossing, so the live + // invalid-stroke check must see the new displayed geometry immediately. + this.map.fire('draw.geometrychange', state.polygon || state.line) } this.dispatchVertexChange(coords) } diff --git a/plugins/draw/src/adapters/maplibre/modes/drawMode/undoHandlers.test.js b/plugins/draw/src/adapters/maplibre/modes/drawMode/undoHandlers.test.js index d3f34f289..f501739f1 100644 --- a/plugins/draw/src/adapters/maplibre/modes/drawMode/undoHandlers.test.js +++ b/plugins/draw/src/adapters/maplibre/modes/drawMode/undoHandlers.test.js @@ -73,17 +73,37 @@ describe('cmd/ctrl+z undo', () => { }) describe('undo via draw.undo event and reinitialisation', () => { - test('draw.undo with a draw_vertex operation removes the last vertex; other types are ignored', () => { + test('draw.undo pops the stack and removes the last vertex; non-draw ops are ignored', () => { const { ctx, state } = setup(DrawPolygonMode) clickAt(ctx, state, 0, 0) clickAt(ctx, state, 10, 0) clickAt(ctx, state, 10, 10) - ctx.map.fire('draw.undo', { operation: { type: 'edit_vertex' } }) + // A non-draw op on top of the stack is popped but not undone. + ctx.map._undoStack.push({ type: 'edit_vertex' }) + ctx.map.fire('draw.undo') expect(state.polygon.coordinates[0]).toHaveLength(4) - ctx.map.fire('draw.undo', { operation: { type: 'draw_vertex' } }) + // The next op is a draw_vertex → the last vertex is removed. + ctx.map.fire('draw.undo') expect(state.polygon.coordinates[0]).toHaveLength(3) }) + test('undo re-validates the committed shape (deferred, kind delete)', () => { + jest.useFakeTimers() + const { ctx, state } = setup(DrawPolygonMode) + clickAt(ctx, state, 0, 0) + clickAt(ctx, state, 10, 0) + clickAt(ctx, state, 10, 10) + jest.runAllTimers() + ctx.map.fire.mockClear() + ctx.onUndo(state) + jest.runAllTimers() + expect(firedWith(ctx.map, 'draw.geometrychange').pop()).toEqual(expect.objectContaining({ + kind: 'delete', + feature: expect.any(Object) + })) + jest.useRealTimers() + }) + test('keyboard interface moves the rubber band to center after undo', () => { const { ctx, state } = setup(DrawPolygonMode, { interfaceType: 'keyboard' }) clickAt(ctx, state, 0, 0) diff --git a/plugins/draw/src/adapters/maplibre/modes/editVertexMode/undoHandlers.js b/plugins/draw/src/adapters/maplibre/modes/editVertexMode/undoHandlers.js index 72a1e25cb..8cfa98a07 100644 --- a/plugins/draw/src/adapters/maplibre/modes/editVertexMode/undoHandlers.js +++ b/plugins/draw/src/adapters/maplibre/modes/editVertexMode/undoHandlers.js @@ -5,6 +5,21 @@ import { } from './geometryHelpers.js' import { scalePoint } from './helpers.js' +// Map an undo-stack op type onto the geometry-change `kind` consumed by validation. +const UNDO_OP_KIND = { + move_vertex: 'move', + insert_vertex: 'insert', + delete_vertex: 'delete' +} + +// Undoing an op commits the inverse change (undo of a delete re-inserts, etc.), +// so its re-validation reports the inverse kind. +const UNDO_INVERSE_KIND = { + move_vertex: 'move', + insert_vertex: 'delete', + delete_vertex: 'insert' +} + export const undoHandlers = { // Fire geometry change event (for external listeners) fireGeometryChange (state) { @@ -17,6 +32,19 @@ export const undoHandlers = { } }, + // Emit a commit-level geometrychange (feature + change kind + vertex index) so the + // validation layer can accept or reject the change. Deferred a tick to avoid + // re-entrancy: rejection calls draw.undo(), which must run after the current + // mutation (and its undo bookkeeping) has fully settled. + emitGeometryValidation (kind, vertexIndex, featureId) { + if (!kind) { return } + setTimeout(() => { + const feature = this.getFeature(featureId) + if (!feature) { return } + this.map.fire('draw.geometrychange', { feature: feature.toGeoJSON(), kind, vertexIndex }) + }, 0) + }, + // Undo support pushUndo (operation) { const undoStack = this.map._undoStack @@ -24,6 +52,9 @@ export const undoHandlers = { return } undoStack.push(operation) + // Every edit commit (move/insert/delete, via mouse or keyboard) records an undo + // op here, so this is the single point that feeds commit-level validation. + this.emitGeometryValidation(UNDO_OP_KIND[operation.type], operation.vertexIndex, operation.featureId) }, handleUndo (state) { @@ -43,6 +74,9 @@ export const undoHandlers = { } else { // No action } + // An undo commits the inverse change, so it must re-validate like any other + // commit — otherwise the invalid stroke and the Done gate go stale. + this.emitGeometryValidation(UNDO_INVERSE_KIND[op.type], op.vertexIndex, op.featureId) }, undoMoveVertex (state, op) { diff --git a/plugins/draw/src/adapters/maplibre/modes/editVertexMode/undoHandlers.test.js b/plugins/draw/src/adapters/maplibre/modes/editVertexMode/undoHandlers.test.js index f07bd40bb..38004867a 100644 --- a/plugins/draw/src/adapters/maplibre/modes/editVertexMode/undoHandlers.test.js +++ b/plugins/draw/src/adapters/maplibre/modes/editVertexMode/undoHandlers.test.js @@ -12,6 +12,44 @@ describe('undoHandlers', () => { expect(map._undoStack.length).toBe(1) }) + test('pushUndo emits a deferred commit-level geometrychange with the change kind', () => { + jest.useFakeTimers() + const { ctx, map } = createHarness() + map.fire.mockClear() + + ctx.pushUndo({ type: 'move_vertex', featureId: 'feat-1', vertexIndex: 2 }) + // Deferred a tick to avoid re-entrancy — nothing fired synchronously. + expect(map.fire).not.toHaveBeenCalledWith('draw.geometrychange', expect.anything()) + + jest.runAllTimers() + expect(map.fire).toHaveBeenCalledWith('draw.geometrychange', expect.objectContaining({ + kind: 'move', + vertexIndex: 2, + feature: expect.any(Object) + })) + jest.useRealTimers() + }) + + test('pushUndo does not emit a geometrychange for unmapped op types', () => { + jest.useFakeTimers() + const { ctx, map } = createHarness() + map.fire.mockClear() + ctx.pushUndo({ type: 'draw_vertex', featureId: 'feat-1' }) + jest.runAllTimers() + expect(map.fire).not.toHaveBeenCalledWith('draw.geometrychange', expect.anything()) + jest.useRealTimers() + }) + + test('emitGeometryValidation does not fire once the feature is gone', () => { + jest.useFakeTimers() + const { ctx, map } = createHarness() + map.fire.mockClear() + ctx.emitGeometryValidation('move', 0, 'missing-feature') + jest.runAllTimers() + expect(map.fire).not.toHaveBeenCalledWith('draw.geometrychange', expect.anything()) + jest.useRealTimers() + }) + test('handleUndo ignores an empty stack and dispatches by operation type', () => { const { ctx, state, map } = createHarness() ctx.handleUndo(state) // empty → no throw @@ -28,6 +66,22 @@ describe('undoHandlers', () => { expect(() => ctx.handleUndo(state)).not.toThrow() }) + test('handleUndo re-validates with the inverse change kind (undo of a delete re-inserts)', () => { + jest.useFakeTimers() + const { ctx, state, map } = createHarness() + jest.spyOn(ctx, 'undoDeleteVertex').mockImplementation(() => {}) + map._undoStack.push({ type: 'delete_vertex', featureId: 'feat-1', vertexIndex: 1, position: [0, 0] }) + map.fire.mockClear() + ctx.handleUndo(state) + jest.runAllTimers() + expect(map.fire).toHaveBeenCalledWith('draw.geometrychange', expect.objectContaining({ + kind: 'insert', + vertexIndex: 1, + feature: expect.any(Object) + })) + jest.useRealTimers() + }) + test('fireGeometryChange fires draw.update only when the feature exists', () => { const { ctx, state, map } = createHarness() ctx.fireGeometryChange(state) diff --git a/plugins/draw/src/adapters/maplibre/styles.js b/plugins/draw/src/adapters/maplibre/styles.js index d6102769f..b07ae8a74 100755 --- a/plugins/draw/src/adapters/maplibre/styles.js +++ b/plugins/draw/src/adapters/maplibre/styles.js @@ -46,6 +46,22 @@ const strokeActive = (editStrokeColor) => ({ paint: { 'line-color': editStrokeColor, 'line-width': 2, 'line-opacity': 1 } }) +// Dashed stroke shown in place of stroke-active while the shape is invalid. Same +// filter as stroke-active; the adapter's setInvalid() toggles the two layers' +// visibility (line-dasharray can't be data-driven per feature in MapLibre). +const strokeActiveInvalid = (invalidStrokeColor) => ({ + id: 'stroke-active-invalid', + type: 'line', + filter: ['all', ['any', ['==', '$type', 'Polygon'], ['==', '$type', 'LineString']], ['==', 'active', 'true'], ['!has', 'user_splitter']], + layout: { 'line-cap': 'round', 'line-join': 'round', visibility: 'none' }, + paint: { + 'line-color': invalidStrokeColor, + 'line-width': 2, + 'line-dasharray': [0.2, 2], // NOSONAR + 'line-opacity': 1 + } +}) + // Splitter line const drawInvalidSplitter = (splitInvalidColor) => ({ id: 'stroke-invalid-splitter', @@ -149,12 +165,14 @@ const createDrawStyles = (mapStyle) => { const editActiveColor = getValueForStyle(COLORS.editActive, scheme) const splitInvalidColor = getValueForStyle(COLORS.splitInvalid, scheme) const splitValidColor = getValueForStyle(COLORS.splitValid, scheme) + const invalidStrokeColor = getValueForStyle(COLORS.invalidStroke, scheme) const { vertexRadius, midpointRadius, vertexHaloRadius, midpointHaloRadius } = SIZES return [ fillInactive(mapStyle), fillActive(editFillColor), strokeActive(editStrokeColor), + strokeActiveInvalid(invalidStrokeColor), strokeInactive(mapStyle), drawInvalidSplitter(splitInvalidColor), drawValidSplitter(splitValidColor), diff --git a/plugins/draw/src/adapters/maplibre/styles.test.js b/plugins/draw/src/adapters/maplibre/styles.test.js index f6bdf0e83..64f2cc7c4 100644 --- a/plugins/draw/src/adapters/maplibre/styles.test.js +++ b/plugins/draw/src/adapters/maplibre/styles.test.js @@ -10,13 +10,22 @@ describe('createDrawStyles', () => { test('returns every draw layer in order', () => { const layers = createDrawStyles(mapStyle) expect(layers.map((l) => l.id)).toEqual([ - 'fill-inactive', 'fill-active', 'stroke-active', 'stroke-inactive', + 'fill-inactive', 'fill-active', 'stroke-active', 'stroke-active-invalid', 'stroke-inactive', 'stroke-invalid-splitter', 'stroke-valid-splitter', 'stroke-preview-line', 'midpoint', 'midpoint-halo', 'midpoint-active', 'vertex', 'vertex-halo', 'vertex-active', 'circle', 'touch-vertex-indicator' ]) }) + test('the invalid stroke layer is a hidden dashed line matching the active shape', () => { + const layers = createDrawStyles(mapStyle) + const invalid = findLayer(layers, 'stroke-active-invalid') + expect(invalid.layout.visibility).toBe('none') // hidden until the shape is invalid + expect(invalid.paint['line-dasharray']).toEqual([0.2, 2]) + expect(invalid.paint['line-color']).toBe(getValueForStyle(COLORS.invalidStroke, 'light')) + expect(invalid.filter).toEqual(findLayer(layers, 'stroke-active').filter) + }) + test('resolves light-scheme colours', () => { const layers = createDrawStyles({ id: 'outdoor', mapColorScheme: 'light' }) expect(findLayer(layers, 'stroke-active').paint['line-color']).toBe(getValueForStyle(COLORS.editStroke, 'light')) diff --git a/plugins/draw/src/adapters/openlayers/OLDrawAdapter.js b/plugins/draw/src/adapters/openlayers/OLDrawAdapter.js index df425216c..562d5669a 100644 --- a/plugins/draw/src/adapters/openlayers/OLDrawAdapter.js +++ b/plugins/draw/src/adapters/openlayers/OLDrawAdapter.js @@ -21,9 +21,9 @@ import { createOLDraw } from './olDraw.js' * remove() */ export class OLDrawAdapter { - constructor (mapProvider, options) { - this._snapEnabled = false + _snapEnabled = false + constructor (mapProvider, options) { const { remove } = createOLDraw({ mapProvider, events: options.events, @@ -62,6 +62,18 @@ export class OLDrawAdapter { undo () { this._manager.undo() } deleteVertex () { this._manager.deleteVertex() } + // Record the current geometry validity so the draw mode can block finish gestures + // (double-click / click-to-close) while the in-progress shape is invalid. + setGeometryValid (valid) { this._manager._geometryValid = valid } + + // The api entry points assign the active user validator to the adapter; store it + // on the manager so the draw mode can veto placements synchronously. + set _geometryValidator (fn) { this._manager._geometryValidator = fn } + get _geometryValidator () { return this._manager._geometryValidator } + + // Show/hide the dashed invalid stroke on the active sketch or edit feature. + setInvalid (invalid) { this._manager.setInvalid(invalid) } + get (id) { return this._manager.get(id) } add (feature) { return this._manager.add(feature) } delete (id) { return this._manager.delete(id) } diff --git a/plugins/draw/src/adapters/openlayers/OLDrawAdapter.test.js b/plugins/draw/src/adapters/openlayers/OLDrawAdapter.test.js index 9ec8b3410..28551a9bd 100644 --- a/plugins/draw/src/adapters/openlayers/OLDrawAdapter.test.js +++ b/plugins/draw/src/adapters/openlayers/OLDrawAdapter.test.js @@ -9,6 +9,7 @@ const fakeManager = () => ({ cancel: jest.fn(), undo: jest.fn(), deleteVertex: jest.fn(), + setInvalid: jest.fn(), get: jest.fn(() => 'feature'), add: jest.fn(), delete: jest.fn(), @@ -104,6 +105,18 @@ test('remaining calls delegate straight through; setFeatureProperty is a deliber expect(manager.deleteVertex).toHaveBeenCalled() }) +test('setGeometryValid records validity on the manager for finish gating', () => { + const { adapter, manager } = setup() + adapter.setGeometryValid(false) + expect(manager._geometryValid).toBe(false) +}) + +test('setInvalid delegates to the manager to toggle the dashed stroke', () => { + const { adapter, manager } = setup() + adapter.setInvalid(true) + expect(manager.setInvalid).toHaveBeenCalledWith(true) +}) + test('remove runs the olDraw cleanup', () => { const { adapter } = setup() adapter.remove() diff --git a/plugins/draw/src/adapters/openlayers/__helpers__/harness.js b/plugins/draw/src/adapters/openlayers/__helpers__/harness.js index 1c32bacd0..fc8781536 100644 --- a/plugins/draw/src/adapters/openlayers/__helpers__/harness.js +++ b/plugins/draw/src/adapters/openlayers/__helpers__/harness.js @@ -55,6 +55,7 @@ export const createFakeManager = () => { // Real Style instances — OL asserts on setStyle args; identify them by reference styles: { editFeatureStyle: new Style({}), + editFeatureStyleInvalid: new Style({}), vertexStyle: new Style({}), midpointStyle: new Style({}), selectedVertexStyle: new Style({}), diff --git a/plugins/draw/src/adapters/openlayers/core/OLDrawManager.js b/plugins/draw/src/adapters/openlayers/core/OLDrawManager.js index 325235729..fa82e6a81 100644 --- a/plugins/draw/src/adapters/openlayers/core/OLDrawManager.js +++ b/plugins/draw/src/adapters/openlayers/core/OLDrawManager.js @@ -120,6 +120,11 @@ export class OLDrawManager { this._modeInstance?.deleteVertex() } + // Show/hide the dashed invalid stroke on the active draw sketch or edit feature. + setInvalid (invalid) { + this._modeInstance?.setInvalid?.(invalid) + } + setInterfaceType (type) { this._modeInstance?.setInterfaceType?.(type) } diff --git a/plugins/draw/src/adapters/openlayers/core/OLDrawManager.test.js b/plugins/draw/src/adapters/openlayers/core/OLDrawManager.test.js index e1bdbb852..1eb81ed6b 100644 --- a/plugins/draw/src/adapters/openlayers/core/OLDrawManager.test.js +++ b/plugins/draw/src/adapters/openlayers/core/OLDrawManager.test.js @@ -8,10 +8,10 @@ import { createFakeMap } from '../__helpers__/harness.js' import { TOLERANCES } from '../defaults.js' jest.mock('../draw/DrawMode.js', () => ({ - createDrawMode: jest.fn(() => ({ destroy: jest.fn(), done: jest.fn(), cancel: jest.fn(), undo: jest.fn(), deleteVertex: jest.fn(), setInterfaceType: jest.fn() })) + createDrawMode: jest.fn(() => ({ destroy: jest.fn(), done: jest.fn(), cancel: jest.fn(), undo: jest.fn(), deleteVertex: jest.fn(), setInterfaceType: jest.fn(), setInvalid: jest.fn() })) })) jest.mock('../edit/EditMode.js', () => ({ - createEditMode: jest.fn(() => ({ destroy: jest.fn(), done: jest.fn(), cancel: jest.fn(), undo: jest.fn(), deleteVertex: jest.fn(), setInterfaceType: jest.fn() })) + createEditMode: jest.fn(() => ({ destroy: jest.fn(), done: jest.fn(), cancel: jest.fn(), undo: jest.fn(), deleteVertex: jest.fn(), setInterfaceType: jest.fn(), setInvalid: jest.fn() })) })) jest.mock('../snap/snapManager.js', () => ({ createSnapManager: jest.fn(() => ({ setIndicatorActive: jest.fn(), reattach: jest.fn(), updateColors: jest.fn(), destroy: jest.fn() })) @@ -74,7 +74,7 @@ describe('mode machine', () => { test('operations delegate to the current mode instance and are safe without one', async () => { const { manager } = setup() - manager.done(); manager.undo(); manager.deleteVertex(); manager.setInterfaceType('touch') // no mode — no throw + manager.done(); manager.undo(); manager.deleteVertex(); manager.setInterfaceType('touch'); manager.setInvalid(true) // no mode — no throw await manager.changeMode('draw_polygon') const instance = createDrawMode.mock.results[0].value @@ -82,10 +82,12 @@ describe('mode machine', () => { manager.undo() manager.deleteVertex() manager.setInterfaceType('touch') + manager.setInvalid(true) expect(instance.done).toHaveBeenCalled() expect(instance.undo).toHaveBeenCalled() expect(instance.deleteVertex).toHaveBeenCalled() expect(instance.setInterfaceType).toHaveBeenCalledWith('touch') + expect(instance.setInvalid).toHaveBeenCalledWith(true) manager.cancel() expect(instance.cancel).toHaveBeenCalled() diff --git a/plugins/draw/src/adapters/openlayers/core/styles.js b/plugins/draw/src/adapters/openlayers/core/styles.js index 294844521..4e5e2b08b 100644 --- a/plugins/draw/src/adapters/openlayers/core/styles.js +++ b/plugins/draw/src/adapters/openlayers/core/styles.js @@ -65,11 +65,22 @@ export const createStyles = (colors) => { fill: new Fill({ color: colors.editFill }) }) + // Dashed variant shown while the edited/drawn shape is invalid. + const editFeatureStyleInvalid = new Style({ + stroke: new Stroke({ color: colors.invalidStroke, width: 2, lineDash: [2, 4] }), + fill: new Fill({ color: colors.editFill }) + }) + const sketchLineStyle = new Style({ stroke: new Stroke({ color: colors.editStroke, width: 2 }), fill: new Fill({ color: colors.editFill }) }) + const sketchLineStyleInvalid = new Style({ + stroke: new Stroke({ color: colors.invalidStroke, width: 2, lineDash: [2, 4] }), + fill: new Fill({ color: colors.editFill }) + }) + // Reused across renders — the geometry function runs every frame while sketching, // so mutate one MultiPoint (setCoordinates bumps its revision, keeping OL's // render caches correct) instead of allocating a new one per frame @@ -89,11 +100,12 @@ export const createStyles = (colors) => { // No style for the Point sketch (cursor-following ghost marker); placed // vertices get markers on the sketch feature instead. geometryType filters // out the extra LineString sketch OL renders alongside a Polygon sketch, - // so vertices aren't drawn twice. - const createSketchStyle = (geometryType) => (feature) => { + // so vertices aren't drawn twice. `invalid` swaps to the dashed line style. + const createSketchStyle = (geometryType, invalid = false) => (feature) => { const type = feature.getGeometry().getType() if (type === 'Point') { return [] } - return type === geometryType ? [sketchLineStyle, sketchVertexStyle] : [sketchLineStyle] + const lineStyle = invalid ? sketchLineStyleInvalid : sketchLineStyle + return type === geometryType ? [lineStyle, sketchVertexStyle] : [lineStyle] } const createFeatureStyle = () => (feature) => { @@ -114,6 +126,7 @@ export const createStyles = (colors) => { midpointStyle, selectedMidpointStyle, editFeatureStyle, + editFeatureStyleInvalid, createSketchStyle, createFeatureStyle } diff --git a/plugins/draw/src/adapters/openlayers/core/styles.test.js b/plugins/draw/src/adapters/openlayers/core/styles.test.js index 3ff6a33b4..00ec07eec 100644 --- a/plugins/draw/src/adapters/openlayers/core/styles.test.js +++ b/plugins/draw/src/adapters/openlayers/core/styles.test.js @@ -12,6 +12,7 @@ const colors = { editMidpoint: '#em', editActive: '#ea', editHalo: '#eh', + invalidStroke: '#is', shapeStroke: '#ss', shapeFill: '#sf', strokeWidth: 3, @@ -62,6 +63,12 @@ describe('handle styles', () => { expect(styles.editFeatureStyle.getStroke().getColor()).toBe(colors.editStroke) expect(styles.editFeatureStyle.getFill().getColor()).toBe(colors.editFill) }) + + test('the invalid edited feature gets a dashed stroke in the invalid colour', () => { + expect(styles.editFeatureStyleInvalid.getStroke().getColor()).toBe(colors.invalidStroke) + expect(styles.editFeatureStyleInvalid.getStroke().getLineDash()).toEqual([2, 4]) + expect(styles.editFeatureStyleInvalid.getFill().getColor()).toBe(colors.editFill) + }) }) describe('sketch styles while drawing', () => { @@ -72,6 +79,13 @@ describe('sketch styles while drawing', () => { expect(polygonStyleFn(new Feature(new LineString([[0, 0], [1, 1]])))).toHaveLength(1) }) + test('the invalid sketch renders a dashed line in the invalid colour', () => { + const invalidStyleFn = styles.createSketchStyle('Polygon', true) + const [lineStyle] = invalidStyleFn(new Feature(new LineString([[0, 0], [1, 1]]))) + expect(lineStyle.getStroke().getColor()).toBe(colors.invalidStroke) + expect(lineStyle.getStroke().getLineDash()).toEqual([2, 4]) + }) + test('placed vertices render with the shared vertex image on a reused MultiPoint', () => { const sketch = polygonFeature([[0, 0], [10, 0], [5, 5], [0, 0]]) // 2 placed + rubber + closing const [, vertexStyle] = polygonStyleFn(sketch) diff --git a/plugins/draw/src/adapters/openlayers/draw/DrawMode.js b/plugins/draw/src/adapters/openlayers/draw/DrawMode.js index 80a71f267..1c18ac78e 100644 --- a/plugins/draw/src/adapters/openlayers/draw/DrawMode.js +++ b/plugins/draw/src/adapters/openlayers/draw/DrawMode.js @@ -5,6 +5,8 @@ import { getPlacedSketchCoords, getLastPlacedSketchCoord } from '../utils/sketch import { TOLERANCES } from '../defaults.js' import { ADAPTER_EVENTS } from '../../../adapterEvents.js' import { STYLES_CHANGED_EVENT } from '../core/internalEvents.js' +import { validatePlacement } from '../../../validation/validateGeometry.js' +import { createLiveStroke } from '../../../validation/liveStroke.js' const MIN_VERTICES = { Polygon: 3, LineString: 2 } const canFinish = (geometryType, sketchFeature) => { @@ -14,12 +16,14 @@ const canFinish = (geometryType, sketchFeature) => { const DUPLICATE_TOLERANCE_PX = 2 -// Blocks clicks with modifier keys, and duplicate clicks on the last placed -// vertex while the shape is not yet finishable (once finishable, clicking the -// last vertex is OL's finish gesture and must go through) -export const buildCondition = (map, geometryType, getSketchFeature) => (e) => { +// Blocks clicks with modifier keys, vetoed placements (the vertex never appears — +// see canPlaceVertex in createDrawMode), and duplicate clicks on the last placed +// vertex while the shape is not yet finishable (once finishable, clicking the last +// vertex is OL's finish gesture). +export const buildCondition = (map, geometryType, getSketchFeature, canPlaceVertex) => (e) => { if (!noModifierKeys(e)) { return false } const sf = getSketchFeature() + if (!canPlaceVertex(e.coordinate)) { return false } if (!sf || canFinish(geometryType, sf)) { return true } const prev = getLastPlacedSketchCoord(sf.getGeometry()) if (!prev) { return true } @@ -29,33 +33,184 @@ export const buildCondition = (map, geometryType, getSketchFeature) => (e) => { return dx * dx + dy * dy > DUPLICATE_TOLERANCE_PX * DUPLICATE_TOLERANCE_PX } +// During drawing the sketch has trailing rubber-band (+ closing, for polygons) coords. +const TRAILING_COORDS = { Polygon: 2, LineString: 1 } + +// The placed-only GeoJSON of an in-progress sketch (lon/lat), dropping the trailing +// cursor-tracking coords so validation tests just the committed vertices. +const placedFeatureGeoJSON = (store, sketchFeature) => { + const gj = store.toGeoJSON(sketchFeature) + const type = gj.geometry.type + const trailing = TRAILING_COORDS[type] + const ring = type === 'Polygon' ? gj.geometry.coordinates[0] : gj.geometry.coordinates + const placed = ring.slice(0, -trailing) + const geometry = type === 'Polygon' + ? { type: 'Polygon', coordinates: [placed] } + : { type: 'LineString', coordinates: placed } + return { type: 'Feature', geometry, properties: gj.properties } +} + +// Gate a would-be placement (mouse click, crosshair tap, Enter) through the hard +// rules + user callback (validatePlacement). On a veto the vertex never appears +// and a PLACEMENT_BLOCKED event carries the reason. +export const buildCanPlaceVertex = ({ manager, geometryType, getSketch }) => (coordinate) => { + const sketch = getSketch() + const placed = sketch ? getPlacedSketchCoords(sketch.getGeometry()) : [] + const candidate = [...placed, coordinate] + const geometry = geometryType === 'Polygon' + ? { type: 'Polygon', coordinates: [candidate] } + : { type: 'LineString', coordinates: candidate } + const feature = { type: 'Feature', geometry, properties: {} } + const mode = geometryType === 'Polygon' ? 'draw_polygon' : 'draw_line' + const result = validatePlacement(feature, { mode, vertexIndex: placed.length }, { onGeometryChange: manager._geometryValidator }) + if (!result.valid) { + manager.emit(ADAPTER_EVENTS.PLACEMENT_BLOCKED, { feature, reason: result.reason ?? null, kind: 'place', mode, vertexIndex: placed.length }) + } + return result.valid +} + +// Build the displayed-geometry payload (placed vertices + cursor) for the live +// stroke check from the current sketch, plus the placed-vertex count for the +// minimum-vertices threshold. +const displayedSketch = (geometryType, sketch) => { + const geom = sketch.getGeometry() + const coords = geom.getCoordinates() + const ring = geometryType === 'Polygon' ? (coords[0] ?? []) : coords + const geometry = geometryType === 'Polygon' + ? { type: 'Polygon', coordinates: [ring] } + : { type: 'LineString', coordinates: ring } + return { feature: { type: 'Feature', geometry }, placedCount: getPlacedSketchCoords(geom).length } +} + +// Commit a finished sketch to the store under the requested id and emit CREATE. +const finalizeDrawnFeature = (manager, olFeature, featureId, properties) => { + olFeature.setId(String(featureId)) + olFeature.setProperties(properties) + manager.store.source.addFeature(olFeature) + manager.emit(ADAPTER_EVENTS.CREATE, manager.store.toGeoJSON(olFeature)) +} + +// Wire the OL Draw interaction's lifecycle: track the sketch, react to every sketch +// geometry change (vertex count + live validity), finalise on end, and report a +// cancel on abort. +const attachDrawListeners = (drawInteraction, { manager, featureId, properties, onStart, onSketchChange }) => { + drawInteraction.on('drawstart', (e) => { + onStart(e.feature) + e.feature.getGeometry().on('change', onSketchChange) + }) + drawInteraction.on('drawend', (e) => finalizeDrawnFeature(manager, e.feature, featureId, properties)) + drawInteraction.on('drawabort', () => { manager.emit(ADAPTER_EVENTS.CANCEL) }) +} + +// Tracks placed-vertex count and emits VERTEX_CHANGE plus, on each genuine placement, +// a commit-level GEOMETRY_CHANGE ('add') for validation. Deferred a tick so a +// rejection's revert runs after the current click settles. +const createVertexTracker = (manager, getSketch) => { + let lastPlacedCount = 0 + const emit = (kind, vertexIndex) => { + setTimeout(() => { + const sketch = getSketch() + if (!sketch) { return } + manager.emit(ADAPTER_EVENTS.GEOMETRY_CHANGE, { feature: placedFeatureGeoJSON(manager.store, sketch), kind, vertexIndex }) + }, 0) + } + return { + resetCount: () => { lastPlacedCount = 0 }, + updateVertexCount: () => { + const sketch = getSketch() + if (!sketch) { return } + const placed = getPlacedSketchCoords(sketch.getGeometry()).length + manager.emit(ADAPTER_EVENTS.VERTEX_CHANGE, { numVertices: placed }) + if (placed > lastPlacedCount) { emit('add', placed - 1) } + lastPlacedCount = placed + }, + // An undo commits a vertex removal, so it must re-validate like any other + // commit — otherwise the Done gate goes stale. + emitUndoValidation: () => { + const sketch = getSketch() + if (!sketch) { return } + emit('delete', getPlacedSketchCoords(sketch.getGeometry()).length) + } + } +} + +// The mode interface consumed by OLDrawManager. +const buildDrawModeApi = ({ map, manager, drawInteraction, input, geometryType, getSketch, updateVertexCount, emitUndoValidation, onStylesChanged, clearSketch, setInvalid, liveStroke }) => ({ + done () { + if (canFinish(geometryType, getSketch())) { drawInteraction.finishDrawing() } + }, + cancel () { drawInteraction.abortDrawing() }, + undo () { drawInteraction.removeLastPoint(); updateVertexCount(); emitUndoValidation() }, + setInvalid, + destroy () { + liveStroke.destroy() + manager.off(STYLES_CHANGED_EVENT, onStylesChanged) + // Emit the final interfaceType so crosshair visibility is correct on exit. + manager.emit(ADAPTER_EVENTS.INTERFACE_TYPE_CHANGE, { interfaceType: input.getInterfaceType() }) + input.destroy() + map.removeInteraction(drawInteraction) + clearSketch() + } +}) + +// Build the touch/keyboard draw input for the interaction. +const buildDrawInput = ({ drawInteraction, manager, options, geometryType, getSketch, updateVertexCount, emitUndoValidation, canPlaceVertex }) => + createDrawInput({ + drawInteraction, + manager, + options: { + container: options.container, + interfaceType: options.interfaceType, + addVertexButtonId: options.addVertexButtonId, + mapProvider: options.mapProvider, + snap: options.snap, + onUndo: () => { drawInteraction.removeLastPoint(); updateVertexCount(); emitUndoValidation() }, + canFinish: () => canFinish(geometryType, getSketch()), + canPlace: canPlaceVertex + } + }) + export const createDrawMode = ({ map, manager, options }) => { - const { - geometryType, - featureId, - properties = {}, - container, - interfaceType, - addVertexButtonId, - mapProvider, - snap - } = options + const { geometryType, featureId, properties = {} } = options let sketchFeature = null + let invalid = false let currentSketchStyle = manager.styles.createSketchStyle(geometryType) + const { updateVertexCount, resetCount, emitUndoValidation } = createVertexTracker(manager, () => sketchFeature) + + const canPlaceVertex = buildCanPlaceVertex({ manager, geometryType, getSketch: () => sketchFeature }) const drawInteraction = new Draw({ type: geometryType, style: (feature) => currentSketchStyle(feature), stopClick: true, snapTolerance: TOLERANCES.snapRadius, - condition: buildCondition(map, geometryType, () => sketchFeature) + condition: buildCondition(map, geometryType, () => sketchFeature, canPlaceVertex), + // Block finish gestures (double-click / click-to-close) while the shape is invalid. + finishCondition: () => manager._geometryValid !== false }) map.addInteraction(drawInteraction) + const setSketchInvalid = (next) => { + invalid = next + currentSketchStyle = manager.styles.createSketchStyle(geometryType, invalid) + drawInteraction.overlay_.changed() + } + // Drives the live invalid stroke from every sketch change (mouse / touch / keyboard + // rubber-banding): default rules synchronously, user callback throttled. + const liveStroke = createLiveStroke({ onChange: setSketchInvalid }) + const liveMode = geometryType === 'Polygon' ? 'draw_polygon' : 'draw_line' + const updateLiveValidity = () => { + if (!sketchFeature) { return } + const { feature, placedCount } = displayedSketch(geometryType, sketchFeature) + // eslint-disable-next-line no-console -- TEMP live-stroke diagnostics + console.log('[live-stroke OL] updateLiveValidity', { placedCount }) + liveStroke.update({ feature, context: { mode: liveMode }, placedCount, onGeometryChange: manager._geometryValidator }) + } + // Update sketch style when map style changes const onStylesChanged = () => { - currentSketchStyle = manager.styles.createSketchStyle(geometryType) + currentSketchStyle = manager.styles.createSketchStyle(geometryType, invalid) drawInteraction.overlay_.changed() } manager.on(STYLES_CHANGED_EVENT, onStylesChanged) @@ -65,55 +220,37 @@ export const createDrawMode = ({ map, manager, options }) => { // Check ol/interaction/Draw.js and ol/layer/BaseVector.js if this breaks after an OL upgrade. drawInteraction.overlay_.updateWhileAnimating_ = true - const updateVertexCount = () => { - if (!sketchFeature) { return } - manager.emit(ADAPTER_EVENTS.VERTEX_CHANGE, { numVertices: getPlacedSketchCoords(sketchFeature.getGeometry()).length }) - } - - drawInteraction.on('drawstart', (e) => { - sketchFeature = e.feature - sketchFeature.getGeometry().on('change', updateVertexCount) - }) - - drawInteraction.on('drawend', (e) => { - const olFeature = e.feature - olFeature.setId(String(featureId)) - olFeature.setProperties(properties) - manager.store.source.addFeature(olFeature) - manager.emit(ADAPTER_EVENTS.CREATE, manager.store.toGeoJSON(olFeature)) - // Mode switches to disabled in events.js after receiving 'create' + attachDrawListeners(drawInteraction, { + manager, + featureId, + properties, + onStart: (f) => { sketchFeature = f; resetCount() }, + onSketchChange: () => { updateVertexCount(); updateLiveValidity() } }) - drawInteraction.on('drawabort', () => { manager.emit(ADAPTER_EVENTS.CANCEL) }) - - const input = createDrawInput({ + const input = buildDrawInput({ drawInteraction, manager, - options: { - container, - interfaceType, - addVertexButtonId, - mapProvider, - snap, - onUndo: () => { drawInteraction.removeLastPoint(); updateVertexCount() }, - canFinish: () => canFinish(geometryType, sketchFeature) - } + options, + geometryType, + getSketch: () => sketchFeature, + updateVertexCount, + emitUndoValidation, + canPlaceVertex }) - return { - done () { - if (canFinish(geometryType, sketchFeature)) { drawInteraction.finishDrawing() } - }, - cancel () { drawInteraction.abortDrawing() }, - undo () { drawInteraction.removeLastPoint(); updateVertexCount() }, - destroy () { - manager.off(STYLES_CHANGED_EVENT, onStylesChanged) - // Emit the final interfaceType from draw mode so it's synced back to appState - // This ensures crosshair visibility is correct when exiting draw mode - manager.emit(ADAPTER_EVENTS.INTERFACE_TYPE_CHANGE, { interfaceType: input.getInterfaceType() }) - input.destroy() - map.removeInteraction(drawInteraction) - sketchFeature = null - } - } + return buildDrawModeApi({ + map, + manager, + drawInteraction, + input, + geometryType, + getSketch: () => sketchFeature, + updateVertexCount, + emitUndoValidation, + onStylesChanged, + clearSketch: () => { sketchFeature = null }, + setInvalid: setSketchInvalid, + liveStroke + }) } diff --git a/plugins/draw/src/adapters/openlayers/draw/DrawMode.test.js b/plugins/draw/src/adapters/openlayers/draw/DrawMode.test.js index eb096a892..3b77219bf 100644 --- a/plugins/draw/src/adapters/openlayers/draw/DrawMode.test.js +++ b/plugins/draw/src/adapters/openlayers/draw/DrawMode.test.js @@ -1,4 +1,4 @@ -import { createDrawMode, buildCondition } from './DrawMode.js' +import { createDrawMode, buildCondition, buildCanPlaceVertex } from './DrawMode.js' import { createDrawInput } from './drawInput.js' import { createFeatureStore } from '../core/featureStore.js' import { ADAPTER_EVENTS } from '../../../adapterEvents.js' @@ -33,7 +33,7 @@ afterEach(() => jest.clearAllMocks()) describe('buildCondition (duplicate-click suppression)', () => { const map = createFakeMap() const click = (x, y, originalEvent = {}) => ({ pixel: [x, y], originalEvent }) - const conditionFor = (sketch) => buildCondition(map, 'LineString', () => sketch) + const conditionFor = (sketch) => buildCondition(map, 'LineString', () => sketch, () => true) test('modifier-key clicks never draw', () => { expect(conditionFor(null)(click(0, 0, { shiftKey: true }))).toBe(false) @@ -54,11 +54,77 @@ describe('buildCondition (duplicate-click suppression)', () => { test('degenerate sketches or unprojectable vertices do not block drawing', () => { expect(conditionFor(lineFeature([[10, 10]]))(click(10, 10))).toBe(true) // nothing placed yet const blindMap = { getPixelFromCoordinate: () => null } - const condition = buildCondition(blindMap, 'LineString', () => lineFeature([[10, 10], [50, 50]])) + const condition = buildCondition(blindMap, 'LineString', () => lineFeature([[10, 10], [50, 50]]), () => true) expect(condition(click(10, 10))).toBe(true) }) }) +describe('buildCondition (placement gate, polygon)', () => { + const map = createFakeMap() + // Polygon sketch: 3 placed vertices + 2 trailing (rubber-band + closing) coords. + const polyClick = (coordinate) => ({ pixel: [0, 0], originalEvent: {}, coordinate }) + const condition = (ring, manager = createFakeManager()) => { + const getSketch = () => polygonFeature(ring) + const gate = buildCanPlaceVertex({ manager, geometryType: 'Polygon', getSketch }) + return buildCondition(map, 'Polygon', getSketch, gate) + } + + test('rejects a click that would make the drawn path cross itself', () => { + const sketch = [[0, 0], [2, 2], [2, 0], [9, 9], [0, 0]] // placed: (0,0)(2,2)(2,0) + expect(condition(sketch)(polyClick([0, 2]))).toBe(false) + }) + + test('allows a click that keeps the path simple', () => { + const sketch = [[0, 0], [2, 0], [2, 2], [9, 9], [0, 0]] // placed: (0,0)(2,0)(2,2) + expect(condition(sketch)(polyClick([0, 2]))).toBe(true) + }) +}) + +describe('buildCanPlaceVertex', () => { + const gateFor = (ring, manager) => + buildCanPlaceVertex({ manager, geometryType: 'Polygon', getSketch: () => (ring ? polygonFeature(ring) : null) }) + + test('a hard-rule veto emits PLACEMENT_BLOCKED with the candidate and reason', () => { + const manager = createFakeManager() + const gate = gateFor([[0, 0], [2, 2], [2, 0], [9, 9], [0, 0]], manager) + expect(gate([0, 2])).toBe(false) + expect(manager.emit).toHaveBeenCalledWith(ADAPTER_EVENTS.PLACEMENT_BLOCKED, expect.objectContaining({ + kind: 'place', + mode: 'draw_polygon', + vertexIndex: 3, + reason: expect.any(String), + feature: expect.objectContaining({ type: 'Feature' }) + })) + }) + + test('the user callback can veto a placement (and receives kind "place")', () => { + const manager = createFakeManager() + manager._geometryValidator = jest.fn((feature, context) => + context.kind === 'place' ? { valid: false, reason: 'outside region' } : { valid: true }) + const gate = gateFor([[0, 0], [2, 0], [9, 9], [0, 0]], manager) + expect(gate([5, 5])).toBe(false) + expect(manager.emit).toHaveBeenCalledWith(ADAPTER_EVENTS.PLACEMENT_BLOCKED, + expect.objectContaining({ reason: 'outside region', kind: 'place' })) + }) + + test('the user callback can veto the very first vertex (no sketch yet)', () => { + const manager = createFakeManager() + manager._geometryValidator = () => ({ valid: false, reason: 'outside region' }) + const gate = gateFor(null, manager) + expect(gate([5, 5])).toBe(false) + expect(manager.emit).toHaveBeenCalledWith(ADAPTER_EVENTS.PLACEMENT_BLOCKED, + expect.objectContaining({ vertexIndex: 0 })) + }) + + test('a valid placement passes and emits nothing', () => { + const manager = createFakeManager() + manager._geometryValidator = jest.fn(() => true) + const gate = gateFor([[0, 0], [2, 0], [9, 9], [0, 0]], manager) + expect(gate([2, 2])).toBe(true) + expect(manager.emit).not.toHaveBeenCalled() + }) +}) + describe('drawing lifecycle', () => { test('the sketch reports its placed vertex count on every geometry change', () => { const { emitted, interaction } = setup() @@ -68,6 +134,131 @@ describe('drawing lifecycle', () => { expect(emitted().at(-1)).toEqual({ type: ADAPTER_EVENTS.VERTEX_CHANGE, payload: { numVertices: 2 } }) }) + test('finishCondition blocks finishing while the geometry is invalid', () => { + const { manager, interaction } = setup() + manager._geometryValid = false + expect(interaction.finishCondition_()).toBe(false) + manager._geometryValid = true + expect(interaction.finishCondition_()).toBe(true) + }) + + test('the live check turns the sketch stroke dashed while the displayed ring self-intersects', () => { + const { manager, interaction } = setup('Polygon') + const sketch = polygonFeature([[0, 0], [5, 5], [0, 0]]) + interaction.dispatchEvent({ type: 'drawstart', feature: sketch }) + // Placed (0,0)(10,10)(10,0) + rubber-band (0,10) + closing — a bowtie. + sketch.getGeometry().setCoordinates([[[0, 0], [10, 10], [10, 0], [0, 10], [0, 0]]]) + expect(manager.styles.createSketchStyle).toHaveBeenLastCalledWith('Polygon', true) + // Rubber-band moves back to a simple ring → solid again. + sketch.getGeometry().setCoordinates([[[0, 0], [10, 0], [10, 10], [0, 10], [0, 0]]]) + expect(manager.styles.createSketchStyle).toHaveBeenLastCalledWith('Polygon', false) + }) + + test('the live check only restyles when the invalid state flips, not on every move', () => { + const { manager, interaction } = setup('Polygon') + const sketch = polygonFeature([[0, 0], [5, 5], [0, 0]]) + interaction.dispatchEvent({ type: 'drawstart', feature: sketch }) + sketch.getGeometry().setCoordinates([[[0, 0], [10, 10], [10, 0], [0, 10], [0, 0]]]) + const styleCalls = manager.styles.createSketchStyle.mock.calls.length + sketch.getGeometry().setCoordinates([[[0, 0], [10, 10], [10, 0], [1, 10], [0, 0]]]) // still crossing + expect(manager.styles.createSketchStyle.mock.calls.length).toBe(styleCalls) + }) + + test('placing a valid vertex keeps the stroke solid while the rubber band still sits on it', () => { + const { manager, interaction } = setup('Polygon') + const sketch = polygonFeature([[0, 0], [5, 5], [0, 0]]) + interaction.dispatchEvent({ type: 'drawstart', feature: sketch }) + // 3 placed + rubber band duplicating the just-placed vertex + closing coord. + sketch.getGeometry().setCoordinates([[[0, 0], [10, 0], [10, 10], [10, 10], [0, 0]]]) + expect(manager.styles.createSketchStyle).not.toHaveBeenCalledWith('Polygon', true) + }) + + test('2 placed vertices + cursor never go dashed (below the 4-point threshold)', () => { + const { manager, interaction } = setup('Polygon') + const sketch = polygonFeature([[0, 0], [5, 5], [0, 0]]) + interaction.dispatchEvent({ type: 'drawstart', feature: sketch }) + sketch.getGeometry().setCoordinates([[[0, 0], [10, 10], [5, 0], [0, 0]]]) // 2 placed + rubber + expect(manager.styles.createSketchStyle).not.toHaveBeenCalledWith('Polygon', true) + }) + + test('drawInput receives the placement gate for touch/keyboard placements', () => { + const { inputOptions } = setup('Polygon') + expect(typeof inputOptions.canPlace).toBe('function') + }) + + test('setInvalid rebuilds the sketch style in the invalid variant and re-renders', () => { + const { mode, manager, interaction } = setup('Polygon') + const changed = jest.spyOn(interaction.overlay_, 'changed') + mode.setInvalid(true) + expect(manager.styles.createSketchStyle).toHaveBeenLastCalledWith('Polygon', true) + expect(changed).toHaveBeenCalled() + }) + + test('undo re-validates the committed shape (deferred, kind delete)', () => { + jest.useFakeTimers() + const { mode, manager, emitted, interaction } = setup() + const sketch = polygonFeature([[0, 0], [10, 0], [5, 5], [0, 0]]) + interaction.dispatchEvent({ type: 'drawstart', feature: sketch }) + jest.spyOn(interaction, 'removeLastPoint').mockImplementation(() => {}) + manager.emit.mockClear() + mode.undo() + jest.runAllTimers() + expect(emitted().filter((e) => e.type === ADAPTER_EVENTS.GEOMETRY_CHANGE).pop()?.payload).toEqual( + expect.objectContaining({ kind: 'delete', feature: expect.any(Object) })) + jest.useRealTimers() + }) + + test('placing a vertex emits a deferred commit-level geometrychange for validation', () => { + jest.useFakeTimers() + const { emitted, interaction } = setup() + const sketch = polygonFeature([[0, 0], [5, 5], [0, 0]]) + interaction.dispatchEvent({ type: 'drawstart', feature: sketch }) + sketch.getGeometry().setCoordinates([[[0, 0], [10, 0], [5, 5], [0, 0]]]) + // Deferred a tick to avoid re-entrancy — not emitted synchronously. + expect(emitted().some((e) => e.type === ADAPTER_EVENTS.GEOMETRY_CHANGE)).toBe(false) + jest.runAllTimers() + const geom = emitted().find((e) => e.type === ADAPTER_EVENTS.GEOMETRY_CHANGE) + expect(geom.payload).toEqual(expect.objectContaining({ kind: 'add' })) + jest.useRealTimers() + }) + + test('does not re-emit an add when the placed count is unchanged', () => { + jest.useFakeTimers() + const { emitted, interaction } = setup() + const sketch = polygonFeature([[0, 0], [5, 5], [0, 0]]) + interaction.dispatchEvent({ type: 'drawstart', feature: sketch }) + sketch.getGeometry().setCoordinates([[[0, 0], [10, 0], [5, 5], [0, 0]]]) // placed grows to 2 → one add + sketch.getGeometry().setCoordinates([[[0, 0], [10, 0], [7, 7], [0, 0]]]) // rubber moved, placed still 2 + jest.runAllTimers() + expect(emitted().filter((e) => e.type === ADAPTER_EVENTS.GEOMETRY_CHANGE)).toHaveLength(1) + jest.useRealTimers() + }) + + test('emits the placed line vertices as a LineString geometrychange', () => { + jest.useFakeTimers() + const { emitted, interaction } = setup('LineString') + const sketch = lineFeature([[0, 0], [5, 5]]) + interaction.dispatchEvent({ type: 'drawstart', feature: sketch }) + sketch.getGeometry().setCoordinates([[0, 0], [10, 0], [5, 5]]) + jest.runAllTimers() + const geom = emitted().find((e) => e.type === ADAPTER_EVENTS.GEOMETRY_CHANGE) + expect(geom.payload.feature.geometry.type).toBe('LineString') + jest.useRealTimers() + }) + + test('vertex tracking becomes inert once the sketch is cleared', () => { + jest.useFakeTimers() + const { emitted, interaction, mode } = setup() + const sketch = polygonFeature([[0, 0], [5, 5], [0, 0]]) + interaction.dispatchEvent({ type: 'drawstart', feature: sketch }) + sketch.getGeometry().setCoordinates([[[0, 0], [10, 0], [5, 5], [0, 0]]]) // schedules a deferred add emit + mode.destroy() // clears the sketch reference + sketch.getGeometry().setCoordinates([[[0, 0], [12, 0], [6, 6], [0, 0]]]) // updateVertexCount now a no-op + jest.runAllTimers() // the earlier deferred emit sees no sketch + expect(emitted().some((e) => e.type === ADAPTER_EVENTS.GEOMETRY_CHANGE)).toBe(false) + jest.useRealTimers() + }) + test('finishing hands the feature over with the requested id and properties', () => { const { manager, emitted, interaction } = setup() interaction.dispatchEvent({ type: 'drawend', feature: polygonFeature([[0, 0], [10, 0], [10, 10], [0, 0]], null) }) diff --git a/plugins/draw/src/adapters/openlayers/draw/drawInput.js b/plugins/draw/src/adapters/openlayers/draw/drawInput.js index 6a68c6693..2c172dec4 100644 --- a/plugins/draw/src/adapters/openlayers/draw/drawInput.js +++ b/plugins/draw/src/adapters/openlayers/draw/drawInput.js @@ -83,7 +83,7 @@ const wireInputEvents = ({ * @returns {{ getInterfaceType: () => string, destroy: () => void }} */ export const createDrawInput = ({ drawInteraction, options }) => { - const { container, addVertexButtonId, mapProvider, snap, onUndo, canFinish } = options + const { container, addVertexButtonId, mapProvider, snap, onUndo, canFinish, canPlace } = options let interfaceType = options.interfaceType ?? 'mouse' const getInterfaceType = () => interfaceType @@ -92,6 +92,7 @@ export const createDrawInput = ({ drawInteraction, options }) => { mapProvider, snap, canFinish, + canPlace, getInterfaceType }) diff --git a/plugins/draw/src/adapters/openlayers/draw/vertexPlacement.js b/plugins/draw/src/adapters/openlayers/draw/vertexPlacement.js index 3514da0f9..4b90f2371 100644 --- a/plugins/draw/src/adapters/openlayers/draw/vertexPlacement.js +++ b/plugins/draw/src/adapters/openlayers/draw/vertexPlacement.js @@ -84,7 +84,7 @@ const tryClose = ({ drawInteraction, canFinish, geom, sketchCoords, coord, lastP * * @returns {{ placeVertex, updateRubberbanding, clearLastCoord }} */ -export const createVertexPlacement = ({ drawInteraction, mapProvider, snap, canFinish, getInterfaceType }) => { +export const createVertexPlacement = ({ drawInteraction, mapProvider, snap, canFinish, canPlace, getInterfaceType }) => { let sketchFeature = null let lastPlacedCoord = null @@ -129,6 +129,9 @@ export const createVertexPlacement = ({ drawInteraction, mapProvider, snap, canF lastPlacedCoord = result.lastPlacedCoord if (result.handled) { return } } + // appendCoordinates bypasses the Draw interaction's condition, so the placement + // gate must run here explicitly — touch/keyboard parity with the mouse path. + if (canPlace && !canPlace(coord)) { return } drawInteraction.appendCoordinates([coord]) lastPlacedCoord = coord } diff --git a/plugins/draw/src/adapters/openlayers/draw/vertexPlacement.test.js b/plugins/draw/src/adapters/openlayers/draw/vertexPlacement.test.js index de4e0c13d..3b67986fc 100644 --- a/plugins/draw/src/adapters/openlayers/draw/vertexPlacement.test.js +++ b/plugins/draw/src/adapters/openlayers/draw/vertexPlacement.test.js @@ -6,7 +6,7 @@ import { createFakeMap, createEmitter, polygonFeature, lineFeature } from '../__ const CENTER = [5, 5] -const setup = ({ interfaceType = 'touch', snap = null, canFinish = () => true } = {}) => { +const setup = ({ interfaceType = 'touch', snap = null, canFinish = () => true, canPlace } = {}) => { const map = createFakeMap() const bus = createEmitter() const drawInteraction = { @@ -21,6 +21,7 @@ const setup = ({ interfaceType = 'touch', snap = null, canFinish = () => true } mapProvider: { getCenter: () => CENTER }, snap, canFinish, + canPlace, getInterfaceType: () => interfaceType }) const startSketch = (feature) => { bus.emit('drawstart', { feature }); return feature } @@ -40,6 +41,24 @@ describe('placing vertices', () => { expect(snap.hideIndicator).toHaveBeenCalled() }) + test('a canPlace veto rejects the placement — the vertex is never appended', () => { + const canPlace = jest.fn(() => false) + const { placement, drawInteraction } = setup({ canPlace }) + placement.placeVertex() + expect(canPlace).toHaveBeenCalledWith(CENTER) + expect(drawInteraction.appendCoordinates).not.toHaveBeenCalled() + }) + + test('a canPlace veto does not block a finish tap (close runs before the gate)', () => { + let allow = true + const { placement, drawInteraction, startSketch } = setup({ canPlace: () => allow }) + startSketch(lineFeature([[0, 0], [50, 50]])) + placement.placeVertex() // placed at the crosshair + allow = false + placement.placeVertex() // same spot again → a finish attempt, not a placement + expect(drawInteraction.finishDrawing).toHaveBeenCalledTimes(1) + }) + test('the mouse interface ignores snapping (the OL snap interaction covers it)', () => { const snap = { apply: jest.fn(() => [9, 9]), hideIndicator: jest.fn() } const { placement, drawInteraction } = setup({ interfaceType: 'mouse', snap }) diff --git a/plugins/draw/src/adapters/openlayers/edit/EditMode.js b/plugins/draw/src/adapters/openlayers/edit/EditMode.js index c6f20cb52..9d04eef19 100644 --- a/plugins/draw/src/adapters/openlayers/edit/EditMode.js +++ b/plugins/draw/src/adapters/openlayers/edit/EditMode.js @@ -14,6 +14,21 @@ import { STYLES_CHANGED_EVENT } from '../core/internalEvents.js' const TOUCH_INTERFACE = 'touch' const VERTEX_TYPE = 'vertex' +// Map an undo-op type onto the geometry-change `kind` consumed by validation. +const OP_KIND = { + move_vertex: 'move', + insert_vertex: 'insert', + delete_vertex: 'delete' +} + +// Undoing an op commits the inverse change (undo of a delete re-inserts, etc.), +// so its re-validation reports the inverse kind. +const UNDO_INVERSE_KIND = { + move_vertex: 'move', + insert_vertex: 'delete', + delete_vertex: 'insert' +} + /** * Edit vertex mode — handles edit_vertex. * @@ -26,7 +41,7 @@ const VERTEX_TYPE = 'vertex' // Delete-selected-vertex and undo operations, shared by pointer, touch and keyboard input const createVertexActions = ({ olFeature, undoStack, selection, getTouchHandler }) => { - const { state, setState, syncGeom } = selection + const { state, setState, syncGeom, emitGeometryValidation } = selection const doDeleteVertex = () => { if (state.selectedVertexType !== VERTEX_TYPE || state.selectedVertexIndex < 0) { @@ -38,6 +53,7 @@ const createVertexActions = ({ olFeature, undoStack, selection, getTouchHandler } undoStack.push({ type: 'delete_vertex', vertexIndex: result.deletedIndex, deletedCoord: result.deletedCoord }) syncGeom() + emitGeometryValidation('delete', result.deletedIndex) setState({ selectedVertexIndex: -1, selectedVertexType: null }) } @@ -49,6 +65,9 @@ const createVertexActions = ({ olFeature, undoStack, selection, getTouchHandler const previousIndex = state.selectedVertexIndex const restoredIndex = applyUndo(olFeature, op) syncGeom() + // An undo commits the inverse change, so it must re-validate like any other + // commit — otherwise the invalid stroke and the Done gate go stale. + emitGeometryValidation(UNDO_INVERSE_KIND[op.type], restoredIndex) // Only re-select if a vertex was already active — undo must not create a new selection const newIndex = previousIndex >= 0 ? restoredIndex : -1 setState({ @@ -64,7 +83,7 @@ const createVertexActions = ({ olFeature, undoStack, selection, getTouchHandler } const wireTouchHandler = ({ map, container, manager, snap, olFeature, undoStack, selection }) => { - const { state, getState, setState, syncGeom } = selection + const { state, getState, setState, syncGeom, emitGeometryValidation } = selection const selectVertex = (index) => setState({ selectedVertexIndex: index, selectedVertexType: VERTEX_TYPE }) const touchHandler = createTouchHandler({ @@ -77,6 +96,7 @@ const wireTouchHandler = ({ map, container, manager, snap, olFeature, undoStack, onVertexMoved ({ vertexIndex, previousCoord }) { undoStack.push({ type: 'move_vertex', vertexIndex, previousCoord }) syncGeom() + emitGeometryValidation('move', vertexIndex) selectVertex(vertexIndex) touchHandler.updateTargetPosition() }, @@ -97,6 +117,7 @@ const wireTouchHandler = ({ map, container, manager, snap, olFeature, undoStack, } undoStack.push({ type: 'insert_vertex', vertexIndex: result.insertedIndex }) syncGeom() + emitGeometryValidation('insert', result.insertedIndex) selectVertex(result.insertedIndex) touchHandler.updateTargetPosition() } @@ -115,7 +136,7 @@ const wireTouchHandler = ({ map, container, manager, snap, olFeature, undoStack, } const wireKeyboardHandler = ({ map, container, snap, undoStack, selection, touchHandler, actions }) => { - const { state, getState, setState, syncGeom } = selection + const { state, getState, setState, syncGeom, emitGeometryValidation } = selection return createKeyboardHandler({ map, @@ -125,11 +146,13 @@ const wireKeyboardHandler = ({ map, container, snap, undoStack, selection, touch onVertexMoved ({ vertexIndex, previousCoord }) { undoStack.push({ type: 'move_vertex', vertexIndex, previousCoord }) syncGeom() + emitGeometryValidation('move', vertexIndex) setState({ selectedVertexIndex: vertexIndex, selectedVertexType: VERTEX_TYPE }) }, onInserted ({ insertedIndex }) { undoStack.push({ type: 'insert_vertex', vertexIndex: insertedIndex }) syncGeom() + emitGeometryValidation('insert', insertedIndex) }, onDeleted: actions.doDeleteVertex, onUndo: actions.doUndo, @@ -198,6 +221,11 @@ const buildModeApi = ({ manager, store, olFeature, originalFeatureStyle, selecti manager.emit(ADAPTER_EVENTS.EDIT_FINISH, store.toGeoJSON(olFeature)) }, + // Swap the edited feature's stroke between solid (valid) and dashed (invalid). + setInvalid (invalid) { + olFeature.setStyle(invalid ? manager.styles.editFeatureStyleInvalid : manager.styles.editFeatureStyle) + }, + // Nothing to restore here: the pre-edit feature is kept as tempFeature in the // reducer and events.js re-adds it on cancel cancel () {}, @@ -247,7 +275,7 @@ export const createEditMode = ({ map, manager, options }) => { interfaceType, layers: { vertexLayer, midpointLayer, activeLayer } }) - const { state, getState, setState, syncGeom } = selection + const { state, getState, setState, syncGeom, emitGeometryValidation } = selection const modify = createModifyInteraction({ map, @@ -260,6 +288,7 @@ export const createEditMode = ({ map, manager, options }) => { return } undoStack.push(op) + emitGeometryValidation(OP_KIND[op.type], op.vertexIndex) setState({ selectedVertexIndex: op.vertexIndex, selectedVertexType: VERTEX_TYPE }) } }) diff --git a/plugins/draw/src/adapters/openlayers/edit/EditMode.test.js b/plugins/draw/src/adapters/openlayers/edit/EditMode.test.js index 351b8c426..6706435af 100644 --- a/plugins/draw/src/adapters/openlayers/edit/EditMode.test.js +++ b/plugins/draw/src/adapters/openlayers/edit/EditMode.test.js @@ -49,6 +49,14 @@ test('entering edit mode swaps the feature style and reports the initial vertex expect(manager.emit).toHaveBeenCalledWith(ADAPTER_EVENTS.UPDATE, expect.objectContaining({ id: 'f1' })) }) +test('setInvalid swaps between the solid and dashed edit styles', () => { + const { manager, mode, olFeature } = setup() + mode.setInvalid(true) + expect(olFeature.getStyle()).toBe(manager.styles.editFeatureStyleInvalid) + mode.setInvalid(false) + expect(olFeature.getStyle()).toBe(manager.styles.editFeatureStyle) +}) + test('done() emits the edited feature; cancel() is a no-op', () => { const { manager, mode } = setup() mode.done() @@ -71,6 +79,23 @@ test('select via pointer, delete the vertex, then undo restores it', () => { mode.undo() // empty stack — no-op }) +test('undo re-validates with the inverse change kind (undo of a delete re-inserts)', () => { + jest.useFakeTimers() + const { container, manager, mode } = setup() + container.dispatchEvent(domEvent('pointerdown', { pointerType: 'mouse', clientX: 100, clientY: 0 })) + mode.deleteVertex() + jest.runAllTimers() + manager.emit.mockClear() + mode.undo() + jest.runAllTimers() + expect(manager.emit).toHaveBeenCalledWith(ADAPTER_EVENTS.GEOMETRY_CHANGE, expect.objectContaining({ + kind: 'insert', + vertexIndex: 1, + feature: expect.any(Object) + })) + jest.useRealTimers() +}) + test('a Modify drag pushes a move op derived from the before/after vertices', () => { const { map, manager, olFeature } = setup() const interaction = map.interactions[0] diff --git a/plugins/draw/src/adapters/openlayers/edit/selectionState.js b/plugins/draw/src/adapters/openlayers/edit/selectionState.js index 5a1dc69f1..c180a9479 100644 --- a/plugins/draw/src/adapters/openlayers/edit/selectionState.js +++ b/plugins/draw/src/adapters/openlayers/edit/selectionState.js @@ -1,6 +1,32 @@ import { getCoords, getMidpoints } from '../utils/geometryHelpers.js' import { ADAPTER_EVENTS } from '../../../adapterEvents.js' +// Deferred commit-level geometrychange emitter (feature + change kind + vertex index) +// consumed by the validation layer. Deferred a tick so that a rejection's revert runs +// after the current mutation's undo bookkeeping has settled. +const createGeometryValidationEmitter = (manager, store, olFeature) => (kind, vertexIndex) => { + if (!kind) { return } + setTimeout(() => { + manager.emit(ADAPTER_EVENTS.GEOMETRY_CHANGE, { feature: store.toGeoJSON(olFeature), kind, vertexIndex }) + }, 0) +} + +// Reflect the current selection onto the handle layers and emit VERTEX_SELECTION. +const applySelectionChange = (state, { vertexLayer, midpointLayer, activeLayer, manager, hooks }) => { + vertexLayer.setSelected(state.selectedVertexType === 'vertex' ? state.selectedVertexIndex : -1) + midpointLayer.setSelected( + state.selectedVertexType === 'midpoint' ? state.selectedVertexIndex - state.vertices.length : -1 + ) + if (state.selectedVertexIndex < 0) { + hooks.onDeselect?.() + } + activeLayer.update(state) + manager.emit(ADAPTER_EVENTS.VERTEX_SELECTION, { + index: state.selectedVertexType === 'vertex' ? state.selectedVertexIndex : -1, + numVertices: state.vertices.length + }) +} + /** * Mutable edit-mode state shared by the Modify interaction, pointer, touch and * keyboard handlers, plus the setState/sync helpers that keep the handle @@ -31,20 +57,7 @@ export const createSelectionState = ({ map, manager, store, olFeature, interface return { type: geom.getType(), coordinates: geom.getCoordinates() } } - const applySelectionChange = () => { - vertexLayer.setSelected(state.selectedVertexType === 'vertex' ? state.selectedVertexIndex : -1) - midpointLayer.setSelected( - state.selectedVertexType === 'midpoint' ? state.selectedVertexIndex - state.vertices.length : -1 - ) - if (state.selectedVertexIndex < 0) { - hooks.onDeselect?.() - } - activeLayer.update(state) - manager.emit(ADAPTER_EVENTS.VERTEX_SELECTION, { - index: state.selectedVertexType === 'vertex' ? state.selectedVertexIndex : -1, - numVertices: state.vertices.length - }) - } + const applySelectionChangeLocal = () => applySelectionChange(state, { vertexLayer, midpointLayer, activeLayer, manager, hooks }) const applyVertexChange = () => { const geom = plainGeom() @@ -59,7 +72,7 @@ export const createSelectionState = ({ map, manager, store, olFeature, interface const setState = (updates) => { Object.assign(state, updates) if (updates.selectedVertexIndex !== undefined) { - applySelectionChange() + applySelectionChangeLocal() } if (updates.vertices !== undefined) { applyVertexChange() @@ -82,6 +95,8 @@ export const createSelectionState = ({ map, manager, store, olFeature, interface manager.emit(ADAPTER_EVENTS.UPDATE, store.toGeoJSON(olFeature)) } + const emitGeometryValidation = createGeometryValidationEmitter(manager, store, olFeature) + // Keep overlay layers in sync on every geometry change (e.g. during pointer drag) const onGeometryChange = () => updateLayersFromGeom() olFeature.getGeometry().on('change', onGeometryChange) @@ -91,6 +106,7 @@ export const createSelectionState = ({ map, manager, store, olFeature, interface getState: () => state, setState, syncGeom, + emitGeometryValidation, updateLayersFromGeom, setHooks, destroy () { diff --git a/plugins/draw/src/adapters/openlayers/edit/selectionState.test.js b/plugins/draw/src/adapters/openlayers/edit/selectionState.test.js index d339e1f95..f76818872 100644 --- a/plugins/draw/src/adapters/openlayers/edit/selectionState.test.js +++ b/plugins/draw/src/adapters/openlayers/edit/selectionState.test.js @@ -70,6 +70,34 @@ test('syncGeom derives state from the geometry and emits vertexchange + update', expect(manager.emit).toHaveBeenCalledWith(ADAPTER_EVENTS.UPDATE, store.toGeoJSON()) }) +test('emitGeometryValidation emits a deferred commit-level geometrychange with the change kind', () => { + jest.useFakeTimers() + const { selection, manager, store } = setup() + manager.emit.mockClear() + + selection.emitGeometryValidation('move', 2) + // Deferred a tick to avoid re-entrancy — nothing emitted synchronously. + expect(manager.emit).not.toHaveBeenCalledWith(ADAPTER_EVENTS.GEOMETRY_CHANGE, expect.anything()) + + jest.runAllTimers() + expect(manager.emit).toHaveBeenCalledWith(ADAPTER_EVENTS.GEOMETRY_CHANGE, { + feature: store.toGeoJSON(), + kind: 'move', + vertexIndex: 2 + }) + jest.useRealTimers() +}) + +test('emitGeometryValidation is a no-op without a change kind', () => { + jest.useFakeTimers() + const { selection, manager } = setup() + manager.emit.mockClear() + selection.emitGeometryValidation(undefined, 0) + jest.runAllTimers() + expect(manager.emit).not.toHaveBeenCalledWith(ADAPTER_EVENTS.GEOMETRY_CHANGE, expect.anything()) + jest.useRealTimers() +}) + test('geometry changes refresh the layers until destroy unbinds the listener', () => { const { selection, olFeature, layers } = setup() olFeature.getGeometry().setCoordinates([[[0, 0], [20, 0], [20, 20], [0, 0]]]) diff --git a/plugins/draw/src/adapters/openlayers/utils/resolveColors.js b/plugins/draw/src/adapters/openlayers/utils/resolveColors.js index f14787d7e..b7265bff2 100644 --- a/plugins/draw/src/adapters/openlayers/utils/resolveColors.js +++ b/plugins/draw/src/adapters/openlayers/utils/resolveColors.js @@ -23,6 +23,7 @@ export const resolveColors = (mapStyle, pluginConfig = {}) => { editMidpoint: resolveColor('editMidpoint'), editActive: resolveColor('editActive'), editHalo: resolveColor('editHalo'), + invalidStroke: resolveColor('invalidStroke'), shapeStroke: resolveColor('shapeStroke'), strokeWidth: pluginConfig.strokeWidth ?? SIZES.strokeWidth, shapeFill: resolveColor('shapeFill'), diff --git a/plugins/draw/src/api/editFeature.js b/plugins/draw/src/api/editFeature.js index 88e87a17a..39f19e430 100644 --- a/plugins/draw/src/api/editFeature.js +++ b/plugins/draw/src/api/editFeature.js @@ -1,4 +1,5 @@ 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 = {}) => { const { dispatch } = pluginState @@ -14,6 +15,9 @@ export const editFeature = ({ appState, appConfig, mapState, pluginConfig, plugi return false } + // Per-call callback overrides the plugin-level one; events.js reads this on every commit. + draw._geometryValidator = options.onGeometryChange ?? pluginConfig.onGeometryChange + const editModeMap = { LineString: 'edit_line', Polygon: 'edit_polygon' } eventBus.emit('draw:editstart', { mode: editModeMap[existingFeature.geometry.type] }) @@ -40,5 +44,11 @@ export const editFeature = ({ appState, appConfig, mapState, pluginConfig, plugi dispatch({ type: 'SET_MODE', payload: 'edit_vertex' }) + // Seed the Done-button gate and the stroke from the feature's starting validity + // so an already invalid feature opens dashed and cannot be "finished" until fixed. + const { valid } = validateGeometry(feature, { kind: 'init', mode: 'edit_vertex' }, { onGeometryChange: draw._geometryValidator }) + dispatch({ type: 'SET_GEOMETRY_VALID', payload: valid }) + draw.setInvalid?.(!valid) + return true } diff --git a/plugins/draw/src/api/editFeature.test.js b/plugins/draw/src/api/editFeature.test.js index e1c06f918..96c4aedee 100644 --- a/plugins/draw/src/api/editFeature.test.js +++ b/plugins/draw/src/api/editFeature.test.js @@ -74,6 +74,42 @@ describe('editFeature', () => { expect(eventBus.emit).toHaveBeenCalledWith('draw:editstart', { mode: 'edit_line' }) }) + test('stores a per-call onGeometryChange validator, overriding the plugin-level one', () => { + const pluginOnGeometryChange = jest.fn() + const onGeometryChange = jest.fn() + const { context, draw } = makeContext({ pluginConfig: { snapLayers: ['pc-layer'], onGeometryChange: pluginOnGeometryChange } }) + editFeature(context, 'f1', { onGeometryChange }) + expect(draw._geometryValidator).toBe(onGeometryChange) + }) + + test('seeds the geometry-valid gate from the feature starting validity', () => { + const { context, dispatch, draw } = makeContext() + // A valid square → gate opens. + draw.get.mockReturnValue({ id: 'f1', geometry: { type: 'Polygon', coordinates: [[[0, 0], [1, 0], [1, 1], [0, 1], [0, 0]]] } }) + editFeature(context, 'f1') + expect(dispatch).toHaveBeenCalledWith({ type: 'SET_GEOMETRY_VALID', payload: true }) + }) + + test('seeds the gate closed for an already self-intersecting feature', () => { + const { context, dispatch, draw } = makeContext() + draw.get.mockReturnValue({ id: 'f1', geometry: { type: 'Polygon', coordinates: [[[0, 0], [1, 1], [1, 0], [0, 1], [0, 0]]] } }) + editFeature(context, 'f1') + expect(dispatch).toHaveBeenCalledWith({ type: 'SET_GEOMETRY_VALID', payload: false }) + }) + + test('seeds the stroke alongside the gate — an invalid feature opens edit mode dashed', () => { + const { context, draw } = makeContext() + draw.setInvalid = jest.fn() + draw.get.mockReturnValue({ id: 'f1', geometry: { type: 'Polygon', coordinates: [[[0, 0], [1, 1], [1, 0], [0, 1], [0, 0]]] } }) + editFeature(context, 'f1') + expect(draw.setInvalid).toHaveBeenCalledWith(true) + + draw.setInvalid.mockClear() + draw.get.mockReturnValue({ id: 'f1', geometry: { type: 'Polygon', coordinates: [[[0, 0], [1, 0], [1, 1], [0, 1], [0, 0]]] } }) + editFeature(context, 'f1') + expect(draw.setInvalid).toHaveBeenCalledWith(false) + }) + test('disables panning for the keyboard interface', () => { const { context, draw } = makeContext({ appState: { layoutRefs: { viewportRef: { current: 'viewport' } }, interfaceType: 'keyboard' } diff --git a/plugins/draw/src/api/newLine.js b/plugins/draw/src/api/newLine.js index 8d4424a45..b8b53ffea 100644 --- a/plugins/draw/src/api/newLine.js +++ b/plugins/draw/src/api/newLine.js @@ -15,7 +15,9 @@ export const newLine = ({ appState, appConfig, pluginConfig, pluginState, mapSta draw.setSnapLayers(snapLayers) dispatch({ type: 'SET_HAS_SNAP_LAYERS', payload: snapLayers?.length > 0 }) - const { stroke, fill, strokeWidth, properties: customProperties, ...modeOptions } = options + const { stroke, fill, strokeWidth, properties: customProperties, onGeometryChange, ...modeOptions } = options + // Per-call callback overrides the plugin-level one; events.js reads this on every commit. + draw._geometryValidator = onGeometryChange ?? pluginConfig.onGeometryChange const properties = { ...customProperties, ...flattenStyleProperties({ stroke, fill, strokeWidth }) diff --git a/plugins/draw/src/api/newLine.test.js b/plugins/draw/src/api/newLine.test.js index b302a7ccd..1c893549e 100644 --- a/plugins/draw/src/api/newLine.test.js +++ b/plugins/draw/src/api/newLine.test.js @@ -75,4 +75,19 @@ describe('newLine', () => { expect(draw.setSnapLayers).toHaveBeenCalledWith(null) expect(dispatch).toHaveBeenCalledWith({ type: 'SET_HAS_SNAP_LAYERS', payload: false }) }) + + test('stores a per-call onGeometryChange validator without leaking it into mode options', () => { + const { context, draw } = makeContext() + const onGeometryChange = jest.fn() + newLine(context, 'f1', { onGeometryChange }) + expect(draw._geometryValidator).toBe(onGeometryChange) + expect(draw.changeMode.mock.calls[0][1]).not.toHaveProperty('onGeometryChange') + }) + + test('falls back to the plugin-level onGeometryChange validator', () => { + const pluginOnGeometryChange = jest.fn() + const { context, draw } = makeContext({ pluginConfig: { onGeometryChange: pluginOnGeometryChange } }) + newLine(context, 'f1') + expect(draw._geometryValidator).toBe(pluginOnGeometryChange) + }) }) diff --git a/plugins/draw/src/api/newPolygon.js b/plugins/draw/src/api/newPolygon.js index a99658bc0..b51219516 100644 --- a/plugins/draw/src/api/newPolygon.js +++ b/plugins/draw/src/api/newPolygon.js @@ -15,7 +15,9 @@ export const newPolygon = ({ appState, appConfig, pluginConfig, pluginState, map draw.setSnapLayers(snapLayers) dispatch({ type: 'SET_HAS_SNAP_LAYERS', payload: snapLayers?.length > 0 }) - const { stroke, fill, strokeWidth, properties: customProperties, ...modeOptions } = options + const { stroke, fill, strokeWidth, properties: customProperties, onGeometryChange, ...modeOptions } = options + // Per-call callback overrides the plugin-level one; events.js reads this on every commit. + draw._geometryValidator = onGeometryChange ?? pluginConfig.onGeometryChange const properties = { ...customProperties, ...flattenStyleProperties({ stroke, fill, strokeWidth }) diff --git a/plugins/draw/src/api/newPolygon.test.js b/plugins/draw/src/api/newPolygon.test.js index 5f8ee6bef..48e6504dc 100644 --- a/plugins/draw/src/api/newPolygon.test.js +++ b/plugins/draw/src/api/newPolygon.test.js @@ -75,4 +75,19 @@ describe('newPolygon', () => { expect(draw.setSnapLayers).toHaveBeenCalledWith(null) expect(dispatch).toHaveBeenCalledWith({ type: 'SET_HAS_SNAP_LAYERS', payload: false }) }) + + test('stores a per-call onGeometryChange validator without leaking it into mode options', () => { + const { context, draw } = makeContext() + const onGeometryChange = jest.fn() + newPolygon(context, 'f1', { onGeometryChange }) + expect(draw._geometryValidator).toBe(onGeometryChange) + expect(draw.changeMode.mock.calls[0][1]).not.toHaveProperty('onGeometryChange') + }) + + test('falls back to the plugin-level onGeometryChange validator', () => { + const pluginOnGeometryChange = jest.fn() + const { context, draw } = makeContext({ pluginConfig: { onGeometryChange: pluginOnGeometryChange } }) + newPolygon(context, 'f1') + expect(draw._geometryValidator).toBe(pluginOnGeometryChange) + }) }) diff --git a/plugins/draw/src/api/split.js b/plugins/draw/src/api/split.js index 144b5bf78..0dce6ca57 100644 --- a/plugins/draw/src/api/split.js +++ b/plugins/draw/src/api/split.js @@ -23,6 +23,9 @@ export const split = ({ appState, appConfig, pluginState, mapState, mapProvider const polygonFeature = draw.get(featureId) + // Split draws its own throwaway line; user geometry validation must not apply here. + draw._geometryValidator = null + // Always include the draw outline layer so the split line snaps to it const snapLayers = ['stroke-inactive.cold', ...(options.snapLayers || [])] draw.setSnapLayers(snapLayers) @@ -50,7 +53,8 @@ export const split = ({ appState, appConfig, pluginState, mapState, mapProvider // Real-time preview: update split validity as vertices are placed (ML only) const DEBOUNCE_MS = 50 const onGeometryChange = debounce((e) => { - if (e.coordinates.length < 2) { + // Ignore commit-level validation events (they carry `kind`, not `coordinates`). + if (!e.coordinates || e.coordinates.length < 2) { return } const lineFeature = { id: '_splitter', geometry: { type: 'LineString', coordinates: e.coordinates } } diff --git a/plugins/draw/src/defaults.js b/plugins/draw/src/defaults.js index 5b1fc4ad3..d583e46cc 100644 --- a/plugins/draw/src/defaults.js +++ b/plugins/draw/src/defaults.js @@ -17,6 +17,7 @@ export const COLORS = { editActive: { light: BLACK, dark: WHITE }, splitInvalid: BLUE, splitValid: BLUE, + invalidStroke: BLUE, shapeStroke: RED, shapeFill: MID_ORANGE, snapVertex: MID_ORANGE, diff --git a/plugins/draw/src/events.js b/plugins/draw/src/events.js index 9588bc98f..c298059b3 100644 --- a/plugins/draw/src/events.js +++ b/plugins/draw/src/events.js @@ -6,15 +6,45 @@ * All MapLibre / OL specifics live in the adapter. */ import { ADAPTER_EVENTS } from './adapterEvents.js' +import { validateGeometry } from './validation/validateGeometry.js' +import { MAP_SIZE_SCALES } from './defaults.js' -function createHandlers ({ pluginState, mapProvider, eventBus, resetState }) { +const EDIT_VERTEX_MODE = 'edit_vertex' +const GEOMETRY_INVALID_EVENT = 'draw:geometryinvalid' + +// Re-open a feature in vertex-edit mode (used when a drawn shape finishes invalid). +// Mirrors the options built by api/editFeature.js. Edit-mode Done is gated by the +// same validity, so the shape can't be finished until it's fixed. +const enterEditVertexMode = ({ draw, appState, appConfig, mapState, dispatch }, featureId) => { + draw.changeMode(EDIT_VERTEX_MODE, { + container: appState?.layoutRefs?.viewportRef?.current, + deleteVertexButtonId: `${appConfig?.id}-draw-delete-point`, + undoButtonId: `${appConfig?.id}-draw-undo`, + isPanEnabled: appState?.interfaceType !== 'keyboard', + interfaceType: appState?.interfaceType, + scale: MAP_SIZE_SCALES[mapState?.mapSize], + featureId, + getSnapEnabled: () => draw.isSnapEnabled() + }) + const editing = draw.get(featureId) + dispatch({ type: 'SET_FEATURE', payload: { feature: editing, tempFeature: editing } }) + dispatch({ type: 'SET_MODE', payload: EDIT_VERTEX_MODE }) + dispatch({ type: 'SET_GEOMETRY_VALID', payload: false }) + draw.setGeometryValid?.(false) + draw.setInvalid?.(true) +} + +function createHandlers ({ appState, appConfig, mapState, pluginState, mapProvider, eventBus, resetState }) { const { draw } = mapProvider const { feature, tempFeature } = pluginState + const { dispatch } = pluginState + return { handleDone: () => { draw.done() }, handleCancel: () => { const mode = draw.getMode() - if (mode === 'edit_vertex' && tempFeature?.id) { draw.add(feature) } + if (mode === EDIT_VERTEX_MODE && tempFeature?.id) { draw.add(feature) } + draw._pendingCreateId = null draw.cancel(); resetState() eventBus.emit('draw:cancelled', feature) }, @@ -24,13 +54,63 @@ function createHandlers ({ pluginState, mapProvider, eventBus, resetState }) { pluginState.dispatch({ type: 'TOGGLE_SNAP' }) draw.setSnapEnabled(!pluginState.snap) }, - onCreate: (f) => { resetState(); setTimeout(() => draw.changeMode('disabled'), 0); eventBus.emit('draw:created', f) }, - onEditFinish: (f) => { resetState(); setTimeout(() => draw.changeMode('disabled'), 0); eventBus.emit('draw:edited', f) }, + onCreate: (f) => { + // A shape can be finished by the Done button OR a map gesture (double-click, + // clicking the first vertex, Enter). Only the button is gated, so re-validate + // here to catch the gesture paths — an invalid shape must never be finalised. + const { valid } = validateGeometry(f, { kind: 'create', mode: draw.getMode() }, { onGeometryChange: draw._geometryValidator }) + if (!valid) { + draw._pendingCreateId = f.id + eventBus.emit(GEOMETRY_INVALID_EVENT, { feature: f, kind: 'create', mode: EDIT_VERTEX_MODE }) + setTimeout(() => enterEditVertexMode({ draw, appState, appConfig, mapState, dispatch }, f.id), 0) + return + } + resetState(); setTimeout(() => draw.changeMode('disabled'), 0); eventBus.emit('draw:created', f) + }, + onEditFinish: (f) => { + resetState() + setTimeout(() => draw.changeMode('disabled'), 0) + // A shape that was drawn-then-fixed reports as a creation, not an edit. + if (draw._pendingCreateId && f.id === draw._pendingCreateId) { + draw._pendingCreateId = null + eventBus.emit('draw:created', f) + } else { + eventBus.emit('draw:edited', f) + } + }, onCancel: () => {}, onVertexSelection: (e) => { pluginState.dispatch({ type: 'SET_SELECTED_VERTEX_INDEX', payload: e }); eventBus.emit('draw:vertexselection', e) }, onVertexChange: (e) => { pluginState.dispatch({ type: 'SET_SELECTED_VERTEX_INDEX', payload: { index: -1, numVertices: e.numVertices } }) }, onUndoChange: (l) => { pluginState.dispatch({ type: 'SET_UNDO_STACK_LENGTH', payload: l }) }, - onUpdate: (f) => { eventBus.emit('draw:updated', f) } + onUpdate: (f) => { eventBus.emit('draw:updated', f) }, + onGeometryChange: (e) => { + // Only commit-level changes (add/move/insert/delete) carry a `kind`. + // Preview events (e.g. split's live preview) have none and are ignored. + if (!e?.kind) { return } + + const mode = draw.getMode() + const context = { kind: e.kind, vertexIndex: e.vertexIndex, mode } + const { valid, reason } = validateGeometry(e.feature, context, { onGeometryChange: draw._geometryValidator }) + + // Rules only gate the Done button — never revert. A shape can pass through + // interim invalid states while being built or reshaped. + pluginState.dispatch({ type: 'SET_GEOMETRY_VALID', payload: valid }) + draw.setGeometryValid?.(valid) + // The invalid stroke is committed-validity-driven in edit mode only; in draw + // mode the adapters drive it live from the displayed geometry (placed + // vertices + cursor) on every rubber-band move. + if (mode === EDIT_VERTEX_MODE) { + draw.setInvalid?.(!valid) + } + if (!valid) { + eventBus.emit(GEOMETRY_INVALID_EVENT, { reason, ...context, feature: e.feature }) + } + }, + // A vertex placement was rejected (hard rule or user callback veto). Surface it + // on the public bus with kind 'place' so a future tooltip can show the reason. + onPlacementBlocked: (e) => { + eventBus.emit(GEOMETRY_INVALID_EVENT, e) + } } } @@ -51,6 +131,8 @@ function attachDrawEvents (draw, handlers) { draw.on(ADAPTER_EVENTS.VERTEX_CHANGE, handlers.onVertexChange) draw.on(ADAPTER_EVENTS.UNDO_CHANGE, handlers.onUndoChange) draw.on(ADAPTER_EVENTS.UPDATE, handlers.onUpdate) + draw.on(ADAPTER_EVENTS.GEOMETRY_CHANGE, handlers.onGeometryChange) + draw.on(ADAPTER_EVENTS.PLACEMENT_BLOCKED, handlers.onPlacementBlocked) } function detachButtonHandlers (buttonConfig) { @@ -70,15 +152,17 @@ function detachDrawEvents (draw, handlers) { draw.off(ADAPTER_EVENTS.VERTEX_CHANGE, handlers.onVertexChange) draw.off(ADAPTER_EVENTS.UNDO_CHANGE, handlers.onUndoChange) draw.off(ADAPTER_EVENTS.UPDATE, handlers.onUpdate) + draw.off(ADAPTER_EVENTS.GEOMETRY_CHANGE, handlers.onGeometryChange) + draw.off(ADAPTER_EVENTS.PLACEMENT_BLOCKED, handlers.onPlacementBlocked) } -export function attachEvents ({ pluginState, mapProvider, buttonConfig, eventBus }) { +export function attachEvents ({ appState, appConfig, mapState, pluginState, mapProvider, buttonConfig, eventBus }) { const { draw } = mapProvider const resetState = () => { pluginState.dispatch({ type: 'SET_MODE', payload: null }) pluginState.dispatch({ type: 'SET_FEATURE', payload: { feature: null, tempFeature: null } }) } - const handlers = createHandlers({ pluginState, mapProvider, eventBus, resetState }) + const handlers = createHandlers({ appState, appConfig, mapState, pluginState, mapProvider, eventBus, resetState }) attachButtonHandlers(buttonConfig, handlers) attachDrawEvents(draw, handlers) return () => { detachButtonHandlers(buttonConfig); detachDrawEvents(draw, handlers) } diff --git a/plugins/draw/src/events.test.js b/plugins/draw/src/events.test.js index fc25467ef..b08f1ee0c 100644 --- a/plugins/draw/src/events.test.js +++ b/plugins/draw/src/events.test.js @@ -2,7 +2,7 @@ import { attachEvents } from './events.js' jest.useFakeTimers() -const DRAW_EVENTS = ['create', 'editfinish', 'cancel', 'vertexselection', 'vertexchange', 'undochange', 'update'] +const DRAW_EVENTS = ['create', 'editfinish', 'cancel', 'vertexselection', 'vertexchange', 'undochange', 'update', 'geometrychange', 'placementblocked'] const setup = (overrides = {}) => { const draw = { @@ -10,9 +10,13 @@ const setup = (overrides = {}) => { done: jest.fn(), cancel: jest.fn(), add: jest.fn(), + get: jest.fn(() => ({ id: 'F' })), undo: jest.fn(), + setGeometryValid: jest.fn(), + setInvalid: jest.fn(), deleteVertex: jest.fn(), setSnapEnabled: jest.fn(), + isSnapEnabled: jest.fn(() => false), changeMode: jest.fn(), on: jest.fn(), off: jest.fn() @@ -22,8 +26,11 @@ const setup = (overrides = {}) => { const mapProvider = { draw } const eventBus = { emit: jest.fn() } 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 detach = attachEvents({ pluginState, mapProvider, buttonConfig, eventBus }) + const detach = attachEvents({ appState, appConfig, mapState, pluginState, mapProvider, buttonConfig, eventBus }) return { draw, dispatch, pluginState, mapProvider, eventBus, buttonConfig, detach } } @@ -128,6 +135,28 @@ describe('draw event handlers', () => { expect(draw.changeMode).toHaveBeenCalledWith('disabled') }) + test('re-opens an invalid finished shape in edit mode instead of creating it', () => { + const { draw, eventBus } = setup() + const bowtie = { id: 'bad', type: 'Feature', geometry: { type: 'Polygon', coordinates: [[[0, 0], [1, 1], [1, 0], [0, 1], [0, 0]]] } } + drawHandler(draw, 'create')(bowtie) + expect(eventBus.emit).not.toHaveBeenCalledWith('draw:created', bowtie) + expect(draw._pendingCreateId).toBe('bad') + jest.runAllTimers() + const editCall = draw.changeMode.mock.calls.find(([mode]) => mode === 'edit_vertex') + expect(editCall[1]).toEqual(expect.objectContaining({ featureId: 'bad' })) + // the wired getSnapEnabled option delegates to the adapter + editCall[1].getSnapEnabled() + expect(draw.isSnapEnabled).toHaveBeenCalled() + }) + + test('edit finish of a drawn-then-fixed shape reports as a creation', () => { + const { draw, eventBus } = setup() + draw._pendingCreateId = 'bad' + drawHandler(draw, 'editfinish')({ id: 'bad' }) + expect(eventBus.emit).toHaveBeenCalledWith('draw:created', { id: 'bad' }) + expect(draw._pendingCreateId).toBeNull() + }) + test('cancel handler is a no-op', () => { const { draw } = setup() expect(() => drawHandler(draw, 'cancel')()).not.toThrow() @@ -159,6 +188,97 @@ describe('draw event handlers', () => { }) }) +describe('geometrychange validation', () => { + const squareFeature = { + type: 'Feature', + geometry: { type: 'Polygon', coordinates: [[[0, 0], [0, 1], [1, 1], [1, 0], [0, 0]]] } + } + const bowtieFeature = { + type: 'Feature', + geometry: { type: 'Polygon', coordinates: [[[0, 0], [1, 1], [1, 0], [0, 1], [0, 0]]] } + } + const collinearFeature = { + type: 'Feature', + geometry: { type: 'Polygon', coordinates: [[[0, 0], [1, 0], [2, 0], [0, 0]]] } + } + + test('ignores preview payloads that carry no change kind', () => { + const { draw, dispatch } = setup() + drawHandler(draw, 'geometrychange')({ coordinates: [[0, 0], [1, 1]] }) + expect(dispatch).not.toHaveBeenCalledWith({ type: 'SET_GEOMETRY_VALID', payload: expect.anything() }) + }) + + test('opens the gate for a valid geometry', () => { + const { draw, dispatch } = setup() + drawHandler(draw, 'geometrychange')({ feature: squareFeature, kind: 'add', vertexIndex: 3 }) + expect(dispatch).toHaveBeenCalledWith({ type: 'SET_GEOMETRY_VALID', payload: true }) + }) + + test('gates a self-intersecting shape while drawing', () => { + const { draw, dispatch, eventBus } = setup() + drawHandler(draw, 'geometrychange')({ feature: bowtieFeature, kind: 'add', vertexIndex: 3 }) + expect(dispatch).toHaveBeenCalledWith({ type: 'SET_GEOMETRY_VALID', payload: false }) + expect(draw.setGeometryValid).toHaveBeenCalledWith(false) + expect(eventBus.emit).toHaveBeenCalledWith('draw:geometryinvalid', expect.objectContaining({ reason: expect.stringMatching(/intersect/i) })) + }) + + test('gates a zero-area shape while drawing', () => { + const { draw, dispatch } = setup() + drawHandler(draw, 'geometrychange')({ feature: collinearFeature, kind: 'add', vertexIndex: 2 }) + expect(dispatch).toHaveBeenCalledWith({ type: 'SET_GEOMETRY_VALID', payload: false }) + }) + + test('gates a self-intersecting move in edit mode (never reverts)', () => { + const { draw, dispatch, eventBus } = setup() + draw.getMode.mockReturnValue('edit_vertex') + drawHandler(draw, 'geometrychange')({ feature: bowtieFeature, kind: 'move', vertexIndex: 2 }) + expect(dispatch).toHaveBeenCalledWith({ type: 'SET_GEOMETRY_VALID', payload: false }) + expect(eventBus.emit).toHaveBeenCalledWith('draw:geometryinvalid', expect.objectContaining({ reason: expect.stringMatching(/intersect/i) })) + }) + + test('keeps a valid edit move (gate open)', () => { + const { draw, dispatch } = setup() + draw.getMode.mockReturnValue('edit_vertex') + drawHandler(draw, 'geometrychange')({ feature: squareFeature, kind: 'move', vertexIndex: 1 }) + expect(dispatch).toHaveBeenCalledWith({ type: 'SET_GEOMETRY_VALID', payload: true }) + }) + + test('applies the per-session user validator as a gate', () => { + const { draw, dispatch, eventBus } = setup() + draw._geometryValidator = () => ({ valid: false, reason: 'too big' }) + drawHandler(draw, 'geometrychange')({ feature: squareFeature, kind: 'add', vertexIndex: 3 }) + expect(dispatch).toHaveBeenCalledWith({ type: 'SET_GEOMETRY_VALID', payload: false }) + expect(eventBus.emit).toHaveBeenCalledWith('draw:geometryinvalid', expect.objectContaining({ reason: 'too big' })) + }) + + test('drives the invalid stroke from committed validity in edit mode only', () => { + const { draw } = setup() + draw.getMode.mockReturnValue('draw_polygon') + drawHandler(draw, 'geometrychange')({ feature: bowtieFeature, kind: 'add', vertexIndex: 3 }) + expect(draw.setInvalid).not.toHaveBeenCalled() // draw mode: the adapter's live check owns the stroke + + draw.getMode.mockReturnValue('edit_vertex') + drawHandler(draw, 'geometrychange')({ feature: bowtieFeature, kind: 'move', vertexIndex: 2 }) + expect(draw.setInvalid).toHaveBeenCalledWith(true) + }) + + test('relays a blocked placement to the public bus as draw:geometryinvalid', () => { + const { draw, eventBus } = setup() + const blocked = { kind: 'place', mode: 'draw_polygon', vertexIndex: 2, reason: 'outside region', feature: { type: 'Feature' } } + drawHandler(draw, 'placementblocked')(blocked) + expect(eventBus.emit).toHaveBeenCalledWith('draw:geometryinvalid', blocked) + }) + + test('passes the current mode into the validation context', () => { + const { draw } = setup() + draw.getMode.mockReturnValue('draw_polygon') + const validator = jest.fn(() => true) + draw._geometryValidator = validator + drawHandler(draw, 'geometrychange')({ feature: squareFeature, kind: 'add', vertexIndex: 3 }) + expect(validator).toHaveBeenCalledWith(squareFeature, { kind: 'add', vertexIndex: 3, mode: 'draw_polygon' }) + }) +}) + describe('detach', () => { test('clears button handlers and unsubscribes from draw events', () => { const { detach, buttonConfig, draw } = setup() diff --git a/plugins/draw/src/manifest.js b/plugins/draw/src/manifest.js index 1d09ef85a..32d40867c 100644 --- a/plugins/draw/src/manifest.js +++ b/plugins/draw/src/manifest.js @@ -51,10 +51,10 @@ export const manifest = { exclusiveSlot: true, hiddenWhen: ({ pluginState }) => !['draw_polygon', 'draw_line', 'edit_vertex'].includes(pluginState.mode), enableWhen: ({ pluginState }) => { - const { mode, numVertices } = pluginState - return (mode === 'draw_polygon' && numVertices >= 3) || // NOSONAR - (mode === 'draw_line' && numVertices >= 2) || // NOSONAR - mode === 'edit_vertex' + const { mode, geometryValid } = pluginState + // Min-vertices, area and self-intersection are all enforced by the validation + // rules via geometryValid, so the gate is simply "is the geometry valid now". + return ['draw_polygon', 'draw_line', 'edit_vertex'].includes(mode) && geometryValid }, ...createButtonSlots(true) }, diff --git a/plugins/draw/src/manifest.test.js b/plugins/draw/src/manifest.test.js index 2c3acdf26..17673ab0f 100644 --- a/plugins/draw/src/manifest.test.js +++ b/plugins/draw/src/manifest.test.js @@ -40,13 +40,17 @@ describe('drawDone', () => { expect(btn().hiddenWhen({ pluginState: { mode: 'draw_polygon' } })).toBe(false) }) - test('enables per mode and vertex count', () => { - expect(btn().enableWhen({ pluginState: { mode: 'draw_polygon', numVertices: 3 } })).toBe(true) - expect(btn().enableWhen({ pluginState: { mode: 'draw_polygon', numVertices: 2 } })).toBe(false) - expect(btn().enableWhen({ pluginState: { mode: 'draw_line', numVertices: 2 } })).toBe(true) - expect(btn().enableWhen({ pluginState: { mode: 'draw_line', numVertices: 1 } })).toBe(false) - expect(btn().enableWhen({ pluginState: { mode: 'edit_vertex' } })).toBe(true) - expect(btn().enableWhen({ pluginState: { mode: 'disabled' } })).toBe(false) + test('enables in a draw/edit mode only when the geometry is valid', () => { + expect(btn().enableWhen({ pluginState: { mode: 'draw_polygon', geometryValid: true } })).toBe(true) + expect(btn().enableWhen({ pluginState: { mode: 'draw_line', geometryValid: true } })).toBe(true) + expect(btn().enableWhen({ pluginState: { mode: 'edit_vertex', geometryValid: true } })).toBe(true) + expect(btn().enableWhen({ pluginState: { mode: 'disabled', geometryValid: true } })).toBe(false) + }) + + test('stays disabled while the geometry is invalid', () => { + expect(btn().enableWhen({ pluginState: { mode: 'draw_polygon', geometryValid: false } })).toBe(false) + expect(btn().enableWhen({ pluginState: { mode: 'draw_line', geometryValid: false } })).toBe(false) + expect(btn().enableWhen({ pluginState: { mode: 'edit_vertex', geometryValid: false } })).toBe(false) }) }) diff --git a/plugins/draw/src/reducer.js b/plugins/draw/src/reducer.js index a2352ff92..4cbb58cdc 100644 --- a/plugins/draw/src/reducer.js +++ b/plugins/draw/src/reducer.js @@ -6,6 +6,7 @@ const initialState = { tempFeature: null, selectedVertexIndex: -1, numVertices: null, + geometryValid: true, snap: false, hasSnapLayers: false, undoStackLength: 0 @@ -16,7 +17,10 @@ const DRAW_MODES = new Set(['draw_polygon', 'draw_line']) const setMode = (state, payload) => ({ ...state, mode: payload, - numVertices: DRAW_MODES.has(payload) ? 0 : state.numVertices + numVertices: DRAW_MODES.has(payload) ? 0 : state.numVertices, + // A new/empty shape is never valid; validation flips this true once the geometry + // passes all soft rules (edit mode seeds it explicitly in api/editFeature). + geometryValid: false }) const setAction = (state, payload) => ({ @@ -45,6 +49,8 @@ const setHasSnapLayers = (state, payload) => ({ ...state, hasSnapLayers: !!paylo const setUndoStackLength = (state, payload) => ({ ...state, undoStackLength: payload }) +const setGeometryValid = (state, payload) => ({ ...state, geometryValid: !!payload }) + const actions = { SET_MODE: setMode, SET_ACTION: setAction, @@ -53,7 +59,8 @@ const actions = { TOGGLE_SNAP: toggleSnap, SET_SNAP: setSnap, SET_HAS_SNAP_LAYERS: setHasSnapLayers, - SET_UNDO_STACK_LENGTH: setUndoStackLength + SET_UNDO_STACK_LENGTH: setUndoStackLength, + SET_GEOMETRY_VALID: setGeometryValid } export { initialState, actions } diff --git a/plugins/draw/src/validation/liveStroke.js b/plugins/draw/src/validation/liveStroke.js new file mode 100644 index 000000000..4d18d8206 --- /dev/null +++ b/plugins/draw/src/validation/liveStroke.js @@ -0,0 +1,75 @@ +import { validateDisplayedGeometry } from './validateGeometry.js' + +const requestFrame = (cb) => + (typeof requestAnimationFrame === 'function' ? requestAnimationFrame(cb) : setTimeout(cb, 16)) +const cancelFrame = (id) => + (typeof cancelAnimationFrame === 'function' ? cancelAnimationFrame(id) : clearTimeout(id)) + +/** + * Engine-agnostic driver for the live invalid stroke while drawing. + * + * On every rubber-band move the caller passes the displayed geometry (placed + * vertices + cursor). The default rules (self-intersection, non-zero area) run + * synchronously for immediate feedback; a user `onGeometryChange` callback — whose + * cost is unknown — is throttled to one call per animation frame using the latest + * geometry (trailing edge), so an arbitrary user rule can't jank the drag. The + * default-fail path short-circuits (no user call) and cancels any pending frame so + * a stale evaluation can't flip the stroke back. `onChange(invalid, reason)` fires + * only when the invalid state actually flips. + * + * @param {object} params + * @param {(invalid: boolean, reason: string|null) => void} params.onChange + * @returns {{ update: Function, reset: Function, destroy: Function }} + */ +export const createLiveStroke = ({ onChange }) => { + let invalid = false + let frame = null + let pending = null + + const cancelPending = () => { + if (frame != null) { cancelFrame(frame); frame = null } + pending = null + } + + const flip = (next, reason) => { + if (next !== invalid) { + invalid = next + // eslint-disable-next-line no-console -- TEMP live-stroke diagnostics + console.log('[live-stroke] FLIP →', next) + onChange(next, reason ?? null) + } + } + + const runUserRule = () => { + frame = null + if (!pending) { return } + const { feature, context, onGeometryChange } = pending + const { valid, reason } = validateDisplayedGeometry(feature, context, { onGeometryChange }) + flip(!valid, reason) + } + + return { + // Re-evaluate the displayed geometry after a rubber-band move. + update ({ feature, context = {}, placedCount, onGeometryChange }) { + const ctx = { ...context, placedCount } + // Default rules first, synchronously — immediate feedback on self-intersection / area. + const base = validateDisplayedGeometry(feature, ctx) + // eslint-disable-next-line no-console -- TEMP live-stroke diagnostics + console.log('[live-stroke] update', { type: feature?.geometry?.type, placedCount, baseValid: base.valid, currentInvalid: invalid }) + if (!base.valid) { cancelPending(); flip(true, base.reason); return } + // Default rules pass — a user rule (if any) decides, throttled to one frame. + if (typeof onGeometryChange !== 'function') { cancelPending(); flip(false, null); return } + pending = { feature, context: ctx, onGeometryChange } + if (frame == null) { frame = requestFrame(runUserRule) } + }, + // Clear state without firing onChange (the caller forces the stroke solid on a + // fresh draw); drops any pending user-rule frame. + reset () { + cancelPending() + invalid = false + }, + destroy () { + cancelPending() + } + } +} diff --git a/plugins/draw/src/validation/rules.areaError.test.js b/plugins/draw/src/validation/rules.areaError.test.js new file mode 100644 index 000000000..34bf203b3 --- /dev/null +++ b/plugins/draw/src/validation/rules.areaError.test.js @@ -0,0 +1,12 @@ +import { nonZeroArea } from './rules.js' + +// A separate file is required: this file-level @turf/area mock is hoisted above the +// import and applies to the whole file, so it can't live in rules.test.js without +// breaking the tests there that rely on the real area calculation. It covers the +// defensive catch in nonZeroArea (a turf failure is treated as "skip / valid"). +jest.mock('@turf/area', () => ({ __esModule: true, default: jest.fn(() => { throw new Error('turf boom') }) })) + +test('nonZeroArea treats a turf area failure as a skip (valid)', () => { + const feature = { type: 'Feature', geometry: { type: 'Polygon', coordinates: [[[0, 0], [1, 0], [1, 1]]] } } + expect(nonZeroArea(feature)).toEqual({ valid: true }) +}) diff --git a/plugins/draw/src/validation/rules.js b/plugins/draw/src/validation/rules.js new file mode 100644 index 000000000..14354aba7 --- /dev/null +++ b/plugins/draw/src/validation/rules.js @@ -0,0 +1,175 @@ +import turfArea from '@turf/area' + +/** + * Engine-agnostic geometry validation. + * + * A rule is a pure `(feature, context) => { valid, reason }`. Two classes: + * - SOFT_RULES gate the Done button (geometryValid) — the change is always + * kept, so a shape can pass through interim invalid states while being + * built or reshaped. + * - HARD_RULES gate vertex placement — a failure rejects the placement and + * the vertex never appears (used for unrecoverable states, e.g. a vertex + * that would force the drawn path to cross itself). + * + * `context` is `{ kind, vertexIndex, mode }` so rules can vary by change kind or mode. + * Add a rule by appending it to SOFT_RULES or HARD_RULES. + */ + +const MIN_POLYGON_VERTICES = 3 +const MIN_LINE_VERTICES = 2 +const MIN_INTERSECT_VERTICES = 4 // need 4+ vertices for two non-adjacent edges to exist + +const getGeometry = (feature) => feature?.geometry ?? feature +const getPolygon = (feature) => { + const geometry = getGeometry(feature) + return geometry?.type === 'Polygon' ? geometry : null +} + +// Distinct outer-ring vertices. Drops consecutive duplicates (zero-length edges — +// e.g. the rubber band sitting on the just-placed vertex) which would otherwise +// read as false intersections, then any explicit closing point so open +// (in-progress) and closed (finished) rings are handled the same way. +const getRingVertices = (geometry) => { + const raw = geometry.coordinates?.[0] ?? [] + const ring = raw.filter((v, i) => i === 0 || v[0] !== raw[i - 1][0] || v[1] !== raw[i - 1][1]) + while (ring.length > 1 && + ring[0][0] === ring[ring.length - 1][0] && + ring[0][1] === ring[ring.length - 1][1]) { + ring.pop() + } + return ring +} + +const closeRing = (vertices) => [...vertices, vertices[0]] + +// Signed area of the triangle (o, a, b); sign gives orientation, zero = collinear. +const cross = (o, a, b) => (a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0]) + +// True when collinear point q lies within the bounding box of segment p→r. +const onSegment = (p, q, r) => + Math.min(p[0], r[0]) <= q[0] && q[0] <= Math.max(p[0], r[0]) && + Math.min(p[1], r[1]) <= q[1] && q[1] <= Math.max(p[1], r[1]) + +// True when d1 and d2 lie on opposite sides (one strictly positive, one strictly negative). +const straddles = (d1, d2) => (d1 > 0 && d2 < 0) || (d1 < 0 && d2 > 0) + +// True when p is collinear with the segment (d === 0) and lies on it. +const collinearHit = (d, a, p, b) => d === 0 && onSegment(a, p, b) + +// True when segments (p1,p2) and (p3,p4) intersect (including collinear overlap). +const segmentsIntersect = (p1, p2, p3, p4) => { + const d1 = cross(p3, p4, p1) + const d2 = cross(p3, p4, p2) + const d3 = cross(p1, p2, p3) + const d4 = cross(p1, p2, p4) + + if (straddles(d1, d2) && straddles(d3, d4)) { return true } + + return collinearHit(d1, p3, p1, p4) || collinearHit(d2, p3, p2, p4) || + collinearHit(d3, p1, p3, p2) || collinearHit(d4, p1, p4, p2) +} + +// Do any two non-adjacent edges cross? When `closed`, the implicit closing edge +// (last vertex back to the first) is included; when open, only the drawn edges are +// tested (used to reject a self-crossing vertex placement while drawing). +const edgesSelfIntersect = (vertices, closed) => { + const n = vertices.length + const edgeCount = closed ? n : n - 1 + for (let i = 0; i < edgeCount; i++) { + for (let j = i + 1; j < edgeCount; j++) { + const adjacent = j === i + 1 || (closed && i === 0 && j === edgeCount - 1) + if (adjacent) { continue } + if (segmentsIntersect(vertices[i], vertices[(i + 1) % n], vertices[j], vertices[(j + 1) % n])) { + return true + } + } + } + return false +} + +/** + * HARD (draw placement): would the open drawn path cross itself? Run against the + * candidate path (placed vertices + the point about to be placed) — a failure + * rejects the placement so the vertex never appears and a genuine self-intersection + * can't be drawn forward. + */ +export const pathSelfIntersects = (feature) => { + const geometry = getPolygon(feature) + if (!geometry) { return false } + const vertices = getRingVertices(geometry) + if (vertices.length < MIN_INTERSECT_VERTICES) { return false } + return edgesSelfIntersect(vertices, false) +} + +/** Rule-shaped wrapper for pathSelfIntersects (see HARD_RULES). */ +export const noPathSelfIntersection = (feature) => + pathSelfIntersects(feature) + ? { valid: false, reason: 'Point would make the shape intersect itself' } + : { valid: true } + +/** + * A polygon must not self-intersect. Gates Done while drawing (a would-be-crossing + * closing edge disables Done) and while editing. + */ +export const noSelfIntersection = (feature) => { + const geometry = getPolygon(feature) + if (!geometry) { return { valid: true } } + const vertices = getRingVertices(geometry) + if (vertices.length < MIN_INTERSECT_VERTICES) { return { valid: true } } + return edgesSelfIntersect(vertices, true) + ? { valid: false, reason: 'Shape must not intersect itself' } + : { valid: true } +} + +/** + * A polygon must enclose a non-zero area (rejects collinear / degenerate rings). + */ +export const nonZeroArea = (feature) => { + const geometry = getPolygon(feature) + if (!geometry) { return { valid: true } } + + const vertices = getRingVertices(geometry) + if (vertices.length < MIN_POLYGON_VERTICES) { return { valid: true } } + + let area = 0 + try { + area = turfArea({ type: 'Feature', geometry: { type: 'Polygon', coordinates: [closeRing(vertices)] }, properties: {} }) + } catch { + return { valid: true } + } + + return area > 0 ? { valid: true } : { valid: false, reason: 'Shape must enclose an area' } +} + +/** + * A shape needs enough vertices to be finishable (3 for a polygon, 2 for a line). + */ +export const minVertices = (feature) => { + const geometry = getGeometry(feature) + if (geometry?.type === 'Polygon') { + return getRingVertices(geometry).length >= MIN_POLYGON_VERTICES + ? { valid: true } + : { valid: false, reason: 'Shape needs at least 3 points' } + } + if (geometry?.type === 'LineString') { + return (geometry.coordinates?.length ?? 0) >= MIN_LINE_VERTICES + ? { valid: true } + : { valid: false, reason: 'Line needs at least 2 points' } + } + return { valid: true } +} + +// Validation rules. A failure disables the Done button (geometryValid). +// Order sets which reason surfaces first. +export const SOFT_RULES = [noSelfIntersection, nonZeroArea, minVertices] + +// Rules that drive the live invalid stroke while drawing, run against the displayed +// geometry (placed vertices + cursor) on every rubber-band move. Everything soft +// EXCEPT minVertices — an incomplete shape is "part-drawn", not invalid. +// validateDisplayedGeometry applies the minimum-placed-vertex threshold instead. +export const LIVE_RULES = [noSelfIntersection, nonZeroArea] + +// Placement rules. Run by validatePlacement against the candidate geometry +// (placed vertices + the point about to be placed); a failure rejects the +// placement outright — the vertex never appears. +export const HARD_RULES = [noPathSelfIntersection] diff --git a/plugins/draw/src/validation/rules.test.js b/plugins/draw/src/validation/rules.test.js new file mode 100644 index 000000000..653a07f97 --- /dev/null +++ b/plugins/draw/src/validation/rules.test.js @@ -0,0 +1,120 @@ +import { noSelfIntersection, nonZeroArea, minVertices, pathSelfIntersects, noPathSelfIntersection, SOFT_RULES, HARD_RULES } from './rules.js' + +const poly = (coordinates) => ({ type: 'Feature', geometry: { type: 'Polygon', coordinates: [coordinates] } }) +const line = (coordinates) => ({ type: 'Feature', geometry: { type: 'LineString', coordinates } }) + +describe('noSelfIntersection', () => { + test('rejects a closed self-intersecting polygon', () => { + expect(noSelfIntersection(poly([[0, 0], [1, 1], [1, 0], [0, 1], [0, 0]])).valid).toBe(false) + }) + + test('rejects a shape whose closing edge crosses another edge', () => { + expect(noSelfIntersection(poly([[0, 0], [2, 0], [0, 2], [2, 2]])).valid).toBe(false) + }) + + test('detects a T-touch (collinear) crossing', () => { + // Edge (1,0)-(1,2) starts on the earlier edge (0,0)-(3,0): a collinear/touch hit. + expect(noSelfIntersection(poly([[0, 0], [3, 0], [1, 0], [1, 2]])).valid).toBe(false) + }) + + test('accepts a simple polygon', () => { + expect(noSelfIntersection(poly([[0, 0], [2, 0], [2, 2], [0, 2], [0, 0]]))).toEqual({ valid: true }) + }) + + test('skips non-polygons and short rings', () => { + expect(noSelfIntersection(line([[0, 0], [1, 1]]))).toEqual({ valid: true }) + expect(noSelfIntersection(poly([[0, 0], [1, 1], [1, 0]]))).toEqual({ valid: true }) + }) +}) + +describe('nonZeroArea (soft)', () => { + test('rejects a collinear (zero-area) ring', () => { + const result = nonZeroArea(poly([[0, 0], [1, 0], [2, 0]])) + expect(result.valid).toBe(false) + expect(result.reason).toMatch(/area/i) + }) + + test('accepts a ring with area', () => { + expect(nonZeroArea(poly([[0, 0], [1, 0], [1, 1]]))).toEqual({ valid: true }) + }) + + test('skips non-polygons and rings under three vertices', () => { + expect(nonZeroArea(line([[0, 0], [1, 1]]))).toEqual({ valid: true }) + expect(nonZeroArea(poly([[0, 0], [1, 0]]))).toEqual({ valid: true }) + }) + // The defensive catch (a turf failure → skip) is covered in rules.areaError.test.js, + // which needs a file-level @turf/area mock that must not affect the real-area tests here. +}) + +describe('minVertices (soft)', () => { + test('requires three points for a polygon', () => { + expect(minVertices(poly([[0, 0], [1, 0]])).valid).toBe(false) + expect(minVertices(poly([[0, 0], [1, 0], [1, 1]]))).toEqual({ valid: true }) + }) + + test('counts a closed ring by its distinct vertices', () => { + expect(minVertices(poly([[0, 0], [1, 0], [1, 1], [0, 0]]))).toEqual({ valid: true }) + }) + + test('requires two points for a line', () => { + expect(minVertices(line([[0, 0]])).valid).toBe(false) + expect(minVertices(line([[0, 0], [1, 1]]))).toEqual({ valid: true }) + }) + + test('skips geometry that is neither a polygon nor a line', () => { + expect(minVertices({ geometry: { type: 'Point', coordinates: [0, 0] } })).toEqual({ valid: true }) + }) +}) + +describe('pathSelfIntersects (hard, draw placement)', () => { + test('detects a self-crossing open drawn path (no closing edge)', () => { + expect(pathSelfIntersects(poly([[0, 0], [2, 2], [2, 0], [0, 2]]))).toBe(true) + }) + + test('accepts an open path that only closes into a crossing', () => { + // A bow-tie only crosses via its closing edge, which the open-path check ignores. + expect(pathSelfIntersects(poly([[0, 0], [2, 0], [0, 2], [2, 2]]))).toBe(false) + }) + + test('accepts a simple open path', () => { + expect(pathSelfIntersects(poly([[0, 0], [2, 0], [2, 2], [0, 2]]))).toBe(false) + }) + + test('skips non-polygons and paths under four vertices', () => { + expect(pathSelfIntersects(line([[0, 0], [1, 1]]))).toBe(false) + expect(pathSelfIntersects(poly([[0, 0], [1, 0], [1, 1]]))).toBe(false) + }) +}) + +describe('consecutive duplicate vertices (zero-length edges)', () => { + test('a rubber band sitting on the just-placed vertex is not an intersection', () => { + // 3 placed + duplicate rubber-band coord + closing point — the in-progress + // ring layout the instant after a vertex is placed. + expect(noSelfIntersection(poly([[0, 0], [10, 0], [10, 10], [10, 10], [0, 0]]))) + .toEqual({ valid: true }) + }) + + test('a real crossing is still detected when a duplicate vertex is present', () => { + expect(noSelfIntersection(poly([[0, 0], [1, 1], [1, 1], [1, 0], [0, 1], [0, 0]])).valid).toBe(false) + }) +}) + +describe('noPathSelfIntersection (hard, rule shape)', () => { + test('wraps pathSelfIntersects with a reason for the placement veto', () => { + expect(noPathSelfIntersection(poly([[0, 0], [2, 2], [2, 0], [0, 2]]))) + .toEqual({ valid: false, reason: expect.stringMatching(/intersect/i) }) + expect(noPathSelfIntersection(poly([[0, 0], [2, 0], [2, 2], [0, 2]]))).toEqual({ valid: true }) + }) +}) + +describe('SOFT_RULES', () => { + test('are the gating rules in reason-priority order', () => { + expect(SOFT_RULES).toEqual([noSelfIntersection, nonZeroArea, minVertices]) + }) +}) + +describe('HARD_RULES', () => { + test('are the placement-veto rules', () => { + expect(HARD_RULES).toEqual([noPathSelfIntersection]) + }) +}) diff --git a/plugins/draw/src/validation/validateGeometry.js b/plugins/draw/src/validation/validateGeometry.js new file mode 100644 index 000000000..5715f0855 --- /dev/null +++ b/plugins/draw/src/validation/validateGeometry.js @@ -0,0 +1,92 @@ +import { SOFT_RULES, HARD_RULES, LIVE_RULES } from './rules.js' + +/** + * Normalise a rule / callback result into `{ valid, reason }`. + * + * Supports three return shapes so a user `onGeometryChange` callback can stay + * terse: + * - `true` / `undefined` → valid + * - `false` → invalid (no reason) + * - `{ valid, reason }` → passed through + */ +const normaliseResult = (result) => { + if (result === undefined || result === true) { return { valid: true } } + if (result === false) { return { valid: false, reason: null } } + return { valid: result.valid !== false, reason: result.reason } +} + +/** + * Validate an in-progress geometry against the default rules and an optional user + * callback. The result only gates the Done button (see events.js); it never reverts + * a vertex change, so a shape can pass through interim invalid states while being + * built. Rules run first (in order) and short-circuit on the first failure; the + * user callback runs last. + * + * @param {object} feature - current GeoJSON feature + * @param {object} context - { kind: 'add'|'move'|'insert'|'delete', vertexIndex, mode } + * @param {object} [config] + * @param {Array} [config.rules] - defaults to DEFAULT_RULES + * @param {Function} [config.onGeometryChange] - user callback, same signature as a rule + * @returns {{ valid: boolean, reason?: string }} + */ +export const validateGeometry = (feature, context = {}, config = {}) => { + const { rules = SOFT_RULES, onGeometryChange } = config + + for (const rule of rules) { + const result = normaliseResult(rule(feature, context)) + if (!result.valid) { return result } + } + + if (typeof onGeometryChange === 'function') { + return normaliseResult(onGeometryChange(feature, context)) + } + + return { valid: true } +} + +/** + * Validate a candidate vertex placement against the hard rules and the same + * optional user callback. `feature` is the candidate geometry — the placed + * vertices plus the point about to be placed. A failure means the adapter must + * reject the placement (the vertex never appears), used for states that could + * not be recovered from by continuing to draw. + * + * The user callback receives `context.kind === 'place'` to distinguish a + * placement veto from a soft validity check. + * + * @param {object} feature - candidate GeoJSON feature (placed vertices + new point) + * @param {object} context - { vertexIndex, mode }; kind is forced to 'place' + * @param {object} [config] + * @param {Array} [config.rules] - defaults to HARD_RULES + * @param {Function} [config.onGeometryChange] - user callback, same signature as a rule + * @returns {{ valid: boolean, reason?: string }} + */ +export const validatePlacement = (feature, context = {}, config = {}) => { + const { rules = HARD_RULES, onGeometryChange } = config + return validateGeometry(feature, { ...context, kind: 'place' }, { rules, onGeometryChange }) +} + +const MIN_VERTICES_BY_TYPE = { Polygon: 3, LineString: 2 } + +/** + * Validate the displayed (in-progress) geometry that drives the live invalid + * stroke: the placed vertices plus the current cursor / crosshair point. It gates + * on `context.placedCount` so a shape below its minimum vertex count is treated as + * "part-drawn" (always valid — solid stroke); once past the threshold it runs the + * live rules (self-intersection, non-zero area) and the optional user callback + * against the displayed geometry, returning `{ valid, reason }`. + * + * @param {object} feature - displayed GeoJSON feature (placed vertices + cursor) + * @param {object} context - { mode, placedCount, kind }; kind defaults to 'preview' + * @param {object} [config] + * @param {Array} [config.rules] - defaults to LIVE_RULES + * @param {Function} [config.onGeometryChange] - user callback, same signature as a rule + * @returns {{ valid: boolean, reason?: string }} + */ +export const validateDisplayedGeometry = (feature, context = {}, config = {}) => { + const { rules = LIVE_RULES, onGeometryChange } = config + const type = feature?.geometry?.type ?? feature?.type + const min = MIN_VERTICES_BY_TYPE[type] ?? 0 + if ((context.placedCount ?? 0) < min) { return { valid: true } } + return validateGeometry(feature, { ...context, kind: context.kind ?? 'preview' }, { rules, onGeometryChange }) +} diff --git a/plugins/draw/src/validation/validateGeometry.test.js b/plugins/draw/src/validation/validateGeometry.test.js new file mode 100644 index 000000000..0a692e546 --- /dev/null +++ b/plugins/draw/src/validation/validateGeometry.test.js @@ -0,0 +1,97 @@ +import { validateGeometry, validatePlacement } from './validateGeometry.js' + +const poly = (coordinates) => ({ type: 'Feature', geometry: { type: 'Polygon', coordinates: [coordinates] } }) + +const square = poly([[0, 0], [0, 1], [1, 1], [1, 0], [0, 0]]) +const bowtie = poly([[0, 0], [1, 1], [1, 0], [0, 1], [0, 0]]) +const collinear = poly([[0, 0], [1, 0], [2, 0], [0, 0]]) +const twoPoints = poly([[0, 0], [1, 0]]) + +describe('validateGeometry (soft gating)', () => { + test('a valid polygon passes', () => { + expect(validateGeometry(square)).toEqual({ valid: true }) + }) + + test('a self-intersecting polygon fails', () => { + const result = validateGeometry(bowtie) + expect(result.valid).toBe(false) + expect(result.reason).toMatch(/intersect/i) + }) + + test('a zero-area polygon fails', () => { + expect(validateGeometry(collinear).reason).toMatch(/area/i) + }) + + test('too few vertices fails', () => { + expect(validateGeometry(twoPoints).reason).toMatch(/points/i) + }) + + test('short-circuits on the first failing rule', () => { + const first = jest.fn(() => ({ valid: false, reason: 'first' })) + const second = jest.fn(() => ({ valid: true })) + expect(validateGeometry(square, {}, { rules: [first, second] })).toEqual({ valid: false, reason: 'first' }) + expect(second).not.toHaveBeenCalled() + }) + + test('passes the context to rules and the callback', () => { + const rule = jest.fn(() => ({ valid: true })) + const onGeometryChange = jest.fn(() => ({ valid: true })) + const context = { kind: 'move', vertexIndex: 2, mode: 'edit_vertex' } + validateGeometry(square, context, { rules: [rule], onGeometryChange }) + expect(rule).toHaveBeenCalledWith(square, context) + expect(onGeometryChange).toHaveBeenCalledWith(square, context) + }) + + test('runs the user callback after the rules pass', () => { + expect(validateGeometry(square, {}, { rules: [], onGeometryChange: () => false })) + .toEqual({ valid: false, reason: null }) + expect(validateGeometry(square, {}, { rules: [], onGeometryChange: () => true })) + .toEqual({ valid: true }) + expect(validateGeometry(square, {}, { rules: [], onGeometryChange: () => ({ valid: false, reason: 'too big' }) })) + .toEqual({ valid: false, reason: 'too big' }) + }) + + test('does not run the callback when a rule fails', () => { + const onGeometryChange = jest.fn() + validateGeometry(square, {}, { rules: [() => ({ valid: false })], onGeometryChange }) + expect(onGeometryChange).not.toHaveBeenCalled() + }) + + test('is valid with no rules and no callback', () => { + expect(validateGeometry(square, {}, { rules: [] })).toEqual({ valid: true }) + }) +}) + +describe('validatePlacement (hard gating)', () => { + // The candidate is the open drawn path plus the point about to be placed. + const crossingPath = poly([[0, 0], [1, 1], [1, 0], [0, 1]]) + const simplePath = poly([[0, 0], [1, 0], [1, 1], [0, 1]]) + + test('rejects a candidate whose open path self-crosses, with a reason', () => { + const result = validatePlacement(crossingPath) + expect(result.valid).toBe(false) + expect(result.reason).toMatch(/intersect/i) + }) + + test('passes a simple candidate', () => { + expect(validatePlacement(simplePath)).toEqual({ valid: true }) + }) + + test('forces kind "place" into the rule and callback context', () => { + const onGeometryChange = jest.fn(() => true) + validatePlacement(simplePath, { mode: 'draw_polygon', vertexIndex: 4 }, { onGeometryChange }) + expect(onGeometryChange).toHaveBeenCalledWith(simplePath, { kind: 'place', mode: 'draw_polygon', vertexIndex: 4 }) + }) + + test('the user callback can veto a placement with a reason', () => { + const onGeometryChange = () => ({ valid: false, reason: 'outside region' }) + expect(validatePlacement(simplePath, {}, { onGeometryChange })) + .toEqual({ valid: false, reason: 'outside region' }) + }) + + test('hard rules run before the user callback', () => { + const onGeometryChange = jest.fn() + validatePlacement(crossingPath, {}, { onGeometryChange }) + expect(onGeometryChange).not.toHaveBeenCalled() + }) +}) From a8b628f7e0575f521de319dba3f4a132571d3b99 Mon Sep 17 00:00:00 2001 From: Dan Leech Date: Tue, 7 Jul 2026 14:11:26 +0100 Subject: [PATCH 51/89] Realtime invalid stroke style --- plugins/draw/src/adapterEvents.js | 11 +- plugins/draw/src/adapterEvents.test.js | 4 +- .../adapters/maplibre/MaplibreDrawAdapter.js | 105 +++++++++---- .../maplibre/MaplibreDrawAdapter.test.js | 89 ++++++++++- .../src/adapters/openlayers/core/styles.js | 9 +- .../adapters/openlayers/core/styles.test.js | 7 +- .../src/adapters/openlayers/draw/DrawMode.js | 21 ++- .../adapters/openlayers/draw/DrawMode.test.js | 23 +++ .../src/adapters/openlayers/edit/EditMode.js | 47 +++++- .../adapters/openlayers/edit/EditMode.test.js | 32 ++++ plugins/draw/src/events.js | 16 ++ plugins/draw/src/events.test.js | 21 ++- plugins/draw/src/manifest.js | 3 + plugins/draw/src/manifest.test.js | 6 + plugins/draw/src/reducer.js | 10 +- plugins/draw/src/reducer.test.js | 15 +- plugins/draw/src/validation/liveStroke.js | 41 +++-- .../draw/src/validation/liveStroke.test.js | 148 ++++++++++++++++++ 18 files changed, 538 insertions(+), 70 deletions(-) create mode 100644 plugins/draw/src/validation/liveStroke.test.js diff --git a/plugins/draw/src/adapterEvents.js b/plugins/draw/src/adapterEvents.js index cadbefc78..08560eb39 100644 --- a/plugins/draw/src/adapterEvents.js +++ b/plugins/draw/src/adapterEvents.js @@ -30,6 +30,13 @@ * a vertex placement was rejected by validatePlacement * (hard rule or user callback); feature is the candidate * geometry that was refused. + * VALIDITY_CHANGE { valid, reason } — the live validity of the displayed + * shape flipped while editing (drag/nudge in progress). + * Drives the Done gate in edit mode, where the displayed + * shape is exactly what Done finishes. Fires on flips only. + * CAN_PLACE_CHANGE { canPlace, reason } — whether placing a vertex at the + * crosshair would be vetoed flipped while drawing. Drives + * the Add-point button. Fires on flips only. */ export const ADAPTER_EVENTS = { CREATE: 'create', @@ -41,5 +48,7 @@ export const ADAPTER_EVENTS = { UPDATE: 'update', GEOMETRY_CHANGE: 'geometrychange', INTERFACE_TYPE_CHANGE: 'interfacetypechange', - PLACEMENT_BLOCKED: 'placementblocked' + PLACEMENT_BLOCKED: 'placementblocked', + VALIDITY_CHANGE: 'validitychange', + CAN_PLACE_CHANGE: 'canplacechange' } diff --git a/plugins/draw/src/adapterEvents.test.js b/plugins/draw/src/adapterEvents.test.js index 0fa1ac679..4246e33ec 100644 --- a/plugins/draw/src/adapterEvents.test.js +++ b/plugins/draw/src/adapterEvents.test.js @@ -12,7 +12,9 @@ describe('adapter event contract', () => { UPDATE: 'update', GEOMETRY_CHANGE: 'geometrychange', INTERFACE_TYPE_CHANGE: 'interfacetypechange', - PLACEMENT_BLOCKED: 'placementblocked' + PLACEMENT_BLOCKED: 'placementblocked', + VALIDITY_CHANGE: 'validitychange', + CAN_PLACE_CHANGE: 'canplacechange' }) }) }) diff --git a/plugins/draw/src/adapters/maplibre/MaplibreDrawAdapter.js b/plugins/draw/src/adapters/maplibre/MaplibreDrawAdapter.js index 6bae1f6b2..dcfdfe634 100644 --- a/plugins/draw/src/adapters/maplibre/MaplibreDrawAdapter.js +++ b/plugins/draw/src/adapters/maplibre/MaplibreDrawAdapter.js @@ -4,6 +4,31 @@ import { createEventBus } from '../../utils/eventBus.js' import { MAPBOX_DRAW_EVENTS, CUSTOM_DRAW_EVENTS, STYLE_DATA_EVENT } from './drawEvents.js' import { ADAPTER_EVENTS } from '../../adapterEvents.js' import { createLiveStroke } from '../../validation/liveStroke.js' +import { validatePlacement } from '../../validation/validateGeometry.js' + +const polygonFeature = (coordinates) => ({ type: 'Feature', geometry: { type: 'Polygon', coordinates } }) +const lineFeature = (coordinates) => ({ type: 'Feature', geometry: { type: 'LineString', coordinates } }) + +// The displayed feature + placed-vertex count for the live stroke check. MapLibre's +// fire() copies the payload onto an Event whose `type` is the event name, so the +// geometry type is clobbered — it comes from the draw mode, or (in edit, where the +// mode covers both shapes) from the coordinate nesting: a polygon's coordinates are +// rings (one level deeper than a line's). Draw-mode coordinates carry a trailing +// rubber-band point; edit-mode coordinates are all committed vertices. +const displayedShape = (mode, coordinates) => { + if (mode === 'draw_polygon') { + return { feature: polygonFeature(coordinates), placedCount: (coordinates[0]?.length ?? 1) - 1 } + } + if (mode === 'draw_line') { + return { feature: lineFeature(coordinates), placedCount: (coordinates?.length ?? 1) - 1 } + } + if (mode === 'edit_vertex') { + return Array.isArray(coordinates[0]?.[0]) + ? { feature: polygonFeature(coordinates), placedCount: coordinates[0]?.length ?? 0 } + : { feature: lineFeature(coordinates), placedCount: coordinates?.length ?? 0 } + } + return null +} /** * Draw adapter for MapLibre GL. @@ -39,9 +64,28 @@ export class MaplibreDrawAdapter { this._draw = draw this._cleanupDraw = remove - // Drives the live invalid stroke from rubber-band moves (default rules sync, - // user callback throttled). onChange toggles the dashed stroke layers. - this._liveStroke = createLiveStroke({ onChange: (invalid) => this.setInvalid(invalid) }) + // Single owner of the dashed-stroke state: live rubber-band / drag moves feed + // update() (default rules sync, user callback throttled) and committed verdicts + // (events.js) land via setInvalid → set(), so the cached state always mirrors + // the rendered layers. onChange does the actual layer toggle; in edit mode the + // displayed shape is exactly what Done finishes, so validity flips also gate + // the Done button (events.js dispatches them). + this._liveStroke = createLiveStroke({ + onChange: (invalid, reason) => { + this._applyStrokeInvalid(invalid) + if (this._draw.getMode() === 'edit_vertex') { + this._bus.emit(ADAPTER_EVENTS.VALIDITY_CHANGE, { valid: !invalid, reason }) + } + } + }) + + // Live Add-point gate: would placing a vertex at the crosshair be vetoed? + // Same throttling as the stroke, but evaluated with the placement (hard) rules + // so the button tracks exactly what a tap would do. + this._livePlacement = createLiveStroke({ + validate: validatePlacement, + onChange: (vetoed, reason) => this._bus.emit(ADAPTER_EVENTS.CAN_PLACE_CHANGE, { canPlace: !vetoed, reason }) + }) // Normalise ML map events → the shared adapter event contract (adapterEvents.js). // The OL adapter emits the same contract directly from OLDrawManager. @@ -82,39 +126,34 @@ export class MaplibreDrawAdapter { if (name === 'edit_vertex') { this._editingFeatureId = options.featureId ?? null } - // A fresh draw always starts with a solid stroke; the live check owns it from here. + // A fresh draw always starts with a solid stroke and a placeable crosshair; + // the live checks own both from here. if (name === 'draw_polygon' || name === 'draw_line') { - this._liveStroke.reset() - this.setInvalid(false) + this._liveStroke.set(false) + this._livePlacement.set(false) } this._draw.changeMode(name, options) } - // Live invalid-stroke driver for draw mode: called on every rubber-band move with - // the displayed feature (placed vertices + cursor). Delegates to the shared + // Live invalid-stroke driver: called on every rubber-band move (draw) and vertex + // drag / nudge (edit) with the displayed feature. Delegates to the shared // live-stroke controller, which runs the default rules synchronously and the user - // callback throttled, toggling the dashed stroke only when validity flips. Edit - // mode is driven from events.js on committed changes instead. - // - // MapLibre's fire() copies the payload onto an Event whose `type` is the event - // name, so the feature's geometry type is clobbered — rebuild the geometry from - // the coordinates using the active draw mode. + // callback throttled, toggling the dashed stroke only when validity flips. While + // drawing, the same displayed geometry (placed vertices + crosshair candidate) + // also feeds the Add-point placement gate. _updateLiveStroke (e) { if (!e?.coordinates) { return } const mode = this._draw.getMode() - // eslint-disable-next-line no-console -- TEMP live-stroke diagnostics - console.log('[live-stroke ML] _updateLiveStroke', { mode, hasCoords: !!e?.coordinates }) - let feature, placedCount - if (mode === 'draw_polygon') { - feature = { type: 'Feature', geometry: { type: 'Polygon', coordinates: e.coordinates } } - placedCount = (e.coordinates[0]?.length ?? 1) - 1 - } else if (mode === 'draw_line') { - feature = { type: 'Feature', geometry: { type: 'LineString', coordinates: e.coordinates } } - placedCount = (e.coordinates?.length ?? 1) - 1 - } else { - return + const shape = displayedShape(mode, e.coordinates) + if (!shape) { return } + this._liveStroke.update({ ...shape, context: { mode }, onGeometryChange: this._geometryValidator }) + if (mode === 'draw_polygon' || mode === 'draw_line') { + this._livePlacement.update({ + feature: shape.feature, + context: { mode, vertexIndex: shape.placedCount }, + onGeometryChange: this._geometryValidator + }) } - this._liveStroke.update({ feature, context: { mode }, placedCount, onGeometryChange: this._geometryValidator }) } getMode () { return this._draw.getMode() } @@ -156,11 +195,20 @@ export class MaplibreDrawAdapter { set _geometryValidator (fn) { this._map._drawGeometryValidator = fn } get _geometryValidator () { return this._map._drawGeometryValidator } - // Toggle the active shape's stroke between solid (valid) and dashed (invalid) by - // swapping which of the two overlaid stroke layers is visible. + // Committed-verdict write (events.js, edit mode): routed through the live-stroke + // controller so its cached state stays in sync with the rendered layers. setInvalid (invalid) { + this._liveStroke.set(invalid) + } + + // Toggle the active shape's stroke between solid (valid) and dashed (invalid) by + // swapping which of the two overlaid stroke layers is visible; the fill is hidden + // while invalid so the shape reads as an outline only. Only the live-stroke + // controller calls this — everything else goes through setInvalid. + _applyStrokeInvalid (invalid) { this._setLayerVisibility('stroke-active', !invalid) this._setLayerVisibility('stroke-active-invalid', invalid) + this._setLayerVisibility('fill-active', !invalid) } _setLayerVisibility (id, visible) { @@ -247,6 +295,7 @@ export class MaplibreDrawAdapter { this._map.off(MAPBOX_DRAW_EVENTS.MODE_CHANGE, this._mapHandlers.modechange) this._map.off(STYLE_DATA_EVENT, this._mapHandlers.styledata) this._liveStroke.destroy() + this._livePlacement.destroy() this._cleanupDraw() } } diff --git a/plugins/draw/src/adapters/maplibre/MaplibreDrawAdapter.test.js b/plugins/draw/src/adapters/maplibre/MaplibreDrawAdapter.test.js index 537a47c62..c309b3aee 100644 --- a/plugins/draw/src/adapters/maplibre/MaplibreDrawAdapter.test.js +++ b/plugins/draw/src/adapters/maplibre/MaplibreDrawAdapter.test.js @@ -192,6 +192,24 @@ describe('live invalid stroke (draw mode)', () => { expect(map.setLayoutProperty).not.toHaveBeenCalledWith('stroke-active-invalid.hot', 'visibility', 'visible') }) + test('a placement-vetoing path disables Add point; a legal one re-enables it', () => { + const { map, bus } = drawPolygonSetup() + const fire = onHandler(map, CUSTOM_DRAW_EVENTS.GEOMETRY_CHANGE) + // Open drawn path crosses itself → placing at the crosshair would be vetoed. + fire({ type: 'draw.geometrychange', coordinates: [[[0, 0], [2, 2], [2, 0], [0, 2]]] }) + expect(bus.emit).toHaveBeenCalledWith('canplacechange', expect.objectContaining({ canPlace: false, reason: expect.any(String) })) + fire({ type: 'draw.geometrychange', coordinates: [[[0, 0], [2, 0], [2, 2], [0, 2]]] }) + expect(bus.emit).toHaveBeenCalledWith('canplacechange', expect.objectContaining({ canPlace: true })) + }) + + test('a red stroke via the closing edge alone keeps Add point enabled', () => { + const { map, bus } = drawPolygonSetup() + // Only the implicit closing edge crosses: stroke dashed, but the placement is legal. + onHandler(map, CUSTOM_DRAW_EVENTS.GEOMETRY_CHANGE)({ type: 'draw.geometrychange', coordinates: [[[0, 0], [2, 0], [0, 2], [2, 2]]] }) + expect(map.setLayoutProperty).toHaveBeenCalledWith('stroke-active-invalid.hot', 'visibility', 'visible') + expect(bus.emit).not.toHaveBeenCalledWith('canplacechange', expect.objectContaining({ canPlace: false })) + }) + test('entering a draw mode resets the stroke to solid', () => { const { adapter, map } = drawPolygonSetup() onHandler(map, CUSTOM_DRAW_EVENTS.GEOMETRY_CHANGE)(bowtie) // dashed @@ -206,6 +224,57 @@ describe('live invalid stroke (draw mode)', () => { }) }) +describe('live invalid stroke (edit mode)', () => { + const editSetup = () => { + const fixture = setup() + fixture.draw.getMode.mockReturnValue('edit_vertex') + fixture.map.getLayer.mockReturnValue({}) + return fixture + } + + test('dragging a polygon vertex into a crossing turns the stroke dashed, and back', () => { + const { map } = editSetup() + const fire = onHandler(map, CUSTOM_DRAW_EVENTS.GEOMETRY_CHANGE) + // Edit payloads have the type clobbered too — polygon detected from ring nesting. + fire({ type: 'draw.geometrychange', coordinates: [[[0, 0], [10, 10], [10, 0], [0, 10]]] }) + expect(map.setLayoutProperty).toHaveBeenCalledWith('stroke-active-invalid.hot', 'visibility', 'visible') + map.setLayoutProperty.mockClear() + fire({ type: 'draw.geometrychange', coordinates: [[[0, 0], [10, 0], [10, 10], [0, 10]]] }) + expect(map.setLayoutProperty).toHaveBeenCalledWith('stroke-active.hot', 'visibility', 'visible') + }) + + test('validity flips while editing also gate the Done button', () => { + const { map, bus } = editSetup() + const fire = onHandler(map, CUSTOM_DRAW_EVENTS.GEOMETRY_CHANGE) + fire({ type: 'draw.geometrychange', coordinates: [[[0, 0], [10, 10], [10, 0], [0, 10]]] }) + expect(bus.emit).toHaveBeenCalledWith('validitychange', expect.objectContaining({ valid: false, reason: expect.any(String) })) + fire({ type: 'draw.geometrychange', coordinates: [[[0, 0], [10, 0], [10, 10], [0, 10]]] }) + expect(bus.emit).toHaveBeenCalledWith('validitychange', expect.objectContaining({ valid: true })) + // Add point is a draw-mode concern — never driven from edit mode. + expect(bus.emit).not.toHaveBeenCalledWith('canplacechange', expect.anything()) + }) + + test('lines never go dashed from the default rules while editing', () => { + const { map } = editSetup() + onHandler(map, CUSTOM_DRAW_EVENTS.GEOMETRY_CHANGE)({ type: 'draw.geometrychange', coordinates: [[0, 0], [10, 10], [10, 0], [0, 10]] }) + expect(map.setLayoutProperty).not.toHaveBeenCalled() + }) + + test('the user callback runs throttled during an edit drag', () => { + jest.useFakeTimers() + const { map } = editSetup() + map._drawGeometryValidator = jest.fn(() => ({ valid: false, reason: 'outside region' })) + const fire = onHandler(map, CUSTOM_DRAW_EVENTS.GEOMETRY_CHANGE) + fire({ type: 'draw.geometrychange', coordinates: [[[0, 0], [10, 0], [10, 10], [0, 10]]] }) + fire({ type: 'draw.geometrychange', coordinates: [[[0, 0], [10, 0], [10, 10], [0, 11]]] }) + expect(map._drawGeometryValidator).not.toHaveBeenCalled() // deferred to the frame + jest.runAllTimers() + expect(map._drawGeometryValidator).toHaveBeenCalledTimes(1) // trailing edge only + expect(map.setLayoutProperty).toHaveBeenCalledWith('stroke-active-invalid.hot', 'visibility', 'visible') + jest.useRealTimers() + }) +}) + describe('_geometryValidator accessor', () => { test('stores the validator on the map for modes to read, and reads it back', () => { const { adapter, map } = setup() @@ -250,7 +319,7 @@ describe('setGeometryValid', () => { }) describe('setInvalid', () => { - test('shows the dashed stroke and hides the solid stroke when invalid', () => { + test('shows the dashed stroke and hides the solid stroke and fill when invalid', () => { const { adapter, map } = setup() map.getLayer.mockReturnValue({}) // every layer exists adapter.setInvalid(true) @@ -258,14 +327,30 @@ describe('setInvalid', () => { expect(map.setLayoutProperty).toHaveBeenCalledWith('stroke-active.cold', 'visibility', 'none') expect(map.setLayoutProperty).toHaveBeenCalledWith('stroke-active-invalid.hot', 'visibility', 'visible') expect(map.setLayoutProperty).toHaveBeenCalledWith('stroke-active-invalid.cold', 'visibility', 'visible') + expect(map.setLayoutProperty).toHaveBeenCalledWith('fill-active.hot', 'visibility', 'none') + expect(map.setLayoutProperty).toHaveBeenCalledWith('fill-active.cold', 'visibility', 'none') }) - test('restores the solid stroke when valid again', () => { + test('restores the solid stroke and fill when valid again', () => { const { adapter, map } = setup() map.getLayer.mockReturnValue({}) + adapter.setInvalid(true) + map.setLayoutProperty.mockClear() adapter.setInvalid(false) expect(map.setLayoutProperty).toHaveBeenCalledWith('stroke-active.hot', 'visibility', 'visible') expect(map.setLayoutProperty).toHaveBeenCalledWith('stroke-active-invalid.hot', 'visibility', 'none') + expect(map.setLayoutProperty).toHaveBeenCalledWith('fill-active.hot', 'visibility', 'visible') + }) + + test('writes are flip-guarded: repeating the same state does not touch the layers', () => { + const { adapter, map } = setup() + map.getLayer.mockReturnValue({}) + adapter.setInvalid(false) // already solid — no-op + expect(map.setLayoutProperty).not.toHaveBeenCalled() + adapter.setInvalid(true) + map.setLayoutProperty.mockClear() + adapter.setInvalid(true) // already dashed — no-op + expect(map.setLayoutProperty).not.toHaveBeenCalled() }) test('skips layers that are not present on the map', () => { diff --git a/plugins/draw/src/adapters/openlayers/core/styles.js b/plugins/draw/src/adapters/openlayers/core/styles.js index 4e5e2b08b..c0c3fd480 100644 --- a/plugins/draw/src/adapters/openlayers/core/styles.js +++ b/plugins/draw/src/adapters/openlayers/core/styles.js @@ -65,10 +65,10 @@ export const createStyles = (colors) => { fill: new Fill({ color: colors.editFill }) }) - // Dashed variant shown while the edited/drawn shape is invalid. + // Dashed variant shown while the edited/drawn shape is invalid — no fill, so an + // invalid shape reads as an outline only. const editFeatureStyleInvalid = new Style({ - stroke: new Stroke({ color: colors.invalidStroke, width: 2, lineDash: [2, 4] }), - fill: new Fill({ color: colors.editFill }) + stroke: new Stroke({ color: colors.invalidStroke, width: 2, lineDash: [2, 4] }) }) const sketchLineStyle = new Style({ @@ -77,8 +77,7 @@ export const createStyles = (colors) => { }) const sketchLineStyleInvalid = new Style({ - stroke: new Stroke({ color: colors.invalidStroke, width: 2, lineDash: [2, 4] }), - fill: new Fill({ color: colors.editFill }) + stroke: new Stroke({ color: colors.invalidStroke, width: 2, lineDash: [2, 4] }) }) // Reused across renders — the geometry function runs every frame while sketching, diff --git a/plugins/draw/src/adapters/openlayers/core/styles.test.js b/plugins/draw/src/adapters/openlayers/core/styles.test.js index 00ec07eec..e6825d569 100644 --- a/plugins/draw/src/adapters/openlayers/core/styles.test.js +++ b/plugins/draw/src/adapters/openlayers/core/styles.test.js @@ -64,10 +64,10 @@ describe('handle styles', () => { expect(styles.editFeatureStyle.getFill().getColor()).toBe(colors.editFill) }) - test('the invalid edited feature gets a dashed stroke in the invalid colour', () => { + test('the invalid edited feature gets a dashed stroke and no fill', () => { expect(styles.editFeatureStyleInvalid.getStroke().getColor()).toBe(colors.invalidStroke) expect(styles.editFeatureStyleInvalid.getStroke().getLineDash()).toEqual([2, 4]) - expect(styles.editFeatureStyleInvalid.getFill().getColor()).toBe(colors.editFill) + expect(styles.editFeatureStyleInvalid.getFill()).toBeNull() }) }) @@ -79,11 +79,12 @@ describe('sketch styles while drawing', () => { expect(polygonStyleFn(new Feature(new LineString([[0, 0], [1, 1]])))).toHaveLength(1) }) - test('the invalid sketch renders a dashed line in the invalid colour', () => { + test('the invalid sketch renders a dashed line in the invalid colour with no fill', () => { const invalidStyleFn = styles.createSketchStyle('Polygon', true) const [lineStyle] = invalidStyleFn(new Feature(new LineString([[0, 0], [1, 1]]))) expect(lineStyle.getStroke().getColor()).toBe(colors.invalidStroke) expect(lineStyle.getStroke().getLineDash()).toEqual([2, 4]) + expect(lineStyle.getFill()).toBeNull() }) test('placed vertices render with the shared vertex image on a reused MultiPoint', () => { diff --git a/plugins/draw/src/adapters/openlayers/draw/DrawMode.js b/plugins/draw/src/adapters/openlayers/draw/DrawMode.js index 1c18ac78e..8e604c87d 100644 --- a/plugins/draw/src/adapters/openlayers/draw/DrawMode.js +++ b/plugins/draw/src/adapters/openlayers/draw/DrawMode.js @@ -135,7 +135,7 @@ const createVertexTracker = (manager, getSketch) => { } // The mode interface consumed by OLDrawManager. -const buildDrawModeApi = ({ map, manager, drawInteraction, input, geometryType, getSketch, updateVertexCount, emitUndoValidation, onStylesChanged, clearSketch, setInvalid, liveStroke }) => ({ +const buildDrawModeApi = ({ map, manager, drawInteraction, input, geometryType, getSketch, updateVertexCount, emitUndoValidation, onStylesChanged, clearSketch, setInvalid, liveStroke, livePlacement }) => ({ done () { if (canFinish(geometryType, getSketch())) { drawInteraction.finishDrawing() } }, @@ -144,6 +144,7 @@ const buildDrawModeApi = ({ map, manager, drawInteraction, input, geometryType, setInvalid, destroy () { liveStroke.destroy() + livePlacement.destroy() manager.off(STYLES_CHANGED_EVENT, onStylesChanged) // Emit the final interfaceType so crosshair visibility is correct on exit. manager.emit(ADAPTER_EVENTS.INTERFACE_TYPE_CHANGE, { interfaceType: input.getInterfaceType() }) @@ -197,15 +198,21 @@ export const createDrawMode = ({ map, manager, options }) => { drawInteraction.overlay_.changed() } // Drives the live invalid stroke from every sketch change (mouse / touch / keyboard - // rubber-banding): default rules synchronously, user callback throttled. + // rubber-banding): default rules synchronously, user callback throttled. The same + // displayed geometry (placed vertices + crosshair candidate) feeds the Add-point + // placement gate, evaluated with the placement (hard) rules so the button tracks + // exactly what a tap would do. const liveStroke = createLiveStroke({ onChange: setSketchInvalid }) + const livePlacement = createLiveStroke({ + validate: validatePlacement, + onChange: (vetoed, reason) => manager.emit(ADAPTER_EVENTS.CAN_PLACE_CHANGE, { canPlace: !vetoed, reason }) + }) const liveMode = geometryType === 'Polygon' ? 'draw_polygon' : 'draw_line' const updateLiveValidity = () => { if (!sketchFeature) { return } const { feature, placedCount } = displayedSketch(geometryType, sketchFeature) - // eslint-disable-next-line no-console -- TEMP live-stroke diagnostics - console.log('[live-stroke OL] updateLiveValidity', { placedCount }) liveStroke.update({ feature, context: { mode: liveMode }, placedCount, onGeometryChange: manager._geometryValidator }) + livePlacement.update({ feature, context: { mode: liveMode, vertexIndex: placedCount }, onGeometryChange: manager._geometryValidator }) } // Update sketch style when map style changes @@ -250,7 +257,9 @@ export const createDrawMode = ({ map, manager, options }) => { emitUndoValidation, onStylesChanged, clearSketch: () => { sketchFeature = null }, - setInvalid: setSketchInvalid, - liveStroke + // External writes go through the controller so its cache mirrors the style. + setInvalid: (next) => liveStroke.set(next), + liveStroke, + livePlacement }) } diff --git a/plugins/draw/src/adapters/openlayers/draw/DrawMode.test.js b/plugins/draw/src/adapters/openlayers/draw/DrawMode.test.js index 3b77219bf..88d1bbcd7 100644 --- a/plugins/draw/src/adapters/openlayers/draw/DrawMode.test.js +++ b/plugins/draw/src/adapters/openlayers/draw/DrawMode.test.js @@ -186,6 +186,29 @@ describe('drawing lifecycle', () => { expect(typeof inputOptions.canPlace).toBe('function') }) + test('a placement-vetoing path disables Add point; a legal one re-enables it', () => { + const { emitted, interaction } = setup('Polygon') + const sketch = polygonFeature([[0, 0], [5, 5], [0, 0]]) + interaction.dispatchEvent({ type: 'drawstart', feature: sketch }) + // Open drawn path crosses itself → placing at the crosshair would be vetoed. + sketch.getGeometry().setCoordinates([[[0, 0], [2, 2], [2, 0], [0, 2], [0, 0]]]) + expect(emitted().find((e) => e.type === ADAPTER_EVENTS.CAN_PLACE_CHANGE)?.payload) + .toEqual(expect.objectContaining({ canPlace: false, reason: expect.any(String) })) + sketch.getGeometry().setCoordinates([[[0, 0], [2, 0], [2, 2], [0, 2], [0, 0]]]) + expect(emitted().filter((e) => e.type === ADAPTER_EVENTS.CAN_PLACE_CHANGE).pop()?.payload) + .toEqual(expect.objectContaining({ canPlace: true })) + }) + + test('a red stroke via the closing edge alone keeps Add point enabled', () => { + const { manager, emitted, interaction } = setup('Polygon') + const sketch = polygonFeature([[0, 0], [5, 5], [0, 0]]) + interaction.dispatchEvent({ type: 'drawstart', feature: sketch }) + // Only the implicit closing edge crosses: stroke dashed, but the placement is legal. + sketch.getGeometry().setCoordinates([[[0, 0], [2, 0], [0, 2], [2, 2], [0, 0]]]) + expect(manager.styles.createSketchStyle).toHaveBeenLastCalledWith('Polygon', true) + expect(emitted().find((e) => e.type === ADAPTER_EVENTS.CAN_PLACE_CHANGE)).toBeUndefined() + }) + test('setInvalid rebuilds the sketch style in the invalid variant and re-renders', () => { const { mode, manager, interaction } = setup('Polygon') const changed = jest.spyOn(interaction.overlay_, 'changed') diff --git a/plugins/draw/src/adapters/openlayers/edit/EditMode.js b/plugins/draw/src/adapters/openlayers/edit/EditMode.js index 9d04eef19..18b360f5c 100644 --- a/plugins/draw/src/adapters/openlayers/edit/EditMode.js +++ b/plugins/draw/src/adapters/openlayers/edit/EditMode.js @@ -10,6 +10,7 @@ import { deleteVertex, insertAtMidpoint } from './vertexOps.js' import { applyUndo } from './undoOps.js' import { ADAPTER_EVENTS } from '../../../adapterEvents.js' import { STYLES_CHANGED_EVENT } from '../core/internalEvents.js' +import { createLiveStroke } from '../../../validation/liveStroke.js' const TOUCH_INTERFACE = 'touch' const VERTEX_TYPE = 'vertex' @@ -29,6 +30,43 @@ const UNDO_INVERSE_KIND = { delete_vertex: 'insert' } +// Live invalid-stroke wiring: re-validate the displayed geometry on every geometry +// change (Modify drag, touch drag, keyboard nudge) via the shared controller — +// default rules synchronously, user callback throttled. All committed vertices are +// "placed", so the polygon count drops only OL's closing duplicate. +const wireLiveStroke = ({ manager, olFeature }) => { + const liveStroke = createLiveStroke({ + onChange: (invalid, reason) => { + olFeature.setStyle(invalid ? manager.styles.editFeatureStyleInvalid : manager.styles.editFeatureStyle) + // In edit mode the displayed shape is exactly what Done finishes, so live + // validity flips also gate the Done button (events.js dispatches them). + manager.emit(ADAPTER_EVENTS.VALIDITY_CHANGE, { valid: !invalid, reason }) + } + }) + const updateLiveValidity = () => { + const geom = olFeature.getGeometry() + const type = geom.getType() + const coordinates = geom.getCoordinates() + const placedCount = type === 'Polygon' + ? Math.max(0, (coordinates[0]?.length ?? 1) - 1) + : coordinates.length + liveStroke.update({ + feature: { type: 'Feature', geometry: { type, coordinates } }, + context: { mode: 'edit_vertex' }, + placedCount, + onGeometryChange: manager._geometryValidator + }) + } + olFeature.getGeometry().on('change', updateLiveValidity) + return { + liveStroke, + destroy () { + olFeature.getGeometry().un('change', updateLiveValidity) + liveStroke.destroy() + } + } +} + /** * Edit vertex mode — handles edit_vertex. * @@ -221,9 +259,10 @@ const buildModeApi = ({ manager, store, olFeature, originalFeatureStyle, selecti manager.emit(ADAPTER_EVENTS.EDIT_FINISH, store.toGeoJSON(olFeature)) }, - // Swap the edited feature's stroke between solid (valid) and dashed (invalid). + // Committed-verdict write (events.js): routed through the live-stroke + // controller so its cached state stays in sync with the rendered style. setInvalid (invalid) { - olFeature.setStyle(invalid ? manager.styles.editFeatureStyleInvalid : manager.styles.editFeatureStyle) + parts.live.liveStroke.set(invalid) }, // Nothing to restore here: the pre-edit feature is kept as tempFeature in the @@ -234,6 +273,7 @@ const buildModeApi = ({ manager, store, olFeature, originalFeatureStyle, selecti deleteVertex: actions.doDeleteVertex, destroy () { + parts.live.destroy() olFeature.setStyle(originalFeatureStyle) selection.destroy() parts.mapSync.destroy() @@ -296,6 +336,7 @@ export const createEditMode = ({ map, manager, options }) => { syncGeom() // initial populate const layers = { vertexLayer, midpointLayer, activeLayer } + const live = wireLiveStroke({ manager, olFeature }) const touchHandler = wireTouchHandler({ map, container, manager, snap, olFeature, undoStack, selection }) const actions = createVertexActions({ olFeature, undoStack, selection, getTouchHandler: () => touchHandler }) const keyboardHandler = wireKeyboardHandler({ map, container, snap, undoStack, selection, touchHandler, actions }) @@ -317,6 +358,6 @@ export const createEditMode = ({ map, manager, options }) => { originalFeatureStyle, selection, actions, - parts: { touchHandler, keyboardHandler, pointerHandlers, modify, mapSync, layers } + parts: { touchHandler, keyboardHandler, pointerHandlers, modify, mapSync, layers, live } }) } diff --git a/plugins/draw/src/adapters/openlayers/edit/EditMode.test.js b/plugins/draw/src/adapters/openlayers/edit/EditMode.test.js index 6706435af..a0def94d6 100644 --- a/plugins/draw/src/adapters/openlayers/edit/EditMode.test.js +++ b/plugins/draw/src/adapters/openlayers/edit/EditMode.test.js @@ -57,6 +57,38 @@ test('setInvalid swaps between the solid and dashed edit styles', () => { expect(olFeature.getStyle()).toBe(manager.styles.editFeatureStyle) }) +test('a mid-drag crossing turns the stroke dashed live, and back when it clears', () => { + const { manager, olFeature } = setup() + // Geometry mutation (as during a Modify/touch drag) — no commit yet. + olFeature.getGeometry().setCoordinates([[[0, 0], [100, 100], [100, 0], [0, 100], [0, 0]]]) + expect(olFeature.getStyle()).toBe(manager.styles.editFeatureStyleInvalid) + olFeature.getGeometry().setCoordinates([[[0, 0], [100, 0], [100, 100], [0, 100], [0, 0]]]) + expect(olFeature.getStyle()).toBe(manager.styles.editFeatureStyle) +}) + +test('validity flips while editing also gate the Done button', () => { + const { manager, olFeature } = setup() + olFeature.getGeometry().setCoordinates([[[0, 0], [100, 100], [100, 0], [0, 100], [0, 0]]]) + expect(manager.emit).toHaveBeenCalledWith(ADAPTER_EVENTS.VALIDITY_CHANGE, + expect.objectContaining({ valid: false, reason: expect.any(String) })) + olFeature.getGeometry().setCoordinates([[[0, 0], [100, 0], [100, 100], [0, 100], [0, 0]]]) + expect(manager.emit).toHaveBeenCalledWith(ADAPTER_EVENTS.VALIDITY_CHANGE, + expect.objectContaining({ valid: true })) +}) + +test('the user callback runs throttled during an edit drag', () => { + jest.useFakeTimers() + const { manager, olFeature } = setup() + manager._geometryValidator = jest.fn(() => ({ valid: false, reason: 'outside region' })) + olFeature.getGeometry().setCoordinates([[[0, 0], [110, 0], [100, 100], [0, 100], [0, 0]]]) + olFeature.getGeometry().setCoordinates([[[0, 0], [120, 0], [100, 100], [0, 100], [0, 0]]]) + expect(manager._geometryValidator).not.toHaveBeenCalled() // deferred to the frame + jest.runAllTimers() + expect(manager._geometryValidator).toHaveBeenCalledTimes(1) // trailing edge only + expect(olFeature.getStyle()).toBe(manager.styles.editFeatureStyleInvalid) + jest.useRealTimers() +}) + test('done() emits the edited feature; cancel() is a no-op', () => { const { manager, mode } = setup() mode.done() diff --git a/plugins/draw/src/events.js b/plugins/draw/src/events.js index c298059b3..ae96846ed 100644 --- a/plugins/draw/src/events.js +++ b/plugins/draw/src/events.js @@ -110,6 +110,18 @@ function createHandlers ({ appState, appConfig, mapState, pluginState, mapProvid // on the public bus with kind 'place' so a future tooltip can show the reason. onPlacementBlocked: (e) => { eventBus.emit(GEOMETRY_INVALID_EVENT, e) + }, + // Live (mid-drag) validity flip while editing — the displayed shape is exactly + // what Done finishes there, so it gates the Done button in real time. Emitted + // by the adapters' edit wiring only, on flips only. + onValidityChange: (e) => { + pluginState.dispatch({ type: 'SET_GEOMETRY_VALID', payload: e.valid }) + draw.setGeometryValid?.(e.valid) + }, + // Live placement-veto flip while drawing — gates the Add point button so it + // never looks active when a tap would be rejected. + onCanPlaceChange: (e) => { + pluginState.dispatch({ type: 'SET_CAN_ADD_POINT', payload: e.canPlace }) } } } @@ -133,6 +145,8 @@ function attachDrawEvents (draw, handlers) { draw.on(ADAPTER_EVENTS.UPDATE, handlers.onUpdate) draw.on(ADAPTER_EVENTS.GEOMETRY_CHANGE, handlers.onGeometryChange) draw.on(ADAPTER_EVENTS.PLACEMENT_BLOCKED, handlers.onPlacementBlocked) + draw.on(ADAPTER_EVENTS.VALIDITY_CHANGE, handlers.onValidityChange) + draw.on(ADAPTER_EVENTS.CAN_PLACE_CHANGE, handlers.onCanPlaceChange) } function detachButtonHandlers (buttonConfig) { @@ -154,6 +168,8 @@ function detachDrawEvents (draw, handlers) { draw.off(ADAPTER_EVENTS.UPDATE, handlers.onUpdate) draw.off(ADAPTER_EVENTS.GEOMETRY_CHANGE, handlers.onGeometryChange) draw.off(ADAPTER_EVENTS.PLACEMENT_BLOCKED, handlers.onPlacementBlocked) + draw.off(ADAPTER_EVENTS.VALIDITY_CHANGE, handlers.onValidityChange) + draw.off(ADAPTER_EVENTS.CAN_PLACE_CHANGE, handlers.onCanPlaceChange) } export function attachEvents ({ appState, appConfig, mapState, pluginState, mapProvider, buttonConfig, eventBus }) { diff --git a/plugins/draw/src/events.test.js b/plugins/draw/src/events.test.js index b08f1ee0c..56dc96dd2 100644 --- a/plugins/draw/src/events.test.js +++ b/plugins/draw/src/events.test.js @@ -2,7 +2,7 @@ import { attachEvents } from './events.js' jest.useFakeTimers() -const DRAW_EVENTS = ['create', 'editfinish', 'cancel', 'vertexselection', 'vertexchange', 'undochange', 'update', 'geometrychange', 'placementblocked'] +const DRAW_EVENTS = ['create', 'editfinish', 'cancel', 'vertexselection', 'vertexchange', 'undochange', 'update', 'geometrychange', 'placementblocked', 'validitychange', 'canplacechange'] const setup = (overrides = {}) => { const draw = { @@ -262,6 +262,25 @@ describe('geometrychange validation', () => { expect(draw.setInvalid).toHaveBeenCalledWith(true) }) + test('a live validity flip (edit drag) drives the Done gate', () => { + const { draw, dispatch } = setup() + drawHandler(draw, 'validitychange')({ valid: false, reason: 'Shape must not intersect itself' }) + expect(dispatch).toHaveBeenCalledWith({ type: 'SET_GEOMETRY_VALID', payload: false }) + expect(draw.setGeometryValid).toHaveBeenCalledWith(false) + + drawHandler(draw, 'validitychange')({ valid: true, reason: null }) + expect(dispatch).toHaveBeenCalledWith({ type: 'SET_GEOMETRY_VALID', payload: true }) + }) + + test('a live placement-veto flip drives the Add point gate', () => { + const { draw, dispatch } = setup() + drawHandler(draw, 'canplacechange')({ canPlace: false, reason: 'outside region' }) + expect(dispatch).toHaveBeenCalledWith({ type: 'SET_CAN_ADD_POINT', payload: false }) + + drawHandler(draw, 'canplacechange')({ canPlace: true, reason: null }) + expect(dispatch).toHaveBeenCalledWith({ type: 'SET_CAN_ADD_POINT', payload: true }) + }) + test('relays a blocked placement to the public bus as draw:geometryinvalid', () => { const { draw, eventBus } = setup() const blocked = { kind: 'place', mode: 'draw_polygon', vertexIndex: 2, reason: 'outside region', feature: { type: 'Feature' } } diff --git a/plugins/draw/src/manifest.js b/plugins/draw/src/manifest.js index 32d40867c..efa46f614 100644 --- a/plugins/draw/src/manifest.js +++ b/plugins/draw/src/manifest.js @@ -42,6 +42,9 @@ export const manifest = { exclusiveSlot: true, hiddenWhen: ({ appState, pluginState }) => !['draw_polygon', 'draw_line'].includes(pluginState.mode) || appState.interfaceType !== 'touch', + // Disabled while placing at the crosshair would be vetoed (validatePlacement) — + // driven live so the button never looks active when a tap would do nothing. + enableWhen: ({ pluginState }) => pluginState.canAddPoint, ...createButtonSlots(true) }, { diff --git a/plugins/draw/src/manifest.test.js b/plugins/draw/src/manifest.test.js index 17673ab0f..6be9e1012 100644 --- a/plugins/draw/src/manifest.test.js +++ b/plugins/draw/src/manifest.test.js @@ -30,6 +30,12 @@ describe('drawAddPoint', () => { expect(hidden('mouse', 'draw_polygon')).toBe(true) expect(hidden('touch', 'edit_vertex')).toBe(true) }) + + test('is disabled while placing at the crosshair would be vetoed', () => { + const btn = findButton('drawAddPoint') + expect(btn.enableWhen({ pluginState: { canAddPoint: true } })).toBe(true) + expect(btn.enableWhen({ pluginState: { canAddPoint: false } })).toBe(false) + }) }) describe('drawDone', () => { diff --git a/plugins/draw/src/reducer.js b/plugins/draw/src/reducer.js index 4cbb58cdc..886ac4166 100644 --- a/plugins/draw/src/reducer.js +++ b/plugins/draw/src/reducer.js @@ -7,6 +7,7 @@ const initialState = { selectedVertexIndex: -1, numVertices: null, geometryValid: true, + canAddPoint: true, snap: false, hasSnapLayers: false, undoStackLength: 0 @@ -20,7 +21,9 @@ const setMode = (state, payload) => ({ numVertices: DRAW_MODES.has(payload) ? 0 : state.numVertices, // A new/empty shape is never valid; validation flips this true once the geometry // passes all soft rules (edit mode seeds it explicitly in api/editFeature). - geometryValid: false + geometryValid: false, + // A fresh mode can always place; the live placement gate flips this on veto. + canAddPoint: true }) const setAction = (state, payload) => ({ @@ -51,6 +54,8 @@ const setUndoStackLength = (state, payload) => ({ ...state, undoStackLength: pay const setGeometryValid = (state, payload) => ({ ...state, geometryValid: !!payload }) +const setCanAddPoint = (state, payload) => ({ ...state, canAddPoint: !!payload }) + const actions = { SET_MODE: setMode, SET_ACTION: setAction, @@ -60,7 +65,8 @@ const actions = { SET_SNAP: setSnap, SET_HAS_SNAP_LAYERS: setHasSnapLayers, SET_UNDO_STACK_LENGTH: setUndoStackLength, - SET_GEOMETRY_VALID: setGeometryValid + SET_GEOMETRY_VALID: setGeometryValid, + SET_CAN_ADD_POINT: setCanAddPoint } export { initialState, actions } diff --git a/plugins/draw/src/reducer.test.js b/plugins/draw/src/reducer.test.js index e9a332367..c3a296082 100644 --- a/plugins/draw/src/reducer.test.js +++ b/plugins/draw/src/reducer.test.js @@ -12,7 +12,8 @@ describe('initialState', () => { numVertices: null, snap: false, hasSnapLayers: false, - undoStackLength: 0 + undoStackLength: 0, + canAddPoint: true }) }) }) @@ -23,6 +24,11 @@ describe('SET_MODE', () => { expect(actions.SET_MODE(initialState, 'draw_line')).toMatchObject({ mode: 'draw_line', numVertices: 0 }) }) + test('a fresh mode can always place a point', () => { + const state = { ...initialState, canAddPoint: false } + expect(actions.SET_MODE(state, 'draw_polygon')).toMatchObject({ canAddPoint: true }) + }) + test('preserves numVertices for non-draw modes', () => { const state = { ...initialState, numVertices: 5 } expect(actions.SET_MODE(state, 'edit_vertex')).toMatchObject({ mode: 'edit_vertex', numVertices: 5 }) @@ -75,3 +81,10 @@ describe('SET_UNDO_STACK_LENGTH', () => { expect(actions.SET_UNDO_STACK_LENGTH(initialState, 3).undoStackLength).toBe(3) }) }) + +describe('SET_CAN_ADD_POINT', () => { + test('coerces the payload to a boolean', () => { + expect(actions.SET_CAN_ADD_POINT(initialState, false).canAddPoint).toBe(false) + expect(actions.SET_CAN_ADD_POINT(initialState, 1).canAddPoint).toBe(true) + }) +}) diff --git a/plugins/draw/src/validation/liveStroke.js b/plugins/draw/src/validation/liveStroke.js index 4d18d8206..82455c797 100644 --- a/plugins/draw/src/validation/liveStroke.js +++ b/plugins/draw/src/validation/liveStroke.js @@ -6,22 +6,25 @@ const cancelFrame = (id) => (typeof cancelAnimationFrame === 'function' ? cancelAnimationFrame(id) : clearTimeout(id)) /** - * Engine-agnostic driver for the live invalid stroke while drawing. + * Engine-agnostic driver for a live validity signal while drawing/editing — + * the invalid stroke by default, or (via `validate`) any other continuously + * re-evaluated verdict such as the Add-point placement gate. * - * On every rubber-band move the caller passes the displayed geometry (placed - * vertices + cursor). The default rules (self-intersection, non-zero area) run - * synchronously for immediate feedback; a user `onGeometryChange` callback — whose - * cost is unknown — is throttled to one call per animation frame using the latest - * geometry (trailing edge), so an arbitrary user rule can't jank the drag. The - * default-fail path short-circuits (no user call) and cancels any pending frame so - * a stale evaluation can't flip the stroke back. `onChange(invalid, reason)` fires - * only when the invalid state actually flips. + * On every rubber-band/drag move the caller passes the displayed geometry (placed + * vertices + cursor). The default rules run synchronously for immediate feedback; + * a user `onGeometryChange` callback — whose cost is unknown — is throttled to one + * call per animation frame using the latest geometry (trailing edge), so an + * arbitrary user rule can't jank the drag. The default-fail path short-circuits + * (no user call) and cancels any pending frame so a stale evaluation can't flip + * the state back. `onChange(invalid, reason)` fires only when the state flips. * * @param {object} params * @param {(invalid: boolean, reason: string|null) => void} params.onChange - * @returns {{ update: Function, reset: Function, destroy: Function }} + * @param {Function} [params.validate] - (feature, context, config) => { valid, reason }; + * defaults to validateDisplayedGeometry (the invalid-stroke rules) + * @returns {{ update: Function, set: Function, reset: Function, destroy: Function }} */ -export const createLiveStroke = ({ onChange }) => { +export const createLiveStroke = ({ onChange, validate = validateDisplayedGeometry }) => { let invalid = false let frame = null let pending = null @@ -34,8 +37,6 @@ export const createLiveStroke = ({ onChange }) => { const flip = (next, reason) => { if (next !== invalid) { invalid = next - // eslint-disable-next-line no-console -- TEMP live-stroke diagnostics - console.log('[live-stroke] FLIP →', next) onChange(next, reason ?? null) } } @@ -44,7 +45,7 @@ export const createLiveStroke = ({ onChange }) => { frame = null if (!pending) { return } const { feature, context, onGeometryChange } = pending - const { valid, reason } = validateDisplayedGeometry(feature, context, { onGeometryChange }) + const { valid, reason } = validate(feature, context, { onGeometryChange }) flip(!valid, reason) } @@ -53,15 +54,21 @@ export const createLiveStroke = ({ onChange }) => { update ({ feature, context = {}, placedCount, onGeometryChange }) { const ctx = { ...context, placedCount } // Default rules first, synchronously — immediate feedback on self-intersection / area. - const base = validateDisplayedGeometry(feature, ctx) - // eslint-disable-next-line no-console -- TEMP live-stroke diagnostics - console.log('[live-stroke] update', { type: feature?.geometry?.type, placedCount, baseValid: base.valid, currentInvalid: invalid }) + const base = validate(feature, ctx) if (!base.valid) { cancelPending(); flip(true, base.reason); return } // Default rules pass — a user rule (if any) decides, throttled to one frame. if (typeof onGeometryChange !== 'function') { cancelPending(); flip(false, null); return } pending = { feature, context: ctx, onGeometryChange } if (frame == null) { frame = requestFrame(runUserRule) } }, + // Authoritative external write — a committed verdict (events.js) or a mode + // reset. Applies through the same flip guard so the cached state always + // mirrors what is rendered, and drops any pending user-rule frame so a stale + // live evaluation can't overwrite it a frame later. + set (next, reason) { + cancelPending() + flip(next, reason ?? null) + }, // Clear state without firing onChange (the caller forces the stroke solid on a // fresh draw); drops any pending user-rule frame. reset () { diff --git a/plugins/draw/src/validation/liveStroke.test.js b/plugins/draw/src/validation/liveStroke.test.js new file mode 100644 index 000000000..b7ddc18f7 --- /dev/null +++ b/plugins/draw/src/validation/liveStroke.test.js @@ -0,0 +1,148 @@ +import { createLiveStroke } from './liveStroke.js' + +const poly = (ring) => ({ type: 'Feature', geometry: { type: 'Polygon', coordinates: [ring] } }) + +const square = poly([[0, 0], [10, 0], [10, 10], [0, 10]]) +const bowtie = poly([[0, 0], [10, 10], [10, 0], [0, 10]]) + +const setup = () => { + const onChange = jest.fn() + const stroke = createLiveStroke({ onChange }) + return { onChange, stroke } +} + +beforeEach(() => jest.useFakeTimers()) +afterEach(() => jest.useRealTimers()) + +describe('default rules (synchronous)', () => { + test('a failing default rule flips dashed immediately with the reason', () => { + const { onChange, stroke } = setup() + stroke.update({ feature: bowtie, placedCount: 3 }) + expect(onChange).toHaveBeenCalledWith(true, expect.stringMatching(/intersect/i)) + }) + + test('going valid again flips back solid', () => { + const { onChange, stroke } = setup() + stroke.update({ feature: bowtie, placedCount: 3 }) + stroke.update({ feature: square, placedCount: 3 }) + expect(onChange).toHaveBeenLastCalledWith(false, null) + }) + + test('onChange fires only when the state flips, not on every update', () => { + const { onChange, stroke } = setup() + stroke.update({ feature: bowtie, placedCount: 3 }) + stroke.update({ feature: bowtie, placedCount: 3 }) + stroke.update({ feature: bowtie, placedCount: 3 }) + expect(onChange).toHaveBeenCalledTimes(1) + }) + + test('below the minimum placed count the shape is part-drawn — never dashed', () => { + const { onChange, stroke } = setup() + stroke.update({ feature: bowtie, placedCount: 2 }) + expect(onChange).not.toHaveBeenCalled() + }) + + test('a default-rule failure never invokes the user callback', () => { + const { stroke } = setup() + const onGeometryChange = jest.fn() + stroke.update({ feature: bowtie, placedCount: 3, onGeometryChange }) + jest.runAllTimers() + expect(onGeometryChange).not.toHaveBeenCalled() + }) +}) + +describe('user callback (throttled)', () => { + test('runs once per frame with the latest geometry (trailing edge)', () => { + const { stroke } = setup() + const onGeometryChange = jest.fn(() => true) + stroke.update({ feature: square, placedCount: 3, onGeometryChange }) + stroke.update({ feature: square, placedCount: 4, onGeometryChange }) + const latest = poly([[0, 0], [20, 0], [20, 20], [0, 20]]) + stroke.update({ feature: latest, placedCount: 5, onGeometryChange }) + expect(onGeometryChange).not.toHaveBeenCalled() // nothing synchronous + jest.runAllTimers() + expect(onGeometryChange).toHaveBeenCalledTimes(1) + expect(onGeometryChange).toHaveBeenCalledWith(latest, expect.objectContaining({ placedCount: 5 })) + }) + + test('a user-callback veto flips dashed with its reason', () => { + const { onChange, stroke } = setup() + stroke.update({ feature: square, placedCount: 3, onGeometryChange: () => ({ valid: false, reason: 'outside region' }) }) + jest.runAllTimers() + expect(onChange).toHaveBeenCalledWith(true, 'outside region') + }) + + test('a synchronous default failure cancels a pending user-rule frame', () => { + const { onChange, stroke } = setup() + const onGeometryChange = jest.fn(() => true) + stroke.update({ feature: square, placedCount: 3, onGeometryChange }) + stroke.update({ feature: bowtie, placedCount: 3, onGeometryChange }) // sync dashed + jest.runAllTimers() + expect(onGeometryChange).not.toHaveBeenCalled() // stale frame dropped + expect(onChange).toHaveBeenLastCalledWith(true, expect.any(String)) + }) + + test('without a user callback a valid update settles solid immediately', () => { + const { onChange, stroke } = setup() + stroke.update({ feature: bowtie, placedCount: 3 }) + stroke.update({ feature: square, placedCount: 3 }) + expect(onChange).toHaveBeenLastCalledWith(false, null) + expect(jest.getTimerCount()).toBe(0) + }) +}) + +describe('custom validate function', () => { + test('drives both the synchronous default pass and the throttled user pass', () => { + const onChange = jest.fn() + const validate = jest.fn((feature, context, config) => + config?.onGeometryChange ? config.onGeometryChange(feature, context) : { valid: true }) + const stroke = createLiveStroke({ onChange, validate }) + const onGeometryChange = jest.fn(() => ({ valid: false, reason: 'vetoed' })) + stroke.update({ feature: square, placedCount: 3, onGeometryChange }) + expect(validate).toHaveBeenCalledWith(square, expect.objectContaining({ placedCount: 3 })) + jest.runAllTimers() + expect(onChange).toHaveBeenCalledWith(true, 'vetoed') + }) +}) + +describe('set / reset / destroy', () => { + test('set() applies through the flip guard and drops any pending frame', () => { + const { onChange, stroke } = setup() + const onGeometryChange = jest.fn(() => true) + stroke.update({ feature: square, placedCount: 3, onGeometryChange }) + stroke.set(true, 'committed invalid') + expect(onChange).toHaveBeenCalledWith(true, 'committed invalid') + jest.runAllTimers() + expect(onGeometryChange).not.toHaveBeenCalled() // stale live frame dropped + onChange.mockClear() + stroke.set(true) // same state — guarded no-op + expect(onChange).not.toHaveBeenCalled() + }) + + test('set() keeps the cache in sync so the next live update flips correctly', () => { + const { onChange, stroke } = setup() + stroke.set(true) + onChange.mockClear() + stroke.update({ feature: square, placedCount: 3 }) // valid → back solid + expect(onChange).toHaveBeenCalledWith(false, null) + }) + + test('reset() clears state without firing onChange', () => { + const { onChange, stroke } = setup() + stroke.update({ feature: bowtie, placedCount: 3 }) + onChange.mockClear() + stroke.reset() + expect(onChange).not.toHaveBeenCalled() + stroke.update({ feature: bowtie, placedCount: 3 }) // cache cleared → flips again + expect(onChange).toHaveBeenCalledWith(true, expect.any(String)) + }) + + test('destroy() cancels a pending user-rule frame', () => { + const { stroke } = setup() + const onGeometryChange = jest.fn(() => true) + stroke.update({ feature: square, placedCount: 3, onGeometryChange }) + stroke.destroy() + jest.runAllTimers() + expect(onGeometryChange).not.toHaveBeenCalled() + }) +}) From 829fcadb2f0f4d8703ed8ea25a0b716353a49019 Mon Sep 17 00:00:00 2001 From: Dan Leech Date: Tue, 7 Jul 2026 21:02:22 +0100 Subject: [PATCH 52/89] Geometry validation refeactoring --- .../draw/src/adapters/adapterContract.test.js | 47 +++++++++++++++ .../adapters/maplibre/MaplibreDrawAdapter.js | 9 ++- .../maplibre/MaplibreDrawAdapter.test.js | 52 ++++++++++++++++- .../draw/src/adapters/maplibre/mapboxDraw.js | 10 ---- .../src/adapters/maplibre/mapboxDraw.test.js | 13 ++--- .../maplibre/modes/drawMode/clickHandlers.js | 31 ++++------ .../modes/drawMode/pointerHandlers.test.js | 11 +++- .../adapters/openlayers/OLDrawAdapter.test.js | 8 +++ .../adapters/openlayers/core/OLDrawManager.js | 3 + .../openlayers/core/OLDrawManager.test.js | 4 ++ .../src/adapters/openlayers/draw/DrawMode.js | 32 +++++------ .../src/adapters/openlayers/edit/EditMode.js | 8 ++- .../adapters/openlayers/edit/EditMode.test.js | 16 ++++++ plugins/draw/src/defaults.js | 2 +- plugins/draw/src/events.js | 18 ++++-- plugins/draw/src/events.test.js | 18 ++++-- plugins/draw/src/reducer.js | 3 - plugins/draw/src/reducer.test.js | 5 -- plugins/draw/src/validation/liveStroke.js | 10 ++-- .../draw/src/validation/liveStroke.test.js | 13 +++-- plugins/draw/src/validation/rules.js | 13 +++-- plugins/draw/src/validation/rules.test.js | 25 +++----- .../draw/src/validation/validateGeometry.js | 31 +++++++++- .../src/validation/validateGeometry.test.js | 57 ++++++++++++++++++- 24 files changed, 324 insertions(+), 115 deletions(-) create mode 100644 plugins/draw/src/adapters/adapterContract.test.js diff --git a/plugins/draw/src/adapters/adapterContract.test.js b/plugins/draw/src/adapters/adapterContract.test.js new file mode 100644 index 000000000..eecfe8d22 --- /dev/null +++ b/plugins/draw/src/adapters/adapterContract.test.js @@ -0,0 +1,47 @@ +import { MaplibreDrawAdapter } from './maplibre/MaplibreDrawAdapter.js' +import { OLDrawAdapter } from './openlayers/OLDrawAdapter.js' + +/** + * The shared adapter contract: events.js, DrawInit and the api entry points are + * written against this surface, so both adapters must implement all of it — even + * where an engine needs only a documented no-op (deleteVertex on MapLibre, + * setFeatureProperty on OpenLayers). A method added to one adapter but not the + * other fails here before it fails at runtime on the other engine. + */ +const CONTRACT_METHODS = [ + 'changeMode', + 'getMode', + 'setInterfaceType', + 'done', + 'cancel', + 'undo', + 'deleteVertex', + 'get', + 'add', + 'delete', + 'deleteAll', + 'setSnapEnabled', + 'setSnapLayers', + 'isSnapEnabled', + 'setFeatureProperty', + 'setGeometryValid', + 'setInvalid', + 'on', + 'off', + 'remove' +] + +describe.each([ + ['MaplibreDrawAdapter', MaplibreDrawAdapter], + ['OLDrawAdapter', OLDrawAdapter] +])('%s adapter contract', (name, Adapter) => { + test.each(CONTRACT_METHODS)('implements %s()', (method) => { + expect(typeof Adapter.prototype[method]).toBe('function') + }) + + test('exposes the _geometryValidator accessor pair (user callback storage)', () => { + const descriptor = Object.getOwnPropertyDescriptor(Adapter.prototype, '_geometryValidator') + expect(typeof descriptor?.get).toBe('function') + expect(typeof descriptor?.set).toBe('function') + }) +}) diff --git a/plugins/draw/src/adapters/maplibre/MaplibreDrawAdapter.js b/plugins/draw/src/adapters/maplibre/MaplibreDrawAdapter.js index dcfdfe634..736b30684 100644 --- a/plugins/draw/src/adapters/maplibre/MaplibreDrawAdapter.js +++ b/plugins/draw/src/adapters/maplibre/MaplibreDrawAdapter.js @@ -15,7 +15,7 @@ const lineFeature = (coordinates) => ({ type: 'Feature', geometry: { type: 'Line // mode covers both shapes) from the coordinate nesting: a polygon's coordinates are // rings (one level deeper than a line's). Draw-mode coordinates carry a trailing // rubber-band point; edit-mode coordinates are all committed vertices. -const displayedShape = (mode, coordinates) => { +export const displayedShape = (mode, coordinates) => { if (mode === 'draw_polygon') { return { feature: polygonFeature(coordinates), placedCount: (coordinates[0]?.length ?? 1) - 1 } } @@ -105,6 +105,7 @@ export class MaplibreDrawAdapter { this._bus.emit(ADAPTER_EVENTS.GEOMETRY_CHANGE, e) }, placementblocked: (e) => this._bus.emit(ADAPTER_EVENTS.PLACEMENT_BLOCKED, e), + interfacetypechange: (e) => this._bus.emit(ADAPTER_EVENTS.INTERFACE_TYPE_CHANGE, { interfaceType: e.interfaceType }), modechange: (e) => this._handleModeChange(e), styledata: () => this._handleStyleData() } @@ -118,6 +119,7 @@ export class MaplibreDrawAdapter { this._map.on(MAPBOX_DRAW_EVENTS.UPDATE, this._mapHandlers.update) this._map.on(CUSTOM_DRAW_EVENTS.GEOMETRY_CHANGE, this._mapHandlers.geometrychange) this._map.on(CUSTOM_DRAW_EVENTS.PLACEMENT_BLOCKED, this._mapHandlers.placementblocked) + this._map.on(CUSTOM_DRAW_EVENTS.INTERFACE_TYPE_CHANGE, this._mapHandlers.interfacetypechange) this._map.on(MAPBOX_DRAW_EVENTS.MODE_CHANGE, this._mapHandlers.modechange) this._map.on(STYLE_DATA_EVENT, this._mapHandlers.styledata) } @@ -273,6 +275,10 @@ export class MaplibreDrawAdapter { // Keeps draw layers on top after MapLibre style reloads _handleStyleData () { + // A style reload re-adds the draw layers with their spec-default visibility + // (solid stroke shown, dashed hidden) — re-assert the cached stroke state so + // an invalid shape stays dashed across the reload. + this._liveStroke.refresh() const layers = this._map.getStyle().layers || [] if (!layers.length || layers[layers.length - 1].source?.startsWith('mapbox-gl-draw')) { return @@ -292,6 +298,7 @@ export class MaplibreDrawAdapter { this._map.off(MAPBOX_DRAW_EVENTS.UPDATE, this._mapHandlers.update) this._map.off(CUSTOM_DRAW_EVENTS.GEOMETRY_CHANGE, this._mapHandlers.geometrychange) this._map.off(CUSTOM_DRAW_EVENTS.PLACEMENT_BLOCKED, this._mapHandlers.placementblocked) + this._map.off(CUSTOM_DRAW_EVENTS.INTERFACE_TYPE_CHANGE, this._mapHandlers.interfacetypechange) this._map.off(MAPBOX_DRAW_EVENTS.MODE_CHANGE, this._mapHandlers.modechange) this._map.off(STYLE_DATA_EVENT, this._mapHandlers.styledata) this._liveStroke.destroy() diff --git a/plugins/draw/src/adapters/maplibre/MaplibreDrawAdapter.test.js b/plugins/draw/src/adapters/maplibre/MaplibreDrawAdapter.test.js index c309b3aee..8759e4d81 100644 --- a/plugins/draw/src/adapters/maplibre/MaplibreDrawAdapter.test.js +++ b/plugins/draw/src/adapters/maplibre/MaplibreDrawAdapter.test.js @@ -2,7 +2,7 @@ import { createMapboxDraw } from './mapboxDraw.js' import { getSnapInstance, clearSnapState, clearSnapIndicator } from './utils/snapHelpers.js' import { createEventBus } from '../../utils/eventBus.js' import { MAPBOX_DRAW_EVENTS, CUSTOM_DRAW_EVENTS, STYLE_DATA_EVENT } from './drawEvents.js' -import { MaplibreDrawAdapter } from './MaplibreDrawAdapter.js' +import { MaplibreDrawAdapter, displayedShape } from './MaplibreDrawAdapter.js' jest.mock('./mapboxDraw.js', () => ({ createMapboxDraw: jest.fn() })) jest.mock('./utils/snapHelpers.js', () => ({ @@ -134,6 +134,33 @@ describe('map event normalisation', () => { }) }) +describe('displayedShape helper', () => { + test('builds a polygon feature from draw_polygon mode', () => { + const result = displayedShape('draw_polygon', [[[0, 0], [10, 0], [10, 10], [0, 0]]]) + expect(result?.feature?.type).toBe('Feature') + expect(result?.feature?.geometry?.type).toBe('Polygon') + expect(result?.placedCount).toBe(3) + }) + + test('builds a line feature from draw_line mode', () => { + const result = displayedShape('draw_line', [[0, 0], [10, 0], [10, 10]]) + expect(result?.feature?.type).toBe('Feature') + expect(result?.feature?.geometry?.type).toBe('LineString') + expect(result?.placedCount).toBe(2) + }) + + test('detects polygon vs line in edit_vertex mode from coordinate nesting', () => { + const polygon = displayedShape('edit_vertex', [[[0, 0], [10, 0], [10, 10], [0, 0]]]) + expect(polygon?.feature?.geometry?.type).toBe('Polygon') + const line = displayedShape('edit_vertex', [[0, 0], [10, 0], [10, 10]]) + expect(line?.feature?.geometry?.type).toBe('LineString') + }) + + test('returns null for an unknown mode', () => { + expect(displayedShape('unknown_mode', [[0, 0], [10, 0]])).toBeNull() + }) +}) + describe('live invalid stroke (draw mode)', () => { // Displayed rings (placed vertices + cursor) as the listener really receives them: // MapLibre's fire() wraps the gl-draw feature in an Event whose `type` is the @@ -600,6 +627,29 @@ describe('_handleStyleData', () => { onHandler(map, STYLE_DATA_EVENT)() expect(map.moveLayer).toHaveBeenCalledWith('d') }) + + test('re-asserts a dashed stroke after a style reload resets layer visibility', () => { + const { adapter, map, draw } = setup() + draw.getMode.mockReturnValue('draw_polygon') + map.getLayer.mockReturnValue({}) + // Live check flags a crossing → dashed. + onHandler(map, CUSTOM_DRAW_EVENTS.GEOMETRY_CHANGE)({ type: 'draw.geometrychange', coordinates: [[[0, 0], [10, 10], [10, 0], [0, 10]]] }) + expect(adapter).toBeDefined() + // A style reload re-adds the layers with spec defaults (solid visible)… + map.setLayoutProperty.mockClear() + onHandler(map, STYLE_DATA_EVENT)() + // …and the handler re-applies the cached dashed state. + expect(map.setLayoutProperty).toHaveBeenCalledWith('stroke-active-invalid.hot', 'visibility', 'visible') + expect(map.setLayoutProperty).toHaveBeenCalledWith('stroke-active.hot', 'visibility', 'none') + }) +}) + +describe('interface-type normalisation', () => { + test('draw.interfacetypechange is forwarded onto the adapter bus', () => { + const { map, bus } = setup() + onHandler(map, CUSTOM_DRAW_EVENTS.INTERFACE_TYPE_CHANGE)({ interfaceType: 'keyboard' }) + expect(bus.emit).toHaveBeenCalledWith('interfacetypechange', { interfaceType: 'keyboard' }) + }) }) describe('remove', () => { diff --git a/plugins/draw/src/adapters/maplibre/mapboxDraw.js b/plugins/draw/src/adapters/maplibre/mapboxDraw.js index 0ecffe8b6..423b7d1e8 100755 --- a/plugins/draw/src/adapters/maplibre/mapboxDraw.js +++ b/plugins/draw/src/adapters/maplibre/mapboxDraw.js @@ -94,15 +94,6 @@ export const createMapboxDraw = ({ mapStyle, mapProvider, events, eventBus, snap } eventBus.on(events.MAP_SET_STYLE, handleSetMapStyle) - // --- Sync final interface type when exiting draw modes --- - // When user switches devices during drawing (e.g., mouse to keyboard), the draw - // mode's local interfaceType diverges from appState.interfaceType. When exiting - // draw mode, we emit the final interfaceType so appState can be updated. - const handleDrawInterfaceTypeChange = (e) => { - eventBus.emit('draw:interfacetypechange', { interfaceType: e.interfaceType }) - } - map.on('draw.interfacetypechange', handleDrawInterfaceTypeChange) - // --- Update map scale --- const handleSetMapSize = (e) => { map.fire('draw.scalechange', { scale: MAP_SIZE_SCALES[e] }) @@ -117,7 +108,6 @@ export const createMapboxDraw = ({ mapStyle, mapProvider, events, eventBus, snap // Remove event listeners eventBus.off(events.MAP_SET_STYLE, handleSetMapStyle) eventBus.off(events.MAP_SET_SIZE, handleSetMapSize) - map.off('draw.interfacetypechange', handleDrawInterfaceTypeChange) // Disable draw mode but keep control on map for reuse draw.changeMode('disabled') // Clear adapter reference (but not _mapboxDrawInstance so it persists) diff --git a/plugins/draw/src/adapters/maplibre/mapboxDraw.test.js b/plugins/draw/src/adapters/maplibre/mapboxDraw.test.js index 372f53cf4..81cc25ad3 100644 --- a/plugins/draw/src/adapters/maplibre/mapboxDraw.test.js +++ b/plugins/draw/src/adapters/maplibre/mapboxDraw.test.js @@ -192,13 +192,9 @@ describe('createMapboxDraw – event handlers', () => { expect(applyTouchVertexColors).toHaveBeenCalledWith(undefined, 'dark') }) - test('draw.interfacetypechange is forwarded to the event bus', () => { - const { map, eventBus } = setup() - - const handler = handlerFor(map.on, 'draw.interfacetypechange') - handler({ interfaceType: 'keyboard' }) - - expect(eventBus.emit).toHaveBeenCalledWith('draw:interfacetypechange', { interfaceType: 'keyboard' }) + test('draw.interfacetypechange is not handled here — the adapter normalises it onto the bus', () => { + const { map } = setup() + expect(map.on).not.toHaveBeenCalledWith('draw.interfacetypechange', expect.any(Function)) }) test('MAP_SET_SIZE fires a scale change using the size lookup', () => { @@ -212,7 +208,7 @@ describe('createMapboxDraw – event handlers', () => { describe('createMapboxDraw – cleanup', () => { test('remove() detaches listeners, disables draw and clears the adapter reference', () => { - const { map, mapProvider, eventBus, removeWorkaround, result } = setup() + const { mapProvider, eventBus, removeWorkaround, result } = setup() const draw = mapProvider.draw result.remove() @@ -220,7 +216,6 @@ describe('createMapboxDraw – cleanup', () => { expect(removeWorkaround).toHaveBeenCalledTimes(1) expect(eventBus.off).toHaveBeenCalledWith(EVENTS.MAP_SET_STYLE, expect.any(Function)) expect(eventBus.off).toHaveBeenCalledWith(EVENTS.MAP_SET_SIZE, expect.any(Function)) - expect(map.off).toHaveBeenCalledWith('draw.interfacetypechange', expect.any(Function)) expect(draw.changeMode).toHaveBeenCalledWith('disabled') expect(mapProvider.draw).toBeNull() expect(mapProvider._mapboxDrawInstance).toBe(draw) diff --git a/plugins/draw/src/adapters/maplibre/modes/drawMode/clickHandlers.js b/plugins/draw/src/adapters/maplibre/modes/drawMode/clickHandlers.js index a1608bbeb..87692574b 100644 --- a/plugins/draw/src/adapters/maplibre/modes/drawMode/clickHandlers.js +++ b/plugins/draw/src/adapters/maplibre/modes/drawMode/clickHandlers.js @@ -1,7 +1,7 @@ import { getSnapInstance, isSnapActive, isSnapEnabled, createSnappedEvent, createSnappedClickEvent } from '../../utils/snapHelpers.js' -import { validatePlacement } from '../../../../validation/validateGeometry.js' +import { checkPlacement } from '../../../../validation/validateGeometry.js' // The commit-level geometrychange payload for a vertex commit: the placed-only // geometry (trailing rubber-band point dropped so validation tests the committed shape). @@ -24,17 +24,6 @@ const scheduleDrawValidation = (map, getFeature, getCoords, state, kind) => { }, 0) } -// Candidate GeoJSON for a would-be placement: the placed path (trailing rubber-band -// coord dropped) plus the point about to be placed. -const candidatePlacement = (feature, getCoords, geometryType, point) => { - const placed = getCoords(feature).slice(0, -1) - const candidate = [...placed, point] - const geometry = geometryType === 'Polygon' - ? { type: 'Polygon', coordinates: [candidate] } - : { type: 'LineString', coordinates: candidate } - return { candidateFeature: { type: 'Feature', geometry, properties: {} }, vertexIndex: placed.length } -} - // Re-id a freshly created feature to the caller's requested id. const reidCreatedFeature = (api, feature, featureId) => { api.delete(feature.id) @@ -49,17 +38,21 @@ const createClickHelpers = ({ geometryType, getFeature, getCoords }) => ({ return e.originalEvent.button > 0 || this.map._undoInProgress || e.originalEvent.target !== this.map.getCanvas() }, - // Gate a would-be placement through the hard rules + user callback - // (validatePlacement). On a veto the vertex never appears and a - // draw.placementblocked event carries the reason. + // Gate a would-be placement through the shared checkPlacement gate (hard rules + + // user callback). On a veto the vertex never appears and a draw.placementblocked + // event carries the reason. The trailing rubber-band coord is dropped so the + // candidate is placed vertices + the point about to be placed. _canPlaceVertex (state, point) { const feature = getFeature(state) if (!feature || !point) { return true } - const { candidateFeature, vertexIndex } = candidatePlacement(feature, getCoords, geometryType, point) - const mode = geometryType === 'Polygon' ? 'draw_polygon' : 'draw_line' - const result = validatePlacement(candidateFeature, { mode, vertexIndex }, { onGeometryChange: this.map._drawGeometryValidator }) + const result = checkPlacement({ + placed: getCoords(feature).slice(0, -1), + point, + geometryType, + onGeometryChange: this.map._drawGeometryValidator + }) if (!result.valid) { - this.map.fire('draw.placementblocked', { feature: candidateFeature, reason: result.reason ?? null, kind: 'place', mode, vertexIndex }) + this.map.fire('draw.placementblocked', result.blocked) } return result.valid }, diff --git a/plugins/draw/src/adapters/maplibre/modes/drawMode/pointerHandlers.test.js b/plugins/draw/src/adapters/maplibre/modes/drawMode/pointerHandlers.test.js index a4a9786b8..cdff586ff 100644 --- a/plugins/draw/src/adapters/maplibre/modes/drawMode/pointerHandlers.test.js +++ b/plugins/draw/src/adapters/maplibre/modes/drawMode/pointerHandlers.test.js @@ -1,4 +1,4 @@ -import { setup, clickAt, firedWith, activeSnap, DrawPolygonMode, CENTER } from './__helpers__/harness.js' +import { setup, clickAt, firedWith, activeSnap, DrawPolygonMode, DrawLineMode, CENTER } from './__helpers__/harness.js' describe('touch and pointer interface', () => { test('touch start/end switch to touch interface and show the crosshair', () => { @@ -91,4 +91,13 @@ describe('rubber band and snapping while moving', () => { ctx.map.fire('move') expect(state.polygon.coordinates[0]).toEqual(before) }) + + test('map move in keyboard line mode with snap fires geometrychange with state.line', () => { + const { ctx, state } = setup(DrawLineMode, { interfaceType: 'keyboard', getSnapEnabled: () => true }) + clickAt(ctx, state, 0, 0) + ctx.map._snapInstance = activeSnap() + ctx.map.fire.mockClear() + ctx.map.fire('move') + expect(firedWith(ctx.map, 'draw.geometrychange').pop()).toBe(state.line) + }) }) diff --git a/plugins/draw/src/adapters/openlayers/OLDrawAdapter.test.js b/plugins/draw/src/adapters/openlayers/OLDrawAdapter.test.js index 28551a9bd..ff5c789a3 100644 --- a/plugins/draw/src/adapters/openlayers/OLDrawAdapter.test.js +++ b/plugins/draw/src/adapters/openlayers/OLDrawAdapter.test.js @@ -111,6 +111,14 @@ test('setGeometryValid records validity on the manager for finish gating', () => expect(manager._geometryValid).toBe(false) }) +test('_geometryValidator accessor stores and retrieves the user callback on the manager', () => { + const { adapter, manager } = setup() + const validator = jest.fn(() => ({ valid: true })) + adapter._geometryValidator = validator + expect(manager._geometryValidator).toBe(validator) + expect(adapter._geometryValidator).toBe(validator) +}) + test('setInvalid delegates to the manager to toggle the dashed stroke', () => { const { adapter, manager } = setup() adapter.setInvalid(true) diff --git a/plugins/draw/src/adapters/openlayers/core/OLDrawManager.js b/plugins/draw/src/adapters/openlayers/core/OLDrawManager.js index fa82e6a81..f9152e99c 100644 --- a/plugins/draw/src/adapters/openlayers/core/OLDrawManager.js +++ b/plugins/draw/src/adapters/openlayers/core/OLDrawManager.js @@ -127,6 +127,9 @@ export class OLDrawManager { setInterfaceType (type) { this._modeInstance?.setInterfaceType?.(type) + // Parity with the ML adapter: an explicit interface-type write is echoed on + // the bus so events.js can relay it as draw:interfacetypechange. + this.emit(ADAPTER_EVENTS.INTERFACE_TYPE_CHANGE, { interfaceType: type }) } // --- Feature store delegation --- diff --git a/plugins/draw/src/adapters/openlayers/core/OLDrawManager.test.js b/plugins/draw/src/adapters/openlayers/core/OLDrawManager.test.js index 1eb81ed6b..a04dbf7f2 100644 --- a/plugins/draw/src/adapters/openlayers/core/OLDrawManager.test.js +++ b/plugins/draw/src/adapters/openlayers/core/OLDrawManager.test.js @@ -81,12 +81,16 @@ describe('mode machine', () => { manager.done() manager.undo() manager.deleteVertex() + const emitted = jest.fn() + manager.on('interfacetypechange', emitted) manager.setInterfaceType('touch') manager.setInvalid(true) expect(instance.done).toHaveBeenCalled() expect(instance.undo).toHaveBeenCalled() expect(instance.deleteVertex).toHaveBeenCalled() expect(instance.setInterfaceType).toHaveBeenCalledWith('touch') + // Parity with ML: an explicit interface-type write is echoed on the bus. + expect(emitted).toHaveBeenCalledWith({ interfaceType: 'touch' }) expect(instance.setInvalid).toHaveBeenCalledWith(true) manager.cancel() diff --git a/plugins/draw/src/adapters/openlayers/draw/DrawMode.js b/plugins/draw/src/adapters/openlayers/draw/DrawMode.js index 8e604c87d..a735e73b3 100644 --- a/plugins/draw/src/adapters/openlayers/draw/DrawMode.js +++ b/plugins/draw/src/adapters/openlayers/draw/DrawMode.js @@ -5,9 +5,9 @@ import { getPlacedSketchCoords, getLastPlacedSketchCoord } from '../utils/sketch import { TOLERANCES } from '../defaults.js' import { ADAPTER_EVENTS } from '../../../adapterEvents.js' import { STYLES_CHANGED_EVENT } from '../core/internalEvents.js' -import { validatePlacement } from '../../../validation/validateGeometry.js' +import { checkPlacement, validatePlacement, MODE_BY_GEOMETRY } from '../../../validation/validateGeometry.js' +import { MIN_VERTICES } from '../../../validation/rules.js' import { createLiveStroke } from '../../../validation/liveStroke.js' -const MIN_VERTICES = { Polygon: 3, LineString: 2 } const canFinish = (geometryType, sketchFeature) => { if (!sketchFeature) { return false } @@ -50,21 +50,19 @@ const placedFeatureGeoJSON = (store, sketchFeature) => { return { type: 'Feature', geometry, properties: gj.properties } } -// Gate a would-be placement (mouse click, crosshair tap, Enter) through the hard -// rules + user callback (validatePlacement). On a veto the vertex never appears -// and a PLACEMENT_BLOCKED event carries the reason. +// Gate a would-be placement (mouse click, crosshair tap, Enter) through the shared +// checkPlacement gate (hard rules + user callback). On a veto the vertex never +// appears and a PLACEMENT_BLOCKED event carries the reason. export const buildCanPlaceVertex = ({ manager, geometryType, getSketch }) => (coordinate) => { const sketch = getSketch() - const placed = sketch ? getPlacedSketchCoords(sketch.getGeometry()) : [] - const candidate = [...placed, coordinate] - const geometry = geometryType === 'Polygon' - ? { type: 'Polygon', coordinates: [candidate] } - : { type: 'LineString', coordinates: candidate } - const feature = { type: 'Feature', geometry, properties: {} } - const mode = geometryType === 'Polygon' ? 'draw_polygon' : 'draw_line' - const result = validatePlacement(feature, { mode, vertexIndex: placed.length }, { onGeometryChange: manager._geometryValidator }) + const result = checkPlacement({ + placed: sketch ? getPlacedSketchCoords(sketch.getGeometry()) : [], + point: coordinate, + geometryType, + onGeometryChange: manager._geometryValidator + }) if (!result.valid) { - manager.emit(ADAPTER_EVENTS.PLACEMENT_BLOCKED, { feature, reason: result.reason ?? null, kind: 'place', mode, vertexIndex: placed.length }) + manager.emit(ADAPTER_EVENTS.PLACEMENT_BLOCKED, result.blocked) } return result.valid } @@ -155,10 +153,9 @@ const buildDrawModeApi = ({ map, manager, drawInteraction, input, geometryType, }) // Build the touch/keyboard draw input for the interaction. -const buildDrawInput = ({ drawInteraction, manager, options, geometryType, getSketch, updateVertexCount, emitUndoValidation, canPlaceVertex }) => +const buildDrawInput = ({ drawInteraction, options, geometryType, getSketch, updateVertexCount, emitUndoValidation, canPlaceVertex }) => createDrawInput({ drawInteraction, - manager, options: { container: options.container, interfaceType: options.interfaceType, @@ -207,7 +204,7 @@ export const createDrawMode = ({ map, manager, options }) => { validate: validatePlacement, onChange: (vetoed, reason) => manager.emit(ADAPTER_EVENTS.CAN_PLACE_CHANGE, { canPlace: !vetoed, reason }) }) - const liveMode = geometryType === 'Polygon' ? 'draw_polygon' : 'draw_line' + const liveMode = MODE_BY_GEOMETRY[geometryType] const updateLiveValidity = () => { if (!sketchFeature) { return } const { feature, placedCount } = displayedSketch(geometryType, sketchFeature) @@ -237,7 +234,6 @@ export const createDrawMode = ({ map, manager, options }) => { const input = buildDrawInput({ drawInteraction, - manager, options, geometryType, getSketch: () => sketchFeature, diff --git a/plugins/draw/src/adapters/openlayers/edit/EditMode.js b/plugins/draw/src/adapters/openlayers/edit/EditMode.js index 18b360f5c..3647fe7ef 100644 --- a/plugins/draw/src/adapters/openlayers/edit/EditMode.js +++ b/plugins/draw/src/adapters/openlayers/edit/EditMode.js @@ -206,11 +206,13 @@ const wireKeyboardHandler = ({ map, container, snap, undoStack, selection, touch } // Style hot-swap on map style change + touch-target reposition on map resize -const wireMapSync = ({ map, manager, olFeature, layers, selection, touchHandler }) => { +const wireMapSync = ({ map, manager, layers, selection, touchHandler, live }) => { const { state } = selection const onStylesChanged = (styles) => { - olFeature.setStyle(styles.editFeatureStyle) + // Re-assert through the live-stroke controller so an invalid (dashed) shape + // stays dashed across a map style change. + live.liveStroke.refresh() layers.vertexLayer.updateStyle(styles.vertexStyle) layers.midpointLayer.updateStyle(styles.midpointStyle) layers.activeLayer.update(state) @@ -349,7 +351,7 @@ export const createEditMode = ({ map, manager, options }) => { deleteVertexButtonId, onDeleteVertex: actions.doDeleteVertex }) - const mapSync = wireMapSync({ map, manager, olFeature, layers, selection, touchHandler }) + const mapSync = wireMapSync({ map, manager, layers, selection, touchHandler, live }) return buildModeApi({ manager, diff --git a/plugins/draw/src/adapters/openlayers/edit/EditMode.test.js b/plugins/draw/src/adapters/openlayers/edit/EditMode.test.js index a0def94d6..83dd42f8e 100644 --- a/plugins/draw/src/adapters/openlayers/edit/EditMode.test.js +++ b/plugins/draw/src/adapters/openlayers/edit/EditMode.test.js @@ -5,6 +5,7 @@ import { ADAPTER_EVENTS } from '../../../adapterEvents.js' import { createFakeMap, createFakeManager, createContainer, domEvent } from '../__helpers__/harness.js' import Style from 'ol/style/Style.js' import Polygon from 'ol/geom/Polygon.js' +import LineString from 'ol/geom/LineString.js' const RING = [[0, 0], [100, 0], [100, 100], [0, 100], [0, 0]] // square: 4 vertices, deletable @@ -241,11 +242,26 @@ test('a tap on empty map deselects', () => { test('style changes re-style the feature and handles', () => { const { manager, olFeature } = setup() + // The real manager swaps manager.styles before emitting (see setMapStyle). const newStyles = { ...manager.styles, editFeatureStyle: new Style({}) } + manager.styles = newStyles manager.emit(STYLES_CHANGED_EVENT, newStyles) expect(olFeature.getStyle()).toBe(newStyles.editFeatureStyle) }) +test('a style change while the shape is invalid keeps the dashed stroke', () => { + const { manager, olFeature } = setup() + olFeature.getGeometry().setCoordinates([[[0, 0], [100, 100], [100, 0], [0, 100], [0, 0]]]) // dashed + const newStyles = { + ...manager.styles, + editFeatureStyle: new Style({}), + editFeatureStyleInvalid: new Style({}) + } + manager.styles = newStyles + manager.emit(STYLES_CHANGED_EVENT, newStyles) + expect(olFeature.getStyle()).toBe(newStyles.editFeatureStyleInvalid) +}) + test('map resize repositions the touch target after the next render — touch with a selection only', () => { const { map, mode, tapAt } = setup() map.emit('change:size') // mouse interface — EditMode's own resize hook ignores it diff --git a/plugins/draw/src/defaults.js b/plugins/draw/src/defaults.js index d583e46cc..b0f564f59 100644 --- a/plugins/draw/src/defaults.js +++ b/plugins/draw/src/defaults.js @@ -17,7 +17,7 @@ export const COLORS = { editActive: { light: BLACK, dark: WHITE }, splitInvalid: BLUE, splitValid: BLUE, - invalidStroke: BLUE, + invalidStroke: { light: BLUE, dark: WHITE }, shapeStroke: RED, shapeFill: MID_ORANGE, snapVertex: MID_ORANGE, diff --git a/plugins/draw/src/events.js b/plugins/draw/src/events.js index ae96846ed..2ccd4882d 100644 --- a/plugins/draw/src/events.js +++ b/plugins/draw/src/events.js @@ -38,13 +38,16 @@ function createHandlers ({ appState, appConfig, mapState, pluginState, mapProvid const { draw } = mapProvider const { feature, tempFeature } = pluginState const { dispatch } = pluginState + // A shape that finished invalid and was re-opened in edit mode: its eventual + // edit-finish must report as a creation, not an edit. + let pendingCreateId = null return { handleDone: () => { draw.done() }, handleCancel: () => { const mode = draw.getMode() if (mode === EDIT_VERTEX_MODE && tempFeature?.id) { draw.add(feature) } - draw._pendingCreateId = null + pendingCreateId = null draw.cancel(); resetState() eventBus.emit('draw:cancelled', feature) }, @@ -60,7 +63,7 @@ function createHandlers ({ appState, appConfig, mapState, pluginState, mapProvid // here to catch the gesture paths — an invalid shape must never be finalised. const { valid } = validateGeometry(f, { kind: 'create', mode: draw.getMode() }, { onGeometryChange: draw._geometryValidator }) if (!valid) { - draw._pendingCreateId = f.id + pendingCreateId = f.id eventBus.emit(GEOMETRY_INVALID_EVENT, { feature: f, kind: 'create', mode: EDIT_VERTEX_MODE }) setTimeout(() => enterEditVertexMode({ draw, appState, appConfig, mapState, dispatch }, f.id), 0) return @@ -71,8 +74,8 @@ function createHandlers ({ appState, appConfig, mapState, pluginState, mapProvid resetState() setTimeout(() => draw.changeMode('disabled'), 0) // A shape that was drawn-then-fixed reports as a creation, not an edit. - if (draw._pendingCreateId && f.id === draw._pendingCreateId) { - draw._pendingCreateId = null + if (pendingCreateId && f.id === pendingCreateId) { + pendingCreateId = null eventBus.emit('draw:created', f) } else { eventBus.emit('draw:edited', f) @@ -122,6 +125,11 @@ function createHandlers ({ appState, appConfig, mapState, pluginState, mapProvid // never looks active when a tap would be rejected. onCanPlaceChange: (e) => { pluginState.dispatch({ type: 'SET_CAN_ADD_POINT', payload: e.canPlace }) + }, + // The mode's interface type changed (device switch mid-draw, or the final + // value on mode exit) — relay so the app can sync appState.interfaceType. + onInterfaceTypeChange: (e) => { + eventBus.emit('draw:interfacetypechange', { interfaceType: e.interfaceType }) } } } @@ -147,6 +155,7 @@ function attachDrawEvents (draw, handlers) { draw.on(ADAPTER_EVENTS.PLACEMENT_BLOCKED, handlers.onPlacementBlocked) draw.on(ADAPTER_EVENTS.VALIDITY_CHANGE, handlers.onValidityChange) draw.on(ADAPTER_EVENTS.CAN_PLACE_CHANGE, handlers.onCanPlaceChange) + draw.on(ADAPTER_EVENTS.INTERFACE_TYPE_CHANGE, handlers.onInterfaceTypeChange) } function detachButtonHandlers (buttonConfig) { @@ -170,6 +179,7 @@ function detachDrawEvents (draw, handlers) { draw.off(ADAPTER_EVENTS.PLACEMENT_BLOCKED, handlers.onPlacementBlocked) draw.off(ADAPTER_EVENTS.VALIDITY_CHANGE, handlers.onValidityChange) draw.off(ADAPTER_EVENTS.CAN_PLACE_CHANGE, handlers.onCanPlaceChange) + draw.off(ADAPTER_EVENTS.INTERFACE_TYPE_CHANGE, handlers.onInterfaceTypeChange) } export function attachEvents ({ appState, appConfig, mapState, pluginState, mapProvider, buttonConfig, eventBus }) { diff --git a/plugins/draw/src/events.test.js b/plugins/draw/src/events.test.js index 56dc96dd2..53fd4d475 100644 --- a/plugins/draw/src/events.test.js +++ b/plugins/draw/src/events.test.js @@ -2,7 +2,7 @@ import { attachEvents } from './events.js' jest.useFakeTimers() -const DRAW_EVENTS = ['create', 'editfinish', 'cancel', 'vertexselection', 'vertexchange', 'undochange', 'update', 'geometrychange', 'placementblocked', 'validitychange', 'canplacechange'] +const DRAW_EVENTS = ['create', 'editfinish', 'cancel', 'vertexselection', 'vertexchange', 'undochange', 'update', 'geometrychange', 'placementblocked', 'validitychange', 'canplacechange', 'interfacetypechange'] const setup = (overrides = {}) => { const draw = { @@ -140,7 +140,6 @@ describe('draw event handlers', () => { const bowtie = { id: 'bad', type: 'Feature', geometry: { type: 'Polygon', coordinates: [[[0, 0], [1, 1], [1, 0], [0, 1], [0, 0]]] } } drawHandler(draw, 'create')(bowtie) expect(eventBus.emit).not.toHaveBeenCalledWith('draw:created', bowtie) - expect(draw._pendingCreateId).toBe('bad') jest.runAllTimers() const editCall = draw.changeMode.mock.calls.find(([mode]) => mode === 'edit_vertex') expect(editCall[1]).toEqual(expect.objectContaining({ featureId: 'bad' })) @@ -151,10 +150,15 @@ describe('draw event handlers', () => { test('edit finish of a drawn-then-fixed shape reports as a creation', () => { const { draw, eventBus } = setup() - draw._pendingCreateId = 'bad' + // An invalid finished shape is re-opened for fixing (marks it as a pending creation)… + const bowtie = { id: 'bad', type: 'Feature', geometry: { type: 'Polygon', coordinates: [[[0, 0], [1, 1], [1, 0], [0, 1], [0, 0]]] } } + drawHandler(draw, 'create')(bowtie) + jest.runAllTimers() + // …so when its edit finishes, it reports as a creation, exactly once. drawHandler(draw, 'editfinish')({ id: 'bad' }) expect(eventBus.emit).toHaveBeenCalledWith('draw:created', { id: 'bad' }) - expect(draw._pendingCreateId).toBeNull() + drawHandler(draw, 'editfinish')({ id: 'bad' }) + expect(eventBus.emit).toHaveBeenCalledWith('draw:edited', { id: 'bad' }) }) test('cancel handler is a no-op', () => { @@ -281,6 +285,12 @@ describe('geometrychange validation', () => { expect(dispatch).toHaveBeenCalledWith({ type: 'SET_CAN_ADD_POINT', payload: true }) }) + test('relays an interface-type change to the public bus', () => { + const { draw, eventBus } = setup() + drawHandler(draw, 'interfacetypechange')({ interfaceType: 'keyboard' }) + expect(eventBus.emit).toHaveBeenCalledWith('draw:interfacetypechange', { interfaceType: 'keyboard' }) + }) + test('relays a blocked placement to the public bus as draw:geometryinvalid', () => { const { draw, eventBus } = setup() const blocked = { kind: 'place', mode: 'draw_polygon', vertexIndex: 2, reason: 'outside region', feature: { type: 'Feature' } } diff --git a/plugins/draw/src/reducer.js b/plugins/draw/src/reducer.js index 886ac4166..73972a2e4 100644 --- a/plugins/draw/src/reducer.js +++ b/plugins/draw/src/reducer.js @@ -46,8 +46,6 @@ const setFeature = (state, payload) => ({ const toggleSnap = (state) => ({ ...state, snap: !state.snap }) -const setSnap = (state, payload) => ({ ...state, snap: !!payload }) - const setHasSnapLayers = (state, payload) => ({ ...state, hasSnapLayers: !!payload }) const setUndoStackLength = (state, payload) => ({ ...state, undoStackLength: payload }) @@ -62,7 +60,6 @@ const actions = { SET_FEATURE: setFeature, SET_SELECTED_VERTEX_INDEX: setSelectedVertexIndex, TOGGLE_SNAP: toggleSnap, - SET_SNAP: setSnap, SET_HAS_SNAP_LAYERS: setHasSnapLayers, SET_UNDO_STACK_LENGTH: setUndoStackLength, SET_GEOMETRY_VALID: setGeometryValid, diff --git a/plugins/draw/src/reducer.test.js b/plugins/draw/src/reducer.test.js index c3a296082..867359b0c 100644 --- a/plugins/draw/src/reducer.test.js +++ b/plugins/draw/src/reducer.test.js @@ -65,11 +65,6 @@ describe('snap actions', () => { expect(actions.TOGGLE_SNAP({ ...initialState, snap: true }).snap).toBe(false) }) - test('SET_SNAP coerces the payload to a boolean', () => { - expect(actions.SET_SNAP(initialState, 1).snap).toBe(true) - expect(actions.SET_SNAP(initialState, 0).snap).toBe(false) - }) - test('SET_HAS_SNAP_LAYERS coerces the payload to a boolean', () => { expect(actions.SET_HAS_SNAP_LAYERS(initialState, ['x']).hasSnapLayers).toBe(true) expect(actions.SET_HAS_SNAP_LAYERS(initialState, null).hasSnapLayers).toBe(false) diff --git a/plugins/draw/src/validation/liveStroke.js b/plugins/draw/src/validation/liveStroke.js index 82455c797..2b2449c3b 100644 --- a/plugins/draw/src/validation/liveStroke.js +++ b/plugins/draw/src/validation/liveStroke.js @@ -69,11 +69,11 @@ export const createLiveStroke = ({ onChange, validate = validateDisplayedGeometr cancelPending() flip(next, reason ?? null) }, - // Clear state without firing onChange (the caller forces the stroke solid on a - // fresh draw); drops any pending user-rule frame. - reset () { - cancelPending() - invalid = false + // Re-assert the cached state through onChange unconditionally — for callers + // whose rendered output was reset behind the controller's back (e.g. a map + // style reload re-adding layers with their spec-default visibility). + refresh () { + onChange(invalid, null) }, destroy () { cancelPending() diff --git a/plugins/draw/src/validation/liveStroke.test.js b/plugins/draw/src/validation/liveStroke.test.js index b7ddc18f7..6d8a22d4a 100644 --- a/plugins/draw/src/validation/liveStroke.test.js +++ b/plugins/draw/src/validation/liveStroke.test.js @@ -127,14 +127,16 @@ describe('set / reset / destroy', () => { expect(onChange).toHaveBeenCalledWith(false, null) }) - test('reset() clears state without firing onChange', () => { + test('refresh() re-asserts the cached state unconditionally (style-reload resync)', () => { const { onChange, stroke } = setup() stroke.update({ feature: bowtie, placedCount: 3 }) onChange.mockClear() - stroke.reset() - expect(onChange).not.toHaveBeenCalled() - stroke.update({ feature: bowtie, placedCount: 3 }) // cache cleared → flips again - expect(onChange).toHaveBeenCalledWith(true, expect.any(String)) + stroke.refresh() // rendered output was reset externally — re-apply dashed + expect(onChange).toHaveBeenCalledWith(true, null) + stroke.update({ feature: square, placedCount: 3 }) + onChange.mockClear() + stroke.refresh() + expect(onChange).toHaveBeenCalledWith(false, null) }) test('destroy() cancels a pending user-rule frame', () => { @@ -145,4 +147,5 @@ describe('set / reset / destroy', () => { jest.runAllTimers() expect(onGeometryChange).not.toHaveBeenCalled() }) + }) diff --git a/plugins/draw/src/validation/rules.js b/plugins/draw/src/validation/rules.js index 14354aba7..afa9360d0 100644 --- a/plugins/draw/src/validation/rules.js +++ b/plugins/draw/src/validation/rules.js @@ -15,8 +15,9 @@ import turfArea from '@turf/area' * Add a rule by appending it to SOFT_RULES or HARD_RULES. */ -const MIN_POLYGON_VERTICES = 3 -const MIN_LINE_VERTICES = 2 +// Minimum vertices for a finishable shape — the single source for every +// threshold check (rules, live gating, adapter finish conditions). +export const MIN_VERTICES = { Polygon: 3, LineString: 2 } const MIN_INTERSECT_VERTICES = 4 // need 4+ vertices for two non-adjacent edges to exist const getGeometry = (feature) => feature?.geometry ?? feature @@ -93,7 +94,7 @@ const edgesSelfIntersect = (vertices, closed) => { * rejects the placement so the vertex never appears and a genuine self-intersection * can't be drawn forward. */ -export const pathSelfIntersects = (feature) => { +const pathSelfIntersects = (feature) => { const geometry = getPolygon(feature) if (!geometry) { return false } const vertices = getRingVertices(geometry) @@ -129,7 +130,7 @@ export const nonZeroArea = (feature) => { if (!geometry) { return { valid: true } } const vertices = getRingVertices(geometry) - if (vertices.length < MIN_POLYGON_VERTICES) { return { valid: true } } + if (vertices.length < MIN_VERTICES.Polygon) { return { valid: true } } let area = 0 try { @@ -147,12 +148,12 @@ export const nonZeroArea = (feature) => { export const minVertices = (feature) => { const geometry = getGeometry(feature) if (geometry?.type === 'Polygon') { - return getRingVertices(geometry).length >= MIN_POLYGON_VERTICES + return getRingVertices(geometry).length >= MIN_VERTICES.Polygon ? { valid: true } : { valid: false, reason: 'Shape needs at least 3 points' } } if (geometry?.type === 'LineString') { - return (geometry.coordinates?.length ?? 0) >= MIN_LINE_VERTICES + return (geometry.coordinates?.length ?? 0) >= MIN_VERTICES.LineString ? { valid: true } : { valid: false, reason: 'Line needs at least 2 points' } } diff --git a/plugins/draw/src/validation/rules.test.js b/plugins/draw/src/validation/rules.test.js index 653a07f97..95868b7d7 100644 --- a/plugins/draw/src/validation/rules.test.js +++ b/plugins/draw/src/validation/rules.test.js @@ -1,4 +1,4 @@ -import { noSelfIntersection, nonZeroArea, minVertices, pathSelfIntersects, noPathSelfIntersection, SOFT_RULES, HARD_RULES } from './rules.js' +import { noSelfIntersection, nonZeroArea, minVertices, noPathSelfIntersection, SOFT_RULES, HARD_RULES } from './rules.js' const poly = (coordinates) => ({ type: 'Feature', geometry: { type: 'Polygon', coordinates: [coordinates] } }) const line = (coordinates) => ({ type: 'Feature', geometry: { type: 'LineString', coordinates } }) @@ -66,23 +66,24 @@ describe('minVertices (soft)', () => { }) }) -describe('pathSelfIntersects (hard, draw placement)', () => { - test('detects a self-crossing open drawn path (no closing edge)', () => { - expect(pathSelfIntersects(poly([[0, 0], [2, 2], [2, 0], [0, 2]]))).toBe(true) +describe('noPathSelfIntersection (hard, draw placement)', () => { + test('rejects a self-crossing open drawn path with a reason', () => { + expect(noPathSelfIntersection(poly([[0, 0], [2, 2], [2, 0], [0, 2]]))) + .toEqual({ valid: false, reason: expect.stringMatching(/intersect/i) }) }) test('accepts an open path that only closes into a crossing', () => { // A bow-tie only crosses via its closing edge, which the open-path check ignores. - expect(pathSelfIntersects(poly([[0, 0], [2, 0], [0, 2], [2, 2]]))).toBe(false) + expect(noPathSelfIntersection(poly([[0, 0], [2, 0], [0, 2], [2, 2]]))).toEqual({ valid: true }) }) test('accepts a simple open path', () => { - expect(pathSelfIntersects(poly([[0, 0], [2, 0], [2, 2], [0, 2]]))).toBe(false) + expect(noPathSelfIntersection(poly([[0, 0], [2, 0], [2, 2], [0, 2]]))).toEqual({ valid: true }) }) test('skips non-polygons and paths under four vertices', () => { - expect(pathSelfIntersects(line([[0, 0], [1, 1]]))).toBe(false) - expect(pathSelfIntersects(poly([[0, 0], [1, 0], [1, 1]]))).toBe(false) + expect(noPathSelfIntersection(line([[0, 0], [1, 1]]))).toEqual({ valid: true }) + expect(noPathSelfIntersection(poly([[0, 0], [1, 0], [1, 1]]))).toEqual({ valid: true }) }) }) @@ -99,14 +100,6 @@ describe('consecutive duplicate vertices (zero-length edges)', () => { }) }) -describe('noPathSelfIntersection (hard, rule shape)', () => { - test('wraps pathSelfIntersects with a reason for the placement veto', () => { - expect(noPathSelfIntersection(poly([[0, 0], [2, 2], [2, 0], [0, 2]]))) - .toEqual({ valid: false, reason: expect.stringMatching(/intersect/i) }) - expect(noPathSelfIntersection(poly([[0, 0], [2, 0], [2, 2], [0, 2]]))).toEqual({ valid: true }) - }) -}) - describe('SOFT_RULES', () => { test('are the gating rules in reason-priority order', () => { expect(SOFT_RULES).toEqual([noSelfIntersection, nonZeroArea, minVertices]) diff --git a/plugins/draw/src/validation/validateGeometry.js b/plugins/draw/src/validation/validateGeometry.js index 5715f0855..513d0f13b 100644 --- a/plugins/draw/src/validation/validateGeometry.js +++ b/plugins/draw/src/validation/validateGeometry.js @@ -1,4 +1,4 @@ -import { SOFT_RULES, HARD_RULES, LIVE_RULES } from './rules.js' +import { SOFT_RULES, HARD_RULES, LIVE_RULES, MIN_VERTICES } from './rules.js' /** * Normalise a rule / callback result into `{ valid, reason }`. @@ -66,7 +66,32 @@ export const validatePlacement = (feature, context = {}, config = {}) => { return validateGeometry(feature, { ...context, kind: 'place' }, { rules, onGeometryChange }) } -const MIN_VERTICES_BY_TYPE = { Polygon: 3, LineString: 2 } +export const MODE_BY_GEOMETRY = { Polygon: 'draw_polygon', LineString: 'draw_line' } + +/** + * Engine-facing placement gate shared by both adapters: builds the candidate + * geometry (placed vertices + the point about to be placed), validates it against + * the hard rules and the user callback, and — on a veto — returns the + * PLACEMENT_BLOCKED payload for the caller to emit on its bus. + * + * @param {object} params + * @param {Array>} params.placed - committed vertex coordinates + * @param {Array} params.point - the coordinate about to be placed + * @param {'Polygon'|'LineString'} params.geometryType + * @param {Function} [params.onGeometryChange] - user callback + * @returns {{ valid: true } | { valid: false, blocked: object }} + */ +export const checkPlacement = ({ placed, point, geometryType, onGeometryChange }) => { + const candidate = [...placed, point] + const geometry = geometryType === 'Polygon' + ? { type: 'Polygon', coordinates: [candidate] } + : { type: 'LineString', coordinates: candidate } + const feature = { type: 'Feature', geometry, properties: {} } + const mode = MODE_BY_GEOMETRY[geometryType] + const { valid, reason } = validatePlacement(feature, { mode, vertexIndex: placed.length }, { onGeometryChange }) + if (valid) { return { valid: true } } + return { valid: false, blocked: { feature, reason: reason ?? null, kind: 'place', mode, vertexIndex: placed.length } } +} /** * Validate the displayed (in-progress) geometry that drives the live invalid @@ -86,7 +111,7 @@ const MIN_VERTICES_BY_TYPE = { Polygon: 3, LineString: 2 } export const validateDisplayedGeometry = (feature, context = {}, config = {}) => { const { rules = LIVE_RULES, onGeometryChange } = config const type = feature?.geometry?.type ?? feature?.type - const min = MIN_VERTICES_BY_TYPE[type] ?? 0 + const min = MIN_VERTICES[type] ?? 0 if ((context.placedCount ?? 0) < min) { return { valid: true } } return validateGeometry(feature, { ...context, kind: context.kind ?? 'preview' }, { rules, onGeometryChange }) } diff --git a/plugins/draw/src/validation/validateGeometry.test.js b/plugins/draw/src/validation/validateGeometry.test.js index 0a692e546..696066b69 100644 --- a/plugins/draw/src/validation/validateGeometry.test.js +++ b/plugins/draw/src/validation/validateGeometry.test.js @@ -1,4 +1,4 @@ -import { validateGeometry, validatePlacement } from './validateGeometry.js' +import { validateGeometry, validatePlacement, checkPlacement, validateDisplayedGeometry } from './validateGeometry.js' const poly = (coordinates) => ({ type: 'Feature', geometry: { type: 'Polygon', coordinates: [coordinates] } }) @@ -62,6 +62,61 @@ describe('validateGeometry (soft gating)', () => { }) }) +describe('checkPlacement (shared engine gate)', () => { + const placedL = [[0, 0], [1, 1], [1, 0]] // adding (0,1) makes the open path cross + + test('a legal placement passes with no payload', () => { + expect(checkPlacement({ placed: [[0, 0], [1, 0], [1, 1]], point: [0, 1], geometryType: 'Polygon' })) + .toEqual({ valid: true }) + }) + + test('a self-crossing placement is vetoed with the PLACEMENT_BLOCKED payload', () => { + const result = checkPlacement({ placed: placedL, point: [0, 1], geometryType: 'Polygon' }) + expect(result.valid).toBe(false) + expect(result.blocked).toEqual({ + feature: expect.objectContaining({ type: 'Feature' }), + reason: expect.stringMatching(/intersect/i), + kind: 'place', + mode: 'draw_polygon', + vertexIndex: 3 + }) + }) + + test('the user callback can veto, with mode derived from the geometry type', () => { + const onGeometryChange = jest.fn(() => ({ valid: false, reason: 'outside region' })) + const result = checkPlacement({ placed: [[0, 0]], point: [1, 1], geometryType: 'LineString', onGeometryChange }) + expect(result.blocked).toEqual(expect.objectContaining({ mode: 'draw_line', reason: 'outside region', vertexIndex: 1 })) + expect(onGeometryChange).toHaveBeenCalledWith(expect.anything(), { kind: 'place', mode: 'draw_line', vertexIndex: 1 }) + }) + + test('checkPlacement mode is set correctly for Polygon vs LineString', () => { + // Both legal and illegal placements should have the correct mode set + const polygonLegal = checkPlacement({ placed: [[0, 0], [1, 0]], point: [1, 1], geometryType: 'Polygon' }) + expect(polygonLegal).toEqual({ valid: true }) + // Test a Polygon placement that would cross + const polygonCrossing = checkPlacement({ placed: [[0, 0], [2, 2], [2, 0]], point: [0, 2], geometryType: 'Polygon' }) + expect(polygonCrossing.blocked?.mode).toBe('draw_polygon') + }) +}) + +describe('validateDisplayedGeometry edge cases', () => { + test('handles unknown geometry types with fallback min vertices', () => { + const result = validateDisplayedGeometry({ type: 'Feature', geometry: { type: 'Unknown', coordinates: [] } }, { placedCount: 0 }) + expect(result.valid).toBe(true) // unknown types use MIN_VERTICES fallback of 0, so 0 placed = valid + }) + + test('feature without geometry type defaults to context.kind', () => { + const result = validateDisplayedGeometry({ type: 'Feature', geometry: { coordinates: [[0, 0]] } }, { placedCount: 2, kind: 'custom' }) + expect(typeof result).toBe('object') + expect(result).toHaveProperty('valid') + }) + + test('context without placedCount defaults to 0 for min-vertex check', () => { + const result = validateDisplayedGeometry(poly([[0, 0]]), {}) + expect(result.valid).toBe(true) // no placedCount = 0, below any min, so valid + }) +}) + describe('validatePlacement (hard gating)', () => { // The candidate is the open drawn path plus the point about to be placed. const crossingPath = poly([[0, 0], [1, 1], [1, 0], [0, 1]]) From ad46ca302116a5c4760388a7d03a2a91fdaa3025 Mon Sep 17 00:00:00 2001 From: Dan Leech Date: Tue, 7 Jul 2026 21:25:58 +0100 Subject: [PATCH 53/89] Lint fixes --- plugins/draw/src/adapters/openlayers/edit/EditMode.test.js | 1 - plugins/draw/src/validation/liveStroke.test.js | 1 - 2 files changed, 2 deletions(-) diff --git a/plugins/draw/src/adapters/openlayers/edit/EditMode.test.js b/plugins/draw/src/adapters/openlayers/edit/EditMode.test.js index 83dd42f8e..ad81c9faf 100644 --- a/plugins/draw/src/adapters/openlayers/edit/EditMode.test.js +++ b/plugins/draw/src/adapters/openlayers/edit/EditMode.test.js @@ -5,7 +5,6 @@ import { ADAPTER_EVENTS } from '../../../adapterEvents.js' import { createFakeMap, createFakeManager, createContainer, domEvent } from '../__helpers__/harness.js' import Style from 'ol/style/Style.js' import Polygon from 'ol/geom/Polygon.js' -import LineString from 'ol/geom/LineString.js' const RING = [[0, 0], [100, 0], [100, 100], [0, 100], [0, 0]] // square: 4 vertices, deletable diff --git a/plugins/draw/src/validation/liveStroke.test.js b/plugins/draw/src/validation/liveStroke.test.js index 6d8a22d4a..54956806f 100644 --- a/plugins/draw/src/validation/liveStroke.test.js +++ b/plugins/draw/src/validation/liveStroke.test.js @@ -147,5 +147,4 @@ describe('set / reset / destroy', () => { jest.runAllTimers() expect(onGeometryChange).not.toHaveBeenCalled() }) - }) From 90df8d497f0f2b6423911d610740200bbcdf1ab9 Mon Sep 17 00:00:00 2001 From: Dan Leech Date: Wed, 8 Jul 2026 15:27:58 +0100 Subject: [PATCH 54/89] govuk plugin updated for songle draw plugin --- assets/templates/draw-tools.njk | 4 ++-- govuk-prototype-kit.config.json | 5 +++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/assets/templates/draw-tools.njk b/assets/templates/draw-tools.njk index 2bb518f95..c64a9f655 100644 --- a/assets/templates/draw-tools.njk +++ b/assets/templates/draw-tools.njk @@ -33,7 +33,7 @@ - + diff --git a/demo/js/draw.js b/demo/js/draw.js index 3af47277a..312b90293 100755 --- a/demo/js/draw.js +++ b/demo/js/draw.js @@ -194,6 +194,16 @@ interactiveMap.on('map:ready', function (e) { interactiveMap.toggleButtonState('geometryActions', 'hidden', true) interactPlugin.disable() } + },{ + id: 'splitShape', + label: 'Split shape', + iconSvgContent: '', + isDisabled: true, + onClick: function (e) { + drawPlugin.split(selectedFeatureIds[0]) + interactiveMap.toggleButtonState('geometryActions', 'hidden', true) + interactPlugin.disable() + } },{ id: 'deleteFeature', label: 'Delete feature', @@ -206,6 +216,7 @@ interactiveMap.on('map:ready', function (e) { interactiveMap.toggleButtonState('drawPolygon', 'disabled', false) interactiveMap.toggleButtonState('drawLine', 'disabled', false) interactiveMap.toggleButtonState('editFeature', 'disabled', true) + interactiveMap.toggleButtonState('splitShape', 'disabled', true) interactiveMap.toggleButtonState('deleteFeature', 'disabled', true) } }] @@ -278,14 +289,36 @@ interactiveMap.on('interact:selectionchange', function (e) { const singleFeature = e.selectedFeatures.length === 1 const anyFeature = e.selectedFeatures.length > 0 const isDrawFeature = singleFeature && drawLayers.includes(e.selectedFeatures[0].layerId) + const isPolygon = singleFeature && e.selectedFeatures[0].geometryType === 'Polygon' const allDrawFeatures = anyFeature && e.selectedFeatures.every(function (f) { return drawLayers.includes(f.layerId) }) selectedFeatureIds = e.selectedFeatures.map(function (f) { return f.featureId }) interactiveMap.toggleButtonState('drawPolygon', 'disabled', !!singleFeature) interactiveMap.toggleButtonState('drawLine', 'disabled', !!singleFeature) interactiveMap.toggleButtonState('editFeature', 'disabled', !isDrawFeature) + interactiveMap.toggleButtonState('splitShape', 'disabled', !isPolygon) interactiveMap.toggleButtonState('deleteFeature', 'disabled', !allDrawFeatures) }) +interactiveMap.on('draw:split', function (e) { + console.log('draw:split', { originalFeatureId: e.originalFeatureId, newFeatures: e.featureCollection.features }) + + // Delete the original polygon + drawPlugin.deleteFeature([e.originalFeatureId]) + + // Add the two new split features with IDs based on the original + e.featureCollection.features.forEach(function (feature, index) { + const newId = e.originalFeatureId + (index === 0 ? '-a' : '-b') + drawPlugin.addFeature({ + id: newId, + type: feature.type, + geometry: feature.geometry, + properties: feature.properties + }) + }) + + interactPlugin.clear() +}) + interactiveMap.on('interact:markerchange', function (e) { // console.log('interact:markerchange', e) }) diff --git a/plugins/draw/src/adapters/maplibre/MaplibreDrawAdapter.js b/plugins/draw/src/adapters/maplibre/MaplibreDrawAdapter.js index 736b30684..b6b6280d9 100644 --- a/plugins/draw/src/adapters/maplibre/MaplibreDrawAdapter.js +++ b/plugins/draw/src/adapters/maplibre/MaplibreDrawAdapter.js @@ -259,12 +259,21 @@ export class MaplibreDrawAdapter { } } - isSnapEnabled () { return this._mapProvider.snapEnabled === true } + isSnapEnabled () { + return this._mapProvider.snapEnabled === true + } - setFeatureProperty (id, property, value) { this._draw.setFeatureProperty(id, property, value) } + setFeatureProperty (id, property, value) { + this._draw.setFeatureProperty(id, property, value) + } - on (type, handler) { this._bus.on(type, handler) } - off (type, handler) { this._bus.off(type, handler) } + on (type, handler) { + this._bus.on(type, handler) + } + + off (type, handler) { + this._bus.off(type, handler) + } _handleModeChange (e) { const DRAW_MODES = new Set(['draw_polygon', 'draw_line']) diff --git a/plugins/draw/src/api/split.js b/plugins/draw/src/api/split.js index 0dce6ca57..141780ad2 100644 --- a/plugins/draw/src/api/split.js +++ b/plugins/draw/src/api/split.js @@ -13,9 +13,10 @@ import { ADAPTER_EVENTS } from '../adapterEvents.js' * @param {string} featureId - ID of the polygon to split * @param {object} options - Options including snapLayers. */ -export const split = ({ appState, appConfig, pluginState, mapState, mapProvider }, featureId, options = {}) => { +export const split = ({ appState, appConfig, pluginState, mapState, mapProvider, services }, featureId, options = {}) => { const { dispatch } = pluginState const { draw } = mapProvider + const { eventBus } = services if (!draw) { return @@ -45,15 +46,21 @@ export const split = ({ appState, appConfig, pluginState, mapState, mapProvider const onSplitCreate = (geojsonFeature) => { draw.off(ADAPTER_EVENTS.CREATE, onSplitCreate) const featureCollection = splitPolygon(polygonFeature, geojsonFeature) - draw.setFeatureProperty('_splitter', 'splitter', featureCollection ? 'valid' : 'invalid') + dispatch({ type: 'SET_ACTION', payload: { name: 'split', isValid: !!featureCollection } }) + + if (featureCollection) { + eventBus.emit('draw:split', { + originalFeatureId: featureId, + featureCollection + }) + } } draw.on(ADAPTER_EVENTS.CREATE, onSplitCreate) // Real-time preview: update split validity as vertices are placed (ML only) const DEBOUNCE_MS = 50 const onGeometryChange = debounce((e) => { - // Ignore commit-level validation events (they carry `kind`, not `coordinates`). if (!e.coordinates || e.coordinates.length < 2) { return } diff --git a/plugins/draw/src/utils/spatial.js b/plugins/draw/src/utils/spatial.js index 24709ca13..b285a0fbc 100755 --- a/plugins/draw/src/utils/spatial.js +++ b/plugins/draw/src/utils/spatial.js @@ -65,11 +65,11 @@ function extendLine (line, extendDist = 1, units = 'meters') { */ const splitPolygon = (polygon, line) => { // Extend only start and end vertices - const extended = extendLine(line) // assume extendLine only touches start/end now + // const extended = extendLine(line) // assume extendLine only touches start/end now let result try { - result = polygonSplitter(polygon, extended) + result = polygonSplitter(polygon, line) } catch { return null } diff --git a/plugins/interact/src/hooks/useHighlightSync.js b/plugins/interact/src/hooks/useHighlightSync.js index 002a6bb7e..576870188 100755 --- a/plugins/interact/src/hooks/useHighlightSync.js +++ b/plugins/interact/src/hooks/useHighlightSync.js @@ -33,7 +33,6 @@ export const useHighlightSync = ({ const activeFeatures = listboxActiveItem ? [{ featureId: listboxActiveItem.featureId, layerId: listboxActiveItem.layerId, idProperty: listboxActiveItem.idProperty, geometry: listboxActiveItem.geometry }] : [] - console.log('[interact-highlight-sync] updateHighlightedFeatures: mapProvider.updateHighlightedFeatures =', !!mapProvider?.updateHighlightedFeatures, 'stylesMap =', stylesMap) mapProvider.updateHighlightedFeatures?.(selectedFeatures, activeFeatures, stylesMap) } diff --git a/plugins/interact/src/hooks/useInteractionHandlers.js b/plugins/interact/src/hooks/useInteractionHandlers.js index 7a0718a14..34a6897df 100755 --- a/plugins/interact/src/hooks/useInteractionHandlers.js +++ b/plugins/interact/src/hooks/useInteractionHandlers.js @@ -48,7 +48,7 @@ const useSelectionChangeEmitter = (eventBus, selectedFeatures, selectedMarkers) } eventBus.emit('interact:selectionchange', { - selectedFeatures: selectedFeatures.map(({ featureId, layerId, idProperty, properties }) => ({ featureId, layerId, idProperty, properties })), + selectedFeatures: selectedFeatures.map(({ featureId, layerId, idProperty, properties, geometry }) => ({ featureId, layerId, idProperty, properties, geometryType: geometry?.type })), selectedMarkers, contiguous: areAllContiguous(selectedFeatures) }) diff --git a/plugins/interact/src/hooks/useInteractionHandlers.test.js b/plugins/interact/src/hooks/useInteractionHandlers.test.js index 01e8d8de1..f583ec9f6 100644 --- a/plugins/interact/src/hooks/useInteractionHandlers.test.js +++ b/plugins/interact/src/hooks/useInteractionHandlers.test.js @@ -360,7 +360,7 @@ it('emits selectionchange when features are selected', () => { expect(deps.services.eventBus.emit).toHaveBeenCalledWith( 'interact:selectionchange', expect.objectContaining({ - selectedFeatures: [{ featureId: 'F1', layerId: 'l1', idProperty: 'id', properties: { name: 'A' } }], + selectedFeatures: [{ featureId: 'F1', layerId: 'l1', idProperty: 'id', properties: { name: 'A' }, geometryType: 'Point' }], selectedMarkers: [], contiguous: false }) diff --git a/plugins/interact/src/utils/buildStylesMap.js b/plugins/interact/src/utils/buildStylesMap.js index 00837b7e4..0f2bd8bfc 100755 --- a/plugins/interact/src/utils/buildStylesMap.js +++ b/plugins/interact/src/utils/buildStylesMap.js @@ -25,8 +25,6 @@ export const buildStylesMap = (dataLayers, mapStyle) => { return stylesMap } - console.log('[interact] buildStylesMap: mapStyle =', mapStyle.id, 'mapColorScheme =', mapStyle.mapColorScheme) - const scheme = THEME_COLORS[mapStyle.mapColorScheme] ?? THEME_COLORS.light const schemeActiveColor = mapStyle.activeColor ?? scheme.activeColor const schemeSelectedColor = mapStyle.selectedColor ?? scheme.selectedColor diff --git a/providers/maplibre/src/utils/highlightFeatures.js b/providers/maplibre/src/utils/highlightFeatures.js index 7c3286e81..2fdaaf09b 100755 --- a/providers/maplibre/src/utils/highlightFeatures.js +++ b/providers/maplibre/src/utils/highlightFeatures.js @@ -241,7 +241,6 @@ export function updateHighlightedFeatures ({ LngLatBounds, map, selectedFeatures if (!map) { return null } - console.log('[maplibre-highlight] updateHighlightedFeatures called with selectedFeatures:', selectedFeatures?.length, 'activeFeatures:', activeFeatures?.length, 'stylesMap keys:', Object.keys(stylesMap || {})) // Active cursor features — rendered first so selected layers appear on top if (activeFeatures?.length) { applyFeatureHighlights(map, activeFeatures, stylesMap, ACTIVE_PREFIX, getActiveImageId) From e87cbf8686f102017d33552aeb96f4d354ed852b Mon Sep 17 00:00:00 2001 From: Dan Leech Date: Thu, 9 Jul 2026 13:47:24 +0100 Subject: [PATCH 56/89] Split extend line fix --- demo/js/draw.js | 8 ++++---- plugins/draw/src/api/split.js | 2 +- plugins/draw/src/defaults.js | 4 ++-- plugins/draw/src/utils/spatial.js | 29 +++++++---------------------- 4 files changed, 14 insertions(+), 29 deletions(-) diff --git a/demo/js/draw.js b/demo/js/draw.js index 312b90293..9e696e5ca 100755 --- a/demo/js/draw.js +++ b/demo/js/draw.js @@ -253,7 +253,7 @@ interactiveMap.on('draw:ready', function () { }) interactiveMap.on('draw:started', function (e) { - console.log('draw:started') + // console.log('draw:started') interactPlugin.disable() }) @@ -262,7 +262,7 @@ interactiveMap.on('draw:editstart', function (e) { }) interactiveMap.on('draw:created', function (e) { - console.log('draw:created', e) + // console.log('draw:created', e) interactiveMap.toggleButtonState('geometryActions', 'hidden', false) interactPlugin.enable() }) @@ -272,7 +272,7 @@ interactiveMap.on('draw:updated', function (e) { }) interactiveMap.on('draw:edited', function (e) { - console.log('draw:edited', e) // Should be editcomplete + // console.log('draw:edited', e) interactiveMap.toggleButtonState('geometryActions', 'hidden', false) interactPlugin.enable() }) @@ -300,7 +300,7 @@ interactiveMap.on('interact:selectionchange', function (e) { }) interactiveMap.on('draw:split', function (e) { - console.log('draw:split', { originalFeatureId: e.originalFeatureId, newFeatures: e.featureCollection.features }) + // console.log('draw:split', { originalFeatureId: e.originalFeatureId, newFeatures: e.featureCollection.features }) // Delete the original polygon drawPlugin.deleteFeature([e.originalFeatureId]) diff --git a/plugins/draw/src/api/split.js b/plugins/draw/src/api/split.js index 141780ad2..62a435f9d 100644 --- a/plugins/draw/src/api/split.js +++ b/plugins/draw/src/api/split.js @@ -59,7 +59,7 @@ export const split = ({ appState, appConfig, pluginState, mapState, mapProvider, draw.on(ADAPTER_EVENTS.CREATE, onSplitCreate) // Real-time preview: update split validity as vertices are placed (ML only) - const DEBOUNCE_MS = 50 + const DEBOUNCE_MS = 10 const onGeometryChange = debounce((e) => { if (!e.coordinates || e.coordinates.length < 2) { return diff --git a/plugins/draw/src/defaults.js b/plugins/draw/src/defaults.js index b0f564f59..47c484d52 100644 --- a/plugins/draw/src/defaults.js +++ b/plugins/draw/src/defaults.js @@ -15,8 +15,8 @@ export const COLORS = { editMidpoint: { light: BLUE, dark: WHITE }, editHalo: { light: WHITE, dark: BLACK }, editActive: { light: BLACK, dark: WHITE }, - splitInvalid: BLUE, - splitValid: BLUE, + splitInvalid: { light: BLUE, dark: WHITE }, + splitValid: { light: BLUE, dark: WHITE }, invalidStroke: { light: BLUE, dark: WHITE }, shapeStroke: RED, shapeFill: MID_ORANGE, diff --git a/plugins/draw/src/utils/spatial.js b/plugins/draw/src/utils/spatial.js index b285a0fbc..3d871d16e 100755 --- a/plugins/draw/src/utils/spatial.js +++ b/plugins/draw/src/utils/spatial.js @@ -19,40 +19,25 @@ import { */ /** - * Extend a LineString at endpoints AND intermediate vertices. - * For intermediate vertices on the polygon boundary, this creates small - * extensions that ensure polygon-splitter recognizes them as crossing points. + * Extend a LineString at endpoints. * * @param {Feature} line * @param {number} extendDist (distance to extend in Turf units) */ function extendLine (line, extendDist = 1, units = 'meters') { - const coords = line.geometry.coordinates - const result = [] + const coords = line.geometry.coordinates.map(c => [...c]) // Extend start point backward const startBearing = turfBearing(coords[1], coords[0]) const newStart = turfDestination(coords[0], extendDist, startBearing, { units }) - result.push(newStart.geometry.coordinates) - - // Process each vertex - for (let i = 0; i < coords.length; i++) { - if (i > 0 && i < coords.length - 1) { - // Intermediate vertex: add extension past it (creates spike for boundary crossing) - const incomingBearing = turfBearing(coords[i - 1], coords[i]) - const pastPt = turfDestination(coords[i], extendDist, incomingBearing, { units }) - result.push(pastPt.geometry.coordinates) - } - - result.push(coords[i]) - } + coords[0] = newStart.geometry.coordinates // Extend end point forward const endBearing = turfBearing(coords[coords.length - 2], coords[coords.length - 1]) const newEnd = turfDestination(coords[coords.length - 1], extendDist, endBearing, { units }) - result.push(newEnd.geometry.coordinates) + coords[coords.length - 1] = newEnd.geometry.coordinates - return turfLineString(result) + return turfLineString(coords) } /** @@ -65,11 +50,11 @@ function extendLine (line, extendDist = 1, units = 'meters') { */ const splitPolygon = (polygon, line) => { // Extend only start and end vertices - // const extended = extendLine(line) // assume extendLine only touches start/end now + const extended = extendLine(line) // assume extendLine only touches start/end now let result try { - result = polygonSplitter(polygon, line) + result = polygonSplitter(polygon, extended) } catch { return null } From 30a4402573f85d5ffb1b270db77fb38c4dca1890 Mon Sep 17 00:00:00 2001 From: Dan Leech Date: Thu, 9 Jul 2026 13:53:19 +0100 Subject: [PATCH 57/89] Split tests updated --- plugins/draw/src/api/split.test.js | 52 ++++++++++++++---------------- 1 file changed, 25 insertions(+), 27 deletions(-) diff --git a/plugins/draw/src/api/split.test.js b/plugins/draw/src/api/split.test.js index 8c2c03b18..3769fe20a 100644 --- a/plugins/draw/src/api/split.test.js +++ b/plugins/draw/src/api/split.test.js @@ -6,13 +6,13 @@ jest.mock('../utils/debounce.js', () => ({ debounce: jest.fn((fn) => fn) })) const makeContext = (overrides = {}) => { const dispatch = jest.fn() + const eventBus = { emit: jest.fn() } const draw = { get: jest.fn(() => ({ id: 'poly' })), setSnapLayers: jest.fn(), changeMode: jest.fn(), on: jest.fn(), off: jest.fn(), - setFeatureProperty: jest.fn(), isSnapEnabled: jest.fn(() => true) } const context = { @@ -21,9 +21,10 @@ const makeContext = (overrides = {}) => { pluginState: { dispatch }, mapState: { crossHair: true }, mapProvider: { draw }, + services: { eventBus }, ...overrides } - return { context, dispatch, draw } + return { context, dispatch, draw, eventBus } } const handlerFor = (draw, event) => draw.on.mock.calls.find(([name]) => name === event)?.[1] @@ -32,16 +33,17 @@ beforeEach(() => jest.clearAllMocks()) describe('split', () => { test('does nothing when there is no draw instance', () => { - const { context, draw } = makeContext({ mapProvider: { draw: null } }) - split(context, 'poly') - expect(draw.changeMode).not.toHaveBeenCalled() + const { context, dispatch } = makeContext({ mapProvider: { draw: null } }) + expect(() => split(context, 'poly')).not.toThrow() + expect(dispatch).not.toHaveBeenCalled() }) test('sets up the splitter line drawing and registers listeners', () => { const { context, dispatch, draw } = makeContext() - split(context, 'poly', {}) + split(context, 'poly') + expect(draw._geometryValidator).toBeNull() expect(draw.setSnapLayers).toHaveBeenCalledWith(['stroke-inactive.cold']) expect(dispatch).toHaveBeenCalledWith({ type: 'SET_HAS_SNAP_LAYERS', payload: true }) expect(draw.changeMode).toHaveBeenCalledWith('draw_line', expect.objectContaining({ @@ -56,10 +58,7 @@ describe('split', () => { expect(draw.on).toHaveBeenCalledWith('geometrychange', expect.any(Function)) expect(dispatch).toHaveBeenCalledWith({ type: 'SET_MODE', payload: 'draw_line' }) expect(dispatch).toHaveBeenCalledWith({ type: 'SET_ACTION', payload: { name: 'split' } }) - - const opts = draw.changeMode.mock.calls[0][1] - expect(opts.getSnapEnabled()).toBe(true) - expect(draw.isSnapEnabled).toHaveBeenCalled() + expect(draw.changeMode.mock.calls[0][1].getSnapEnabled()).toBe(true) }) test('merges option snapLayers with the outline layer', () => { @@ -68,43 +67,43 @@ describe('split', () => { expect(draw.setSnapLayers).toHaveBeenCalledWith(['stroke-inactive.cold', 'extra']) }) - test('finalising the line computes a valid split', () => { - const { context, dispatch, draw } = makeContext() + test('finalising the line computes a valid split and emits the result', () => { + const { context, dispatch, draw, eventBus } = makeContext() const polygonFeature = { id: 'poly' } + const featureCollection = { type: 'FeatureCollection' } draw.get.mockReturnValue(polygonFeature) - splitPolygon.mockReturnValue({ type: 'FeatureCollection' }) + splitPolygon.mockReturnValue(featureCollection) - split(context, 'poly', {}) + split(context, 'poly') const onCreate = handlerFor(draw, 'create') const geojson = { id: 'line' } onCreate(geojson) expect(draw.off).toHaveBeenCalledWith('create', onCreate) expect(splitPolygon).toHaveBeenCalledWith(polygonFeature, geojson) - expect(draw.setFeatureProperty).toHaveBeenCalledWith('_splitter', 'splitter', 'valid') expect(dispatch).toHaveBeenCalledWith({ type: 'SET_ACTION', payload: { name: 'split', isValid: true } }) + expect(eventBus.emit).toHaveBeenCalledWith('draw:split', { originalFeatureId: 'poly', featureCollection }) }) - test('finalising the line computes an invalid split', () => { - const { context, dispatch, draw } = makeContext() + test('finalising the line computes an invalid split and does not emit', () => { + const { context, dispatch, draw, eventBus } = makeContext() splitPolygon.mockReturnValue(null) - split(context, 'poly', {}) + split(context, 'poly') handlerFor(draw, 'create')({ id: 'line' }) - expect(draw.setFeatureProperty).toHaveBeenCalledWith('_splitter', 'splitter', 'invalid') expect(dispatch).toHaveBeenCalledWith({ type: 'SET_ACTION', payload: { name: 'split', isValid: false } }) + expect(eventBus.emit).not.toHaveBeenCalled() }) test('geometry change updates validity and re-renders', () => { const { context, dispatch, draw } = makeContext() splitPolygon.mockReturnValue({ type: 'FeatureCollection' }) - split(context, 'poly', {}) - const onGeometryChange = handlerFor(draw, 'geometrychange') + split(context, 'poly') const render = jest.fn() const e = { coordinates: [[0, 0], [1, 1]], properties: {}, ctx: { store: { render } } } - onGeometryChange(e) + handlerFor(draw, 'geometrychange')(e) expect(e.properties.splitter).toBe('valid') expect(render).toHaveBeenCalled() @@ -113,7 +112,7 @@ describe('split', () => { test('geometry change ignores lines with fewer than two coordinates', () => { const { context, draw } = makeContext() - split(context, 'poly', {}) + split(context, 'poly') splitPolygon.mockClear() handlerFor(draw, 'geometrychange')({ coordinates: [[0, 0]], properties: {} }) @@ -125,12 +124,11 @@ describe('split', () => { const { context, dispatch, draw } = makeContext() splitPolygon.mockReturnValue(null) - split(context, 'poly', {}) - const onGeometryChange = handlerFor(draw, 'geometrychange') + split(context, 'poly') const e = { coordinates: [[0, 0], [1, 1]], properties: {} } - expect(() => onGeometryChange(e)).not.toThrow() + expect(() => handlerFor(draw, 'geometrychange')(e)).not.toThrow() expect(e.properties.splitter).toBe('invalid') expect(dispatch).toHaveBeenCalledWith({ type: 'SET_ACTION', payload: { name: 'split', isValid: false } }) }) -}) +}) \ No newline at end of file From 7d25664addbf374a7f23b520a779267dc080eb1f Mon Sep 17 00:00:00 2001 From: Dan Leech Date: Thu, 9 Jul 2026 14:07:19 +0100 Subject: [PATCH 58/89] Snap tests updated --- .../adapters/maplibre/snap/snapInstance.test.js | 15 ++++++++++++++- plugins/draw/src/utils/spatial.test.js | 8 ++++---- 2 files changed, 18 insertions(+), 5 deletions(-) diff --git a/plugins/draw/src/adapters/maplibre/snap/snapInstance.test.js b/plugins/draw/src/adapters/maplibre/snap/snapInstance.test.js index 78dba5496..02399ce89 100644 --- a/plugins/draw/src/adapters/maplibre/snap/snapInstance.test.js +++ b/plugins/draw/src/adapters/maplibre/snap/snapInstance.test.js @@ -67,7 +67,8 @@ describe('createSnapInstance', () => { test('exposes an externally-controlled status via setSnapStatus', () => { const map = makeMap() - const snap = createSnapInstance(map, {}, {}, { ...config, status: false }) + const draw = { getMode: jest.fn(() => 'draw_polygon') } + const snap = createSnapInstance(map, draw, {}, { ...config, status: false }) expect(snap.status).toBe(false) snap.status = true // library write ignored @@ -76,6 +77,18 @@ describe('createSnapInstance', () => { expect(snap.status).toBe(true) }) + test('status is false outside draw/edit modes even when the toggle is on', () => { + const map = makeMap() + const draw = { getMode: jest.fn(() => 'simple_select') } + const snap = createSnapInstance(map, draw, {}, { ...config, status: false }) + + snap.setSnapStatus(true) + expect(snap.status).toBe(false) + + draw.getMode.mockReturnValue('edit_vertex') + expect(snap.status).toBe(true) + }) + test('setSnapLayers overrides, resets and ignores invalid input', () => { const map = makeMap() const snap = createSnapInstance(map, {}, {}, { ...config, layers: ['a'] }) diff --git a/plugins/draw/src/utils/spatial.test.js b/plugins/draw/src/utils/spatial.test.js index e13d7c9f1..35829bd19 100644 --- a/plugins/draw/src/utils/spatial.test.js +++ b/plugins/draw/src/utils/spatial.test.js @@ -41,12 +41,12 @@ describe('extendLine', () => { const line = { geometry: { coordinates: [[0, 0], [0, 1]] } } const result = extendLine(line) expect(result.geometry.type).toBe('LineString') - expect(result.geometry.coordinates).toHaveLength(4) + expect(result.geometry.coordinates).toHaveLength(2) }) - test('adds a spike at each intermediate vertex', () => { + test('extends only the endpoints of a multi-point line', () => { const line = { geometry: { coordinates: [[0, 0], [0, 1], [0, 2]] } } - expect(extendLine(line).geometry.coordinates).toHaveLength(6) + expect(extendLine(line).geometry.coordinates).toHaveLength(3) }) }) @@ -172,4 +172,4 @@ describe('spatialNavigate', () => { test('falls back to the start point when no pixel is in the quadrant', () => { expect(spatialNavigate(start, [[0, 0]], 'ArrowUp')).toBe(0) }) -}) +}) \ No newline at end of file From 165ed01b0f682b65db6bfeab14bbb3308585ced6 Mon Sep 17 00:00:00 2001 From: Dan Leech Date: Thu, 9 Jul 2026 14:10:04 +0100 Subject: [PATCH 59/89] Snap indicators and split line fix --- .../adapters/maplibre/MaplibreDrawAdapter.js | 11 ++++++- .../maplibre/MaplibreDrawAdapter.test.js | 10 +++++-- .../adapters/maplibre/snap/snapInstance.js | 18 ++++++++++-- plugins/draw/src/api/split.js | 17 ++++++++++- plugins/draw/src/api/split.test.js | 29 +++++++++++++++++++ 5 files changed, 78 insertions(+), 7 deletions(-) diff --git a/plugins/draw/src/adapters/maplibre/MaplibreDrawAdapter.js b/plugins/draw/src/adapters/maplibre/MaplibreDrawAdapter.js index b6b6280d9..3a84ff488 100644 --- a/plugins/draw/src/adapters/maplibre/MaplibreDrawAdapter.js +++ b/plugins/draw/src/adapters/maplibre/MaplibreDrawAdapter.js @@ -135,6 +135,10 @@ export class MaplibreDrawAdapter { this._livePlacement.set(false) } this._draw.changeMode(name, options) + // The underlying mapbox-gl-draw control's public changeMode API is silent by + // default (it never fires 'draw.modechange'), so every mode change requested + // through this adapter must drive the same cleanup manually. + this._handleModeChange({ mode: name }) } // Live invalid-stroke driver: called on every rubber-band move (draw) and vertex @@ -168,11 +172,15 @@ export class MaplibreDrawAdapter { this._mapProvider.undoStack?.clear() const mode = this._draw.getMode() if (mode === 'edit_vertex' && this._editingFeatureId) { + // Leaving edit_vertex here — hide immediately rather than waiting on the + // async disable() the EDIT_FINISH handler fires later (see changeMode()). + this._handleModeChange({ mode: 'disabled' }) this._map.fire(CUSTOM_DRAW_EVENTS.EDIT_FINISH, { features: [this._draw.get(this._editingFeatureId)] }) return } if (mode === 'draw_polygon' || mode === 'draw_line') { this._draw.changeMode('disabled') + this._handleModeChange({ mode: 'disabled' }) } } @@ -180,6 +188,7 @@ export class MaplibreDrawAdapter { this._mapProvider.undoStack?.clear() this._draw.trash() this._draw.changeMode('disabled') + this._handleModeChange({ mode: 'disabled' }) } undo () { @@ -276,7 +285,7 @@ export class MaplibreDrawAdapter { } _handleModeChange (e) { - const DRAW_MODES = new Set(['draw_polygon', 'draw_line']) + const DRAW_MODES = new Set(['draw_polygon', 'draw_line', 'edit_vertex']) if (!DRAW_MODES.has(e.mode)) { clearSnapIndicator(getSnapInstance(this._map), this._map) } diff --git a/plugins/draw/src/adapters/maplibre/MaplibreDrawAdapter.test.js b/plugins/draw/src/adapters/maplibre/MaplibreDrawAdapter.test.js index 8759e4d81..fab043cb6 100644 --- a/plugins/draw/src/adapters/maplibre/MaplibreDrawAdapter.test.js +++ b/plugins/draw/src/adapters/maplibre/MaplibreDrawAdapter.test.js @@ -572,12 +572,12 @@ describe('setSnapLayers', () => { }) describe('_handleModeChange', () => { - test('clears the snap indicator when leaving a draw mode', () => { + test('clears the snap indicator when leaving to a non-draw mode', () => { const { map } = setup() const snap = { id: 'snap' } getSnapInstance.mockReturnValue(snap) - onHandler(map, MAPBOX_DRAW_EVENTS.MODE_CHANGE)({ mode: 'edit_vertex' }) + onHandler(map, MAPBOX_DRAW_EVENTS.MODE_CHANGE)({ mode: 'simple_select' }) expect(clearSnapIndicator).toHaveBeenCalledWith(snap, map) }) @@ -587,6 +587,12 @@ describe('_handleModeChange', () => { onHandler(map, MAPBOX_DRAW_EVENTS.MODE_CHANGE)({ mode: 'draw_polygon' }) expect(clearSnapIndicator).not.toHaveBeenCalled() }) + + test('keeps the snap indicator while in edit_vertex mode', () => { + const { map } = setup() + onHandler(map, MAPBOX_DRAW_EVENTS.MODE_CHANGE)({ mode: 'edit_vertex' }) + expect(clearSnapIndicator).not.toHaveBeenCalled() + }) }) describe('_handleStyleData', () => { diff --git a/plugins/draw/src/adapters/maplibre/snap/snapInstance.js b/plugins/draw/src/adapters/maplibre/snap/snapInstance.js index 8ea7da6fd..11a4af885 100644 --- a/plugins/draw/src/adapters/maplibre/snap/snapInstance.js +++ b/plugins/draw/src/adapters/maplibre/snap/snapInstance.js @@ -12,16 +12,28 @@ function cleanupOldSnap (map) { } } +// Snap indicators (and all snap processing) are only meaningful while actively +// drawing or editing vertices — outside these modes there is nothing to snap to. +const SNAP_ACTIVE_MODES = new Set(['draw_polygon', 'draw_line', 'edit_vertex']) + /** * Externally-controlled status: ignore library writes so status is only changed * via setSnapStatus(). The library otherwise sets status=true on mode/selection change. + * + * Also gated on the current draw mode: the library's own mousemove listener + * (added once in its constructor) calls snapToClosestPoint on every mouse move + * regardless of draw mode, repainting the snap-helper-circle layer whenever + * status is true. Without this gate, the marker reappears on the very next + * mouse move after leaving draw/edit mode, since mapbox-gl-draw's public + * changeMode API is silent by default and rarely gives us a mode-change event + * to react to. */ -function defineControlledStatus (snap, initialStatus) { +function defineControlledStatus (snap, initialStatus, draw) { let controlledStatus = initialStatus Object.defineProperty(snap, 'status', { get () { // nosonar - return controlledStatus + return controlledStatus && SNAP_ACTIVE_MODES.has(draw.getMode()) }, set () { // nosonar // intentionally empty: library writes are ignored @@ -70,7 +82,7 @@ export function createSnapInstance (map, draw, source, config) { onSnapped: config.onSnapped }) - defineControlledStatus(snap, config.status) + defineControlledStatus(snap, config.status, draw) configureSnapLayers(snap, config.layers) // Apply any pending snap layers that were set before the instance was ready diff --git a/plugins/draw/src/api/split.js b/plugins/draw/src/api/split.js index 62a435f9d..2c9b6c2d0 100644 --- a/plugins/draw/src/api/split.js +++ b/plugins/draw/src/api/split.js @@ -42,9 +42,17 @@ export const split = ({ appState, appConfig, pluginState, mapState, mapProvider, properties: { splitter: 'invalid' } }) + // Both listeners are scoped to this one splitter-line session — leaving either + // registered past that would leak into whatever the user draws or edits next. + const stopListening = () => { + draw.off(ADAPTER_EVENTS.CREATE, onSplitCreate) + draw.off(ADAPTER_EVENTS.CANCEL, onSplitCancel) + draw.off(ADAPTER_EVENTS.GEOMETRY_CHANGE, onGeometryChange) + } + // One-shot: compute split result once the line is finalised const onSplitCreate = (geojsonFeature) => { - draw.off(ADAPTER_EVENTS.CREATE, onSplitCreate) + stopListening() const featureCollection = splitPolygon(polygonFeature, geojsonFeature) dispatch({ type: 'SET_ACTION', payload: { name: 'split', isValid: !!featureCollection } }) @@ -56,7 +64,14 @@ export const split = ({ appState, appConfig, pluginState, mapState, mapProvider, }) } } + + // The splitter line was abandoned (e.g. Escape) — nothing to compute, just stop listening. + const onSplitCancel = () => { + stopListening() + } + draw.on(ADAPTER_EVENTS.CREATE, onSplitCreate) + draw.on(ADAPTER_EVENTS.CANCEL, onSplitCancel) // Real-time preview: update split validity as vertices are placed (ML only) const DEBOUNCE_MS = 10 diff --git a/plugins/draw/src/api/split.test.js b/plugins/draw/src/api/split.test.js index 3769fe20a..67244959a 100644 --- a/plugins/draw/src/api/split.test.js +++ b/plugins/draw/src/api/split.test.js @@ -80,11 +80,40 @@ describe('split', () => { onCreate(geojson) expect(draw.off).toHaveBeenCalledWith('create', onCreate) + expect(draw.off).toHaveBeenCalledWith('cancel', expect.any(Function)) + expect(draw.off).toHaveBeenCalledWith('geometrychange', expect.any(Function)) expect(splitPolygon).toHaveBeenCalledWith(polygonFeature, geojson) expect(dispatch).toHaveBeenCalledWith({ type: 'SET_ACTION', payload: { name: 'split', isValid: true } }) expect(eventBus.emit).toHaveBeenCalledWith('draw:split', { originalFeatureId: 'poly', featureCollection }) }) + test('cancelling the splitter line stops listening without computing a split', () => { + const { context, draw } = makeContext() + + split(context, 'poly') + const onCancel = handlerFor(draw, 'cancel') + onCancel() + + expect(draw.off).toHaveBeenCalledWith('create', expect.any(Function)) + expect(draw.off).toHaveBeenCalledWith('cancel', onCancel) + expect(draw.off).toHaveBeenCalledWith('geometrychange', expect.any(Function)) + expect(splitPolygon).not.toHaveBeenCalled() + }) + + test('a stale geometrychange listener cannot fire after the split line is created', () => { + const { context, draw } = makeContext() + splitPolygon.mockReturnValue({ type: 'FeatureCollection' }) + + split(context, 'poly') + const onCreate = handlerFor(draw, 'create') + onCreate({ id: 'line' }) + + // Real draw.off would deregister the handler; confirm split.js requested it + // for every listener it registered, so none can leak into a later session. + const offEvents = draw.off.mock.calls.map(([name]) => name) + expect(offEvents).toEqual(expect.arrayContaining(['create', 'cancel', 'geometrychange'])) + }) + test('finalising the line computes an invalid split and does not emit', () => { const { context, dispatch, draw, eventBus } = makeContext() splitPolygon.mockReturnValue(null) From c348a7cc907ee06ede5a62c1763aa759998bbc9f Mon Sep 17 00:00:00 2001 From: Dan Leech Date: Thu, 9 Jul 2026 14:53:29 +0100 Subject: [PATCH 60/89] Split done button disabling --- plugins/draw/src/api/split.js | 73 ++++++++++++++---- plugins/draw/src/api/split.test.js | 116 ++++++++++++++++++++++++++--- 2 files changed, 166 insertions(+), 23 deletions(-) diff --git a/plugins/draw/src/api/split.js b/plugins/draw/src/api/split.js index 2c9b6c2d0..f0cb091e0 100644 --- a/plugins/draw/src/api/split.js +++ b/plugins/draw/src/api/split.js @@ -2,6 +2,61 @@ import { splitPolygon } from '../utils/spatial.js' import { debounce } from '../utils/debounce.js' import { ADAPTER_EVENTS } from '../adapterEvents.js' +const DEBOUNCE_MS = 10 + +// Recompute split validity and re-apply the same gate normal draw/edit uses: +// disables Done (manifest.js enableWhen reads pluginState.geometryValid) and blocks +// the double-click/click-vertex finish gesture (drawMode/clickHandlers.js reads +// map._drawGeometryValid) while the line wouldn't produce a valid split. Only uses +// the shared, engine-agnostic adapter interface — no mapbox-gl-draw internals. +const applySplitValidity = ({ draw, dispatch, polygonFeature, coordinates }) => { + const lineFeature = { id: '_splitter', geometry: { type: 'LineString', coordinates } } + const featureCollection = splitPolygon(polygonFeature, lineFeature) + const isValid = !!featureCollection + draw.setFeatureProperty('_splitter', 'splitter', isValid ? 'valid' : 'invalid') + dispatch({ type: 'SET_ACTION', payload: { name: 'split', isValid } }) + dispatch({ type: 'SET_GEOMETRY_VALID', payload: isValid }) + draw.setGeometryValid(isValid) +} + +// Real-time preview + completion gate as the splitter line is drawn (ML only — +// setFeatureProperty is a no-op on the OL adapter, per its documented interface). +// +// events.js's shared onGeometryChange handler also reacts to every commit-level +// (kind-ful) geometrychange event and — since split nulls out the user validator +// (see split() below) — always marks it valid via the default rules, clobbering +// split's own gate. DrawInit's effect re-attaches that shared handler on every +// pluginState change (detach+reattach reorders the event bus's listener Set), so +// which handler runs first for a given event isn't stable — this can't rely on +// registration order to "win". Instead the correction is deferred one more tick: +// whatever events.js did already ran synchronously by the time this fires, so +// this is always the final word regardless of ordering. +const createGeometryChangeHandler = ({ draw, dispatch, polygonFeature, isStopped }) => { + const apply = (coordinates) => applySplitValidity({ draw, dispatch, polygonFeature, coordinates }) + const debouncedPreview = debounce(apply, DEBOUNCE_MS) + + return (e) => { + if (e?.kind) { + debouncedPreview.cancel?.() + // Commit-level events carry { feature, kind, vertexIndex } per the shared + // adapter contract (adapterEvents.js) rather than a top-level coordinates. + const coordinates = e.feature?.geometry?.coordinates + if (!coordinates || coordinates.length < 2) { + return + } + setTimeout(() => { + if (isStopped()) { return } + apply(coordinates) + }, 0) + return + } + if (!e.coordinates || e.coordinates.length < 2) { + return + } + debouncedPreview(e.coordinates) + } +} + /** * Start drawing a split line for a polygon. * @@ -44,7 +99,11 @@ export const split = ({ appState, appConfig, pluginState, mapState, mapProvider, // Both listeners are scoped to this one splitter-line session — leaving either // registered past that would leak into whatever the user draws or edits next. + // Also guards the deferred correction in createGeometryChangeHandler: once true, + // a stale setTimeout callback from a since-ended session must not touch shared state. + let stopped = false const stopListening = () => { + stopped = true draw.off(ADAPTER_EVENTS.CREATE, onSplitCreate) draw.off(ADAPTER_EVENTS.CANCEL, onSplitCancel) draw.off(ADAPTER_EVENTS.GEOMETRY_CHANGE, onGeometryChange) @@ -73,19 +132,7 @@ export const split = ({ appState, appConfig, pluginState, mapState, mapProvider, draw.on(ADAPTER_EVENTS.CREATE, onSplitCreate) draw.on(ADAPTER_EVENTS.CANCEL, onSplitCancel) - // Real-time preview: update split validity as vertices are placed (ML only) - const DEBOUNCE_MS = 10 - const onGeometryChange = debounce((e) => { - if (!e.coordinates || e.coordinates.length < 2) { - return - } - const lineFeature = { id: '_splitter', geometry: { type: 'LineString', coordinates: e.coordinates } } - const featureCollection = splitPolygon(polygonFeature, lineFeature) - const isValid = !!featureCollection - e.properties.splitter = isValid ? 'valid' : 'invalid' - e.ctx?.store?.render() - dispatch({ type: 'SET_ACTION', payload: { name: 'split', isValid } }) - }, DEBOUNCE_MS) + const onGeometryChange = createGeometryChangeHandler({ draw, dispatch, polygonFeature, isStopped: () => stopped }) draw.on(ADAPTER_EVENTS.GEOMETRY_CHANGE, onGeometryChange) dispatch({ type: 'SET_MODE', payload: 'draw_line' }) diff --git a/plugins/draw/src/api/split.test.js b/plugins/draw/src/api/split.test.js index 67244959a..e64c2a88f 100644 --- a/plugins/draw/src/api/split.test.js +++ b/plugins/draw/src/api/split.test.js @@ -13,7 +13,9 @@ const makeContext = (overrides = {}) => { changeMode: jest.fn(), on: jest.fn(), off: jest.fn(), - isSnapEnabled: jest.fn(() => true) + isSnapEnabled: jest.fn(() => true), + setGeometryValid: jest.fn(), + setFeatureProperty: jest.fn() } const context = { appState: { layoutRefs: { viewportRef: { current: 'viewport' } }, interfaceType: 'mouse' }, @@ -125,18 +127,33 @@ describe('split', () => { expect(eventBus.emit).not.toHaveBeenCalled() }) - test('geometry change updates validity and re-renders', () => { + test('geometry change updates validity via the adapter only — no mapbox-gl-draw internals', () => { const { context, dispatch, draw } = makeContext() splitPolygon.mockReturnValue({ type: 'FeatureCollection' }) split(context, 'poly') - const render = jest.fn() - const e = { coordinates: [[0, 0], [1, 1]], properties: {}, ctx: { store: { render } } } + const e = { coordinates: [[0, 0], [1, 1]] } handlerFor(draw, 'geometrychange')(e) - expect(e.properties.splitter).toBe('valid') - expect(render).toHaveBeenCalled() + expect(draw.setFeatureProperty).toHaveBeenCalledWith('_splitter', 'splitter', 'valid') expect(dispatch).toHaveBeenCalledWith({ type: 'SET_ACTION', payload: { name: 'split', isValid: true } }) + // A valid split line must allow completion: Done enabled (pluginState.geometryValid) + // and the double-click/click-vertex finish gesture unblocked (map._drawGeometryValid). + expect(dispatch).toHaveBeenCalledWith({ type: 'SET_GEOMETRY_VALID', payload: true }) + expect(draw.setGeometryValid).toHaveBeenCalledWith(true) + }) + + test('an invalid split line blocks completion: disables Done and the finish gesture', () => { + const { context, dispatch, draw } = makeContext() + splitPolygon.mockReturnValue(null) + + split(context, 'poly') + const e = { coordinates: [[0, 0], [1, 1]] } + handlerFor(draw, 'geometrychange')(e) + + expect(draw.setFeatureProperty).toHaveBeenCalledWith('_splitter', 'splitter', 'invalid') + expect(dispatch).toHaveBeenCalledWith({ type: 'SET_GEOMETRY_VALID', payload: false }) + expect(draw.setGeometryValid).toHaveBeenCalledWith(false) }) test('geometry change ignores lines with fewer than two coordinates', () => { @@ -144,20 +161,99 @@ describe('split', () => { split(context, 'poly') splitPolygon.mockClear() - handlerFor(draw, 'geometrychange')({ coordinates: [[0, 0]], properties: {} }) + handlerFor(draw, 'geometrychange')({ coordinates: [[0, 0]] }) expect(splitPolygon).not.toHaveBeenCalled() }) - test('geometry change marks invalid splits and tolerates a missing render context', () => { + // events.js's shared onGeometryChange also reacts to this commit event and — since + // split nulls out the user validator — always marks it valid via the default rules, + // clobbering split's own gate. DrawInit re-attaches that shared handler on every + // pluginState change, so listener order on the bus isn't stable; the correction is + // deferred one tick (a real setTimeout) so it's always the final word regardless. + describe('commit-level (kind-ful) geometry change', () => { + beforeEach(() => jest.useFakeTimers()) + afterEach(() => jest.useRealTimers()) + + test('re-validates from a valid split after the deferred tick', () => { + const { context, dispatch, draw } = makeContext() + splitPolygon.mockReturnValue({ type: 'FeatureCollection' }) + + split(context, 'poly') + const commitEvent = { + kind: 'add', + vertexIndex: 1, + feature: { type: 'Feature', geometry: { type: 'LineString', coordinates: [[0, 0], [1, 1]] }, properties: {} } + } + handlerFor(draw, 'geometrychange')(commitEvent) + jest.runAllTimers() + + expect(splitPolygon).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ + geometry: { type: 'LineString', coordinates: [[0, 0], [1, 1]] } + })) + expect(dispatch).toHaveBeenCalledWith({ type: 'SET_GEOMETRY_VALID', payload: true }) + expect(draw.setGeometryValid).toHaveBeenCalledWith(true) + }) + + test('re-validates from an invalid split after the deferred tick', () => { + const { context, dispatch, draw } = makeContext() + splitPolygon.mockReturnValue(null) + + split(context, 'poly') + handlerFor(draw, 'geometrychange')({ + kind: 'add', + feature: { type: 'Feature', geometry: { type: 'LineString', coordinates: [[0, 0], [1, 1]] }, properties: {} } + }) + jest.runAllTimers() + + expect(dispatch).toHaveBeenCalledWith({ type: 'SET_GEOMETRY_VALID', payload: false }) + expect(draw.setGeometryValid).toHaveBeenCalledWith(false) + }) + + test('with too few coordinates is ignored and never schedules a correction', () => { + const { context, draw } = makeContext() + split(context, 'poly') + splitPolygon.mockClear() + + handlerFor(draw, 'geometrychange')({ + kind: 'add', + feature: { type: 'Feature', geometry: { type: 'LineString', coordinates: [[0, 0]] }, properties: {} } + }) + jest.runAllTimers() + + expect(splitPolygon).not.toHaveBeenCalled() + }) + + test('does not touch state if the split session already ended before the tick fires', () => { + const { context, dispatch, draw } = makeContext() + splitPolygon.mockReturnValue({ type: 'FeatureCollection' }) + + split(context, 'poly') + handlerFor(draw, 'geometrychange')({ + kind: 'add', + feature: { type: 'Feature', geometry: { type: 'LineString', coordinates: [[0, 0], [1, 1]] }, properties: {} } + }) + // The split completes (or is cancelled) before the deferred tick runs. + handlerFor(draw, 'create')({ id: 'line' }) + dispatch.mockClear() + draw.setGeometryValid.mockClear() + + jest.runAllTimers() + + expect(dispatch).not.toHaveBeenCalled() + expect(draw.setGeometryValid).not.toHaveBeenCalled() + }) + }) + + test('geometry change marks invalid splits', () => { const { context, dispatch, draw } = makeContext() splitPolygon.mockReturnValue(null) split(context, 'poly') - const e = { coordinates: [[0, 0], [1, 1]], properties: {} } + const e = { coordinates: [[0, 0], [1, 1]] } expect(() => handlerFor(draw, 'geometrychange')(e)).not.toThrow() - expect(e.properties.splitter).toBe('invalid') + expect(draw.setFeatureProperty).toHaveBeenCalledWith('_splitter', 'splitter', 'invalid') expect(dispatch).toHaveBeenCalledWith({ type: 'SET_ACTION', payload: { name: 'split', isValid: false } }) }) }) \ No newline at end of file From e09433eff44285c1146c16ebc6f78545183fa6c8 Mon Sep 17 00:00:00 2001 From: Dan Leech Date: Thu, 9 Jul 2026 16:06:13 +0100 Subject: [PATCH 61/89] Split invalid disable complete --- .../adapters/maplibre/MaplibreDrawAdapter.js | 24 +++++++++++++-- .../maplibre/MaplibreDrawAdapter.test.js | 30 +++++++++++++++++++ .../src/adapters/openlayers/OLDrawAdapter.js | 2 ++ plugins/draw/src/api/split.js | 6 +++- plugins/draw/src/api/split.test.js | 8 ++--- 5 files changed, 63 insertions(+), 7 deletions(-) diff --git a/plugins/draw/src/adapters/maplibre/MaplibreDrawAdapter.js b/plugins/draw/src/adapters/maplibre/MaplibreDrawAdapter.js index 3a84ff488..3f4b42079 100644 --- a/plugins/draw/src/adapters/maplibre/MaplibreDrawAdapter.js +++ b/plugins/draw/src/adapters/maplibre/MaplibreDrawAdapter.js @@ -43,6 +43,7 @@ export const displayedShape = (mode, coordinates) => { * done() / cancel() / undo() / deleteVertex() * get(id) / add(feature) / delete(id) / deleteAll() * setSnapEnabled(bool) / setSnapLayers(layers) / isSnapEnabled() + * setFeatureProperty(id, property, value) / setDrawingPreviewProperty(property, value) * on(event, handler) / off(event, handler) * remove() */ @@ -100,8 +101,14 @@ export class MaplibreDrawAdapter { update: (e) => this._bus.emit(ADAPTER_EVENTS.UPDATE, e.features[0]), geometrychange: (e) => { // Kind-less events are rubber-band moves carrying the displayed feature - // (placed vertices + cursor) — they drive the live invalid stroke. - if (!e?.kind) { this._updateLiveStroke(e) } + // (placed vertices + cursor) — they drive the live invalid stroke, and are + // cached for setDrawingPreviewProperty (the in-progress feature has no + // stable id yet — only assigned once drawing actually finishes — so it + // can't be targeted via setFeatureProperty). + if (!e?.kind) { + this._updateLiveStroke(e) + this._currentDrawEvent = e + } this._bus.emit(ADAPTER_EVENTS.GEOMETRY_CHANGE, e) }, placementblocked: (e) => this._bus.emit(ADAPTER_EVENTS.PLACEMENT_BLOCKED, e), @@ -276,6 +283,19 @@ export class MaplibreDrawAdapter { this._draw.setFeatureProperty(id, property, value) } + // Tag the feature currently being drawn (rubber-band, not yet created — so it + // has no stable id to target via setFeatureProperty) with a property, and + // re-render so the change is visible immediately. Used for live preview styling + // while drawing, e.g. split's valid/invalid line colour. A no-op once nothing + // has been drawn yet this session, or outside draw_polygon/draw_line. + setDrawingPreviewProperty (property, value) { + const e = this._currentDrawEvent + if (e?.properties) { + e.properties[property] = value + } + e?.ctx?.store?.render() + } + on (type, handler) { this._bus.on(type, handler) } diff --git a/plugins/draw/src/adapters/maplibre/MaplibreDrawAdapter.test.js b/plugins/draw/src/adapters/maplibre/MaplibreDrawAdapter.test.js index fab043cb6..5b4bf55ca 100644 --- a/plugins/draw/src/adapters/maplibre/MaplibreDrawAdapter.test.js +++ b/plugins/draw/src/adapters/maplibre/MaplibreDrawAdapter.test.js @@ -429,6 +429,36 @@ describe('simple delegations', () => { expect(draw.setFeatureProperty).toHaveBeenCalledWith('d', 'p', 1) }) + test('setDrawingPreviewProperty tags the in-progress feature and re-renders', () => { + const { adapter, map } = setup() + const render = jest.fn() + const drawEvent = { coordinates: [[0, 0], [1, 1]], properties: {}, ctx: { store: { render } } } + // Kind-less events are rubber-band moves — cached so setDrawingPreviewProperty + // has something to tag (the in-progress feature has no id yet to look up). + onHandler(map, CUSTOM_DRAW_EVENTS.GEOMETRY_CHANGE)(drawEvent) + + adapter.setDrawingPreviewProperty('splitter', 'valid') + + expect(drawEvent.properties.splitter).toBe('valid') + expect(render).toHaveBeenCalled() + }) + + test('setDrawingPreviewProperty tolerates nothing having been drawn yet', () => { + const { adapter } = setup() + expect(() => adapter.setDrawingPreviewProperty('splitter', 'valid')).not.toThrow() + }) + + test('setDrawingPreviewProperty ignores commit-level (kind-ful) events', () => { + const { adapter, map } = setup() + const render = jest.fn() + // A commit event (kind-ful) must not become the cached preview target — it + // carries a `feature`, not the live `properties` object rubber-band moves do. + onHandler(map, CUSTOM_DRAW_EVENTS.GEOMETRY_CHANGE)({ feature: {}, kind: 'add', ctx: { store: { render } } }) + + expect(() => adapter.setDrawingPreviewProperty('splitter', 'valid')).not.toThrow() + expect(render).not.toHaveBeenCalled() + }) + test('on/off delegate to the internal event bus', () => { const { adapter, bus } = setup() const handler = jest.fn() diff --git a/plugins/draw/src/adapters/openlayers/OLDrawAdapter.js b/plugins/draw/src/adapters/openlayers/OLDrawAdapter.js index 562d5669a..fe65b3185 100644 --- a/plugins/draw/src/adapters/openlayers/OLDrawAdapter.js +++ b/plugins/draw/src/adapters/openlayers/OLDrawAdapter.js @@ -17,6 +17,7 @@ import { createOLDraw } from './olDraw.js' * done() / cancel() / undo() / deleteVertex() * get(id) / add(feature) / delete(id) / deleteAll() * setSnapEnabled(bool) / setSnapLayers(layers) / isSnapEnabled() + * setFeatureProperty(id, property, value) / setDrawingPreviewProperty(property, value) * on(event, handler) / off(event, handler) * remove() */ @@ -91,6 +92,7 @@ export class OLDrawAdapter { isSnapEnabled () { return this._snapEnabled } setFeatureProperty () { /* not implemented for OL */ } + setDrawingPreviewProperty () { /* not implemented for OL */ } on (type, handler) { this._manager.on(type, handler) } off (type, handler) { this._manager.off(type, handler) } diff --git a/plugins/draw/src/api/split.js b/plugins/draw/src/api/split.js index f0cb091e0..c553a6d4d 100644 --- a/plugins/draw/src/api/split.js +++ b/plugins/draw/src/api/split.js @@ -13,7 +13,11 @@ const applySplitValidity = ({ draw, dispatch, polygonFeature, coordinates }) => const lineFeature = { id: '_splitter', geometry: { type: 'LineString', coordinates } } const featureCollection = splitPolygon(polygonFeature, lineFeature) const isValid = !!featureCollection - draw.setFeatureProperty('_splitter', 'splitter', isValid ? 'valid' : 'invalid') + // The splitter line has no stable id until it's actually created, so the + // in-progress preview colour is tagged via setDrawingPreviewProperty, not + // setFeatureProperty (that targets the '_splitter' id assigned on completion — + // see onSplitCreate below). + draw.setDrawingPreviewProperty('splitter', isValid ? 'valid' : 'invalid') dispatch({ type: 'SET_ACTION', payload: { name: 'split', isValid } }) dispatch({ type: 'SET_GEOMETRY_VALID', payload: isValid }) draw.setGeometryValid(isValid) diff --git a/plugins/draw/src/api/split.test.js b/plugins/draw/src/api/split.test.js index e64c2a88f..131f063f6 100644 --- a/plugins/draw/src/api/split.test.js +++ b/plugins/draw/src/api/split.test.js @@ -15,7 +15,7 @@ const makeContext = (overrides = {}) => { off: jest.fn(), isSnapEnabled: jest.fn(() => true), setGeometryValid: jest.fn(), - setFeatureProperty: jest.fn() + setDrawingPreviewProperty: jest.fn() } const context = { appState: { layoutRefs: { viewportRef: { current: 'viewport' } }, interfaceType: 'mouse' }, @@ -135,7 +135,7 @@ describe('split', () => { const e = { coordinates: [[0, 0], [1, 1]] } handlerFor(draw, 'geometrychange')(e) - expect(draw.setFeatureProperty).toHaveBeenCalledWith('_splitter', 'splitter', 'valid') + expect(draw.setDrawingPreviewProperty).toHaveBeenCalledWith('splitter', 'valid') expect(dispatch).toHaveBeenCalledWith({ type: 'SET_ACTION', payload: { name: 'split', isValid: true } }) // A valid split line must allow completion: Done enabled (pluginState.geometryValid) // and the double-click/click-vertex finish gesture unblocked (map._drawGeometryValid). @@ -151,7 +151,7 @@ describe('split', () => { const e = { coordinates: [[0, 0], [1, 1]] } handlerFor(draw, 'geometrychange')(e) - expect(draw.setFeatureProperty).toHaveBeenCalledWith('_splitter', 'splitter', 'invalid') + expect(draw.setDrawingPreviewProperty).toHaveBeenCalledWith('splitter', 'invalid') expect(dispatch).toHaveBeenCalledWith({ type: 'SET_GEOMETRY_VALID', payload: false }) expect(draw.setGeometryValid).toHaveBeenCalledWith(false) }) @@ -253,7 +253,7 @@ describe('split', () => { const e = { coordinates: [[0, 0], [1, 1]] } expect(() => handlerFor(draw, 'geometrychange')(e)).not.toThrow() - expect(draw.setFeatureProperty).toHaveBeenCalledWith('_splitter', 'splitter', 'invalid') + expect(draw.setDrawingPreviewProperty).toHaveBeenCalledWith('splitter', 'invalid') expect(dispatch).toHaveBeenCalledWith({ type: 'SET_ACTION', payload: { name: 'split', isValid: false } }) }) }) \ No newline at end of file From e13984406f674efd0429c4cfdd1a4202c837eca3 Mon Sep 17 00:00:00 2001 From: Dan Leech Date: Thu, 9 Jul 2026 16:22:23 +0100 Subject: [PATCH 62/89] Comment updated --- plugins/draw/src/api/split.js | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/plugins/draw/src/api/split.js b/plugins/draw/src/api/split.js index c553a6d4d..8579d3c5a 100644 --- a/plugins/draw/src/api/split.js +++ b/plugins/draw/src/api/split.js @@ -26,15 +26,16 @@ const applySplitValidity = ({ draw, dispatch, polygonFeature, coordinates }) => // Real-time preview + completion gate as the splitter line is drawn (ML only — // setFeatureProperty is a no-op on the OL adapter, per its documented interface). // -// events.js's shared onGeometryChange handler also reacts to every commit-level -// (kind-ful) geometrychange event and — since split nulls out the user validator -// (see split() below) — always marks it valid via the default rules, clobbering -// split's own gate. DrawInit's effect re-attaches that shared handler on every -// pluginState change (detach+reattach reorders the event bus's listener Set), so -// which handler runs first for a given event isn't stable — this can't rely on -// registration order to "win". Instead the correction is deferred one more tick: -// whatever events.js did already ran synchronously by the time this fires, so -// this is always the final word regardless of ordering. +// events.js has its own shared geometrychange handler, and it also reacts to +// every commit-level event (one with a `kind`, e.g. a vertex add/move/insert/ +// delete). Since split nulls out the user validator (see split() below), that +// shared handler always marks the event valid via the default rules — clobbering +// split's own validity gate. DrawInit's effect re-attaches the shared handler on +// every pluginState change (detach+reattach reorders the event bus's listener +// Set), so which handler runs first for a given event isn't stable — this can't +// rely on registration order to "win". Instead the correction is deferred one +// more tick: whatever events.js did already ran synchronously by the time this +// fires, so this is always the final word regardless of ordering. const createGeometryChangeHandler = ({ draw, dispatch, polygonFeature, isStopped }) => { const apply = (coordinates) => applySplitValidity({ draw, dispatch, polygonFeature, coordinates }) const debouncedPreview = debounce(apply, DEBOUNCE_MS) From f0ea94e53cd873d543cd75a41f8c891f0165b0bb Mon Sep 17 00:00:00 2001 From: Dan Leech Date: Thu, 9 Jul 2026 16:31:49 +0100 Subject: [PATCH 63/89] OLDrawManager _manager ref fixed --- plugins/draw/src/adapters/openlayers/OLDrawAdapter.js | 5 ++--- plugins/draw/src/adapters/openlayers/OLDrawAdapter.test.js | 7 ++----- plugins/draw/src/adapters/openlayers/olDraw.js | 3 ++- 3 files changed, 6 insertions(+), 9 deletions(-) diff --git a/plugins/draw/src/adapters/openlayers/OLDrawAdapter.js b/plugins/draw/src/adapters/openlayers/OLDrawAdapter.js index fe65b3185..6fb85f983 100644 --- a/plugins/draw/src/adapters/openlayers/OLDrawAdapter.js +++ b/plugins/draw/src/adapters/openlayers/OLDrawAdapter.js @@ -25,7 +25,7 @@ export class OLDrawAdapter { _snapEnabled = false constructor (mapProvider, options) { - const { remove } = createOLDraw({ + const { manager, remove } = createOLDraw({ mapProvider, events: options.events, eventBus: options.eventBus, @@ -33,8 +33,7 @@ export class OLDrawAdapter { mapStyle: options.mapStyle }) this._cleanupOLDraw = remove - // createOLDraw sets mapProvider.draw = manager; save it before DrawInit overwrites it - this._manager = mapProvider.draw + this._manager = manager this._mapProvider = mapProvider } diff --git a/plugins/draw/src/adapters/openlayers/OLDrawAdapter.test.js b/plugins/draw/src/adapters/openlayers/OLDrawAdapter.test.js index ff5c789a3..9afca9944 100644 --- a/plugins/draw/src/adapters/openlayers/OLDrawAdapter.test.js +++ b/plugins/draw/src/adapters/openlayers/OLDrawAdapter.test.js @@ -21,10 +21,7 @@ const fakeManager = () => ({ }) jest.mock('./olDraw.js', () => ({ - createOLDraw: jest.fn(({ mapProvider }) => { - mapProvider.draw = mapProvider._testManager - return { remove: jest.fn() } - }) + createOLDraw: jest.fn(({ mapProvider }) => ({ manager: mapProvider._testManager, remove: jest.fn() })) })) const setup = () => { @@ -41,7 +38,7 @@ const setup = () => { afterEach(() => jest.clearAllMocks()) -test('wires olDraw with the plugin options and keeps the manager before DrawInit overwrites it', () => { +test('wires olDraw with the plugin options and uses the returned manager', () => { const { manager, adapter } = setup() expect(createOLDraw).toHaveBeenCalledWith(expect.objectContaining({ pluginConfig: { snapLayers: ['boundaries'] }, diff --git a/plugins/draw/src/adapters/openlayers/olDraw.js b/plugins/draw/src/adapters/openlayers/olDraw.js index bd008115a..c43301a7c 100644 --- a/plugins/draw/src/adapters/openlayers/olDraw.js +++ b/plugins/draw/src/adapters/openlayers/olDraw.js @@ -6,7 +6,7 @@ import { MAP_SIZE_SCALES } from './defaults.js' * app-level events (MAP_SET_SIZE for scale-aware touch targets, * MAP_SET_STYLE for dynamic color updates). * - * @returns {{ remove: () => void }} + * @returns {{ manager: OLDrawManager, remove: () => void }} */ export const createOLDraw = ({ mapProvider, events, eventBus, pluginConfig = {}, mapStyle = null }) => { const { map } = mapProvider @@ -29,6 +29,7 @@ export const createOLDraw = ({ mapProvider, events, eventBus, pluginConfig = {}, eventBus.on(events.MAP_SET_STYLE, handleSetMapStyle) return { + manager, remove () { eventBus.off(events.MAP_SET_SIZE, handleSetMapSize) eventBus.off(events.MAP_SET_STYLE, handleSetMapStyle) From eab72054f625091e8e5e27b9a147e47b3b03edb6 Mon Sep 17 00:00:00 2001 From: Dan Leech Date: Thu, 9 Jul 2026 20:26:52 +0100 Subject: [PATCH 64/89] geometry change kind prop renamed phase --- plugins/draw/src/adapterEvents.js | 30 +++++++++++++--- .../adapters/maplibre/MaplibreDrawAdapter.js | 4 +-- .../maplibre/MaplibreDrawAdapter.test.js | 14 ++++---- .../maplibre/modes/drawMode/clickHandlers.js | 12 +++---- .../modes/drawMode/clickHandlers.test.js | 10 +++--- .../maplibre/modes/drawMode/undoHandlers.js | 2 +- .../modes/drawMode/undoHandlers.test.js | 4 +-- .../modes/editVertexMode/undoHandlers.js | 32 ++++++++--------- .../modes/editVertexMode/undoHandlers.test.js | 10 +++--- .../src/adapters/openlayers/draw/DrawMode.js | 10 +++--- .../adapters/openlayers/draw/DrawMode.test.js | 14 ++++---- .../src/adapters/openlayers/edit/EditMode.js | 34 +++++++++---------- .../adapters/openlayers/edit/EditMode.test.js | 4 +-- .../openlayers/edit/selectionState.js | 8 ++--- .../openlayers/edit/selectionState.test.js | 8 ++--- plugins/draw/src/api/editFeature.js | 2 +- plugins/draw/src/api/split.js | 6 ++-- plugins/draw/src/api/split.test.js | 12 +++---- plugins/draw/src/events.js | 12 +++---- plugins/draw/src/events.test.js | 24 ++++++------- plugins/draw/src/utils/spatial.test.js | 2 +- plugins/draw/src/validation/rules.js | 3 +- .../draw/src/validation/validateGeometry.js | 14 ++++---- .../src/validation/validateGeometry.test.js | 14 ++++---- 24 files changed, 154 insertions(+), 131 deletions(-) diff --git a/plugins/draw/src/adapterEvents.js b/plugins/draw/src/adapterEvents.js index 08560eb39..3e8712c4e 100644 --- a/plugins/draw/src/adapterEvents.js +++ b/plugins/draw/src/adapterEvents.js @@ -1,3 +1,24 @@ +/** + * What stage of the draw/edit lifecycle a geometry validation call is running at. + * Passed as `context.phase` through validateGeometry (validation/validateGeometry.js) + * to the default rules (validation/rules.js) and the user's `onGeometryChange` callback. + * + * @typedef {'preview' | 'place' | 'create' | 'edit-start' | + * 'commit-add' | 'commit-move' | 'commit-insert' | 'commit-delete'} GeometryChangePhase + * + * 'preview' - live, in-progress feedback (drag / rubber-band); nothing + * has committed yet, and nothing can be vetoed at this stage. + * 'place' - a candidate vertex is about to be committed; a HARD_RULES + * failure vetoes it outright and it never appears. + * 'create' - a whole new feature just finished being drawn. + * 'edit-start' - an existing feature was just loaded into an edit session; + * nothing has changed yet, this is the baseline check. + * 'commit-add' - a vertex was added while drawing. + * 'commit-move' - a vertex was dragged/nudged while editing. + * 'commit-insert' - a vertex was inserted at a midpoint while editing. + * 'commit-delete' - a vertex was removed while editing. + */ + /** * Shared adapter event contract. * @@ -20,13 +41,14 @@ * UPDATE GeoJSON feature after a vertex operation * GEOMETRY_CHANGE Two payload shapes share this event: * - Preview (draw/split live update): the in-progress - * feature itself (has `coordinates`, no `kind`). - * - Commit-level validation: `{ feature, kind, vertexIndex }` - * where kind ∈ 'add' | 'move' | 'insert' | 'delete'. + * feature itself (has `coordinates`, no `phase`). + * - Commit-level validation: `{ feature, phase, vertexIndex }` + * where phase ∈ 'commit-add' | 'commit-move' | + * 'commit-insert' | 'commit-delete' (see GeometryChangePhase). * events.js validates these and reverts invalid ones. * Listeners must guard for the shape they expect. * INTERFACE_TYPE_CHANGE { interfaceType: 'mouse' | 'touch' | 'keyboard' } - * PLACEMENT_BLOCKED { feature, reason, kind: 'place', mode, vertexIndex } — + * PLACEMENT_BLOCKED { feature, reason, phase: 'place', mode, vertexIndex } — * a vertex placement was rejected by validatePlacement * (hard rule or user callback); feature is the candidate * geometry that was refused. diff --git a/plugins/draw/src/adapters/maplibre/MaplibreDrawAdapter.js b/plugins/draw/src/adapters/maplibre/MaplibreDrawAdapter.js index 3f4b42079..494e252d8 100644 --- a/plugins/draw/src/adapters/maplibre/MaplibreDrawAdapter.js +++ b/plugins/draw/src/adapters/maplibre/MaplibreDrawAdapter.js @@ -100,12 +100,12 @@ export class MaplibreDrawAdapter { undochange: (e) => this._bus.emit(ADAPTER_EVENTS.UNDO_CHANGE, e.length), update: (e) => this._bus.emit(ADAPTER_EVENTS.UPDATE, e.features[0]), geometrychange: (e) => { - // Kind-less events are rubber-band moves carrying the displayed feature + // Phase-less events are rubber-band moves carrying the displayed feature // (placed vertices + cursor) — they drive the live invalid stroke, and are // cached for setDrawingPreviewProperty (the in-progress feature has no // stable id yet — only assigned once drawing actually finishes — so it // can't be targeted via setFeatureProperty). - if (!e?.kind) { + if (!e?.phase) { this._updateLiveStroke(e) this._currentDrawEvent = e } diff --git a/plugins/draw/src/adapters/maplibre/MaplibreDrawAdapter.test.js b/plugins/draw/src/adapters/maplibre/MaplibreDrawAdapter.test.js index 5b4bf55ca..bc5524ffa 100644 --- a/plugins/draw/src/adapters/maplibre/MaplibreDrawAdapter.test.js +++ b/plugins/draw/src/adapters/maplibre/MaplibreDrawAdapter.test.js @@ -128,7 +128,7 @@ describe('map event normalisation', () => { test('placementblocked forwards the raw event', () => { const { map, bus } = setup() - const e = { kind: 'place', reason: 'outside region' } + const e = { phase: 'place', reason: 'outside region' } onHandler(map, CUSTOM_DRAW_EVENTS.PLACEMENT_BLOCKED)(e) expect(bus.emit).toHaveBeenCalledWith('placementblocked', e) }) @@ -198,9 +198,9 @@ describe('live invalid stroke (draw mode)', () => { expect(map.setLayoutProperty.mock.calls.length).toBe(callsAfterFlip) }) - test('commit-level (kind-ful) events do not drive the stroke; events.js owns those', () => { + test('commit-level (has a phase) events do not drive the stroke; events.js owns those', () => { const { map } = drawPolygonSetup() - onHandler(map, CUSTOM_DRAW_EVENTS.GEOMETRY_CHANGE)({ feature: bowtie, kind: 'add', vertexIndex: 3 }) + onHandler(map, CUSTOM_DRAW_EVENTS.GEOMETRY_CHANGE)({ feature: bowtie, phase: 'commit-add', vertexIndex: 3 }) expect(map.setLayoutProperty).not.toHaveBeenCalled() }) @@ -433,7 +433,7 @@ describe('simple delegations', () => { const { adapter, map } = setup() const render = jest.fn() const drawEvent = { coordinates: [[0, 0], [1, 1]], properties: {}, ctx: { store: { render } } } - // Kind-less events are rubber-band moves — cached so setDrawingPreviewProperty + // Phase-less events are rubber-band moves — cached so setDrawingPreviewProperty // has something to tag (the in-progress feature has no id yet to look up). onHandler(map, CUSTOM_DRAW_EVENTS.GEOMETRY_CHANGE)(drawEvent) @@ -448,12 +448,12 @@ describe('simple delegations', () => { expect(() => adapter.setDrawingPreviewProperty('splitter', 'valid')).not.toThrow() }) - test('setDrawingPreviewProperty ignores commit-level (kind-ful) events', () => { + test('setDrawingPreviewProperty ignores commit-level (has a phase) events', () => { const { adapter, map } = setup() const render = jest.fn() - // A commit event (kind-ful) must not become the cached preview target — it + // A commit event (has a phase) must not become the cached preview target — it // carries a `feature`, not the live `properties` object rubber-band moves do. - onHandler(map, CUSTOM_DRAW_EVENTS.GEOMETRY_CHANGE)({ feature: {}, kind: 'add', ctx: { store: { render } } }) + onHandler(map, CUSTOM_DRAW_EVENTS.GEOMETRY_CHANGE)({ feature: {}, phase: 'commit-add', ctx: { store: { render } } }) expect(() => adapter.setDrawingPreviewProperty('splitter', 'valid')).not.toThrow() expect(render).not.toHaveBeenCalled() diff --git a/plugins/draw/src/adapters/maplibre/modes/drawMode/clickHandlers.js b/plugins/draw/src/adapters/maplibre/modes/drawMode/clickHandlers.js index 87692574b..323699264 100644 --- a/plugins/draw/src/adapters/maplibre/modes/drawMode/clickHandlers.js +++ b/plugins/draw/src/adapters/maplibre/modes/drawMode/clickHandlers.js @@ -5,22 +5,22 @@ import { checkPlacement } from '../../../../validation/validateGeometry.js' // The commit-level geometrychange payload for a vertex commit: the placed-only // geometry (trailing rubber-band point dropped so validation tests the committed shape). -const placedDrawGeometryChange = (feature, getCoords, kind) => { +const placedDrawGeometryChange = (feature, getCoords, phase) => { const placed = getCoords(feature).slice(0, -1) const type = feature.toGeoJSON().geometry.type const geometry = type === 'Polygon' ? { type: 'Polygon', coordinates: [placed] } : { type: 'LineString', coordinates: placed } - return { feature: { type: 'Feature', geometry, properties: {} }, kind, vertexIndex: Math.max(0, placed.length - 1) } + return { feature: { type: 'Feature', geometry, properties: {} }, phase, vertexIndex: Math.max(0, placed.length - 1) } } // Fire a commit-level geometrychange for validation, deferred a tick so it runs after // the current click settles. -const scheduleDrawValidation = (map, getFeature, getCoords, state, kind) => { +const scheduleDrawValidation = (map, getFeature, getCoords, state, phase) => { setTimeout(() => { const feature = getFeature(state) if (!feature) { return } - map.fire('draw.geometrychange', placedDrawGeometryChange(feature, getCoords, kind)) + map.fire('draw.geometrychange', placedDrawGeometryChange(feature, getCoords, phase)) }, 0) } @@ -66,8 +66,8 @@ const createClickHelpers = ({ geometryType, getFeature, getCoords }) => ({ // Emit a commit-level geometrychange after a vertex commit (placement or undo) // so the validation layer can gate the Done button. - emitDrawValidation (state, kind = 'add') { - scheduleDrawValidation(this.map, getFeature, getCoords, state, kind) + emitDrawValidation (state, phase = 'commit-add') { + scheduleDrawValidation(this.map, getFeature, getCoords, state, phase) }, onTap () { diff --git a/plugins/draw/src/adapters/maplibre/modes/drawMode/clickHandlers.test.js b/plugins/draw/src/adapters/maplibre/modes/drawMode/clickHandlers.test.js index 234cbedfd..d75b59a51 100644 --- a/plugins/draw/src/adapters/maplibre/modes/drawMode/clickHandlers.test.js +++ b/plugins/draw/src/adapters/maplibre/modes/drawMode/clickHandlers.test.js @@ -39,7 +39,7 @@ describe('mouse clicks (polygon)', () => { clickAt(ctx, state, 10, 0) clickAt(ctx, state, 0, 10) expect(firedWith(ctx.map, 'draw.placementblocked').pop()).toEqual(expect.objectContaining({ - kind: 'place', + phase: 'place', mode: 'draw_polygon', vertexIndex: 3, reason: expect.any(String), @@ -47,10 +47,10 @@ describe('mouse clicks (polygon)', () => { })) }) - test('the user callback can veto a mouse placement (and receives kind "place")', () => { + test('the user callback can veto a mouse placement (and receives phase "place")', () => { const { ctx, state } = setup(DrawPolygonMode) ctx.map._drawGeometryValidator = jest.fn((feature, context) => - context.kind === 'place' ? { valid: false, reason: 'outside region' } : { valid: true }) + context.phase === 'place' ? { valid: false, reason: 'outside region' } : { valid: true }) clickAt(ctx, state, 0, 0) expect(state.polygon.coordinates[0]).toHaveLength(0) // first vertex vetoed expect(firedWith(ctx.map, 'draw.placementblocked').pop()).toEqual( @@ -87,7 +87,7 @@ describe('mouse clicks (polygon)', () => { clickAt(ctx, state, 10, 0) jest.runAllTimers() const geomChange = firedWith(ctx.map, 'draw.geometrychange').pop() - expect(geomChange).toEqual(expect.objectContaining({ kind: 'add', feature: expect.any(Object) })) + expect(geomChange).toEqual(expect.objectContaining({ phase: 'commit-add', feature: expect.any(Object) })) jest.useRealTimers() }) @@ -172,7 +172,7 @@ describe('add-vertex button and doClick', () => { ctx.doClick(state) expect(state.polygon.coordinates[0]).toHaveLength(0) expect(firedWith(ctx.map, 'draw.placementblocked').pop()).toEqual( - expect.objectContaining({ reason: 'outside region', kind: 'place' })) + expect.objectContaining({ reason: 'outside region', phase: 'place' })) }) test('doClick places at the snapped position when snapping', () => { diff --git a/plugins/draw/src/adapters/maplibre/modes/drawMode/undoHandlers.js b/plugins/draw/src/adapters/maplibre/modes/drawMode/undoHandlers.js index f14d2bb52..5f62f1022 100644 --- a/plugins/draw/src/adapters/maplibre/modes/drawMode/undoHandlers.js +++ b/plugins/draw/src/adapters/maplibre/modes/drawMode/undoHandlers.js @@ -39,7 +39,7 @@ const createUndoStackHandlers = ({ geometryType, getFeature }) => ({ this.undoVertex(state) // An undo commits a vertex removal, so it must re-validate like any other // commit — otherwise the Done gate goes stale. - this.emitDrawValidation(state, 'delete') + this.emitDrawValidation(state, 'commit-delete') } }, diff --git a/plugins/draw/src/adapters/maplibre/modes/drawMode/undoHandlers.test.js b/plugins/draw/src/adapters/maplibre/modes/drawMode/undoHandlers.test.js index f501739f1..fb1c0c6cf 100644 --- a/plugins/draw/src/adapters/maplibre/modes/drawMode/undoHandlers.test.js +++ b/plugins/draw/src/adapters/maplibre/modes/drawMode/undoHandlers.test.js @@ -87,7 +87,7 @@ describe('undo via draw.undo event and reinitialisation', () => { expect(state.polygon.coordinates[0]).toHaveLength(3) }) - test('undo re-validates the committed shape (deferred, kind delete)', () => { + test('undo re-validates the committed shape (deferred, phase commit-delete)', () => { jest.useFakeTimers() const { ctx, state } = setup(DrawPolygonMode) clickAt(ctx, state, 0, 0) @@ -98,7 +98,7 @@ describe('undo via draw.undo event and reinitialisation', () => { ctx.onUndo(state) jest.runAllTimers() expect(firedWith(ctx.map, 'draw.geometrychange').pop()).toEqual(expect.objectContaining({ - kind: 'delete', + phase: 'commit-delete', feature: expect.any(Object) })) jest.useRealTimers() diff --git a/plugins/draw/src/adapters/maplibre/modes/editVertexMode/undoHandlers.js b/plugins/draw/src/adapters/maplibre/modes/editVertexMode/undoHandlers.js index 8cfa98a07..4c9dea8e0 100644 --- a/plugins/draw/src/adapters/maplibre/modes/editVertexMode/undoHandlers.js +++ b/plugins/draw/src/adapters/maplibre/modes/editVertexMode/undoHandlers.js @@ -5,19 +5,19 @@ import { } from './geometryHelpers.js' import { scalePoint } from './helpers.js' -// Map an undo-stack op type onto the geometry-change `kind` consumed by validation. -const UNDO_OP_KIND = { - move_vertex: 'move', - insert_vertex: 'insert', - delete_vertex: 'delete' +// Map an undo-stack op type onto the geometry-change `phase` consumed by validation. +const UNDO_OP_PHASE = { + move_vertex: 'commit-move', + insert_vertex: 'commit-insert', + delete_vertex: 'commit-delete' } // Undoing an op commits the inverse change (undo of a delete re-inserts, etc.), -// so its re-validation reports the inverse kind. -const UNDO_INVERSE_KIND = { - move_vertex: 'move', - insert_vertex: 'delete', - delete_vertex: 'insert' +// so its re-validation reports the inverse phase. +const UNDO_INVERSE_PHASE = { + move_vertex: 'commit-move', + insert_vertex: 'commit-delete', + delete_vertex: 'commit-insert' } export const undoHandlers = { @@ -32,16 +32,16 @@ export const undoHandlers = { } }, - // Emit a commit-level geometrychange (feature + change kind + vertex index) so the + // Emit a commit-level geometrychange (feature + change phase + vertex index) so the // validation layer can accept or reject the change. Deferred a tick to avoid // re-entrancy: rejection calls draw.undo(), which must run after the current // mutation (and its undo bookkeeping) has fully settled. - emitGeometryValidation (kind, vertexIndex, featureId) { - if (!kind) { return } + emitGeometryValidation (phase, vertexIndex, featureId) { + if (!phase) { return } setTimeout(() => { const feature = this.getFeature(featureId) if (!feature) { return } - this.map.fire('draw.geometrychange', { feature: feature.toGeoJSON(), kind, vertexIndex }) + this.map.fire('draw.geometrychange', { feature: feature.toGeoJSON(), phase, vertexIndex }) }, 0) }, @@ -54,7 +54,7 @@ export const undoHandlers = { undoStack.push(operation) // Every edit commit (move/insert/delete, via mouse or keyboard) records an undo // op here, so this is the single point that feeds commit-level validation. - this.emitGeometryValidation(UNDO_OP_KIND[operation.type], operation.vertexIndex, operation.featureId) + this.emitGeometryValidation(UNDO_OP_PHASE[operation.type], operation.vertexIndex, operation.featureId) }, handleUndo (state) { @@ -76,7 +76,7 @@ export const undoHandlers = { } // An undo commits the inverse change, so it must re-validate like any other // commit — otherwise the invalid stroke and the Done gate go stale. - this.emitGeometryValidation(UNDO_INVERSE_KIND[op.type], op.vertexIndex, op.featureId) + this.emitGeometryValidation(UNDO_INVERSE_PHASE[op.type], op.vertexIndex, op.featureId) }, undoMoveVertex (state, op) { diff --git a/plugins/draw/src/adapters/maplibre/modes/editVertexMode/undoHandlers.test.js b/plugins/draw/src/adapters/maplibre/modes/editVertexMode/undoHandlers.test.js index 38004867a..6d2c45c6e 100644 --- a/plugins/draw/src/adapters/maplibre/modes/editVertexMode/undoHandlers.test.js +++ b/plugins/draw/src/adapters/maplibre/modes/editVertexMode/undoHandlers.test.js @@ -12,7 +12,7 @@ describe('undoHandlers', () => { expect(map._undoStack.length).toBe(1) }) - test('pushUndo emits a deferred commit-level geometrychange with the change kind', () => { + test('pushUndo emits a deferred commit-level geometrychange with the change phase', () => { jest.useFakeTimers() const { ctx, map } = createHarness() map.fire.mockClear() @@ -23,7 +23,7 @@ describe('undoHandlers', () => { jest.runAllTimers() expect(map.fire).toHaveBeenCalledWith('draw.geometrychange', expect.objectContaining({ - kind: 'move', + phase: 'commit-move', vertexIndex: 2, feature: expect.any(Object) })) @@ -44,7 +44,7 @@ describe('undoHandlers', () => { jest.useFakeTimers() const { ctx, map } = createHarness() map.fire.mockClear() - ctx.emitGeometryValidation('move', 0, 'missing-feature') + ctx.emitGeometryValidation('commit-move', 0, 'missing-feature') jest.runAllTimers() expect(map.fire).not.toHaveBeenCalledWith('draw.geometrychange', expect.anything()) jest.useRealTimers() @@ -66,7 +66,7 @@ describe('undoHandlers', () => { expect(() => ctx.handleUndo(state)).not.toThrow() }) - test('handleUndo re-validates with the inverse change kind (undo of a delete re-inserts)', () => { + test('handleUndo re-validates with the inverse change phase (undo of a delete re-inserts)', () => { jest.useFakeTimers() const { ctx, state, map } = createHarness() jest.spyOn(ctx, 'undoDeleteVertex').mockImplementation(() => {}) @@ -75,7 +75,7 @@ describe('undoHandlers', () => { ctx.handleUndo(state) jest.runAllTimers() expect(map.fire).toHaveBeenCalledWith('draw.geometrychange', expect.objectContaining({ - kind: 'insert', + phase: 'commit-insert', vertexIndex: 1, feature: expect.any(Object) })) diff --git a/plugins/draw/src/adapters/openlayers/draw/DrawMode.js b/plugins/draw/src/adapters/openlayers/draw/DrawMode.js index a735e73b3..b2411de29 100644 --- a/plugins/draw/src/adapters/openlayers/draw/DrawMode.js +++ b/plugins/draw/src/adapters/openlayers/draw/DrawMode.js @@ -101,15 +101,15 @@ const attachDrawListeners = (drawInteraction, { manager, featureId, properties, } // Tracks placed-vertex count and emits VERTEX_CHANGE plus, on each genuine placement, -// a commit-level GEOMETRY_CHANGE ('add') for validation. Deferred a tick so a +// a commit-level GEOMETRY_CHANGE ('commit-add') for validation. Deferred a tick so a // rejection's revert runs after the current click settles. const createVertexTracker = (manager, getSketch) => { let lastPlacedCount = 0 - const emit = (kind, vertexIndex) => { + const emit = (phase, vertexIndex) => { setTimeout(() => { const sketch = getSketch() if (!sketch) { return } - manager.emit(ADAPTER_EVENTS.GEOMETRY_CHANGE, { feature: placedFeatureGeoJSON(manager.store, sketch), kind, vertexIndex }) + manager.emit(ADAPTER_EVENTS.GEOMETRY_CHANGE, { feature: placedFeatureGeoJSON(manager.store, sketch), phase, vertexIndex }) }, 0) } return { @@ -119,7 +119,7 @@ const createVertexTracker = (manager, getSketch) => { if (!sketch) { return } const placed = getPlacedSketchCoords(sketch.getGeometry()).length manager.emit(ADAPTER_EVENTS.VERTEX_CHANGE, { numVertices: placed }) - if (placed > lastPlacedCount) { emit('add', placed - 1) } + if (placed > lastPlacedCount) { emit('commit-add', placed - 1) } lastPlacedCount = placed }, // An undo commits a vertex removal, so it must re-validate like any other @@ -127,7 +127,7 @@ const createVertexTracker = (manager, getSketch) => { emitUndoValidation: () => { const sketch = getSketch() if (!sketch) { return } - emit('delete', getPlacedSketchCoords(sketch.getGeometry()).length) + emit('commit-delete', getPlacedSketchCoords(sketch.getGeometry()).length) } } } diff --git a/plugins/draw/src/adapters/openlayers/draw/DrawMode.test.js b/plugins/draw/src/adapters/openlayers/draw/DrawMode.test.js index 88d1bbcd7..6344d51de 100644 --- a/plugins/draw/src/adapters/openlayers/draw/DrawMode.test.js +++ b/plugins/draw/src/adapters/openlayers/draw/DrawMode.test.js @@ -89,7 +89,7 @@ describe('buildCanPlaceVertex', () => { const gate = gateFor([[0, 0], [2, 2], [2, 0], [9, 9], [0, 0]], manager) expect(gate([0, 2])).toBe(false) expect(manager.emit).toHaveBeenCalledWith(ADAPTER_EVENTS.PLACEMENT_BLOCKED, expect.objectContaining({ - kind: 'place', + phase: 'place', mode: 'draw_polygon', vertexIndex: 3, reason: expect.any(String), @@ -97,14 +97,14 @@ describe('buildCanPlaceVertex', () => { })) }) - test('the user callback can veto a placement (and receives kind "place")', () => { + test('the user callback can veto a placement (and receives phase "place")', () => { const manager = createFakeManager() manager._geometryValidator = jest.fn((feature, context) => - context.kind === 'place' ? { valid: false, reason: 'outside region' } : { valid: true }) + context.phase === 'place' ? { valid: false, reason: 'outside region' } : { valid: true }) const gate = gateFor([[0, 0], [2, 0], [9, 9], [0, 0]], manager) expect(gate([5, 5])).toBe(false) expect(manager.emit).toHaveBeenCalledWith(ADAPTER_EVENTS.PLACEMENT_BLOCKED, - expect.objectContaining({ reason: 'outside region', kind: 'place' })) + expect.objectContaining({ reason: 'outside region', phase: 'place' })) }) test('the user callback can veto the very first vertex (no sketch yet)', () => { @@ -217,7 +217,7 @@ describe('drawing lifecycle', () => { expect(changed).toHaveBeenCalled() }) - test('undo re-validates the committed shape (deferred, kind delete)', () => { + test('undo re-validates the committed shape (deferred, phase commit-delete)', () => { jest.useFakeTimers() const { mode, manager, emitted, interaction } = setup() const sketch = polygonFeature([[0, 0], [10, 0], [5, 5], [0, 0]]) @@ -227,7 +227,7 @@ describe('drawing lifecycle', () => { mode.undo() jest.runAllTimers() expect(emitted().filter((e) => e.type === ADAPTER_EVENTS.GEOMETRY_CHANGE).pop()?.payload).toEqual( - expect.objectContaining({ kind: 'delete', feature: expect.any(Object) })) + expect.objectContaining({ phase: 'commit-delete', feature: expect.any(Object) })) jest.useRealTimers() }) @@ -241,7 +241,7 @@ describe('drawing lifecycle', () => { expect(emitted().some((e) => e.type === ADAPTER_EVENTS.GEOMETRY_CHANGE)).toBe(false) jest.runAllTimers() const geom = emitted().find((e) => e.type === ADAPTER_EVENTS.GEOMETRY_CHANGE) - expect(geom.payload).toEqual(expect.objectContaining({ kind: 'add' })) + expect(geom.payload).toEqual(expect.objectContaining({ phase: 'commit-add' })) jest.useRealTimers() }) diff --git a/plugins/draw/src/adapters/openlayers/edit/EditMode.js b/plugins/draw/src/adapters/openlayers/edit/EditMode.js index 3647fe7ef..cf3931650 100644 --- a/plugins/draw/src/adapters/openlayers/edit/EditMode.js +++ b/plugins/draw/src/adapters/openlayers/edit/EditMode.js @@ -15,19 +15,19 @@ import { createLiveStroke } from '../../../validation/liveStroke.js' const TOUCH_INTERFACE = 'touch' const VERTEX_TYPE = 'vertex' -// Map an undo-op type onto the geometry-change `kind` consumed by validation. -const OP_KIND = { - move_vertex: 'move', - insert_vertex: 'insert', - delete_vertex: 'delete' +// Map an undo-op type onto the geometry-change `phase` consumed by validation. +const OP_PHASE = { + move_vertex: 'commit-move', + insert_vertex: 'commit-insert', + delete_vertex: 'commit-delete' } // Undoing an op commits the inverse change (undo of a delete re-inserts, etc.), -// so its re-validation reports the inverse kind. -const UNDO_INVERSE_KIND = { - move_vertex: 'move', - insert_vertex: 'delete', - delete_vertex: 'insert' +// so its re-validation reports the inverse phase. +const UNDO_INVERSE_PHASE = { + move_vertex: 'commit-move', + insert_vertex: 'commit-delete', + delete_vertex: 'commit-insert' } // Live invalid-stroke wiring: re-validate the displayed geometry on every geometry @@ -91,7 +91,7 @@ const createVertexActions = ({ olFeature, undoStack, selection, getTouchHandler } undoStack.push({ type: 'delete_vertex', vertexIndex: result.deletedIndex, deletedCoord: result.deletedCoord }) syncGeom() - emitGeometryValidation('delete', result.deletedIndex) + emitGeometryValidation('commit-delete', result.deletedIndex) setState({ selectedVertexIndex: -1, selectedVertexType: null }) } @@ -105,7 +105,7 @@ const createVertexActions = ({ olFeature, undoStack, selection, getTouchHandler syncGeom() // An undo commits the inverse change, so it must re-validate like any other // commit — otherwise the invalid stroke and the Done gate go stale. - emitGeometryValidation(UNDO_INVERSE_KIND[op.type], restoredIndex) + emitGeometryValidation(UNDO_INVERSE_PHASE[op.type], restoredIndex) // Only re-select if a vertex was already active — undo must not create a new selection const newIndex = previousIndex >= 0 ? restoredIndex : -1 setState({ @@ -134,7 +134,7 @@ const wireTouchHandler = ({ map, container, manager, snap, olFeature, undoStack, onVertexMoved ({ vertexIndex, previousCoord }) { undoStack.push({ type: 'move_vertex', vertexIndex, previousCoord }) syncGeom() - emitGeometryValidation('move', vertexIndex) + emitGeometryValidation('commit-move', vertexIndex) selectVertex(vertexIndex) touchHandler.updateTargetPosition() }, @@ -155,7 +155,7 @@ const wireTouchHandler = ({ map, container, manager, snap, olFeature, undoStack, } undoStack.push({ type: 'insert_vertex', vertexIndex: result.insertedIndex }) syncGeom() - emitGeometryValidation('insert', result.insertedIndex) + emitGeometryValidation('commit-insert', result.insertedIndex) selectVertex(result.insertedIndex) touchHandler.updateTargetPosition() } @@ -184,13 +184,13 @@ const wireKeyboardHandler = ({ map, container, snap, undoStack, selection, touch onVertexMoved ({ vertexIndex, previousCoord }) { undoStack.push({ type: 'move_vertex', vertexIndex, previousCoord }) syncGeom() - emitGeometryValidation('move', vertexIndex) + emitGeometryValidation('commit-move', vertexIndex) setState({ selectedVertexIndex: vertexIndex, selectedVertexType: VERTEX_TYPE }) }, onInserted ({ insertedIndex }) { undoStack.push({ type: 'insert_vertex', vertexIndex: insertedIndex }) syncGeom() - emitGeometryValidation('insert', insertedIndex) + emitGeometryValidation('commit-insert', insertedIndex) }, onDeleted: actions.doDeleteVertex, onUndo: actions.doUndo, @@ -330,7 +330,7 @@ export const createEditMode = ({ map, manager, options }) => { return } undoStack.push(op) - emitGeometryValidation(OP_KIND[op.type], op.vertexIndex) + emitGeometryValidation(OP_PHASE[op.type], op.vertexIndex) setState({ selectedVertexIndex: op.vertexIndex, selectedVertexType: VERTEX_TYPE }) } }) diff --git a/plugins/draw/src/adapters/openlayers/edit/EditMode.test.js b/plugins/draw/src/adapters/openlayers/edit/EditMode.test.js index ad81c9faf..9130090cd 100644 --- a/plugins/draw/src/adapters/openlayers/edit/EditMode.test.js +++ b/plugins/draw/src/adapters/openlayers/edit/EditMode.test.js @@ -111,7 +111,7 @@ test('select via pointer, delete the vertex, then undo restores it', () => { mode.undo() // empty stack — no-op }) -test('undo re-validates with the inverse change kind (undo of a delete re-inserts)', () => { +test('undo re-validates with the inverse change phase (undo of a delete re-inserts)', () => { jest.useFakeTimers() const { container, manager, mode } = setup() container.dispatchEvent(domEvent('pointerdown', { pointerType: 'mouse', clientX: 100, clientY: 0 })) @@ -121,7 +121,7 @@ test('undo re-validates with the inverse change kind (undo of a delete re-insert mode.undo() jest.runAllTimers() expect(manager.emit).toHaveBeenCalledWith(ADAPTER_EVENTS.GEOMETRY_CHANGE, expect.objectContaining({ - kind: 'insert', + phase: 'commit-insert', vertexIndex: 1, feature: expect.any(Object) })) diff --git a/plugins/draw/src/adapters/openlayers/edit/selectionState.js b/plugins/draw/src/adapters/openlayers/edit/selectionState.js index c180a9479..53d1a10fa 100644 --- a/plugins/draw/src/adapters/openlayers/edit/selectionState.js +++ b/plugins/draw/src/adapters/openlayers/edit/selectionState.js @@ -1,13 +1,13 @@ import { getCoords, getMidpoints } from '../utils/geometryHelpers.js' import { ADAPTER_EVENTS } from '../../../adapterEvents.js' -// Deferred commit-level geometrychange emitter (feature + change kind + vertex index) +// Deferred commit-level geometrychange emitter (feature + change phase + vertex index) // consumed by the validation layer. Deferred a tick so that a rejection's revert runs // after the current mutation's undo bookkeeping has settled. -const createGeometryValidationEmitter = (manager, store, olFeature) => (kind, vertexIndex) => { - if (!kind) { return } +const createGeometryValidationEmitter = (manager, store, olFeature) => (phase, vertexIndex) => { + if (!phase) { return } setTimeout(() => { - manager.emit(ADAPTER_EVENTS.GEOMETRY_CHANGE, { feature: store.toGeoJSON(olFeature), kind, vertexIndex }) + manager.emit(ADAPTER_EVENTS.GEOMETRY_CHANGE, { feature: store.toGeoJSON(olFeature), phase, vertexIndex }) }, 0) } diff --git a/plugins/draw/src/adapters/openlayers/edit/selectionState.test.js b/plugins/draw/src/adapters/openlayers/edit/selectionState.test.js index f76818872..be85b8fea 100644 --- a/plugins/draw/src/adapters/openlayers/edit/selectionState.test.js +++ b/plugins/draw/src/adapters/openlayers/edit/selectionState.test.js @@ -70,25 +70,25 @@ test('syncGeom derives state from the geometry and emits vertexchange + update', expect(manager.emit).toHaveBeenCalledWith(ADAPTER_EVENTS.UPDATE, store.toGeoJSON()) }) -test('emitGeometryValidation emits a deferred commit-level geometrychange with the change kind', () => { +test('emitGeometryValidation emits a deferred commit-level geometrychange with the change phase', () => { jest.useFakeTimers() const { selection, manager, store } = setup() manager.emit.mockClear() - selection.emitGeometryValidation('move', 2) + selection.emitGeometryValidation('commit-move', 2) // Deferred a tick to avoid re-entrancy — nothing emitted synchronously. expect(manager.emit).not.toHaveBeenCalledWith(ADAPTER_EVENTS.GEOMETRY_CHANGE, expect.anything()) jest.runAllTimers() expect(manager.emit).toHaveBeenCalledWith(ADAPTER_EVENTS.GEOMETRY_CHANGE, { feature: store.toGeoJSON(), - kind: 'move', + phase: 'commit-move', vertexIndex: 2 }) jest.useRealTimers() }) -test('emitGeometryValidation is a no-op without a change kind', () => { +test('emitGeometryValidation is a no-op without a change phase', () => { jest.useFakeTimers() const { selection, manager } = setup() manager.emit.mockClear() diff --git a/plugins/draw/src/api/editFeature.js b/plugins/draw/src/api/editFeature.js index 39f19e430..b7388a955 100644 --- a/plugins/draw/src/api/editFeature.js +++ b/plugins/draw/src/api/editFeature.js @@ -46,7 +46,7 @@ export const editFeature = ({ appState, appConfig, mapState, pluginConfig, plugi // Seed the Done-button gate and the stroke from the feature's starting validity // so an already invalid feature opens dashed and cannot be "finished" until fixed. - const { valid } = validateGeometry(feature, { kind: 'init', mode: 'edit_vertex' }, { onGeometryChange: draw._geometryValidator }) + const { valid } = validateGeometry(feature, { phase: 'edit-start', mode: 'edit_vertex' }, { onGeometryChange: draw._geometryValidator }) dispatch({ type: 'SET_GEOMETRY_VALID', payload: valid }) draw.setInvalid?.(!valid) diff --git a/plugins/draw/src/api/split.js b/plugins/draw/src/api/split.js index 8579d3c5a..24d32581b 100644 --- a/plugins/draw/src/api/split.js +++ b/plugins/draw/src/api/split.js @@ -27,7 +27,7 @@ const applySplitValidity = ({ draw, dispatch, polygonFeature, coordinates }) => // setFeatureProperty is a no-op on the OL adapter, per its documented interface). // // events.js has its own shared geometrychange handler, and it also reacts to -// every commit-level event (one with a `kind`, e.g. a vertex add/move/insert/ +// every commit-level event (one with a `phase`, e.g. commit-add/move/insert/ // delete). Since split nulls out the user validator (see split() below), that // shared handler always marks the event valid via the default rules — clobbering // split's own validity gate. DrawInit's effect re-attaches the shared handler on @@ -41,9 +41,9 @@ const createGeometryChangeHandler = ({ draw, dispatch, polygonFeature, isStopped const debouncedPreview = debounce(apply, DEBOUNCE_MS) return (e) => { - if (e?.kind) { + if (e?.phase) { debouncedPreview.cancel?.() - // Commit-level events carry { feature, kind, vertexIndex } per the shared + // Commit-level events carry { feature, phase, vertexIndex } per the shared // adapter contract (adapterEvents.js) rather than a top-level coordinates. const coordinates = e.feature?.geometry?.coordinates if (!coordinates || coordinates.length < 2) { diff --git a/plugins/draw/src/api/split.test.js b/plugins/draw/src/api/split.test.js index 131f063f6..4ac1c9350 100644 --- a/plugins/draw/src/api/split.test.js +++ b/plugins/draw/src/api/split.test.js @@ -171,7 +171,7 @@ describe('split', () => { // clobbering split's own gate. DrawInit re-attaches that shared handler on every // pluginState change, so listener order on the bus isn't stable; the correction is // deferred one tick (a real setTimeout) so it's always the final word regardless. - describe('commit-level (kind-ful) geometry change', () => { + describe('commit-level (has a phase) geometry change', () => { beforeEach(() => jest.useFakeTimers()) afterEach(() => jest.useRealTimers()) @@ -181,7 +181,7 @@ describe('split', () => { split(context, 'poly') const commitEvent = { - kind: 'add', + phase: 'commit-add', vertexIndex: 1, feature: { type: 'Feature', geometry: { type: 'LineString', coordinates: [[0, 0], [1, 1]] }, properties: {} } } @@ -201,7 +201,7 @@ describe('split', () => { split(context, 'poly') handlerFor(draw, 'geometrychange')({ - kind: 'add', + phase: 'commit-add', feature: { type: 'Feature', geometry: { type: 'LineString', coordinates: [[0, 0], [1, 1]] }, properties: {} } }) jest.runAllTimers() @@ -216,7 +216,7 @@ describe('split', () => { splitPolygon.mockClear() handlerFor(draw, 'geometrychange')({ - kind: 'add', + phase: 'commit-add', feature: { type: 'Feature', geometry: { type: 'LineString', coordinates: [[0, 0]] }, properties: {} } }) jest.runAllTimers() @@ -230,7 +230,7 @@ describe('split', () => { split(context, 'poly') handlerFor(draw, 'geometrychange')({ - kind: 'add', + phase: 'commit-add', feature: { type: 'Feature', geometry: { type: 'LineString', coordinates: [[0, 0], [1, 1]] }, properties: {} } }) // The split completes (or is cancelled) before the deferred tick runs. @@ -256,4 +256,4 @@ describe('split', () => { expect(draw.setDrawingPreviewProperty).toHaveBeenCalledWith('splitter', 'invalid') expect(dispatch).toHaveBeenCalledWith({ type: 'SET_ACTION', payload: { name: 'split', isValid: false } }) }) -}) \ No newline at end of file +}) diff --git a/plugins/draw/src/events.js b/plugins/draw/src/events.js index 2ccd4882d..4e3133802 100644 --- a/plugins/draw/src/events.js +++ b/plugins/draw/src/events.js @@ -61,10 +61,10 @@ function createHandlers ({ appState, appConfig, mapState, pluginState, mapProvid // A shape can be finished by the Done button OR a map gesture (double-click, // clicking the first vertex, Enter). Only the button is gated, so re-validate // here to catch the gesture paths — an invalid shape must never be finalised. - const { valid } = validateGeometry(f, { kind: 'create', mode: draw.getMode() }, { onGeometryChange: draw._geometryValidator }) + const { valid } = validateGeometry(f, { phase: 'create', mode: draw.getMode() }, { onGeometryChange: draw._geometryValidator }) if (!valid) { pendingCreateId = f.id - eventBus.emit(GEOMETRY_INVALID_EVENT, { feature: f, kind: 'create', mode: EDIT_VERTEX_MODE }) + eventBus.emit(GEOMETRY_INVALID_EVENT, { feature: f, phase: 'create', mode: EDIT_VERTEX_MODE }) setTimeout(() => enterEditVertexMode({ draw, appState, appConfig, mapState, dispatch }, f.id), 0) return } @@ -87,12 +87,12 @@ function createHandlers ({ appState, appConfig, mapState, pluginState, mapProvid onUndoChange: (l) => { pluginState.dispatch({ type: 'SET_UNDO_STACK_LENGTH', payload: l }) }, onUpdate: (f) => { eventBus.emit('draw:updated', f) }, onGeometryChange: (e) => { - // Only commit-level changes (add/move/insert/delete) carry a `kind`. + // Only commit-level changes (commit-add/move/insert/delete) carry a `phase`. // Preview events (e.g. split's live preview) have none and are ignored. - if (!e?.kind) { return } + if (!e?.phase) { return } const mode = draw.getMode() - const context = { kind: e.kind, vertexIndex: e.vertexIndex, mode } + const context = { phase: e.phase, vertexIndex: e.vertexIndex, mode } const { valid, reason } = validateGeometry(e.feature, context, { onGeometryChange: draw._geometryValidator }) // Rules only gate the Done button — never revert. A shape can pass through @@ -110,7 +110,7 @@ function createHandlers ({ appState, appConfig, mapState, pluginState, mapProvid } }, // A vertex placement was rejected (hard rule or user callback veto). Surface it - // on the public bus with kind 'place' so a future tooltip can show the reason. + // on the public bus with phase 'place' so a future tooltip can show the reason. onPlacementBlocked: (e) => { eventBus.emit(GEOMETRY_INVALID_EVENT, e) }, diff --git a/plugins/draw/src/events.test.js b/plugins/draw/src/events.test.js index 53fd4d475..6066563b1 100644 --- a/plugins/draw/src/events.test.js +++ b/plugins/draw/src/events.test.js @@ -206,7 +206,7 @@ describe('geometrychange validation', () => { geometry: { type: 'Polygon', coordinates: [[[0, 0], [1, 0], [2, 0], [0, 0]]] } } - test('ignores preview payloads that carry no change kind', () => { + test('ignores preview payloads that carry no phase', () => { const { draw, dispatch } = setup() drawHandler(draw, 'geometrychange')({ coordinates: [[0, 0], [1, 1]] }) expect(dispatch).not.toHaveBeenCalledWith({ type: 'SET_GEOMETRY_VALID', payload: expect.anything() }) @@ -214,13 +214,13 @@ describe('geometrychange validation', () => { test('opens the gate for a valid geometry', () => { const { draw, dispatch } = setup() - drawHandler(draw, 'geometrychange')({ feature: squareFeature, kind: 'add', vertexIndex: 3 }) + drawHandler(draw, 'geometrychange')({ feature: squareFeature, phase: 'commit-add', vertexIndex: 3 }) expect(dispatch).toHaveBeenCalledWith({ type: 'SET_GEOMETRY_VALID', payload: true }) }) test('gates a self-intersecting shape while drawing', () => { const { draw, dispatch, eventBus } = setup() - drawHandler(draw, 'geometrychange')({ feature: bowtieFeature, kind: 'add', vertexIndex: 3 }) + drawHandler(draw, 'geometrychange')({ feature: bowtieFeature, phase: 'commit-add', vertexIndex: 3 }) expect(dispatch).toHaveBeenCalledWith({ type: 'SET_GEOMETRY_VALID', payload: false }) expect(draw.setGeometryValid).toHaveBeenCalledWith(false) expect(eventBus.emit).toHaveBeenCalledWith('draw:geometryinvalid', expect.objectContaining({ reason: expect.stringMatching(/intersect/i) })) @@ -228,14 +228,14 @@ describe('geometrychange validation', () => { test('gates a zero-area shape while drawing', () => { const { draw, dispatch } = setup() - drawHandler(draw, 'geometrychange')({ feature: collinearFeature, kind: 'add', vertexIndex: 2 }) + drawHandler(draw, 'geometrychange')({ feature: collinearFeature, phase: 'commit-add', vertexIndex: 2 }) expect(dispatch).toHaveBeenCalledWith({ type: 'SET_GEOMETRY_VALID', payload: false }) }) test('gates a self-intersecting move in edit mode (never reverts)', () => { const { draw, dispatch, eventBus } = setup() draw.getMode.mockReturnValue('edit_vertex') - drawHandler(draw, 'geometrychange')({ feature: bowtieFeature, kind: 'move', vertexIndex: 2 }) + drawHandler(draw, 'geometrychange')({ feature: bowtieFeature, phase: 'commit-move', vertexIndex: 2 }) expect(dispatch).toHaveBeenCalledWith({ type: 'SET_GEOMETRY_VALID', payload: false }) expect(eventBus.emit).toHaveBeenCalledWith('draw:geometryinvalid', expect.objectContaining({ reason: expect.stringMatching(/intersect/i) })) }) @@ -243,14 +243,14 @@ describe('geometrychange validation', () => { test('keeps a valid edit move (gate open)', () => { const { draw, dispatch } = setup() draw.getMode.mockReturnValue('edit_vertex') - drawHandler(draw, 'geometrychange')({ feature: squareFeature, kind: 'move', vertexIndex: 1 }) + drawHandler(draw, 'geometrychange')({ feature: squareFeature, phase: 'commit-move', vertexIndex: 1 }) expect(dispatch).toHaveBeenCalledWith({ type: 'SET_GEOMETRY_VALID', payload: true }) }) test('applies the per-session user validator as a gate', () => { const { draw, dispatch, eventBus } = setup() draw._geometryValidator = () => ({ valid: false, reason: 'too big' }) - drawHandler(draw, 'geometrychange')({ feature: squareFeature, kind: 'add', vertexIndex: 3 }) + drawHandler(draw, 'geometrychange')({ feature: squareFeature, phase: 'commit-add', vertexIndex: 3 }) expect(dispatch).toHaveBeenCalledWith({ type: 'SET_GEOMETRY_VALID', payload: false }) expect(eventBus.emit).toHaveBeenCalledWith('draw:geometryinvalid', expect.objectContaining({ reason: 'too big' })) }) @@ -258,11 +258,11 @@ describe('geometrychange validation', () => { test('drives the invalid stroke from committed validity in edit mode only', () => { const { draw } = setup() draw.getMode.mockReturnValue('draw_polygon') - drawHandler(draw, 'geometrychange')({ feature: bowtieFeature, kind: 'add', vertexIndex: 3 }) + drawHandler(draw, 'geometrychange')({ feature: bowtieFeature, phase: 'commit-add', vertexIndex: 3 }) expect(draw.setInvalid).not.toHaveBeenCalled() // draw mode: the adapter's live check owns the stroke draw.getMode.mockReturnValue('edit_vertex') - drawHandler(draw, 'geometrychange')({ feature: bowtieFeature, kind: 'move', vertexIndex: 2 }) + drawHandler(draw, 'geometrychange')({ feature: bowtieFeature, phase: 'commit-move', vertexIndex: 2 }) expect(draw.setInvalid).toHaveBeenCalledWith(true) }) @@ -293,7 +293,7 @@ describe('geometrychange validation', () => { test('relays a blocked placement to the public bus as draw:geometryinvalid', () => { const { draw, eventBus } = setup() - const blocked = { kind: 'place', mode: 'draw_polygon', vertexIndex: 2, reason: 'outside region', feature: { type: 'Feature' } } + const blocked = { phase: 'place', mode: 'draw_polygon', vertexIndex: 2, reason: 'outside region', feature: { type: 'Feature' } } drawHandler(draw, 'placementblocked')(blocked) expect(eventBus.emit).toHaveBeenCalledWith('draw:geometryinvalid', blocked) }) @@ -303,8 +303,8 @@ describe('geometrychange validation', () => { draw.getMode.mockReturnValue('draw_polygon') const validator = jest.fn(() => true) draw._geometryValidator = validator - drawHandler(draw, 'geometrychange')({ feature: squareFeature, kind: 'add', vertexIndex: 3 }) - expect(validator).toHaveBeenCalledWith(squareFeature, { kind: 'add', vertexIndex: 3, mode: 'draw_polygon' }) + drawHandler(draw, 'geometrychange')({ feature: squareFeature, phase: 'commit-add', vertexIndex: 3 }) + expect(validator).toHaveBeenCalledWith(squareFeature, { phase: 'commit-add', vertexIndex: 3, mode: 'draw_polygon' }) }) }) diff --git a/plugins/draw/src/utils/spatial.test.js b/plugins/draw/src/utils/spatial.test.js index 35829bd19..2a1a9a230 100644 --- a/plugins/draw/src/utils/spatial.test.js +++ b/plugins/draw/src/utils/spatial.test.js @@ -172,4 +172,4 @@ describe('spatialNavigate', () => { test('falls back to the start point when no pixel is in the quadrant', () => { expect(spatialNavigate(start, [[0, 0]], 'ArrowUp')).toBe(0) }) -}) \ No newline at end of file +}) diff --git a/plugins/draw/src/validation/rules.js b/plugins/draw/src/validation/rules.js index afa9360d0..945d082c4 100644 --- a/plugins/draw/src/validation/rules.js +++ b/plugins/draw/src/validation/rules.js @@ -11,7 +11,8 @@ import turfArea from '@turf/area' * the vertex never appears (used for unrecoverable states, e.g. a vertex * that would force the drawn path to cross itself). * - * `context` is `{ kind, vertexIndex, mode }` so rules can vary by change kind or mode. + * `context` is `{ phase, vertexIndex, mode }` (phase: import('../adapterEvents.js').GeometryChangePhase) + * so rules can vary by phase or mode. * Add a rule by appending it to SOFT_RULES or HARD_RULES. */ diff --git a/plugins/draw/src/validation/validateGeometry.js b/plugins/draw/src/validation/validateGeometry.js index 513d0f13b..11d9fdc58 100644 --- a/plugins/draw/src/validation/validateGeometry.js +++ b/plugins/draw/src/validation/validateGeometry.js @@ -23,7 +23,7 @@ const normaliseResult = (result) => { * user callback runs last. * * @param {object} feature - current GeoJSON feature - * @param {object} context - { kind: 'add'|'move'|'insert'|'delete', vertexIndex, mode } + * @param {object} context - { phase: import('../adapterEvents.js').GeometryChangePhase, vertexIndex, mode } * @param {object} [config] * @param {Array} [config.rules] - defaults to DEFAULT_RULES * @param {Function} [config.onGeometryChange] - user callback, same signature as a rule @@ -51,11 +51,11 @@ export const validateGeometry = (feature, context = {}, config = {}) => { * reject the placement (the vertex never appears), used for states that could * not be recovered from by continuing to draw. * - * The user callback receives `context.kind === 'place'` to distinguish a + * The user callback receives `context.phase === 'place'` to distinguish a * placement veto from a soft validity check. * * @param {object} feature - candidate GeoJSON feature (placed vertices + new point) - * @param {object} context - { vertexIndex, mode }; kind is forced to 'place' + * @param {object} context - { vertexIndex, mode }; phase is forced to 'place' * @param {object} [config] * @param {Array} [config.rules] - defaults to HARD_RULES * @param {Function} [config.onGeometryChange] - user callback, same signature as a rule @@ -63,7 +63,7 @@ export const validateGeometry = (feature, context = {}, config = {}) => { */ export const validatePlacement = (feature, context = {}, config = {}) => { const { rules = HARD_RULES, onGeometryChange } = config - return validateGeometry(feature, { ...context, kind: 'place' }, { rules, onGeometryChange }) + return validateGeometry(feature, { ...context, phase: 'place' }, { rules, onGeometryChange }) } export const MODE_BY_GEOMETRY = { Polygon: 'draw_polygon', LineString: 'draw_line' } @@ -90,7 +90,7 @@ export const checkPlacement = ({ placed, point, geometryType, onGeometryChange } const mode = MODE_BY_GEOMETRY[geometryType] const { valid, reason } = validatePlacement(feature, { mode, vertexIndex: placed.length }, { onGeometryChange }) if (valid) { return { valid: true } } - return { valid: false, blocked: { feature, reason: reason ?? null, kind: 'place', mode, vertexIndex: placed.length } } + return { valid: false, blocked: { feature, reason: reason ?? null, phase: 'place', mode, vertexIndex: placed.length } } } /** @@ -102,7 +102,7 @@ export const checkPlacement = ({ placed, point, geometryType, onGeometryChange } * against the displayed geometry, returning `{ valid, reason }`. * * @param {object} feature - displayed GeoJSON feature (placed vertices + cursor) - * @param {object} context - { mode, placedCount, kind }; kind defaults to 'preview' + * @param {object} context - { mode, placedCount, phase }; phase defaults to 'preview' * @param {object} [config] * @param {Array} [config.rules] - defaults to LIVE_RULES * @param {Function} [config.onGeometryChange] - user callback, same signature as a rule @@ -113,5 +113,5 @@ export const validateDisplayedGeometry = (feature, context = {}, config = {}) => const type = feature?.geometry?.type ?? feature?.type const min = MIN_VERTICES[type] ?? 0 if ((context.placedCount ?? 0) < min) { return { valid: true } } - return validateGeometry(feature, { ...context, kind: context.kind ?? 'preview' }, { rules, onGeometryChange }) + return validateGeometry(feature, { ...context, phase: context.phase ?? 'preview' }, { rules, onGeometryChange }) } diff --git a/plugins/draw/src/validation/validateGeometry.test.js b/plugins/draw/src/validation/validateGeometry.test.js index 696066b69..09d6725ad 100644 --- a/plugins/draw/src/validation/validateGeometry.test.js +++ b/plugins/draw/src/validation/validateGeometry.test.js @@ -36,7 +36,7 @@ describe('validateGeometry (soft gating)', () => { test('passes the context to rules and the callback', () => { const rule = jest.fn(() => ({ valid: true })) const onGeometryChange = jest.fn(() => ({ valid: true })) - const context = { kind: 'move', vertexIndex: 2, mode: 'edit_vertex' } + const context = { phase: 'commit-move', vertexIndex: 2, mode: 'edit_vertex' } validateGeometry(square, context, { rules: [rule], onGeometryChange }) expect(rule).toHaveBeenCalledWith(square, context) expect(onGeometryChange).toHaveBeenCalledWith(square, context) @@ -76,7 +76,7 @@ describe('checkPlacement (shared engine gate)', () => { expect(result.blocked).toEqual({ feature: expect.objectContaining({ type: 'Feature' }), reason: expect.stringMatching(/intersect/i), - kind: 'place', + phase: 'place', mode: 'draw_polygon', vertexIndex: 3 }) @@ -86,7 +86,7 @@ describe('checkPlacement (shared engine gate)', () => { const onGeometryChange = jest.fn(() => ({ valid: false, reason: 'outside region' })) const result = checkPlacement({ placed: [[0, 0]], point: [1, 1], geometryType: 'LineString', onGeometryChange }) expect(result.blocked).toEqual(expect.objectContaining({ mode: 'draw_line', reason: 'outside region', vertexIndex: 1 })) - expect(onGeometryChange).toHaveBeenCalledWith(expect.anything(), { kind: 'place', mode: 'draw_line', vertexIndex: 1 }) + expect(onGeometryChange).toHaveBeenCalledWith(expect.anything(), { phase: 'place', mode: 'draw_line', vertexIndex: 1 }) }) test('checkPlacement mode is set correctly for Polygon vs LineString', () => { @@ -105,8 +105,8 @@ describe('validateDisplayedGeometry edge cases', () => { expect(result.valid).toBe(true) // unknown types use MIN_VERTICES fallback of 0, so 0 placed = valid }) - test('feature without geometry type defaults to context.kind', () => { - const result = validateDisplayedGeometry({ type: 'Feature', geometry: { coordinates: [[0, 0]] } }, { placedCount: 2, kind: 'custom' }) + test('feature without geometry type defaults to context.phase', () => { + const result = validateDisplayedGeometry({ type: 'Feature', geometry: { coordinates: [[0, 0]] } }, { placedCount: 2, phase: 'custom' }) expect(typeof result).toBe('object') expect(result).toHaveProperty('valid') }) @@ -132,10 +132,10 @@ describe('validatePlacement (hard gating)', () => { expect(validatePlacement(simplePath)).toEqual({ valid: true }) }) - test('forces kind "place" into the rule and callback context', () => { + test('forces phase "place" into the rule and callback context', () => { const onGeometryChange = jest.fn(() => true) validatePlacement(simplePath, { mode: 'draw_polygon', vertexIndex: 4 }, { onGeometryChange }) - expect(onGeometryChange).toHaveBeenCalledWith(simplePath, { kind: 'place', mode: 'draw_polygon', vertexIndex: 4 }) + expect(onGeometryChange).toHaveBeenCalledWith(simplePath, { phase: 'place', mode: 'draw_polygon', vertexIndex: 4 }) }) test('the user callback can veto a placement with a reason', () => { From 8a54a82d8e7d476faff1059d442feb9dd492813b Mon Sep 17 00:00:00 2001 From: Dan Leech Date: Fri, 10 Jul 2026 13:21:47 +0100 Subject: [PATCH 65/89] Split amened to use exisitng geometryValidation mechanism --- plugins/draw/src/api/split.js | 80 +++++-------- plugins/draw/src/api/split.test.js | 181 +++++++++-------------------- 2 files changed, 81 insertions(+), 180 deletions(-) diff --git a/plugins/draw/src/api/split.js b/plugins/draw/src/api/split.js index 24d32581b..98e8e0169 100644 --- a/plugins/draw/src/api/split.js +++ b/plugins/draw/src/api/split.js @@ -1,16 +1,15 @@ import { splitPolygon } from '../utils/spatial.js' -import { debounce } from '../utils/debounce.js' import { ADAPTER_EVENTS } from '../adapterEvents.js' -const DEBOUNCE_MS = 10 +const INVALID_REASON = 'Line does not split the shape into two parts' // Recompute split validity and re-apply the same gate normal draw/edit uses: // disables Done (manifest.js enableWhen reads pluginState.geometryValid) and blocks // the double-click/click-vertex finish gesture (drawMode/clickHandlers.js reads // map._drawGeometryValid) while the line wouldn't produce a valid split. Only uses // the shared, engine-agnostic adapter interface — no mapbox-gl-draw internals. -const applySplitValidity = ({ draw, dispatch, polygonFeature, coordinates }) => { - const lineFeature = { id: '_splitter', geometry: { type: 'LineString', coordinates } } +const applySplitValidity = ({ draw, dispatch, polygonFeature, feature }) => { + const lineFeature = { id: '_splitter', geometry: feature.geometry } const featureCollection = splitPolygon(polygonFeature, lineFeature) const isValid = !!featureCollection // The splitter line has no stable id until it's actually created, so the @@ -21,45 +20,28 @@ const applySplitValidity = ({ draw, dispatch, polygonFeature, coordinates }) => dispatch({ type: 'SET_ACTION', payload: { name: 'split', isValid } }) dispatch({ type: 'SET_GEOMETRY_VALID', payload: isValid }) draw.setGeometryValid(isValid) + return isValid ? { valid: true } : { valid: false, reason: INVALID_REASON } } -// Real-time preview + completion gate as the splitter line is drawn (ML only — -// setFeatureProperty is a no-op on the OL adapter, per its documented interface). +// Installed as draw._geometryValidator for the splitter-line session, so the shared +// validation pipeline (rules.js / validateGeometry.js, and the adapters' live-stroke +// and live-placement controllers) drives split's validity the same way any other draw +// session's rules do — no separate GEOMETRY_CHANGE listener, debounce, or +// event-ordering correction needed. // -// events.js has its own shared geometrychange handler, and it also reacts to -// every commit-level event (one with a `phase`, e.g. commit-add/move/insert/ -// delete). Since split nulls out the user validator (see split() below), that -// shared handler always marks the event valid via the default rules — clobbering -// split's own validity gate. DrawInit's effect re-attaches the shared handler on -// every pluginState change (detach+reattach reorders the event bus's listener -// Set), so which handler runs first for a given event isn't stable — this can't -// rely on registration order to "win". Instead the correction is deferred one -// more tick: whatever events.js did already ran synchronously by the time this -// fires, so this is always the final word regardless of ordering. -const createGeometryChangeHandler = ({ draw, dispatch, polygonFeature, isStopped }) => { - const apply = (coordinates) => applySplitValidity({ draw, dispatch, polygonFeature, coordinates }) - const debouncedPreview = debounce(apply, DEBOUNCE_MS) - - return (e) => { - if (e?.phase) { - debouncedPreview.cancel?.() - // Commit-level events carry { feature, phase, vertexIndex } per the shared - // adapter contract (adapterEvents.js) rather than a top-level coordinates. - const coordinates = e.feature?.geometry?.coordinates - if (!coordinates || coordinates.length < 2) { - return - } - setTimeout(() => { - if (isStopped()) { return } - apply(coordinates) - }, 0) - return - } - if (!e.coordinates || e.coordinates.length < 2) { - return - } - debouncedPreview(e.coordinates) +// 'place' (hard placement veto — checkPlacement/validatePlacement) and 'create' +// (whole-feature finish check — events.js's onCreate, which on failure drops the +// feature into edit_vertex mode) must never run split's rule: the splitter line +// isn't a complete valid split until its final vertex, so almost every intermediate +// placement or early finish would "fail" a check that was never meant to gate them. +// Only the continuous soft checks — the live preview and each committed vertex — +// actually evaluate the split. +const createSplitValidator = ({ draw, dispatch, polygonFeature }) => (feature, context) => { + const isSoftCheck = context.phase === 'preview' || context.phase?.startsWith('commit-') + if (!isSoftCheck) { + return { valid: true } } + return applySplitValidity({ draw, dispatch, polygonFeature, feature }) } /** @@ -84,8 +66,11 @@ export const split = ({ appState, appConfig, pluginState, mapState, mapProvider, const polygonFeature = draw.get(featureId) - // Split draws its own throwaway line; user geometry validation must not apply here. - draw._geometryValidator = null + // Swap in split's own rule for the splitter-line session; restored in + // stopListening so the polygon's own developer-supplied validator isn't left + // permanently replaced once the split session ends. + const previousValidator = draw._geometryValidator + draw._geometryValidator = createSplitValidator({ draw, dispatch, polygonFeature }) // Always include the draw outline layer so the split line snaps to it const snapLayers = ['stroke-inactive.cold', ...(options.snapLayers || [])] @@ -102,16 +87,12 @@ export const split = ({ appState, appConfig, pluginState, mapState, mapProvider, properties: { splitter: 'invalid' } }) - // Both listeners are scoped to this one splitter-line session — leaving either - // registered past that would leak into whatever the user draws or edits next. - // Also guards the deferred correction in createGeometryChangeHandler: once true, - // a stale setTimeout callback from a since-ended session must not touch shared state. - let stopped = false + // Scoped to this one splitter-line session — leaving either registered past + // that would leak into whatever the user draws or edits next. const stopListening = () => { - stopped = true + draw._geometryValidator = previousValidator draw.off(ADAPTER_EVENTS.CREATE, onSplitCreate) draw.off(ADAPTER_EVENTS.CANCEL, onSplitCancel) - draw.off(ADAPTER_EVENTS.GEOMETRY_CHANGE, onGeometryChange) } // One-shot: compute split result once the line is finalised @@ -137,9 +118,6 @@ export const split = ({ appState, appConfig, pluginState, mapState, mapProvider, draw.on(ADAPTER_EVENTS.CREATE, onSplitCreate) draw.on(ADAPTER_EVENTS.CANCEL, onSplitCancel) - const onGeometryChange = createGeometryChangeHandler({ draw, dispatch, polygonFeature, isStopped: () => stopped }) - draw.on(ADAPTER_EVENTS.GEOMETRY_CHANGE, onGeometryChange) - dispatch({ type: 'SET_MODE', payload: 'draw_line' }) dispatch({ type: 'SET_ACTION', payload: { name: 'split' } }) } diff --git a/plugins/draw/src/api/split.test.js b/plugins/draw/src/api/split.test.js index 4ac1c9350..da3550638 100644 --- a/plugins/draw/src/api/split.test.js +++ b/plugins/draw/src/api/split.test.js @@ -2,7 +2,6 @@ import { split } from './split.js' import { splitPolygon } from '../utils/spatial.js' jest.mock('../utils/spatial.js', () => ({ splitPolygon: jest.fn() })) -jest.mock('../utils/debounce.js', () => ({ debounce: jest.fn((fn) => fn) })) const makeContext = (overrides = {}) => { const dispatch = jest.fn() @@ -31,6 +30,8 @@ const makeContext = (overrides = {}) => { const handlerFor = (draw, event) => draw.on.mock.calls.find(([name]) => name === event)?.[1] +const lineFeature = (coordinates) => ({ type: 'Feature', geometry: { type: 'LineString', coordinates } }) + beforeEach(() => jest.clearAllMocks()) describe('split', () => { @@ -40,12 +41,12 @@ describe('split', () => { expect(dispatch).not.toHaveBeenCalled() }) - test('sets up the splitter line drawing and registers listeners', () => { + test('sets up the splitter line drawing, installs a validator, and registers listeners', () => { const { context, dispatch, draw } = makeContext() split(context, 'poly') - expect(draw._geometryValidator).toBeNull() + expect(typeof draw._geometryValidator).toBe('function') expect(draw.setSnapLayers).toHaveBeenCalledWith(['stroke-inactive.cold']) expect(dispatch).toHaveBeenCalledWith({ type: 'SET_HAS_SNAP_LAYERS', payload: true }) expect(draw.changeMode).toHaveBeenCalledWith('draw_line', expect.objectContaining({ @@ -57,7 +58,8 @@ describe('split', () => { properties: { splitter: 'invalid' } })) expect(draw.on).toHaveBeenCalledWith('create', expect.any(Function)) - expect(draw.on).toHaveBeenCalledWith('geometrychange', expect.any(Function)) + expect(draw.on).toHaveBeenCalledWith('cancel', expect.any(Function)) + expect(draw.on).not.toHaveBeenCalledWith('geometrychange', expect.anything()) expect(dispatch).toHaveBeenCalledWith({ type: 'SET_MODE', payload: 'draw_line' }) expect(dispatch).toHaveBeenCalledWith({ type: 'SET_ACTION', payload: { name: 'split' } }) expect(draw.changeMode.mock.calls[0][1].getSnapEnabled()).toBe(true) @@ -69,10 +71,12 @@ describe('split', () => { expect(draw.setSnapLayers).toHaveBeenCalledWith(['stroke-inactive.cold', 'extra']) }) - test('finalising the line computes a valid split and emits the result', () => { + test('finalising the line computes a valid split, emits the result, and restores the previous validator', () => { const { context, dispatch, draw, eventBus } = makeContext() const polygonFeature = { id: 'poly' } const featureCollection = { type: 'FeatureCollection' } + const previousValidator = jest.fn() + draw._geometryValidator = previousValidator draw.get.mockReturnValue(polygonFeature) splitPolygon.mockReturnValue(featureCollection) @@ -83,14 +87,16 @@ describe('split', () => { expect(draw.off).toHaveBeenCalledWith('create', onCreate) expect(draw.off).toHaveBeenCalledWith('cancel', expect.any(Function)) - expect(draw.off).toHaveBeenCalledWith('geometrychange', expect.any(Function)) expect(splitPolygon).toHaveBeenCalledWith(polygonFeature, geojson) expect(dispatch).toHaveBeenCalledWith({ type: 'SET_ACTION', payload: { name: 'split', isValid: true } }) expect(eventBus.emit).toHaveBeenCalledWith('draw:split', { originalFeatureId: 'poly', featureCollection }) + expect(draw._geometryValidator).toBe(previousValidator) }) - test('cancelling the splitter line stops listening without computing a split', () => { + test('cancelling the splitter line stops listening and restores the previous validator', () => { const { context, draw } = makeContext() + const previousValidator = jest.fn() + draw._geometryValidator = previousValidator split(context, 'poly') const onCancel = handlerFor(draw, 'cancel') @@ -98,22 +104,8 @@ describe('split', () => { expect(draw.off).toHaveBeenCalledWith('create', expect.any(Function)) expect(draw.off).toHaveBeenCalledWith('cancel', onCancel) - expect(draw.off).toHaveBeenCalledWith('geometrychange', expect.any(Function)) expect(splitPolygon).not.toHaveBeenCalled() - }) - - test('a stale geometrychange listener cannot fire after the split line is created', () => { - const { context, draw } = makeContext() - splitPolygon.mockReturnValue({ type: 'FeatureCollection' }) - - split(context, 'poly') - const onCreate = handlerFor(draw, 'create') - onCreate({ id: 'line' }) - - // Real draw.off would deregister the handler; confirm split.js requested it - // for every listener it registered, so none can leak into a later session. - const offEvents = draw.off.mock.calls.map(([name]) => name) - expect(offEvents).toEqual(expect.arrayContaining(['create', 'cancel', 'geometrychange'])) + expect(draw._geometryValidator).toBe(previousValidator) }) test('finalising the line computes an invalid split and does not emit', () => { @@ -127,133 +119,64 @@ describe('split', () => { expect(eventBus.emit).not.toHaveBeenCalled() }) - test('geometry change updates validity via the adapter only — no mapbox-gl-draw internals', () => { - const { context, dispatch, draw } = makeContext() - splitPolygon.mockReturnValue({ type: 'FeatureCollection' }) - - split(context, 'poly') - const e = { coordinates: [[0, 0], [1, 1]] } - handlerFor(draw, 'geometrychange')(e) - - expect(draw.setDrawingPreviewProperty).toHaveBeenCalledWith('splitter', 'valid') - expect(dispatch).toHaveBeenCalledWith({ type: 'SET_ACTION', payload: { name: 'split', isValid: true } }) - // A valid split line must allow completion: Done enabled (pluginState.geometryValid) - // and the double-click/click-vertex finish gesture unblocked (map._drawGeometryValid). - expect(dispatch).toHaveBeenCalledWith({ type: 'SET_GEOMETRY_VALID', payload: true }) - expect(draw.setGeometryValid).toHaveBeenCalledWith(true) - }) - - test('an invalid split line blocks completion: disables Done and the finish gesture', () => { - const { context, dispatch, draw } = makeContext() - splitPolygon.mockReturnValue(null) - - split(context, 'poly') - const e = { coordinates: [[0, 0], [1, 1]] } - handlerFor(draw, 'geometrychange')(e) - - expect(draw.setDrawingPreviewProperty).toHaveBeenCalledWith('splitter', 'invalid') - expect(dispatch).toHaveBeenCalledWith({ type: 'SET_GEOMETRY_VALID', payload: false }) - expect(draw.setGeometryValid).toHaveBeenCalledWith(false) - }) - - test('geometry change ignores lines with fewer than two coordinates', () => { - const { context, draw } = makeContext() - split(context, 'poly') - splitPolygon.mockClear() - - handlerFor(draw, 'geometrychange')({ coordinates: [[0, 0]] }) - - expect(splitPolygon).not.toHaveBeenCalled() - }) - - // events.js's shared onGeometryChange also reacts to this commit event and — since - // split nulls out the user validator — always marks it valid via the default rules, - // clobbering split's own gate. DrawInit re-attaches that shared handler on every - // pluginState change, so listener order on the bus isn't stable; the correction is - // deferred one tick (a real setTimeout) so it's always the final word regardless. - describe('commit-level (has a phase) geometry change', () => { - beforeEach(() => jest.useFakeTimers()) - afterEach(() => jest.useRealTimers()) - - test('re-validates from a valid split after the deferred tick', () => { + describe('the installed draw._geometryValidator', () => { + test('never blocks a hard placement veto (phase: place)', () => { const { context, dispatch, draw } = makeContext() - splitPolygon.mockReturnValue({ type: 'FeatureCollection' }) - split(context, 'poly') - const commitEvent = { - phase: 'commit-add', - vertexIndex: 1, - feature: { type: 'Feature', geometry: { type: 'LineString', coordinates: [[0, 0], [1, 1]] }, properties: {} } - } - handlerFor(draw, 'geometrychange')(commitEvent) - jest.runAllTimers() - - expect(splitPolygon).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ - geometry: { type: 'LineString', coordinates: [[0, 0], [1, 1]] } - })) - expect(dispatch).toHaveBeenCalledWith({ type: 'SET_GEOMETRY_VALID', payload: true }) - expect(draw.setGeometryValid).toHaveBeenCalledWith(true) - }) - - test('re-validates from an invalid split after the deferred tick', () => { - const { context, dispatch, draw } = makeContext() - splitPolygon.mockReturnValue(null) + dispatch.mockClear() - split(context, 'poly') - handlerFor(draw, 'geometrychange')({ - phase: 'commit-add', - feature: { type: 'Feature', geometry: { type: 'LineString', coordinates: [[0, 0], [1, 1]] }, properties: {} } - }) - jest.runAllTimers() + const result = draw._geometryValidator(lineFeature([[0, 0], [1, 1]]), { phase: 'place', mode: 'draw_line', vertexIndex: 1 }) - expect(dispatch).toHaveBeenCalledWith({ type: 'SET_GEOMETRY_VALID', payload: false }) - expect(draw.setGeometryValid).toHaveBeenCalledWith(false) + expect(result).toEqual({ valid: true }) + expect(splitPolygon).not.toHaveBeenCalled() + expect(draw.setDrawingPreviewProperty).not.toHaveBeenCalled() + expect(dispatch).not.toHaveBeenCalledWith(expect.objectContaining({ type: 'SET_ACTION' })) }) - test('with too few coordinates is ignored and never schedules a correction', () => { - const { context, draw } = makeContext() + test('never blocks the whole-feature finish check (phase: create)', () => { + const { context, dispatch, draw } = makeContext() split(context, 'poly') - splitPolygon.mockClear() + dispatch.mockClear() - handlerFor(draw, 'geometrychange')({ - phase: 'commit-add', - feature: { type: 'Feature', geometry: { type: 'LineString', coordinates: [[0, 0]] }, properties: {} } - }) - jest.runAllTimers() + const result = draw._geometryValidator(lineFeature([[0, 0], [1, 1]]), { phase: 'create', mode: 'draw_line' }) + expect(result).toEqual({ valid: true }) expect(splitPolygon).not.toHaveBeenCalled() + expect(dispatch).not.toHaveBeenCalledWith(expect.objectContaining({ type: 'SET_ACTION' })) }) - test('does not touch state if the split session already ended before the tick fires', () => { + test('evaluates a valid split on the live preview (phase: preview)', () => { const { context, dispatch, draw } = makeContext() + const polygonFeature = { id: 'poly' } + draw.get.mockReturnValue(polygonFeature) splitPolygon.mockReturnValue({ type: 'FeatureCollection' }) - split(context, 'poly') - handlerFor(draw, 'geometrychange')({ - phase: 'commit-add', - feature: { type: 'Feature', geometry: { type: 'LineString', coordinates: [[0, 0], [1, 1]] }, properties: {} } - }) - // The split completes (or is cancelled) before the deferred tick runs. - handlerFor(draw, 'create')({ id: 'line' }) - dispatch.mockClear() - draw.setGeometryValid.mockClear() - jest.runAllTimers() + const feature = lineFeature([[0, 0], [1, 1]]) + const result = draw._geometryValidator(feature, { phase: 'preview', mode: 'draw_line', placedCount: 2 }) - expect(dispatch).not.toHaveBeenCalled() - expect(draw.setGeometryValid).not.toHaveBeenCalled() + expect(splitPolygon).toHaveBeenCalledWith(polygonFeature, { id: '_splitter', geometry: feature.geometry }) + expect(draw.setDrawingPreviewProperty).toHaveBeenCalledWith('splitter', 'valid') + expect(dispatch).toHaveBeenCalledWith({ type: 'SET_ACTION', payload: { name: 'split', isValid: true } }) + // A valid split line must allow completion: Done enabled (pluginState.geometryValid) + // and the double-click/click-vertex finish gesture unblocked (map._drawGeometryValid). + expect(dispatch).toHaveBeenCalledWith({ type: 'SET_GEOMETRY_VALID', payload: true }) + expect(draw.setGeometryValid).toHaveBeenCalledWith(true) + expect(result).toEqual({ valid: true }) }) - }) - test('geometry change marks invalid splits', () => { - const { context, dispatch, draw } = makeContext() - splitPolygon.mockReturnValue(null) + test('evaluates an invalid split with a reason on a committed vertex (phase: commit-add)', () => { + const { context, dispatch, draw } = makeContext() + splitPolygon.mockReturnValue(null) + split(context, 'poly') - split(context, 'poly') - const e = { coordinates: [[0, 0], [1, 1]] } + const feature = lineFeature([[0, 0], [1, 1]]) + const result = draw._geometryValidator(feature, { phase: 'commit-add', mode: 'draw_line', vertexIndex: 1 }) - expect(() => handlerFor(draw, 'geometrychange')(e)).not.toThrow() - expect(draw.setDrawingPreviewProperty).toHaveBeenCalledWith('splitter', 'invalid') - expect(dispatch).toHaveBeenCalledWith({ type: 'SET_ACTION', payload: { name: 'split', isValid: false } }) + expect(draw.setDrawingPreviewProperty).toHaveBeenCalledWith('splitter', 'invalid') + expect(dispatch).toHaveBeenCalledWith({ type: 'SET_GEOMETRY_VALID', payload: false }) + expect(draw.setGeometryValid).toHaveBeenCalledWith(false) + expect(result).toEqual({ valid: false, reason: expect.any(String) }) + }) }) }) From 5efa9579c2b97acd0419c05669c13e095c315bea Mon Sep 17 00:00:00 2001 From: Dan Leech Date: Fri, 10 Jul 2026 13:38:55 +0100 Subject: [PATCH 66/89] Comment shortened --- plugins/draw/src/api/split.js | 17 ++++------------- 1 file changed, 4 insertions(+), 13 deletions(-) diff --git a/plugins/draw/src/api/split.js b/plugins/draw/src/api/split.js index 98e8e0169..557a1cd81 100644 --- a/plugins/draw/src/api/split.js +++ b/plugins/draw/src/api/split.js @@ -23,19 +23,10 @@ const applySplitValidity = ({ draw, dispatch, polygonFeature, feature }) => { return isValid ? { valid: true } : { valid: false, reason: INVALID_REASON } } -// Installed as draw._geometryValidator for the splitter-line session, so the shared -// validation pipeline (rules.js / validateGeometry.js, and the adapters' live-stroke -// and live-placement controllers) drives split's validity the same way any other draw -// session's rules do — no separate GEOMETRY_CHANGE listener, debounce, or -// event-ordering correction needed. -// -// 'place' (hard placement veto — checkPlacement/validatePlacement) and 'create' -// (whole-feature finish check — events.js's onCreate, which on failure drops the -// feature into edit_vertex mode) must never run split's rule: the splitter line -// isn't a complete valid split until its final vertex, so almost every intermediate -// placement or early finish would "fail" a check that was never meant to gate them. -// Only the continuous soft checks — the live preview and each committed vertex — -// actually evaluate the split. +// Installed as draw._geometryValidator so the normal validation pipeline drives +// split's validity too. Skip 'place' and 'create': the line isn't a full split until +// its last vertex, so checking those would block placement and hijack an early +// finish into edit mode. Only check on the live preview and each committed vertex. const createSplitValidator = ({ draw, dispatch, polygonFeature }) => (feature, context) => { const isSoftCheck = context.phase === 'preview' || context.phase?.startsWith('commit-') if (!isSoftCheck) { From 29af1292e64798bc4f30c42bce96fee087446cb0 Mon Sep 17 00:00:00 2001 From: Dan Leech Date: Fri, 10 Jul 2026 15:24:03 +0100 Subject: [PATCH 67/89] Merge shapes added --- demo/js/draw.js | 27 ++++++++++ package-lock.json | 69 ++++++++++++++++++++++++++ package.json | 1 + plugins/draw/src/api/merge.js | 29 +++++++++-- plugins/draw/src/api/merge.test.js | 56 ++++++++++++++++++--- plugins/draw/src/utils/spatial.js | 27 ++++++++++ plugins/draw/src/utils/spatial.test.js | 55 ++++++++++++++++++++ 7 files changed, 253 insertions(+), 11 deletions(-) diff --git a/demo/js/draw.js b/demo/js/draw.js index 9e696e5ca..56704ce7d 100755 --- a/demo/js/draw.js +++ b/demo/js/draw.js @@ -204,6 +204,15 @@ interactiveMap.on('map:ready', function (e) { interactiveMap.toggleButtonState('geometryActions', 'hidden', true) interactPlugin.disable() } + },{ + id: 'mergeShapes', + label: 'Merge shapes', + iconSvgContent: '', + isDisabled: true, + onClick: function (e) { + drawPlugin.merge(selectedFeatureIds) + interactPlugin.clear() + } },{ id: 'deleteFeature', label: 'Delete feature', @@ -217,6 +226,7 @@ interactiveMap.on('map:ready', function (e) { interactiveMap.toggleButtonState('drawLine', 'disabled', false) interactiveMap.toggleButtonState('editFeature', 'disabled', true) interactiveMap.toggleButtonState('splitShape', 'disabled', true) + interactiveMap.toggleButtonState('mergeShapes', 'disabled', true) interactiveMap.toggleButtonState('deleteFeature', 'disabled', true) } }] @@ -291,11 +301,13 @@ interactiveMap.on('interact:selectionchange', function (e) { const isDrawFeature = singleFeature && drawLayers.includes(e.selectedFeatures[0].layerId) const isPolygon = singleFeature && e.selectedFeatures[0].geometryType === 'Polygon' const allDrawFeatures = anyFeature && e.selectedFeatures.every(function (f) { return drawLayers.includes(f.layerId) }) + const canMerge = allDrawFeatures && e.contiguous && e.selectedFeatures.length > 1 selectedFeatureIds = e.selectedFeatures.map(function (f) { return f.featureId }) interactiveMap.toggleButtonState('drawPolygon', 'disabled', !!singleFeature) interactiveMap.toggleButtonState('drawLine', 'disabled', !!singleFeature) interactiveMap.toggleButtonState('editFeature', 'disabled', !isDrawFeature) interactiveMap.toggleButtonState('splitShape', 'disabled', !isPolygon) + interactiveMap.toggleButtonState('mergeShapes', 'disabled', !canMerge) interactiveMap.toggleButtonState('deleteFeature', 'disabled', !allDrawFeatures) }) @@ -319,6 +331,21 @@ interactiveMap.on('draw:split', function (e) { interactPlugin.clear() }) +interactiveMap.on('draw:merge', function (e) { + // console.log('draw:merge', { originalFeatureIds: e.originalFeatureIds, feature: e.feature }) + + // Delete the original polygons + drawPlugin.deleteFeature(e.originalFeatureIds) + + // Add the single merged feature, keeping the first original's id + drawPlugin.addFeature({ + id: e.originalFeatureIds[0], + type: e.feature.type, + geometry: e.feature.geometry, + properties: e.feature.properties + }) +}) + interactiveMap.on('interact:markerchange', function (e) { // console.log('interact:markerchange', e) }) diff --git a/package-lock.json b/package-lock.json index 6684c5ecf..52298c0fd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,6 +19,7 @@ "@turf/line-intersect": "^7.3.3", "@turf/point-to-line-distance": "^7.3.3", "@turf/polygon-to-line": "^7.3.3", + "@turf/union": "^7.3.5", "accessible-autocomplete": "^3.0.1", "govuk-frontend": "^5.13.0", "maplibre-gl": "^5.23.0", @@ -10245,6 +10246,49 @@ "url": "https://opencollective.com/turf" } }, + "node_modules/@turf/union": { + "version": "7.3.5", + "resolved": "https://registry.npmjs.org/@turf/union/-/union-7.3.5.tgz", + "integrity": "sha512-/FSKhl+LX4+M7L/Trmiln0CDPWS8vCneGnQktt1o5XbCY/zIpH1JdxHEBFXhFZg4beAyXCz0uuxRyW9N/DH+KA==", + "license": "MIT", + "dependencies": { + "@turf/helpers": "7.3.5", + "@turf/meta": "7.3.5", + "@types/geojson": "^7946.0.10", + "polyclip-ts": "^0.16.8", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/union/node_modules/@turf/helpers": { + "version": "7.3.5", + "resolved": "https://registry.npmjs.org/@turf/helpers/-/helpers-7.3.5.tgz", + "integrity": "sha512-E/NMGV5MwbjjP7AJXBtsanC3yY8N2MQ87IGdIgkB2ji5AtBpwnH4L3gEqpYN4RlCJJWbLbzO91BbKv2waUd0eg==", + "license": "MIT", + "dependencies": { + "@types/geojson": "^7946.0.10", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/union/node_modules/@turf/meta": { + "version": "7.3.5", + "resolved": "https://registry.npmjs.org/@turf/meta/-/meta-7.3.5.tgz", + "integrity": "sha512-r+ohqxoyqeigFB0oFrQx/YEHIkOKqcKpCjvZkvZs7Tkv+IFco5MezAd2zd4rzK+0DfFgDP3KpJc7HqrYjvEjhg==", + "license": "MIT", + "dependencies": { + "@turf/helpers": "7.3.5", + "@types/geojson": "^7946.0.10", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, "node_modules/@tybys/wasm-util": { "version": "0.10.1", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", @@ -12191,6 +12235,15 @@ "node": "*" } }, + "node_modules/bignumber.js": { + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", + "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==", + "license": "MIT", + "engines": { + "node": "*" + } + }, "node_modules/binary-extensions": { "version": "2.3.0", "dev": true, @@ -25473,6 +25526,16 @@ "robust-predicates": "^3.0.2" } }, + "node_modules/polyclip-ts": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/polyclip-ts/-/polyclip-ts-0.16.8.tgz", + "integrity": "sha512-JPtKbDRuPEuAjuTdhR62Gph7Is2BS1Szx69CFOO3g71lpJDFo78k4tFyi+qFOMVPePEzdSKkpGU3NBXPHHjvKQ==", + "license": "MIT", + "dependencies": { + "bignumber.js": "^9.1.0", + "splaytree-ts": "^1.0.2" + } + }, "node_modules/polygon-splitter": { "version": "0.0.11", "license": "MIT", @@ -30147,6 +30210,12 @@ "dev": true, "license": "MIT" }, + "node_modules/splaytree-ts": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/splaytree-ts/-/splaytree-ts-1.0.2.tgz", + "integrity": "sha512-0kGecIZNIReCSiznK3uheYB8sbstLjCZLiwcQwbmLhgHJj2gz6OnSPkVzJQCMnmEz1BQ4gPK59ylhBoEWOhGNA==", + "license": "BDS-3-Clause" + }, "node_modules/sprintf-js": { "version": "1.0.3", "dev": true, diff --git a/package.json b/package.json index 3c5a611fd..73e3d7f30 100755 --- a/package.json +++ b/package.json @@ -233,6 +233,7 @@ "@turf/line-intersect": "^7.3.3", "@turf/point-to-line-distance": "^7.3.3", "@turf/polygon-to-line": "^7.3.3", + "@turf/union": "^7.3.5", "accessible-autocomplete": "^3.0.1", "govuk-frontend": "^5.13.0", "maplibre-gl": "^5.23.0", diff --git a/plugins/draw/src/api/merge.js b/plugins/draw/src/api/merge.js index 6e3eb5374..42c46f7c2 100644 --- a/plugins/draw/src/api/merge.js +++ b/plugins/draw/src/api/merge.js @@ -1,11 +1,30 @@ +import { mergePolygons } from '../utils/spatial.js' + /** - * Merge multiple polygons into a single polygon. + * Merge multiple contiguous polygons into a single polygon. * - * Not yet implemented — stub only. + * Pure computation, like split — this does not touch the feature store. The + * caller is responsible for deleting the original features and adding the + * merged result (see the draw:merge event). * * @param {object} context - plugin context - * @param {Array} polygons - array of GeoJSON polygon features to merge + * @param {string[]} featureIds - IDs of the polygons to merge + * @returns {object|null} the merged GeoJSON feature, or null if the merge failed */ -export const merge = (_context, polygons) => { - console.warn('draw: merge is not yet implemented', polygons) +export const merge = ({ mapProvider, services }, featureIds) => { + const { draw } = mapProvider + const { eventBus } = services + + if (!draw) { + return null + } + + const polygons = featureIds.map((id) => draw.get(id)) + const feature = mergePolygons(polygons) + + if (feature) { + eventBus.emit('draw:merge', { originalFeatureIds: featureIds, feature }) + } + + return feature } diff --git a/plugins/draw/src/api/merge.test.js b/plugins/draw/src/api/merge.test.js index a7c4a90a7..f37caf85f 100644 --- a/plugins/draw/src/api/merge.test.js +++ b/plugins/draw/src/api/merge.test.js @@ -1,13 +1,57 @@ import { merge } from './merge.js' +import { mergePolygons } from '../utils/spatial.js' + +jest.mock('../utils/spatial.js', () => ({ mergePolygons: jest.fn() })) + +const makeContext = (overrides = {}) => { + const eventBus = { emit: jest.fn() } + const draw = { get: jest.fn((id) => ({ id })) } + const context = { + mapProvider: { draw }, + services: { eventBus }, + ...overrides + } + return { context, draw, eventBus } +} + +beforeEach(() => jest.clearAllMocks()) describe('merge', () => { - test('warns that it is not yet implemented and passes through the polygons', () => { - const spy = jest.spyOn(console, 'warn').mockImplementation(() => {}) - const polygons = [{ id: 'p1' }, { id: 'p2' }] + test('does nothing when there is no draw instance', () => { + const { context, eventBus } = makeContext({ mapProvider: { draw: null } }) + expect(merge(context, ['a', 'b'])).toBeNull() + expect(eventBus.emit).not.toHaveBeenCalled() + }) + + test('resolves each feature id via the adapter and merges them', () => { + const { context, draw } = makeContext() + mergePolygons.mockReturnValue({ id: 'a', geometry: { type: 'Polygon', coordinates: [] } }) + + merge(context, ['a', 'b']) + + expect(draw.get).toHaveBeenCalledWith('a') + expect(draw.get).toHaveBeenCalledWith('b') + expect(mergePolygons).toHaveBeenCalledWith([{ id: 'a' }, { id: 'b' }]) + }) + + test('returns the merged feature and emits draw:merge on success', () => { + const { context, eventBus } = makeContext() + const feature = { id: 'a', geometry: { type: 'Polygon', coordinates: [] } } + mergePolygons.mockReturnValue(feature) + + const result = merge(context, ['a', 'b']) + + expect(result).toBe(feature) + expect(eventBus.emit).toHaveBeenCalledWith('draw:merge', { originalFeatureIds: ['a', 'b'], feature }) + }) + + test('returns null and does not emit when the merge fails', () => { + const { context, eventBus } = makeContext() + mergePolygons.mockReturnValue(null) - merge({}, polygons) + const result = merge(context, ['a', 'b']) - expect(spy).toHaveBeenCalledWith('draw: merge is not yet implemented', polygons) - spy.mockRestore() + expect(result).toBeNull() + expect(eventBus.emit).not.toHaveBeenCalled() }) }) diff --git a/plugins/draw/src/utils/spatial.js b/plugins/draw/src/utils/spatial.js index 3d871d16e..eaf5b19be 100755 --- a/plugins/draw/src/utils/spatial.js +++ b/plugins/draw/src/utils/spatial.js @@ -1,6 +1,7 @@ import polygonSplitter from 'polygon-splitter' import turfBearing from '@turf/bearing' import turfDestination from '@turf/destination' +import turfUnion from '@turf/union' import { featureCollection as turfFeatureCollection, polygon as turfPolygon, @@ -83,6 +84,31 @@ const splitPolygon = (polygon, line) => { return turfFeatureCollection(features) } +/** + * Merge multiple contiguous polygons into a single polygon. + * Only accepts a merge that results in exactly one polygon — a gap between two + * of the inputs would make Turf return a MultiPolygon instead, which is rejected. + * + * @param {Polygon[]} polygons + * @returns {Polygon|null} + */ +const mergePolygons = (polygons) => { + let result + try { + result = turfUnion(turfFeatureCollection(polygons)) + } catch { + return null + } + + if (result?.geometry?.type !== 'Polygon') { + return null + } + + // Assign ID & properties from the first feature, matching splitPolygon's convention. + const baseId = polygons[0].id ?? polygons[0].properties?.id ?? 'poly' + return turfPolygon(result.geometry.coordinates, { ...polygons[0].properties, id: baseId }, { id: baseId }) +} + /** * Convert a GeoJSON Feature or geometry-like object into a Turf geometry. * @@ -200,6 +226,7 @@ const spatialNavigate = (start, pixels, direction) => { export { toTurfGeometry, splitPolygon, + mergePolygons, extendLine, isNewCoordinate, isValidClick, diff --git a/plugins/draw/src/utils/spatial.test.js b/plugins/draw/src/utils/spatial.test.js index 2a1a9a230..d5d7c9a46 100644 --- a/plugins/draw/src/utils/spatial.test.js +++ b/plugins/draw/src/utils/spatial.test.js @@ -1,7 +1,9 @@ import polygonSplitter from 'polygon-splitter' +import turfUnion from '@turf/union' import { toTurfGeometry, splitPolygon, + mergePolygons, extendLine, isNewCoordinate, isValidClick, @@ -10,6 +12,7 @@ import { } from './spatial.js' jest.mock('polygon-splitter', () => jest.fn()) +jest.mock('@turf/union', () => jest.fn()) beforeEach(() => jest.clearAllMocks()) @@ -106,6 +109,58 @@ describe('splitPolygon', () => { }) }) +describe('mergePolygons', () => { + const squareA = { + id: 'sq-a', + properties: { id: 'sq-a', name: 'field' }, + geometry: { type: 'Polygon', coordinates: [[[0, 0], [1, 0], [1, 1], [0, 1], [0, 0]]] } + } + const squareB = { + id: 'sq-b', + properties: { id: 'sq-b' }, + geometry: { type: 'Polygon', coordinates: [[[1, 0], [2, 0], [2, 1], [1, 1], [1, 0]]] } + } + + const mergedResult = { + geometry: { type: 'Polygon', coordinates: [[[0, 0], [2, 0], [2, 1], [0, 1], [0, 0]]] } + } + + test('returns a single merged polygon for a valid union', () => { + turfUnion.mockReturnValue(mergedResult) + + const result = mergePolygons([squareA, squareB]) + + expect(result.geometry.type).toBe('Polygon') + expect(result.id).toBe('sq-a') + expect(result.properties).toMatchObject({ id: 'sq-a', name: 'field' }) + }) + + test('returns null when union throws', () => { + turfUnion.mockImplementation(() => { throw new Error('bad geometry') }) + expect(mergePolygons([squareA, squareB])).toBeNull() + }) + + test('returns null when the inputs are not contiguous (MultiPolygon result)', () => { + turfUnion.mockReturnValue({ geometry: { type: 'MultiPolygon', coordinates: [] } }) + expect(mergePolygons([squareA, squareB])).toBeNull() + }) + + test('returns null when union returns null', () => { + turfUnion.mockReturnValue(null) + expect(mergePolygons([squareA, squareB])).toBeNull() + }) + + test('derives the base id from properties.id then a default', () => { + turfUnion.mockReturnValue(mergedResult) + + const noTopLevelId = { properties: { id: 'pid' }, geometry: squareA.geometry } + expect(mergePolygons([noTopLevelId, squareB]).id).toBe('pid') + + const anonymous = { properties: {}, geometry: squareA.geometry } + expect(mergePolygons([anonymous, squareB]).id).toBe('poly') + }) +}) + describe('isNewCoordinate', () => { test('is true for a ring with a single point', () => { expect(isNewCoordinate([[[0, 0]]])).toBe(true) From 3e8d9a39142603694c5d134b5cd4115fea11e8a4 Mon Sep 17 00:00:00 2001 From: Dan Leech Date: Fri, 10 Jul 2026 16:15:52 +0100 Subject: [PATCH 68/89] Split and merge minor refactor --- demo/js/draw.js | 8 +-- .../maplibre/modes/drawMode/clickHandlers.js | 12 ++-- .../src/adapters/openlayers/draw/DrawMode.js | 10 +-- plugins/draw/src/api/split.js | 70 ++++++++++--------- plugins/draw/src/api/split.test.js | 33 ++++++--- .../draw/src/validation/validateGeometry.js | 38 ++++------ .../src/validation/validateGeometry.test.js | 37 +++++++--- 7 files changed, 122 insertions(+), 86 deletions(-) diff --git a/demo/js/draw.js b/demo/js/draw.js index 56704ce7d..85693a78f 100755 --- a/demo/js/draw.js +++ b/demo/js/draw.js @@ -182,8 +182,8 @@ interactiveMap.on('map:ready', function (e) { }) } },{ - id: 'editFeature', - label: 'Edit feature', + id: 'editShape', + label: 'Edit shape', iconSvgContent: '', isDisabled: true, onClick: function (e) { @@ -224,7 +224,7 @@ interactiveMap.on('map:ready', function (e) { interactPlugin.clear() interactiveMap.toggleButtonState('drawPolygon', 'disabled', false) interactiveMap.toggleButtonState('drawLine', 'disabled', false) - interactiveMap.toggleButtonState('editFeature', 'disabled', true) + interactiveMap.toggleButtonState('editShape', 'disabled', true) interactiveMap.toggleButtonState('splitShape', 'disabled', true) interactiveMap.toggleButtonState('mergeShapes', 'disabled', true) interactiveMap.toggleButtonState('deleteFeature', 'disabled', true) @@ -305,7 +305,7 @@ interactiveMap.on('interact:selectionchange', function (e) { selectedFeatureIds = e.selectedFeatures.map(function (f) { return f.featureId }) interactiveMap.toggleButtonState('drawPolygon', 'disabled', !!singleFeature) interactiveMap.toggleButtonState('drawLine', 'disabled', !!singleFeature) - interactiveMap.toggleButtonState('editFeature', 'disabled', !isDrawFeature) + interactiveMap.toggleButtonState('editShape', 'disabled', !isDrawFeature) interactiveMap.toggleButtonState('splitShape', 'disabled', !isPolygon) interactiveMap.toggleButtonState('mergeShapes', 'disabled', !canMerge) interactiveMap.toggleButtonState('deleteFeature', 'disabled', !allDrawFeatures) diff --git a/plugins/draw/src/adapters/maplibre/modes/drawMode/clickHandlers.js b/plugins/draw/src/adapters/maplibre/modes/drawMode/clickHandlers.js index 323699264..bb9b51027 100644 --- a/plugins/draw/src/adapters/maplibre/modes/drawMode/clickHandlers.js +++ b/plugins/draw/src/adapters/maplibre/modes/drawMode/clickHandlers.js @@ -1,7 +1,7 @@ import { getSnapInstance, isSnapActive, isSnapEnabled, createSnappedEvent, createSnappedClickEvent } from '../../utils/snapHelpers.js' -import { checkPlacement } from '../../../../validation/validateGeometry.js' +import { attemptPlacement } from '../../../../validation/validateGeometry.js' // The commit-level geometrychange payload for a vertex commit: the placed-only // geometry (trailing rubber-band point dropped so validation tests the committed shape). @@ -38,14 +38,14 @@ const createClickHelpers = ({ geometryType, getFeature, getCoords }) => ({ return e.originalEvent.button > 0 || this.map._undoInProgress || e.originalEvent.target !== this.map.getCanvas() }, - // Gate a would-be placement through the shared checkPlacement gate (hard rules + - // user callback). On a veto the vertex never appears and a draw.placementblocked - // event carries the reason. The trailing rubber-band coord is dropped so the - // candidate is placed vertices + the point about to be placed. + // Attempt a placement through the shared hard rules + user callback. On a veto + // the vertex never appears and a draw.placementblocked event carries the + // reason. The trailing rubber-band coord is dropped so the candidate is placed + // vertices + the point about to be placed. _canPlaceVertex (state, point) { const feature = getFeature(state) if (!feature || !point) { return true } - const result = checkPlacement({ + const result = attemptPlacement({ placed: getCoords(feature).slice(0, -1), point, geometryType, diff --git a/plugins/draw/src/adapters/openlayers/draw/DrawMode.js b/plugins/draw/src/adapters/openlayers/draw/DrawMode.js index b2411de29..bb6bcdfb8 100644 --- a/plugins/draw/src/adapters/openlayers/draw/DrawMode.js +++ b/plugins/draw/src/adapters/openlayers/draw/DrawMode.js @@ -5,7 +5,7 @@ import { getPlacedSketchCoords, getLastPlacedSketchCoord } from '../utils/sketch import { TOLERANCES } from '../defaults.js' import { ADAPTER_EVENTS } from '../../../adapterEvents.js' import { STYLES_CHANGED_EVENT } from '../core/internalEvents.js' -import { checkPlacement, validatePlacement, MODE_BY_GEOMETRY } from '../../../validation/validateGeometry.js' +import { attemptPlacement, validatePlacement, MODE_BY_GEOMETRY } from '../../../validation/validateGeometry.js' import { MIN_VERTICES } from '../../../validation/rules.js' import { createLiveStroke } from '../../../validation/liveStroke.js' @@ -50,12 +50,12 @@ const placedFeatureGeoJSON = (store, sketchFeature) => { return { type: 'Feature', geometry, properties: gj.properties } } -// Gate a would-be placement (mouse click, crosshair tap, Enter) through the shared -// checkPlacement gate (hard rules + user callback). On a veto the vertex never -// appears and a PLACEMENT_BLOCKED event carries the reason. +// Attempt a placement (mouse click, crosshair tap, Enter) through the shared +// hard rules + user callback. On a veto the vertex never appears and a +// PLACEMENT_BLOCKED event carries the reason. export const buildCanPlaceVertex = ({ manager, geometryType, getSketch }) => (coordinate) => { const sketch = getSketch() - const result = checkPlacement({ + const result = attemptPlacement({ placed: sketch ? getPlacedSketchCoords(sketch.getGeometry()) : [], point: coordinate, geometryType, diff --git a/plugins/draw/src/api/split.js b/plugins/draw/src/api/split.js index 557a1cd81..336fbcc80 100644 --- a/plugins/draw/src/api/split.js +++ b/plugins/draw/src/api/split.js @@ -2,46 +2,55 @@ import { splitPolygon } from '../utils/spatial.js' import { ADAPTER_EVENTS } from '../adapterEvents.js' const INVALID_REASON = 'Line does not split the shape into two parts' +const MIN_LINE_VERTICES = 2 -// Recompute split validity and re-apply the same gate normal draw/edit uses: -// disables Done (manifest.js enableWhen reads pluginState.geometryValid) and blocks -// the double-click/click-vertex finish gesture (drawMode/clickHandlers.js reads -// map._drawGeometryValid) while the line wouldn't produce a valid split. Only uses -// the shared, engine-agnostic adapter interface — no mapbox-gl-draw internals. -const applySplitValidity = ({ draw, dispatch, polygonFeature, feature }) => { - const lineFeature = { id: '_splitter', geometry: feature.geometry } - const featureCollection = splitPolygon(polygonFeature, lineFeature) - const isValid = !!featureCollection - // The splitter line has no stable id until it's actually created, so the - // in-progress preview colour is tagged via setDrawingPreviewProperty, not - // setFeatureProperty (that targets the '_splitter' id assigned on completion — - // see onSplitCreate below). +const computeIsValid = (polygonFeature, feature) => { + const coordinates = feature.geometry?.coordinates + if (!coordinates || coordinates.length < MIN_LINE_VERTICES) { + return false + } + return !!splitPolygon(polygonFeature, { id: '_splitter', geometry: feature.geometry }) +} + +// Colours the splitter line preview. Uses setDrawingPreviewProperty rather than +// setFeatureProperty since the line has no stable id until it's created. +const applySplitPreview = ({ draw, polygonFeature, feature }) => { + const isValid = computeIsValid(polygonFeature, feature) draw.setDrawingPreviewProperty('splitter', isValid ? 'valid' : 'invalid') + // DEBUG + console.log('[split] preview', { coords: feature.geometry?.coordinates, isValid }) + return isValid +} + +// Colours the preview and gates Done/the finish gesture for a committed vertex. +const applySplitCommit = ({ draw, dispatch, polygonFeature, feature }) => { + const isValid = applySplitPreview({ draw, polygonFeature, feature }) dispatch({ type: 'SET_ACTION', payload: { name: 'split', isValid } }) dispatch({ type: 'SET_GEOMETRY_VALID', payload: isValid }) draw.setGeometryValid(isValid) - return isValid ? { valid: true } : { valid: false, reason: INVALID_REASON } + // DEBUG + console.log('[split] commit', { coords: feature.geometry?.coordinates, isValid }) + return isValid } -// Installed as draw._geometryValidator so the normal validation pipeline drives -// split's validity too. Skip 'place' and 'create': the line isn't a full split until -// its last vertex, so checking those would block placement and hijack an early -// finish into edit mode. Only check on the live preview and each committed vertex. +// Installed as draw._geometryValidator. 'place' and 'create' are no-ops — a split +// isn't complete until its last vertex, so checking those would block placement +// and hijack an early finish into edit mode. const createSplitValidator = ({ draw, dispatch, polygonFeature }) => (feature, context) => { - const isSoftCheck = context.phase === 'preview' || context.phase?.startsWith('commit-') - if (!isSoftCheck) { + let isValid + if (context.phase === 'preview') { + isValid = applySplitPreview({ draw, polygonFeature, feature }) + } else if (context.phase?.startsWith('commit-')) { + isValid = applySplitCommit({ draw, dispatch, polygonFeature, feature }) + } else { return { valid: true } } - return applySplitValidity({ draw, dispatch, polygonFeature, feature }) + return isValid ? { valid: true } : { valid: false, reason: INVALID_REASON } } /** * Start drawing a split line for a polygon. * - * Only fully implemented for MapLibre. For OpenLayers the geometry calculation - * will run but real-time preview (geometrychange) and snap-to-outline are not - * wired up — coordinate-system differences mean results may be incorrect too. - * * @param {object} context - plugin context * @param {string} featureId - ID of the polygon to split * @param {object} options - Options including snapLayers. @@ -57,9 +66,7 @@ export const split = ({ appState, appConfig, pluginState, mapState, mapProvider, const polygonFeature = draw.get(featureId) - // Swap in split's own rule for the splitter-line session; restored in - // stopListening so the polygon's own developer-supplied validator isn't left - // permanently replaced once the split session ends. + // Swap in split's own rule; restored in stopListening. const previousValidator = draw._geometryValidator draw._geometryValidator = createSplitValidator({ draw, dispatch, polygonFeature }) @@ -78,15 +85,14 @@ export const split = ({ appState, appConfig, pluginState, mapState, mapProvider, properties: { splitter: 'invalid' } }) - // Scoped to this one splitter-line session — leaving either registered past - // that would leak into whatever the user draws or edits next. + // Unregister everything scoped to this session. const stopListening = () => { draw._geometryValidator = previousValidator draw.off(ADAPTER_EVENTS.CREATE, onSplitCreate) draw.off(ADAPTER_EVENTS.CANCEL, onSplitCancel) } - // One-shot: compute split result once the line is finalised + // Compute split result once the line is finalised const onSplitCreate = (geojsonFeature) => { stopListening() const featureCollection = splitPolygon(polygonFeature, geojsonFeature) @@ -101,7 +107,7 @@ export const split = ({ appState, appConfig, pluginState, mapState, mapProvider, } } - // The splitter line was abandoned (e.g. Escape) — nothing to compute, just stop listening. + // Abandoned (e.g. Escape) — just stop listening. const onSplitCancel = () => { stopListening() } diff --git a/plugins/draw/src/api/split.test.js b/plugins/draw/src/api/split.test.js index da3550638..dda4cef69 100644 --- a/plugins/draw/src/api/split.test.js +++ b/plugins/draw/src/api/split.test.js @@ -145,27 +145,31 @@ describe('split', () => { expect(dispatch).not.toHaveBeenCalledWith(expect.objectContaining({ type: 'SET_ACTION' })) }) - test('evaluates a valid split on the live preview (phase: preview)', () => { + test('the live preview (phase: preview) only updates the visual colour, never Done-gating — even with just 1 placed vertex', () => { const { context, dispatch, draw } = makeContext() const polygonFeature = { id: 'poly' } draw.get.mockReturnValue(polygonFeature) splitPolygon.mockReturnValue({ type: 'FeatureCollection' }) split(context, 'poly') + dispatch.mockClear() + // 1 placed vertex + the rubber-band cursor. validateDisplayedGeometry only + // gates the built-in rules by placedCount, not a caller's own rule, so this + // must still reach the validator (placedCount: 1, below MIN_VERTICES.LineString). const feature = lineFeature([[0, 0], [1, 1]]) - const result = draw._geometryValidator(feature, { phase: 'preview', mode: 'draw_line', placedCount: 2 }) + const result = draw._geometryValidator(feature, { phase: 'preview', mode: 'draw_line', placedCount: 1 }) expect(splitPolygon).toHaveBeenCalledWith(polygonFeature, { id: '_splitter', geometry: feature.geometry }) expect(draw.setDrawingPreviewProperty).toHaveBeenCalledWith('splitter', 'valid') - expect(dispatch).toHaveBeenCalledWith({ type: 'SET_ACTION', payload: { name: 'split', isValid: true } }) - // A valid split line must allow completion: Done enabled (pluginState.geometryValid) - // and the double-click/click-vertex finish gesture unblocked (map._drawGeometryValid). - expect(dispatch).toHaveBeenCalledWith({ type: 'SET_GEOMETRY_VALID', payload: true }) - expect(draw.setGeometryValid).toHaveBeenCalledWith(true) + // Committed Done-gating (pluginState.geometryValid / map._drawGeometryValid) must only + // change on an actual commit — otherwise a live "what if you added a point here" check + // could flicker a just-committed valid split back to disabled. + expect(dispatch).not.toHaveBeenCalled() + expect(draw.setGeometryValid).not.toHaveBeenCalled() expect(result).toEqual({ valid: true }) }) - test('evaluates an invalid split with a reason on a committed vertex (phase: commit-add)', () => { + test('a committed vertex (phase: commit-add) updates both the visual colour and Done-gating', () => { const { context, dispatch, draw } = makeContext() splitPolygon.mockReturnValue(null) split(context, 'poly') @@ -174,9 +178,22 @@ describe('split', () => { const result = draw._geometryValidator(feature, { phase: 'commit-add', mode: 'draw_line', vertexIndex: 1 }) expect(draw.setDrawingPreviewProperty).toHaveBeenCalledWith('splitter', 'invalid') + expect(dispatch).toHaveBeenCalledWith({ type: 'SET_ACTION', payload: { name: 'split', isValid: false } }) expect(dispatch).toHaveBeenCalledWith({ type: 'SET_GEOMETRY_VALID', payload: false }) expect(draw.setGeometryValid).toHaveBeenCalledWith(false) expect(result).toEqual({ valid: false, reason: expect.any(String) }) }) + + test('a line with fewer than two coordinates is treated as invalid without calling splitPolygon', () => { + const { context, draw } = makeContext() + split(context, 'poly') + + const feature = lineFeature([[0, 0]]) + const result = draw._geometryValidator(feature, { phase: 'preview', mode: 'draw_line', placedCount: 0 }) + + expect(splitPolygon).not.toHaveBeenCalled() + expect(draw.setDrawingPreviewProperty).toHaveBeenCalledWith('splitter', 'invalid') + expect(result).toEqual({ valid: false, reason: expect.any(String) }) + }) }) }) diff --git a/plugins/draw/src/validation/validateGeometry.js b/plugins/draw/src/validation/validateGeometry.js index 11d9fdc58..2b3b06368 100644 --- a/plugins/draw/src/validation/validateGeometry.js +++ b/plugins/draw/src/validation/validateGeometry.js @@ -17,10 +17,8 @@ const normaliseResult = (result) => { /** * Validate an in-progress geometry against the default rules and an optional user - * callback. The result only gates the Done button (see events.js); it never reverts - * a vertex change, so a shape can pass through interim invalid states while being - * built. Rules run first (in order) and short-circuit on the first failure; the - * user callback runs last. + * callback. Rules run first and short-circuit on the first failure; the callback + * runs last. Gates the Done button only — never reverts a vertex change. * * @param {object} feature - current GeoJSON feature * @param {object} context - { phase: import('../adapterEvents.js').GeometryChangePhase, vertexIndex, mode } @@ -46,13 +44,8 @@ export const validateGeometry = (feature, context = {}, config = {}) => { /** * Validate a candidate vertex placement against the hard rules and the same - * optional user callback. `feature` is the candidate geometry — the placed - * vertices plus the point about to be placed. A failure means the adapter must - * reject the placement (the vertex never appears), used for states that could - * not be recovered from by continuing to draw. - * - * The user callback receives `context.phase === 'place'` to distinguish a - * placement veto from a soft validity check. + * optional user callback. A failure means the vertex is rejected outright and + * never appears. The callback receives `context.phase === 'place'`. * * @param {object} feature - candidate GeoJSON feature (placed vertices + new point) * @param {object} context - { vertexIndex, mode }; phase is forced to 'place' @@ -69,10 +62,9 @@ export const validatePlacement = (feature, context = {}, config = {}) => { export const MODE_BY_GEOMETRY = { Polygon: 'draw_polygon', LineString: 'draw_line' } /** - * Engine-facing placement gate shared by both adapters: builds the candidate - * geometry (placed vertices + the point about to be placed), validates it against - * the hard rules and the user callback, and — on a veto — returns the - * PLACEMENT_BLOCKED payload for the caller to emit on its bus. + * Attempt to place a vertex — shared by both adapters. Builds the candidate + * geometry, validates it, and on a veto returns the PLACEMENT_BLOCKED payload + * for the caller to emit. * * @param {object} params * @param {Array>} params.placed - committed vertex coordinates @@ -81,7 +73,7 @@ export const MODE_BY_GEOMETRY = { Polygon: 'draw_polygon', LineString: 'draw_lin * @param {Function} [params.onGeometryChange] - user callback * @returns {{ valid: true } | { valid: false, blocked: object }} */ -export const checkPlacement = ({ placed, point, geometryType, onGeometryChange }) => { +export const attemptPlacement = ({ placed, point, geometryType, onGeometryChange }) => { const candidate = [...placed, point] const geometry = geometryType === 'Polygon' ? { type: 'Polygon', coordinates: [candidate] } @@ -95,11 +87,10 @@ export const checkPlacement = ({ placed, point, geometryType, onGeometryChange } /** * Validate the displayed (in-progress) geometry that drives the live invalid - * stroke: the placed vertices plus the current cursor / crosshair point. It gates - * on `context.placedCount` so a shape below its minimum vertex count is treated as - * "part-drawn" (always valid — solid stroke); once past the threshold it runs the - * live rules (self-intersection, non-zero area) and the optional user callback - * against the displayed geometry, returning `{ valid, reason }`. + * stroke: the placed vertices plus the current cursor point. Below the minimum + * vertex count, the built-in live rules are skipped (not enough of a shape yet + * to check self-intersection/area) — but a caller's own `onGeometryChange` always + * runs regardless, since its data requirements are its own business. * * @param {object} feature - displayed GeoJSON feature (placed vertices + cursor) * @param {object} context - { mode, placedCount, phase }; phase defaults to 'preview' @@ -112,6 +103,7 @@ export const validateDisplayedGeometry = (feature, context = {}, config = {}) => const { rules = LIVE_RULES, onGeometryChange } = config const type = feature?.geometry?.type ?? feature?.type const min = MIN_VERTICES[type] ?? 0 - if ((context.placedCount ?? 0) < min) { return { valid: true } } - return validateGeometry(feature, { ...context, phase: context.phase ?? 'preview' }, { rules, onGeometryChange }) + const belowMinVertices = (context.placedCount ?? 0) < min + const effectiveRules = belowMinVertices ? [] : rules + return validateGeometry(feature, { ...context, phase: context.phase ?? 'preview' }, { rules: effectiveRules, onGeometryChange }) } diff --git a/plugins/draw/src/validation/validateGeometry.test.js b/plugins/draw/src/validation/validateGeometry.test.js index 09d6725ad..e38192460 100644 --- a/plugins/draw/src/validation/validateGeometry.test.js +++ b/plugins/draw/src/validation/validateGeometry.test.js @@ -1,4 +1,4 @@ -import { validateGeometry, validatePlacement, checkPlacement, validateDisplayedGeometry } from './validateGeometry.js' +import { validateGeometry, validatePlacement, attemptPlacement, validateDisplayedGeometry } from './validateGeometry.js' const poly = (coordinates) => ({ type: 'Feature', geometry: { type: 'Polygon', coordinates: [coordinates] } }) @@ -62,16 +62,16 @@ describe('validateGeometry (soft gating)', () => { }) }) -describe('checkPlacement (shared engine gate)', () => { +describe('attemptPlacement (shared engine gate)', () => { const placedL = [[0, 0], [1, 1], [1, 0]] // adding (0,1) makes the open path cross test('a legal placement passes with no payload', () => { - expect(checkPlacement({ placed: [[0, 0], [1, 0], [1, 1]], point: [0, 1], geometryType: 'Polygon' })) + expect(attemptPlacement({ placed: [[0, 0], [1, 0], [1, 1]], point: [0, 1], geometryType: 'Polygon' })) .toEqual({ valid: true }) }) test('a self-crossing placement is vetoed with the PLACEMENT_BLOCKED payload', () => { - const result = checkPlacement({ placed: placedL, point: [0, 1], geometryType: 'Polygon' }) + const result = attemptPlacement({ placed: placedL, point: [0, 1], geometryType: 'Polygon' }) expect(result.valid).toBe(false) expect(result.blocked).toEqual({ feature: expect.objectContaining({ type: 'Feature' }), @@ -84,17 +84,17 @@ describe('checkPlacement (shared engine gate)', () => { test('the user callback can veto, with mode derived from the geometry type', () => { const onGeometryChange = jest.fn(() => ({ valid: false, reason: 'outside region' })) - const result = checkPlacement({ placed: [[0, 0]], point: [1, 1], geometryType: 'LineString', onGeometryChange }) + const result = attemptPlacement({ placed: [[0, 0]], point: [1, 1], geometryType: 'LineString', onGeometryChange }) expect(result.blocked).toEqual(expect.objectContaining({ mode: 'draw_line', reason: 'outside region', vertexIndex: 1 })) expect(onGeometryChange).toHaveBeenCalledWith(expect.anything(), { phase: 'place', mode: 'draw_line', vertexIndex: 1 }) }) - test('checkPlacement mode is set correctly for Polygon vs LineString', () => { + test('attemptPlacement mode is set correctly for Polygon vs LineString', () => { // Both legal and illegal placements should have the correct mode set - const polygonLegal = checkPlacement({ placed: [[0, 0], [1, 0]], point: [1, 1], geometryType: 'Polygon' }) + const polygonLegal = attemptPlacement({ placed: [[0, 0], [1, 0]], point: [1, 1], geometryType: 'Polygon' }) expect(polygonLegal).toEqual({ valid: true }) // Test a Polygon placement that would cross - const polygonCrossing = checkPlacement({ placed: [[0, 0], [2, 2], [2, 0]], point: [0, 2], geometryType: 'Polygon' }) + const polygonCrossing = attemptPlacement({ placed: [[0, 0], [2, 2], [2, 0]], point: [0, 2], geometryType: 'Polygon' }) expect(polygonCrossing.blocked?.mode).toBe('draw_polygon') }) }) @@ -115,6 +115,27 @@ describe('validateDisplayedGeometry edge cases', () => { const result = validateDisplayedGeometry(poly([[0, 0]]), {}) expect(result.valid).toBe(true) // no placedCount = 0, below any min, so valid }) + + test('still calls the caller\'s own onGeometryChange below the vertex threshold — only the built-in rules are gated', () => { + const onGeometryChange = jest.fn(() => ({ valid: false, reason: 'too few for my rule' })) + const feature = poly([[0, 0], [1, 0]]) + + const result = validateDisplayedGeometry(feature, { placedCount: 1 }, { onGeometryChange }) + + expect(onGeometryChange).toHaveBeenCalledWith(feature, expect.objectContaining({ placedCount: 1, phase: 'preview' })) + expect(result).toEqual({ valid: false, reason: 'too few for my rule' }) + }) + + test('skips the built-in rules below the vertex threshold even when a callback is supplied', () => { + const failingRule = jest.fn(() => ({ valid: false, reason: 'should not run' })) + const onGeometryChange = jest.fn(() => true) + + const result = validateDisplayedGeometry(poly([[0, 0]]), { placedCount: 0 }, { rules: [failingRule], onGeometryChange }) + + expect(failingRule).not.toHaveBeenCalled() + expect(onGeometryChange).toHaveBeenCalled() + expect(result).toEqual({ valid: true }) + }) }) describe('validatePlacement (hard gating)', () => { From 4d62c40e74e4e5e6b2f4a5876a588f5832ad9538 Mon Sep 17 00:00:00 2001 From: Dan Leech Date: Fri, 10 Jul 2026 16:18:25 +0100 Subject: [PATCH 69/89] Minor formatting fix --- plugins/draw/src/api/split.js | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/plugins/draw/src/api/split.js b/plugins/draw/src/api/split.js index 336fbcc80..8e4f13807 100644 --- a/plugins/draw/src/api/split.js +++ b/plugins/draw/src/api/split.js @@ -108,9 +108,7 @@ export const split = ({ appState, appConfig, pluginState, mapState, mapProvider, } // Abandoned (e.g. Escape) — just stop listening. - const onSplitCancel = () => { - stopListening() - } + const onSplitCancel = () => { stopListening() } draw.on(ADAPTER_EVENTS.CREATE, onSplitCreate) draw.on(ADAPTER_EVENTS.CANCEL, onSplitCancel) From 42696b292077971b7edd8bb7ebc27542ce95c8da Mon Sep 17 00:00:00 2001 From: Dan Leech Date: Fri, 10 Jul 2026 17:01:57 +0100 Subject: [PATCH 70/89] OL Split and merge basics added --- demo/js/draw-ol.js | 64 +++++++++++++++++-- .../src/adapters/openlayers/OLDrawAdapter.js | 14 +++- .../adapters/openlayers/OLDrawAdapter.test.js | 13 ++++ .../adapters/openlayers/core/OLDrawManager.js | 6 ++ .../openlayers/core/OLDrawManager.test.js | 5 +- .../src/adapters/openlayers/core/styles.js | 17 ++++- .../adapters/openlayers/core/styles.test.js | 16 +++++ .../src/adapters/openlayers/draw/DrawMode.js | 8 +++ .../adapters/openlayers/draw/DrawMode.test.js | 13 ++++ .../openlayers/utils/resolveColors.js | 2 + .../openlayers/utils/resolveColors.test.js | 6 ++ plugins/draw/src/api/split.js | 9 ++- plugins/draw/src/api/split.test.js | 7 +- plugins/draw/src/utils/spatial.js | 38 ++++++----- plugins/draw/src/utils/spatial.test.js | 23 +++++-- 15 files changed, 209 insertions(+), 32 deletions(-) diff --git a/demo/js/draw-ol.js b/demo/js/draw-ol.js index 94c71fb22..ee1f96900 100644 --- a/demo/js/draw-ol.js +++ b/demo/js/draw-ol.js @@ -101,8 +101,8 @@ interactiveMap.on('map:ready', function (e) { }) } },{ - id: 'editFeature', - label: 'Edit feature', + id: 'editShape', + label: 'Edit shape', iconSvgContent: '', isDisabled: true, onClick: function (e) { @@ -113,6 +113,25 @@ interactiveMap.on('map:ready', function (e) { interactiveMap.toggleButtonState('geometryActions', 'hidden', true) interactPlugin.disable() } + },{ + id: 'splitShape', + label: 'Split shape', + iconSvgContent: '', + isDisabled: true, + onClick: function (e) { + drawPlugin.split(selectedFeatureIds[0]) + interactiveMap.toggleButtonState('geometryActions', 'hidden', true) + interactPlugin.disable() + } + },{ + id: 'mergeShapes', + label: 'Merge shapes', + iconSvgContent: '', + isDisabled: true, + onClick: function (e) { + drawPlugin.merge(selectedFeatureIds) + interactPlugin.clear() + } },{ id: 'deleteFeature', label: 'Delete feature', @@ -124,7 +143,9 @@ interactiveMap.on('map:ready', function (e) { interactPlugin.clear() interactiveMap.toggleButtonState('drawPolygon', 'disabled', false) interactiveMap.toggleButtonState('drawLine', 'disabled', false) - interactiveMap.toggleButtonState('editFeature', 'disabled', true) + interactiveMap.toggleButtonState('editShape', 'disabled', true) + interactiveMap.toggleButtonState('splitShape', 'disabled', true) + interactiveMap.toggleButtonState('mergeShapes', 'disabled', true) interactiveMap.toggleButtonState('deleteFeature', 'disabled', true) } }] @@ -177,16 +198,51 @@ interactiveMap.on('draw:cancelled', function (e) { interactPlugin.enable() }) +interactiveMap.on('draw:split', function (e) { + // Delete the original polygon + drawPlugin.deleteFeature([e.originalFeatureId]) + + // Add the two new split features with IDs based on the original + e.featureCollection.features.forEach(function (feature, index) { + const newId = e.originalFeatureId + (index === 0 ? '-a' : '-b') + drawPlugin.addFeature({ + id: newId, + type: feature.type, + geometry: feature.geometry, + properties: feature.properties + }) + }) + + interactPlugin.clear() +}) + +interactiveMap.on('draw:merge', function (e) { + // Delete the original polygons + drawPlugin.deleteFeature(e.originalFeatureIds) + + // Add the single merged feature, keeping the first original's id + drawPlugin.addFeature({ + id: e.originalFeatureIds[0], + type: e.feature.type, + geometry: e.feature.geometry, + properties: e.feature.properties + }) +}) + interactiveMap.on('interact:selectionchange', function (e) { const singleFeature = e.selectedFeatures.length === 1 const anyFeature = e.selectedFeatures.length > 0 const isDrawFeature = singleFeature && e.selectedFeatures[0].layerId === 'draw' + const isPolygon = singleFeature && e.selectedFeatures[0].geometryType === 'Polygon' const allDrawFeatures = anyFeature && e.selectedFeatures.every(function (f) { return f.layerId === 'draw' }) + const canMerge = allDrawFeatures && e.contiguous && e.selectedFeatures.length > 1 selectedFeatureIds = e.selectedFeatures.map(function (f) { return f.featureId }) interactiveMap.toggleButtonState('drawPolygon', 'disabled', !!singleFeature) interactiveMap.toggleButtonState('drawLine', 'disabled', !!singleFeature) - interactiveMap.toggleButtonState('editFeature', 'disabled', !isDrawFeature) + interactiveMap.toggleButtonState('editShape', 'disabled', !isDrawFeature) + interactiveMap.toggleButtonState('splitShape', 'disabled', !isPolygon) + interactiveMap.toggleButtonState('mergeShapes', 'disabled', !canMerge) interactiveMap.toggleButtonState('deleteFeature', 'disabled', !allDrawFeatures) }) diff --git a/plugins/draw/src/adapters/openlayers/OLDrawAdapter.js b/plugins/draw/src/adapters/openlayers/OLDrawAdapter.js index 6fb85f983..86fbc1f65 100644 --- a/plugins/draw/src/adapters/openlayers/OLDrawAdapter.js +++ b/plugins/draw/src/adapters/openlayers/OLDrawAdapter.js @@ -1,5 +1,14 @@ import { createOLDraw } from './olDraw.js' +// split.js passes this literal — MapLibre's own always-present "already-drawn +// shapes" style layer id — as a snapLayers entry so the split line snaps to the +// polygon it's splitting. On MapLibre it needs no help: mapbox-gl-snap resolves +// it by querying the map's rendered style layers directly. OL's snap engine has +// no such string-based lookup for plain vector layers (only direct instances or +// vector-tile layer names), so this adapter recognises the same literal here and +// resolves it to the draw plugin's own VectorLayer instance instead. +const DRAW_OUTLINE_STYLE_LAYER = 'stroke-inactive.cold' + /** * Draw adapter for OpenLayers. * @@ -85,13 +94,14 @@ export class OLDrawAdapter { } setSnapLayers (layers) { - this._manager.snap?.setSnapLayers(layers) + const translated = layers?.map((l) => (l === DRAW_OUTLINE_STYLE_LAYER ? this._manager._layer : l)) + this._manager.snap?.setSnapLayers(translated) } isSnapEnabled () { return this._snapEnabled } setFeatureProperty () { /* not implemented for OL */ } - setDrawingPreviewProperty () { /* not implemented for OL */ } + setDrawingPreviewProperty (property, value) { this._manager.setDrawingPreviewProperty(property, value) } on (type, handler) { this._manager.on(type, handler) } off (type, handler) { this._manager.off(type, handler) } diff --git a/plugins/draw/src/adapters/openlayers/OLDrawAdapter.test.js b/plugins/draw/src/adapters/openlayers/OLDrawAdapter.test.js index 9afca9944..a71671631 100644 --- a/plugins/draw/src/adapters/openlayers/OLDrawAdapter.test.js +++ b/plugins/draw/src/adapters/openlayers/OLDrawAdapter.test.js @@ -1,7 +1,13 @@ import { OLDrawAdapter } from './OLDrawAdapter.js' import { createOLDraw } from './olDraw.js' +// The literal split.js passes — see OLDrawAdapter.js's DRAW_OUTLINE_STYLE_LAYER comment. +const DRAW_OUTLINE_STYLE_LAYER = 'stroke-inactive.cold' + +const fakeVectorLayer = { id: 'draw-layer' } + const fakeManager = () => ({ + _layer: fakeVectorLayer, changeMode: jest.fn(), getMode: jest.fn(() => 'disabled'), setInterfaceType: jest.fn(), @@ -10,6 +16,7 @@ const fakeManager = () => ({ undo: jest.fn(), deleteVertex: jest.fn(), setInvalid: jest.fn(), + setDrawingPreviewProperty: jest.fn(), get: jest.fn(() => 'feature'), add: jest.fn(), delete: jest.fn(), @@ -76,6 +83,10 @@ test('snap state is tracked locally and forwarded, tolerating a missing snap man adapter.setSnapLayers(['x']) expect(manager.snap.setSnapLayers).toHaveBeenCalledWith(['x']) + // The shared sentinel translates onto the draw plugin's own VectorLayer instance. + adapter.setSnapLayers([DRAW_OUTLINE_STYLE_LAYER, 'x']) + expect(manager.snap.setSnapLayers).toHaveBeenCalledWith([fakeVectorLayer, 'x']) + manager.snap = null adapter.setSnapEnabled(false) adapter.setSnapLayers(['y']) // no throw @@ -95,6 +106,8 @@ test('remaining calls delegate straight through; setFeatureProperty is a deliber adapter.on('create', handler) adapter.off('create', handler) expect(adapter.setFeatureProperty('f1', 'stroke', '#000')).toBeUndefined() + adapter.setDrawingPreviewProperty('splitter', 'valid') + expect(manager.setDrawingPreviewProperty).toHaveBeenCalledWith('splitter', 'valid') expect(manager.setInterfaceType).toHaveBeenCalledWith('touch') expect(manager.on).toHaveBeenCalledWith('create', handler) expect(manager.off).toHaveBeenCalledWith('create', handler) diff --git a/plugins/draw/src/adapters/openlayers/core/OLDrawManager.js b/plugins/draw/src/adapters/openlayers/core/OLDrawManager.js index f9152e99c..5ce6ab0a1 100644 --- a/plugins/draw/src/adapters/openlayers/core/OLDrawManager.js +++ b/plugins/draw/src/adapters/openlayers/core/OLDrawManager.js @@ -125,6 +125,12 @@ export class OLDrawManager { this._modeInstance?.setInvalid?.(invalid) } + // Tag the active draw sketch with a custom style property (e.g. split's + // valid/invalid line colour). No-op in edit mode or without a mode instance. + setDrawingPreviewProperty (property, value) { + this._modeInstance?.setDrawingPreviewProperty?.(property, value) + } + setInterfaceType (type) { this._modeInstance?.setInterfaceType?.(type) // Parity with the ML adapter: an explicit interface-type write is echoed on diff --git a/plugins/draw/src/adapters/openlayers/core/OLDrawManager.test.js b/plugins/draw/src/adapters/openlayers/core/OLDrawManager.test.js index a04dbf7f2..b91a186da 100644 --- a/plugins/draw/src/adapters/openlayers/core/OLDrawManager.test.js +++ b/plugins/draw/src/adapters/openlayers/core/OLDrawManager.test.js @@ -8,7 +8,7 @@ import { createFakeMap } from '../__helpers__/harness.js' import { TOLERANCES } from '../defaults.js' jest.mock('../draw/DrawMode.js', () => ({ - createDrawMode: jest.fn(() => ({ destroy: jest.fn(), done: jest.fn(), cancel: jest.fn(), undo: jest.fn(), deleteVertex: jest.fn(), setInterfaceType: jest.fn(), setInvalid: jest.fn() })) + createDrawMode: jest.fn(() => ({ destroy: jest.fn(), done: jest.fn(), cancel: jest.fn(), undo: jest.fn(), deleteVertex: jest.fn(), setInterfaceType: jest.fn(), setInvalid: jest.fn(), setDrawingPreviewProperty: jest.fn() })) })) jest.mock('../edit/EditMode.js', () => ({ createEditMode: jest.fn(() => ({ destroy: jest.fn(), done: jest.fn(), cancel: jest.fn(), undo: jest.fn(), deleteVertex: jest.fn(), setInterfaceType: jest.fn(), setInvalid: jest.fn() })) @@ -75,6 +75,7 @@ describe('mode machine', () => { test('operations delegate to the current mode instance and are safe without one', async () => { const { manager } = setup() manager.done(); manager.undo(); manager.deleteVertex(); manager.setInterfaceType('touch'); manager.setInvalid(true) // no mode — no throw + manager.setDrawingPreviewProperty('splitter', 'valid') // no mode — no throw await manager.changeMode('draw_polygon') const instance = createDrawMode.mock.results[0].value @@ -85,6 +86,7 @@ describe('mode machine', () => { manager.on('interfacetypechange', emitted) manager.setInterfaceType('touch') manager.setInvalid(true) + manager.setDrawingPreviewProperty('splitter', 'valid') expect(instance.done).toHaveBeenCalled() expect(instance.undo).toHaveBeenCalled() expect(instance.deleteVertex).toHaveBeenCalled() @@ -92,6 +94,7 @@ describe('mode machine', () => { // Parity with ML: an explicit interface-type write is echoed on the bus. expect(emitted).toHaveBeenCalledWith({ interfaceType: 'touch' }) expect(instance.setInvalid).toHaveBeenCalledWith(true) + expect(instance.setDrawingPreviewProperty).toHaveBeenCalledWith('splitter', 'valid') manager.cancel() expect(instance.cancel).toHaveBeenCalled() diff --git a/plugins/draw/src/adapters/openlayers/core/styles.js b/plugins/draw/src/adapters/openlayers/core/styles.js index c0c3fd480..365820220 100644 --- a/plugins/draw/src/adapters/openlayers/core/styles.js +++ b/plugins/draw/src/adapters/openlayers/core/styles.js @@ -80,6 +80,16 @@ export const createStyles = (colors) => { stroke: new Stroke({ color: colors.invalidStroke, width: 2, lineDash: [2, 4] }) }) + // Split-line preview colours: valid is solid, invalid is dashed — matching + // ML's stroke-valid-splitter / stroke-invalid-splitter layers. + const sketchLineStyleSplitValid = new Style({ + stroke: new Stroke({ color: colors.splitValid, width: 2 }) + }) + + const sketchLineStyleSplitInvalid = new Style({ + stroke: new Stroke({ color: colors.splitInvalid, width: 2, lineDash: [2, 4] }) + }) + // Reused across renders — the geometry function runs every frame while sketching, // so mutate one MultiPoint (setCoordinates bumps its revision, keeping OL's // render caches correct) instead of allocating a new one per frame @@ -100,10 +110,15 @@ export const createStyles = (colors) => { // vertices get markers on the sketch feature instead. geometryType filters // out the extra LineString sketch OL renders alongside a Polygon sketch, // so vertices aren't drawn twice. `invalid` swaps to the dashed line style. + // A 'splitter' property on the feature (set via setDrawingPreviewProperty) + // overrides both with the split-specific valid/invalid colours. const createSketchStyle = (geometryType, invalid = false) => (feature) => { const type = feature.getGeometry().getType() if (type === 'Point') { return [] } - const lineStyle = invalid ? sketchLineStyleInvalid : sketchLineStyle + const splitter = feature.get('splitter') + let lineStyle = invalid ? sketchLineStyleInvalid : sketchLineStyle + if (splitter === 'valid') { lineStyle = sketchLineStyleSplitValid } + if (splitter === 'invalid') { lineStyle = sketchLineStyleSplitInvalid } return type === geometryType ? [lineStyle, sketchVertexStyle] : [lineStyle] } diff --git a/plugins/draw/src/adapters/openlayers/core/styles.test.js b/plugins/draw/src/adapters/openlayers/core/styles.test.js index e6825d569..8438a23c9 100644 --- a/plugins/draw/src/adapters/openlayers/core/styles.test.js +++ b/plugins/draw/src/adapters/openlayers/core/styles.test.js @@ -13,6 +13,8 @@ const colors = { editActive: '#ea', editHalo: '#eh', invalidStroke: '#is', + splitValid: '#sv', + splitInvalid: '#si', shapeStroke: '#ss', shapeFill: '#sf', strokeWidth: 3, @@ -87,6 +89,20 @@ describe('sketch styles while drawing', () => { expect(lineStyle.getFill()).toBeNull() }) + test('a splitter-tagged sketch renders the split colours (valid solid, invalid dashed), overriding invalid', () => { + const valid = lineFeature([[0, 0], [1, 1]]) + valid.set('splitter', 'valid') + const [validStyle] = styles.createSketchStyle('LineString')(valid) + expect(validStyle.getStroke().getColor()).toBe(colors.splitValid) + expect(validStyle.getStroke().getLineDash()).toBeNull() + + const invalid = lineFeature([[0, 0], [1, 1]]) + invalid.set('splitter', 'invalid') + const [invalidStyle] = styles.createSketchStyle('LineString', true)(invalid) + expect(invalidStyle.getStroke().getColor()).toBe(colors.splitInvalid) + expect(invalidStyle.getStroke().getLineDash()).toEqual([2, 4]) + }) + test('placed vertices render with the shared vertex image on a reused MultiPoint', () => { const sketch = polygonFeature([[0, 0], [10, 0], [5, 5], [0, 0]]) // 2 placed + rubber + closing const [, vertexStyle] = polygonStyleFn(sketch) diff --git a/plugins/draw/src/adapters/openlayers/draw/DrawMode.js b/plugins/draw/src/adapters/openlayers/draw/DrawMode.js index bb6bcdfb8..18abcc981 100644 --- a/plugins/draw/src/adapters/openlayers/draw/DrawMode.js +++ b/plugins/draw/src/adapters/openlayers/draw/DrawMode.js @@ -140,6 +140,14 @@ const buildDrawModeApi = ({ map, manager, drawInteraction, input, geometryType, cancel () { drawInteraction.abortDrawing() }, undo () { drawInteraction.removeLastPoint(); updateVertexCount(); emitUndoValidation() }, setInvalid, + // Tags the sketch feature so createSketchStyle can pick the split-specific + // colour (see styles.js); no-op before any sketch exists. + setDrawingPreviewProperty (property, value) { + const sketch = getSketch() + if (!sketch) { return } + sketch.set(property, value) + drawInteraction.overlay_.changed() + }, destroy () { liveStroke.destroy() livePlacement.destroy() diff --git a/plugins/draw/src/adapters/openlayers/draw/DrawMode.test.js b/plugins/draw/src/adapters/openlayers/draw/DrawMode.test.js index 6344d51de..6f28e97d7 100644 --- a/plugins/draw/src/adapters/openlayers/draw/DrawMode.test.js +++ b/plugins/draw/src/adapters/openlayers/draw/DrawMode.test.js @@ -217,6 +217,19 @@ describe('drawing lifecycle', () => { expect(changed).toHaveBeenCalled() }) + test('setDrawingPreviewProperty tags the sketch feature and re-renders; safe with no sketch yet', () => { + const { mode, interaction } = setup('LineString') + const changed = jest.spyOn(interaction.overlay_, 'changed') + expect(() => mode.setDrawingPreviewProperty('splitter', 'valid')).not.toThrow() + expect(changed).not.toHaveBeenCalled() + + const sketch = lineFeature([[0, 0], [1, 1]]) + interaction.dispatchEvent({ type: 'drawstart', feature: sketch }) + mode.setDrawingPreviewProperty('splitter', 'valid') + expect(sketch.get('splitter')).toBe('valid') + expect(changed).toHaveBeenCalled() + }) + test('undo re-validates the committed shape (deferred, phase commit-delete)', () => { jest.useFakeTimers() const { mode, manager, emitted, interaction } = setup() diff --git a/plugins/draw/src/adapters/openlayers/utils/resolveColors.js b/plugins/draw/src/adapters/openlayers/utils/resolveColors.js index b7265bff2..617b46c7e 100644 --- a/plugins/draw/src/adapters/openlayers/utils/resolveColors.js +++ b/plugins/draw/src/adapters/openlayers/utils/resolveColors.js @@ -24,6 +24,8 @@ export const resolveColors = (mapStyle, pluginConfig = {}) => { editActive: resolveColor('editActive'), editHalo: resolveColor('editHalo'), invalidStroke: resolveColor('invalidStroke'), + splitValid: resolveColor('splitValid'), + splitInvalid: resolveColor('splitInvalid'), shapeStroke: resolveColor('shapeStroke'), strokeWidth: pluginConfig.strokeWidth ?? SIZES.strokeWidth, shapeFill: resolveColor('shapeFill'), diff --git a/plugins/draw/src/adapters/openlayers/utils/resolveColors.test.js b/plugins/draw/src/adapters/openlayers/utils/resolveColors.test.js index 6e6c00f54..a19c23660 100644 --- a/plugins/draw/src/adapters/openlayers/utils/resolveColors.test.js +++ b/plugins/draw/src/adapters/openlayers/utils/resolveColors.test.js @@ -9,6 +9,12 @@ test('without a map style, colours resolve to their light variants and defaults' expect(colors.mapStyleId).toBeNull() }) +test('splitter colours are resolved', () => { + const colors = resolveColors(null) + expect(colors.splitValid).toBe(COLORS.splitValid.light) + expect(colors.splitInvalid).toBe(COLORS.splitInvalid.light) +}) + test('a dark map style resolves dark variants and carries its id through', () => { const colors = resolveColors({ id: 'dark', mapColorScheme: 'dark' }) expect(colors.editStroke).toBe(COLORS.editStroke.dark) diff --git a/plugins/draw/src/api/split.js b/plugins/draw/src/api/split.js index 8e4f13807..608b7d90f 100644 --- a/plugins/draw/src/api/split.js +++ b/plugins/draw/src/api/split.js @@ -3,13 +3,14 @@ import { ADAPTER_EVENTS } from '../adapterEvents.js' const INVALID_REASON = 'Line does not split the shape into two parts' const MIN_LINE_VERTICES = 2 +const SPLITTER_ID = '_splitter' const computeIsValid = (polygonFeature, feature) => { const coordinates = feature.geometry?.coordinates if (!coordinates || coordinates.length < MIN_LINE_VERTICES) { return false } - return !!splitPolygon(polygonFeature, { id: '_splitter', geometry: feature.geometry }) + return !!splitPolygon(polygonFeature, { id: SPLITTER_ID, geometry: feature.geometry }) } // Colours the splitter line preview. Uses setDrawingPreviewProperty rather than @@ -81,7 +82,7 @@ export const split = ({ appState, appConfig, pluginState, mapState, mapProvider, interfaceType: appState.interfaceType, crossHair: mapState?.crossHair, getSnapEnabled: () => draw.isSnapEnabled(), - featureId: '_splitter', + featureId: SPLITTER_ID, properties: { splitter: 'invalid' } }) @@ -92,9 +93,11 @@ export const split = ({ appState, appConfig, pluginState, mapState, mapProvider, draw.off(ADAPTER_EVENTS.CANCEL, onSplitCancel) } - // Compute split result once the line is finalised + // Compute split result once the line is finalised. The splitter line only ever + // exists to compute this — it must never linger in the draw store afterwards. const onSplitCreate = (geojsonFeature) => { stopListening() + draw.delete(SPLITTER_ID) const featureCollection = splitPolygon(polygonFeature, geojsonFeature) dispatch({ type: 'SET_ACTION', payload: { name: 'split', isValid: !!featureCollection } }) diff --git a/plugins/draw/src/api/split.test.js b/plugins/draw/src/api/split.test.js index dda4cef69..e3ad4c33e 100644 --- a/plugins/draw/src/api/split.test.js +++ b/plugins/draw/src/api/split.test.js @@ -14,7 +14,8 @@ const makeContext = (overrides = {}) => { off: jest.fn(), isSnapEnabled: jest.fn(() => true), setGeometryValid: jest.fn(), - setDrawingPreviewProperty: jest.fn() + setDrawingPreviewProperty: jest.fn(), + delete: jest.fn() } const context = { appState: { layoutRefs: { viewportRef: { current: 'viewport' } }, interfaceType: 'mouse' }, @@ -91,6 +92,9 @@ describe('split', () => { expect(dispatch).toHaveBeenCalledWith({ type: 'SET_ACTION', payload: { name: 'split', isValid: true } }) expect(eventBus.emit).toHaveBeenCalledWith('draw:split', { originalFeatureId: 'poly', featureCollection }) expect(draw._geometryValidator).toBe(previousValidator) + // The splitter line only ever existed to compute the split — it must not + // linger in the draw store afterwards. + expect(draw.delete).toHaveBeenCalledWith('_splitter') }) test('cancelling the splitter line stops listening and restores the previous validator', () => { @@ -117,6 +121,7 @@ describe('split', () => { expect(dispatch).toHaveBeenCalledWith({ type: 'SET_ACTION', payload: { name: 'split', isValid: false } }) expect(eventBus.emit).not.toHaveBeenCalled() + expect(draw.delete).toHaveBeenCalledWith('_splitter') }) describe('the installed draw._geometryValidator', () => { diff --git a/plugins/draw/src/utils/spatial.js b/plugins/draw/src/utils/spatial.js index eaf5b19be..adf46b7eb 100755 --- a/plugins/draw/src/utils/spatial.js +++ b/plugins/draw/src/utils/spatial.js @@ -1,6 +1,4 @@ import polygonSplitter from 'polygon-splitter' -import turfBearing from '@turf/bearing' -import turfDestination from '@turf/destination' import turfUnion from '@turf/union' import { featureCollection as turfFeatureCollection, @@ -20,23 +18,33 @@ import { */ /** - * Extend a LineString at endpoints. + * Extend a LineString at its endpoints, along their own direction, by a small + * fraction of the adjacent segment's length. Pure planar vector math — no + * geodesic assumption, so it works for any coordinate system (lon/lat degrees, + * projected meters like British National Grid, etc.). Turf's bearing/distance + * functions assume WGS84 lon/lat input; feeding them projected coordinates + * silently produces nonsense (large eastings/northings get treated as + * out-of-range degrees and wrapped), which is why this doesn't use them. * * @param {Feature} line - * @param {number} extendDist (distance to extend in Turf units) + * @param {number} fraction - portion of the adjacent segment's length to extend by */ -function extendLine (line, extendDist = 1, units = 'meters') { +function extendLine (line, fraction = 0.01) { const coords = line.geometry.coordinates.map(c => [...c]) - - // Extend start point backward - const startBearing = turfBearing(coords[1], coords[0]) - const newStart = turfDestination(coords[0], extendDist, startBearing, { units }) - coords[0] = newStart.geometry.coordinates - - // Extend end point forward - const endBearing = turfBearing(coords[coords.length - 2], coords[coords.length - 1]) - const newEnd = turfDestination(coords[coords.length - 1], extendDist, endBearing, { units }) - coords[coords.length - 1] = newEnd.geometry.coordinates + const last = coords.length - 1 + + const extend = (from, towards) => [ + from[0] + (from[0] - towards[0]) * fraction, + from[1] + (from[1] - towards[1]) * fraction + ] + + // Compute both from the original coordinates before assigning either — for a + // 2-point line, coords[last - 1] is coords[0], so writing coords[0] first + // would corrupt the reference point the end extension reads. + const newStart = extend(coords[0], coords[1]) + const newEnd = extend(coords[last], coords[last - 1]) + coords[0] = newStart + coords[last] = newEnd return turfLineString(coords) } diff --git a/plugins/draw/src/utils/spatial.test.js b/plugins/draw/src/utils/spatial.test.js index d5d7c9a46..960327ce4 100644 --- a/plugins/draw/src/utils/spatial.test.js +++ b/plugins/draw/src/utils/spatial.test.js @@ -40,16 +40,29 @@ describe('toTurfGeometry', () => { }) describe('extendLine', () => { - test('extends a two-point line at both ends', () => { - const line = { geometry: { coordinates: [[0, 0], [0, 1]] } } - const result = extendLine(line) + test('extends a two-point line at both ends, along each segment\'s own direction', () => { + const line = { geometry: { coordinates: [[0, 0], [0, 10]] } } + const result = extendLine(line, 0.1) expect(result.geometry.type).toBe('LineString') - expect(result.geometry.coordinates).toHaveLength(2) + // Start moves further away from the second point (backward); end moves + // further away from the second-to-last point (forward). + expect(result.geometry.coordinates).toEqual([[0, -1], [0, 11]]) }) test('extends only the endpoints of a multi-point line', () => { const line = { geometry: { coordinates: [[0, 0], [0, 1], [0, 2]] } } - expect(extendLine(line).geometry.coordinates).toHaveLength(3) + const result = extendLine(line, 0.1) + expect(result.geometry.coordinates).toHaveLength(3) + expect(result.geometry.coordinates[1]).toEqual([0, 1]) // middle vertex untouched + }) + + test('is pure planar math — correct at British National Grid scale (large eastings/northings)', () => { + // Geodesic bearing/destination would misread these as out-of-range lon/lat + // degrees and wrap them; plain vector extension has no such assumption. + const line = { geometry: { coordinates: [[337600, 504600], [337650, 504650]] } } + const result = extendLine(line, 0.1) + expect(result.geometry.coordinates[0]).toEqual([337595, 504595]) + expect(result.geometry.coordinates[1]).toEqual([337655, 504655]) }) }) From cabf7284193cd9e501882aada007604798c1b7cf Mon Sep 17 00:00:00 2001 From: Dan Leech Date: Fri, 10 Jul 2026 17:09:38 +0100 Subject: [PATCH 71/89] Openlayers merge fixes --- .../src/adapters/openlayers/core/featureStore.js | 12 ++++++++---- .../adapters/openlayers/core/featureStore.test.js | 10 ++++++++++ 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/plugins/draw/src/adapters/openlayers/core/featureStore.js b/plugins/draw/src/adapters/openlayers/core/featureStore.js index 5d3aa6e72..6c49195e7 100644 --- a/plugins/draw/src/adapters/openlayers/core/featureStore.js +++ b/plugins/draw/src/adapters/openlayers/core/featureStore.js @@ -39,11 +39,15 @@ export const createFeatureStore = () => { return feature ? format.writeFeatureObject(feature) : null }, - /** Remove a feature by ID. */ + /** Remove one feature, or several, by ID. Matches mapbox-gl-draw's delete(ids), + * which accepts a single id or an array — deleteFeature.js passes either. */ remove (id) { - const feature = this.getOL(id) - if (feature) { - source.removeFeature(feature) + const ids = Array.isArray(id) ? id : [id] + for (const featureId of ids) { + const feature = this.getOL(featureId) + if (feature) { + source.removeFeature(feature) + } } }, diff --git a/plugins/draw/src/adapters/openlayers/core/featureStore.test.js b/plugins/draw/src/adapters/openlayers/core/featureStore.test.js index fd59d3781..33a4e7e9c 100644 --- a/plugins/draw/src/adapters/openlayers/core/featureStore.test.js +++ b/plugins/draw/src/adapters/openlayers/core/featureStore.test.js @@ -40,6 +40,16 @@ test('remove deletes by id and tolerates unknown ids; clear empties the source', expect(store.source.getFeatures()).toHaveLength(0) }) +test('remove accepts an array of ids and deletes all of them', () => { + const store = createFeatureStore() + store.add(geojson('f1')) + store.add(geojson('f2')) + store.add(geojson('f3')) + store.remove(['f1', 'f2', 'missing']) + expect(store.source.getFeatures()).toHaveLength(1) + expect(store.get('f3')).not.toBeNull() +}) + test('toGeoJSON / fromGeoJSON round-trip without touching the source', () => { const store = createFeatureStore() const olFeature = store.fromGeoJSON(geojson('f9')) From 557df47a5c5c5bb3690026d40845349abf0e2a4c Mon Sep 17 00:00:00 2001 From: Dan Leech Date: Fri, 10 Jul 2026 17:14:43 +0100 Subject: [PATCH 72/89] draw-ol.js demo minor consistency amends --- demo/js/draw-ol.js | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/demo/js/draw-ol.js b/demo/js/draw-ol.js index ee1f96900..a185b1e78 100644 --- a/demo/js/draw-ol.js +++ b/demo/js/draw-ol.js @@ -167,13 +167,12 @@ interactiveMap.on('draw:ready', function () { }) interactiveMap.on('draw:started', function (e) { - interactiveMap.toggleButtonState('geometryActions', 'hidden', true) + // console.log('draw:started') interactPlugin.disable() }) interactiveMap.on('draw:editstart', function (e) { - interactiveMap.toggleButtonState('geometryActions', 'hidden', true) - interactPlugin.disable() + console.log('draw:editstart', e) }) interactiveMap.on('draw:created', function (e) { From 02aa1281be0f400adfb02dfddc6b6de18f64d5b0 Mon Sep 17 00:00:00 2001 From: Dan Leech Date: Wed, 15 Jul 2026 09:55:49 +0100 Subject: [PATCH 73/89] package.json unsed deps removed --- demo/js/esm.js | 16 ++++++------ package-lock.json | 66 ++--------------------------------------------- package.json | 4 --- 3 files changed, 10 insertions(+), 76 deletions(-) diff --git a/demo/js/esm.js b/demo/js/esm.js index f2f053f2a..9d87cd8d7 100644 --- a/demo/js/esm.js +++ b/demo/js/esm.js @@ -1,17 +1,17 @@ -import InteractiveMap from '../../src/index.js' +import InteractiveMap from '../../dist/esm/index.js' import { vtsMapStyles3857 } from './mapStyles.js' import { parcelSearch, gridRefSearchETRS89 } from './searchCustomDatasets.js' import { transformGeocodeRequest, transformVtsRequest3857, transformDataRequest } from './auth.js' // Providers -import maplibreProvider from '/providers/maplibre/src/index.js' -import openNamesProvider from '/providers/beta/open-names/src/index.js' +import maplibreProvider from '/providers/maplibre/dist/esm/index.js' +import openNamesProvider from '/providers/beta/open-names/dist/esm/index.js' // Plugins -import mapStylesPlugin from '/plugins/beta/map-styles/src/index.js' +import mapStylesPlugin from '/plugins/beta/map-styles/dist/esm/index.js' import createDatasetsPlugin from '/plugins/beta/datasets/dist/esm/index.js' -import scaleBarPlugin from '/plugins/beta/scale-bar/src/index.js' -import searchPlugin from '/plugins/search/src/index.js' -import createInteractPlugin from '/plugins/interact/src/index.js' -import createFramePlugin from '/plugins/beta/frame/src/index.js' +import scaleBarPlugin from '/plugins/beta/scale-bar/dist/esm/index.js' +import searchPlugin from '/plugins/search/dist/esm/index.js' +import createInteractPlugin from '/plugins/interact/dist/esm/index.js' +import createFramePlugin from '/plugins/beta/frame/dist/esm/index.js' const pointData = { type: 'FeatureCollection', diff --git a/package-lock.json b/package-lock.json index 52298c0fd..bb599b2f5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,11 +16,7 @@ "@turf/boolean-valid": "^7.2.0", "@turf/destination": "^7.3.3", "@turf/helpers": "^7.2.0", - "@turf/line-intersect": "^7.3.3", - "@turf/point-to-line-distance": "^7.3.3", - "@turf/polygon-to-line": "^7.3.3", "@turf/union": "^7.3.5", - "accessible-autocomplete": "^3.0.1", "govuk-frontend": "^5.13.0", "maplibre-gl": "^5.23.0", "polygon-splitter": "^0.0.11", @@ -9903,6 +9899,7 @@ }, "node_modules/@turf/clone": { "version": "7.3.3", + "dev": true, "license": "MIT", "dependencies": { "@turf/helpers": "7.3.3", @@ -10122,26 +10119,6 @@ "url": "https://opencollective.com/turf" } }, - "node_modules/@turf/point-to-line-distance": { - "version": "7.3.3", - "license": "MIT", - "dependencies": { - "@turf/bearing": "7.3.3", - "@turf/distance": "7.3.3", - "@turf/helpers": "7.3.3", - "@turf/invariant": "7.3.3", - "@turf/meta": "7.3.3", - "@turf/nearest-point-on-line": "7.3.3", - "@turf/projection": "7.3.3", - "@turf/rhumb-bearing": "7.3.3", - "@turf/rhumb-distance": "7.3.3", - "@types/geojson": "^7946.0.10", - "tslib": "^2.8.1" - }, - "funding": { - "url": "https://opencollective.com/turf" - } - }, "node_modules/@turf/polygon-to-line": { "version": "7.3.3", "license": "MIT", @@ -10157,6 +10134,7 @@ }, "node_modules/@turf/projection": { "version": "7.3.3", + "dev": true, "license": "MIT", "dependencies": { "@turf/clone": "7.3.3", @@ -10220,32 +10198,6 @@ "url": "https://opencollective.com/turf" } }, - "node_modules/@turf/rhumb-bearing": { - "version": "7.3.3", - "license": "MIT", - "dependencies": { - "@turf/helpers": "7.3.3", - "@turf/invariant": "7.3.3", - "@types/geojson": "^7946.0.10", - "tslib": "^2.8.1" - }, - "funding": { - "url": "https://opencollective.com/turf" - } - }, - "node_modules/@turf/rhumb-distance": { - "version": "7.3.3", - "license": "MIT", - "dependencies": { - "@turf/helpers": "7.3.3", - "@turf/invariant": "7.3.3", - "@types/geojson": "^7946.0.10", - "tslib": "^2.8.1" - }, - "funding": { - "url": "https://opencollective.com/turf" - } - }, "node_modules/@turf/union": { "version": "7.3.5", "resolved": "https://registry.npmjs.org/@turf/union/-/union-7.3.5.tgz", @@ -11521,20 +11473,6 @@ "node": ">= 0.6" } }, - "node_modules/accessible-autocomplete": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/accessible-autocomplete/-/accessible-autocomplete-3.0.1.tgz", - "integrity": "sha512-xMshgc2LT5addvvfCTGzIkRrvhbOFeylFSnSMfS/PdjvvvElZkakCwxO3/yJYBWyi1hi3tZloqOJQ5kqqJtH4g==", - "license": "MIT", - "peerDependencies": { - "preact": "^8.0.0" - }, - "peerDependenciesMeta": { - "preact": { - "optional": true - } - } - }, "node_modules/acorn": { "version": "8.15.0", "dev": true, diff --git a/package.json b/package.json index 73e3d7f30..4225f0fbb 100755 --- a/package.json +++ b/package.json @@ -230,11 +230,7 @@ "@turf/boolean-valid": "^7.2.0", "@turf/destination": "^7.3.3", "@turf/helpers": "^7.2.0", - "@turf/line-intersect": "^7.3.3", - "@turf/point-to-line-distance": "^7.3.3", - "@turf/polygon-to-line": "^7.3.3", "@turf/union": "^7.3.5", - "accessible-autocomplete": "^3.0.1", "govuk-frontend": "^5.13.0", "maplibre-gl": "^5.23.0", "polygon-splitter": "^0.0.11", From 1a0d65df51bbe6827b781f38ef9f05bee39d8e25 Mon Sep 17 00:00:00 2001 From: Dan Leech Date: Mon, 3 Aug 2026 17:08:01 +0100 Subject: [PATCH 74/89] Rubberband fix when switching to touch and using the move control --- plugins/draw/src/DrawInit.jsx | 8 +++++--- plugins/draw/src/DrawInit.test.jsx | 8 ++++---- .../src/adapters/maplibre/modes/drawMode/lifecycle.js | 6 ++++-- .../maplibre/modes/drawMode/pointerHandlers.js | 9 +++++++++ .../maplibre/modes/drawMode/pointerHandlers.test.js | 8 ++++++++ plugins/draw/src/adapters/openlayers/draw/DrawMode.js | 1 + .../src/adapters/openlayers/draw/DrawMode.test.js | 7 +++++++ .../draw/src/adapters/openlayers/draw/drawInput.js | 10 ++++++++++ .../src/adapters/openlayers/draw/drawInput.test.js | 11 +++++++++++ 9 files changed, 59 insertions(+), 9 deletions(-) diff --git a/plugins/draw/src/DrawInit.jsx b/plugins/draw/src/DrawInit.jsx index 8342f06ec..f3881627d 100644 --- a/plugins/draw/src/DrawInit.jsx +++ b/plugins/draw/src/DrawInit.jsx @@ -53,10 +53,12 @@ export const DrawInit = ({ appState, appConfig, mapState, pluginConfig, pluginSt return undefined }, [pluginState.mode, appState.interfaceType]) - // Keep edit mode in sync with the global interface type so the touch offset - // target shows/hides immediately when the input device changes. + // Keep the active draw/edit session in sync with the global interface type so + // the touch offset target shows/hides, and the rubber band keeps following the + // map, immediately when the input device changes mid-session (e.g. the user + // starts drawing with the mouse then switches to touch and pans via MoveControl). useEffect(() => { - if (pluginState.mode !== 'edit_vertex' || !mapProvider.draw) { + if (!['edit_vertex', 'draw_polygon', 'draw_line'].includes(pluginState.mode) || !mapProvider.draw) { return undefined } mapProvider.draw.setInterfaceType(appState.interfaceType) diff --git a/plugins/draw/src/DrawInit.test.jsx b/plugins/draw/src/DrawInit.test.jsx index 87ea9495d..2681fbbce 100644 --- a/plugins/draw/src/DrawInit.test.jsx +++ b/plugins/draw/src/DrawInit.test.jsx @@ -143,15 +143,15 @@ describe('crosshair', () => { }) describe('interface type sync', () => { - test('pushes the interface type to the adapter in edit mode', async () => { - const { props, adapter } = makeProps({ pluginState: { dispatch: jest.fn(), mode: 'edit_vertex' } }) + test.each(['edit_vertex', 'draw_polygon', 'draw_line'])('pushes the interface type to the adapter in %s mode', async (mode) => { + const { props, adapter } = makeProps({ pluginState: { dispatch: jest.fn(), mode } }) props.mapProvider.draw = adapter await renderInit(props) expect(adapter.setInterfaceType).toHaveBeenCalledWith('mouse') }) - test('does nothing outside edit mode', async () => { - const { props, adapter } = makeProps({ pluginState: { dispatch: jest.fn(), mode: 'draw_line' } }) + test('does nothing outside draw/edit modes', async () => { + const { props, adapter } = makeProps({ pluginState: { dispatch: jest.fn(), mode: null } }) props.mapProvider.draw = adapter await renderInit(props) expect(adapter.setInterfaceType).not.toHaveBeenCalled() diff --git a/plugins/draw/src/adapters/maplibre/modes/drawMode/lifecycle.js b/plugins/draw/src/adapters/maplibre/modes/drawMode/lifecycle.js index 6e6379492..cdebad34b 100644 --- a/plugins/draw/src/adapters/maplibre/modes/drawMode/lifecycle.js +++ b/plugins/draw/src/adapters/maplibre/modes/drawMode/lifecycle.js @@ -43,7 +43,8 @@ export const createLifecycle = ({ ParentMode, featureProp, excludeFeatureIdFromS pointermoveHandler: this.onPointermove, pointerupHandler: this.onPointerup, vertexButtonClickHandler: this.onVertexButtonClick, - undoHandler: this.onUndo + undoHandler: this.onUndo, + interfaceTypeChangeHandler: this.onInterfaceTypeChange } Object.entries(handlers).forEach(([k, fn]) => bind(k, fn)) @@ -58,7 +59,8 @@ export const createLifecycle = ({ ParentMode, featureProp, excludeFeatureIdFromS [map, 'pointerdown', this.pointerdownHandler], [map, 'draw.create', this.createHandler], [map, 'move', this.moveHandler], - [map, 'draw.undo', this.undoHandler] + [map, 'draw.undo', this.undoHandler], + [map, 'draw.interfacetypechange', this.interfaceTypeChangeHandler] ] this._listeners.forEach(([t, e, h]) => t.addEventListener ? t.addEventListener(e, h) : t.on(e, h)) diff --git a/plugins/draw/src/adapters/maplibre/modes/drawMode/pointerHandlers.js b/plugins/draw/src/adapters/maplibre/modes/drawMode/pointerHandlers.js index 9c8f9e89c..d59e69c3f 100644 --- a/plugins/draw/src/adapters/maplibre/modes/drawMode/pointerHandlers.js +++ b/plugins/draw/src/adapters/maplibre/modes/drawMode/pointerHandlers.js @@ -17,6 +17,15 @@ export const createPointerHandlers = ({ ParentMode, getFeature, getCoords }) => this.onMove(state, e) }, + // The global interface type (e.g. switching to touch and panning via MoveControl) + // can change mid-session without any touch/pointer/key event ever landing on the + // map container, so this can't rely on onTouchStart/onPointerdown alone — refresh + // the rubber band immediately rather than waiting for the next incidental 'move'. + onInterfaceTypeChange (state, e) { + this._setInterface(state, e.interfaceType) + this.onMove(state) + }, + onBlur (state, e) { if (e.target !== state.container) { this._hideCrossHair(state) diff --git a/plugins/draw/src/adapters/maplibre/modes/drawMode/pointerHandlers.test.js b/plugins/draw/src/adapters/maplibre/modes/drawMode/pointerHandlers.test.js index cdff586ff..a9a06cf56 100644 --- a/plugins/draw/src/adapters/maplibre/modes/drawMode/pointerHandlers.test.js +++ b/plugins/draw/src/adapters/maplibre/modes/drawMode/pointerHandlers.test.js @@ -43,6 +43,14 @@ describe('touch and pointer interface', () => { ctx.blurHandler({ target: document.body }) expect(marker.style.display).toBe('none') }) + + test('draw.interfacetypechange (e.g. switching to touch and panning via MoveControl mid-session) updates the interface and refreshes the rubber band immediately', () => { + const { ctx, state } = setup(DrawPolygonMode) + clickAt(ctx, state, 0, 0) + ctx.map.fire('draw.interfacetypechange', { interfaceType: 'touch' }) + expect(state.interfaceType).toBe('touch') + expect(state.polygon.coordinates[0].at(-1)).toEqual([CENTER.lng, CENTER.lat]) + }) }) describe('rubber band and snapping while moving', () => { diff --git a/plugins/draw/src/adapters/openlayers/draw/DrawMode.js b/plugins/draw/src/adapters/openlayers/draw/DrawMode.js index 18abcc981..85ebb3e0b 100644 --- a/plugins/draw/src/adapters/openlayers/draw/DrawMode.js +++ b/plugins/draw/src/adapters/openlayers/draw/DrawMode.js @@ -140,6 +140,7 @@ const buildDrawModeApi = ({ map, manager, drawInteraction, input, geometryType, cancel () { drawInteraction.abortDrawing() }, undo () { drawInteraction.removeLastPoint(); updateVertexCount(); emitUndoValidation() }, setInvalid, + setInterfaceType (type) { input.setInterfaceType(type) }, // Tags the sketch feature so createSketchStyle can pick the split-specific // colour (see styles.js); no-op before any sketch exists. setDrawingPreviewProperty (property, value) { diff --git a/plugins/draw/src/adapters/openlayers/draw/DrawMode.test.js b/plugins/draw/src/adapters/openlayers/draw/DrawMode.test.js index 6f28e97d7..229f6b107 100644 --- a/plugins/draw/src/adapters/openlayers/draw/DrawMode.test.js +++ b/plugins/draw/src/adapters/openlayers/draw/DrawMode.test.js @@ -8,6 +8,7 @@ import { createFakeMap, createFakeManager, polygonFeature, lineFeature } from '. jest.mock('./drawInput.js', () => ({ createDrawInput: jest.fn(() => ({ getInterfaceType: jest.fn(() => 'keyboard'), + setInterfaceType: jest.fn(), destroy: jest.fn() })) })) @@ -217,6 +218,12 @@ describe('drawing lifecycle', () => { expect(changed).toHaveBeenCalled() }) + test('setInterfaceType forwards to the draw input (e.g. DrawInit syncing MoveControl-driven interface changes mid-session)', () => { + const { mode, input } = setup('Polygon') + mode.setInterfaceType('touch') + expect(input.setInterfaceType).toHaveBeenCalledWith('touch') + }) + test('setDrawingPreviewProperty tags the sketch feature and re-renders; safe with no sketch yet', () => { const { mode, interaction } = setup('LineString') const changed = jest.spyOn(interaction.overlay_, 'changed') diff --git a/plugins/draw/src/adapters/openlayers/draw/drawInput.js b/plugins/draw/src/adapters/openlayers/draw/drawInput.js index 2c172dec4..13abba36a 100644 --- a/plugins/draw/src/adapters/openlayers/draw/drawInput.js +++ b/plugins/draw/src/adapters/openlayers/draw/drawInput.js @@ -121,6 +121,16 @@ export const createDrawInput = ({ drawInteraction, options }) => { return { getInterfaceType, + // Called when the global interface type changes without any pointer/touch/key + // event landing on the map container (e.g. panning via MoveControl after + // switching to touch) — refresh the rubber band immediately rather than + // waiting for the next incidental change:center/postrender event. + setInterfaceType (type) { + interfaceType = type + if (type !== 'mouse') { + placement.updateRubberbanding() + } + }, destroy () { events.destroy() map?.un('postrender', onMapRender) diff --git a/plugins/draw/src/adapters/openlayers/draw/drawInput.test.js b/plugins/draw/src/adapters/openlayers/draw/drawInput.test.js index 8f3d31d1b..2f715dc55 100644 --- a/plugins/draw/src/adapters/openlayers/draw/drawInput.test.js +++ b/plugins/draw/src/adapters/openlayers/draw/drawInput.test.js @@ -109,6 +109,17 @@ test('pointer moves and map pans update the rubber band except for the mouse int expect(mouse.placement.updateRubberbanding).not.toHaveBeenCalled() }) +test('setInterfaceType updates the interface and refreshes the rubber band immediately for non-mouse types, e.g. switching to touch and panning via MoveControl mid-session', () => { + const { input, placement } = setup('mouse') + input.setInterfaceType('touch') + expect(input.getInterfaceType()).toBe('touch') + expect(placement.updateRubberbanding).toHaveBeenCalledTimes(1) + + input.setInterfaceType('mouse') + expect(input.getInterfaceType()).toBe('mouse') + expect(placement.updateRubberbanding).toHaveBeenCalledTimes(1) // not called again for mouse +}) + test('keyboard pan animations keep the rubber band anchored via postrender', () => { const { map, view, placement } = setup('keyboard') view.getAnimating.mockReturnValue(true) From 0a5ce4a5e9b255b6c62377e6a6aa463dd923fa8e Mon Sep 17 00:00:00 2001 From: Dan Leech Date: Mon, 3 Aug 2026 18:04:38 +0100 Subject: [PATCH 75/89] Move control wired up to draw plugin --- demo/js/draw-ol.js | 2 +- plugins/draw/src/DrawInit.jsx | 2 + plugins/draw/src/DrawInit.test.jsx | 4 +- .../draw/src/adapters/adapterContract.test.js | 1 + .../adapters/maplibre/MaplibreDrawAdapter.js | 9 ++++ .../maplibre/MaplibreDrawAdapter.test.js | 6 +++ .../draw/src/adapters/maplibre/drawEvents.js | 3 +- .../adapters/maplibre/modes/editVertexMode.js | 20 +++++++- .../maplibre/modes/editVertexMode.test.js | 13 +++++ .../modes/editVertexMode/vertexOperations.js | 29 +++++++++++ .../editVertexMode/vertexOperations.test.js | 28 +++++++++++ .../src/adapters/openlayers/OLDrawAdapter.js | 2 + .../adapters/openlayers/OLDrawAdapter.test.js | 3 ++ .../adapters/openlayers/core/OLDrawManager.js | 4 ++ .../openlayers/core/OLDrawManager.test.js | 8 +-- .../src/adapters/openlayers/edit/EditMode.js | 5 +- .../adapters/openlayers/edit/EditMode.test.js | 11 +++++ .../openlayers/edit/keyboardHandler.js | 5 +- .../openlayers/edit/keyboardHandler.test.js | 8 +++ .../src/adapters/openlayers/edit/nudge.js | 49 ++++++++++++++----- .../adapters/openlayers/edit/nudge.test.js | 39 ++++++++++++++- plugins/draw/src/events.js | 28 ++++++++++- plugins/draw/src/events.test.js | 28 ++++++++--- plugins/draw/src/manifest.js | 4 +- plugins/draw/src/manifest.test.js | 6 +++ .../components/MoveControl/MoveControl.jsx | 14 ++++++ .../MoveControl/MoveControl.test.jsx | 25 ++++++++++ 27 files changed, 323 insertions(+), 33 deletions(-) diff --git a/demo/js/draw-ol.js b/demo/js/draw-ol.js index b9893fc36..4010a4e7a 100644 --- a/demo/js/draw-ol.js +++ b/demo/js/draw-ol.js @@ -75,7 +75,7 @@ interactiveMap.on('map:ready', function (e) { interactPlugin.enable() interactiveMap.addButton('geometryActions', { label: 'Draw tools', - mobile: { slot: 'bottom-right', order: 3 }, + mobile: { slot: 'top-middle', order: 3 }, tablet: { slot: 'top-middle', order: 3 }, desktop: { slot: 'top-middle', order: 3 }, menuItems: [{ diff --git a/plugins/draw/src/DrawInit.jsx b/plugins/draw/src/DrawInit.jsx index f3881627d..4f5886806 100644 --- a/plugins/draw/src/DrawInit.jsx +++ b/plugins/draw/src/DrawInit.jsx @@ -34,6 +34,8 @@ export const DrawInit = ({ appState, appConfig, mapState, pluginConfig, pluginSt isMounted = false mapProvider.draw?.remove() mapProvider.draw = null + // Release MoveControl's D-pad if this plugin instance still held it. + mapProvider.activeMoveTarget = null } }, [mapState.isMapReady, appState.mode]) diff --git a/plugins/draw/src/DrawInit.test.jsx b/plugins/draw/src/DrawInit.test.jsx index 2681fbbce..98040bcc3 100644 --- a/plugins/draw/src/DrawInit.test.jsx +++ b/plugins/draw/src/DrawInit.test.jsx @@ -80,15 +80,17 @@ describe('adapter lifecycle', () => { expect(loadDrawAdapter).not.toHaveBeenCalled() }) - test('removes the adapter and clears the reference on unmount', async () => { + test('removes the adapter, clears the reference, and releases activeMoveTarget on unmount', async () => { const { props, adapter } = makeProps() const result = await renderInit(props) expect(props.mapProvider.draw).toBe(adapter) + props.mapProvider.activeMoveTarget = { move: jest.fn(), label: 'vertex' } await act(async () => { result.unmount() }) expect(adapter.remove).toHaveBeenCalled() expect(props.mapProvider.draw).toBeNull() + expect(props.mapProvider.activeMoveTarget).toBeNull() }) test('ignores a late-resolving adapter after unmount', async () => { diff --git a/plugins/draw/src/adapters/adapterContract.test.js b/plugins/draw/src/adapters/adapterContract.test.js index eecfe8d22..3b4ca4f00 100644 --- a/plugins/draw/src/adapters/adapterContract.test.js +++ b/plugins/draw/src/adapters/adapterContract.test.js @@ -16,6 +16,7 @@ const CONTRACT_METHODS = [ 'cancel', 'undo', 'deleteVertex', + 'nudgeSelectedVertex', 'get', 'add', 'delete', diff --git a/plugins/draw/src/adapters/maplibre/MaplibreDrawAdapter.js b/plugins/draw/src/adapters/maplibre/MaplibreDrawAdapter.js index 494e252d8..6685159cf 100644 --- a/plugins/draw/src/adapters/maplibre/MaplibreDrawAdapter.js +++ b/plugins/draw/src/adapters/maplibre/MaplibreDrawAdapter.js @@ -41,6 +41,7 @@ export const displayedShape = (mode, coordinates) => { * getMode() * setInterfaceType(type) * done() / cancel() / undo() / deleteVertex() + * nudgeSelectedVertex(dx, dy, isLargeStep) * get(id) / add(feature) / delete(id) / deleteAll() * setSnapEnabled(bool) / setSnapLayers(layers) / isSnapEnabled() * setFeatureProperty(id, property, value) / setDrawingPreviewProperty(property, value) @@ -202,6 +203,14 @@ export class MaplibreDrawAdapter { this._map.fire(CUSTOM_DRAW_EVENTS.UNDO) } + // MoveControl's D-pad, routed here via mapProvider.activeMoveTarget (see + // events.js) once a vertex is selected. Bridged into the running edit mode the + // same way setInterfaceType is, since the adapter has no direct reference to the + // mode's live state. + nudgeSelectedVertex (dx, dy, isLargeStep) { + this._map.fire(CUSTOM_DRAW_EVENTS.NUDGE_VERTEX, { dx, dy, isLargeStep }) + } + // Record the current geometry validity so the draw mode can block finish gestures // (double-click / click-to-close) while the in-progress shape is invalid. setGeometryValid (valid) { diff --git a/plugins/draw/src/adapters/maplibre/MaplibreDrawAdapter.test.js b/plugins/draw/src/adapters/maplibre/MaplibreDrawAdapter.test.js index bc5524ffa..def3d03a0 100644 --- a/plugins/draw/src/adapters/maplibre/MaplibreDrawAdapter.test.js +++ b/plugins/draw/src/adapters/maplibre/MaplibreDrawAdapter.test.js @@ -407,6 +407,12 @@ describe('simple delegations', () => { expect(map.fire).toHaveBeenCalledWith(CUSTOM_DRAW_EVENTS.UNDO) }) + test('nudgeSelectedVertex fires the nudge-vertex event with the given delta and step size', () => { + const { adapter, map } = setup() + adapter.nudgeSelectedVertex(1, 0, true) + expect(map.fire).toHaveBeenCalledWith(CUSTOM_DRAW_EVENTS.NUDGE_VERTEX, { dx: 1, dy: 0, isLargeStep: true }) + }) + test('deleteVertex is a no-op', () => { const { adapter, draw, map } = setup() expect(() => adapter.deleteVertex()).not.toThrow() diff --git a/plugins/draw/src/adapters/maplibre/drawEvents.js b/plugins/draw/src/adapters/maplibre/drawEvents.js index c28c48d9d..6b158037a 100644 --- a/plugins/draw/src/adapters/maplibre/drawEvents.js +++ b/plugins/draw/src/adapters/maplibre/drawEvents.js @@ -27,7 +27,8 @@ export const CUSTOM_DRAW_EVENTS = { UNDO: 'draw.undo', GEOMETRY_CHANGE: 'draw.geometrychange', INTERFACE_TYPE_CHANGE: 'draw.interfacetypechange', - PLACEMENT_BLOCKED: 'draw.placementblocked' + PLACEMENT_BLOCKED: 'draw.placementblocked', + NUDGE_VERTEX: 'draw.nudgevertex' } // Native MapLibre map event (not a draw event) — fires whenever the map style data changes. diff --git a/plugins/draw/src/adapters/maplibre/modes/editVertexMode.js b/plugins/draw/src/adapters/maplibre/modes/editVertexMode.js index 55df4b685..4a392754a 100755 --- a/plugins/draw/src/adapters/maplibre/modes/editVertexMode.js +++ b/plugins/draw/src/adapters/maplibre/modes/editVertexMode.js @@ -10,6 +10,7 @@ import { keyboardHandlers } from './editVertexMode/keyboardHandlers.js' import { pointerHandlers } from './editVertexMode/pointerHandlers.js' const EVENT_VERTEX_SELECTION = 'draw.vertexselection' +const EVENT_NUDGE_VERTEX = 'draw.nudgevertex' export const EditVertexMode = { ...MapboxDraw.modes.direct_select, @@ -94,7 +95,8 @@ export const EditVertexMode = { scalechange: bind(this.onScaleChange), update: bind(this.onUpdate), move: bind(this.onMove), - interfacetypechange: bind(this.onInterfaceTypeChange) + interfacetypechange: bind(this.onInterfaceTypeChange), + nudgevertex: bind(this.onNudgeVertex) } window.addEventListener('keydown', h.keydown, { capture: true }) @@ -111,6 +113,7 @@ export const EditVertexMode = { this.map.on('draw.update', h.update) this.map.on('move', h.move) this.map.on('draw.interfacetypechange', h.interfacetypechange) + this.map.on(EVENT_NUDGE_VERTEX, h.nudgevertex) }, applyVertexSelection (state, options) { @@ -186,6 +189,20 @@ export const EditVertexMode = { } }, + // Inbound signal from MaplibreDrawAdapter.nudgeSelectedVertex — bridges into the + // running mode the same way onInterfaceTypeChange does, since the adapter has no + // direct reference to this mode's live state. Unlike OL (where setState's shared + // onUpdate hook repositions the touch target for any vertex move), moveVertex + // here has no equivalent hook, so the reposition has to happen explicitly — + // same as onMove/onSelectionChange/onInterfaceTypeChange already do. + onNudgeVertex (state, e) { + this.nudgeVertexByDelta(state, e.dx, e.dy, e.isLargeStep) + const vertex = state.vertecies[state.selectedVertexIndex] + if (vertex) { + this.updateTouchVertexTarget(state, scalePoint(this.map.project(vertex), state.scale)) + } + }, + onButtonClick (state, e) { if (e.target.closest(`#${state.deleteVertexButtonId}`) && state.selectedVertexType === 'vertex') { this.deleteVertex(state) @@ -222,6 +239,7 @@ export const EditVertexMode = { this.map.off('draw.update', h.update) this.map.off('move', h.move) this.map.off('draw.interfacetypechange', h.interfacetypechange) + this.map.off(EVENT_NUDGE_VERTEX, h.nudgevertex) this.map.dragPan.enable() window.removeEventListener('click', h.click) window.removeEventListener('keydown', h.keydown, { capture: true }) diff --git a/plugins/draw/src/adapters/maplibre/modes/editVertexMode.test.js b/plugins/draw/src/adapters/maplibre/modes/editVertexMode.test.js index 93bd36a0c..debc66630 100644 --- a/plugins/draw/src/adapters/maplibre/modes/editVertexMode.test.js +++ b/plugins/draw/src/adapters/maplibre/modes/editVertexMode.test.js @@ -17,6 +17,7 @@ describe('onSetup / onStop lifecycle', () => { expect(map._lastEditFeatureId).toBe('feat-1') expect(map._drawEditContainer).toBe(state.container) expect(ctx.map.on).toHaveBeenCalledWith('draw.update', expect.any(Function)) + expect(ctx.map.on).toHaveBeenCalledWith('draw.nudgevertex', expect.any(Function)) }) test('does not clear the undo stack when re-entering the same feature', () => { @@ -33,6 +34,7 @@ describe('onSetup / onStop lifecycle', () => { ctx.onStop(state) expect(map._drawEditContainer).toBeNull() expect(map.off).toHaveBeenCalledWith('draw.update', expect.any(Function)) + expect(map.off).toHaveBeenCalledWith('draw.nudgevertex', expect.any(Function)) }) test('onSetup edge cases: clears an active snap indicator, positions or skips the touch target, clears an explicit -1 selection', () => { @@ -97,6 +99,17 @@ describe('selection, scale and update events', () => { ctx.onInterfaceTypeChange({ ...state, selectedVertexIndex: -1 }, { interfaceType: 'mouse' }) }) + test('draw.nudgevertex moves the selected vertex and repositions the touch target — the inbound bridge for MoveControl.mapProvider.activeMoveTarget', () => { + jest.useFakeTimers() + const { state, map } = createHarness(POLYGON(), { interfaceType: 'touch', selectedVertexIndex: 0, selectedVertexType: 'vertex' }) + jest.runAllTimers() // flush onSetup's deferred initial touch-target positioning + const before = [...state.vertecies[0]] + const targetBefore = { top: state.touchVertexTarget.style.top, left: state.touchVertexTarget.style.left } + map.fire('draw.nudgevertex', { dx: 1, dy: 0, isLargeStep: true }) + expect(state.vertecies[0]).not.toEqual(before) + expect({ top: state.touchVertexTarget.style.top, left: state.touchVertexTarget.style.left }).not.toEqual(targetBefore) + }) + test('onUpdate re-selects a changed vertex only when the vertex count is ambiguous', () => { const { ctx, state } = createHarness() ctx.onUpdate(state) // unique coords → no change diff --git a/plugins/draw/src/adapters/maplibre/modes/editVertexMode/vertexOperations.js b/plugins/draw/src/adapters/maplibre/modes/editVertexMode/vertexOperations.js index c2de734e6..87c148e2e 100644 --- a/plugins/draw/src/adapters/maplibre/modes/editVertexMode/vertexOperations.js +++ b/plugins/draw/src/adapters/maplibre/modes/editVertexMode/vertexOperations.js @@ -38,6 +38,35 @@ export const vertexOperations = { return this.getOffset(getCoords(this.getFeature(state.featureId))[state.selectedVertexIndex], e) }, + // Explicit-delta counterpart to getOffset, driven by a unit direction vector and + // MoveControl's own Precision toggle rather than a KeyboardEvent — used by + // nudgeVertexByDelta below. + getOffsetByDelta (coord, dx, dy, isLargeStep) { + const pt = this.map.project(coord) + const amount = isLargeStep ? KEYBOARD.stepAmount : KEYBOARD.nudgeAmount + return this.map.unproject({ x: pt.x + dx * amount, y: pt.y + dy * amount }) + }, + + // Moves the selected vertex by an explicit (dx, dy) unit direction — the entry + // point for MoveControl's D-pad (see mapProvider.activeMoveTarget in events.js), + // as opposed to moveVertexByKey's KeyboardEvent-driven path. Each call is treated + // as one complete, undoable action (no held-key sequencing/snap-breaking, since a + // button click has no "held" state to batch the way arrow keys do). + nudgeVertexByDelta (state, dx, dy, isLargeStep) { + if (state.selectedVertexType !== 'vertex' || state.selectedVertexIndex < 0) { + return + } + const feature = this.getFeature(state.featureId) + const currentCoord = feature && getCoords(feature)?.[state.selectedVertexIndex] + if (!currentCoord) { + return + } + const previousPosition = [...currentCoord] + const vertexIndex = state.selectedVertexIndex + this.moveVertex(state, this.getOffsetByDelta(currentCoord, dx, dy, isLargeStep)) + this.pushUndo({ type: 'move_vertex', featureId: state.featureId, vertexIndex, previousPosition }) + }, + insertVertex (state, e) { const midIdx = state.selectedVertexIndex - state.vertecies.length const newCoord = this.getOffset(state.midpoints[midIdx], e) diff --git a/plugins/draw/src/adapters/maplibre/modes/editVertexMode/vertexOperations.test.js b/plugins/draw/src/adapters/maplibre/modes/editVertexMode/vertexOperations.test.js index 45efe27c8..92fae3c77 100644 --- a/plugins/draw/src/adapters/maplibre/modes/editVertexMode/vertexOperations.test.js +++ b/plugins/draw/src/adapters/maplibre/modes/editVertexMode/vertexOperations.test.js @@ -45,6 +45,34 @@ describe('vertexOperations', () => { expect(ctx.getNewCoord(state, { key: 'ArrowRight', shiftKey: false })).toHaveProperty('lng') }) + test('getOffsetByDelta applies step/nudge amounts along an explicit direction, mirroring getOffset\'s shiftKey polarity', () => { + const { ctx } = createHarness() + const large = ctx.getOffsetByDelta([5, 5], 1, 0, true) + const small = ctx.getOffsetByDelta([5, 5], 1, 0, false) + expect(large.lng).not.toBe(5) + expect(Math.abs(small.lng - 5)).toBeLessThan(Math.abs(large.lng - 5)) + expect(ctx.getOffsetByDelta([5, 5], 0, 0, true)).toEqual({ lng: 5, lat: 5 }) + }) + + test('nudgeVertexByDelta moves the selected vertex and pushes a single undo entry, or no-ops for a midpoint/no selection', () => { + const { ctx, state, map } = createHarness() + state.selectedVertexIndex = 0 + state.selectedVertexType = 'vertex' + const before = [...state.vertecies[0]] + ctx.nudgeVertexByDelta(state, 1, 0, true) + expect(state.vertecies[0]).not.toEqual(before) + expect(map._undoStack.pop()).toMatchObject({ type: 'move_vertex', vertexIndex: 0, previousPosition: before }) + + map._undoStack.clear() + ctx.nudgeVertexByDelta({ ...state, selectedVertexType: 'midpoint' }, 1, 0, true) + ctx.nudgeVertexByDelta({ ...state, selectedVertexIndex: -1 }, 1, 0, true) + // Passes the type/index guard but resolves to no coordinate — missing feature, + // and a valid feature with an out-of-range index. + ctx.nudgeVertexByDelta({ ...state, featureId: 'missing' }, 1, 0, true) + ctx.nudgeVertexByDelta({ ...state, selectedVertexIndex: 99 }, 1, 0, true) + expect(map._undoStack).toHaveLength(0) + }) + test('insertVertex splits a midpoint into a new vertex, records undo and selects it', () => { const { ctx, state, api } = createHarness() ctx.insertVertex({ ...state, selectedVertexIndex: state.vertecies.length, selectedVertexType: 'midpoint' }, { key: 'ArrowRight', shiftKey: false }) diff --git a/plugins/draw/src/adapters/openlayers/OLDrawAdapter.js b/plugins/draw/src/adapters/openlayers/OLDrawAdapter.js index 86fbc1f65..b43cb982f 100644 --- a/plugins/draw/src/adapters/openlayers/OLDrawAdapter.js +++ b/plugins/draw/src/adapters/openlayers/OLDrawAdapter.js @@ -24,6 +24,7 @@ const DRAW_OUTLINE_STYLE_LAYER = 'stroke-inactive.cold' * getMode() * setInterfaceType(type) * done() / cancel() / undo() / deleteVertex() + * nudgeSelectedVertex(dx, dy, isLargeStep) * get(id) / add(feature) / delete(id) / deleteAll() * setSnapEnabled(bool) / setSnapLayers(layers) / isSnapEnabled() * setFeatureProperty(id, property, value) / setDrawingPreviewProperty(property, value) @@ -70,6 +71,7 @@ export class OLDrawAdapter { undo () { this._manager.undo() } deleteVertex () { this._manager.deleteVertex() } + nudgeSelectedVertex (dx, dy, isLargeStep) { this._manager.nudgeSelectedVertex(dx, dy, isLargeStep) } // Record the current geometry validity so the draw mode can block finish gestures // (double-click / click-to-close) while the in-progress shape is invalid. diff --git a/plugins/draw/src/adapters/openlayers/OLDrawAdapter.test.js b/plugins/draw/src/adapters/openlayers/OLDrawAdapter.test.js index a71671631..511d5fa32 100644 --- a/plugins/draw/src/adapters/openlayers/OLDrawAdapter.test.js +++ b/plugins/draw/src/adapters/openlayers/OLDrawAdapter.test.js @@ -15,6 +15,7 @@ const fakeManager = () => ({ cancel: jest.fn(), undo: jest.fn(), deleteVertex: jest.fn(), + nudgeSelectedVertex: jest.fn(), setInvalid: jest.fn(), setDrawingPreviewProperty: jest.fn(), get: jest.fn(() => 'feature'), @@ -102,6 +103,7 @@ test('remaining calls delegate straight through; setFeatureProperty is a deliber adapter.deleteAll() adapter.undo() adapter.deleteVertex() + adapter.nudgeSelectedVertex(1, 0, true) const handler = () => {} adapter.on('create', handler) adapter.off('create', handler) @@ -113,6 +115,7 @@ test('remaining calls delegate straight through; setFeatureProperty is a deliber expect(manager.off).toHaveBeenCalledWith('create', handler) expect(manager.undo).toHaveBeenCalled() expect(manager.deleteVertex).toHaveBeenCalled() + expect(manager.nudgeSelectedVertex).toHaveBeenCalledWith(1, 0, true) }) test('setGeometryValid records validity on the manager for finish gating', () => { diff --git a/plugins/draw/src/adapters/openlayers/core/OLDrawManager.js b/plugins/draw/src/adapters/openlayers/core/OLDrawManager.js index 5ce6ab0a1..3d85c2da4 100644 --- a/plugins/draw/src/adapters/openlayers/core/OLDrawManager.js +++ b/plugins/draw/src/adapters/openlayers/core/OLDrawManager.js @@ -120,6 +120,10 @@ export class OLDrawManager { this._modeInstance?.deleteVertex() } + nudgeSelectedVertex (dx, dy, isLargeStep) { + this._modeInstance?.nudgeSelectedVertex?.(dx, dy, isLargeStep) + } + // Show/hide the dashed invalid stroke on the active draw sketch or edit feature. setInvalid (invalid) { this._modeInstance?.setInvalid?.(invalid) diff --git a/plugins/draw/src/adapters/openlayers/core/OLDrawManager.test.js b/plugins/draw/src/adapters/openlayers/core/OLDrawManager.test.js index b91a186da..84546ee10 100644 --- a/plugins/draw/src/adapters/openlayers/core/OLDrawManager.test.js +++ b/plugins/draw/src/adapters/openlayers/core/OLDrawManager.test.js @@ -8,10 +8,10 @@ import { createFakeMap } from '../__helpers__/harness.js' import { TOLERANCES } from '../defaults.js' jest.mock('../draw/DrawMode.js', () => ({ - createDrawMode: jest.fn(() => ({ destroy: jest.fn(), done: jest.fn(), cancel: jest.fn(), undo: jest.fn(), deleteVertex: jest.fn(), setInterfaceType: jest.fn(), setInvalid: jest.fn(), setDrawingPreviewProperty: jest.fn() })) + createDrawMode: jest.fn(() => ({ destroy: jest.fn(), done: jest.fn(), cancel: jest.fn(), undo: jest.fn(), deleteVertex: jest.fn(), nudgeSelectedVertex: jest.fn(), setInterfaceType: jest.fn(), setInvalid: jest.fn(), setDrawingPreviewProperty: jest.fn() })) })) jest.mock('../edit/EditMode.js', () => ({ - createEditMode: jest.fn(() => ({ destroy: jest.fn(), done: jest.fn(), cancel: jest.fn(), undo: jest.fn(), deleteVertex: jest.fn(), setInterfaceType: jest.fn(), setInvalid: jest.fn() })) + createEditMode: jest.fn(() => ({ destroy: jest.fn(), done: jest.fn(), cancel: jest.fn(), undo: jest.fn(), deleteVertex: jest.fn(), nudgeSelectedVertex: jest.fn(), setInterfaceType: jest.fn(), setInvalid: jest.fn() })) })) jest.mock('../snap/snapManager.js', () => ({ createSnapManager: jest.fn(() => ({ setIndicatorActive: jest.fn(), reattach: jest.fn(), updateColors: jest.fn(), destroy: jest.fn() })) @@ -74,7 +74,7 @@ describe('mode machine', () => { test('operations delegate to the current mode instance and are safe without one', async () => { const { manager } = setup() - manager.done(); manager.undo(); manager.deleteVertex(); manager.setInterfaceType('touch'); manager.setInvalid(true) // no mode — no throw + manager.done(); manager.undo(); manager.deleteVertex(); manager.nudgeSelectedVertex(1, 0, true); manager.setInterfaceType('touch'); manager.setInvalid(true) // no mode — no throw manager.setDrawingPreviewProperty('splitter', 'valid') // no mode — no throw await manager.changeMode('draw_polygon') @@ -82,6 +82,7 @@ describe('mode machine', () => { manager.done() manager.undo() manager.deleteVertex() + manager.nudgeSelectedVertex(1, 0, true) const emitted = jest.fn() manager.on('interfacetypechange', emitted) manager.setInterfaceType('touch') @@ -90,6 +91,7 @@ describe('mode machine', () => { expect(instance.done).toHaveBeenCalled() expect(instance.undo).toHaveBeenCalled() expect(instance.deleteVertex).toHaveBeenCalled() + expect(instance.nudgeSelectedVertex).toHaveBeenCalledWith(1, 0, true) expect(instance.setInterfaceType).toHaveBeenCalledWith('touch') // Parity with ML: an explicit interface-type write is echoed on the bus. expect(emitted).toHaveBeenCalledWith({ interfaceType: 'touch' }) diff --git a/plugins/draw/src/adapters/openlayers/edit/EditMode.js b/plugins/draw/src/adapters/openlayers/edit/EditMode.js index cf3931650..3b141b8ee 100644 --- a/plugins/draw/src/adapters/openlayers/edit/EditMode.js +++ b/plugins/draw/src/adapters/openlayers/edit/EditMode.js @@ -273,6 +273,9 @@ const buildModeApi = ({ manager, store, olFeature, originalFeatureStyle, selecti undo: actions.doUndo, deleteVertex: actions.doDeleteVertex, + // MoveControl's D-pad, routed here via mapProvider.activeMoveTarget (see + // events.js) once a vertex is selected. + nudgeSelectedVertex: parts.keyboardHandler.nudgeByDelta, destroy () { parts.live.destroy() @@ -291,7 +294,7 @@ const buildModeApi = ({ manager, store, olFeature, originalFeatureStyle, selecti } /** - * @returns {{ setInterfaceType, done, cancel, undo, deleteVertex, destroy } | null} + * @returns {{ setInterfaceType, done, cancel, undo, deleteVertex, nudgeSelectedVertex, destroy } | null} */ export const createEditMode = ({ map, manager, options }) => { const { featureId, container, interfaceType, deleteVertexButtonId, snap } = options diff --git a/plugins/draw/src/adapters/openlayers/edit/EditMode.test.js b/plugins/draw/src/adapters/openlayers/edit/EditMode.test.js index 9130090cd..3b137cf46 100644 --- a/plugins/draw/src/adapters/openlayers/edit/EditMode.test.js +++ b/plugins/draw/src/adapters/openlayers/edit/EditMode.test.js @@ -111,6 +111,17 @@ test('select via pointer, delete the vertex, then undo restores it', () => { mode.undo() // empty stack — no-op }) +test('nudgeSelectedVertex (MoveControl D-pad) moves the selected vertex and is undoable', () => { + const { container, manager, mode, ring } = setup() + container.dispatchEvent(domEvent('pointerdown', { pointerType: 'mouse', clientX: 100, clientY: 0 })) + mode.nudgeSelectedVertex(1, 0, true) + expect(ring()[1]).not.toEqual([100, 0]) + expect(manager.undoStack.length).toBe(1) + + mode.undo() + expect(ring()[1]).toEqual([100, 0]) +}) + test('undo re-validates with the inverse change phase (undo of a delete re-inserts)', () => { jest.useFakeTimers() const { container, manager, mode } = setup() diff --git a/plugins/draw/src/adapters/openlayers/edit/keyboardHandler.js b/plugins/draw/src/adapters/openlayers/edit/keyboardHandler.js index 9a7a20052..7c0bfa647 100644 --- a/plugins/draw/src/adapters/openlayers/edit/keyboardHandler.js +++ b/plugins/draw/src/adapters/openlayers/edit/keyboardHandler.js @@ -133,11 +133,11 @@ const buildKeyupHandler = ({ snap, keyMove, onVertexMoved, onDeleted, isFocused * convert it. Only pressing a plain/Shift arrow converts it. * * @param {{ map, getState, setState, snap, onVertexMoved, onInserted, onDeleted, onUndo, onKeyboardActive }} options - * @returns {{ destroy }} + * @returns {{ nudgeByDelta: (dx: number, dy: number, isLargeStep: boolean) => void, destroy: () => void }} */ export const createKeyboardHandler = (options) => { const { map, snap, getState, setState, onVertexMoved, onInserted, onDeleted, onUndo, onKeyboardActive } = options - const { nudge, keyMove } = wireNudge({ map, snap, getState, setState, onInserted }) + const { nudge, keyMove, nudgeByDelta } = wireNudge({ map, snap, getState, setState, onInserted, onVertexMoved }) const appViewport = map.getViewport().closest('[role="application"]') ?? map.getViewport() const isFocused = () => isInteractiveElementFocused(appViewport) @@ -148,6 +148,7 @@ export const createKeyboardHandler = (options) => { globalThis.addEventListener('keyup', onKeyup, { capture: true }) return { + nudgeByDelta, destroy () { globalThis.removeEventListener('keydown', onKeydown, { capture: true }) globalThis.removeEventListener('keyup', onKeyup, { capture: true }) diff --git a/plugins/draw/src/adapters/openlayers/edit/keyboardHandler.test.js b/plugins/draw/src/adapters/openlayers/edit/keyboardHandler.test.js index 4fa0e3317..7d0011e6c 100644 --- a/plugins/draw/src/adapters/openlayers/edit/keyboardHandler.test.js +++ b/plugins/draw/src/adapters/openlayers/edit/keyboardHandler.test.js @@ -33,6 +33,14 @@ afterEach(() => { const key = (type, props) => window.dispatchEvent(new KeyboardEvent(type, { cancelable: true, ...props })) +test('nudgeByDelta (exposed for MoveControl) moves the selected vertex and reports it via onVertexMoved', () => { + const { state, handler, onVertexMoved } = setup() + Object.assign(state, { selectedVertexIndex: 1, selectedVertexType: 'vertex' }) + handler.nudgeByDelta(1, 0, true) + expect(state.vertices[1]).not.toEqual([100, 0]) + expect(onVertexMoved).toHaveBeenCalledWith(expect.objectContaining({ vertexIndex: 1 })) +}) + test('Space selects the vertex or midpoint nearest the crosshair, only when nothing is selected', () => { const { state, setState, onKeyboardActive } = setup() key('keydown', { key: ' ' }) diff --git a/plugins/draw/src/adapters/openlayers/edit/nudge.js b/plugins/draw/src/adapters/openlayers/edit/nudge.js index 60d850077..9db9e2786 100644 --- a/plugins/draw/src/adapters/openlayers/edit/nudge.js +++ b/plugins/draw/src/adapters/openlayers/edit/nudge.js @@ -30,11 +30,29 @@ export const resolveSnappedCoord = (snap, map, current, nudgedCoord, snappedCoor * `keyMove` tracks the coordinate at the start of a nudge sequence so a single * move_vertex undo op can be pushed on keyup (see keyboardHandler). * - * @returns {{ nudge: (e: KeyboardEvent) => void, keyMove: { start, index } }} + * @returns {{ nudge: (e: KeyboardEvent) => void, keyMove: { start, index }, nudgeByDelta: (dx: number, dy: number, isLargeStep: boolean) => void }} */ -export const wireNudge = ({ map, snap, getState, setState, onInserted }) => { +export const wireNudge = ({ map, snap, getState, setState, onInserted, onVertexMoved }) => { const keyMove = { start: null, index: null } + // Shared core: moves the selected vertex by an already pixel-scaled delta, + // applying snap. Returns the previous coordinate (for undo) and vertex index, + // or null if there's no valid vertex selection to move. + const moveSelectedVertexBy = (dx, dy) => { + const { selectedVertexIndex, vertices, olFeature } = getState() + if (!olFeature || selectedVertexIndex < 0 || !vertices[selectedVertexIndex]) { + return null + } + const current = vertices[selectedVertexIndex] + const nudgedCoord = nudgeCoord(map, current, dx, dy) + const snappedCoord = snap ? snap.apply(nudgedCoord) : nudgedCoord + snap?.hideIndicator() + const newCoord = resolveSnappedCoord(snap, map, current, nudgedCoord, snappedCoord, dx, dy) + moveVertex(olFeature, selectedVertexIndex, newCoord) + setState({ vertices: vertices.map((c, i) => i === selectedVertexIndex ? newCoord : c) }) + return { previousCoord: current, vertexIndex: selectedVertexIndex } + } + // Insert the midpoint as a vertex then move it — midpoints stay as midpoints until actually moved. const nudgeMidpoint = (olFeature, midpoints, selectedVertexIndex, vertices, dx, dy) => { const result = insertAtMidpoint(olFeature, midpoints, selectedVertexIndex, vertices.length) @@ -74,18 +92,27 @@ export const wireNudge = ({ map, snap, getState, setState, onInserted }) => { if (selectedVertexIndex < 0 || !vertices[selectedVertexIndex]) { return } - const current = vertices[selectedVertexIndex] if (!keyMove.start) { - keyMove.start = [...current] + keyMove.start = [...vertices[selectedVertexIndex]] keyMove.index = selectedVertexIndex } - const nudgedCoord = nudgeCoord(map, current, dx, dy) - const snappedCoord = snap ? snap.apply(nudgedCoord) : nudgedCoord - snap?.hideIndicator() - const newCoord = resolveSnappedCoord(snap, map, current, nudgedCoord, snappedCoord, dx, dy) - moveVertex(olFeature, selectedVertexIndex, newCoord) - setState({ vertices: vertices.map((c, i) => i === selectedVertexIndex ? newCoord : c) }) + moveSelectedVertexBy(dx, dy) + } + + // Explicit-delta counterpart to nudge(e) — the entry point for MoveControl's + // D-pad (see mapProvider.activeMoveTarget in events.js). Unlike nudge(e)'s + // held-key sequencing (undo pushed on keyup via keyMove, so repeated keydowns + // while held batch into one undo step), each call here is one complete, + // undoable action — a button click has no "held" state to batch. Midpoints + // aren't handled here: MoveControl only claims the D-pad once a real vertex is + // selected (see buildVertexMoveTarget in events.js). + const nudgeByDelta = (dx, dy, isLargeStep) => { + const step = isLargeStep ? KEYBOARD.stepAmount : KEYBOARD.nudgeAmount + const result = moveSelectedVertexBy(dx * step, dy * step) + if (result) { + onVertexMoved({ vertexIndex: result.vertexIndex, previousCoord: result.previousCoord }) + } } - return { nudge, keyMove } + return { nudge, keyMove, nudgeByDelta } } diff --git a/plugins/draw/src/adapters/openlayers/edit/nudge.test.js b/plugins/draw/src/adapters/openlayers/edit/nudge.test.js index 5537c3dc5..e8bce53fb 100644 --- a/plugins/draw/src/adapters/openlayers/edit/nudge.test.js +++ b/plugins/draw/src/adapters/openlayers/edit/nudge.test.js @@ -19,8 +19,9 @@ const setup = ({ snap = null } = {}) => { // EditMode's onInserted runs syncGeom, refreshing vertices from the geometry state.vertices = getCoords({ type: 'Polygon', coordinates: olFeature.getGeometry().getCoordinates() }) }) - const { nudge, keyMove } = wireNudge({ map, snap, getState: () => state, setState, onInserted }) - return { olFeature, state, setState, onInserted, nudge, keyMove } + const onVertexMoved = jest.fn() + const { nudge, keyMove, nudgeByDelta } = wireNudge({ map, snap, getState: () => state, setState, onInserted, onVertexMoved }) + return { olFeature, state, setState, onInserted, onVertexMoved, nudge, keyMove, nudgeByDelta } } const ring = (olFeature) => olFeature.getGeometry().getCoordinates()[0] @@ -52,6 +53,40 @@ describe('nudging a vertex', () => { }) }) +describe('nudgeByDelta (MoveControl D-pad)', () => { + test('moves the selected vertex by an explicit direction, scaled by isLargeStep, and reports the move for undo', () => { + const { state, nudgeByDelta, olFeature, onVertexMoved } = setup() + Object.assign(state, { selectedVertexIndex: 1, selectedVertexType: 'vertex' }) + nudgeByDelta(1, 0, true) + expect(ring(olFeature)[1]).toEqual([15, 0]) + expect(onVertexMoved).toHaveBeenCalledWith({ vertexIndex: 1, previousCoord: [10, 0] }) + + onVertexMoved.mockClear() + nudgeByDelta(0, 1, false) + expect(ring(olFeature)[1]).toEqual([15, 1]) + expect(onVertexMoved).toHaveBeenCalledWith({ vertexIndex: 1, previousCoord: [15, 0] }) + }) + + test('each call is its own undo-able action, unlike nudge(e)\'s held-key batching', () => { + const { state, nudgeByDelta, onVertexMoved } = setup() + Object.assign(state, { selectedVertexIndex: 1, selectedVertexType: 'vertex' }) + nudgeByDelta(1, 0, true) + nudgeByDelta(1, 0, true) + expect(onVertexMoved).toHaveBeenCalledTimes(2) + }) + + test('does nothing without a feature or a valid vertex selection, and never touches midpoints', () => { + const { state, nudgeByDelta, onVertexMoved } = setup() + nudgeByDelta(1, 0, true) // nothing selected + Object.assign(state, { selectedVertexIndex: 4, selectedVertexType: 'midpoint' }) + nudgeByDelta(1, 0, true) // midpoint selected — MoveControl only claims a real vertex + state.olFeature = null + Object.assign(state, { selectedVertexIndex: 1, selectedVertexType: 'vertex' }) + nudgeByDelta(1, 0, true) + expect(onVertexMoved).not.toHaveBeenCalled() + }) +}) + describe('nudging a midpoint', () => { test('inserts the midpoint as a vertex, then moves and selects it', () => { const { state, nudge, onInserted, olFeature, setState } = setup() diff --git a/plugins/draw/src/events.js b/plugins/draw/src/events.js index 4e3133802..00c891f59 100644 --- a/plugins/draw/src/events.js +++ b/plugins/draw/src/events.js @@ -12,6 +12,28 @@ import { MAP_SIZE_SCALES } from './defaults.js' const EDIT_VERTEX_MODE = 'edit_vertex' const GEOMETRY_INVALID_EVENT = 'draw:geometryinvalid' +// Claims MoveControl's D-pad for the currently selected vertex — see the generic +// mapProvider.activeMoveTarget contract (MoveControl.jsx). Any plugin could use +// this slot; the draw plugin is just its first consumer. +const buildVertexMoveTarget = (draw) => ({ + move: (dx, dy, isLargeStep) => draw.nudgeSelectedVertex(dx, dy, isLargeStep), + label: 'vertex' +}) + +// Vertex-selection handlers: sync pluginState.selectedVertexIndex, and claim/release +// MoveControl's D-pad via buildVertexMoveTarget above. +const createVertexSelectionHandlers = ({ draw, pluginState, mapProvider, eventBus }) => ({ + onVertexSelection: (e) => { + pluginState.dispatch({ type: 'SET_SELECTED_VERTEX_INDEX', payload: e }) + mapProvider.activeMoveTarget = e.index >= 0 ? buildVertexMoveTarget(draw) : null + eventBus.emit('draw:vertexselection', e) + }, + onVertexChange: (e) => { + pluginState.dispatch({ type: 'SET_SELECTED_VERTEX_INDEX', payload: { index: -1, numVertices: e.numVertices } }) + mapProvider.activeMoveTarget = null + } +}) + // Re-open a feature in vertex-edit mode (used when a drawn shape finishes invalid). // Mirrors the options built by api/editFeature.js. Edit-mode Done is gated by the // same validity, so the shape can't be finished until it's fixed. @@ -82,8 +104,7 @@ function createHandlers ({ appState, appConfig, mapState, pluginState, mapProvid } }, onCancel: () => {}, - onVertexSelection: (e) => { pluginState.dispatch({ type: 'SET_SELECTED_VERTEX_INDEX', payload: e }); eventBus.emit('draw:vertexselection', e) }, - onVertexChange: (e) => { pluginState.dispatch({ type: 'SET_SELECTED_VERTEX_INDEX', payload: { index: -1, numVertices: e.numVertices } }) }, + ...createVertexSelectionHandlers({ draw, pluginState, mapProvider, eventBus }), onUndoChange: (l) => { pluginState.dispatch({ type: 'SET_UNDO_STACK_LENGTH', payload: l }) }, onUpdate: (f) => { eventBus.emit('draw:updated', f) }, onGeometryChange: (e) => { @@ -187,6 +208,9 @@ export function attachEvents ({ appState, appConfig, mapState, pluginState, mapP const resetState = () => { pluginState.dispatch({ type: 'SET_MODE', payload: null }) pluginState.dispatch({ type: 'SET_FEATURE', payload: { feature: null, tempFeature: null } }) + // Release MoveControl's D-pad back to panning the map whenever a draw/edit + // session ends — a stale claim here would silently hijack it for good. + mapProvider.activeMoveTarget = null } const handlers = createHandlers({ appState, appConfig, mapState, pluginState, mapProvider, eventBus, resetState }) attachButtonHandlers(buttonConfig, handlers) diff --git a/plugins/draw/src/events.test.js b/plugins/draw/src/events.test.js index 6066563b1..8145c9860 100644 --- a/plugins/draw/src/events.test.js +++ b/plugins/draw/src/events.test.js @@ -15,6 +15,7 @@ const setup = (overrides = {}) => { setGeometryValid: jest.fn(), setInvalid: jest.fn(), deleteVertex: jest.fn(), + nudgeSelectedVertex: jest.fn(), setSnapEnabled: jest.fn(), isSnapEnabled: jest.fn(() => false), changeMode: jest.fn(), @@ -71,9 +72,10 @@ describe('button handlers', () => { expect(draw.done).toHaveBeenCalled() }) - test('cancel re-adds the feature when cancelling a vertex edit', () => { - const { buttonConfig, draw, dispatch, eventBus } = setup() + test('cancel re-adds the feature when cancelling a vertex edit, and releases activeMoveTarget', () => { + const { buttonConfig, draw, dispatch, eventBus, mapProvider } = setup() draw.getMode.mockReturnValue('edit_vertex') + drawHandler(draw, 'vertexselection')({ index: 1 }) buttonConfig.drawCancel.onClick() @@ -82,6 +84,7 @@ describe('button handlers', () => { expect(dispatch).toHaveBeenCalledWith({ type: 'SET_MODE', payload: null }) expect(dispatch).toHaveBeenCalledWith({ type: 'SET_FEATURE', payload: { feature: null, tempFeature: null } }) expect(eventBus.emit).toHaveBeenCalledWith('draw:cancelled', { id: 'F' }) + expect(mapProvider.activeMoveTarget).toBeNull() }) test('cancel does not re-add outside a vertex edit', () => { @@ -166,17 +169,30 @@ describe('draw event handlers', () => { expect(() => drawHandler(draw, 'cancel')()).not.toThrow() }) - test('vertexselection dispatches and emits', () => { - const { draw, dispatch, eventBus } = setup() + test('vertexselection dispatches, emits, and claims mapProvider.activeMoveTarget for MoveControl', () => { + const { draw, dispatch, eventBus, mapProvider } = setup() drawHandler(draw, 'vertexselection')({ index: 2 }) expect(dispatch).toHaveBeenCalledWith({ type: 'SET_SELECTED_VERTEX_INDEX', payload: { index: 2 } }) expect(eventBus.emit).toHaveBeenCalledWith('draw:vertexselection', { index: 2 }) + expect(mapProvider.activeMoveTarget).toMatchObject({ label: 'vertex' }) + + mapProvider.activeMoveTarget.move(1, 0, true) + expect(draw.nudgeSelectedVertex).toHaveBeenCalledWith(1, 0, true) }) - test('vertexchange resets the selected index with the new count', () => { - const { draw, dispatch } = setup() + test('vertexselection releases activeMoveTarget when the index is deselected', () => { + const { draw, mapProvider } = setup() + drawHandler(draw, 'vertexselection')({ index: 2 }) + drawHandler(draw, 'vertexselection')({ index: -1 }) + expect(mapProvider.activeMoveTarget).toBeNull() + }) + + test('vertexchange resets the selected index with the new count and releases activeMoveTarget', () => { + const { draw, dispatch, mapProvider } = setup() + drawHandler(draw, 'vertexselection')({ index: 2 }) drawHandler(draw, 'vertexchange')({ numVertices: 5 }) expect(dispatch).toHaveBeenCalledWith({ type: 'SET_SELECTED_VERTEX_INDEX', payload: { index: -1, numVertices: 5 } }) + expect(mapProvider.activeMoveTarget).toBeNull() }) test('undochange dispatches the stack length', () => { diff --git a/plugins/draw/src/manifest.js b/plugins/draw/src/manifest.js index efa46f614..25c140a45 100644 --- a/plugins/draw/src/manifest.js +++ b/plugins/draw/src/manifest.js @@ -63,7 +63,7 @@ export const manifest = { }, { id: 'drawMenu', - label: 'Menu', + label: ({ pluginState }) => pluginState.mode === 'edit_vertex' ? 'Edit actions' : 'Draw actions', iconId: 'menu', exclusiveSlot: true, hiddenWhen: ({ pluginState }) => !['draw_polygon', 'draw_line', 'edit_vertex'].includes(pluginState.mode), @@ -99,7 +99,7 @@ export const manifest = { hiddenWhen: ({ pluginState }) => pluginState.mode !== 'edit_vertex' } ], - mobile: { slot: 'bottom-right' }, + mobile: { slot: 'top-middle' }, tablet: { slot: 'top-middle' }, desktop: { slot: 'top-middle' } } diff --git a/plugins/draw/src/manifest.test.js b/plugins/draw/src/manifest.test.js index 6be9e1012..5a13cddd5 100644 --- a/plugins/draw/src/manifest.test.js +++ b/plugins/draw/src/manifest.test.js @@ -66,6 +66,12 @@ describe('drawMenu', () => { expect(findButton('drawMenu').hiddenWhen({ pluginState: { mode: 'edit_vertex' } })).toBe(false) }) + test('labels "Edit actions" in edit mode, "Draw actions" otherwise', () => { + expect(findButton('drawMenu').label({ pluginState: { mode: 'edit_vertex' } })).toBe('Edit actions') + expect(findButton('drawMenu').label({ pluginState: { mode: 'draw_polygon' } })).toBe('Draw actions') + expect(findButton('drawMenu').label({ pluginState: { mode: 'draw_line' } })).toBe('Draw actions') + }) + describe('drawUndo', () => { const item = () => findMenuItem('drawMenu', 'drawUndo') diff --git a/src/App/components/MoveControl/MoveControl.jsx b/src/App/components/MoveControl/MoveControl.jsx index d27904c8d..69b017db7 100644 --- a/src/App/components/MoveControl/MoveControl.jsx +++ b/src/App/components/MoveControl/MoveControl.jsx @@ -39,6 +39,20 @@ export const MoveControl = () => { const actionWord = isLargeStep ? 'Move' : 'Nudge' const handlePan = (dx, dy, verb) => { + // Any plugin can claim the D-pad for something other than panning the map (e.g. + // moving a selected draw vertex) by setting mapProvider.activeMoveTarget — a + // generic { move(dx, dy, isLargeStep), label? } contract. MoveControl doesn't + // know or care what's claiming it; it just checks for a claimant before + // defaulting to its own panBy behaviour. Read fresh on every click rather than + // subscribed to reactively, since it's a plain mapProvider property, not state. + const activeMoveTarget = mapProvider.activeMoveTarget + if (activeMoveTarget) { + activeMoveTarget.move(dx, dy, isLargeStep) + const target = activeMoveTarget.label ? `${activeMoveTarget.label} ` : '' + announce(`${actionWord}d ${target}${verb}`) + return + } + const amount = resolveStepAmount(isLargeStep, nudgePanDelta, panDelta) mapProvider.panBy([dx * amount, dy * amount]) announce(`${actionWord}d ${verb}`) diff --git a/src/App/components/MoveControl/MoveControl.test.jsx b/src/App/components/MoveControl/MoveControl.test.jsx index 54035d485..80980d3f6 100644 --- a/src/App/components/MoveControl/MoveControl.test.jsx +++ b/src/App/components/MoveControl/MoveControl.test.jsx @@ -100,6 +100,31 @@ describe('MoveControl', () => { expect(announce).toHaveBeenCalledWith('Nudged up') }) + it('routes direction clicks to mapProvider.activeMoveTarget instead of panning, when a plugin has claimed it', () => { + mapProvider.activeMoveTarget = { move: jest.fn(), label: 'vertex' } + render() + fireEvent.click(screen.getByRole('button', { name: 'Move right' })) + expect(mapProvider.activeMoveTarget.move).toHaveBeenCalledWith(1, 0, true) + expect(mapProvider.panBy).not.toHaveBeenCalled() + expect(announce).toHaveBeenCalledWith('Moved vertex right') + }) + + it('falls back to panning once activeMoveTarget is released', () => { + mapProvider.activeMoveTarget = { move: jest.fn(), label: 'vertex' } + const { rerender } = render() + mapProvider.activeMoveTarget = null + rerender() + fireEvent.click(screen.getByRole('button', { name: 'Move right' })) + expect(mapProvider.panBy).toHaveBeenCalledWith([100, 0]) + }) + + it('omits the target label from the announcement when activeMoveTarget has none', () => { + mapProvider.activeMoveTarget = { move: jest.fn() } + render() + fireEvent.click(screen.getByRole('button', { name: 'Move up' })) + expect(announce).toHaveBeenCalledWith('Moved up') + }) + it('zooms in and out by the large delta by default and announces the action', () => { render() fireEvent.click(screen.getByRole('button', { name: 'Zoom in' })) From f516f8f077a8b848560fca1077ad7a73a93d7909 Mon Sep 17 00:00:00 2001 From: Dan Leech Date: Mon, 3 Aug 2026 18:22:50 +0100 Subject: [PATCH 76/89] Keyboard interacytion focus fix on move control --- .../components/MoveControl/MoveControl.jsx | 23 ++++++++- .../MoveControl/MoveControl.test.jsx | 47 +++++++++++++++++++ 2 files changed, 69 insertions(+), 1 deletion(-) diff --git a/src/App/components/MoveControl/MoveControl.jsx b/src/App/components/MoveControl/MoveControl.jsx index 69b017db7..93b9e32cf 100644 --- a/src/App/components/MoveControl/MoveControl.jsx +++ b/src/App/components/MoveControl/MoveControl.jsx @@ -20,7 +20,7 @@ const ZOOM_ACTIONS = [ export const MoveControl = () => { const { id: appId, mapProvider, panDelta, nudgePanDelta, zoomDelta, nudgeZoomDelta } = useConfig() - const { dispatch, expandedButtons, nudgeStepSize } = useApp() + const { dispatch, expandedButtons, nudgeStepSize, interfaceType, layoutRefs } = useApp() const { isAtMaxZoom, isAtMinZoom } = useMap() const { announce } = useService() @@ -38,6 +38,24 @@ export const MoveControl = () => { // keyboard-shortcut vocabulary, so the label always describes the step size in effect. const actionWord = isLargeStep ? 'Move' : 'Nudge' + // Registry buttons (declared via a plugin's manifest) auto-return focus to the map + // viewport after a click (see createButtonClickHandler in mapButtons.js) unless + // marked keepFocus — which is how e.g. draw's own toolbar buttons never leave + // keyboard shortcuts (like arrow-key vertex nudging) stranded afterward. MoveControl + // renders its own buttons directly, bypassing that mechanism entirely, so it has to + // replicate the same return-to-viewport behaviour itself. But unlike a one-off + // action button, the D-pad is meant to be pressed repeatedly — a keyboard/switch + // user tabs to a direction button once and presses Enter/Space many times in a row, + // which requires focus to stay put. So this only returns focus when the interface + // type isn't 'keyboard': a mouse/touch user doesn't need retained focus to click the + // same visible button again, and returning it immediately means they can switch to + // arrow keys right after without an extra click into the map first. + const returnFocusToViewport = () => { + if (interfaceType !== 'keyboard') { + requestAnimationFrame(() => layoutRefs.viewportRef.current?.focus()) + } + } + const handlePan = (dx, dy, verb) => { // Any plugin can claim the D-pad for something other than panning the map (e.g. // moving a selected draw vertex) by setting mapProvider.activeMoveTarget — a @@ -50,18 +68,21 @@ export const MoveControl = () => { activeMoveTarget.move(dx, dy, isLargeStep) const target = activeMoveTarget.label ? `${activeMoveTarget.label} ` : '' announce(`${actionWord}d ${target}${verb}`) + returnFocusToViewport() return } const amount = resolveStepAmount(isLargeStep, nudgePanDelta, panDelta) mapProvider.panBy([dx * amount, dy * amount]) announce(`${actionWord}d ${verb}`) + returnFocusToViewport() } const handleZoom = (method, label) => { const amount = resolveStepAmount(isLargeStep, nudgeZoomDelta, zoomDelta) mapProvider[method](amount) announce(label) + returnFocusToViewport() } const handleToggleStep = () => { diff --git a/src/App/components/MoveControl/MoveControl.test.jsx b/src/App/components/MoveControl/MoveControl.test.jsx index 80980d3f6..a561507d6 100644 --- a/src/App/components/MoveControl/MoveControl.test.jsx +++ b/src/App/components/MoveControl/MoveControl.test.jsx @@ -25,6 +25,7 @@ describe('MoveControl', () => { dispatch, expandedButtons: new Set(['moveControl']), nudgeStepSize: 'large', + layoutRefs: { viewportRef: { current: { focus: jest.fn() } } }, ...overrides }) @@ -164,6 +165,52 @@ describe('MoveControl', () => { expect(mapProvider.zoomOut).not.toHaveBeenCalled() }) + describe('returning focus to the viewport after a click', () => { + let rafSpy + + beforeEach(() => { + rafSpy = jest.spyOn(global, 'requestAnimationFrame').mockImplementation(cb => { cb(); return 1 }) + }) + + afterEach(() => rafSpy.mockRestore()) + + it('returns focus to the viewport after panning on mouse/touch, so arrow-key shortcuts elsewhere are not left stranded on the D-pad button', () => { + const appState = buildAppState({ interfaceType: 'mouse' }) + useApp.mockReturnValue(appState) + render() + + fireEvent.click(screen.getByRole('button', { name: 'Move right' })) + expect(appState.layoutRefs.viewportRef.current.focus).toHaveBeenCalled() + }) + + it('keeps focus on the button when driven by keyboard, so repeated Enter/Space presses do not require re-tabbing', () => { + useApp.mockReturnValue(buildAppState({ interfaceType: 'keyboard' })) + render() + fireEvent.click(screen.getByRole('button', { name: 'Move right' })) + expect(rafSpy).not.toHaveBeenCalled() + }) + + it('also returns focus after zooming on mouse/touch, but not on keyboard', () => { + useApp.mockReturnValue(buildAppState({ interfaceType: 'mouse' })) + const { rerender } = render() + fireEvent.click(screen.getByRole('button', { name: 'Zoom in' })) + expect(rafSpy).toHaveBeenCalledTimes(1) + + useApp.mockReturnValue(buildAppState({ interfaceType: 'keyboard' })) + rerender() + fireEvent.click(screen.getByRole('button', { name: 'Zoom in' })) + expect(rafSpy).toHaveBeenCalledTimes(1) + }) + + it('also returns focus after a vertex nudge via activeMoveTarget on mouse/touch', () => { + mapProvider.activeMoveTarget = { move: jest.fn(), label: 'vertex' } + useApp.mockReturnValue(buildAppState({ interfaceType: 'touch' })) + render() + fireEvent.click(screen.getByRole('button', { name: 'Move right' })) + expect(rafSpy).toHaveBeenCalledTimes(1) + }) + }) + it('has a stable "Precision" label regardless of state', () => { const { rerender } = render() expect(screen.getByRole('button', { name: 'Precision' })).toBeInTheDocument() From af6b1b57cb1bd81aaeac5f403e5972728501fd3a Mon Sep 17 00:00:00 2001 From: Dan Leech Date: Tue, 4 Aug 2026 09:37:51 +0100 Subject: [PATCH 77/89] Move control keyboard events --- .../components/MoveControl/MoveControl.jsx | 49 +++++++++++--- .../MoveControl/MoveControl.test.jsx | 64 +++++++++++++++++++ 2 files changed, 103 insertions(+), 10 deletions(-) diff --git a/src/App/components/MoveControl/MoveControl.jsx b/src/App/components/MoveControl/MoveControl.jsx index 93b9e32cf..e10308794 100644 --- a/src/App/components/MoveControl/MoveControl.jsx +++ b/src/App/components/MoveControl/MoveControl.jsx @@ -7,10 +7,10 @@ import { useService } from '../../store/serviceContext.js' import { resolveStepAmount } from '../../../utils/resolveNudgeStep.js' const DIRECTIONS = [ - { id: 'panUp', verb: 'up', dx: 0, dy: -1 }, - { id: 'panDown', verb: 'down', dx: 0, dy: 1 }, - { id: 'panLeft', verb: 'left', dx: -1, dy: 0 }, - { id: 'panRight', verb: 'right', dx: 1, dy: 0 } + { id: 'panUp', verb: 'up', dx: 0, dy: -1, key: 'ArrowUp' }, + { id: 'panDown', verb: 'down', dx: 0, dy: 1, key: 'ArrowDown' }, + { id: 'panLeft', verb: 'left', dx: -1, dy: 0, key: 'ArrowLeft' }, + { id: 'panRight', verb: 'right', dx: 1, dy: 0, key: 'ArrowRight' } ] const ZOOM_ACTIONS = [ @@ -56,7 +56,18 @@ export const MoveControl = () => { } } - const handlePan = (dx, dy, verb) => { + const handlePan = (dx, dy, verb, shiftKey = false) => { + // Shift is a momentary "use the small step" override — matching the map's own + // native keyboard shortcuts (keyboardActions.js's getPan/getZoom: "Shift held + // selects the fine nudge amount... not a large step") — regardless of what the + // Precision toggle currently says. Only the arrow-key path below passes a + // shiftKey; button clicks don't, so they're unaffected and still follow the + // toggle exactly as before. Button labels also stay toggle-only (WAI-ARIA + // stable-name pattern) — only the actual step taken and its announcement + // reflect a momentary Shift override. + const effectiveIsLargeStep = shiftKey ? false : isLargeStep + const effectiveActionWord = effectiveIsLargeStep ? 'Move' : 'Nudge' + // Any plugin can claim the D-pad for something other than panning the map (e.g. // moving a selected draw vertex) by setting mapProvider.activeMoveTarget — a // generic { move(dx, dy, isLargeStep), label? } contract. MoveControl doesn't @@ -65,16 +76,16 @@ export const MoveControl = () => { // subscribed to reactively, since it's a plain mapProvider property, not state. const activeMoveTarget = mapProvider.activeMoveTarget if (activeMoveTarget) { - activeMoveTarget.move(dx, dy, isLargeStep) + activeMoveTarget.move(dx, dy, effectiveIsLargeStep) const target = activeMoveTarget.label ? `${activeMoveTarget.label} ` : '' - announce(`${actionWord}d ${target}${verb}`) + announce(`${effectiveActionWord}d ${target}${verb}`) returnFocusToViewport() return } - const amount = resolveStepAmount(isLargeStep, nudgePanDelta, panDelta) + const amount = resolveStepAmount(effectiveIsLargeStep, nudgePanDelta, panDelta) mapProvider.panBy([dx * amount, dy * amount]) - announce(`${actionWord}d ${verb}`) + announce(`${effectiveActionWord}d ${verb}`) returnFocusToViewport() } @@ -90,6 +101,24 @@ export const MoveControl = () => { announce(isLargeStep ? 'Precision on' : 'Precision off') } + // A keyboard user tabs to a direction button and can repeat-press Enter/Space on + // it (see returnFocusToViewport above — focus deliberately stays put for + // 'keyboard'), but that leaves them unable to fall back to raw arrow keys without + // first tabbing all the way back to the map. Handling arrow keys here — while + // focus is anywhere within this control, not just on a direction button — covers + // that directly, without needing draw's (or the app's own) keyboard-shortcut + // guards to special-case MoveControl's buttons. Only arrow keys are handled; + // other shortcuts (delete/escape/undo) still require focus on the viewport, + // which mouse/touch users already get back automatically after any click here. + const handleContainerKeyDown = (e) => { + const direction = DIRECTIONS.find(({ key }) => key === e.key) + if (!direction) { + return + } + e.preventDefault() + handlePan(direction.dx, direction.dy, direction.verb, e.shiftKey) + } + const containerClassName = [ 'im-c-move-control', !isOpen && 'im-c-move-control--collapsed' @@ -140,7 +169,7 @@ export const MoveControl = () => { ) return ( -
+
{/* NOSONAR - only catches arrow-key bubbling from the real, already-focusable button descendants below; the div itself needs no role/tabIndex */} {directionsGroup} {zoomGroup}
diff --git a/src/App/components/MoveControl/MoveControl.test.jsx b/src/App/components/MoveControl/MoveControl.test.jsx index a561507d6..933bbc124 100644 --- a/src/App/components/MoveControl/MoveControl.test.jsx +++ b/src/App/components/MoveControl/MoveControl.test.jsx @@ -211,6 +211,70 @@ describe('MoveControl', () => { }) }) + describe('arrow keys while focus is anywhere within the control', () => { + // A keyboard user who tabs to a direction button and repeat-presses Enter keeps + // focus there (see the describe block above) — this lets them fall back to raw + // arrow keys without first tabbing all the way back out to the map. + it('pans the map on an arrow key, regardless of which button currently has focus', () => { + useApp.mockReturnValue(buildAppState({ interfaceType: 'keyboard' })) + render() + // Focus a button unrelated to the direction being pressed, to prove this + // isn't just reading the focused button's own handler. + fireEvent.focus(screen.getByRole('button', { name: 'Zoom in' })) + fireEvent.keyDown(screen.getByRole('button', { name: 'Zoom in' }), { key: 'ArrowRight' }) + expect(mapProvider.panBy).toHaveBeenCalledWith([100, 0]) + expect(announce).toHaveBeenCalledWith('Moved right') + }) + + it('routes the arrow key through activeMoveTarget when a plugin has claimed the control', () => { + mapProvider.activeMoveTarget = { move: jest.fn(), label: 'vertex' } + useApp.mockReturnValue(buildAppState({ interfaceType: 'keyboard' })) + render() + fireEvent.keyDown(screen.getByRole('button', { name: 'Move up' }), { key: 'ArrowUp' }) + expect(mapProvider.activeMoveTarget.move).toHaveBeenCalledWith(0, -1, true) + expect(mapProvider.panBy).not.toHaveBeenCalled() + }) + + it('ignores non-arrow keys, leaving default behaviour (e.g. Enter/Space activating the focused button) untouched', () => { + useApp.mockReturnValue(buildAppState({ interfaceType: 'keyboard' })) + render() + fireEvent.keyDown(screen.getByRole('button', { name: 'Move up' }), { key: 'Enter' }) + expect(mapProvider.panBy).not.toHaveBeenCalled() + }) + + it('shift+arrow overrides the Precision toggle to the small step, matching the map\'s own native keyboard shortcuts', () => { + useApp.mockReturnValue(buildAppState({ interfaceType: 'keyboard', nudgeStepSize: 'large' })) + render() + fireEvent.keyDown(screen.getByRole('button', { name: 'Move right' }), { key: 'ArrowRight', shiftKey: true }) + expect(mapProvider.panBy).toHaveBeenCalledWith([5, 0]) + expect(announce).toHaveBeenCalledWith('Nudged right') + }) + + it('shift+arrow still resolves to the small step when Precision is already on (idempotent, not a toggle-relative flip)', () => { + useApp.mockReturnValue(buildAppState({ interfaceType: 'keyboard', nudgeStepSize: 'small' })) + render() + fireEvent.keyDown(screen.getByRole('button', { name: 'Nudge right' }), { key: 'ArrowRight', shiftKey: true }) + expect(mapProvider.panBy).toHaveBeenCalledWith([5, 0]) + expect(announce).toHaveBeenCalledWith('Nudged right') + }) + + it('shift+arrow overrides activeMoveTarget.move to the small step too', () => { + mapProvider.activeMoveTarget = { move: jest.fn(), label: 'vertex' } + useApp.mockReturnValue(buildAppState({ interfaceType: 'keyboard', nudgeStepSize: 'large' })) + render() + fireEvent.keyDown(screen.getByRole('button', { name: 'Move up' }), { key: 'ArrowUp', shiftKey: true }) + expect(mapProvider.activeMoveTarget.move).toHaveBeenCalledWith(0, -1, false) + }) + + it('arrow key without shift still follows the Precision toggle as before', () => { + useApp.mockReturnValue(buildAppState({ interfaceType: 'keyboard', nudgeStepSize: 'large' })) + render() + fireEvent.keyDown(screen.getByRole('button', { name: 'Move right' }), { key: 'ArrowRight' }) + expect(mapProvider.panBy).toHaveBeenCalledWith([100, 0]) + expect(announce).toHaveBeenCalledWith('Moved right') + }) + }) + it('has a stable "Precision" label regardless of state', () => { const { rerender } = render() expect(screen.getByRole('button', { name: 'Precision' })).toBeInTheDocument() From 1b047a28a68e2ce34808c5b6456c6b7a1420f9bb Mon Sep 17 00:00:00 2001 From: Dan Leech Date: Tue, 4 Aug 2026 09:53:19 +0100 Subject: [PATCH 78/89] MapLibre move control snapping fix --- .../modes/editVertexMode/keyboardHandlers.js | 33 +++------------ .../modes/editVertexMode/vertexOperations.js | 42 +++++++++++++++++-- .../editVertexMode/vertexOperations.test.js | 25 +++++++++++ 3 files changed, 70 insertions(+), 30 deletions(-) diff --git a/plugins/draw/src/adapters/maplibre/modes/editVertexMode/keyboardHandlers.js b/plugins/draw/src/adapters/maplibre/modes/editVertexMode/keyboardHandlers.js index 2ace22d23..bd4e5ed5d 100644 --- a/plugins/draw/src/adapters/maplibre/modes/editVertexMode/keyboardHandlers.js +++ b/plugins/draw/src/adapters/maplibre/modes/editVertexMode/keyboardHandlers.js @@ -1,7 +1,4 @@ -import { - getSnapInstance, isSnapActive, isSnapEnabled, getSnapLngLat, - getSnapRadius, triggerSnapAtPoint, clearSnapIndicator -} from '../../utils/snapHelpers.js' +import { getSnapInstance, clearSnapIndicator } from '../../utils/snapHelpers.js' import { getCoords } from './geometryHelpers.js' const ARROW_KEYS = new Set(['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown']) @@ -107,30 +104,12 @@ export const keyboardHandlers = { this.moveVertex(state, this._keyboardMoveTarget(state, e, currentCoord)) }, - // Resolve the destination coordinate for a keyboard nudge, applying or breaking snap. + // Resolve the destination coordinate for a keyboard nudge, applying or breaking + // snap — delegates to the shared resolver (vertexOperations.js) also used by + // MoveControl's nudgeVertexByDelta, so both snap identically. _keyboardMoveTarget (state, e, currentCoord) { - const snap = getSnapInstance(this.map) - - // Break out of an active snap by moving beyond the snap radius - if (isSnapEnabled(state) && state._isSnapped && snap) { - const offset = getSnapRadius(snap) + 1 - const pt = this.map.project(currentCoord) - const [dx, dy] = ARROW_OFFSETS[e.key].map(v => v * offset) - state._isSnapped = false - clearSnapIndicator(snap, this.map) - return this.map.unproject({ x: pt.x + dx, y: pt.y + dy }) - } - - const newCoord = this.getNewCoord(state, e) - if (isSnapEnabled(state) && snap) { - triggerSnapAtPoint(snap, this.map, this.map.project(newCoord)) - if (isSnapActive(snap)) { - state._isSnapped = true - return getSnapLngLat(snap) - } - } - state._isSnapped = false - return newCoord + const [dx, dy] = ARROW_OFFSETS[e.key] + return this.resolveSnapTarget(state, dx, dy, currentCoord, () => this.getNewCoord(state, e)) }, // Cmd/Ctrl+Z: undo the last edit, unless the user is typing in a text field. diff --git a/plugins/draw/src/adapters/maplibre/modes/editVertexMode/vertexOperations.js b/plugins/draw/src/adapters/maplibre/modes/editVertexMode/vertexOperations.js index 87c148e2e..d573997ac 100644 --- a/plugins/draw/src/adapters/maplibre/modes/editVertexMode/vertexOperations.js +++ b/plugins/draw/src/adapters/maplibre/modes/editVertexMode/vertexOperations.js @@ -5,6 +5,10 @@ import { getModifiableCoords } from './geometryHelpers.js' import { KEYBOARD } from '../../defaults.js' +import { + getSnapInstance, isSnapActive, isSnapEnabled, getSnapLngLat, + getSnapRadius, triggerSnapAtPoint, clearSnapIndicator +} from '../../utils/snapHelpers.js' const ARROW_OFFSETS = { ArrowUp: [0, -1], ArrowDown: [0, 1], ArrowLeft: [-1, 0], ArrowRight: [1, 0] } @@ -47,11 +51,42 @@ export const vertexOperations = { return this.map.unproject({ x: pt.x + dx * amount, y: pt.y + dy * amount }) }, + // Resolves the destination coordinate for a vertex nudge by (dx, dy) unit + // direction, applying snap or breaking out of an already-active one — shared by + // the keyboard arrow-key path (_keyboardMoveTarget) and MoveControl's + // explicit-delta path (nudgeVertexByDelta) so both snap identically. dx/dy are + // only needed for the snap-escape offset; getCandidate computes the raw, + // un-snapped destination the caller would otherwise have used. + resolveSnapTarget (state, dx, dy, currentCoord, getCandidate) { + const snap = getSnapInstance(this.map) + + // Break out of an active snap by moving beyond the snap radius + if (isSnapEnabled(state) && state._isSnapped && snap) { + const offset = getSnapRadius(snap) + 1 + const pt = this.map.project(currentCoord) + state._isSnapped = false + clearSnapIndicator(snap, this.map) + return this.map.unproject({ x: pt.x + dx * offset, y: pt.y + dy * offset }) + } + + const newCoord = getCandidate() + if (isSnapEnabled(state) && snap) { + triggerSnapAtPoint(snap, this.map, this.map.project(newCoord)) + if (isSnapActive(snap)) { + state._isSnapped = true + return getSnapLngLat(snap) + } + } + state._isSnapped = false + return newCoord + }, + // Moves the selected vertex by an explicit (dx, dy) unit direction — the entry // point for MoveControl's D-pad (see mapProvider.activeMoveTarget in events.js), // as opposed to moveVertexByKey's KeyboardEvent-driven path. Each call is treated - // as one complete, undoable action (no held-key sequencing/snap-breaking, since a - // button click has no "held" state to batch the way arrow keys do). + // as one complete, undoable action (no held-key sequencing, since a button click + // has no "held" state to batch the way arrow keys do) — but still honours snap + // via the shared resolveSnapTarget, same as keyboard nudging. nudgeVertexByDelta (state, dx, dy, isLargeStep) { if (state.selectedVertexType !== 'vertex' || state.selectedVertexIndex < 0) { return @@ -63,7 +98,8 @@ export const vertexOperations = { } const previousPosition = [...currentCoord] const vertexIndex = state.selectedVertexIndex - this.moveVertex(state, this.getOffsetByDelta(currentCoord, dx, dy, isLargeStep)) + const target = this.resolveSnapTarget(state, dx, dy, currentCoord, () => this.getOffsetByDelta(currentCoord, dx, dy, isLargeStep)) + this.moveVertex(state, target) this.pushUndo({ type: 'move_vertex', featureId: state.featureId, vertexIndex, previousPosition }) }, diff --git a/plugins/draw/src/adapters/maplibre/modes/editVertexMode/vertexOperations.test.js b/plugins/draw/src/adapters/maplibre/modes/editVertexMode/vertexOperations.test.js index 92fae3c77..5792d1c7e 100644 --- a/plugins/draw/src/adapters/maplibre/modes/editVertexMode/vertexOperations.test.js +++ b/plugins/draw/src/adapters/maplibre/modes/editVertexMode/vertexOperations.test.js @@ -73,6 +73,31 @@ describe('vertexOperations', () => { expect(map._undoStack).toHaveLength(0) }) + test('nudgeVertexByDelta snaps to a nearby target, breaks out of an active snap, and falls back to the raw coord when snap is inactive — same as keyboard nudging (regression: MoveControl bypassed snap entirely before resolveSnapTarget was shared)', () => { + const { ctx, state, map } = createHarness() + state.selectedVertexIndex = 1 + state.selectedVertexType = 'vertex' + state.getSnapEnabled = () => true + + map._snapInstance = { status: true, snapStatus: true, snapCoords: [7, 8], snapToClosestPoint: jest.fn() } + ctx.nudgeVertexByDelta(state, 0, -1, true) + expect(state._isSnapped).toBe(true) + expect(state.vertecies[1]).toEqual([7, 8]) + + ctx.nudgeVertexByDelta(state, -1, 0, true) // already snapped → breaks out of the snap radius + expect(state._isSnapped).toBe(false) + + map._snapInstance = { status: true, snapStatus: false, snapCoords: null, snapToClosestPoint: jest.fn() } + ctx.nudgeVertexByDelta(state, 1, 0, true) // snap enabled but inactive → raw offset coord + expect(state._isSnapped).toBe(false) + }) + + test('resolveSnapTarget is a no-op passthrough to the candidate coordinate when snap is disabled', () => { + const { ctx, state } = createHarness() + const candidate = { lng: 3, lat: 4 } + expect(ctx.resolveSnapTarget(state, 1, 0, [0, 0], () => candidate)).toEqual(candidate) + }) + test('insertVertex splits a midpoint into a new vertex, records undo and selects it', () => { const { ctx, state, api } = createHarness() ctx.insertVertex({ ...state, selectedVertexIndex: state.vertecies.length, selectedVertexType: 'midpoint' }, { key: 'ArrowRight', shiftKey: false }) From 88634620a16a2b888e16ac4914420159cf26634c Mon Sep 17 00:00:00 2001 From: Dan Leech Date: Tue, 4 Aug 2026 14:17:27 +0100 Subject: [PATCH 79/89] placeCount renamed numVertices plus onGeometryChnage amends --- demo/js/draw-ol.js | 17 +++++- demo/js/draw.js | 16 +++++- demo/js/planning.js | 6 +- plugins/draw/src/adapterEvents.js | 27 +++++++++ .../adapters/maplibre/MaplibreDrawAdapter.js | 10 ++-- .../maplibre/MaplibreDrawAdapter.test.js | 4 +- .../modes/drawMode/clickHandlers.test.js | 4 +- .../src/adapters/openlayers/draw/DrawMode.js | 8 +-- .../adapters/openlayers/draw/DrawMode.test.js | 4 +- .../src/adapters/openlayers/edit/EditMode.js | 30 +++++++--- .../adapters/openlayers/edit/EditMode.test.js | 28 +++++++--- plugins/draw/src/api/split.js | 6 +- plugins/draw/src/api/split.test.js | 14 ++--- plugins/draw/src/events.test.js | 2 +- plugins/draw/src/validation/liveStroke.js | 4 +- .../draw/src/validation/liveStroke.test.js | 50 ++++++++--------- .../draw/src/validation/validateGeometry.js | 41 +++++++++----- .../src/validation/validateGeometry.test.js | 56 ++++++++++++++----- 18 files changed, 223 insertions(+), 104 deletions(-) diff --git a/demo/js/draw-ol.js b/demo/js/draw-ol.js index 4010a4e7a..135eb458d 100644 --- a/demo/js/draw-ol.js +++ b/demo/js/draw-ol.js @@ -28,9 +28,22 @@ const interactPlugin = createInteractPlugin({ // debug: true }) +// Rough approximation of the England/Wales border as a line of easting — +// good enough for a demo, not for anything that actually needs to be accurate. +const WALES_BORDER_EASTING = 337300 + +const getAllCoordinates = (coordinates) => + Array.isArray(coordinates[0][0]) ? coordinates.flatMap(getAllCoordinates) : coordinates + +const isEastOfWalesBorder = (geometry) => + getAllCoordinates(geometry.coordinates).every(([lng]) => lng > WALES_BORDER_EASTING) + const drawPlugin = createDrawPlugin({ - // snapLayers: ['OS/NGD/lnd_fts_land/Arable Or Grazing Land'] - snapLayers: ['OS/TopographicArea_1/Agricultural Land', 'OS/TopographicLine/Building Outline'] + snapLayers: ['OS/TopographicArea_1/Agricultural Land', 'OS/TopographicLine/Building Outline'], + onGeometryChange: (event) => ({ + valid: isEastOfWalesBorder(event.feature.geometry), + reason: 'Points must be placed east of the England/Wales border' + }) }) const interactiveMap = new InteractiveMap('map', { diff --git a/demo/js/draw.js b/demo/js/draw.js index 5574f59d9..ab189cf48 100755 --- a/demo/js/draw.js +++ b/demo/js/draw.js @@ -40,8 +40,22 @@ const interactPlugin = createInteractPlugin({ deselectOnClickOutside: true }) +// Rough approximation of the England/Wales border as a line of longitude — +// good enough for a demo, not for anything that actually needs to be accurate. +const WALES_BORDER_LONGITUDE = -3.0 + +const getAllCoordinates = (coordinates) => + Array.isArray(coordinates[0][0]) ? coordinates.flatMap(getAllCoordinates) : coordinates + +const isEastOfWalesBorder = (geometry) => + getAllCoordinates(geometry.coordinates).every(([lng]) => lng > WALES_BORDER_LONGITUDE) + const drawPlugin = createDrawPlugin({ - snapLayers: ['OS/TopographicArea_1/Agricultural Land', 'OS/TopographicLine/Building Outline'] + snapLayers: ['OS/TopographicArea_1/Agricultural Land', 'OS/TopographicLine/Building Outline'], + onGeometryChange: (event) => ({ + valid: isEastOfWalesBorder(event.feature.geometry), + reason: 'Points must be placed east of the England/Wales border' + }) }) const datasetsPlugin = createDatasetsPlugin({ diff --git a/demo/js/planning.js b/demo/js/planning.js index 1a94d64c9..91a3c39b5 100755 --- a/demo/js/planning.js +++ b/demo/js/planning.js @@ -64,7 +64,7 @@ const interactPlugin = createInteractPlugin({ }) const drawPlugin = createDrawPlugin({ - onGeometryChange: (geometry) => true + onGeometryChange: (event) => true }) const framePlugin = createFramePlugin({ @@ -231,7 +231,7 @@ interactiveMap.on('draw:ready', function () { addMenuClickHandlers({ onDrawShape: function() { drawPlugin.newPolygon('boundary', { - onGeometryChange: (geometry) => true + onGeometryChange: (event) => true }) hideMenu(interactiveMap) }, @@ -247,7 +247,7 @@ interactiveMap.on('draw:ready', function () { framePlugin.editFeature(feature) } else { drawPlugin.editFeature('boundary', { - onGeometryChange: (geometry) => true + onGeometryChange: (event) => true }) } hideMenu(interactiveMap) diff --git a/plugins/draw/src/adapterEvents.js b/plugins/draw/src/adapterEvents.js index 3e8712c4e..06291435f 100644 --- a/plugins/draw/src/adapterEvents.js +++ b/plugins/draw/src/adapterEvents.js @@ -19,6 +19,33 @@ * 'commit-delete' - a vertex was removed while editing. */ +/** + * The single event object passed to a user's `onGeometryChange` callback + * (validateGeometry.js) — `onGeometryChange({ feature, ...context })`. Not every + * field is present on every phase; see each field's own doc below. + * + * @typedef {object} GeometryChangeEvent + * @property {object} feature - GeoJSON feature representing the shape at this + * point. Always present. During 'preview' this is the in-progress feature + * (placed vertices + rubber-band cursor); at every other phase it's the + * committed/candidate feature for that specific change. + * @property {GeometryChangePhase} phase - what stage of the draw/edit lifecycle + * this is. Always present. + * @property {'draw_polygon' | 'draw_line' | 'edit_vertex'} mode - the active draw + * mode. Always present. + * @property {number} [vertexIndex] - index of the vertex being placed, added, + * moved, inserted, or deleted. Present on 'place' and every 'commit-*' phase; + * absent on 'preview', 'create', and 'edit-start' (those have no single vertex + * to point at). + * @property {number} [numVertices] - count of already-committed vertices, + * excluding any in-progress rubber-band cursor point. Present only on + * 'preview', and only once at least one vertex has been committed + * (validateDisplayedGeometry skips the callback entirely with zero + * committed vertices, since there's nothing meaningful to validate yet). + * Note this is a lower bar than the built-in live rules, which stay + * skipped until the geometry type's minimum vertex count is reached. + */ + /** * Shared adapter event contract. * diff --git a/plugins/draw/src/adapters/maplibre/MaplibreDrawAdapter.js b/plugins/draw/src/adapters/maplibre/MaplibreDrawAdapter.js index 6685159cf..f167ee023 100644 --- a/plugins/draw/src/adapters/maplibre/MaplibreDrawAdapter.js +++ b/plugins/draw/src/adapters/maplibre/MaplibreDrawAdapter.js @@ -17,15 +17,15 @@ const lineFeature = (coordinates) => ({ type: 'Feature', geometry: { type: 'Line // rubber-band point; edit-mode coordinates are all committed vertices. export const displayedShape = (mode, coordinates) => { if (mode === 'draw_polygon') { - return { feature: polygonFeature(coordinates), placedCount: (coordinates[0]?.length ?? 1) - 1 } + return { feature: polygonFeature(coordinates), numVertices: (coordinates[0]?.length ?? 1) - 1 } } if (mode === 'draw_line') { - return { feature: lineFeature(coordinates), placedCount: (coordinates?.length ?? 1) - 1 } + return { feature: lineFeature(coordinates), numVertices: (coordinates?.length ?? 1) - 1 } } if (mode === 'edit_vertex') { return Array.isArray(coordinates[0]?.[0]) - ? { feature: polygonFeature(coordinates), placedCount: coordinates[0]?.length ?? 0 } - : { feature: lineFeature(coordinates), placedCount: coordinates?.length ?? 0 } + ? { feature: polygonFeature(coordinates), numVertices: coordinates[0]?.length ?? 0 } + : { feature: lineFeature(coordinates), numVertices: coordinates?.length ?? 0 } } return null } @@ -164,7 +164,7 @@ export class MaplibreDrawAdapter { if (mode === 'draw_polygon' || mode === 'draw_line') { this._livePlacement.update({ feature: shape.feature, - context: { mode, vertexIndex: shape.placedCount }, + context: { mode, vertexIndex: shape.numVertices }, onGeometryChange: this._geometryValidator }) } diff --git a/plugins/draw/src/adapters/maplibre/MaplibreDrawAdapter.test.js b/plugins/draw/src/adapters/maplibre/MaplibreDrawAdapter.test.js index def3d03a0..b0544ded5 100644 --- a/plugins/draw/src/adapters/maplibre/MaplibreDrawAdapter.test.js +++ b/plugins/draw/src/adapters/maplibre/MaplibreDrawAdapter.test.js @@ -139,14 +139,14 @@ describe('displayedShape helper', () => { const result = displayedShape('draw_polygon', [[[0, 0], [10, 0], [10, 10], [0, 0]]]) expect(result?.feature?.type).toBe('Feature') expect(result?.feature?.geometry?.type).toBe('Polygon') - expect(result?.placedCount).toBe(3) + expect(result?.numVertices).toBe(3) }) test('builds a line feature from draw_line mode', () => { const result = displayedShape('draw_line', [[0, 0], [10, 0], [10, 10]]) expect(result?.feature?.type).toBe('Feature') expect(result?.feature?.geometry?.type).toBe('LineString') - expect(result?.placedCount).toBe(2) + expect(result?.numVertices).toBe(2) }) test('detects polygon vs line in edit_vertex mode from coordinate nesting', () => { diff --git a/plugins/draw/src/adapters/maplibre/modes/drawMode/clickHandlers.test.js b/plugins/draw/src/adapters/maplibre/modes/drawMode/clickHandlers.test.js index d75b59a51..986d0dbab 100644 --- a/plugins/draw/src/adapters/maplibre/modes/drawMode/clickHandlers.test.js +++ b/plugins/draw/src/adapters/maplibre/modes/drawMode/clickHandlers.test.js @@ -49,8 +49,8 @@ describe('mouse clicks (polygon)', () => { test('the user callback can veto a mouse placement (and receives phase "place")', () => { const { ctx, state } = setup(DrawPolygonMode) - ctx.map._drawGeometryValidator = jest.fn((feature, context) => - context.phase === 'place' ? { valid: false, reason: 'outside region' } : { valid: true }) + ctx.map._drawGeometryValidator = jest.fn((event) => + event.phase === 'place' ? { valid: false, reason: 'outside region' } : { valid: true }) clickAt(ctx, state, 0, 0) expect(state.polygon.coordinates[0]).toHaveLength(0) // first vertex vetoed expect(firedWith(ctx.map, 'draw.placementblocked').pop()).toEqual( diff --git a/plugins/draw/src/adapters/openlayers/draw/DrawMode.js b/plugins/draw/src/adapters/openlayers/draw/DrawMode.js index 85ebb3e0b..48bc3199d 100644 --- a/plugins/draw/src/adapters/openlayers/draw/DrawMode.js +++ b/plugins/draw/src/adapters/openlayers/draw/DrawMode.js @@ -77,7 +77,7 @@ const displayedSketch = (geometryType, sketch) => { const geometry = geometryType === 'Polygon' ? { type: 'Polygon', coordinates: [ring] } : { type: 'LineString', coordinates: ring } - return { feature: { type: 'Feature', geometry }, placedCount: getPlacedSketchCoords(geom).length } + return { feature: { type: 'Feature', geometry }, numVertices: getPlacedSketchCoords(geom).length } } // Commit a finished sketch to the store under the requested id and emit CREATE. @@ -216,9 +216,9 @@ export const createDrawMode = ({ map, manager, options }) => { const liveMode = MODE_BY_GEOMETRY[geometryType] const updateLiveValidity = () => { if (!sketchFeature) { return } - const { feature, placedCount } = displayedSketch(geometryType, sketchFeature) - liveStroke.update({ feature, context: { mode: liveMode }, placedCount, onGeometryChange: manager._geometryValidator }) - livePlacement.update({ feature, context: { mode: liveMode, vertexIndex: placedCount }, onGeometryChange: manager._geometryValidator }) + const { feature, numVertices } = displayedSketch(geometryType, sketchFeature) + liveStroke.update({ feature, context: { mode: liveMode }, numVertices, onGeometryChange: manager._geometryValidator }) + livePlacement.update({ feature, context: { mode: liveMode, vertexIndex: numVertices }, onGeometryChange: manager._geometryValidator }) } // Update sketch style when map style changes diff --git a/plugins/draw/src/adapters/openlayers/draw/DrawMode.test.js b/plugins/draw/src/adapters/openlayers/draw/DrawMode.test.js index 229f6b107..107fe7b1e 100644 --- a/plugins/draw/src/adapters/openlayers/draw/DrawMode.test.js +++ b/plugins/draw/src/adapters/openlayers/draw/DrawMode.test.js @@ -100,8 +100,8 @@ describe('buildCanPlaceVertex', () => { test('the user callback can veto a placement (and receives phase "place")', () => { const manager = createFakeManager() - manager._geometryValidator = jest.fn((feature, context) => - context.phase === 'place' ? { valid: false, reason: 'outside region' } : { valid: true }) + manager._geometryValidator = jest.fn((event) => + event.phase === 'place' ? { valid: false, reason: 'outside region' } : { valid: true }) const gate = gateFor([[0, 0], [2, 0], [9, 9], [0, 0]], manager) expect(gate([5, 5])).toBe(false) expect(manager.emit).toHaveBeenCalledWith(ADAPTER_EVENTS.PLACEMENT_BLOCKED, diff --git a/plugins/draw/src/adapters/openlayers/edit/EditMode.js b/plugins/draw/src/adapters/openlayers/edit/EditMode.js index 3b141b8ee..ddccc6b3a 100644 --- a/plugins/draw/src/adapters/openlayers/edit/EditMode.js +++ b/plugins/draw/src/adapters/openlayers/edit/EditMode.js @@ -34,26 +34,41 @@ const UNDO_INVERSE_PHASE = { // change (Modify drag, touch drag, keyboard nudge) via the shared controller — // default rules synchronously, user callback throttled. All committed vertices are // "placed", so the polygon count drops only OL's closing duplicate. -const wireLiveStroke = ({ manager, olFeature }) => { +// +// The style is set ONCE to a function backed by `invalid`, rather than calling +// Feature#setStyle() on every flip: setStyle() fires a feature-level 'change' +// event, which OL's Modify interaction also listens for (to detect out-of-band +// edits and rebuild its internal drag/segment index) — guarded only while Modify +// itself is synchronously mutating the geometry. The built-in rules' flip stays +// inside that guarded window, but the user-callback flip is deliberately +// throttled to the next animation frame (liveStroke.js), landing outside it — +// so calling setStyle() there makes Modify treat its own flip as an external +// edit and rebuild mid-drag, killing the in-progress drag. A style function + +// map.render() repaints without ever touching the feature, so Modify's listener +// never fires from our own bookkeeping. +const wireLiveStroke = ({ map, manager, olFeature }) => { + let invalid = false + olFeature.setStyle(() => (invalid ? manager.styles.editFeatureStyleInvalid : manager.styles.editFeatureStyle)) const liveStroke = createLiveStroke({ - onChange: (invalid, reason) => { - olFeature.setStyle(invalid ? manager.styles.editFeatureStyleInvalid : manager.styles.editFeatureStyle) + onChange: (next, reason) => { + invalid = next + map.render() // In edit mode the displayed shape is exactly what Done finishes, so live // validity flips also gate the Done button (events.js dispatches them). - manager.emit(ADAPTER_EVENTS.VALIDITY_CHANGE, { valid: !invalid, reason }) + manager.emit(ADAPTER_EVENTS.VALIDITY_CHANGE, { valid: !next, reason }) } }) const updateLiveValidity = () => { const geom = olFeature.getGeometry() const type = geom.getType() const coordinates = geom.getCoordinates() - const placedCount = type === 'Polygon' + const numVertices = type === 'Polygon' ? Math.max(0, (coordinates[0]?.length ?? 1) - 1) : coordinates.length liveStroke.update({ feature: { type: 'Feature', geometry: { type, coordinates } }, context: { mode: 'edit_vertex' }, - placedCount, + numVertices, onGeometryChange: manager._geometryValidator }) } @@ -306,7 +321,6 @@ export const createEditMode = ({ map, manager, options }) => { } const originalFeatureStyle = olFeature.getStyle() - olFeature.setStyle(manager.styles.editFeatureStyle) const midpointLayer = createMidpointLayer(map, manager.styles.midpointStyle) const vertexLayer = createVertexLayer(map, manager.styles.vertexStyle) @@ -341,7 +355,7 @@ export const createEditMode = ({ map, manager, options }) => { syncGeom() // initial populate const layers = { vertexLayer, midpointLayer, activeLayer } - const live = wireLiveStroke({ manager, olFeature }) + const live = wireLiveStroke({ map, manager, olFeature }) const touchHandler = wireTouchHandler({ map, container, manager, snap, olFeature, undoStack, selection }) const actions = createVertexActions({ olFeature, undoStack, selection, getTouchHandler: () => touchHandler }) const keyboardHandler = wireKeyboardHandler({ map, container, snap, undoStack, selection, touchHandler, actions }) diff --git a/plugins/draw/src/adapters/openlayers/edit/EditMode.test.js b/plugins/draw/src/adapters/openlayers/edit/EditMode.test.js index 3b137cf46..e6d532059 100644 --- a/plugins/draw/src/adapters/openlayers/edit/EditMode.test.js +++ b/plugins/draw/src/adapters/openlayers/edit/EditMode.test.js @@ -8,6 +8,11 @@ import Polygon from 'ol/geom/Polygon.js' const RING = [[0, 0], [100, 0], [100, 100], [0, 100], [0, 0]] // square: 4 vertices, deletable +// The feature style is a live function (see wireLiveStroke in EditMode.js — avoids +// Feature#setStyle() on every flip, which would break an in-progress Modify drag), +// not a static Style instance, so tests must invoke it to read the current style. +const currentStyle = (olFeature) => olFeature.getStyle()() + const setup = (ring = RING) => { const map = createFakeMap() const manager = createFakeManager() @@ -44,7 +49,7 @@ test('returns null for an unknown feature id', () => { test('entering edit mode swaps the feature style and reports the initial vertex state', () => { const { manager, olFeature } = setup() - expect(olFeature.getStyle()).toBe(manager.styles.editFeatureStyle) + expect(currentStyle(olFeature)).toBe(manager.styles.editFeatureStyle) expect(manager.emit).toHaveBeenCalledWith(ADAPTER_EVENTS.VERTEX_CHANGE, { numVertices: 4 }) expect(manager.emit).toHaveBeenCalledWith(ADAPTER_EVENTS.UPDATE, expect.objectContaining({ id: 'f1' })) }) @@ -52,18 +57,25 @@ test('entering edit mode swaps the feature style and reports the initial vertex test('setInvalid swaps between the solid and dashed edit styles', () => { const { manager, mode, olFeature } = setup() mode.setInvalid(true) - expect(olFeature.getStyle()).toBe(manager.styles.editFeatureStyleInvalid) + expect(currentStyle(olFeature)).toBe(manager.styles.editFeatureStyleInvalid) mode.setInvalid(false) - expect(olFeature.getStyle()).toBe(manager.styles.editFeatureStyle) + expect(currentStyle(olFeature)).toBe(manager.styles.editFeatureStyle) }) test('a mid-drag crossing turns the stroke dashed live, and back when it clears', () => { const { manager, olFeature } = setup() // Geometry mutation (as during a Modify/touch drag) — no commit yet. olFeature.getGeometry().setCoordinates([[[0, 0], [100, 100], [100, 0], [0, 100], [0, 0]]]) - expect(olFeature.getStyle()).toBe(manager.styles.editFeatureStyleInvalid) + expect(currentStyle(olFeature)).toBe(manager.styles.editFeatureStyleInvalid) olFeature.getGeometry().setCoordinates([[[0, 0], [100, 0], [100, 100], [0, 100], [0, 0]]]) - expect(olFeature.getStyle()).toBe(manager.styles.editFeatureStyle) + expect(currentStyle(olFeature)).toBe(manager.styles.editFeatureStyle) +}) + +test('a validity flip repaints the map without touching the feature — Modify tracks its own drag via feature #change, which setStyle() would trip', () => { + const { map, olFeature } = setup() + const renderCallsBefore = map.render.mock.calls.length + olFeature.getGeometry().setCoordinates([[[0, 0], [100, 100], [100, 0], [0, 100], [0, 0]]]) + expect(map.render.mock.calls.length).toBeGreaterThan(renderCallsBefore) }) test('validity flips while editing also gate the Done button', () => { @@ -85,7 +97,7 @@ test('the user callback runs throttled during an edit drag', () => { expect(manager._geometryValidator).not.toHaveBeenCalled() // deferred to the frame jest.runAllTimers() expect(manager._geometryValidator).toHaveBeenCalledTimes(1) // trailing edge only - expect(olFeature.getStyle()).toBe(manager.styles.editFeatureStyleInvalid) + expect(currentStyle(olFeature)).toBe(manager.styles.editFeatureStyleInvalid) jest.useRealTimers() }) @@ -256,7 +268,7 @@ test('style changes re-style the feature and handles', () => { const newStyles = { ...manager.styles, editFeatureStyle: new Style({}) } manager.styles = newStyles manager.emit(STYLES_CHANGED_EVENT, newStyles) - expect(olFeature.getStyle()).toBe(newStyles.editFeatureStyle) + expect(currentStyle(olFeature)).toBe(newStyles.editFeatureStyle) }) test('a style change while the shape is invalid keeps the dashed stroke', () => { @@ -269,7 +281,7 @@ test('a style change while the shape is invalid keeps the dashed stroke', () => } manager.styles = newStyles manager.emit(STYLES_CHANGED_EVENT, newStyles) - expect(olFeature.getStyle()).toBe(newStyles.editFeatureStyleInvalid) + expect(currentStyle(olFeature)).toBe(newStyles.editFeatureStyleInvalid) }) test('map resize repositions the touch target after the next render — touch with a selection only', () => { diff --git a/plugins/draw/src/api/split.js b/plugins/draw/src/api/split.js index 608b7d90f..7f70a3dec 100644 --- a/plugins/draw/src/api/split.js +++ b/plugins/draw/src/api/split.js @@ -37,11 +37,11 @@ const applySplitCommit = ({ draw, dispatch, polygonFeature, feature }) => { // Installed as draw._geometryValidator. 'place' and 'create' are no-ops — a split // isn't complete until its last vertex, so checking those would block placement // and hijack an early finish into edit mode. -const createSplitValidator = ({ draw, dispatch, polygonFeature }) => (feature, context) => { +const createSplitValidator = ({ draw, dispatch, polygonFeature }) => ({ feature, phase }) => { let isValid - if (context.phase === 'preview') { + if (phase === 'preview') { isValid = applySplitPreview({ draw, polygonFeature, feature }) - } else if (context.phase?.startsWith('commit-')) { + } else if (phase?.startsWith('commit-')) { isValid = applySplitCommit({ draw, dispatch, polygonFeature, feature }) } else { return { valid: true } diff --git a/plugins/draw/src/api/split.test.js b/plugins/draw/src/api/split.test.js index e3ad4c33e..3a50db401 100644 --- a/plugins/draw/src/api/split.test.js +++ b/plugins/draw/src/api/split.test.js @@ -130,7 +130,7 @@ describe('split', () => { split(context, 'poly') dispatch.mockClear() - const result = draw._geometryValidator(lineFeature([[0, 0], [1, 1]]), { phase: 'place', mode: 'draw_line', vertexIndex: 1 }) + const result = draw._geometryValidator({ feature: lineFeature([[0, 0], [1, 1]]), phase: 'place', mode: 'draw_line', vertexIndex: 1 }) expect(result).toEqual({ valid: true }) expect(splitPolygon).not.toHaveBeenCalled() @@ -143,7 +143,7 @@ describe('split', () => { split(context, 'poly') dispatch.mockClear() - const result = draw._geometryValidator(lineFeature([[0, 0], [1, 1]]), { phase: 'create', mode: 'draw_line' }) + const result = draw._geometryValidator({ feature: lineFeature([[0, 0], [1, 1]]), phase: 'create', mode: 'draw_line' }) expect(result).toEqual({ valid: true }) expect(splitPolygon).not.toHaveBeenCalled() @@ -159,10 +159,10 @@ describe('split', () => { dispatch.mockClear() // 1 placed vertex + the rubber-band cursor. validateDisplayedGeometry only - // gates the built-in rules by placedCount, not a caller's own rule, so this - // must still reach the validator (placedCount: 1, below MIN_VERTICES.LineString). + // gates the built-in rules by numVertices, not a caller's own rule, so this + // must still reach the validator (numVertices: 1, below MIN_VERTICES.LineString). const feature = lineFeature([[0, 0], [1, 1]]) - const result = draw._geometryValidator(feature, { phase: 'preview', mode: 'draw_line', placedCount: 1 }) + const result = draw._geometryValidator({ feature, phase: 'preview', mode: 'draw_line', numVertices: 1 }) expect(splitPolygon).toHaveBeenCalledWith(polygonFeature, { id: '_splitter', geometry: feature.geometry }) expect(draw.setDrawingPreviewProperty).toHaveBeenCalledWith('splitter', 'valid') @@ -180,7 +180,7 @@ describe('split', () => { split(context, 'poly') const feature = lineFeature([[0, 0], [1, 1]]) - const result = draw._geometryValidator(feature, { phase: 'commit-add', mode: 'draw_line', vertexIndex: 1 }) + const result = draw._geometryValidator({ feature, phase: 'commit-add', mode: 'draw_line', vertexIndex: 1 }) expect(draw.setDrawingPreviewProperty).toHaveBeenCalledWith('splitter', 'invalid') expect(dispatch).toHaveBeenCalledWith({ type: 'SET_ACTION', payload: { name: 'split', isValid: false } }) @@ -194,7 +194,7 @@ describe('split', () => { split(context, 'poly') const feature = lineFeature([[0, 0]]) - const result = draw._geometryValidator(feature, { phase: 'preview', mode: 'draw_line', placedCount: 0 }) + const result = draw._geometryValidator({ feature, phase: 'preview', mode: 'draw_line', numVertices: 0 }) expect(splitPolygon).not.toHaveBeenCalled() expect(draw.setDrawingPreviewProperty).toHaveBeenCalledWith('splitter', 'invalid') diff --git a/plugins/draw/src/events.test.js b/plugins/draw/src/events.test.js index 8145c9860..db9fbf1ee 100644 --- a/plugins/draw/src/events.test.js +++ b/plugins/draw/src/events.test.js @@ -320,7 +320,7 @@ describe('geometrychange validation', () => { const validator = jest.fn(() => true) draw._geometryValidator = validator drawHandler(draw, 'geometrychange')({ feature: squareFeature, phase: 'commit-add', vertexIndex: 3 }) - expect(validator).toHaveBeenCalledWith(squareFeature, { phase: 'commit-add', vertexIndex: 3, mode: 'draw_polygon' }) + expect(validator).toHaveBeenCalledWith({ feature: squareFeature, phase: 'commit-add', vertexIndex: 3, mode: 'draw_polygon' }) }) }) diff --git a/plugins/draw/src/validation/liveStroke.js b/plugins/draw/src/validation/liveStroke.js index 2b2449c3b..98627fbd9 100644 --- a/plugins/draw/src/validation/liveStroke.js +++ b/plugins/draw/src/validation/liveStroke.js @@ -51,8 +51,8 @@ export const createLiveStroke = ({ onChange, validate = validateDisplayedGeometr return { // Re-evaluate the displayed geometry after a rubber-band move. - update ({ feature, context = {}, placedCount, onGeometryChange }) { - const ctx = { ...context, placedCount } + update ({ feature, context = {}, numVertices, onGeometryChange }) { + const ctx = { ...context, numVertices } // Default rules first, synchronously — immediate feedback on self-intersection / area. const base = validate(feature, ctx) if (!base.valid) { cancelPending(); flip(true, base.reason); return } diff --git a/plugins/draw/src/validation/liveStroke.test.js b/plugins/draw/src/validation/liveStroke.test.js index 54956806f..bd6e2575d 100644 --- a/plugins/draw/src/validation/liveStroke.test.js +++ b/plugins/draw/src/validation/liveStroke.test.js @@ -17,35 +17,35 @@ afterEach(() => jest.useRealTimers()) describe('default rules (synchronous)', () => { test('a failing default rule flips dashed immediately with the reason', () => { const { onChange, stroke } = setup() - stroke.update({ feature: bowtie, placedCount: 3 }) + stroke.update({ feature: bowtie, numVertices: 3 }) expect(onChange).toHaveBeenCalledWith(true, expect.stringMatching(/intersect/i)) }) test('going valid again flips back solid', () => { const { onChange, stroke } = setup() - stroke.update({ feature: bowtie, placedCount: 3 }) - stroke.update({ feature: square, placedCount: 3 }) + stroke.update({ feature: bowtie, numVertices: 3 }) + stroke.update({ feature: square, numVertices: 3 }) expect(onChange).toHaveBeenLastCalledWith(false, null) }) test('onChange fires only when the state flips, not on every update', () => { const { onChange, stroke } = setup() - stroke.update({ feature: bowtie, placedCount: 3 }) - stroke.update({ feature: bowtie, placedCount: 3 }) - stroke.update({ feature: bowtie, placedCount: 3 }) + stroke.update({ feature: bowtie, numVertices: 3 }) + stroke.update({ feature: bowtie, numVertices: 3 }) + stroke.update({ feature: bowtie, numVertices: 3 }) expect(onChange).toHaveBeenCalledTimes(1) }) - test('below the minimum placed count the shape is part-drawn — never dashed', () => { + test('below the minimum vertex count the shape is part-drawn — never dashed', () => { const { onChange, stroke } = setup() - stroke.update({ feature: bowtie, placedCount: 2 }) + stroke.update({ feature: bowtie, numVertices: 2 }) expect(onChange).not.toHaveBeenCalled() }) test('a default-rule failure never invokes the user callback', () => { const { stroke } = setup() const onGeometryChange = jest.fn() - stroke.update({ feature: bowtie, placedCount: 3, onGeometryChange }) + stroke.update({ feature: bowtie, numVertices: 3, onGeometryChange }) jest.runAllTimers() expect(onGeometryChange).not.toHaveBeenCalled() }) @@ -55,19 +55,19 @@ describe('user callback (throttled)', () => { test('runs once per frame with the latest geometry (trailing edge)', () => { const { stroke } = setup() const onGeometryChange = jest.fn(() => true) - stroke.update({ feature: square, placedCount: 3, onGeometryChange }) - stroke.update({ feature: square, placedCount: 4, onGeometryChange }) + stroke.update({ feature: square, numVertices: 3, onGeometryChange }) + stroke.update({ feature: square, numVertices: 4, onGeometryChange }) const latest = poly([[0, 0], [20, 0], [20, 20], [0, 20]]) - stroke.update({ feature: latest, placedCount: 5, onGeometryChange }) + stroke.update({ feature: latest, numVertices: 5, onGeometryChange }) expect(onGeometryChange).not.toHaveBeenCalled() // nothing synchronous jest.runAllTimers() expect(onGeometryChange).toHaveBeenCalledTimes(1) - expect(onGeometryChange).toHaveBeenCalledWith(latest, expect.objectContaining({ placedCount: 5 })) + expect(onGeometryChange).toHaveBeenCalledWith(expect.objectContaining({ feature: latest, numVertices: 5 })) }) test('a user-callback veto flips dashed with its reason', () => { const { onChange, stroke } = setup() - stroke.update({ feature: square, placedCount: 3, onGeometryChange: () => ({ valid: false, reason: 'outside region' }) }) + stroke.update({ feature: square, numVertices: 3, onGeometryChange: () => ({ valid: false, reason: 'outside region' }) }) jest.runAllTimers() expect(onChange).toHaveBeenCalledWith(true, 'outside region') }) @@ -75,8 +75,8 @@ describe('user callback (throttled)', () => { test('a synchronous default failure cancels a pending user-rule frame', () => { const { onChange, stroke } = setup() const onGeometryChange = jest.fn(() => true) - stroke.update({ feature: square, placedCount: 3, onGeometryChange }) - stroke.update({ feature: bowtie, placedCount: 3, onGeometryChange }) // sync dashed + stroke.update({ feature: square, numVertices: 3, onGeometryChange }) + stroke.update({ feature: bowtie, numVertices: 3, onGeometryChange }) // sync dashed jest.runAllTimers() expect(onGeometryChange).not.toHaveBeenCalled() // stale frame dropped expect(onChange).toHaveBeenLastCalledWith(true, expect.any(String)) @@ -84,8 +84,8 @@ describe('user callback (throttled)', () => { test('without a user callback a valid update settles solid immediately', () => { const { onChange, stroke } = setup() - stroke.update({ feature: bowtie, placedCount: 3 }) - stroke.update({ feature: square, placedCount: 3 }) + stroke.update({ feature: bowtie, numVertices: 3 }) + stroke.update({ feature: square, numVertices: 3 }) expect(onChange).toHaveBeenLastCalledWith(false, null) expect(jest.getTimerCount()).toBe(0) }) @@ -98,8 +98,8 @@ describe('custom validate function', () => { config?.onGeometryChange ? config.onGeometryChange(feature, context) : { valid: true }) const stroke = createLiveStroke({ onChange, validate }) const onGeometryChange = jest.fn(() => ({ valid: false, reason: 'vetoed' })) - stroke.update({ feature: square, placedCount: 3, onGeometryChange }) - expect(validate).toHaveBeenCalledWith(square, expect.objectContaining({ placedCount: 3 })) + stroke.update({ feature: square, numVertices: 3, onGeometryChange }) + expect(validate).toHaveBeenCalledWith(square, expect.objectContaining({ numVertices: 3 })) jest.runAllTimers() expect(onChange).toHaveBeenCalledWith(true, 'vetoed') }) @@ -109,7 +109,7 @@ describe('set / reset / destroy', () => { test('set() applies through the flip guard and drops any pending frame', () => { const { onChange, stroke } = setup() const onGeometryChange = jest.fn(() => true) - stroke.update({ feature: square, placedCount: 3, onGeometryChange }) + stroke.update({ feature: square, numVertices: 3, onGeometryChange }) stroke.set(true, 'committed invalid') expect(onChange).toHaveBeenCalledWith(true, 'committed invalid') jest.runAllTimers() @@ -123,17 +123,17 @@ describe('set / reset / destroy', () => { const { onChange, stroke } = setup() stroke.set(true) onChange.mockClear() - stroke.update({ feature: square, placedCount: 3 }) // valid → back solid + stroke.update({ feature: square, numVertices: 3 }) // valid → back solid expect(onChange).toHaveBeenCalledWith(false, null) }) test('refresh() re-asserts the cached state unconditionally (style-reload resync)', () => { const { onChange, stroke } = setup() - stroke.update({ feature: bowtie, placedCount: 3 }) + stroke.update({ feature: bowtie, numVertices: 3 }) onChange.mockClear() stroke.refresh() // rendered output was reset externally — re-apply dashed expect(onChange).toHaveBeenCalledWith(true, null) - stroke.update({ feature: square, placedCount: 3 }) + stroke.update({ feature: square, numVertices: 3 }) onChange.mockClear() stroke.refresh() expect(onChange).toHaveBeenCalledWith(false, null) @@ -142,7 +142,7 @@ describe('set / reset / destroy', () => { test('destroy() cancels a pending user-rule frame', () => { const { stroke } = setup() const onGeometryChange = jest.fn(() => true) - stroke.update({ feature: square, placedCount: 3, onGeometryChange }) + stroke.update({ feature: square, numVertices: 3, onGeometryChange }) stroke.destroy() jest.runAllTimers() expect(onGeometryChange).not.toHaveBeenCalled() diff --git a/plugins/draw/src/validation/validateGeometry.js b/plugins/draw/src/validation/validateGeometry.js index 2b3b06368..dcf6febe1 100644 --- a/plugins/draw/src/validation/validateGeometry.js +++ b/plugins/draw/src/validation/validateGeometry.js @@ -23,8 +23,11 @@ const normaliseResult = (result) => { * @param {object} feature - current GeoJSON feature * @param {object} context - { phase: import('../adapterEvents.js').GeometryChangePhase, vertexIndex, mode } * @param {object} [config] - * @param {Array} [config.rules] - defaults to DEFAULT_RULES - * @param {Function} [config.onGeometryChange] - user callback, same signature as a rule + * @param {Array} [config.rules] - defaults to DEFAULT_RULES; called as rule(feature, context) + * @param {function(import('../adapterEvents.js').GeometryChangeEvent): (boolean|{valid: boolean, reason?: string}|undefined)} [config.onGeometryChange] + * - user callback, called as onGeometryChange({ feature, ...context }) — a single + * event object, unlike the internal rules above, matching the shape of the + * PLACEMENT_BLOCKED adapter event * @returns {{ valid: boolean, reason?: string }} */ export const validateGeometry = (feature, context = {}, config = {}) => { @@ -36,7 +39,7 @@ export const validateGeometry = (feature, context = {}, config = {}) => { } if (typeof onGeometryChange === 'function') { - return normaliseResult(onGeometryChange(feature, context)) + return normaliseResult(onGeometryChange({ feature, ...context })) } return { valid: true } @@ -51,7 +54,7 @@ export const validateGeometry = (feature, context = {}, config = {}) => { * @param {object} context - { vertexIndex, mode }; phase is forced to 'place' * @param {object} [config] * @param {Array} [config.rules] - defaults to HARD_RULES - * @param {Function} [config.onGeometryChange] - user callback, same signature as a rule + * @param {function(import('../adapterEvents.js').GeometryChangeEvent): (boolean|{valid: boolean, reason?: string}|undefined)} [config.onGeometryChange] - user callback * @returns {{ valid: boolean, reason?: string }} */ export const validatePlacement = (feature, context = {}, config = {}) => { @@ -70,7 +73,7 @@ export const MODE_BY_GEOMETRY = { Polygon: 'draw_polygon', LineString: 'draw_lin * @param {Array>} params.placed - committed vertex coordinates * @param {Array} params.point - the coordinate about to be placed * @param {'Polygon'|'LineString'} params.geometryType - * @param {Function} [params.onGeometryChange] - user callback + * @param {function(import('../adapterEvents.js').GeometryChangeEvent): (boolean|{valid: boolean, reason?: string}|undefined)} [params.onGeometryChange] - user callback * @returns {{ valid: true } | { valid: false, blocked: object }} */ export const attemptPlacement = ({ placed, point, geometryType, onGeometryChange }) => { @@ -87,23 +90,33 @@ export const attemptPlacement = ({ placed, point, geometryType, onGeometryChange /** * Validate the displayed (in-progress) geometry that drives the live invalid - * stroke: the placed vertices plus the current cursor point. Below the minimum - * vertex count, the built-in live rules are skipped (not enough of a shape yet - * to check self-intersection/area) — but a caller's own `onGeometryChange` always - * runs regardless, since its data requirements are its own business. + * stroke: the placed vertices plus the current cursor point. Two different + * thresholds gate the two kinds of check: + * - The built-in live rules (self-intersection/area) are meaningless below + * the geometry type's minimum vertex count — there isn't a real ring/line + * yet — so they're skipped entirely until then. + * - A caller's own `onGeometryChange` can be meaningful on a single point + * (e.g. "is this point inside a region?"), so it runs as soon as at least + * one vertex is committed, rather than waiting for a real shape. + * With zero committed vertices neither check runs — there's nothing to + * validate on the very first mouse-move before any click. This only affects + * the live-stroke preview; the Add-point placement gate (validatePlacement, + * used by attemptPlacement) is a separate check and keeps running from the + * first vertex. * * @param {object} feature - displayed GeoJSON feature (placed vertices + cursor) - * @param {object} context - { mode, placedCount, phase }; phase defaults to 'preview' + * @param {object} context - { mode, numVertices, phase }; phase defaults to 'preview' * @param {object} [config] * @param {Array} [config.rules] - defaults to LIVE_RULES - * @param {Function} [config.onGeometryChange] - user callback, same signature as a rule + * @param {function(import('../adapterEvents.js').GeometryChangeEvent): (boolean|{valid: boolean, reason?: string}|undefined)} [config.onGeometryChange] - user callback * @returns {{ valid: boolean, reason?: string }} */ export const validateDisplayedGeometry = (feature, context = {}, config = {}) => { const { rules = LIVE_RULES, onGeometryChange } = config const type = feature?.geometry?.type ?? feature?.type const min = MIN_VERTICES[type] ?? 0 - const belowMinVertices = (context.placedCount ?? 0) < min - const effectiveRules = belowMinVertices ? [] : rules - return validateGeometry(feature, { ...context, phase: context.phase ?? 'preview' }, { rules: effectiveRules, onGeometryChange }) + const numVertices = context.numVertices ?? 0 + const effectiveRules = numVertices < min ? [] : rules + const effectiveOnGeometryChange = numVertices < 1 ? undefined : onGeometryChange + return validateGeometry(feature, { ...context, phase: context.phase ?? 'preview' }, { rules: effectiveRules, onGeometryChange: effectiveOnGeometryChange }) } diff --git a/plugins/draw/src/validation/validateGeometry.test.js b/plugins/draw/src/validation/validateGeometry.test.js index e38192460..ebc469178 100644 --- a/plugins/draw/src/validation/validateGeometry.test.js +++ b/plugins/draw/src/validation/validateGeometry.test.js @@ -33,13 +33,13 @@ describe('validateGeometry (soft gating)', () => { expect(second).not.toHaveBeenCalled() }) - test('passes the context to rules and the callback', () => { + test('passes the context to rules as (feature, context), and to the callback as a single flattened event object', () => { const rule = jest.fn(() => ({ valid: true })) const onGeometryChange = jest.fn(() => ({ valid: true })) const context = { phase: 'commit-move', vertexIndex: 2, mode: 'edit_vertex' } validateGeometry(square, context, { rules: [rule], onGeometryChange }) expect(rule).toHaveBeenCalledWith(square, context) - expect(onGeometryChange).toHaveBeenCalledWith(square, context) + expect(onGeometryChange).toHaveBeenCalledWith({ feature: square, ...context }) }) test('runs the user callback after the rules pass', () => { @@ -86,7 +86,13 @@ describe('attemptPlacement (shared engine gate)', () => { const onGeometryChange = jest.fn(() => ({ valid: false, reason: 'outside region' })) const result = attemptPlacement({ placed: [[0, 0]], point: [1, 1], geometryType: 'LineString', onGeometryChange }) expect(result.blocked).toEqual(expect.objectContaining({ mode: 'draw_line', reason: 'outside region', vertexIndex: 1 })) - expect(onGeometryChange).toHaveBeenCalledWith(expect.anything(), { phase: 'place', mode: 'draw_line', vertexIndex: 1 }) + expect(onGeometryChange).toHaveBeenCalledWith(expect.objectContaining({ phase: 'place', mode: 'draw_line', vertexIndex: 1 })) + }) + + test('a veto with no reason falls back to null in the PLACEMENT_BLOCKED payload', () => { + const onGeometryChange = jest.fn(() => false) // false → invalid, no reason (see normaliseResult) + const result = attemptPlacement({ placed: [[0, 0]], point: [1, 1], geometryType: 'LineString', onGeometryChange }) + expect(result.blocked.reason).toBeNull() }) test('attemptPlacement mode is set correctly for Polygon vs LineString', () => { @@ -101,41 +107,61 @@ describe('attemptPlacement (shared engine gate)', () => { describe('validateDisplayedGeometry edge cases', () => { test('handles unknown geometry types with fallback min vertices', () => { - const result = validateDisplayedGeometry({ type: 'Feature', geometry: { type: 'Unknown', coordinates: [] } }, { placedCount: 0 }) + const result = validateDisplayedGeometry({ type: 'Feature', geometry: { type: 'Unknown', coordinates: [] } }, { numVertices: 0 }) expect(result.valid).toBe(true) // unknown types use MIN_VERTICES fallback of 0, so 0 placed = valid }) test('feature without geometry type defaults to context.phase', () => { - const result = validateDisplayedGeometry({ type: 'Feature', geometry: { coordinates: [[0, 0]] } }, { placedCount: 2, phase: 'custom' }) + const result = validateDisplayedGeometry({ type: 'Feature', geometry: { coordinates: [[0, 0]] } }, { numVertices: 2, phase: 'custom' }) expect(typeof result).toBe('object') expect(result).toHaveProperty('valid') }) - test('context without placedCount defaults to 0 for min-vertex check', () => { + test('context without numVertices defaults to 0 for min-vertex check', () => { const result = validateDisplayedGeometry(poly([[0, 0]]), {}) - expect(result.valid).toBe(true) // no placedCount = 0, below any min, so valid + expect(result.valid).toBe(true) // no numVertices = 0, below any min, so valid }) - test('still calls the caller\'s own onGeometryChange below the vertex threshold — only the built-in rules are gated', () => { + test('context and config are both optional — called with just a feature', () => { + expect(validateDisplayedGeometry(poly([[0, 0]]))).toEqual({ valid: true }) + }) + + test('calls the caller\'s own onGeometryChange once at/above the vertex threshold, with a single flattened event object', () => { const onGeometryChange = jest.fn(() => ({ valid: false, reason: 'too few for my rule' })) - const feature = poly([[0, 0], [1, 0]]) + // Polygon minimum is 3 placed vertices — this feature/numVertices pair is at it. + const feature = poly([[0, 0], [1, 0], [1, 1]]) - const result = validateDisplayedGeometry(feature, { placedCount: 1 }, { onGeometryChange }) + const result = validateDisplayedGeometry(feature, { numVertices: 3 }, { onGeometryChange }) - expect(onGeometryChange).toHaveBeenCalledWith(feature, expect.objectContaining({ placedCount: 1, phase: 'preview' })) + expect(onGeometryChange).toHaveBeenCalledWith(expect.objectContaining({ feature, numVertices: 3, phase: 'preview' })) expect(result).toEqual({ valid: false, reason: 'too few for my rule' }) }) - test('skips the built-in rules below the vertex threshold even when a callback is supplied', () => { + test('skips both the built-in rules AND the caller\'s onGeometryChange with zero committed vertices — nothing meaningful to validate yet before the first click', () => { const failingRule = jest.fn(() => ({ valid: false, reason: 'should not run' })) const onGeometryChange = jest.fn(() => true) - const result = validateDisplayedGeometry(poly([[0, 0]]), { placedCount: 0 }, { rules: [failingRule], onGeometryChange }) + const result = validateDisplayedGeometry(poly([[0, 0]]), { numVertices: 0 }, { rules: [failingRule], onGeometryChange }) expect(failingRule).not.toHaveBeenCalled() - expect(onGeometryChange).toHaveBeenCalled() + expect(onGeometryChange).not.toHaveBeenCalled() expect(result).toEqual({ valid: true }) }) + + test('runs the caller\'s onGeometryChange from the first committed vertex, even while the built-in rules are still skipped', () => { + const failingRule = jest.fn(() => ({ valid: false, reason: 'should not run' })) + // A single committed vertex — well below the Polygon minimum of 3 — so the + // built-in self-intersection/area rules stay skipped, but a per-point user + // rule (e.g. "is this inside a region?") is meaningful and should still run. + const onGeometryChange = jest.fn(() => ({ valid: false, reason: 'outside region' })) + const feature = poly([[0, 0], [1, 1]]) + + const result = validateDisplayedGeometry(feature, { numVertices: 1 }, { rules: [failingRule], onGeometryChange }) + + expect(failingRule).not.toHaveBeenCalled() + expect(onGeometryChange).toHaveBeenCalledWith(expect.objectContaining({ feature, numVertices: 1, phase: 'preview' })) + expect(result).toEqual({ valid: false, reason: 'outside region' }) + }) }) describe('validatePlacement (hard gating)', () => { @@ -156,7 +182,7 @@ describe('validatePlacement (hard gating)', () => { test('forces phase "place" into the rule and callback context', () => { const onGeometryChange = jest.fn(() => true) validatePlacement(simplePath, { mode: 'draw_polygon', vertexIndex: 4 }, { onGeometryChange }) - expect(onGeometryChange).toHaveBeenCalledWith(simplePath, { phase: 'place', mode: 'draw_polygon', vertexIndex: 4 }) + expect(onGeometryChange).toHaveBeenCalledWith({ feature: simplePath, phase: 'place', mode: 'draw_polygon', vertexIndex: 4 }) }) test('the user callback can veto a placement with a reason', () => { From 11c4a222934a662300e0b6b2440e77494f3ddd32 Mon Sep 17 00:00:00 2001 From: Dan Leech Date: Tue, 4 Aug 2026 14:46:48 +0100 Subject: [PATCH 80/89] Hint added to show validation reason --- demo/js/draw.js | 4 +++ plugins/draw/src/DrawInit.jsx | 5 +-- plugins/draw/src/events.js | 34 ++++++++++++++----- plugins/draw/src/events.test.js | 41 +++++++++++++++++++---- plugins/draw/src/validation/rules.js | 13 +++++-- plugins/draw/src/validation/rules.test.js | 9 ++++- 6 files changed, 86 insertions(+), 20 deletions(-) diff --git a/demo/js/draw.js b/demo/js/draw.js index ab189cf48..83fdd32b3 100755 --- a/demo/js/draw.js +++ b/demo/js/draw.js @@ -56,6 +56,10 @@ const drawPlugin = createDrawPlugin({ valid: isEastOfWalesBorder(event.feature.geometry), reason: 'Points must be placed east of the England/Wales border' }) + // onGeometryChange: (event) => { console.log(event); return { + // valid: isEastOfWalesBorder(event.feature.geometry), + // reason: 'Points must be placed east of the England/Wales border' + // }} }) const datasetsPlugin = createDatasetsPlugin({ diff --git a/plugins/draw/src/DrawInit.jsx b/plugins/draw/src/DrawInit.jsx index 4f5886806..5abf3968a 100644 --- a/plugins/draw/src/DrawInit.jsx +++ b/plugins/draw/src/DrawInit.jsx @@ -4,7 +4,7 @@ import { loadDrawAdapter } from './adapters/loadDrawAdapter.js' import { attachEvents } from './events.js' export const DrawInit = ({ appState, appConfig, mapState, pluginConfig, pluginState, services, mapProvider, buttonConfig }) => { - const { eventBus } = services + const { eventBus, hints } = services const { crossHair } = mapState const isTouchOrKeyboard = ['touch', 'keyboard'].includes(appState.interfaceType) @@ -81,7 +81,8 @@ export const DrawInit = ({ appState, appConfig, mapState, pluginConfig, pluginSt buttonConfig, pluginState, events: EVENTS, - eventBus + eventBus, + hints }) }, [mapProvider, appState, pluginState]) } diff --git a/plugins/draw/src/events.js b/plugins/draw/src/events.js index 00c891f59..fa581330e 100644 --- a/plugins/draw/src/events.js +++ b/plugins/draw/src/events.js @@ -7,11 +7,28 @@ */ 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' const EDIT_VERTEX_MODE = 'edit_vertex' const GEOMETRY_INVALID_EVENT = 'draw:geometryinvalid' +// A shape that simply hasn't reached its minimum vertex count yet isn't a mistake — +// it's the normal, expected state of "still being drawn" — so it's excluded below +// even though it's a genuine SOFT_RULES failure like any other. +const isIncompleteShape = (reason) => Object.values(MIN_VERTICES_REASONS).includes(reason) + +// A discrete, one-shot invalid result (a rejected placement, a finished-but-invalid +// shape, or a completed commit that failed validation — never a continuous live-drag +// flip, which goes via onValidityChange/CAN_PLACE_CHANGE instead) gets a hint toast +// alongside the existing public event, so the reason is visible without every +// consumer having to wire up their own listener for it. The public event still +// fires either way — only the hint is skipped for an incomplete shape. +const createGeometryInvalidEmitter = (eventBus, hints) => (payload) => { + eventBus.emit(GEOMETRY_INVALID_EVENT, payload) + if (payload.reason && !isIncompleteShape(payload.reason)) { hints.show(payload.reason) } +} + // Claims MoveControl's D-pad for the currently selected vertex — see the generic // mapProvider.activeMoveTarget contract (MoveControl.jsx). Any plugin could use // this slot; the draw plugin is just its first consumer. @@ -56,8 +73,9 @@ const enterEditVertexMode = ({ draw, appState, appConfig, mapState, dispatch }, draw.setInvalid?.(true) } -function createHandlers ({ appState, appConfig, mapState, pluginState, mapProvider, eventBus, resetState }) { +function createHandlers ({ appState, appConfig, mapState, pluginState, mapProvider, eventBus, hints, resetState }) { const { draw } = mapProvider + const emitGeometryInvalid = createGeometryInvalidEmitter(eventBus, hints) const { feature, tempFeature } = pluginState const { dispatch } = pluginState // A shape that finished invalid and was re-opened in edit mode: its eventual @@ -83,10 +101,10 @@ function createHandlers ({ appState, appConfig, mapState, pluginState, mapProvid // A shape can be finished by the Done button OR a map gesture (double-click, // clicking the first vertex, Enter). Only the button is gated, so re-validate // here to catch the gesture paths — an invalid shape must never be finalised. - const { valid } = validateGeometry(f, { phase: 'create', mode: draw.getMode() }, { onGeometryChange: draw._geometryValidator }) + const { valid, reason } = validateGeometry(f, { phase: 'create', mode: draw.getMode() }, { onGeometryChange: draw._geometryValidator }) if (!valid) { pendingCreateId = f.id - eventBus.emit(GEOMETRY_INVALID_EVENT, { feature: f, phase: 'create', mode: EDIT_VERTEX_MODE }) + emitGeometryInvalid({ feature: f, reason, phase: 'create', mode: EDIT_VERTEX_MODE }) setTimeout(() => enterEditVertexMode({ draw, appState, appConfig, mapState, dispatch }, f.id), 0) return } @@ -127,13 +145,13 @@ function createHandlers ({ appState, appConfig, mapState, pluginState, mapProvid draw.setInvalid?.(!valid) } if (!valid) { - eventBus.emit(GEOMETRY_INVALID_EVENT, { reason, ...context, feature: e.feature }) + emitGeometryInvalid({ reason, ...context, feature: e.feature }) } }, // A vertex placement was rejected (hard rule or user callback veto). Surface it - // on the public bus with phase 'place' so a future tooltip can show the reason. + // on the public bus with phase 'place', and as a hint toast, so the reason is visible. onPlacementBlocked: (e) => { - eventBus.emit(GEOMETRY_INVALID_EVENT, e) + emitGeometryInvalid(e) }, // Live (mid-drag) validity flip while editing — the displayed shape is exactly // what Done finishes there, so it gates the Done button in real time. Emitted @@ -203,7 +221,7 @@ function detachDrawEvents (draw, handlers) { draw.off(ADAPTER_EVENTS.INTERFACE_TYPE_CHANGE, handlers.onInterfaceTypeChange) } -export function attachEvents ({ appState, appConfig, mapState, pluginState, mapProvider, buttonConfig, eventBus }) { +export function attachEvents ({ appState, appConfig, mapState, pluginState, mapProvider, buttonConfig, eventBus, hints }) { const { draw } = mapProvider const resetState = () => { pluginState.dispatch({ type: 'SET_MODE', payload: null }) @@ -212,7 +230,7 @@ export function attachEvents ({ appState, appConfig, mapState, pluginState, mapP // session ends — a stale claim here would silently hijack it for good. mapProvider.activeMoveTarget = null } - const handlers = createHandlers({ appState, appConfig, mapState, pluginState, mapProvider, eventBus, resetState }) + const handlers = createHandlers({ appState, appConfig, mapState, pluginState, mapProvider, eventBus, hints, resetState }) attachButtonHandlers(buttonConfig, handlers) attachDrawEvents(draw, handlers) return () => { detachButtonHandlers(buttonConfig); detachDrawEvents(draw, handlers) } diff --git a/plugins/draw/src/events.test.js b/plugins/draw/src/events.test.js index db9fbf1ee..6f1ac856e 100644 --- a/plugins/draw/src/events.test.js +++ b/plugins/draw/src/events.test.js @@ -26,13 +26,14 @@ const setup = (overrides = {}) => { const pluginState = { dispatch, feature: { id: 'F' }, tempFeature: { id: 'T' }, snap: false, ...overrides.pluginState } const mapProvider = { draw } const eventBus = { emit: jest.fn() } + const hints = { show: jest.fn() } 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 detach = attachEvents({ appState, appConfig, mapState, pluginState, mapProvider, buttonConfig, eventBus }) - return { draw, dispatch, pluginState, mapProvider, eventBus, buttonConfig, detach } + const detach = attachEvents({ appState, appConfig, mapState, pluginState, mapProvider, buttonConfig, eventBus, hints }) + return { draw, dispatch, pluginState, mapProvider, eventBus, hints, buttonConfig, detach } } const drawHandler = (draw, event) => draw.on.mock.calls.find(([name]) => name === event)[1] @@ -139,10 +140,12 @@ describe('draw event handlers', () => { }) test('re-opens an invalid finished shape in edit mode instead of creating it', () => { - const { draw, eventBus } = setup() + const { draw, eventBus, hints } = setup() const bowtie = { id: 'bad', type: 'Feature', geometry: { type: 'Polygon', coordinates: [[[0, 0], [1, 1], [1, 0], [0, 1], [0, 0]]] } } drawHandler(draw, 'create')(bowtie) expect(eventBus.emit).not.toHaveBeenCalledWith('draw:created', bowtie) + expect(eventBus.emit).toHaveBeenCalledWith('draw:geometryinvalid', expect.objectContaining({ reason: expect.stringMatching(/intersect/i) })) + expect(hints.show).toHaveBeenCalledWith(expect.stringMatching(/intersect/i)) jest.runAllTimers() const editCall = draw.changeMode.mock.calls.find(([mode]) => mode === 'edit_vertex') expect(editCall[1]).toEqual(expect.objectContaining({ featureId: 'bad' })) @@ -221,6 +224,10 @@ describe('geometrychange validation', () => { type: 'Feature', geometry: { type: 'Polygon', coordinates: [[[0, 0], [1, 0], [2, 0], [0, 0]]] } } + const partDrawnFeature = { + type: 'Feature', + geometry: { type: 'Polygon', coordinates: [[[0, 0], [1, 0], [0, 0]]] } + } test('ignores preview payloads that carry no phase', () => { const { draw, dispatch } = setup() @@ -235,11 +242,20 @@ describe('geometrychange validation', () => { }) test('gates a self-intersecting shape while drawing', () => { - const { draw, dispatch, eventBus } = setup() + const { draw, dispatch, eventBus, hints } = setup() drawHandler(draw, 'geometrychange')({ feature: bowtieFeature, phase: 'commit-add', vertexIndex: 3 }) expect(dispatch).toHaveBeenCalledWith({ type: 'SET_GEOMETRY_VALID', payload: false }) expect(draw.setGeometryValid).toHaveBeenCalledWith(false) expect(eventBus.emit).toHaveBeenCalledWith('draw:geometryinvalid', expect.objectContaining({ reason: expect.stringMatching(/intersect/i) })) + expect(hints.show).toHaveBeenCalledWith(expect.stringMatching(/intersect/i)) + }) + + test('gates a part-drawn shape below the minimum vertex count, but skips the hint — an incomplete shape is expected, not a mistake', () => { + const { draw, dispatch, eventBus, hints } = setup() + drawHandler(draw, 'geometrychange')({ feature: partDrawnFeature, phase: 'commit-add', vertexIndex: 1 }) + expect(dispatch).toHaveBeenCalledWith({ type: 'SET_GEOMETRY_VALID', payload: false }) + expect(eventBus.emit).toHaveBeenCalledWith('draw:geometryinvalid', expect.objectContaining({ reason: expect.stringMatching(/at least 3 points/) })) + expect(hints.show).not.toHaveBeenCalled() }) test('gates a zero-area shape while drawing', () => { @@ -264,11 +280,21 @@ describe('geometrychange validation', () => { }) test('applies the per-session user validator as a gate', () => { - const { draw, dispatch, eventBus } = setup() + const { draw, dispatch, eventBus, hints } = setup() draw._geometryValidator = () => ({ valid: false, reason: 'too big' }) drawHandler(draw, 'geometrychange')({ feature: squareFeature, phase: 'commit-add', vertexIndex: 3 }) expect(dispatch).toHaveBeenCalledWith({ type: 'SET_GEOMETRY_VALID', payload: false }) expect(eventBus.emit).toHaveBeenCalledWith('draw:geometryinvalid', expect.objectContaining({ reason: 'too big' })) + expect(hints.show).toHaveBeenCalledWith('too big') + }) + + test('a user validator that vetoes with no reason (plain `false`) still emits, but skips the hint', () => { + const { draw, dispatch, eventBus, hints } = setup() + draw._geometryValidator = () => false + drawHandler(draw, 'geometrychange')({ feature: squareFeature, phase: 'commit-add', vertexIndex: 3 }) + expect(dispatch).toHaveBeenCalledWith({ type: 'SET_GEOMETRY_VALID', payload: false }) + expect(eventBus.emit).toHaveBeenCalledWith('draw:geometryinvalid', expect.objectContaining({ reason: null })) + expect(hints.show).not.toHaveBeenCalled() }) test('drives the invalid stroke from committed validity in edit mode only', () => { @@ -307,11 +333,12 @@ describe('geometrychange validation', () => { expect(eventBus.emit).toHaveBeenCalledWith('draw:interfacetypechange', { interfaceType: 'keyboard' }) }) - test('relays a blocked placement to the public bus as draw:geometryinvalid', () => { - const { draw, eventBus } = setup() + test('relays a blocked placement to the public bus as draw:geometryinvalid, and as a hint', () => { + const { draw, eventBus, hints } = setup() const blocked = { phase: 'place', mode: 'draw_polygon', vertexIndex: 2, reason: 'outside region', feature: { type: 'Feature' } } drawHandler(draw, 'placementblocked')(blocked) expect(eventBus.emit).toHaveBeenCalledWith('draw:geometryinvalid', blocked) + expect(hints.show).toHaveBeenCalledWith('outside region') }) test('passes the current mode into the validation context', () => { diff --git a/plugins/draw/src/validation/rules.js b/plugins/draw/src/validation/rules.js index 945d082c4..75ffc8651 100644 --- a/plugins/draw/src/validation/rules.js +++ b/plugins/draw/src/validation/rules.js @@ -143,6 +143,15 @@ export const nonZeroArea = (feature) => { return area > 0 ? { valid: true } : { valid: false, reason: 'Shape must enclose an area' } } +// minVertices' own reason strings, exported so callers (events.js's hint toast) +// can recognise "still being built" as distinct from a genuine rule violation — +// unlike self-intersection/zero-area/a custom onGeometryChange veto, an +// incomplete shape isn't a mistake worth interrupting the user over. +export const MIN_VERTICES_REASONS = { + Polygon: 'Shape needs at least 3 points', + LineString: 'Line needs at least 2 points' +} + /** * A shape needs enough vertices to be finishable (3 for a polygon, 2 for a line). */ @@ -151,12 +160,12 @@ export const minVertices = (feature) => { if (geometry?.type === 'Polygon') { return getRingVertices(geometry).length >= MIN_VERTICES.Polygon ? { valid: true } - : { valid: false, reason: 'Shape needs at least 3 points' } + : { valid: false, reason: MIN_VERTICES_REASONS.Polygon } } if (geometry?.type === 'LineString') { return (geometry.coordinates?.length ?? 0) >= MIN_VERTICES.LineString ? { valid: true } - : { valid: false, reason: 'Line needs at least 2 points' } + : { valid: false, reason: MIN_VERTICES_REASONS.LineString } } return { valid: true } } diff --git a/plugins/draw/src/validation/rules.test.js b/plugins/draw/src/validation/rules.test.js index 95868b7d7..15e2f2a46 100644 --- a/plugins/draw/src/validation/rules.test.js +++ b/plugins/draw/src/validation/rules.test.js @@ -1,4 +1,4 @@ -import { noSelfIntersection, nonZeroArea, minVertices, noPathSelfIntersection, SOFT_RULES, HARD_RULES } from './rules.js' +import { noSelfIntersection, nonZeroArea, minVertices, noPathSelfIntersection, SOFT_RULES, HARD_RULES, MIN_VERTICES_REASONS } from './rules.js' const poly = (coordinates) => ({ type: 'Feature', geometry: { type: 'Polygon', coordinates: [coordinates] } }) const line = (coordinates) => ({ type: 'Feature', geometry: { type: 'LineString', coordinates } }) @@ -52,6 +52,13 @@ describe('minVertices (soft)', () => { expect(minVertices(poly([[0, 0], [1, 0], [1, 1]]))).toEqual({ valid: true }) }) + // events.js's hint toast recognises MIN_VERTICES_REASONS to skip hinting on an + // incomplete (still-being-drawn) shape — these must stay the actual reasons used. + test('uses the exported MIN_VERTICES_REASONS text', () => { + expect(minVertices(poly([[0, 0], [1, 0]])).reason).toBe(MIN_VERTICES_REASONS.Polygon) + expect(minVertices(line([[0, 0]])).reason).toBe(MIN_VERTICES_REASONS.LineString) + }) + test('counts a closed ring by its distinct vertices', () => { expect(minVertices(poly([[0, 0], [1, 0], [1, 1], [0, 0]]))).toEqual({ valid: true }) }) From 7190fd9a8497e05f51eef1ee1b5c8c001594ae95 Mon Sep 17 00:00:00 2001 From: Dan Leech Date: Tue, 4 Aug 2026 17:21:14 +0100 Subject: [PATCH 81/89] onGeometryChange callback event consolidation --- demo/js/draw-ol.js | 4 +- demo/js/draw.js | 12 +- plugins/draw/src/adapterEvents.js | 48 ++++-- .../adapters/maplibre/MaplibreDrawAdapter.js | 41 ++--- .../maplibre/MaplibreDrawAdapter.test.js | 29 ++++ .../src/adapters/openlayers/draw/DrawMode.js | 30 ++-- .../adapters/openlayers/draw/DrawMode.test.js | 31 ++++ plugins/draw/src/validation/liveDrawChecks.js | 120 +++++++++++++ .../src/validation/liveDrawChecks.test.js | 159 ++++++++++++++++++ plugins/draw/src/validation/liveStroke.js | 7 +- .../draw/src/validation/validateGeometry.js | 27 ++- .../src/validation/validateGeometry.test.js | 11 +- 12 files changed, 443 insertions(+), 76 deletions(-) create mode 100644 plugins/draw/src/validation/liveDrawChecks.js create mode 100644 plugins/draw/src/validation/liveDrawChecks.test.js diff --git a/demo/js/draw-ol.js b/demo/js/draw-ol.js index 135eb458d..aaea922f3 100644 --- a/demo/js/draw-ol.js +++ b/demo/js/draw-ol.js @@ -40,10 +40,10 @@ const isEastOfWalesBorder = (geometry) => const drawPlugin = createDrawPlugin({ snapLayers: ['OS/TopographicArea_1/Agricultural Land', 'OS/TopographicLine/Building Outline'], - onGeometryChange: (event) => ({ + onGeometryChange: (event) => { console.log(event.phase); return { valid: isEastOfWalesBorder(event.feature.geometry), reason: 'Points must be placed east of the England/Wales border' - }) + }} }) const interactiveMap = new InteractiveMap('map', { diff --git a/demo/js/draw.js b/demo/js/draw.js index 83fdd32b3..210932eed 100755 --- a/demo/js/draw.js +++ b/demo/js/draw.js @@ -52,14 +52,14 @@ const isEastOfWalesBorder = (geometry) => const drawPlugin = createDrawPlugin({ snapLayers: ['OS/TopographicArea_1/Agricultural Land', 'OS/TopographicLine/Building Outline'], - onGeometryChange: (event) => ({ - valid: isEastOfWalesBorder(event.feature.geometry), - reason: 'Points must be placed east of the England/Wales border' - }) - // onGeometryChange: (event) => { console.log(event); return { + // onGeometryChange: (event) => ({ // valid: isEastOfWalesBorder(event.feature.geometry), // reason: 'Points must be placed east of the England/Wales border' - // }} + // }) + onGeometryChange: (event) => { console.log(event.phase); return { + valid: isEastOfWalesBorder(event.feature.geometry), + reason: 'Points must be placed east of the England/Wales border' + }} }) const datasetsPlugin = createDatasetsPlugin({ diff --git a/plugins/draw/src/adapterEvents.js b/plugins/draw/src/adapterEvents.js index 06291435f..aee336ddc 100644 --- a/plugins/draw/src/adapterEvents.js +++ b/plugins/draw/src/adapterEvents.js @@ -8,8 +8,14 @@ * * 'preview' - live, in-progress feedback (drag / rubber-band); nothing * has committed yet, and nothing can be vetoed at this stage. - * 'place' - a candidate vertex is about to be committed; a HARD_RULES - * failure vetoes it outright and it never appears. + * While drawing, this single check also drives the + * Add-point button (see liveDrawChecks.js) — it isn't only + * a display concern, just never a commit/veto one. + * 'place' - a candidate vertex is genuinely about to be committed + * (a real click/tap/Add-point press), evaluated once, + * synchronously; a HARD_RULES failure vetoes it outright + * and it never appears. Never fires continuously — that's + * 'preview''s job (see above). * 'create' - a whole new feature just finished being drawn. * 'edit-start' - an existing feature was just loaded into an edit session; * nothing has changed yet, this is the baseline check. @@ -24,6 +30,19 @@ * (validateGeometry.js) — `onGeometryChange({ feature, ...context })`. Not every * field is present on every phase; see each field's own doc below. * + * While drawing, every rubber-band mouse-move invokes this callback ONCE, + * throttled to one call per animation frame, always with `phase: 'preview'` — + * even before any vertex is committed, since a location-based rule can be + * meaningful against a single candidate point. Its verdict is combined + * (internally, in liveDrawChecks.js) with two independently-floored built-in + * checks to drive BOTH the dashed stroke / Done gate AND the Add-point button — + * you don't need to do anything to support both; they share this one call. A + * separate, genuinely momentary call with `phase: 'place'` (see + * GeometryChangePhase) fires only for a real placement attempt, evaluated + * synchronously, never continuously. Earlier versions of this plugin called + * the callback twice per mouse-move (once per phase, for the same instant) — + * that's been unified into the single 'preview' call described here. + * * @typedef {object} GeometryChangeEvent * @property {object} feature - GeoJSON feature representing the shape at this * point. Always present. During 'preview' this is the in-progress feature @@ -38,12 +57,12 @@ * absent on 'preview', 'create', and 'edit-start' (those have no single vertex * to point at). * @property {number} [numVertices] - count of already-committed vertices, - * excluding any in-progress rubber-band cursor point. Present only on - * 'preview', and only once at least one vertex has been committed - * (validateDisplayedGeometry skips the callback entirely with zero - * committed vertices, since there's nothing meaningful to validate yet). - * Note this is a lower bar than the built-in live rules, which stay - * skipped until the geometry type's minimum vertex count is reached. + * excluding any in-progress rubber-band cursor point. Present on every + * 'preview' call, with no minimum — including 0, against the very first + * candidate point, before any vertex is committed. Note this is a lower bar + * than the built-in live rules, which stay skipped until the geometry type's + * minimum vertex count is reached (self-intersection/area are meaningless + * below that). */ /** @@ -76,16 +95,21 @@ * Listeners must guard for the shape they expect. * INTERFACE_TYPE_CHANGE { interfaceType: 'mouse' | 'touch' | 'keyboard' } * PLACEMENT_BLOCKED { feature, reason, phase: 'place', mode, vertexIndex } — - * a vertex placement was rejected by validatePlacement - * (hard rule or user callback); feature is the candidate - * geometry that was refused. + * a REAL placement attempt (click/tap/Add-point press) + * was rejected by validatePlacement (hard rule or user + * callback), evaluated once, synchronously; feature is + * the candidate geometry that was refused. Distinct from + * CAN_PLACE_CHANGE below, which is a continuous forecast + * of this same outcome, not an attempt. * VALIDITY_CHANGE { valid, reason } — the live validity of the displayed * shape flipped while editing (drag/nudge in progress). * Drives the Done gate in edit mode, where the displayed * shape is exactly what Done finishes. Fires on flips only. * CAN_PLACE_CHANGE { canPlace, reason } — whether placing a vertex at the * crosshair would be vetoed flipped while drawing. Drives - * the Add-point button. Fires on flips only. + * the Add-point button. Derived from the same 'preview' + * check that drives the dashed stroke (liveDrawChecks.js), + * not a separate attempt — fires on flips only. */ export const ADAPTER_EVENTS = { CREATE: 'create', diff --git a/plugins/draw/src/adapters/maplibre/MaplibreDrawAdapter.js b/plugins/draw/src/adapters/maplibre/MaplibreDrawAdapter.js index f167ee023..9c4559750 100644 --- a/plugins/draw/src/adapters/maplibre/MaplibreDrawAdapter.js +++ b/plugins/draw/src/adapters/maplibre/MaplibreDrawAdapter.js @@ -4,7 +4,7 @@ import { createEventBus } from '../../utils/eventBus.js' import { MAPBOX_DRAW_EVENTS, CUSTOM_DRAW_EVENTS, STYLE_DATA_EVENT } from './drawEvents.js' import { ADAPTER_EVENTS } from '../../adapterEvents.js' import { createLiveStroke } from '../../validation/liveStroke.js' -import { validatePlacement } from '../../validation/validateGeometry.js' +import { createLiveDrawChecks } from '../../validation/liveDrawChecks.js' const polygonFeature = (coordinates) => ({ type: 'Feature', geometry: { type: 'Polygon', coordinates } }) const lineFeature = (coordinates) => ({ type: 'Feature', geometry: { type: 'LineString', coordinates } }) @@ -81,12 +81,16 @@ export class MaplibreDrawAdapter { } }) - // Live Add-point gate: would placing a vertex at the crosshair be vetoed? - // Same throttling as the stroke, but evaluated with the placement (hard) rules - // so the button tracks exactly what a tap would do. - this._livePlacement = createLiveStroke({ - validate: validatePlacement, - onChange: (vetoed, reason) => this._bus.emit(ADAPTER_EVENTS.CAN_PLACE_CHANGE, { canPlace: !vetoed, reason }) + // Draw-mode-only: computes both the stroke/Done verdict and the Add-point + // verdict from a SINGLE throttled call to the user's callback per rubber-band + // move (see liveDrawChecks.js for why), and is flip-guarded itself. The + // stroke verdict still routes through _liveStroke.set() — that instance is + // also driven independently by edit mode and must stay the single source of + // truth for the rendered stroke across mode switches. The Add-point verdict + // has no such cross-mode instance to stay in sync with, so it emits directly. + this._liveDrawChecks = createLiveDrawChecks({ + onStrokeChange: (invalid, reason) => this._liveStroke.set(invalid, reason), + onPlaceChange: (vetoed, reason) => this._bus.emit(ADAPTER_EVENTS.CAN_PLACE_CHANGE, { canPlace: !vetoed, reason }) }) // Normalise ML map events → the shared adapter event contract (adapterEvents.js). @@ -140,7 +144,7 @@ export class MaplibreDrawAdapter { // the live checks own both from here. if (name === 'draw_polygon' || name === 'draw_line') { this._liveStroke.set(false) - this._livePlacement.set(false) + this._liveDrawChecks.reset() } this._draw.changeMode(name, options) // The underlying mapbox-gl-draw control's public changeMode API is silent by @@ -150,23 +154,20 @@ export class MaplibreDrawAdapter { } // Live invalid-stroke driver: called on every rubber-band move (draw) and vertex - // drag / nudge (edit) with the displayed feature. Delegates to the shared - // live-stroke controller, which runs the default rules synchronously and the user - // callback throttled, toggling the dashed stroke only when validity flips. While - // drawing, the same displayed geometry (placed vertices + crosshair candidate) - // also feeds the Add-point placement gate. + // drag / nudge (edit) with the displayed feature. Edit mode has no Add-point + // gate, so it goes straight through the live-stroke controller as before. Draw + // mode routes through _liveDrawChecks instead, which computes both the stroke + // and Add-point verdicts from one throttled user-callback call (see + // liveDrawChecks.js). _updateLiveStroke (e) { if (!e?.coordinates) { return } const mode = this._draw.getMode() const shape = displayedShape(mode, e.coordinates) if (!shape) { return } - this._liveStroke.update({ ...shape, context: { mode }, onGeometryChange: this._geometryValidator }) if (mode === 'draw_polygon' || mode === 'draw_line') { - this._livePlacement.update({ - feature: shape.feature, - context: { mode, vertexIndex: shape.numVertices }, - onGeometryChange: this._geometryValidator - }) + this._liveDrawChecks.update({ feature: shape.feature, numVertices: shape.numVertices, context: { mode }, onGeometryChange: this._geometryValidator }) + } else { + this._liveStroke.update({ ...shape, context: { mode }, onGeometryChange: this._geometryValidator }) } } @@ -349,7 +350,7 @@ export class MaplibreDrawAdapter { this._map.off(MAPBOX_DRAW_EVENTS.MODE_CHANGE, this._mapHandlers.modechange) this._map.off(STYLE_DATA_EVENT, this._mapHandlers.styledata) this._liveStroke.destroy() - this._livePlacement.destroy() + this._liveDrawChecks.destroy() this._cleanupDraw() } } diff --git a/plugins/draw/src/adapters/maplibre/MaplibreDrawAdapter.test.js b/plugins/draw/src/adapters/maplibre/MaplibreDrawAdapter.test.js index b0544ded5..914089d40 100644 --- a/plugins/draw/src/adapters/maplibre/MaplibreDrawAdapter.test.js +++ b/plugins/draw/src/adapters/maplibre/MaplibreDrawAdapter.test.js @@ -237,6 +237,35 @@ describe('live invalid stroke (draw mode)', () => { expect(bus.emit).not.toHaveBeenCalledWith('canplacechange', expect.objectContaining({ canPlace: false })) }) + test('the user callback runs ONCE per frame in draw mode, with phase preview, driving both the stroke and Add point from that single call', () => { + jest.useFakeTimers() + const { map, bus } = drawPolygonSetup() + map._drawGeometryValidator = jest.fn(() => ({ valid: false, reason: 'outside region' })) + const fire = onHandler(map, CUSTOM_DRAW_EVENTS.GEOMETRY_CHANGE) + fire(square) + expect(map._drawGeometryValidator).not.toHaveBeenCalled() // deferred to the frame + jest.runAllTimers() + expect(map._drawGeometryValidator).toHaveBeenCalledTimes(1) // not once per gate + expect(map._drawGeometryValidator).toHaveBeenCalledWith(expect.objectContaining({ phase: 'preview' })) + expect(map.setLayoutProperty).toHaveBeenCalledWith('stroke-active-invalid.hot', 'visibility', 'visible') + expect(bus.emit).toHaveBeenCalledWith('canplacechange', expect.objectContaining({ canPlace: false, reason: 'outside region' })) + jest.useRealTimers() + }) + + test('the user callback runs even before any vertex is placed, gating both the stroke and Add point from the very first candidate point', () => { + jest.useFakeTimers() + const { map, bus } = drawPolygonSetup() + map._drawGeometryValidator = jest.fn(() => ({ valid: false, reason: 'outside region' })) + const fire = onHandler(map, CUSTOM_DRAW_EVENTS.GEOMETRY_CHANGE) + // Zero committed vertices — coordinates is just the rubber-band cursor point. + fire({ type: 'draw.geometrychange', coordinates: [[[5, 5]]] }) + jest.runAllTimers() + expect(map._drawGeometryValidator).toHaveBeenCalledWith(expect.objectContaining({ numVertices: 0, phase: 'preview' })) + expect(bus.emit).toHaveBeenCalledWith('canplacechange', expect.objectContaining({ canPlace: false, reason: 'outside region' })) + expect(map.setLayoutProperty).toHaveBeenCalledWith('stroke-active-invalid.hot', 'visibility', 'visible') + jest.useRealTimers() + }) + test('entering a draw mode resets the stroke to solid', () => { const { adapter, map } = drawPolygonSetup() onHandler(map, CUSTOM_DRAW_EVENTS.GEOMETRY_CHANGE)(bowtie) // dashed diff --git a/plugins/draw/src/adapters/openlayers/draw/DrawMode.js b/plugins/draw/src/adapters/openlayers/draw/DrawMode.js index 48bc3199d..4067e4038 100644 --- a/plugins/draw/src/adapters/openlayers/draw/DrawMode.js +++ b/plugins/draw/src/adapters/openlayers/draw/DrawMode.js @@ -5,9 +5,10 @@ import { getPlacedSketchCoords, getLastPlacedSketchCoord } from '../utils/sketch import { TOLERANCES } from '../defaults.js' import { ADAPTER_EVENTS } from '../../../adapterEvents.js' import { STYLES_CHANGED_EVENT } from '../core/internalEvents.js' -import { attemptPlacement, validatePlacement, MODE_BY_GEOMETRY } from '../../../validation/validateGeometry.js' +import { attemptPlacement, MODE_BY_GEOMETRY } from '../../../validation/validateGeometry.js' import { MIN_VERTICES } from '../../../validation/rules.js' import { createLiveStroke } from '../../../validation/liveStroke.js' +import { createLiveDrawChecks } from '../../../validation/liveDrawChecks.js' const canFinish = (geometryType, sketchFeature) => { if (!sketchFeature) { return false } @@ -133,7 +134,7 @@ const createVertexTracker = (manager, getSketch) => { } // The mode interface consumed by OLDrawManager. -const buildDrawModeApi = ({ map, manager, drawInteraction, input, geometryType, getSketch, updateVertexCount, emitUndoValidation, onStylesChanged, clearSketch, setInvalid, liveStroke, livePlacement }) => ({ +const buildDrawModeApi = ({ map, manager, drawInteraction, input, geometryType, getSketch, updateVertexCount, emitUndoValidation, onStylesChanged, clearSketch, setInvalid, liveStroke, liveDrawChecks }) => ({ done () { if (canFinish(geometryType, getSketch())) { drawInteraction.finishDrawing() } }, @@ -151,7 +152,7 @@ const buildDrawModeApi = ({ map, manager, drawInteraction, input, geometryType, }, destroy () { liveStroke.destroy() - livePlacement.destroy() + liveDrawChecks.destroy() manager.off(STYLES_CHANGED_EVENT, onStylesChanged) // Emit the final interfaceType so crosshair visibility is correct on exit. manager.emit(ADAPTER_EVENTS.INTERFACE_TYPE_CHANGE, { interfaceType: input.getInterfaceType() }) @@ -203,22 +204,23 @@ export const createDrawMode = ({ map, manager, options }) => { currentSketchStyle = manager.styles.createSketchStyle(geometryType, invalid) drawInteraction.overlay_.changed() } - // Drives the live invalid stroke from every sketch change (mouse / touch / keyboard - // rubber-banding): default rules synchronously, user callback throttled. The same - // displayed geometry (placed vertices + crosshair candidate) feeds the Add-point - // placement gate, evaluated with the placement (hard) rules so the button tracks - // exactly what a tap would do. + // liveStroke stays a real createLiveStroke instance (not just a set() sink) + // since edit mode drives its own separate instance the same way — kept here + // for consistency with that pattern, even though draw mode only ever calls + // set() on it (see liveDrawChecks.js, which is flip-guarded itself). There's + // no equivalent cross-mode instance for Add-point, so that verdict emits directly. const liveStroke = createLiveStroke({ onChange: setSketchInvalid }) - const livePlacement = createLiveStroke({ - validate: validatePlacement, - onChange: (vetoed, reason) => manager.emit(ADAPTER_EVENTS.CAN_PLACE_CHANGE, { canPlace: !vetoed, reason }) + // Computes both verdicts from a SINGLE throttled call to the user's callback + // per sketch change (see liveDrawChecks.js for why that matters). + const liveDrawChecks = createLiveDrawChecks({ + onStrokeChange: (nextInvalid, reason) => liveStroke.set(nextInvalid, reason), + onPlaceChange: (vetoed, reason) => manager.emit(ADAPTER_EVENTS.CAN_PLACE_CHANGE, { canPlace: !vetoed, reason }) }) const liveMode = MODE_BY_GEOMETRY[geometryType] const updateLiveValidity = () => { if (!sketchFeature) { return } const { feature, numVertices } = displayedSketch(geometryType, sketchFeature) - liveStroke.update({ feature, context: { mode: liveMode }, numVertices, onGeometryChange: manager._geometryValidator }) - livePlacement.update({ feature, context: { mode: liveMode, vertexIndex: numVertices }, onGeometryChange: manager._geometryValidator }) + liveDrawChecks.update({ feature, context: { mode: liveMode }, numVertices, onGeometryChange: manager._geometryValidator }) } // Update sketch style when map style changes @@ -265,6 +267,6 @@ export const createDrawMode = ({ map, manager, options }) => { // External writes go through the controller so its cache mirrors the style. setInvalid: (next) => liveStroke.set(next), liveStroke, - livePlacement + liveDrawChecks }) } diff --git a/plugins/draw/src/adapters/openlayers/draw/DrawMode.test.js b/plugins/draw/src/adapters/openlayers/draw/DrawMode.test.js index 107fe7b1e..50a3b06df 100644 --- a/plugins/draw/src/adapters/openlayers/draw/DrawMode.test.js +++ b/plugins/draw/src/adapters/openlayers/draw/DrawMode.test.js @@ -210,6 +210,37 @@ describe('drawing lifecycle', () => { expect(emitted().find((e) => e.type === ADAPTER_EVENTS.CAN_PLACE_CHANGE)).toBeUndefined() }) + test('the user callback runs ONCE per sketch change, with phase preview, driving both the stroke and Add point from that single call', () => { + jest.useFakeTimers() + const { manager, emitted, interaction } = setup('Polygon') + manager._geometryValidator = jest.fn(() => ({ valid: false, reason: 'outside region' })) + const sketch = polygonFeature([[0, 0], [5, 5], [0, 0]]) + interaction.dispatchEvent({ type: 'drawstart', feature: sketch }) + sketch.getGeometry().setCoordinates([[[0, 0], [10, 0], [10, 10], [0, 10], [0, 0]]]) + expect(manager._geometryValidator).not.toHaveBeenCalled() // deferred to the frame + jest.runAllTimers() + expect(manager._geometryValidator).toHaveBeenCalledTimes(1) // not once per gate + expect(manager._geometryValidator).toHaveBeenCalledWith(expect.objectContaining({ phase: 'preview' })) + expect(manager.styles.createSketchStyle).toHaveBeenLastCalledWith('Polygon', true) + expect(emitted().find((e) => e.type === ADAPTER_EVENTS.CAN_PLACE_CHANGE)?.payload) + .toEqual(expect.objectContaining({ canPlace: false, reason: 'outside region' })) + jest.useRealTimers() + }) + + test('the user callback runs even before any vertex is placed, gating Add point from the very first candidate point', () => { + jest.useFakeTimers() + const { manager, emitted, interaction } = setup('Polygon') + manager._geometryValidator = jest.fn(() => ({ valid: false, reason: 'outside region' })) + const sketch = polygonFeature([[5, 5], [5, 5]]) // no committed vertices yet — rubber-band + closing dupe only + interaction.dispatchEvent({ type: 'drawstart', feature: sketch }) + sketch.getGeometry().setCoordinates([[[5, 5], [5, 5]]]) + jest.runAllTimers() + expect(manager._geometryValidator).toHaveBeenCalledWith(expect.objectContaining({ numVertices: 0, phase: 'preview' })) + expect(emitted().find((e) => e.type === ADAPTER_EVENTS.CAN_PLACE_CHANGE)?.payload) + .toEqual(expect.objectContaining({ canPlace: false, reason: 'outside region' })) + jest.useRealTimers() + }) + test('setInvalid rebuilds the sketch style in the invalid variant and re-renders', () => { const { mode, manager, interaction } = setup('Polygon') const changed = jest.spyOn(interaction.overlay_, 'changed') diff --git a/plugins/draw/src/validation/liveDrawChecks.js b/plugins/draw/src/validation/liveDrawChecks.js new file mode 100644 index 000000000..17fbac469 --- /dev/null +++ b/plugins/draw/src/validation/liveDrawChecks.js @@ -0,0 +1,120 @@ +import { validateGeometry } from './validateGeometry.js' +import { LIVE_RULES, HARD_RULES, MIN_VERTICES } from './rules.js' +import { requestFrame, cancelFrame } from './liveStroke.js' + +/** + * Live validation driver for DRAW mode (draw_polygon/draw_line) only — edit mode + * has no Add-point gate and keeps using the plain createLiveStroke (liveStroke.js). + * + * On every rubber-band move, a user `onGeometryChange` callback is called ONCE, + * throttled to one call per animation frame (phase 'preview', with NO + * vertex-count floor — a location-based rule is meaningful even against the + * very first candidate point, before any vertex is committed). Its verdict is + * combined with two INDEPENDENTLY-floored built-in rule checks, each flip-guarded + * and reported through its own callback — `onChange(invalid, reason)` fires only + * when THAT gate's own state flips, same contract as createLiveStroke: + * - LIVE_RULES (closed-ring self-intersect/area), floored at the geometry + * type's minimum vertex count — self-intersection/area are meaningless + * below that — reported via onStrokeChange (dashed stroke / Done gate). + * - HARD_RULES (open-path self-intersect), never floored, so it still + * protects placing an invalid first vertex — reported via onPlaceChange + * (Add-point gate). + * Previously each gate ran its own independently-throttled call to the same + * user callback (one forcing phase 'preview', the other forcing phase 'place'), + * firing it twice per rubber-band move for what was really one live moment. + * This unifies that into a single call, whose verdict both gates share. + * + * Being flip-guarded itself (unlike an earlier version of this module), a + * caller only strictly needs createLiveStroke for onStrokeChange — where + * MaplibreDrawAdapter.js/OL DrawMode.js feed it into their existing shared + * `_liveStroke.set()` sink, since that instance is also driven independently + * by edit mode and must stay the single source of truth for the rendered + * stroke. onPlaceChange has no such cross-mode instance to stay in sync with — + * draw mode is its only caller — so it can emit CAN_PLACE_CHANGE directly. + * + * @param {object} params + * @param {(invalid: boolean, reason: string|null) => void} params.onStrokeChange + * @param {(vetoed: boolean, reason: string|null) => void} params.onPlaceChange + * @returns {{ update: Function, reset: Function, destroy: Function }} + */ +export const createLiveDrawChecks = ({ onStrokeChange, onPlaceChange }) => { + // A small flip-guarded gate — same shape as liveStroke.js's own `flip`, one + // per output so the stroke and placement verdicts track independently. + const makeGate = (onChange) => { + let invalid = false + return (next, reason) => { + if (next !== invalid) { + invalid = next + onChange(next, reason ?? null) + } + } + } + const flipStroke = makeGate(onStrokeChange) + const flipPlace = makeGate(onPlaceChange) + + let customInvalid = false + let customReason = null + let frame = null + let pending = null + + const cancelPending = () => { + if (frame != null) { cancelFrame(frame); frame = null } + pending = null + } + + // One built-in rule set, its own floor, and the gate it drives — the stroke + // and placement checks are otherwise identical shape. + const evaluateGate = (feature, context, rules, floor, flip) => { + const numVertices = context.numVertices ?? 0 + const result = numVertices < floor + ? { valid: true } + : validateGeometry(feature, { ...context, phase: 'preview' }, { rules }) + flip(!result.valid || customInvalid, result.valid ? customReason : result.reason) + } + + const recompute = () => { + if (!pending) { return } + const { feature, context } = pending + const min = MIN_VERTICES[feature?.geometry?.type] ?? 0 + evaluateGate(feature, context, LIVE_RULES, min, flipStroke) + evaluateGate(feature, context, HARD_RULES, 0, flipPlace) + } + + const runUserRule = () => { + frame = null + if (!pending) { return } + const { feature, context, onGeometryChange } = pending + const result = validateGeometry(feature, { ...context, phase: 'preview' }, { rules: [], onGeometryChange }) + customInvalid = !result.valid + customReason = result.reason + recompute() + } + + return { + // Re-evaluate the displayed geometry after a rubber-band move. + update ({ feature, context = {}, numVertices, onGeometryChange }) { + pending = { feature, context: { ...context, numVertices }, onGeometryChange } + // Built-in rules first, synchronously — immediate feedback either gate. + recompute() + // A user rule (if any) decides last, throttled to one frame; its cached + // verdict (customInvalid) feeds the next recompute() once it lands. + if (typeof onGeometryChange === 'function' && frame == null) { + frame = requestFrame(runUserRule) + } + }, + // Authoritative reset for starting a fresh draw session: clears cached + // custom validity and drops any pending frame so a previous session's + // verdict can't leak, and re-asserts both gates back to valid — through + // the same flip guards, so it only fires what actually changed. + reset () { + cancelPending() + customInvalid = false + customReason = null + flipStroke(false, null) + flipPlace(false, null) + }, + destroy () { + cancelPending() + } + } +} diff --git a/plugins/draw/src/validation/liveDrawChecks.test.js b/plugins/draw/src/validation/liveDrawChecks.test.js new file mode 100644 index 000000000..e03055b39 --- /dev/null +++ b/plugins/draw/src/validation/liveDrawChecks.test.js @@ -0,0 +1,159 @@ +import { createLiveDrawChecks } from './liveDrawChecks.js' + +const poly = (ring) => ({ type: 'Feature', geometry: { type: 'Polygon', coordinates: [ring] } }) + +// Closed-ring self-intersect only (LIVE_RULES fails, HARD_RULES/open-path passes) — +// only the implicit closing edge crosses. +const closingEdgeCrosses = poly([[0, 0], [2, 0], [0, 2], [2, 2]]) +// Open-path self-intersect (HARD_RULES fails) — also fails LIVE_RULES, since a +// crossing open path implies a crossing closed one too. +const openPathCrosses = poly([[0, 0], [2, 2], [2, 0], [0, 2]]) +const square = poly([[0, 0], [10, 0], [10, 10], [0, 10]]) + +const setup = () => { + const onStrokeChange = jest.fn() + const onPlaceChange = jest.fn() + const checks = createLiveDrawChecks({ onStrokeChange, onPlaceChange }) + return { onStrokeChange, onPlaceChange, checks } +} + +beforeEach(() => jest.useFakeTimers()) +afterEach(() => jest.useRealTimers()) + +describe('built-in rules (synchronous, independently floored, flip-guarded)', () => { + test('LIVE_RULES (closed ring) gates the stroke; HARD_RULES (open path) gates placement — independently', () => { + const { onStrokeChange, onPlaceChange, checks } = setup() + checks.update({ feature: closingEdgeCrosses, numVertices: 3 }) + expect(onStrokeChange).toHaveBeenCalledWith(true, expect.any(String)) // dashed + expect(onPlaceChange).not.toHaveBeenCalled() // still placeable — never flipped from its valid default + }) + + test('an open-path crossing vetoes placement too, since it also crosses closed', () => { + const { onStrokeChange, onPlaceChange, checks } = setup() + checks.update({ feature: openPathCrosses, numVertices: 3 }) + expect(onStrokeChange).toHaveBeenCalledWith(true, expect.any(String)) + expect(onPlaceChange).toHaveBeenCalledWith(true, expect.any(String)) + }) + + test('a valid shape leaves both gates open, and flips them back once they were invalid', () => { + const { onStrokeChange, onPlaceChange, checks } = setup() + checks.update({ feature: openPathCrosses, numVertices: 3 }) // both invalid first + checks.update({ feature: square, numVertices: 3 }) // valid again — should flip back + expect(onStrokeChange).toHaveBeenLastCalledWith(false, null) + expect(onPlaceChange).toHaveBeenLastCalledWith(false, null) + }) + + test('LIVE_RULES stays floored below the minimum vertex count — never flips at all', () => { + const { onStrokeChange, checks } = setup() + checks.update({ feature: closingEdgeCrosses, numVertices: 1 }) + expect(onStrokeChange).not.toHaveBeenCalled() + }) + + test('numVertices defaults to 0 when omitted from context', () => { + const { onStrokeChange, checks } = setup() + checks.update({ feature: closingEdgeCrosses }) + expect(onStrokeChange).not.toHaveBeenCalled() // treated as 0 — below the floor + }) + + test('HARD_RULES is never floored — it must protect placing an invalid first vertex', () => { + const { onPlaceChange, checks } = setup() + // A single point can never self-intersect, so this stays placeable — the + // point is that placement is EVALUATED (not skipped) even at numVertices 0, + // proven by the user-callback tests below where a custom veto DOES apply here. + checks.update({ feature: poly([[0, 0]]), numVertices: 0 }) + expect(onPlaceChange).not.toHaveBeenCalled() // stayed valid — no flip to report + }) +}) + +describe('user callback (throttled, single call drives both gates)', () => { + test('runs ONCE per frame with phase preview, not once per gate', () => { + const { checks } = setup() + const onGeometryChange = jest.fn(() => true) + checks.update({ feature: square, numVertices: 3, onGeometryChange }) + expect(onGeometryChange).not.toHaveBeenCalled() // deferred to the frame + jest.runAllTimers() + expect(onGeometryChange).toHaveBeenCalledTimes(1) + expect(onGeometryChange).toHaveBeenCalledWith(expect.objectContaining({ feature: square, numVertices: 3, phase: 'preview' })) + }) + + test('a veto flips BOTH the stroke and placement gates from the one call', () => { + const { onStrokeChange, onPlaceChange, checks } = setup() + const onGeometryChange = () => ({ valid: false, reason: 'outside region' }) + checks.update({ feature: square, numVertices: 3, onGeometryChange }) + jest.runAllTimers() + expect(onStrokeChange).toHaveBeenLastCalledWith(true, 'outside region') + expect(onPlaceChange).toHaveBeenLastCalledWith(true, 'outside region') + }) + + test('runs even with zero committed vertices — a location-based rule is meaningful against the first candidate point', () => { + const { onPlaceChange, checks } = setup() + const onGeometryChange = jest.fn(() => ({ valid: false, reason: 'outside region' })) + checks.update({ feature: poly([[0, 0]]), numVertices: 0, onGeometryChange }) + jest.runAllTimers() + expect(onGeometryChange).toHaveBeenCalledWith(expect.objectContaining({ numVertices: 0, phase: 'preview' })) + expect(onPlaceChange).toHaveBeenLastCalledWith(true, 'outside region') + }) + + test('a built-in HARD_RULES failure still applies even when the user callback passes', () => { + const { onPlaceChange, checks } = setup() + const onGeometryChange = jest.fn(() => true) + checks.update({ feature: openPathCrosses, numVertices: 3, onGeometryChange }) + jest.runAllTimers() + expect(onPlaceChange).toHaveBeenLastCalledWith(true, expect.any(String)) + }) + + test('runs once per frame using the latest geometry (trailing edge)', () => { + const { checks } = setup() + const onGeometryChange = jest.fn(() => true) + checks.update({ feature: square, numVertices: 3, onGeometryChange }) + checks.update({ feature: square, numVertices: 4, onGeometryChange }) + const latest = poly([[0, 0], [20, 0], [20, 20], [0, 20]]) + checks.update({ feature: latest, numVertices: 5, onGeometryChange }) + jest.runAllTimers() + expect(onGeometryChange).toHaveBeenCalledTimes(1) + expect(onGeometryChange).toHaveBeenCalledWith(expect.objectContaining({ feature: latest, numVertices: 5 })) + }) +}) + +describe('flip guard', () => { + test('each gate fires only when its own state flips, not on every update', () => { + const { onStrokeChange, onPlaceChange, checks } = setup() + checks.update({ feature: openPathCrosses, numVertices: 3 }) + checks.update({ feature: openPathCrosses, numVertices: 3 }) + checks.update({ feature: openPathCrosses, numVertices: 3 }) + expect(onStrokeChange).toHaveBeenCalledTimes(1) + expect(onPlaceChange).toHaveBeenCalledTimes(1) + }) + + test('reset() is a no-op for a gate that was never invalid', () => { + const { onStrokeChange, onPlaceChange, checks } = setup() + checks.update({ feature: square, numVertices: 3 }) // valid — neither gate ever flips + checks.reset() + expect(onStrokeChange).not.toHaveBeenCalled() + expect(onPlaceChange).not.toHaveBeenCalled() + }) +}) + +describe('reset / destroy', () => { + test('reset() clears the cached custom verdict and drops a pending frame', () => { + const { onStrokeChange, checks } = setup() + const onGeometryChange = jest.fn(() => ({ valid: false, reason: 'outside region' })) + checks.update({ feature: square, numVertices: 3, onGeometryChange }) + jest.runAllTimers() + expect(onStrokeChange).toHaveBeenLastCalledWith(true, 'outside region') + + checks.reset() + onGeometryChange.mockClear() + checks.update({ feature: square, numVertices: 3 }) // fresh session, no callback this time + expect(onStrokeChange).toHaveBeenLastCalledWith(false, null) + }) + + test('destroy() cancels a pending user-rule frame', () => { + const { checks } = setup() + const onGeometryChange = jest.fn(() => true) + checks.update({ feature: square, numVertices: 3, onGeometryChange }) + checks.destroy() + jest.runAllTimers() + expect(onGeometryChange).not.toHaveBeenCalled() + }) +}) diff --git a/plugins/draw/src/validation/liveStroke.js b/plugins/draw/src/validation/liveStroke.js index 98627fbd9..715d8822f 100644 --- a/plugins/draw/src/validation/liveStroke.js +++ b/plugins/draw/src/validation/liveStroke.js @@ -1,8 +1,11 @@ import { validateDisplayedGeometry } from './validateGeometry.js' -const requestFrame = (cb) => +// Shared with liveDrawChecks.js — the same one-call-per-frame throttle for a +// user rule of unknown cost, used both for the invalid stroke and (there) the +// combined draw-mode live checks. +export const requestFrame = (cb) => (typeof requestAnimationFrame === 'function' ? requestAnimationFrame(cb) : setTimeout(cb, 16)) -const cancelFrame = (id) => +export const cancelFrame = (id) => (typeof cancelAnimationFrame === 'function' ? cancelAnimationFrame(id) : clearTimeout(id)) /** diff --git a/plugins/draw/src/validation/validateGeometry.js b/plugins/draw/src/validation/validateGeometry.js index dcf6febe1..a697bae46 100644 --- a/plugins/draw/src/validation/validateGeometry.js +++ b/plugins/draw/src/validation/validateGeometry.js @@ -90,19 +90,17 @@ export const attemptPlacement = ({ placed, point, geometryType, onGeometryChange /** * Validate the displayed (in-progress) geometry that drives the live invalid - * stroke: the placed vertices plus the current cursor point. Two different - * thresholds gate the two kinds of check: - * - The built-in live rules (self-intersection/area) are meaningless below - * the geometry type's minimum vertex count — there isn't a real ring/line - * yet — so they're skipped entirely until then. - * - A caller's own `onGeometryChange` can be meaningful on a single point - * (e.g. "is this point inside a region?"), so it runs as soon as at least - * one vertex is committed, rather than waiting for a real shape. - * With zero committed vertices neither check runs — there's nothing to - * validate on the very first mouse-move before any click. This only affects - * the live-stroke preview; the Add-point placement gate (validatePlacement, - * used by attemptPlacement) is a separate check and keeps running from the - * first vertex. + * stroke: the placed vertices plus the current cursor point. The built-in live + * rules (self-intersection/area) are meaningless below the geometry type's + * minimum vertex count — there isn't a real ring/line yet — so they're skipped + * entirely until then; a caller's own `onGeometryChange` has no such floor + * (numVertices defaults to 0 via `context.numVertices ?? 0`, so it still runs). + * + * Used directly by edit mode's live-stroke check (there's no Add-point gate to + * combine with there, so it's a self-contained call). Draw mode does NOT use + * this — see liveDrawChecks.js, which combines this same LIVE_RULES set with a + * separate HARD_RULES-based placement check, both driven from a single shared + * `onGeometryChange` call rather than one call each. * * @param {object} feature - displayed GeoJSON feature (placed vertices + cursor) * @param {object} context - { mode, numVertices, phase }; phase defaults to 'preview' @@ -117,6 +115,5 @@ export const validateDisplayedGeometry = (feature, context = {}, config = {}) => const min = MIN_VERTICES[type] ?? 0 const numVertices = context.numVertices ?? 0 const effectiveRules = numVertices < min ? [] : rules - const effectiveOnGeometryChange = numVertices < 1 ? undefined : onGeometryChange - return validateGeometry(feature, { ...context, phase: context.phase ?? 'preview' }, { rules: effectiveRules, onGeometryChange: effectiveOnGeometryChange }) + return validateGeometry(feature, { ...context, phase: context.phase ?? 'preview' }, { rules: effectiveRules, onGeometryChange }) } diff --git a/plugins/draw/src/validation/validateGeometry.test.js b/plugins/draw/src/validation/validateGeometry.test.js index ebc469178..657b68576 100644 --- a/plugins/draw/src/validation/validateGeometry.test.js +++ b/plugins/draw/src/validation/validateGeometry.test.js @@ -137,15 +137,16 @@ describe('validateDisplayedGeometry edge cases', () => { expect(result).toEqual({ valid: false, reason: 'too few for my rule' }) }) - test('skips both the built-in rules AND the caller\'s onGeometryChange with zero committed vertices — nothing meaningful to validate yet before the first click', () => { + test('skips the built-in rules with zero committed vertices, but still runs the caller\'s onGeometryChange — a location-based rule is meaningful against the very first candidate point', () => { const failingRule = jest.fn(() => ({ valid: false, reason: 'should not run' })) - const onGeometryChange = jest.fn(() => true) + const onGeometryChange = jest.fn(() => ({ valid: false, reason: 'outside region' })) + const feature = poly([[0, 0]]) - const result = validateDisplayedGeometry(poly([[0, 0]]), { numVertices: 0 }, { rules: [failingRule], onGeometryChange }) + const result = validateDisplayedGeometry(feature, { numVertices: 0 }, { rules: [failingRule], onGeometryChange }) expect(failingRule).not.toHaveBeenCalled() - expect(onGeometryChange).not.toHaveBeenCalled() - expect(result).toEqual({ valid: true }) + expect(onGeometryChange).toHaveBeenCalledWith(expect.objectContaining({ feature, numVertices: 0, phase: 'preview' })) + expect(result).toEqual({ valid: false, reason: 'outside region' }) }) test('runs the caller\'s onGeometryChange from the first committed vertex, even while the built-in rules are still skipped', () => { From e0eaaf8ba1ddabd7d23fd621b9872c5f1f4913ac Mon Sep 17 00:00:00 2001 From: Dan Leech Date: Wed, 5 Aug 2026 09:24:37 +0100 Subject: [PATCH 82/89] Import MIN_VERTICES plus minor amends --- .../maplibre/modes/editVertexMode/vertexOperations.js | 7 +++++-- .../draw/src/adapters/maplibre/snap/prototypePatches.js | 6 ++---- .../src/adapters/maplibre/snap/prototypePatches.test.js | 5 ----- plugins/draw/src/adapters/openlayers/edit/vertexOps.js | 5 +++-- plugins/draw/src/api/newLine.js | 3 +++ plugins/draw/src/api/newPolygon.js | 3 +++ plugins/draw/src/api/split.js | 4 ---- 7 files changed, 16 insertions(+), 17 deletions(-) diff --git a/plugins/draw/src/adapters/maplibre/modes/editVertexMode/vertexOperations.js b/plugins/draw/src/adapters/maplibre/modes/editVertexMode/vertexOperations.js index d573997ac..27ad0a1e3 100644 --- a/plugins/draw/src/adapters/maplibre/modes/editVertexMode/vertexOperations.js +++ b/plugins/draw/src/adapters/maplibre/modes/editVertexMode/vertexOperations.js @@ -9,6 +9,7 @@ import { getSnapInstance, isSnapActive, isSnapEnabled, getSnapLngLat, getSnapRadius, triggerSnapAtPoint, clearSnapIndicator } from '../../utils/snapHelpers.js' +import { MIN_VERTICES } from '../../../../validation/rules.js' const ARROW_OFFSETS = { ArrowUp: [0, -1], ArrowDown: [0, 1], ArrowLeft: [-1, 0], ArrowRight: [1, 0] } @@ -174,8 +175,10 @@ export const vertexOperations = { } const { segment } = result - // Minimum vertices per segment: 3 for closed rings (mapbox-gl-draw's internal representation), 2 for lines - const minVertices = segment.closed ? 3 : 2 // NOSONAR, min vertices for closed ring + // Minimum vertices per segment (mapbox-gl-draw's internal representation is + // a closed ring for polygons, an open path for lines) — MIN_VERTICES is the + // single source for this threshold (validation/rules.js). + const minVertices = segment.closed ? MIN_VERTICES.Polygon : MIN_VERTICES.LineString if (segment.length <= minVertices) { return } diff --git a/plugins/draw/src/adapters/maplibre/snap/prototypePatches.js b/plugins/draw/src/adapters/maplibre/snap/prototypePatches.js index fb4597271..5c6e23457 100644 --- a/plugins/draw/src/adapters/maplibre/snap/prototypePatches.js +++ b/plugins/draw/src/adapters/maplibre/snap/prototypePatches.js @@ -47,9 +47,8 @@ function patchGeometryMethods (proto, orig) { return coords.filter(c => Array.isArray(c) && c.length > 0).map(c => lineString(c)) } return orig.getLines.call(this, feature, mouse, radiusArg) - } catch (e) { + } catch { // Invalid geometry - skip this feature - console.log(e) return [] } } @@ -111,9 +110,8 @@ function patchSnapMethod (proto, orig) { this.lines.length = 0 } return result - } catch (err) { + } catch { // Invalid geometry encountered - clear state and continue - console.log(err) this.snapStatus = false this.snapCoords = null return undefined diff --git a/plugins/draw/src/adapters/maplibre/snap/prototypePatches.test.js b/plugins/draw/src/adapters/maplibre/snap/prototypePatches.test.js index fcab15072..109785049 100644 --- a/plugins/draw/src/adapters/maplibre/snap/prototypePatches.test.js +++ b/plugins/draw/src/adapters/maplibre/snap/prototypePatches.test.js @@ -35,11 +35,6 @@ beforeAll(() => { beforeEach(() => { jest.clearAllMocks() - jest.spyOn(console, 'log').mockImplementation(() => {}) -}) - -afterEach(() => { - console.log.mockRestore() }) describe('applyMapboxSnapPatches', () => { diff --git a/plugins/draw/src/adapters/openlayers/edit/vertexOps.js b/plugins/draw/src/adapters/openlayers/edit/vertexOps.js index fde9ea04f..4882f2a65 100644 --- a/plugins/draw/src/adapters/openlayers/edit/vertexOps.js +++ b/plugins/draw/src/adapters/openlayers/edit/vertexOps.js @@ -4,10 +4,11 @@ import { getSegmentForIndex, getModifiableCoords } from '../utils/geometryHelpers.js' +import { MIN_VERTICES } from '../../../validation/rules.js' /** * Delete the vertex at `selectedIndex` from the OL feature's geometry. - * Respects minimum vertex counts (3 for closed rings, 2 for lines). + * Respects minimum vertex counts (MIN_VERTICES — validation/rules.js). * * @returns {{ deletedIndex: number, deletedCoord: number[] } | null} undo payload, or null if not deleted */ @@ -22,7 +23,7 @@ export const deleteVertex = (olFeature, selectedIndex) => { } const { segment } = result - const minVertices = segment.closed ? 3 : 2 // NOSONAR, min vertecies in ring + const minVertices = segment.closed ? MIN_VERTICES.Polygon : MIN_VERTICES.LineString if (segment.length <= minVertices) { return null } diff --git a/plugins/draw/src/api/newLine.js b/plugins/draw/src/api/newLine.js index b8b53ffea..91ddb4e62 100644 --- a/plugins/draw/src/api/newLine.js +++ b/plugins/draw/src/api/newLine.js @@ -1,5 +1,8 @@ import { flattenStyleProperties } from '../utils/flattenStyleProperties.js' +// Near-identical to newPolygon.js, differing only in the 'draw_line'/'draw_polygon' +// mode string — kept as two small, explicit entry points rather than one +// mode-parameterised factory. Revisit if a third geometry type is ever added. export const newLine = ({ appState, appConfig, pluginConfig, pluginState, mapState, mapProvider, services }, featureId, options = {}) => { const { dispatch } = pluginState const { draw } = mapProvider diff --git a/plugins/draw/src/api/newPolygon.js b/plugins/draw/src/api/newPolygon.js index b51219516..055553413 100644 --- a/plugins/draw/src/api/newPolygon.js +++ b/plugins/draw/src/api/newPolygon.js @@ -1,5 +1,8 @@ import { flattenStyleProperties } from '../utils/flattenStyleProperties.js' +// Near-identical to newLine.js, differing only in the 'draw_polygon'/'draw_line' +// mode string — kept as two small, explicit entry points rather than one +// mode-parameterised factory. Revisit if a third geometry type is ever added. export const newPolygon = ({ appState, appConfig, pluginConfig, pluginState, mapState, mapProvider, services }, featureId, options = {}) => { const { dispatch } = pluginState const { draw } = mapProvider diff --git a/plugins/draw/src/api/split.js b/plugins/draw/src/api/split.js index 7f70a3dec..595500189 100644 --- a/plugins/draw/src/api/split.js +++ b/plugins/draw/src/api/split.js @@ -18,8 +18,6 @@ const computeIsValid = (polygonFeature, feature) => { const applySplitPreview = ({ draw, polygonFeature, feature }) => { const isValid = computeIsValid(polygonFeature, feature) draw.setDrawingPreviewProperty('splitter', isValid ? 'valid' : 'invalid') - // DEBUG - console.log('[split] preview', { coords: feature.geometry?.coordinates, isValid }) return isValid } @@ -29,8 +27,6 @@ const applySplitCommit = ({ draw, dispatch, polygonFeature, feature }) => { dispatch({ type: 'SET_ACTION', payload: { name: 'split', isValid } }) dispatch({ type: 'SET_GEOMETRY_VALID', payload: isValid }) draw.setGeometryValid(isValid) - // DEBUG - console.log('[split] commit', { coords: feature.geometry?.coordinates, isValid }) return isValid } From 6b93dca40d25d860b540106a82343282e787cd89 Mon Sep 17 00:00:00 2001 From: Dan Leech Date: Wed, 5 Aug 2026 10:11:25 +0100 Subject: [PATCH 83/89] Menu cross hair fix --- .../maplibre/modes/drawMode/pointerHandlers.js | 6 +++++- .../modes/drawMode/pointerHandlers.test.js | 16 ++++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/plugins/draw/src/adapters/maplibre/modes/drawMode/pointerHandlers.js b/plugins/draw/src/adapters/maplibre/modes/drawMode/pointerHandlers.js index d59e69c3f..fd7137ac1 100644 --- a/plugins/draw/src/adapters/maplibre/modes/drawMode/pointerHandlers.js +++ b/plugins/draw/src/adapters/maplibre/modes/drawMode/pointerHandlers.js @@ -21,8 +21,12 @@ export const createPointerHandlers = ({ ParentMode, getFeature, getCoords }) => // can change mid-session without any touch/pointer/key event ever landing on the // map container, so this can't rely on onTouchStart/onPointerdown alone — refresh // the rubber band immediately rather than waiting for the next incidental 'move'. + // Also fires once on every mode entry (DrawInit.jsx syncs setInterfaceType whenever + // draw_polygon/draw_line starts), so this must only show the crosshair for + // touch/keyboard — same as onSetup/onMove — not unconditionally like touch's own + // onTouchStart/onTouchEnd, or a mouse-driven session would flash it on every entry. onInterfaceTypeChange (state, e) { - this._setInterface(state, e.interfaceType) + this._setInterface(state, e.interfaceType, ['touch', 'keyboard'].includes(e.interfaceType)) this.onMove(state) }, diff --git a/plugins/draw/src/adapters/maplibre/modes/drawMode/pointerHandlers.test.js b/plugins/draw/src/adapters/maplibre/modes/drawMode/pointerHandlers.test.js index a9a06cf56..226bfe132 100644 --- a/plugins/draw/src/adapters/maplibre/modes/drawMode/pointerHandlers.test.js +++ b/plugins/draw/src/adapters/maplibre/modes/drawMode/pointerHandlers.test.js @@ -51,6 +51,22 @@ describe('touch and pointer interface', () => { expect(state.interfaceType).toBe('touch') expect(state.polygon.coordinates[0].at(-1)).toEqual([CENTER.lng, CENTER.lat]) }) + + test('draw.interfacetypechange to touch/keyboard shows the crosshair; to mouse it does not', () => { + const { ctx, marker } = setup(DrawPolygonMode) + ctx.map.fire('draw.interfacetypechange', { interfaceType: 'touch' }) + expect(marker.style.display).toBe('block') + + marker.style.display = 'none' // reset, isolate the next assertion + ctx.map.fire('draw.interfacetypechange', { interfaceType: 'keyboard' }) + expect(marker.style.display).toBe('block') + }) + + test('draw.interfacetypechange to mouse never shows the crosshair — fires on every mode entry regardless of device', () => { + const { ctx, marker } = setup(DrawPolygonMode) + ctx.map.fire('draw.interfacetypechange', { interfaceType: 'mouse' }) + expect(marker.style.display).toBe('none') + }) }) describe('rubber band and snapping while moving', () => { From bef02c5c415da20f9553907302c30af9a8b297c8 Mon Sep 17 00:00:00 2001 From: Dan Leech Date: Wed, 5 Aug 2026 11:20:50 +0100 Subject: [PATCH 84/89] Docusuarus draw demo fixes --- demo/DemoMapDrawTools.js | 4 ++-- demo/DemoMapSelectFeature.js | 2 +- docs/examples/draw-tools.mdx | 4 ---- package-lock.json | 15 +++++++++++++++ package.json | 1 + .../src/adapters/openlayers/core/OLDrawManager.js | 6 +++++- plugins/draw/src/utils/eventBus.js | 9 ++++++++- .../MoveControl/MoveControl.module.scss | 14 ++++++++++++-- 8 files changed, 44 insertions(+), 11 deletions(-) diff --git a/demo/DemoMapDrawTools.js b/demo/DemoMapDrawTools.js index 0e140c869..511b6b711 100644 --- a/demo/DemoMapDrawTools.js +++ b/demo/DemoMapDrawTools.js @@ -29,7 +29,7 @@ function MapInner () { import('../src/index.js'), import('../providers/maplibre/src/index.js'), import('../plugins/interact/src/index.js'), - import('../plugins/beta/draw-ml/src/index.js') + import('../plugins/draw/src/index.js') ]).then(([ { default: InteractiveMap }, { default: maplibreProvider }, @@ -70,7 +70,7 @@ function MapInner () { interactiveMap.addButton('drawTools', { label: 'Draw tools', - mobile: { slot: 'bottom-right' }, + mobile: { slot: 'top-middle' }, tablet: { slot: 'top-middle' }, desktop: { slot: 'top-middle' }, menuItems: [ diff --git a/demo/DemoMapSelectFeature.js b/demo/DemoMapSelectFeature.js index 8aaa00f6e..bf28f3a0e 100644 --- a/demo/DemoMapSelectFeature.js +++ b/demo/DemoMapSelectFeature.js @@ -43,7 +43,7 @@ function MapInner () { import('../src/index.js'), import('../providers/maplibre/src/index.js'), import('../plugins/interact/src/index.js'), - import('../plugins/beta/datasets/src/index.js') + import('../plugins/datasets/src/index.js') ]).then(([ { default: InteractiveMap }, { default: maplibreProvider }, diff --git a/docs/examples/draw-tools.mdx b/docs/examples/draw-tools.mdx index eb62dda14..aa257e506 100644 --- a/docs/examples/draw-tools.mdx +++ b/docs/examples/draw-tools.mdx @@ -5,10 +5,6 @@ import CodeTabs from '../../demo/js/codeTabs.js' Draw, edit, select and delete polygons and lines using the draw and interact plugins. A single "Draw tools" menu button groups all drawing actions together. The menu items update their enabled state based on whether a drawn feature is selected. -:::note -The draw plugin (`draw-ml`) is currently in beta. -::: - :::note This example configures snapping (`snapLayers`) against the base map style's woodland layer, so new vertices placed near a wooded area snap to its boundary. Swap in whichever layer ID suits your own style. ::: diff --git a/package-lock.json b/package-lock.json index 2e3dc1cd0..4069e1fa7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,6 +17,7 @@ "@turf/destination": "^7.3.3", "@turf/helpers": "^7.2.0", "@turf/union": "^7.3.5", + "accessible-autocomplete": "^3.0.1", "govuk-frontend": "^5.13.0", "maplibre-gl": "^5.23.0", "polygon-splitter": "^0.0.11", @@ -11447,6 +11448,20 @@ "node": ">= 0.6" } }, + "node_modules/accessible-autocomplete": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/accessible-autocomplete/-/accessible-autocomplete-3.0.1.tgz", + "integrity": "sha512-xMshgc2LT5addvvfCTGzIkRrvhbOFeylFSnSMfS/PdjvvvElZkakCwxO3/yJYBWyi1hi3tZloqOJQ5kqqJtH4g==", + "license": "MIT", + "peerDependencies": { + "preact": "^8.0.0" + }, + "peerDependenciesMeta": { + "preact": { + "optional": true + } + } + }, "node_modules/acorn": { "version": "8.15.0", "dev": true, diff --git a/package.json b/package.json index 44d4c4f6c..0e032d700 100755 --- a/package.json +++ b/package.json @@ -231,6 +231,7 @@ "@turf/destination": "^7.3.3", "@turf/helpers": "^7.2.0", "@turf/union": "^7.3.5", + "accessible-autocomplete": "^3.0.1", "govuk-frontend": "^5.13.0", "maplibre-gl": "^5.23.0", "polygon-splitter": "^0.0.11", diff --git a/plugins/draw/src/adapters/openlayers/core/OLDrawManager.js b/plugins/draw/src/adapters/openlayers/core/OLDrawManager.js index 3d85c2da4..8410800fc 100644 --- a/plugins/draw/src/adapters/openlayers/core/OLDrawManager.js +++ b/plugins/draw/src/adapters/openlayers/core/OLDrawManager.js @@ -70,7 +70,11 @@ export class OLDrawManager { emit (type, detail) { const handlers = this._listeners.get(type) - if (handlers) { [...handlers].forEach(h => h(detail)) } + // Array.from, not [...handlers] — see the comment in utils/eventBus.js: + // under a loose-mode Babel build (Docusaurus's docs site), spreading a Set + // compiles to [].concat(handlers), which doesn't flatten it — it appends + // the whole Set as one non-function element, and h(...) throws. + if (handlers) { Array.from(handlers).forEach(h => h(detail)) } } // --- Mode machine --- diff --git a/plugins/draw/src/utils/eventBus.js b/plugins/draw/src/utils/eventBus.js index ca0a68e1e..fa17407f1 100644 --- a/plugins/draw/src/utils/eventBus.js +++ b/plugins/draw/src/utils/eventBus.js @@ -22,7 +22,14 @@ export const createEventBus = () => { emit (type, ...args) { const handlers = listeners.get(type) if (handlers) { - [...handlers].forEach(h => h(...args)) + // Array.from, not [...handlers] — under Docusaurus's docs build, Babel + // compiles with `loose: true` (@docusaurus/babel/preset.js), which turns + // spread-of-non-array-iterable into `[].concat(handlers)`. concat() only + // flattens real arrays; for a Set it appends the whole Set as a single + // element, so every handler call becomes `set.apply(...)` and throws + // "h.apply is not a function". Array.from() is a plain function call, so + // it's untouched by that transform and always iterates correctly. + Array.from(handlers).forEach(h => h(...args)) } } } diff --git a/src/App/components/MoveControl/MoveControl.module.scss b/src/App/components/MoveControl/MoveControl.module.scss index efff04827..887b161a5 100644 --- a/src/App/components/MoveControl/MoveControl.module.scss +++ b/src/App/components/MoveControl/MoveControl.module.scss @@ -12,7 +12,7 @@ gap: var(--divider-gap); } -.im-c-move-control--collapsed { +.im-c-move-control.im-c-move-control--collapsed { display: none; } @@ -99,7 +99,17 @@ // aria-expanded rather than aria-pressed — the trigger is correctly a disclosure // button (it reveals the control below), not a toggle button, so it doesn't carry // aria-pressed itself; the colour is purely a visual echo of its expanded state. -.im-c-map-button--move-control[aria-expanded="true"] { +// +// Compound selector (base class + variant class), not the variant class alone — +// same reasoning as the --collapsed rule above. This selector's specificity was +// tied with .im-c-map-button:hover (MapButton.module.scss), both (0,2,0), so +// hovering the expanded trigger button was a coin-flip decided by cascade order — +// one cssnano mergeRules reshuffle in production and hover started winning, +// showing the hover tint instead of the pressed colours. The sibling +// .im-c-move-control .im-c-map-button[aria-pressed="true"] rule above never had +// this problem (it's already (0,3,0), safely ahead of hover); this one needed the +// same headroom. +.im-c-map-button.im-c-map-button--move-control[aria-expanded="true"] { color: var(--pressed-button-foreground-color); border-color: var(--pressed-button-border-color); background-color: var(--pressed-button-background-color); From de3c743a58253186a3b5bbea2a1eee20ac7585fb Mon Sep 17 00:00:00 2001 From: Dan Leech Date: Wed, 5 Aug 2026 12:15:20 +0100 Subject: [PATCH 85/89] Maplibre colour and size config fixes and demos updated --- assets/templates/draw-tools.njk | 15 +- docs/plugins.md | 8 +- docs/plugins/draw.md | 482 ++++++++++++++++++ plugins/draw/src/DrawInit.jsx | 1 + .../adapters/maplibre/MaplibreDrawAdapter.js | 3 +- .../maplibre/MaplibreDrawAdapter.test.js | 30 +- .../draw/src/adapters/maplibre/mapboxDraw.js | 21 +- .../src/adapters/maplibre/mapboxDraw.test.js | 61 ++- .../modes/editVertexMode/touchHandlers.js | 16 +- .../editVertexMode/touchHandlers.test.js | 13 + plugins/draw/src/adapters/maplibre/styles.js | 76 ++- .../draw/src/adapters/maplibre/styles.test.js | 32 ++ .../src/adapters/openlayers/OLDrawAdapter.js | 5 +- .../adapters/openlayers/OLDrawAdapter.test.js | 14 + .../adapters/openlayers/core/OLDrawManager.js | 2 +- .../openlayers => }/utils/resolveColors.js | 9 +- .../utils/resolveColors.test.js | 0 17 files changed, 696 insertions(+), 92 deletions(-) create mode 100644 docs/plugins/draw.md rename plugins/draw/src/{adapters/openlayers => }/utils/resolveColors.js (76%) rename plugins/draw/src/{adapters/openlayers => }/utils/resolveColors.test.js (100%) diff --git a/assets/templates/draw-tools.njk b/assets/templates/draw-tools.njk index aa3da70dd..5e0ae407d 100644 --- a/assets/templates/draw-tools.njk +++ b/assets/templates/draw-tools.njk @@ -75,7 +75,7 @@ interactiveMap.addButton('drawTools', { label: 'Draw tools', - mobile: { slot: 'bottom-right' }, + mobile: { slot: 'top-middle' }, tablet: { slot: 'top-middle' }, desktop: { slot: 'top-middle' }, menuItems: [ @@ -108,17 +108,6 @@ interactPlugin.disable() } }, - { - id: 'splitShape', - label: 'Split shape', - iconSvgContent: '', - isDisabled: true, - onClick: function () { - drawPlugin.split(selectedFeatureIds[0]) - interactiveMap.toggleButtonState('drawTools', 'hidden', true) - interactPlugin.disable() - } - }, { id: 'deleteFeature', label: 'Delete feature', @@ -131,7 +120,6 @@ interactiveMap.toggleButtonState('drawPolygon', 'disabled', false) interactiveMap.toggleButtonState('drawLine', 'disabled', false) interactiveMap.toggleButtonState('editFeature', 'disabled', true) - interactiveMap.toggleButtonState('splitShape', 'disabled', true) interactiveMap.toggleButtonState('deleteFeature', 'disabled', true) } } @@ -168,7 +156,6 @@ interactiveMap.toggleButtonState('drawPolygon', 'disabled', singleFeature) interactiveMap.toggleButtonState('drawLine', 'disabled', singleFeature) interactiveMap.toggleButtonState('editFeature', 'disabled', !isDrawFeature) - interactiveMap.toggleButtonState('splitShape', 'disabled', !isPolygon) interactiveMap.toggleButtonState('deleteFeature', 'disabled', !allDrawFeatures) }) diff --git a/docs/plugins.md b/docs/plugins.md index 872fc04ef..42354bb40 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -32,13 +32,9 @@ The following plugins are in early development. APIs and features may change. Add datasets to your map, configure the display, layer toggling and render a key of symbology. -### Draw for MapLibre +### [Draw](./plugins/draw.md) -Draw lines, polygons and place points using the MapLibre map provider. Includes geometry actions such as split and merge. - -### Draw for ESRI SDK - -Draw polygons using the Esri map provider. +Draw and edit polygon and line features, with snapping and validation; polygons can also be split and merged. Works with both the MapLibre and OpenLayers map providers. ### Frame diff --git a/docs/plugins/draw.md b/docs/plugins/draw.md new file mode 100644 index 000000000..27cefda1c --- /dev/null +++ b/docs/plugins/draw.md @@ -0,0 +1,482 @@ +# Draw Plugin + +The draw plugin lets users draw and edit polygon and line features on the map — placing vertices by click, tap, or keyboard, snapping to existing map layers, and validating geometry as it's built. Polygons can also be split and merged. It works identically with both the MapLibre and OpenLayers map providers, determining the correct adapter to use from the `mapProvider` passed to `InteractiveMap` — there's nothing to configure. + +## ESM usage + +```js +import createDrawPlugin from '@defra/interactive-map/plugins/draw' + +const drawPlugin = createDrawPlugin() + +const interactiveMap = new InteractiveMap('map', { + plugins: [drawPlugin] +}) + +interactiveMap.on('map:ready', () => { + interactiveMap.addButton('drawPolygon', { + label: 'Draw polygon', + onClick: () => drawPlugin.newPolygon(crypto.randomUUID()) + }) +}) +``` + +## UMD usage + +Copy the entire `plugins/draw/dist/umd/` directory to `/your-assets-path/plugins/draw/umd/`. The plugin uses dynamic imports to load MapLibre or OpenLayers support on demand, so all files in the directory must be served from the same location. Then add the script tag: + +```html + +``` + +```js +const drawPlugin = defra.drawPlugin() + +const interactiveMap = new defra.InteractiveMap('map', { + mapProvider: defra.maplibreProvider(), + plugins: [drawPlugin] +}) +``` + +> [!NOTE] +> **GOV.UK Prototype Kit** — skip the copy step. All files are served automatically. Use this path instead: +> ```html +> +> ``` + +## Options + +Options are passed to the factory function when creating the plugin. + +--- + +### `snapLayers` + +**Type:** `string[]` + +Vector tile source-layer names to snap new and edited vertices against. Can be overridden per call — see `newPolygon`, `newLine`, `editFeature`, and `split` below. + +The layer names available depend entirely on your basemap style — there's no universal default, so check your style's vector tile source(s) for the source-layer names to use. The example below (`'OS/TopographicArea_1/Agricultural Land'`) is specific to an Ordnance Survey basemap style. + +When set (globally or per call), a "Snap to feature" toggle appears in the draw menu, letting the user turn snapping on and off during a session. + +```js +createDrawPlugin({ + snapLayers: ['OS/TopographicArea_1/Agricultural Land', 'OS/TopographicLine/Building Outline'] +}) +``` + +--- + +### `onGeometryChange` + +**Type:** `Function` + +Plugin-level validation callback, called throughout the draw/edit lifecycle so you can enforce your own rules (e.g. "shapes must stay inside a boundary") alongside the built-in ones. Can be overridden per call — see [Validation](#validation) below for the full contract, and `newPolygon`, `newLine`, `editFeature` for the per-call override. + +--- + +### `includeModes` + +**Type:** `string[]` + +When set, the plugin only initialises when the app is in one of the specified modes. + +--- + +### `excludeModes` + +**Type:** `string[]` + +When set, the plugin does not initialise when the app is in one of the specified modes. + +--- + +### Colour and size overrides + +> [!NOTE] +> Each colour accepts a plain colour string or a style-keyed object (e.g. `{ light: '#1d70b8', dark: '#ffffff' }`). + +| Property | Type | Description | +|----------|------|-------------| +| `shapeStroke` | `string \| Record` | Default stroke colour for an inactive (not being drawn/edited) shape, when the feature sets no `stroke` of its own | +| `shapeFill` | `string \| Record` | Default fill colour for an inactive shape, when the feature sets no `fill` of its own | +| `strokeWidth` | `number` | Default stroke width in pixels. **Default:** `2` | +| `editStroke` | `string \| Record` | Stroke colour of the shape currently being drawn or edited | +| `editFill` | `string \| Record` | Fill colour of the shape currently being drawn or edited | +| `editVertex` | `string \| Record` | Colour of placed, unselected vertex handles | +| `editMidpoint` | `string \| Record` | Colour of the midpoint handles used to insert a new vertex on an edge | +| `editActive` | `string \| Record` | Colour of the currently selected/active vertex handle | +| `editHalo` | `string \| Record` | Colour of the halo drawn behind vertex/midpoint handles for contrast | +| `invalidStroke` | `string \| Record` | Stroke colour of the dashed outline shown while the shape fails validation | +| `splitValid` | `string \| Record` | Colour of the split line while it would produce a valid split | +| `splitInvalid` | `string \| Record` | Colour of the split line while it would not produce a valid split | +| `snapVertex` | `string \| Record` | Colour of the snap indicator shown over a vertex | +| `snapEdge` | `string \| Record` | Colour of the snap indicator shown over an edge | +| `snapRadius` | `number` | Snap tolerance in pixels. **Default:** `12` | + +```js +createDrawPlugin({ + shapeStroke: { light: '#1d70b8', dark: '#5694ca' }, + editStroke: '#0b0c0c', + strokeWidth: 3, + snapRadius: 16 +}) +``` + +## Validation + +Every geometry change — placing a vertex, dragging one, finishing a shape — is checked against a set of built-in rules before it's accepted, plus your own `onGeometryChange` callback if you provide one. + +### Built-in rules + +| Rule | Applies to | Behaviour | +|------|-----------|-----------| +| Minimum vertices | Polygon (3), Line (2) | Gates the Done button — a shape below the minimum can't be finished | +| Self-intersection | Polygon | Gates the Done button — a self-crossing shape can't be finished | +| Self-intersection (placement) | Polygon | Rejects a vertex placement outright if it would make the drawn path cross itself — the vertex never appears | +| Non-zero area | Polygon | Gates the Done button — a collinear/degenerate ring can't be finished | + +Rule failures gate the Done button (the shape can pass through interim invalid states while being built or reshaped, shown with a dashed outline) except the placement-time self-intersection check, which is a hard veto — that specific vertex is rejected and never placed. + +### `onGeometryChange` callback + +Set at the plugin level (`createDrawPlugin({ onGeometryChange })`) or per call (`newPolygon`/`newLine`/`editFeature`'s `options.onGeometryChange`, which takes precedence when given). Not used by `split` (which validates that the line actually bisects the shape) or `merge`/`addFeature` (no validation). + +**Signature:** `onGeometryChange(event) => boolean | { valid: boolean, reason?: string } | undefined` + +| Return value | Meaning | +|---|---| +| `true` / `undefined` | Valid | +| `false` | Invalid, no reason shown | +| `{ valid, reason }` | Valid or invalid, with an optional reason surfaced as a hint toast (except for placement, which never toasts — see `phase` below) | + +**Event payload:** + +| Property | Type | Description | +|----------|------|-------------| +| `feature` | `GeoJSON.Feature` | The shape at this point — the in-progress feature during `'preview'`, the committed/candidate feature at every other phase | +| `phase` | `string` | See table below | +| `mode` | `'draw_polygon' \| 'draw_line' \| 'edit_vertex'` | The active draw mode | +| `vertexIndex` | `number` | Index of the vertex being placed/added/moved/inserted/deleted. Present on `'place'` and every `'commit-*'` phase | +| `numVertices` | `number` | Count of already-committed vertices, excluding any in-progress cursor point. Present on every `'preview'` call | + +**Phases:** + +| Phase | When | Can veto? | +|-------|------|-----------| +| `'preview'` | Live feedback on every rubber-band move while drawing/dragging, throttled to once per frame. Drives both the dashed-outline check and the Add-point button | No — display only | +| `'place'` | A real click/tap/Add-point press, evaluated once, synchronously | Yes — a hard rule or your callback returning invalid rejects the vertex outright; it never appears | +| `'create'` | A whole new feature just finished being drawn | Gates whether it's accepted as-is or reopened in edit mode | +| `'edit-start'` | An existing feature was just loaded into an edit session — the baseline check before anything has changed | Gates the initial Done state | +| `'commit-add'` | A vertex was added while drawing | Gates Done | +| `'commit-move'` | A vertex was dragged/nudged while editing | Gates Done | +| `'commit-insert'` | A vertex was inserted at a midpoint while editing | Gates Done | +| `'commit-delete'` | A vertex was removed while editing | Gates Done | + +```js +createDrawPlugin({ + onGeometryChange: (event) => ({ + valid: isEastOfWalesBorder(event.feature.geometry), + reason: 'Points must be placed east of the England/Wales border' + }) +}) +``` + +## Methods + +Methods are called on the plugin instance. `newPolygon`, `newLine`, `editFeature`, `addFeature`, `deleteFeature`, `split`, and `merge` all need `mapProvider.draw` to be ready — call them after [`draw:ready`](#drawready). + +--- + +### `newPolygon(featureId, options?)` + +Start drawing a new polygon. + +| Argument | Type | Description | +|----------|------|-------------| +| `featureId` | `string` | **Required.** ID to assign the finished feature | +| `options.snapLayers` | `string[]` | Overrides the plugin-level `snapLayers` for this session | +| `options.onGeometryChange` | `Function` | Overrides the plugin-level `onGeometryChange` for this session — see [Validation](#validation) | +| `options.stroke` | `string \| Record` | Stroke colour for this shape | +| `options.fill` | `string \| Record` | Fill colour for this shape | +| `options.strokeWidth` | `number` | Stroke width in pixels for this shape | +| `options.properties` | `Object` | Custom GeoJSON properties to set on the finished feature | + +```js +drawPlugin.newPolygon(crypto.randomUUID(), { + stroke: '#e6c700', + fill: 'rgba(255, 221, 0, 0.1)' +}) +``` + +--- + +### `newLine(featureId, options?)` + +Start drawing a new line. Same options as `newPolygon`. + +```js +drawPlugin.newLine(crypto.randomUUID(), { + stroke: { outdoor: '#99704a', dark: '#ffffff' }, + strokeWidth: 6 +}) +``` + +--- + +### `editFeature(featureId, options?)` + +Open an existing feature in vertex-edit mode. Returns `false` (without doing anything) if the feature doesn't exist or the plugin isn't ready yet — check the return value before assuming the edit session started. + +| Argument | Type | Description | +|----------|------|-------------| +| `featureId` | `string` | **Required.** ID of the feature to edit | +| `options.snapLayers` | `string[]` | Overrides the plugin-level `snapLayers` for this session | +| `options.onGeometryChange` | `Function` | Overrides the plugin-level `onGeometryChange` for this session | + +```js +const editSuccess = drawPlugin.editFeature(selectedFeatureId) +if (!editSuccess) { + return +} +``` + +--- + +### `addFeature(feature)` + +Add a feature directly to the map without a draw session — e.g. loading in existing shapes on `draw:ready`. + +| Argument | Type | Description | +|----------|------|-------------| +| `feature.id` | `string` | **Required.** Feature ID | +| `feature.geometry` | `GeoJSON.Geometry` | **Required.** `Polygon` or `LineString` geometry | +| `feature.stroke` | `string \| Record` | Stroke colour | +| `feature.fill` | `string \| Record` | Fill colour | +| `feature.strokeWidth` | `number` | Stroke width in pixels | +| `feature.properties` | `Object` | Custom GeoJSON properties | + +```js +interactiveMap.on('draw:ready', () => { + drawPlugin.addFeature({ + id: 'test1234', + type: 'Feature', + geometry: { type: 'Polygon', coordinates: [[[-2.879, 54.709], [-2.877, 54.708], [-2.875, 54.708], [-2.879, 54.709]]] }, + stroke: 'rgba(0,112,60,1)', + fill: 'rgba(0,112,60,0.2)', + strokeWidth: 2 + }) +}) +``` + +--- + +### `deleteFeature(featureIds)` + +Remove one or more features from the map. + +| Argument | Type | Description | +|----------|------|-------------| +| `featureIds` | `string[]` | IDs of the features to remove | + +```js +drawPlugin.deleteFeature(['test1234']) +``` + +--- + +### `split(featureId, options?)` + +Start drawing a line across a polygon to split it in two. Splitting is a pure computation — it does not remove the original feature or add the results itself. Listen for [`draw:split`](#drawsplit) and call `deleteFeature`/`addFeature` yourself. + +Always snaps to the polygon's own outline, in addition to any layers in `snapLayers`. + +| Argument | Type | Description | +|----------|------|-------------| +| `featureId` | `string` | **Required.** ID of the polygon to split | +| `options.snapLayers` | `string[]` | Additional layers to snap against, on top of the polygon's own outline | + +```js +drawPlugin.split(selectedFeatureId) + +interactiveMap.on('draw:split', (e) => { + drawPlugin.deleteFeature([e.originalFeatureId]) + e.featureCollection.features.forEach((feature, index) => { + drawPlugin.addFeature({ + id: `${e.originalFeatureId}-${index === 0 ? 'a' : 'b'}`, + type: feature.type, + geometry: feature.geometry, + properties: feature.properties + }) + }) +}) +``` + +--- + +### `merge(featureIds)` + +Merge multiple contiguous polygons into one. Like `split`, this is a pure computation — it does not touch the map. Listen for the return value or [`draw:merge`](#drawmerge) and call `deleteFeature`/`addFeature` yourself. + +| Argument | Type | Description | +|----------|------|-------------| +| `featureIds` | `string[]` | IDs of the polygons to merge | + +**Returns:** the merged GeoJSON feature, or `null` if the merge failed (e.g. the polygons aren't actually contiguous). + +```js +drawPlugin.merge(selectedFeatureIds) + +interactiveMap.on('draw:merge', (e) => { + drawPlugin.deleteFeature(e.originalFeatureIds) + drawPlugin.addFeature({ + id: e.originalFeatureIds[0], + type: e.feature.type, + geometry: e.feature.geometry, + properties: e.feature.properties + }) +}) +``` + +## Buttons and keyboard shortcuts + +The plugin registers its own toolbar buttons automatically — Cancel, Add point (touch only), Done, and a Draw actions menu (Undo, Snap to feature, Delete point) — which show and enable themselves based on the current draw/edit state. You don't need to render these yourself; augment them with your own trigger buttons (e.g. "Draw polygon", "Draw line") the way the [Draw tools example](../examples/draw-tools.mdx) does. + +| Shortcut | Action | +|----------|--------| +| Enter | Add point (draw) | +| Spacebar | Select nearest point (edit) | +| Alt + /// | Select adjacent point (edit) | +| /// | Move point (edit) | +| Shift + /// | Nudge point, fine step (edit) | +| Delete | Delete point (edit) | +| Command/Ctrl + Z | Undo | + +A selected edit vertex also claims the map's [`enableMoveControl`](../api.md#enablemovecontrol) D-pad, if enabled, so it can be nudged with the on-screen directional buttons as well as the keyboard. + +## Events + +Subscribe to events using `interactiveMap.on()`. + +--- + +### `draw:ready` + +Emitted once the draw plugin has initialised. Safe to call API methods from here. + +**Payload:** None + +--- + +### `draw:started` + +Emitted when `newPolygon` or `newLine` starts a new draw session. + +**Payload:** `{ mode: 'draw_polygon' | 'draw_line' }` + +--- + +### `draw:editstart` + +Emitted when `editFeature` opens an existing feature for editing. + +**Payload:** `{ mode: 'edit_polygon' | 'edit_line' }` + +--- + +### `draw:created` + +Emitted when a new shape finishes drawing and passes validation. Also fires for a shape that finished invalid, was automatically reopened in edit mode, and was then fixed and finished — from the caller's perspective it's still a creation, not an edit. + +**Payload:** the finished `GeoJSON.Feature` + +--- + +### `draw:edited` + +Emitted when an existing feature finishes an edit session (via `editFeature`). + +**Payload:** the edited `GeoJSON.Feature` + +--- + +### `draw:cancelled` + +Emitted when the Cancel button is pressed during a draw or edit session. + +**Payload:** the feature being drawn/edited at the time of cancellation + +--- + +### `draw:updated` + +Emitted after a committed vertex operation (add/move/insert/delete) while editing. + +**Payload:** the updated `GeoJSON.Feature` + +--- + +### `draw:vertexselection` + +Emitted when the selected vertex changes in edit mode. + +**Payload:** `{ index: number, numVertices: number }` — `index` is `-1` when nothing is selected + +--- + +### `draw:interfacetypechange` + +Emitted when the input device changes mid-session (e.g. switching from mouse to touch and panning via the Move control) — sync your own `interfaceType` state from this if you're tracking it independently. + +**Payload:** `{ interfaceType: 'mouse' | 'touch' | 'keyboard' }` + +--- + +### `draw:geometryinvalid` + +Emitted whenever a validation check — a built-in rule or your `onGeometryChange` callback — fails, alongside the same hint toast shown to the user (skipped only for an in-progress shape that simply hasn't reached its minimum vertex count yet). See [Validation](#validation). + +**Payload:** `{ feature, reason, phase, mode, vertexIndex? }` + +--- + +### `draw:add` + +Emitted after `addFeature` adds a feature to the map. + +**Payload:** the added `GeoJSON.Feature` + +--- + +### `draw:delete` + +Emitted after `deleteFeature` removes a feature. + +**Payload:** `{ featureId: string }` + +--- + +### `draw:split` + +Emitted when `split` computes a successful split. + +**Payload:** `{ originalFeatureId: string, featureCollection: GeoJSON.FeatureCollection }` — the two resulting polygons + +--- + +### `draw:merge` + +Emitted when `merge` computes a successful merge. + +**Payload:** `{ originalFeatureIds: string[], feature: GeoJSON.Feature }` — the single merged polygon + +```js +interactiveMap.on('draw:created', (feature) => { + console.log('New feature drawn:', feature.id) +}) + +interactiveMap.on('draw:geometryinvalid', ({ reason }) => { + console.log('Validation failed:', reason) +}) +``` diff --git a/plugins/draw/src/DrawInit.jsx b/plugins/draw/src/DrawInit.jsx index 5abf3968a..9ce2d4479 100644 --- a/plugins/draw/src/DrawInit.jsx +++ b/plugins/draw/src/DrawInit.jsx @@ -21,6 +21,7 @@ export const DrawInit = ({ appState, appConfig, mapState, pluginConfig, pluginSt loadDrawAdapter(mapProvider, { mapStyle: mapState.mapStyle, snapLayers: pluginConfig.snapLayers, + pluginConfig, events: EVENTS, eventBus }).then(adapter => { diff --git a/plugins/draw/src/adapters/maplibre/MaplibreDrawAdapter.js b/plugins/draw/src/adapters/maplibre/MaplibreDrawAdapter.js index 9c4559750..dbe15416f 100644 --- a/plugins/draw/src/adapters/maplibre/MaplibreDrawAdapter.js +++ b/plugins/draw/src/adapters/maplibre/MaplibreDrawAdapter.js @@ -60,7 +60,8 @@ export class MaplibreDrawAdapter { mapProvider, events: options.events, eventBus: options.eventBus, - snapLayers: options.snapLayers + snapLayers: options.snapLayers, + pluginConfig: options.pluginConfig ?? {} }) this._draw = draw diff --git a/plugins/draw/src/adapters/maplibre/MaplibreDrawAdapter.test.js b/plugins/draw/src/adapters/maplibre/MaplibreDrawAdapter.test.js index 914089d40..0eb0c2dbc 100644 --- a/plugins/draw/src/adapters/maplibre/MaplibreDrawAdapter.test.js +++ b/plugins/draw/src/adapters/maplibre/MaplibreDrawAdapter.test.js @@ -67,10 +67,38 @@ describe('construction', () => { mapProvider: expect.any(Object), events: options.events, eventBus: options.eventBus, - snapLayers: ['layer-a'] + snapLayers: ['layer-a'], + pluginConfig: {} }) }) + test('forwards a provided pluginConfig through to createMapboxDraw', () => { + const map = { + on: jest.fn(), + off: jest.fn(), + fire: jest.fn(), + getLayer: jest.fn(() => null), + setLayoutProperty: jest.fn(), + getStyle: jest.fn(() => ({ layers: [] })), + moveLayer: jest.fn() + } + const mapProvider = { map, undoStack: { clear: jest.fn() }, snapEnabled: false } + createMapboxDraw.mockReturnValue({ draw: { changeMode: jest.fn(), getMode: jest.fn() }, remove: jest.fn() }) + createEventBus.mockReturnValue({ on: jest.fn(), off: jest.fn(), emit: jest.fn() }) + + const pluginConfig = { shapeStroke: '#custom' } + // eslint-disable-next-line no-new + new MaplibreDrawAdapter(mapProvider, { + mapStyle: 'light', + events: { MAP_SET_STYLE: 'mss' }, + eventBus: { on: jest.fn() }, + snapLayers: ['layer-a'], + pluginConfig + }) + + expect(createMapboxDraw).toHaveBeenCalledWith(expect.objectContaining({ pluginConfig })) + }) + test('subscribes to every MapLibre draw event', () => { const { map } = setup() const subscribed = map.on.mock.calls.map(([name]) => name) diff --git a/plugins/draw/src/adapters/maplibre/mapboxDraw.js b/plugins/draw/src/adapters/maplibre/mapboxDraw.js index 423b7d1e8..e80decfd6 100755 --- a/plugins/draw/src/adapters/maplibre/mapboxDraw.js +++ b/plugins/draw/src/adapters/maplibre/mapboxDraw.js @@ -8,6 +8,7 @@ import { initMapLibreSnap } from './mapboxSnap.js' import { createUndoStack } from '../../utils/undoStack.js' import { setupTouchClickWorkaround } from './utils/touchClickWorkaround.js' import { applyTouchVertexColors } from './modes/editVertexMode/touchHandlers.js' +import { resolveColors } from '../../utils/resolveColors.js' import { TOLERANCES, MAP_SIZE_SCALES } from './defaults.js' /** @@ -23,9 +24,10 @@ import { TOLERANCES, MAP_SIZE_SCALES } from './defaults.js' * @param {string} options.mapStyle - Map style object * @param {Object} options.mapProvider - Object containing the map instance * @param {Object} options.eventBus - Event bus for app-level events + * @param {Object} [options.pluginConfig] - Plugin-level colour/size overrides — see resolveColors() * @returns {{ draw: MapboxDraw, remove: Function }} draw instance and cleanup function */ -export const createMapboxDraw = ({ mapStyle, mapProvider, events, eventBus, snapLayers }) => { +export const createMapboxDraw = ({ mapStyle, mapProvider, events, eventBus, snapLayers, pluginConfig = {} }) => { const { map } = mapProvider // --- Configure MapLibre GL Draw CSS classes --- @@ -50,7 +52,7 @@ export const createMapboxDraw = ({ mapStyle, mapProvider, events, eventBus, snap } else { draw = new MapboxDraw({ modes, - styles: createDrawStyles(mapStyle), + styles: createDrawStyles(mapStyle, pluginConfig), displayControlsDefault: false, userProperties: true, defaultMode: 'disabled' @@ -65,6 +67,11 @@ export const createMapboxDraw = ({ mapStyle, mapProvider, events, eventBus, snap // We need a reference to this mapProvider.draw = draw map._drawCurrentMapStyle = mapStyle + // Stashed on the map (alongside _drawCurrentMapStyle) so mode code that only + // has `this.map` — e.g. touchHandlers.js's addTouchVertexTarget — can still + // resolve colour overrides without pluginConfig being threaded through every + // mode's option object. + map._drawPluginConfig = pluginConfig // Initialize snap as disabled (matches initialState.snap = false) mapProvider.snapEnabled = false // Initialize undo stack (reuse if already exists) @@ -77,19 +84,21 @@ export const createMapboxDraw = ({ mapStyle, mapProvider, events, eventBus, snap // --- Initialize MapboxSnap using external module --- // Start with status: false to match initial snap disabled state + const snapColors = resolveColors(mapStyle, pluginConfig) initMapLibreSnap(map, draw, { layers: snapLayers, - radius: TOLERANCES.snapRadius, - rules: ['vertex', 'edge'] + radius: pluginConfig.snapRadius ?? TOLERANCES.snapRadius, + rules: ['vertex', 'edge'], + colors: { vertex: snapColors.snapVertex, edge: snapColors.snapEdge } }) // --- Update colour scheme --- const handleSetMapStyle = (e) => { map._drawCurrentMapStyle = e map.once('idle', () => { - updateDrawStyles(map, e) + updateDrawStyles(map, e, pluginConfig) const svg = map._drawEditContainer?.querySelector('[data-im-draw-touch-target]') - applyTouchVertexColors(svg, e) + applyTouchVertexColors(svg, e, pluginConfig) }) } eventBus.on(events.MAP_SET_STYLE, handleSetMapStyle) diff --git a/plugins/draw/src/adapters/maplibre/mapboxDraw.test.js b/plugins/draw/src/adapters/maplibre/mapboxDraw.test.js index 81cc25ad3..3c8ec52f3 100644 --- a/plugins/draw/src/adapters/maplibre/mapboxDraw.test.js +++ b/plugins/draw/src/adapters/maplibre/mapboxDraw.test.js @@ -4,6 +4,7 @@ import { initMapLibreSnap } from './mapboxSnap.js' import { createUndoStack } from '../../utils/undoStack.js' import { setupTouchClickWorkaround } from './utils/touchClickWorkaround.js' import { applyTouchVertexColors } from './modes/editVertexMode/touchHandlers.js' +import { resolveColors } from '../../utils/resolveColors.js' import { TOLERANCES, MAP_SIZE_SCALES } from './defaults.js' import { createMapboxDraw } from './mapboxDraw.js' @@ -29,6 +30,9 @@ jest.mock('./mapboxSnap.js', () => ({ initMapLibreSnap: jest.fn() })) jest.mock('../../utils/undoStack.js', () => ({ createUndoStack: jest.fn(() => ({ id: 'undo-stack' })) })) jest.mock('./utils/touchClickWorkaround.js', () => ({ setupTouchClickWorkaround: jest.fn() })) jest.mock('./modes/editVertexMode/touchHandlers.js', () => ({ applyTouchVertexColors: jest.fn() })) +jest.mock('../../utils/resolveColors.js', () => ({ + resolveColors: jest.fn(() => ({ snapVertex: 'resolved-vertex', snapEdge: 'resolved-edge' })) +})) jest.mock('./defaults.js', () => ({ TOLERANCES: { snapRadius: 12 }, MAP_SIZE_SCALES: { medium: 1.5 } @@ -47,7 +51,7 @@ const createMap = () => ({ fire: jest.fn() }) -const setup = ({ existingDraw, existingUndoStack } = {}) => { +const setup = ({ existingDraw, existingUndoStack, pluginConfig } = {}) => { const map = createMap() const mapProvider = { map, @@ -63,7 +67,8 @@ const setup = ({ existingDraw, existingUndoStack } = {}) => { mapProvider, events: EVENTS, eventBus, - snapLayers: ['layer-a'] + snapLayers: ['layer-a'], + ...(pluginConfig !== undefined ? { pluginConfig } : {}) }) return { map, mapProvider, eventBus, removeWorkaround, result } @@ -101,7 +106,7 @@ describe('createMapboxDraw – instance creation', () => { draw_polygon: { id: 'draw_polygon' }, draw_line: { id: 'draw_line' } }) - expect(createDrawStyles).toHaveBeenCalledWith('light') + expect(createDrawStyles).toHaveBeenCalledWith('light', {}) const draw = MapboxDraw.mock.instances[0] expect(map.addControl).toHaveBeenCalledWith(draw) @@ -134,6 +139,19 @@ describe('createMapboxDraw – setup side effects', () => { expect(mapProvider.snapEnabled).toBe(false) }) + test('stashes pluginConfig on the map for mode code that only has `this.map`', () => { + const pluginConfig = { shapeStroke: '#custom' } + const { map } = setup({ pluginConfig }) + + expect(map._drawPluginConfig).toBe(pluginConfig) + }) + + test('defaults pluginConfig to {} when not provided', () => { + const { map } = setup() + + expect(map._drawPluginConfig).toEqual({}) + }) + test('creates an undo stack when none exists and wires it to the map', () => { const { map, mapProvider } = setup() @@ -155,15 +173,31 @@ describe('createMapboxDraw – setup side effects', () => { expect(map._undoStack).toBe(existingUndoStack) }) - test('initializes snapping with the configured radius and rules', () => { + test('initializes snapping with the default radius, rules, and resolved colours', () => { const { map, mapProvider } = setup() expect(initMapLibreSnap).toHaveBeenCalledWith(map, mapProvider.draw, { layers: ['layer-a'], radius: TOLERANCES.snapRadius, - rules: ['vertex', 'edge'] + rules: ['vertex', 'edge'], + colors: { vertex: 'resolved-vertex', edge: 'resolved-edge' } }) }) + + test('a pluginConfig.snapRadius overrides the default snap radius', () => { + const { map, mapProvider } = setup({ pluginConfig: { snapRadius: 20 } }) + + expect(initMapLibreSnap).toHaveBeenCalledWith(map, mapProvider.draw, expect.objectContaining({ + radius: 20 + })) + }) + + test('resolves snap colours from mapStyle + pluginConfig', () => { + const pluginConfig = { snapVertex: '#custom' } + setup({ pluginConfig }) + + expect(resolveColors).toHaveBeenCalledWith('light', pluginConfig) + }) }) describe('createMapboxDraw – event handlers', () => { @@ -178,9 +212,9 @@ describe('createMapboxDraw – event handlers', () => { const idleCallback = handlerFor(map.once, 'idle') idleCallback() - expect(updateDrawStyles).toHaveBeenCalledWith(map, 'dark') + expect(updateDrawStyles).toHaveBeenCalledWith(map, 'dark', {}) expect(map._drawEditContainer.querySelector).toHaveBeenCalledWith('[data-im-draw-touch-target]') - expect(applyTouchVertexColors).toHaveBeenCalledWith('svg-el', 'dark') + expect(applyTouchVertexColors).toHaveBeenCalledWith('svg-el', 'dark', {}) }) test('MAP_SET_STYLE idle handler tolerates a missing edit container', () => { @@ -189,7 +223,18 @@ describe('createMapboxDraw – event handlers', () => { handlerFor(eventBus.on, EVENTS.MAP_SET_STYLE)('dark') handlerFor(map.once, 'idle')() - expect(applyTouchVertexColors).toHaveBeenCalledWith(undefined, 'dark') + expect(applyTouchVertexColors).toHaveBeenCalledWith(undefined, 'dark', {}) + }) + + test('MAP_SET_STYLE passes pluginConfig through to restyling and touch colours', () => { + const pluginConfig = { editStroke: '#custom' } + const { map, eventBus } = setup({ pluginConfig }) + + handlerFor(eventBus.on, EVENTS.MAP_SET_STYLE)('dark') + handlerFor(map.once, 'idle')() + + expect(updateDrawStyles).toHaveBeenCalledWith(map, 'dark', pluginConfig) + expect(applyTouchVertexColors).toHaveBeenCalledWith(undefined, 'dark', pluginConfig) }) test('draw.interfacetypechange is not handled here — the adapter normalises it onto the bus', () => { diff --git a/plugins/draw/src/adapters/maplibre/modes/editVertexMode/touchHandlers.js b/plugins/draw/src/adapters/maplibre/modes/editVertexMode/touchHandlers.js index 325e2f3a6..ed088d2c8 100644 --- a/plugins/draw/src/adapters/maplibre/modes/editVertexMode/touchHandlers.js +++ b/plugins/draw/src/adapters/maplibre/modes/editVertexMode/touchHandlers.js @@ -5,24 +5,18 @@ import { import { coordPathToFlatIndex } from './geometryHelpers.js' import { isOnSVG } from './helpers.js' import { createTouchTarget, applyTouchTargetColors } from '../../../../utils/touchTarget.js' -import { COLORS } from '../../defaults.js' -import { getValueForStyle } from '../../../../utils/getValueForStyle.js' +import { resolveColors } from '../../../../utils/resolveColors.js' -export const applyTouchVertexColors = (el, mapStyle) => { +export const applyTouchVertexColors = (el, mapStyle, pluginConfig = {}) => { if (!el) { return } - const scheme = mapStyle?.mapColorScheme ?? 'light' - const colors = { - editActive: getValueForStyle(COLORS.editActive, scheme), - editHalo: getValueForStyle(COLORS.editHalo, scheme), - editVertex: getValueForStyle(COLORS.editVertex, scheme) - } - applyTouchTargetColors(el, colors) + const { editActive, editHalo, editVertex } = resolveColors(mapStyle, pluginConfig) + applyTouchTargetColors(el, { editActive, editHalo, editVertex }) } export const touchHandlers = { addTouchVertexTarget (state) { state.touchVertexTarget = createTouchTarget(state.container) - applyTouchVertexColors(state.touchVertexTarget, this.map._drawCurrentMapStyle) + applyTouchVertexColors(state.touchVertexTarget, this.map._drawCurrentMapStyle, this.map._drawPluginConfig) }, updateTouchVertexTarget (state, point) { diff --git a/plugins/draw/src/adapters/maplibre/modes/editVertexMode/touchHandlers.test.js b/plugins/draw/src/adapters/maplibre/modes/editVertexMode/touchHandlers.test.js index 659016810..edc246665 100644 --- a/plugins/draw/src/adapters/maplibre/modes/editVertexMode/touchHandlers.test.js +++ b/plugins/draw/src/adapters/maplibre/modes/editVertexMode/touchHandlers.test.js @@ -8,6 +8,19 @@ describe('touchHandlers', () => { expect(() => applyTouchVertexColors(state.touchVertexTarget, null)).not.toThrow() }) + test('applyTouchVertexColors applies a pluginConfig override to the CSS custom properties', () => { + const el = document.createElement('div') + applyTouchVertexColors(el, { mapColorScheme: 'light' }, { editVertex: '#custom' }) + expect(el.style.getPropertyValue('--draw-primary')).toBe('#custom') + }) + + test('addTouchVertexTarget resolves colours from map._drawPluginConfig', () => { + const { ctx, state, map } = createHarness() + map._drawPluginConfig = { editVertex: '#custom' } + ctx.addTouchVertexTarget(state) + expect(state.touchVertexTarget.style.getPropertyValue('--draw-primary')).toBe('#custom') + }) + test('updateTouchVertexTarget shows the target for a touch selection and hides it otherwise', () => { const { ctx, state } = createHarness() state.interfaceType = 'touch' diff --git a/plugins/draw/src/adapters/maplibre/styles.js b/plugins/draw/src/adapters/maplibre/styles.js index b07ae8a74..f512bccc5 100755 --- a/plugins/draw/src/adapters/maplibre/styles.js +++ b/plugins/draw/src/adapters/maplibre/styles.js @@ -1,32 +1,34 @@ // styles.js -import { COLORS, SIZES } from './defaults.js' -import { getValueForStyle } from '../../utils/getValueForStyle.js' - -const getColorScheme = (mapStyle) => mapStyle.mapColorScheme ?? 'light' - -const getUserProp = (mapStyle, prop, defaultsKey) => [ +import { SIZES } from './defaults.js' +import { resolveColors } from '../../utils/resolveColors.js' + +// `defaultValue` is the already-resolved fallback (colors[defaultsKey] — the +// plugin-config override if one was set, else the built-in default), not the +// raw COLORS constant, so a shapeStroke/shapeFill override applies to every +// feature that doesn't set its own stroke/fill property. +const getUserProp = (mapStyle, prop, defaultValue) => [ 'coalesce', ['get', `user_${prop}${mapStyle.id.charAt(0).toUpperCase() + mapStyle.id.slice(1)}`], ['get', `user_${prop}`], - COLORS[defaultsKey] + defaultValue ] // Inactive lines and fills -const fillInactive = (mapStyle) => ({ +const fillInactive = (mapStyle, colors) => ({ id: 'fill-inactive', type: 'fill', filter: ['all', ['==', '$type', 'Polygon'], ['==', 'active', 'false']], - paint: { 'fill-color': getUserProp(mapStyle, 'fill', 'shapeFill') } + paint: { 'fill-color': getUserProp(mapStyle, 'fill', colors.shapeFill) } }) -const strokeInactive = (mapStyle) => ({ +const strokeInactive = (mapStyle, colors) => ({ id: 'stroke-inactive', type: 'line', filter: ['all', ['any', ['==', '$type', 'Polygon'], ['==', '$type', 'LineString']], ['==', 'active', 'false'], ['!has', 'user_splitter']], layout: { 'line-cap': 'round', 'line-join': 'round' }, paint: { - 'line-color': getUserProp(mapStyle, 'stroke', 'shapeStroke'), - 'line-width': SIZES.strokeWidth + 'line-color': getUserProp(mapStyle, 'stroke', colors.shapeStroke), + 'line-width': colors.strokeWidth } }) @@ -155,35 +157,29 @@ const touchVertexIndicator = () => ({ paint: { 'circle-radius': 30, 'circle-color': '#3bb2d0', 'circle-stroke-width': 3, 'circle-stroke-color': '#ffffff', 'circle-opacity': 0.9 } }) -const createDrawStyles = (mapStyle) => { - const scheme = getColorScheme(mapStyle) - const editStrokeColor = getValueForStyle(COLORS.editStroke, scheme) - const editFillColor = getValueForStyle(COLORS.editFill, scheme) - const editVertexColor = getValueForStyle(COLORS.editVertex, scheme) - const editMidpointColor = getValueForStyle(COLORS.editMidpoint, scheme) - const editHaloColor = getValueForStyle(COLORS.editHalo, scheme) - const editActiveColor = getValueForStyle(COLORS.editActive, scheme) - const splitInvalidColor = getValueForStyle(COLORS.splitInvalid, scheme) - const splitValidColor = getValueForStyle(COLORS.splitValid, scheme) - const invalidStrokeColor = getValueForStyle(COLORS.invalidStroke, scheme) +// `pluginConfig` lets callers override any of these colours/strokeWidth — see +// resolveColors() and the draw plugin's own options docs. Unaffected keys +// still resolve to the built-in COLORS/SIZES defaults. +const createDrawStyles = (mapStyle, pluginConfig = {}) => { + const colors = resolveColors(mapStyle, pluginConfig) const { vertexRadius, midpointRadius, vertexHaloRadius, midpointHaloRadius } = SIZES return [ - fillInactive(mapStyle), - fillActive(editFillColor), - strokeActive(editStrokeColor), - strokeActiveInvalid(invalidStrokeColor), - strokeInactive(mapStyle), - drawInvalidSplitter(splitInvalidColor), - drawValidSplitter(splitValidColor), - drawPreviewLine(editStrokeColor), - midpoint(editMidpointColor, midpointRadius), - midpointHalo(editHaloColor, editActiveColor, midpointHaloRadius), - midpointActive(editMidpointColor, midpointRadius), - vertex(editVertexColor, vertexRadius), - vertexHalo(editHaloColor, editActiveColor, vertexHaloRadius), - vertexActive(editVertexColor, vertexRadius), - circle(editStrokeColor), + fillInactive(mapStyle, colors), + fillActive(colors.editFill), + strokeActive(colors.editStroke), + strokeActiveInvalid(colors.invalidStroke), + strokeInactive(mapStyle, colors), + drawInvalidSplitter(colors.splitInvalid), + drawValidSplitter(colors.splitValid), + drawPreviewLine(colors.editStroke), + midpoint(colors.editMidpoint, midpointRadius), + midpointHalo(colors.editHalo, colors.editActive, midpointHaloRadius), + midpointActive(colors.editMidpoint, midpointRadius), + vertex(colors.editVertex, vertexRadius), + vertexHalo(colors.editHalo, colors.editActive, vertexHaloRadius), + vertexActive(colors.editVertex, vertexRadius), + circle(colors.editStroke), touchVertexIndicator() ] } @@ -191,8 +187,8 @@ const createDrawStyles = (mapStyle) => { /** * Helper to iterate over a MapLibre map and apply new paint properties */ -const updateDrawStyles = (map, mapStyle) => { - const layers = createDrawStyles(mapStyle) +const updateDrawStyles = (map, mapStyle, pluginConfig = {}) => { + const layers = createDrawStyles(mapStyle, pluginConfig) layers.forEach(layer => { Object.entries(layer.paint).forEach(([prop, value]) => { if (map.getLayer(`${layer.id}.cold`)) { diff --git a/plugins/draw/src/adapters/maplibre/styles.test.js b/plugins/draw/src/adapters/maplibre/styles.test.js index 64f2cc7c4..91d30e3f5 100644 --- a/plugins/draw/src/adapters/maplibre/styles.test.js +++ b/plugins/draw/src/adapters/maplibre/styles.test.js @@ -83,6 +83,30 @@ describe('createDrawStyles', () => { expect(findLayer(layers, 'stroke-valid-splitter').paint['line-color']).toBe(getValueForStyle(COLORS.splitValid, 'light')) expect(findLayer(layers, 'touch-vertex-indicator').paint['circle-color']).toBe('#3bb2d0') }) + + test('a pluginConfig override replaces the default colour/width everywhere it applies', () => { + const pluginConfig = { editStroke: '#custom-stroke', shapeStroke: '#custom-shape', strokeWidth: 9 } + const layers = createDrawStyles(mapStyle, pluginConfig) + + expect(findLayer(layers, 'stroke-active').paint['line-color']).toBe('#custom-stroke') + expect(findLayer(layers, 'stroke-preview-line').paint['line-color']).toBe('#custom-stroke') + expect(findLayer(layers, 'circle').paint['line-color']).toBe('#custom-stroke') + expect(findLayer(layers, 'stroke-inactive').paint['line-color']).toEqual([ + 'coalesce', + ['get', 'user_strokeOutdoor'], + ['get', 'user_stroke'], + '#custom-shape' + ]) + expect(findLayer(layers, 'stroke-inactive').paint['line-width']).toBe(9) + }) + + test('a per-feature stroke/fill property still wins over a pluginConfig override — coalesce checks it first', () => { + const layers = createDrawStyles(mapStyle, { shapeStroke: '#custom-shape' }) + const [, userStyleKey, userKey, fallback] = findLayer(layers, 'stroke-inactive').paint['line-color'] + expect(userStyleKey).toEqual(['get', 'user_strokeOutdoor']) + expect(userKey).toEqual(['get', 'user_stroke']) + expect(fallback).toBe('#custom-shape') // only reached when the feature sets neither + }) }) describe('updateDrawStyles', () => { @@ -120,4 +144,12 @@ describe('updateDrawStyles', () => { expect(targets.every((id) => id.endsWith('.cold'))).toBe(true) expect(targets.some((id) => id.endsWith('.hot'))).toBe(false) }) + + test('applies a pluginConfig override on the re-styled layers', () => { + const map = { getLayer: jest.fn(() => true), setPaintProperty: jest.fn() } + + updateDrawStyles(map, mapStyle, { editStroke: '#custom' }) + + expect(map.setPaintProperty).toHaveBeenCalledWith('stroke-active.cold', 'line-color', '#custom') + }) }) diff --git a/plugins/draw/src/adapters/openlayers/OLDrawAdapter.js b/plugins/draw/src/adapters/openlayers/OLDrawAdapter.js index b43cb982f..c22877b04 100644 --- a/plugins/draw/src/adapters/openlayers/OLDrawAdapter.js +++ b/plugins/draw/src/adapters/openlayers/OLDrawAdapter.js @@ -39,7 +39,10 @@ export class OLDrawAdapter { mapProvider, events: options.events, eventBus: options.eventBus, - pluginConfig: { snapLayers: options.snapLayers }, + // The full pluginConfig, not just snapLayers — OLDrawManager also reads + // colour/size overrides (shapeStroke, editStroke, strokeWidth, snapRadius, + // etc.) off this object via resolveColors(). + pluginConfig: options.pluginConfig ?? { snapLayers: options.snapLayers }, mapStyle: options.mapStyle }) this._cleanupOLDraw = remove diff --git a/plugins/draw/src/adapters/openlayers/OLDrawAdapter.test.js b/plugins/draw/src/adapters/openlayers/OLDrawAdapter.test.js index 511d5fa32..9395e2463 100644 --- a/plugins/draw/src/adapters/openlayers/OLDrawAdapter.test.js +++ b/plugins/draw/src/adapters/openlayers/OLDrawAdapter.test.js @@ -56,6 +56,20 @@ test('wires olDraw with the plugin options and uses the returned manager', () => expect(manager.getMode).toHaveBeenCalled() }) +test('forwards the full pluginConfig (colour/size overrides too), not just snapLayers', () => { + const manager = fakeManager() + const mapProvider = { _testManager: manager } + const pluginConfig = { snapLayers: ['boundaries'], shapeStroke: '#custom', strokeWidth: 5 } + // eslint-disable-next-line no-new + new OLDrawAdapter(mapProvider, { + events: { MAP_SET_STYLE: 's' }, + eventBus: {}, + pluginConfig, + mapStyle: { id: 'default' } + }) + expect(createOLDraw).toHaveBeenCalledWith(expect.objectContaining({ pluginConfig })) +}) + test('changeMode injects the mapProvider and the OL geometry type per draw mode', () => { const { manager, mapProvider, adapter } = setup() adapter.changeMode('draw_polygon', { featureId: 'f1' }) diff --git a/plugins/draw/src/adapters/openlayers/core/OLDrawManager.js b/plugins/draw/src/adapters/openlayers/core/OLDrawManager.js index 8410800fc..6890be83c 100644 --- a/plugins/draw/src/adapters/openlayers/core/OLDrawManager.js +++ b/plugins/draw/src/adapters/openlayers/core/OLDrawManager.js @@ -2,7 +2,7 @@ import VectorLayer from 'ol/layer/Vector.js' import { createFeatureStore } from './featureStore.js' import { createUndoStack } from '../../../utils/undoStack.js' import { createStyles } from './styles.js' -import { resolveColors } from '../utils/resolveColors.js' +import { resolveColors } from '../../../utils/resolveColors.js' import { createSnapManager } from '../snap/snapManager.js' import { createDrawMode } from '../draw/DrawMode.js' import { createEditMode } from '../edit/EditMode.js' diff --git a/plugins/draw/src/adapters/openlayers/utils/resolveColors.js b/plugins/draw/src/utils/resolveColors.js similarity index 76% rename from plugins/draw/src/adapters/openlayers/utils/resolveColors.js rename to plugins/draw/src/utils/resolveColors.js index 617b46c7e..e666f1499 100644 --- a/plugins/draw/src/adapters/openlayers/utils/resolveColors.js +++ b/plugins/draw/src/utils/resolveColors.js @@ -1,15 +1,18 @@ import { COLORS, SIZES } from '../defaults.js' -import { getValueForStyle } from '../../../utils/getValueForStyle.js' +import { getValueForStyle } from './getValueForStyle.js' /** - * Resolve all draw-ol colors for the given map style and plugin config overrides. + * Resolve all draw colors for the given map style and plugin config overrides. + * Shared by both adapters (MapLibre and OpenLayers) — they draw from the same + * COLORS/SIZES palette (defaults.js), so a plugin-level override behaves + * identically regardless of which map provider is in use. * * Values in pluginConfig may be plain strings or variant objects (e.g. { light: '...', dark: '...' }). * Variant resolution order: exact style ID match → color scheme → 'light' fallback → first value. * * @param {object|null} mapStyle - Current map style object (has .id and .mapColorScheme) * @param {object} pluginConfig - Plugin-level user overrides (may override any COLORS key) - * @returns {object} Flat color values ready for use in createStyles() + * @returns {object} Flat color values ready for use in each adapter's styles module */ export const resolveColors = (mapStyle, pluginConfig = {}) => { const scheme = mapStyle?.mapColorScheme ?? 'light' diff --git a/plugins/draw/src/adapters/openlayers/utils/resolveColors.test.js b/plugins/draw/src/utils/resolveColors.test.js similarity index 100% rename from plugins/draw/src/adapters/openlayers/utils/resolveColors.test.js rename to plugins/draw/src/utils/resolveColors.test.js From 1295edf451f052b0583df00440a057bc13530750 Mon Sep 17 00:00:00 2001 From: Dan Leech Date: Wed, 5 Aug 2026 12:35:18 +0100 Subject: [PATCH 86/89] Maplibre edit cancel revert fix --- .../adapters/maplibre/MaplibreDrawAdapter.js | 12 ++++++- .../maplibre/MaplibreDrawAdapter.test.js | 32 ++++++++++++++++++- 2 files changed, 42 insertions(+), 2 deletions(-) diff --git a/plugins/draw/src/adapters/maplibre/MaplibreDrawAdapter.js b/plugins/draw/src/adapters/maplibre/MaplibreDrawAdapter.js index dbe15416f..8b65340b8 100644 --- a/plugins/draw/src/adapters/maplibre/MaplibreDrawAdapter.js +++ b/plugins/draw/src/adapters/maplibre/MaplibreDrawAdapter.js @@ -196,7 +196,17 @@ export class MaplibreDrawAdapter { cancel () { this._mapProvider.undoStack?.clear() - this._draw.trash() + const mode = this._draw.getMode() + // trash() only belongs to an in-progress, never-committed draw — it deletes + // whatever's selected. For edit_vertex, events.js's handleCancel has already + // restored the original feature via draw.add() before this runs; trash() + // here would instead run mapbox-gl-draw's direct_select onTrash on the + // mode's own live state (removing the selected vertex, or — if the + // in-progress edit happened to leave the shape invalid — deleting the + // entire feature), silently discarding the just-restored original. + if (mode === 'draw_polygon' || mode === 'draw_line') { + this._draw.trash() + } this._draw.changeMode('disabled') this._handleModeChange({ mode: 'disabled' }) } diff --git a/plugins/draw/src/adapters/maplibre/MaplibreDrawAdapter.test.js b/plugins/draw/src/adapters/maplibre/MaplibreDrawAdapter.test.js index 0eb0c2dbc..f1c0b0d0a 100644 --- a/plugins/draw/src/adapters/maplibre/MaplibreDrawAdapter.test.js +++ b/plugins/draw/src/adapters/maplibre/MaplibreDrawAdapter.test.js @@ -573,13 +573,43 @@ describe('done', () => { }) describe('cancel', () => { - test('clears the undo stack, trashes and disables the control', () => { + test('cancelling an in-progress draw clears the undo stack, trashes the sketch and disables the control', () => { const { adapter, draw, undoStack } = setup() + draw.getMode.mockReturnValue('draw_polygon') adapter.cancel() expect(undoStack.clear).toHaveBeenCalled() expect(draw.trash).toHaveBeenCalled() expect(draw.changeMode).toHaveBeenCalledWith('disabled') }) + + test('cancelling a draw_line session also trashes the sketch', () => { + const { adapter, draw } = setup() + draw.getMode.mockReturnValue('draw_line') + adapter.cancel() + expect(draw.trash).toHaveBeenCalled() + }) + + // Regression: events.js's handleCancel already restores the original feature + // via draw.add() before calling this. trash() runs mapbox-gl-draw's + // direct_select onTrash handler, which operates on the mode's own live + // state — removing the selected vertex or (if the in-progress edit left the + // shape invalid) deleting the whole feature — silently discarding the + // just-restored original. See MaplibreDrawAdapter.js's cancel() comment. + test('cancelling an edit session does NOT trash — the restored feature must survive', () => { + const { adapter, draw, undoStack } = setup() + draw.getMode.mockReturnValue('edit_vertex') + adapter.cancel() + expect(undoStack.clear).toHaveBeenCalled() + expect(draw.trash).not.toHaveBeenCalled() + expect(draw.changeMode).toHaveBeenCalledWith('disabled') + }) + + test('cancelling with no active session (disabled mode) does not trash', () => { + const { adapter, draw } = setup() + adapter.cancel() + expect(draw.trash).not.toHaveBeenCalled() + expect(draw.changeMode).toHaveBeenCalledWith('disabled') + }) }) describe('setSnapEnabled', () => { From 873c83e1abb475068b1a7b3529cc3003f5b1385f Mon Sep 17 00:00:00 2001 From: Dan Leech Date: Wed, 5 Aug 2026 13:27:59 +0100 Subject: [PATCH 87/89] Sonar cloud fixes --- .../maplibre/modes/drawMode/clickHandlers.js | 154 +++++++++--------- .../src/adapters/openlayers/core/styles.js | 80 +++++---- .../src/adapters/openlayers/edit/EditMode.js | 25 +-- plugins/draw/src/manifest.js | 8 +- 4 files changed, 143 insertions(+), 124 deletions(-) diff --git a/plugins/draw/src/adapters/maplibre/modes/drawMode/clickHandlers.js b/plugins/draw/src/adapters/maplibre/modes/drawMode/clickHandlers.js index bb9b51027..8d5fa1581 100644 --- a/plugins/draw/src/adapters/maplibre/modes/drawMode/clickHandlers.js +++ b/plugins/draw/src/adapters/maplibre/modes/drawMode/clickHandlers.js @@ -88,92 +88,98 @@ const createClickHelpers = ({ geometryType, getFeature, getCoords }) => ({ // The click paths themselves: mouse clicks and the simulated crosshair click // (touch / keyboard / add-vertex button). -const createClickActions = ({ ParentMode, getFeature, getCoords, validateClick, finishOnInvalidClick }) => ({ - onClick (state, e) { - // Skip non-primary clicks, undo operations, or clicks outside canvas - if (this._isIgnorableClick(e)) { - return +const createClickActions = ({ ParentMode, getFeature, getCoords, validateClick, finishOnInvalidClick }) => { + // Snap is inactive: sync the trailing rubber-band point to the click, then guard + // against a duplicate-coordinate click reaching ParentMode, which would trigger a + // changeMode chain and cause a runtime error on coords.length access (polygon). + // Returns false when the click should be dropped without placing a vertex. + const prepareUnsnappedClick = (state, e) => { + const coords = getCoords(getFeature(state)) + if (coords.length > 0) { + coords[coords.length - 1] = [e.lngLat.lng, e.lngLat.lat] } - // Block a finish/close gesture (clicking a placed vertex) while the shape is invalid. - if (this.map._drawGeometryValid === false && e.featureTarget?.properties?.meta === 'vertex') { - return - } - const snap = getSnapInstance(this.map) - if (isSnapEnabled(state) && isSnapActive(snap)) { - e = createSnappedEvent(e, snap) - } else { - const coords = getCoords(getFeature(state)) - if (coords.length > 0) { - coords[coords.length - 1] = [e.lngLat.lng, e.lngLat.lat] + return finishOnInvalidClick || validateClick(getFeature(state)) + } + + return { + onClick (state, e) { + // Skip non-primary clicks, undo operations, or clicks outside canvas + if (this._isIgnorableClick(e)) { + return } - // For polygon: prevent duplicate-coordinate clicks from reaching ParentMode, which - // would trigger a changeMode chain and cause a runtime error on coords.length access - if (!finishOnInvalidClick && !validateClick(getFeature(state))) { + // Block a finish/close gesture (clicking a placed vertex) while the shape is invalid. + if (this.map._drawGeometryValid === false && e.featureTarget?.properties?.meta === 'vertex') { return } - } - // Hard gate: a placement the rules or the user callback veto never appears, so - // an unrecoverable state (e.g. a self-crossing path) can't be drawn forward. - if (!this._canPlaceVertex(state, [e.lngLat.lng, e.lngLat.lat])) { - return - } - const coordsBefore = getCoords(getFeature(state)).length - ParentMode.onClick.call(this, state, e) - // Push undo and update count if a vertex was added - if (getCoords(getFeature(state)).length > coordsBefore) { - this.pushDrawUndo(state) - this.dispatchVertexChange(getCoords(getFeature(state))) - this.emitDrawValidation(state) - } - }, + const snap = getSnapInstance(this.map) + if (isSnapEnabled(state) && isSnapActive(snap)) { + e = createSnappedEvent(e, snap) + } else if (!prepareUnsnappedClick(state, e)) { + return + } + // Hard gate: a placement the rules or the user callback veto never appears, so + // an unrecoverable state (e.g. a self-crossing path) can't be drawn forward. + if (!this._canPlaceVertex(state, [e.lngLat.lng, e.lngLat.lat])) { + return + } + const coordsBefore = getCoords(getFeature(state)).length + ParentMode.onClick.call(this, state, e) + // Push undo and update count if a vertex was added + if (getCoords(getFeature(state)).length > coordsBefore) { + this.pushDrawUndo(state) + this.dispatchVertexChange(getCoords(getFeature(state))) + this.emitDrawValidation(state) + } + }, - doClick (state) { - // Skip during undo operation - if (this.map._undoInProgress) { - return - } + doClick (state) { + // Skip during undo operation + if (this.map._undoInProgress) { + return + } - const feature = getFeature(state) - const coords = getCoords(feature) - this.dispatchVertexChange(coords) - - if (!validateClick(feature)) { - // For lines: clicking same spot (like double-click) should finish the line — but - // only when the geometry is valid, so an invalid line can't be completed. - // isValidLineClick only returns false with 2+ coords, so coords.length is always > 1 here. - if (finishOnInvalidClick && this.map._drawGeometryValid !== false) { - coords.pop() - this.map.fire('draw.create', { features: [feature.toGeoJSON()] }) - this.changeMode('simple_select', { featureIds: [feature.id] }) + const feature = getFeature(state) + const coords = getCoords(feature) + this.dispatchVertexChange(coords) + + if (!validateClick(feature)) { + // For lines: clicking same spot (like double-click) should finish the line — but + // only when the geometry is valid, so an invalid line can't be completed. + // isValidLineClick only returns false with 2+ coords, so coords.length is always > 1 here. + if (finishOnInvalidClick && this.map._drawGeometryValid !== false) { + coords.pop() + this.map.fire('draw.create', { features: [feature.toGeoJSON()] }) + this.changeMode('simple_select', { featureIds: [feature.id] }) + } + return } - return - } - const snap = getSnapInstance(this.map) - const snappedEvent = isSnapEnabled(state) && createSnappedClickEvent(this.map, snap) + const snap = getSnapInstance(this.map) + const snappedEvent = isSnapEnabled(state) && createSnappedClickEvent(this.map, snap) - // Hard-gate parity with onClick: touch/keyboard/add-button placements are vetoed - // at the point that would actually be committed (snapped or map centre). - const target = snappedEvent ? snappedEvent.lngLat : this.map.getCenter() - if (!this._canPlaceVertex(state, [target.lng, target.lat])) { - return - } + // Hard-gate parity with onClick: touch/keyboard/add-button placements are vetoed + // at the point that would actually be committed (snapped or map centre). + const target = snappedEvent ? snappedEvent.lngLat : this.map.getCenter() + if (!this._canPlaceVertex(state, [target.lng, target.lat])) { + return + } - if (snappedEvent) { - ParentMode.onClick.call(this, state, snappedEvent) - this._ctx.store.render() - } else { - this._simulateMouse('click', ParentMode.onClick, state) - } + if (snappedEvent) { + ParentMode.onClick.call(this, state, snappedEvent) + this._ctx.store.render() + } else { + this._simulateMouse('click', ParentMode.onClick, state) + } - // Push undo and update count if a vertex was added. A validated click always - // adds one vertex via the parent mode, so this runs on every successful doClick. - const newCoords = getCoords(getFeature(state)) - this.pushDrawUndo(state) - this.dispatchVertexChange(newCoords) - this.emitDrawValidation(state) + // Push undo and update count if a vertex was added. A validated click always + // adds one vertex via the parent mode, so this runs on every successful doClick. + const newCoords = getCoords(getFeature(state)) + this.pushDrawUndo(state) + this.dispatchVertexChange(newCoords) + this.emitDrawValidation(state) + } } -}) +} /** * Click / vertex-placement handling for the shared draw mode: mouse clicks, the diff --git a/plugins/draw/src/adapters/openlayers/core/styles.js b/plugins/draw/src/adapters/openlayers/core/styles.js index 365820220..715d771c2 100644 --- a/plugins/draw/src/adapters/openlayers/core/styles.js +++ b/plugins/draw/src/adapters/openlayers/core/styles.js @@ -33,32 +33,57 @@ const makeRingRenderer = ({ outer, mid, inner }, colors, innerKey) => (pixelCoor const capitalize = (s) => s.charAt(0).toUpperCase() + s.slice(1) -/** - * Create all draw-ol style instances for the given resolved color set. - * - * @param {object} colors - Output of resolveColors() - * @returns {{ vertexStyle, selectedVertexStyle, midpointStyle, selectedMidpointStyle, - * editFeatureStyle, createSketchStyle, createFeatureStyle }} - */ -export const createStyles = (colors) => { - // Shared by edit-mode vertices and in-progress sketch vertices so they always look the same +// Shared by edit-mode vertices and in-progress sketch vertices so they always look the same +const createVertexStyles = (colors) => { const vertexImage = new CircleStyle({ radius: SIZES.vertexRadius, fill: new Fill({ color: colors.editVertex }) }) + return { + vertexImage, + vertexStyle: new Style({ image: vertexImage }), + selectedVertexStyle: new Style({ renderer: makeRingRenderer(selectedVertexRadii, colors, 'editVertex') }) + } +} - const vertexStyle = new Style({ image: vertexImage }) - - const selectedVertexStyle = new Style({ renderer: makeRingRenderer(selectedVertexRadii, colors, 'editVertex') }) - - const midpointStyle = new Style({ +const createMidpointStyles = (colors) => ({ + midpointStyle: new Style({ image: new CircleStyle({ radius: SIZES.midpointRadius, fill: new Fill({ color: colors.editMidpoint }) }) + }), + selectedMidpointStyle: new Style({ renderer: makeRingRenderer(selectedMidpointRadii, colors, 'editMidpoint') }) +}) + +// Split-line preview colours: valid is solid, invalid is dashed — matching +// ML's stroke-valid-splitter / stroke-invalid-splitter layers. +const createSketchLineStyles = (colors) => ({ + valid: new Style({ + stroke: new Stroke({ color: colors.editStroke, width: 2 }), + fill: new Fill({ color: colors.editFill }) + }), + invalid: new Style({ + stroke: new Stroke({ color: colors.invalidStroke, width: 2, lineDash: [2, 4] }) + }), + splitValid: new Style({ + stroke: new Stroke({ color: colors.splitValid, width: 2 }) + }), + splitInvalid: new Style({ + stroke: new Stroke({ color: colors.splitInvalid, width: 2, lineDash: [2, 4] }) }) +}) - const selectedMidpointStyle = new Style({ renderer: makeRingRenderer(selectedMidpointRadii, colors, 'editMidpoint') }) +/** + * Create all draw-ol style instances for the given resolved color set. + * + * @param {object} colors - Output of resolveColors() + * @returns {{ vertexStyle, selectedVertexStyle, midpointStyle, selectedMidpointStyle, + * editFeatureStyle, createSketchStyle, createFeatureStyle }} + */ +export const createStyles = (colors) => { + const { vertexImage, vertexStyle, selectedVertexStyle } = createVertexStyles(colors) + const { midpointStyle, selectedMidpointStyle } = createMidpointStyles(colors) const editFeatureStyle = new Style({ stroke: new Stroke({ color: colors.editStroke, width: 2 }), @@ -71,24 +96,7 @@ export const createStyles = (colors) => { stroke: new Stroke({ color: colors.invalidStroke, width: 2, lineDash: [2, 4] }) }) - const sketchLineStyle = new Style({ - stroke: new Stroke({ color: colors.editStroke, width: 2 }), - fill: new Fill({ color: colors.editFill }) - }) - - const sketchLineStyleInvalid = new Style({ - stroke: new Stroke({ color: colors.invalidStroke, width: 2, lineDash: [2, 4] }) - }) - - // Split-line preview colours: valid is solid, invalid is dashed — matching - // ML's stroke-valid-splitter / stroke-invalid-splitter layers. - const sketchLineStyleSplitValid = new Style({ - stroke: new Stroke({ color: colors.splitValid, width: 2 }) - }) - - const sketchLineStyleSplitInvalid = new Style({ - stroke: new Stroke({ color: colors.splitInvalid, width: 2, lineDash: [2, 4] }) - }) + const sketchLineStyles = createSketchLineStyles(colors) // Reused across renders — the geometry function runs every frame while sketching, // so mutate one MultiPoint (setCoordinates bumps its revision, keeping OL's @@ -116,9 +124,9 @@ export const createStyles = (colors) => { const type = feature.getGeometry().getType() if (type === 'Point') { return [] } const splitter = feature.get('splitter') - let lineStyle = invalid ? sketchLineStyleInvalid : sketchLineStyle - if (splitter === 'valid') { lineStyle = sketchLineStyleSplitValid } - if (splitter === 'invalid') { lineStyle = sketchLineStyleSplitInvalid } + let lineStyle = invalid ? sketchLineStyles.invalid : sketchLineStyles.valid + if (splitter === 'valid') { lineStyle = sketchLineStyles.splitValid } + if (splitter === 'invalid') { lineStyle = sketchLineStyles.splitInvalid } return type === geometryType ? [lineStyle, sketchVertexStyle] : [lineStyle] } diff --git a/plugins/draw/src/adapters/openlayers/edit/EditMode.js b/plugins/draw/src/adapters/openlayers/edit/EditMode.js index ddccc6b3a..efe47e879 100644 --- a/plugins/draw/src/adapters/openlayers/edit/EditMode.js +++ b/plugins/draw/src/adapters/openlayers/edit/EditMode.js @@ -14,20 +14,23 @@ import { createLiveStroke } from '../../../validation/liveStroke.js' const TOUCH_INTERFACE = 'touch' const VERTEX_TYPE = 'vertex' +const MOVE_VERTEX = 'commit-move' +const INSERT_VERTEX = 'commit-insert' +const DELETE_VERTEX = 'commit-delete' // Map an undo-op type onto the geometry-change `phase` consumed by validation. const OP_PHASE = { - move_vertex: 'commit-move', - insert_vertex: 'commit-insert', - delete_vertex: 'commit-delete' + move_vertex: MOVE_VERTEX, + insert_vertex: INSERT_VERTEX, + delete_vertex: DELETE_VERTEX } // Undoing an op commits the inverse change (undo of a delete re-inserts, etc.), // so its re-validation reports the inverse phase. const UNDO_INVERSE_PHASE = { - move_vertex: 'commit-move', - insert_vertex: 'commit-delete', - delete_vertex: 'commit-insert' + move_vertex: MOVE_VERTEX, + insert_vertex: DELETE_VERTEX, + delete_vertex: INSERT_VERTEX } // Live invalid-stroke wiring: re-validate the displayed geometry on every geometry @@ -106,7 +109,7 @@ const createVertexActions = ({ olFeature, undoStack, selection, getTouchHandler } undoStack.push({ type: 'delete_vertex', vertexIndex: result.deletedIndex, deletedCoord: result.deletedCoord }) syncGeom() - emitGeometryValidation('commit-delete', result.deletedIndex) + emitGeometryValidation(DELETE_VERTEX, result.deletedIndex) setState({ selectedVertexIndex: -1, selectedVertexType: null }) } @@ -149,7 +152,7 @@ const wireTouchHandler = ({ map, container, manager, snap, olFeature, undoStack, onVertexMoved ({ vertexIndex, previousCoord }) { undoStack.push({ type: 'move_vertex', vertexIndex, previousCoord }) syncGeom() - emitGeometryValidation('commit-move', vertexIndex) + emitGeometryValidation(MOVE_VERTEX, vertexIndex) selectVertex(vertexIndex) touchHandler.updateTargetPosition() }, @@ -170,7 +173,7 @@ const wireTouchHandler = ({ map, container, manager, snap, olFeature, undoStack, } undoStack.push({ type: 'insert_vertex', vertexIndex: result.insertedIndex }) syncGeom() - emitGeometryValidation('commit-insert', result.insertedIndex) + emitGeometryValidation(INSERT_VERTEX, result.insertedIndex) selectVertex(result.insertedIndex) touchHandler.updateTargetPosition() } @@ -199,13 +202,13 @@ const wireKeyboardHandler = ({ map, container, snap, undoStack, selection, touch onVertexMoved ({ vertexIndex, previousCoord }) { undoStack.push({ type: 'move_vertex', vertexIndex, previousCoord }) syncGeom() - emitGeometryValidation('commit-move', vertexIndex) + emitGeometryValidation(MOVE_VERTEX, vertexIndex) setState({ selectedVertexIndex: vertexIndex, selectedVertexType: VERTEX_TYPE }) }, onInserted ({ insertedIndex }) { undoStack.push({ type: 'insert_vertex', vertexIndex: insertedIndex }) syncGeom() - emitGeometryValidation('commit-insert', insertedIndex) + emitGeometryValidation(INSERT_VERTEX, insertedIndex) }, onDeleted: actions.doDeleteVertex, onUndo: actions.doUndo, diff --git a/plugins/draw/src/manifest.js b/plugins/draw/src/manifest.js index 25c140a45..85f3a5d1d 100644 --- a/plugins/draw/src/manifest.js +++ b/plugins/draw/src/manifest.js @@ -9,6 +9,8 @@ import { split } from './api/split.js' import { merge } from './api/merge.js' import { isMac } from '../../../src/utils/isMac.js' +const DRAW_ACTIONS_SLOT = 'top-middle' + // Show the platform-appropriate undo modifier (⌘ on macOS, Ctrl elsewhere). const undoCommand = isMac() ? 'Command + Z' : 'Ctrl + Z' @@ -99,9 +101,9 @@ export const manifest = { hiddenWhen: ({ pluginState }) => pluginState.mode !== 'edit_vertex' } ], - mobile: { slot: 'top-middle' }, - tablet: { slot: 'top-middle' }, - desktop: { slot: 'top-middle' } + mobile: { slot: DRAW_ACTIONS_SLOT }, + tablet: { slot: DRAW_ACTIONS_SLOT }, + desktop: { slot: DRAW_ACTIONS_SLOT } } ], From bbab8c111eab22073466205af38739ffbe167786 Mon Sep 17 00:00:00 2001 From: Dan Leech Date: Wed, 5 Aug 2026 13:31:13 +0100 Subject: [PATCH 88/89] Validation error message amend --- plugins/draw/src/validation/rules.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/draw/src/validation/rules.js b/plugins/draw/src/validation/rules.js index 75ffc8651..cf80e9a41 100644 --- a/plugins/draw/src/validation/rules.js +++ b/plugins/draw/src/validation/rules.js @@ -106,7 +106,7 @@ const pathSelfIntersects = (feature) => { /** Rule-shaped wrapper for pathSelfIntersects (see HARD_RULES). */ export const noPathSelfIntersection = (feature) => pathSelfIntersects(feature) - ? { valid: false, reason: 'Point would make the shape intersect itself' } + ? { valid: false, reason: 'Shape must not intersect itself' } : { valid: true } /** From 77e3ab58cbd666107d9af727e8a8bafc45eb36ec Mon Sep 17 00:00:00 2001 From: Dan Leech Date: Wed, 5 Aug 2026 13:38:03 +0100 Subject: [PATCH 89/89] Sonar missing else fix --- .../draw/src/adapters/maplibre/modes/drawMode/clickHandlers.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/plugins/draw/src/adapters/maplibre/modes/drawMode/clickHandlers.js b/plugins/draw/src/adapters/maplibre/modes/drawMode/clickHandlers.js index 8d5fa1581..df5e55255 100644 --- a/plugins/draw/src/adapters/maplibre/modes/drawMode/clickHandlers.js +++ b/plugins/draw/src/adapters/maplibre/modes/drawMode/clickHandlers.js @@ -116,6 +116,8 @@ const createClickActions = ({ ParentMode, getFeature, getCoords, validateClick, e = createSnappedEvent(e, snap) } else if (!prepareUnsnappedClick(state, e)) { return + } else { + // No action } // Hard gate: a placement the rules or the user callback veto never appears, so // an unrecoverable state (e.g. a self-crossing path) can't be drawn forward.