From 88ad39e385c3d3f3c064f4600a67e50815bcc982 Mon Sep 17 00:00:00 2001 From: Dan Leech Date: Wed, 13 May 2026 16:29:47 +0100 Subject: [PATCH 01/26] Basics --- plugins/beta/draw-ol/src/DrawInit.jsx | 51 ++++ .../beta/draw-ol/src/core/OLDrawManager.js | 104 ++++++++ plugins/beta/draw-ol/src/core/featureStore.js | 61 +++++ plugins/beta/draw-ol/src/core/styles.js | 94 ++++++++ plugins/beta/draw-ol/src/core/undoStack.js | 31 +++ plugins/beta/draw-ol/src/draw/DrawMode.js | 95 ++++++++ plugins/beta/draw-ol/src/draw/drawInput.js | 69 ++++++ plugins/beta/draw-ol/src/edit/EditMode.js | 226 ++++++++++++++++++ .../beta/draw-ol/src/edit/keyboardHandler.js | 156 ++++++++++++ .../beta/draw-ol/src/edit/midpointLayer.js | 54 +++++ plugins/beta/draw-ol/src/edit/touchHandler.js | 107 +++++++++ plugins/beta/draw-ol/src/edit/undoOps.js | 76 ++++++ .../beta/draw-ol/src/edit/vertexHitTest.js | 65 +++++ plugins/beta/draw-ol/src/edit/vertexOps.js | 85 +++++++ plugins/beta/draw-ol/src/events.js | 106 ++++++++ plugins/beta/draw-ol/src/olDraw.js | 28 +++ plugins/beta/draw-ol/src/reducer.js | 31 +++ .../beta/draw-ol/src/utils/geometryHelpers.js | 95 ++++++++ plugins/beta/draw-ol/src/utils/olCoords.js | 35 +++ plugins/beta/draw-ol/src/utils/spatial.js | 28 +++ plugins/beta/draw-ol/src/utils/touchTarget.js | 41 ++++ 21 files changed, 1638 insertions(+) create mode 100644 plugins/beta/draw-ol/src/DrawInit.jsx create mode 100644 plugins/beta/draw-ol/src/core/OLDrawManager.js create mode 100644 plugins/beta/draw-ol/src/core/featureStore.js create mode 100644 plugins/beta/draw-ol/src/core/styles.js create mode 100644 plugins/beta/draw-ol/src/core/undoStack.js create mode 100644 plugins/beta/draw-ol/src/draw/DrawMode.js create mode 100644 plugins/beta/draw-ol/src/draw/drawInput.js create mode 100644 plugins/beta/draw-ol/src/edit/EditMode.js create mode 100644 plugins/beta/draw-ol/src/edit/keyboardHandler.js create mode 100644 plugins/beta/draw-ol/src/edit/midpointLayer.js create mode 100644 plugins/beta/draw-ol/src/edit/touchHandler.js create mode 100644 plugins/beta/draw-ol/src/edit/undoOps.js create mode 100644 plugins/beta/draw-ol/src/edit/vertexHitTest.js create mode 100644 plugins/beta/draw-ol/src/edit/vertexOps.js create mode 100644 plugins/beta/draw-ol/src/events.js create mode 100644 plugins/beta/draw-ol/src/olDraw.js create mode 100644 plugins/beta/draw-ol/src/reducer.js create mode 100644 plugins/beta/draw-ol/src/utils/geometryHelpers.js create mode 100644 plugins/beta/draw-ol/src/utils/olCoords.js create mode 100644 plugins/beta/draw-ol/src/utils/spatial.js create mode 100644 plugins/beta/draw-ol/src/utils/touchTarget.js diff --git a/plugins/beta/draw-ol/src/DrawInit.jsx b/plugins/beta/draw-ol/src/DrawInit.jsx new file mode 100644 index 00000000..92423692 --- /dev/null +++ b/plugins/beta/draw-ol/src/DrawInit.jsx @@ -0,0 +1,51 @@ +import { useEffect } from 'react' +import { EVENTS } from '../../../../src/config/events.js' +import { createOLDraw } from './olDraw.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) + + // Create the OLDrawManager once when the map is ready + useEffect(() => { + const inModeWhitelist = pluginConfig.includeModes?.includes(appState.mode) ?? true + const inExcludeModes = pluginConfig.excludeModes?.includes(appState.mode) ?? false + if (!mapState.isMapReady || !inModeWhitelist || inExcludeModes) return + + const { remove } = createOLDraw({ mapProvider, events: EVENTS, eventBus }) + + pluginState.dispatch({ type: 'SET_MODE', payload: null }) + eventBus.emit('draw:ready') + + return () => remove() + }, [mapState.isMapReady, appState.mode]) + + // Show crosshair when entering draw mode on touch/keyboard + useEffect(() => { + if (['draw_polygon', 'draw_line'].includes(pluginState.mode) && isTouchOrKeyboard) { + const wasVisible = crossHair.isVisible + crossHair.fixAtCenter() + return () => { + if (!wasVisible) crossHair.hide() + } + } + }, [pluginState.mode, appState.interfaceType]) + + // Re-attach events when state changes + useEffect(() => { + if (!mapProvider.draw) return + + return attachEvents({ + appState, + appConfig, + mapState, + mapProvider, + buttonConfig, + pluginState, + events: EVENTS, + eventBus + }) + }, [mapProvider, appState, pluginState]) +} diff --git a/plugins/beta/draw-ol/src/core/OLDrawManager.js b/plugins/beta/draw-ol/src/core/OLDrawManager.js new file mode 100644 index 00000000..90bb177f --- /dev/null +++ b/plugins/beta/draw-ol/src/core/OLDrawManager.js @@ -0,0 +1,104 @@ +import VectorLayer from 'ol/layer/Vector.js' +import { createFeatureStore } from './featureStore.js' +import { createUndoStack } from './undoStack.js' +import { createFeatureStyle } from './styles.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) { + this._map = map + this._mode = 'disabled' + this._modeInstance = null + this._listeners = new Map() + + this.store = createFeatureStore() + this.undoStack = createUndoStack((length) => this.emit('undochange', length)) + + this._layer = new VectorLayer({ + source: this.store.source, + style: createFeatureStyle(), + zIndex: 100 + }) + map.addLayer(this._layer) + } + + // --- 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) { + this._listeners.get(type)?.forEach(h => h(detail)) + } + + // --- Mode machine --- + + async changeMode (modeName, options = {}) { + this._modeInstance?.destroy() + this._modeInstance = null + this._mode = modeName + + if (modeName === 'draw_polygon' || modeName === 'draw_line') { + const { createDrawMode } = await import('../draw/DrawMode.js') + this._modeInstance = createDrawMode({ map: this._map, manager: this, options }) + } else if (modeName === 'edit_vertex') { + const { createEditMode } = await import('../edit/EditMode.js') + this._modeInstance = createEditMode({ map: this._map, manager: this, options }) + } + } + + 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() + } + + // --- 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.store.clear() + this._map.removeLayer(this._layer) + this._listeners.clear() + } +} diff --git a/plugins/beta/draw-ol/src/core/featureStore.js b/plugins/beta/draw-ol/src/core/featureStore.js new file mode 100644 index 00000000..76d672f9 --- /dev/null +++ b/plugins/beta/draw-ol/src/core/featureStore.js @@ -0,0 +1,61 @@ +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, + + /** Add or replace a GeoJSON feature. Returns the OL Feature. */ + add (geojsonFeature) { + const existing = source.getFeatureById(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 = source.getFeatureById(String(id)) + return feature ? format.writeFeatureObject(feature) : null + }, + + /** Get the raw OL Feature by ID, or null. */ + getOL (id) { + return source.getFeatureById(String(id)) ?? null + }, + + /** Remove a feature by ID. */ + remove (id) { + const feature = source.getFeatureById(String(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-ol/src/core/styles.js b/plugins/beta/draw-ol/src/core/styles.js new file mode 100644 index 00000000..e7a7bd39 --- /dev/null +++ b/plugins/beta/draw-ol/src/core/styles.js @@ -0,0 +1,94 @@ +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 COLOR = { + primary: '#3b82f6', // vertex stroke, sketch line, default feature stroke + selected: '#f97316', // selected vertex + midpoint: '#94a3b8', // midpoint handle + white: '#ffffff', + sketchFill: 'rgba(59,130,246,0.08)', + featureFill: 'rgba(59,130,246,0.1)' +} + +// --- Shared style instances (stateless, reused across renders) --- + +const vertexStyle = new Style({ + image: new CircleStyle({ + radius: 6, + fill: new Fill({ color: COLOR.white }), + stroke: new Stroke({ color: COLOR.primary, width: 2 }) + }) +}) + +const selectedVertexStyle = new Style({ + image: new CircleStyle({ + radius: 7, + fill: new Fill({ color: COLOR.white }), + stroke: new Stroke({ color: COLOR.selected, width: 2.5 }) + }) +}) + +const midpointStyle = new Style({ + image: new CircleStyle({ + radius: 4, + fill: new Fill({ color: COLOR.white }), + stroke: new Stroke({ color: COLOR.midpoint, width: 1.5 }) + }) +}) + +const sketchLineStyle = new Style({ + stroke: new Stroke({ color: COLOR.primary, width: 2, lineDash: [6, 4] }), + fill: new Fill({ color: COLOR.sketchFill }) +}) + +const sketchPointStyle = new Style({ + image: new CircleStyle({ + radius: 5, + fill: new Fill({ color: COLOR.primary }), + stroke: new Stroke({ color: COLOR.white, width: 1.5 }) + }) +}) + +// --- Style functions --- + +/** + * Style for OL Draw interaction's sketch overlay. + * Receives a sketch feature (Point, LineString, or Polygon). + */ +export const createSketchStyle = () => (feature) => { + return feature.getGeometry().getType() === 'Point' + ? [sketchPointStyle] + : [sketchLineStyle] +} + +/** + * Style for OL Modify interaction's vertex overlay. + * Uses a mutable `state` ref so EditMode can update selectedCoord + * without recreating the style function. + * + * @param {{ selectedCoord: number[]|null }} state + */ +export const createEditStyle = (state) => (feature) => { + if (feature.getGeometry().getType() !== 'Point') return [sketchLineStyle] + const [ex, ey] = feature.getGeometry().getCoordinates() + const sel = state.selectedCoord + const isSelected = sel && Math.abs(ex - sel[0]) < 1 && Math.abs(ey - sel[1]) < 1 + return [isSelected ? selectedVertexStyle : vertexStyle] +} + +/** Style for the midpoint overlay layer (always the same). */ +export const getMidpointStyle = () => midpointStyle + +/** + * Style for completed features in the main VectorLayer. + * Reads stroke/fill/strokeWidth from feature properties if set. + */ +export const createFeatureStyle = () => (feature) => { + const p = feature.getProperties() + return [new Style({ + stroke: new Stroke({ color: p.stroke || COLOR.primary, width: p.strokeWidth || 2 }), + fill: new Fill({ color: p.fill || COLOR.featureFill }) + })] +} diff --git a/plugins/beta/draw-ol/src/core/undoStack.js b/plugins/beta/draw-ol/src/core/undoStack.js new file mode 100644 index 00000000..bd27e465 --- /dev/null +++ b/plugins/beta/draw-ol/src/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-ol/src/draw/DrawMode.js b/plugins/beta/draw-ol/src/draw/DrawMode.js new file mode 100644 index 00000000..bab2324f --- /dev/null +++ b/plugins/beta/draw-ol/src/draw/DrawMode.js @@ -0,0 +1,95 @@ +import Draw from 'ol/interaction/Draw.js' +import { createSketchStyle } from '../core/styles.js' +import { createDrawInput } from './drawInput.js' +import { getCoords } from '../utils/geometryHelpers.js' + +/** + * Draw mode — handles draw_polygon and draw_line. + * + * OL's Draw interaction handles all pointer/mouse behaviour natively. + * drawInput.js handles touch/keyboard/button input. + * + * @returns {{ done, cancel, undo, destroy }} + */ +export const createDrawMode = ({ map, manager, options }) => { + const { + geometryType, // 'Polygon' | 'LineString' + featureId, + properties = {}, + container, + interfaceType, + addVertexButtonId, + mapProvider + } = options + + const drawInteraction = new Draw({ + type: geometryType, + style: createSketchStyle(), + stopClick: true, + // minPoints defaults: 3 for Polygon, 2 for LineString — OL handles this + // snapTolerance: how close to first point to auto-close polygon + snapTolerance: 12 + }) + map.addInteraction(drawInteraction) + + // Track vertex count for the Done button enabled state + let sketchFeature = null + + 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 + const numVertecies = Math.max(0, coords.length - 1) + manager.emit('vertexchange', { numVertecies }) + } + + 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) + const geojson = manager.store.toGeoJSON(olFeature) + manager.emit('create', geojson) + // 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 } }) + + return { + done () { + // Validate minimum points before finishing + if (sketchFeature) { + const geom = sketchFeature.getGeometry() + const coords = getCoords({ type: geometryType, coordinates: geom.getCoordinates() }) + const min = geometryType === 'Polygon' ? 4 : 3 // +1 for rubber band + if (coords.length < min) return + } + drawInteraction.finishDrawing() + }, + + cancel () { + drawInteraction.abortDrawing() + }, + + undo () { + drawInteraction.removeLastPoint() + updateVertexCount() + }, + + destroy () { + input.destroy() + map.removeInteraction(drawInteraction) + sketchFeature = null + } + } +} diff --git a/plugins/beta/draw-ol/src/draw/drawInput.js b/plugins/beta/draw-ol/src/draw/drawInput.js new file mode 100644 index 00000000..d4af022b --- /dev/null +++ b/plugins/beta/draw-ol/src/draw/drawInput.js @@ -0,0 +1,69 @@ +/** + * Input handling for draw mode: touch, keyboard, and button events. + * + * Mouse/pointer drawing is handled entirely by OL's Draw interaction. + * This module handles the crosshair-based input path (touch + keyboard) + * and the Done / Add Point / Cancel button wiring. + */ + +/** + * @param {object} params + * @param {import('ol/interaction/Draw').default} params.drawInteraction + * @param {import('../core/OLDrawManager').OLDrawManager} params.manager + * @param {object} params.options - { container, interfaceType, addVertexButtonId, mapProvider } + * @returns {{ destroy: () => void }} + */ +export const createDrawInput = ({ drawInteraction, manager, options }) => { + const { container, addVertexButtonId, mapProvider } = options + let interfaceType = options.interfaceType + + // --- Place a vertex at the current map center (crosshair position) --- + const placeVertex = () => { + const coord = mapProvider.getCenter() + drawInteraction.appendCoordinates([coord]) + } + + // --- Event handlers --- + const onKeydown = (e) => { + if (document.activeElement !== container) return + if (e.key === 'Enter') { + e.preventDefault() + interfaceType = 'keyboard' + placeVertex() + } + } + + // Button click covers both Add Point button and any element inside it + const onButtonClick = (e) => { + if (addVertexButtonId && e.target.closest(`#${addVertexButtonId}`)) { + placeVertex() + } + } + + // Track interface type so DrawMode can show/hide crosshair correctly + const onPointerdown = (e) => { + if (e.pointerType !== 'touch') { + interfaceType = 'pointer' + } + } + + const onTouchstart = () => { + interfaceType = 'touch' + } + + window.addEventListener('keydown', onKeydown) + window.addEventListener('click', onButtonClick) + container.addEventListener('pointerdown', onPointerdown) + container.addEventListener('touchstart', onTouchstart, { passive: true }) + + return { + getInterfaceType: () => interfaceType, + + destroy () { + window.removeEventListener('keydown', onKeydown) + window.removeEventListener('click', onButtonClick) + container.removeEventListener('pointerdown', onPointerdown) + container.removeEventListener('touchstart', onTouchstart) + } + } +} diff --git a/plugins/beta/draw-ol/src/edit/EditMode.js b/plugins/beta/draw-ol/src/edit/EditMode.js new file mode 100644 index 00000000..c79dd948 --- /dev/null +++ b/plugins/beta/draw-ol/src/edit/EditMode.js @@ -0,0 +1,226 @@ +import Modify from 'ol/interaction/Modify.js' +import Collection from 'ol/Collection.js' +import { createEditStyle } from '../core/styles.js' +import { createMidpointLayer } from './midpointLayer.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 } = options + const { store, undoStack } = manager + + const olFeature = store.getOL(featureId) + if (!olFeature) return null + + // Mutable state shared across sub-handlers + const state = { + olFeature, + selectedVertexIndex: -1, + selectedVertexType: null, + vertecies: [], + midpoints: [], + interfaceType: interfaceType ?? 'pointer', + // Used by createEditStyle to highlight the selected vertex + selectedCoord: null + } + + const getState = () => state + const setState = (updates) => { + Object.assign(state, updates) + if (updates.selectedVertexIndex !== undefined) { + const coord = state.vertecies[state.selectedVertexIndex] ?? null + state.selectedCoord = coord + manager.emit('vertexselection', { + index: state.selectedVertexType === 'vertex' ? state.selectedVertexIndex : -1, + numVertecies: state.vertecies.length + }) + } + if (updates.vertecies !== undefined) { + midpointLayer.update(olFeature.getGeometry().toJSON?.() ?? { + type: olFeature.getGeometry().getType(), + coordinates: olFeature.getGeometry().getCoordinates() + }) + state.midpoints = midpointLayer.getCoords() + // Trigger Modify overlay re-render + map.render() + } + } + + const syncGeom = () => { + const geom = olFeature.getGeometry() + const plainGeom = { type: geom.getType(), coordinates: geom.getCoordinates() } + state.vertecies = getCoords(plainGeom) + state.midpoints = getMidpoints(plainGeom) + midpointLayer.update(plainGeom) + manager.emit('vertexchange', { numVertecies: state.vertecies.length }) + manager.emit('update', store.toGeoJSON(olFeature)) + } + + // --- Style state ref shared with the style function --- + const styleState = { selectedCoord: null } + const editStyleFn = createEditStyle(styleState) + + // --- OL Modify (handles pointer vertex drag + midpoint insertion natively) --- + const collection = new Collection([olFeature]) + const modifyInteraction = new Modify({ + features: collection, + style: editStyleFn, + pixelTolerance: 12 + }) + map.addInteraction(modifyInteraction) + + // Track move start for undo + let modifyStartCoords = null + let modifyStartIndex = -1 + + modifyInteraction.on('modifystart', () => { + if (state.interfaceType === 'touch') return + modifyStartCoords = state.vertecies.map(c => [...c]) + }) + + modifyInteraction.on('modifyend', () => { + if (state.interfaceType === 'touch') return + const prevCoords = modifyStartCoords + syncGeom() + if (!prevCoords) return + + // Detect what changed + const newCoords = state.vertecies + if (newCoords.length > prevCoords.length) { + // Midpoint drag inserted a vertex — find it + const insertedIdx = newCoords.findIndex((c, i) => !prevCoords[i] || c[0] !== prevCoords[i][0]) + undoStack.push({ type: 'insert_vertex', vertexIndex: Math.max(0, insertedIdx) }) + } 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' }) + } + } + modifyStartCoords = null + }) + + // --- Midpoint layer --- + const midpointLayer = createMidpointLayer(map) + syncGeom() // initial populate + + // --- Pointer hit detection (click selects vertex or midpoint) --- + const onPointerdown = (e) => { + if (e.pointerType === 'touch') { + state.interfaceType = 'touch' + modifyInteraction.setActive(false) + return + } + state.interfaceType = 'pointer' + modifyInteraction.setActive(true) + + const olPixel = map.getEventPixel(e) + const pixel = { x: olPixel[0], y: olPixel[1] } + const hit = findNearest(map, state.vertecies, state.midpoints, pixel) + if (hit) { + setState({ selectedVertexIndex: hit.index, selectedVertexType: hit.type }) + styleState.selectedCoord = state.selectedCoord + } + } + + container.addEventListener('pointerdown', onPointerdown) + + // --- Button click (delete vertex) --- + const onButtonClick = (e) => { + if (deleteVertexButtonId && e.target.closest(`#${deleteVertexButtonId}`)) { + doDeleteVertex() + } + } + window.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', ...result }) + syncGeom() + setState({ selectedVertexIndex: -1, selectedVertexType: null }) + styleState.selectedCoord = null + } + + const doUndo = () => { + const op = undoStack.pop() + if (!op) return + const newIndex = applyUndo(olFeature, op) + syncGeom() + setState({ + selectedVertexIndex: newIndex, + selectedVertexType: newIndex >= 0 ? 'vertex' : null + }) + styleState.selectedCoord = newIndex >= 0 ? state.vertecies[newIndex] : null + } + + // --- Touch handler --- + const touchHandler = createTouchHandler({ + map, + container, + getState, + setState, + onVertexMoved ({ vertexIndex, previousCoord }) { + undoStack.push({ type: 'move_vertex', vertexIndex, previousCoord }) + syncGeom() + touchHandler.updateTargetPosition() + } + }) + + // --- Keyboard handler --- + const keyboardHandler = createKeyboardHandler({ + map, + container, + getState, + setState, + onVertexMoved ({ vertexIndex, previousCoord }) { + undoStack.push({ type: 'move_vertex', vertexIndex, previousCoord }) + syncGeom() + }, + onInserted ({ insertedIndex }) { + undoStack.push({ type: 'insert_vertex', vertexIndex: insertedIndex }) + syncGeom() + }, + onDeleted: doDeleteVertex, + onUndo: doUndo + }) + + return { + 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 () { + container.removeEventListener('pointerdown', onPointerdown) + window.removeEventListener('click', onButtonClick) + map.removeInteraction(modifyInteraction) + midpointLayer.remove() + touchHandler.destroy() + keyboardHandler.destroy() + } + } +} diff --git a/plugins/beta/draw-ol/src/edit/keyboardHandler.js b/plugins/beta/draw-ol/src/edit/keyboardHandler.js new file mode 100644 index 00000000..0c021a36 --- /dev/null +++ b/plugins/beta/draw-ol/src/edit/keyboardHandler.js @@ -0,0 +1,156 @@ +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 NUDGE_PX = 1 +const STEP_PX = 5 + +/** + * Keyboard handler for edit mode. + * + * Space — select nearest vertex to safe-zone center + * Alt+Arrow — navigate vertices / midpoints spatially + * Arrow — nudge selected vertex (Shift = fine, plain = coarse) + * Delete — delete selected vertex + * Ctrl/Cmd+Z — undo + * + * @param {{ map, container, getState, setState, onVertexMoved, onInserted, onDeleted, onUndo }} + * @returns {{ destroy }} + */ +export const createKeyboardHandler = ({ + map, container, getState, setState, + onVertexMoved, onInserted, onDeleted, onUndo +}) => { + // Accumulate keyboard nudge moves for a single undo entry + let keyMoveStart = null + let keyMoveIndex = null + + const selectNearest = () => { + const { vertecies, midpoints } = getState() + if (!vertecies.length) return + + const centerCoord = map.getView().getCenter() + const centerPx = coordToPixel(map, centerCoord) + if (!centerPx) return + + const allPixels = [ + ...vertecies.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) + const type = idx < vertecies.length ? 'vertex' : 'midpoint' + setState({ selectedVertexIndex: idx, selectedVertexType: type }) + } + + const navigateTo = (direction) => { + const { selectedVertexIndex, vertecies, midpoints } = getState() + if (!vertecies.length) return + + const allCoords = [...vertecies, ...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) + const type = idx < vertecies.length ? 'vertex' : 'midpoint' + setState({ selectedVertexIndex: idx, selectedVertexType: type }) + } + + const nudge = (e) => { + const { selectedVertexIndex, selectedVertexType, vertecies, midpoints, olFeature } = getState() + if (!olFeature) return + + if (selectedVertexType === 'midpoint') { + // Nudge on midpoint = insert vertex at midpoint, then move it + const localIdx = selectedVertexIndex - vertecies.length + const result = insertAtMidpoint(olFeature, midpoints, selectedVertexIndex, vertecies.length) + if (!result) return + onInserted({ insertedIndex: result.insertedIndex }) + setState({ selectedVertexIndex: result.insertedIndex, selectedVertexType: 'vertex' }) + return + } + + if (selectedVertexIndex < 0 || !vertecies[selectedVertexIndex]) 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] + + const current = vertecies[selectedVertexIndex] + + if (!keyMoveStart) { + keyMoveStart = [...current] + keyMoveIndex = selectedVertexIndex + } + + const newCoord = nudgeCoord(map, current, dx, dy) + moveVertex(olFeature, selectedVertexIndex, newCoord) + setState({ vertecies: vertecies.map((c, i) => i === selectedVertexIndex ? newCoord : c) }) + } + + const onKeydown = (e) => { + if (document.activeElement !== container) return + + if (e.key === ' ' && getState().selectedVertexIndex < 0) { + e.preventDefault() + selectNearest() + return + } + + if (e.altKey && ARROW_KEYS.has(e.key) && getState().selectedVertexIndex >= 0) { + e.preventDefault() + e.stopPropagation() + navigateTo(e.key) + return + } + + if (!e.altKey && ARROW_KEYS.has(e.key) && getState().selectedVertexIndex >= 0) { + e.preventDefault() + e.stopPropagation() + nudge(e) + return + } + + if (e.key === 'z' && (e.metaKey || e.ctrlKey)) { + const tag = document.activeElement?.tagName + if (tag === 'INPUT' || tag === 'TEXTAREA') return + e.preventDefault() + e.stopPropagation() + onUndo() + } + } + + const onKeyup = (e) => { + if (document.activeElement !== container) return + + // Commit accumulated keyboard nudge as single undo entry + if (ARROW_KEYS.has(e.key) && keyMoveStart && keyMoveIndex != null) { + onVertexMoved({ vertexIndex: keyMoveIndex, previousCoord: keyMoveStart }) + keyMoveStart = null + keyMoveIndex = 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 }) + } + } +} diff --git a/plugins/beta/draw-ol/src/edit/midpointLayer.js b/plugins/beta/draw-ol/src/edit/midpointLayer.js new file mode 100644 index 00000000..db158979 --- /dev/null +++ b/plugins/beta/draw-ol/src/edit/midpointLayer.js @@ -0,0 +1,54 @@ +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' +import { getMidpointStyle } from '../core/styles.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). + */ +export const createMidpointLayer = (map) => { + const source = new VectorSource() + const layer = new VectorLayer({ + source, + style: getMidpointStyle(), + zIndex: 101 + }) + map.addLayer(layer) + + return { + /** + * Recompute and render midpoints from a geometry object. + * @param {{ type: string, coordinates: any }} geom - plain GeoJSON geometry + */ + 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) + }, + + /** Current midpoint coordinates in order. */ + getCoords () { + return source.getFeatures() + .sort((a, b) => a.get('midpointIndex') - b.get('midpointIndex')) + .map(f => f.getGeometry().getCoordinates()) + }, + + clear () { + source.clear() + }, + + remove () { + source.clear() + map.removeLayer(layer) + } + } +} diff --git a/plugins/beta/draw-ol/src/edit/touchHandler.js b/plugins/beta/draw-ol/src/edit/touchHandler.js new file mode 100644 index 00000000..2fbac742 --- /dev/null +++ b/plugins/beta/draw-ol/src/edit/touchHandler.js @@ -0,0 +1,107 @@ +import { coordToPixel, pixelToCoord } from '../utils/olCoords.js' +import { createTouchTarget, showTouchTarget, hideTouchTarget, isOnTouchTarget } from '../utils/touchTarget.js' +import { moveVertex } from './vertexOps.js' + +/** + * Touch vertex drag handler for edit mode. + * + * Shows an SVG offset target below the finger when a vertex is selected in + * touch mode, allowing accurate repositioning without finger occlusion. + * Drag moves the vertex directly (bypasses OL Modify). + * + * @param {{ map, container, olFeature, getState, setState, onVertexMoved }} + * @returns {{ onTap, updateTargetPosition, hide, destroy }} + */ +export const createTouchHandler = ({ map, container, getState, setState, onVertexMoved }) => { + const targetEl = createTouchTarget(container) + + // Per-drag state + let dragStartCoord = null // vertex coordinate at touch start + let dragStartIndex = null // vertex index being dragged + let vertexTouchDelta = null // offset from touch point to vertex pixel + let targetTouchDelta = null // offset from touch point to target element + + // --- Target positioning --- + + const updateTargetPosition = () => { + const { selectedVertexIndex, vertecies } = getState() + if (selectedVertexIndex < 0 || !vertecies[selectedVertexIndex]) { + hideTouchTarget(targetEl) + return + } + const pixel = coordToPixel(map, vertecies[selectedVertexIndex]) + showTouchTarget(targetEl, pixel) + } + + // --- Touch event handlers --- + + const onTouchstart = (e) => { + if (!isOnTouchTarget(e.target)) return + const { selectedVertexIndex, vertecies } = getState() + const vertex = vertecies[selectedVertexIndex] + if (!vertex) return + + const touch = { x: e.touches[0].clientX, y: e.touches[0].clientY } + const vertexPx = coordToPixel(map, vertex) + const style = window.getComputedStyle(targetEl) + + dragStartCoord = [...vertex] + dragStartIndex = selectedVertexIndex + vertexTouchDelta = { x: touch.x - vertexPx.x, y: touch.y - vertexPx.y } + targetTouchDelta = { x: touch.x - parseFloat(style.left), y: touch.y - parseFloat(style.top) } + + e.preventDefault() + } + + const onTouchmove = (e) => { + if (!isOnTouchTarget(e.target) || dragStartIndex == null) return + e.preventDefault() + + const touch = { x: e.touches[0].clientX, y: e.touches[0].clientY } + const newVertexPx = { x: touch.x - vertexTouchDelta.x, y: touch.y - vertexTouchDelta.y } + const newCoord = pixelToCoord(map, newVertexPx) + const olFeature = getState().olFeature + if (!olFeature) return + + moveVertex(olFeature, dragStartIndex, newCoord) + setState({ vertecies: getState().vertecies.map((c, i) => i === dragStartIndex ? newCoord : c) }) + showTouchTarget(targetEl, { x: touch.x - targetTouchDelta.x, y: touch.y - targetTouchDelta.y }) + } + + const onTouchend = (e) => { + if (dragStartIndex == null) return + + const olFeature = getState().olFeature + const { vertecies } = getState() + const finalCoord = vertecies[dragStartIndex] + + if (finalCoord && dragStartCoord) { + onVertexMoved({ + vertexIndex: dragStartIndex, + previousCoord: dragStartCoord + }) + } + + 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 { + updateTargetPosition, + hide () { hideTouchTarget(targetEl) }, + + destroy () { + container.removeEventListener('touchstart', onTouchstart) + container.removeEventListener('touchmove', onTouchmove) + container.removeEventListener('touchend', onTouchend) + hideTouchTarget(targetEl) + } + } +} diff --git a/plugins/beta/draw-ol/src/edit/undoOps.js b/plugins/beta/draw-ol/src/edit/undoOps.js new file mode 100644 index 00000000..ae1c2d12 --- /dev/null +++ b/plugins/beta/draw-ol/src/edit/undoOps.js @@ -0,0 +1,76 @@ +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] + 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) + 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]) + 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-ol/src/edit/vertexHitTest.js b/plugins/beta/draw-ol/src/edit/vertexHitTest.js new file mode 100644 index 00000000..d4125c9e --- /dev/null +++ b/plugins/beta/draw-ol/src/edit/vertexHitTest.js @@ -0,0 +1,65 @@ +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[][]} vertecies - flat coordinate array [[e,n], ...] + * @param {{ x: number, y: number }} pixel + * @returns {{ index: number, type: 'vertex' } | null} + */ +export const findNearestVertex = (map, vertecies, pixel) => { + let bestIdx = -1 + let bestDist = PIXEL_TOLERANCE + + vertecies.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) + * @returns {{ index: number, type: 'midpoint' } | null} + */ +export const findNearestMidpoint = (map, midpoints, pixel, vertexCount) => { + let bestIdx = -1 + let bestDist = PIXEL_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. + * + * @returns {{ index: number, type: 'vertex'|'midpoint' } | null} + */ +export const findNearest = (map, vertecies, midpoints, pixel) => { + return findNearestVertex(map, vertecies, pixel) ?? + findNearestMidpoint(map, midpoints, pixel, vertecies.length) +} diff --git a/plugins/beta/draw-ol/src/edit/vertexOps.js b/plugins/beta/draw-ol/src/edit/vertexOps.js new file mode 100644 index 00000000..70a99803 --- /dev/null +++ b/plugins/beta/draw-ol/src/edit/vertexOps.js @@ -0,0 +1,85 @@ +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) + + 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[][]} vertecies - 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] + geom.setCoordinates(geojsonGeom.coordinates) +} diff --git a/plugins/beta/draw-ol/src/events.js b/plugins/beta/draw-ol/src/events.js new file mode 100644 index 00000000..72cddfb6 --- /dev/null +++ b/plugins/beta/draw-ol/src/events.js @@ -0,0 +1,106 @@ +/** + * Button and manager event wiring for the OL draw plugin. + * Mirrors draw-ml/events.js structure but uses manager.on/off instead of map.on/off. + */ +export function attachEvents ({ pluginState, mapProvider, buttonConfig, eventBus }) { + const { drawDone, drawCancel, drawUndo, drawDeletePoint } = buttonConfig + const { draw } = mapProvider + const { dispatch, feature, tempFeature } = pluginState + + const resetState = () => { + draw.undoStack.clear() + dispatch({ type: 'SET_MODE', payload: null }) + dispatch({ type: 'SET_FEATURE', payload: { feature: null, tempFeature: null } }) + } + + // --- Button handlers --- + + const handleDone = () => { + draw.undoStack.clear() + draw.done() + } + + const handleCancel = () => { + const mode = draw.getMode() + if (mode === 'edit_vertex' && tempFeature?.id) { + // Restore original geometry by re-adding the pre-edit feature + draw.add(feature) + } + draw.cancel() + resetState() + eventBus.emit('draw:cancelled', feature) + } + + const handleUndo = () => { + draw.undo() + } + + const handleDeleteVertex = () => { + draw.deleteVertex() + } + + // --- Manager event handlers --- + + const onCreate = (geojsonFeature) => { + resetState() + draw.changeMode('disabled') + eventBus.emit('draw:created', geojsonFeature) + } + + const onEditFinish = (geojsonFeature) => { + resetState() + draw.changeMode('disabled') + eventBus.emit('draw:edited', geojsonFeature) + } + + const onCancel = () => { + // Fired by draw.cancel() / drawabort — only when not user-initiated via button + } + + 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, numVertecies: e.numVertecies } }) + } + + const onUndoChange = (length) => { + dispatch({ type: 'SET_UNDO_STACK_LENGTH', payload: length }) + } + + const onUpdate = (geojsonFeature) => { + eventBus.emit('draw:updated', geojsonFeature) + } + + // --- Wire up --- + + drawDone.onClick = handleDone + drawCancel.onClick = handleCancel + drawUndo.onClick = handleUndo + if (drawDeletePoint) drawDeletePoint.onClick = handleDeleteVertex + + 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 + + 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-ol/src/olDraw.js b/plugins/beta/draw-ol/src/olDraw.js new file mode 100644 index 00000000..03b22716 --- /dev/null +++ b/plugins/beta/draw-ol/src/olDraw.js @@ -0,0 +1,28 @@ +import { OLDrawManager } from './core/OLDrawManager.js' + +/** + * Creates the OLDrawManager, attaches it to mapProvider, and wires + * any app-level events (e.g. MAP_SET_SIZE for scale-aware touch targets). + * + * @returns {{ remove: () => void }} + */ +export const createOLDraw = ({ mapProvider, events, eventBus }) => { + const { map } = mapProvider + const manager = new OLDrawManager(map) + + mapProvider.draw = manager + + const handleSetMapSize = (size) => { + // Scale factor informs touch target pixel offsets + mapProvider.drawScale = { small: 1, medium: 1.5, large: 2 }[size] ?? 1 + } + eventBus.on(events.MAP_SET_SIZE, handleSetMapSize) + + return { + remove () { + eventBus.off(events.MAP_SET_SIZE, handleSetMapSize) + manager.remove() + mapProvider.draw = null + } + } +} diff --git a/plugins/beta/draw-ol/src/reducer.js b/plugins/beta/draw-ol/src/reducer.js new file mode 100644 index 00000000..a2ae6ba0 --- /dev/null +++ b/plugins/beta/draw-ol/src/reducer.js @@ -0,0 +1,31 @@ +const initialState = { + mode: null, + feature: null, + tempFeature: null, + selectedVertexIndex: -1, + numVertecies: null, + undoStackLength: 0 +} + +const actions = { + SET_MODE: (state, payload) => ({ ...state, mode: payload }), + + SET_FEATURE: (state, payload) => ({ + ...state, + feature: payload.feature === undefined ? state.feature : payload.feature, + tempFeature: payload.tempFeature === undefined ? state.tempFeature : payload.tempFeature + }), + + SET_SELECTED_VERTEX_INDEX: (state, payload) => ({ + ...state, + selectedVertexIndex: payload.index, + numVertecies: payload.numVertecies + }), + + SET_UNDO_STACK_LENGTH: (state, payload) => ({ + ...state, + undoStackLength: payload + }) +} + +export { initialState, actions } diff --git a/plugins/beta/draw-ol/src/utils/geometryHelpers.js b/plugins/beta/draw-ol/src/utils/geometryHelpers.js new file mode 100644 index 00000000..b9f0ea47 --- /dev/null +++ b/plugins/beta/draw-ol/src/utils/geometryHelpers.js @@ -0,0 +1,95 @@ +/** + * 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.flat(1) + case 'MultiLineString': return geom.coordinates.flat(1) + case 'MultiPolygon': return geom.coordinates.flat(2) + 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) => { + segments.push({ start, length: ring.length, path: [i], closed: true }) + start += ring.length + }) + 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) => { + segments.push({ start, length: ring.length, path: [pi, ri], closed: true }) + start += ring.length + }) + }) + 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-ol/src/utils/olCoords.js b/plugins/beta/draw-ol/src/utils/olCoords.js new file mode 100644 index 00000000..7b0abbcb --- /dev/null +++ b/plugins/beta/draw-ol/src/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-ol/src/utils/spatial.js b/plugins/beta/draw-ol/src/utils/spatial.js new file mode 100644 index 00000000..60f3c8ba --- /dev/null +++ b/plugins/beta/draw-ol/src/utils/spatial.js @@ -0,0 +1,28 @@ +/** + * 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.sqrt((start[0] - p[0]) ** 2 + (start[1] - p[1]) ** 2) + const closest = quadrant.reduce((best, p) => dist(p) < dist(best) ? p : best) + return pixels.findIndex(p => JSON.stringify(p) === JSON.stringify(closest)) +} diff --git a/plugins/beta/draw-ol/src/utils/touchTarget.js b/plugins/beta/draw-ol/src/utils/touchTarget.js new file mode 100644 index 00000000..a9299eee --- /dev/null +++ b/plugins/beta/draw-ol/src/utils/touchTarget.js @@ -0,0 +1,41 @@ +/** + * 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. + */ + +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 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 window.SVGElement) || (parent?.ownerSVGElement != null) +} From a9fdc7dd71da51ee61b374681164c093c2623d0f Mon Sep 17 00:00:00 2001 From: Dan Leech Date: Thu, 14 May 2026 11:34:48 +0100 Subject: [PATCH 02/26] Touch done and rubberbanding fixes --- demo/draw-ol.html | 16 ++ demo/js/draw-ol.js | 154 +++++++++++++++ plugins/beta/draw-ol/src/api/addFeature.js | 28 +++ plugins/beta/draw-ol/src/api/deleteFeature.js | 11 ++ plugins/beta/draw-ol/src/api/editFeature.js | 45 +++++ plugins/beta/draw-ol/src/api/newLine.js | 45 +++++ plugins/beta/draw-ol/src/api/newPolygon.js | 45 +++++ plugins/beta/draw-ol/src/draw.scss | 17 ++ plugins/beta/draw-ol/src/draw/DrawMode.js | 56 +++++- plugins/beta/draw-ol/src/draw/drawInput.js | 175 ++++++++++++++++++ plugins/beta/draw-ol/src/events.js | 2 +- plugins/beta/draw-ol/src/index.js | 12 ++ plugins/beta/draw-ol/src/manifest.js | 105 +++++++++++ plugins/beta/draw-ol/src/reducer.js | 7 +- rollup.esm.mjs | 6 + webpack.dev.mjs | 1 + 16 files changed, 715 insertions(+), 10 deletions(-) create mode 100644 demo/draw-ol.html create mode 100644 demo/js/draw-ol.js create mode 100644 plugins/beta/draw-ol/src/api/addFeature.js create mode 100644 plugins/beta/draw-ol/src/api/deleteFeature.js create mode 100644 plugins/beta/draw-ol/src/api/editFeature.js create mode 100644 plugins/beta/draw-ol/src/api/newLine.js create mode 100644 plugins/beta/draw-ol/src/api/newPolygon.js create mode 100644 plugins/beta/draw-ol/src/draw.scss create mode 100644 plugins/beta/draw-ol/src/index.js create mode 100644 plugins/beta/draw-ol/src/manifest.js diff --git a/demo/draw-ol.html b/demo/draw-ol.html new file mode 100644 index 00000000..e7cd812e --- /dev/null +++ b/demo/draw-ol.html @@ -0,0 +1,16 @@ + + + + + + Draw tools demo (OpenLayers) + + + + + +

Draw tools demo (OpenLayers)

+
+ + + diff --git a/demo/js/draw-ol.js b/demo/js/draw-ol.js new file mode 100644 index 00000000..107c4a99 --- /dev/null +++ b/demo/js/draw-ol.js @@ -0,0 +1,154 @@ +// InteractiveMap with OpenLayers provider and draw-ol plugin +import InteractiveMap from '../../src/index.js' +import { ngdMapStyles27700 } from './mapStyles.js' +import { transformGeocodeRequest, transformVtsRequest27700 } from './auth.js' +// Providers +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 searchPlugin from '/plugins/search/src/index.js' + +const drawPlugin = createDrawPlugin({ + snapLayers: ['OS/TopographicArea_1/Agricultural Land', 'OS/TopographicLine/Building Outline'] +}) + +const interactiveMap = new InteractiveMap('map', { + behaviour: 'hybrid', + mapProvider: openLayersProvider({ + zoomAlignment: 'world' + }), + reverseGeocodeProvider: openNamesProvider({ + url: process.env.OS_NEAREST_URL, + transformRequest: transformGeocodeRequest + }), + mapLabel: 'Map showing Carlisle (OpenLayers)', + minZoom: 6, + maxZoom: 20, + autoColorScheme: true, + center: [337584, 504538], + zoom: 14, + containerHeight: '650px', + transformRequest: transformVtsRequest27700, + enableZoomControls: true, + readMapText: true, + plugins: [ + mapStylesPlugin({ + mapStyles: ngdMapStyles27700 + }), + searchPlugin({ + transformRequest: transformGeocodeRequest, + osNamesURL: process.env.OS_NAMES_URL, + width: '300px', + showMarker: false, + }), + drawPlugin + ] +}) + +interactiveMap.on('app:ready', function (e) { + // app ready +}) + +interactiveMap.on('map:ready', function (e) { + interactiveMap.addButton('geometryActions', { + label: 'Draw tools', + mobile: { slot: 'bottom-right', order: 3 }, + tablet: { slot: 'top-middle', order: 3 }, + desktop: { slot: 'top-middle', order: 3 }, + menuItems: [{ + id: 'drawPolygon', + label: 'Draw polygon', + iconSvgContent: '', + onClick: function (e) { + interactiveMap.toggleButtonState('geometryActions', 'hidden', true) + drawPlugin.newPolygon(crypto.randomUUID(), { + stroke: '#e6c700', + fill: 'rgba(255, 221, 0, 0.1)' + }) + } + },{ + id: 'drawLine', + label: 'Draw line', + iconSvgContent: '', + onClick: function (e) { + interactiveMap.toggleButtonState('geometryActions', 'hidden', true) + drawPlugin.newLine(crypto.randomUUID(), { + stroke: { outdoor: '#99704a', dark: '#ffffff' }, + strokeWidth: 6 + }) + } + },{ + id: 'editFeature', + label: 'Edit feature', + iconSvgContent: '', + isDisabled: true, + onClick: function (e) { + const editSuccess = drawPlugin.editFeature(selectedFeatureIds[0]) + if (!editSuccess) { + return + } + interactiveMap.toggleButtonState('geometryActions', 'hidden', true) + } + },{ + id: 'deleteFeature', + label: 'Delete feature', + iconSvgContent: '', + isDisabled: true, + onClick: function (e) { + interactiveMap.toggleButtonState('geometryActions', 'hidden', false) + drawPlugin.deleteFeature(selectedFeatureIds) + interactiveMap.toggleButtonState('drawPolygon', 'disabled', false) + interactiveMap.toggleButtonState('drawLine', 'disabled', false) + interactiveMap.toggleButtonState('editFeature', 'disabled', true) + interactiveMap.toggleButtonState('deleteFeature', 'disabled', true) + } + }] + }) +}) + +interactiveMap.on('datasets:ready', function () { + // datasets ready +}) + +let selectedFeatureIds = [] + +interactiveMap.on('draw:ready', function () { + // drawPlugin.addFeature({ + // id: 'test1234', + // type: 'Feature', + // geometry: {'type':'Polygon','coordinates':[[[337612,504612],[337592,504595],[337575,504583],[337570,504582],[337560,504582],[337554,504590],[337559,504596],[337568,504604],[337572,504610],[337582,504611],[337585,504610],[337602,504612],[337603,504607],[337605,504605],[337609,504605],[337612,504612]],[[337598,504609],[337587,504605],[337577,504605],[337572,504607],[337573,504610],[337575,504613],[337580,504613],[337586,504612],[337593,504613],[337597,504611],[337598,504609]]]}, + // stroke: 'rgba(0,112,60,1)', + // fill: 'rgba(0,112,60,0.2)', + // strokeWidth: 2 + // }) + // drawPlugin.editFeature('test1234') +}) + +interactiveMap.on('draw:started', function (e) { + console.log('draw:started') +}) + +interactiveMap.on('draw:editstart', function (e) { + console.log('draw:editstart', e) +}) + +interactiveMap.on('draw:created', function (e) { + console.log('draw:created', e) + interactiveMap.toggleButtonState('geometryActions', 'hidden', false) +}) + +interactiveMap.on('draw:updated', function (e) { + console.log('draw:updated', e) +}) + +interactiveMap.on('draw:edited', function (e) { + console.log('draw:edited', e) + interactiveMap.toggleButtonState('geometryActions', 'hidden', false) +}) + +interactiveMap.on('draw:cancelled', function (e) { + console.log('draw:cancelled', e) + interactiveMap.toggleButtonState('geometryActions', 'hidden', false) +}) \ No newline at end of file diff --git a/plugins/beta/draw-ol/src/api/addFeature.js b/plugins/beta/draw-ol/src/api/addFeature.js new file mode 100644 index 00000000..f580fb6f --- /dev/null +++ b/plugins/beta/draw-ol/src/api/addFeature.js @@ -0,0 +1,28 @@ +/** + * Add a pre-drawn feature (GeoJSON) to the draw layer. + * The feature will be stored and available for editing. + * + * @param {object} context - plugin context + * @param {object} feature - GeoJSON feature with id, geometry, properties + */ +export const addFeature = ({ mapProvider, services }, feature) => { + const { draw } = mapProvider + const { eventBus } = services + + if (!draw) return + + // Extract style properties from top level + const { stroke, fill, strokeWidth, properties, ...featureRest } = feature + const flatFeature = { + ...featureRest, + properties: { + ...properties, + ...(stroke && { stroke }), + ...(fill && { fill }), + ...(strokeWidth && { strokeWidth }) + } + } + + draw.add(flatFeature) + eventBus.emit('draw:add', flatFeature) +} diff --git a/plugins/beta/draw-ol/src/api/deleteFeature.js b/plugins/beta/draw-ol/src/api/deleteFeature.js new file mode 100644 index 00000000..1ed647f9 --- /dev/null +++ b/plugins/beta/draw-ol/src/api/deleteFeature.js @@ -0,0 +1,11 @@ +/** + * Delete a feature from the draw layer by ID. + * + * @param {object} context - plugin context + * @param {string} featureId - ID of the feature to delete + */ +export const deleteFeature = ({ mapProvider }, featureId) => { + const { draw } = mapProvider + if (!draw) return + draw.delete(featureId) +} diff --git a/plugins/beta/draw-ol/src/api/editFeature.js b/plugins/beta/draw-ol/src/api/editFeature.js new file mode 100644 index 00000000..2af32b55 --- /dev/null +++ b/plugins/beta/draw-ol/src/api/editFeature.js @@ -0,0 +1,45 @@ +/** + * Programmatically start editing a feature. + * + * @param {object} context - plugin context + * @param {string} featureId - ID of the feature to edit + * @param {object} options - { snapLayers } + * @returns {boolean} true if edit mode entered, false if feature not found + */ +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 + + // Feature must exist before entering edit mode + const existingFeature = draw.get(featureId) + if (!existingFeature) return false + + const mode = existingFeature.geometry.type === 'LineString' ? 'edit_line' : 'edit_polygon' + eventBus.emit('draw:editstart', { mode: 'edit_vertex' }) + + // Snap layers (for later when snap is implemented) + const snapLayers = options.snapLayers ?? pluginConfig.snapLayers ?? null + + draw.changeMode('edit_vertex', { + container: appState.layoutRefs.viewportRef.current, + deleteVertexButtonId: `${appConfig.id}-draw-delete-point`, + interfaceType: appState.interfaceType, + featureId + }) + + // Store the feature for cancel/restore + dispatch({ + type: 'SET_FEATURE', + payload: { feature: existingFeature, tempFeature: existingFeature } + }) + + dispatch({ type: 'SET_MODE', payload: 'edit_vertex' }) + return true +} diff --git a/plugins/beta/draw-ol/src/api/newLine.js b/plugins/beta/draw-ol/src/api/newLine.js new file mode 100644 index 00000000..e5b25602 --- /dev/null +++ b/plugins/beta/draw-ol/src/api/newLine.js @@ -0,0 +1,45 @@ +/** + * Programmatically start drawing a new line. + * + * @param {object} context - plugin context + * @param {string} featureId - unique ID for this feature + * @param {object} options - { snapLayers, stroke, fill, strokeWidth, properties } + */ +export const newLine = ( + { appState, appConfig, pluginConfig, pluginState, mapProvider, services }, + featureId, + options = {} +) => { + const { dispatch } = pluginState + const { draw } = mapProvider + const { eventBus } = services + + if (!draw) return + + eventBus.emit('draw:started', { mode: 'draw_line' }) + + // Snap layers (for later when snap is implemented) + const snapLayers = options.snapLayers ?? pluginConfig.snapLayers ?? null + + // Extract style properties and merge with custom properties + const { stroke, fill, strokeWidth, properties: customProperties, ...modeOptions } = options + const properties = { + ...customProperties, + ...(stroke && { stroke }), + ...(fill && { fill }), + ...(strokeWidth && { strokeWidth }) + } + + draw.changeMode('draw_line', { + container: appState.layoutRefs.viewportRef.current, + interfaceType: appState.interfaceType, + addVertexButtonId: `${appConfig.id}-draw-add-point`, + featureId, + geometryType: 'LineString', + properties, + mapProvider, + ...modeOptions + }) + + dispatch({ type: 'SET_MODE', payload: 'draw_line' }) +} diff --git a/plugins/beta/draw-ol/src/api/newPolygon.js b/plugins/beta/draw-ol/src/api/newPolygon.js new file mode 100644 index 00000000..1641e0d3 --- /dev/null +++ b/plugins/beta/draw-ol/src/api/newPolygon.js @@ -0,0 +1,45 @@ +/** + * Programmatically start drawing a new polygon. + * + * @param {object} context - plugin context + * @param {string} featureId - unique ID for this feature + * @param {object} options - { snapLayers, stroke, fill, strokeWidth, properties } + */ +export const newPolygon = ( + { appState, appConfig, pluginConfig, pluginState, mapProvider, services }, + featureId, + options = {} +) => { + const { dispatch } = pluginState + const { draw } = mapProvider + const { eventBus } = services + + if (!draw) return + + eventBus.emit('draw:started', { mode: 'draw_polygon' }) + + // Snap layers (for later when snap is implemented) + const snapLayers = options.snapLayers ?? pluginConfig.snapLayers ?? null + + // Extract style properties and merge with custom properties + const { stroke, fill, strokeWidth, properties: customProperties, ...modeOptions } = options + const properties = { + ...customProperties, + ...(stroke && { stroke }), + ...(fill && { fill }), + ...(strokeWidth && { strokeWidth }) + } + + draw.changeMode('draw_polygon', { + container: appState.layoutRefs.viewportRef.current, + interfaceType: appState.interfaceType, + addVertexButtonId: `${appConfig.id}-draw-add-point`, + featureId, + geometryType: 'Polygon', + properties, + mapProvider, + ...modeOptions + }) + + dispatch({ type: 'SET_MODE', payload: 'draw_polygon' }) +} diff --git a/plugins/beta/draw-ol/src/draw.scss b/plugins/beta/draw-ol/src/draw.scss new file mode 100644 index 00000000..eed868a0 --- /dev/null +++ b/plugins/beta/draw-ol/src/draw.scss @@ -0,0 +1,17 @@ +// Draw plugin styles for 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; +} diff --git a/plugins/beta/draw-ol/src/draw/DrawMode.js b/plugins/beta/draw-ol/src/draw/DrawMode.js index bab2324f..fc9c186b 100644 --- a/plugins/beta/draw-ol/src/draw/DrawMode.js +++ b/plugins/beta/draw-ol/src/draw/DrawMode.js @@ -1,7 +1,6 @@ import Draw from 'ol/interaction/Draw.js' import { createSketchStyle } from '../core/styles.js' import { createDrawInput } from './drawInput.js' -import { getCoords } from '../utils/geometryHelpers.js' /** * Draw mode — handles draw_polygon and draw_line. @@ -34,22 +33,49 @@ export const createDrawMode = ({ map, manager, options }) => { // Track vertex count for the Done button enabled state let sketchFeature = null + let pendingVertexUpdate = null 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 - const numVertecies = Math.max(0, coords.length - 1) + const rawCoords = geom.getCoordinates() + + let numVertecies = 0 + + if (geometryType === 'Polygon' && rawCoords.length > 0) { + // For Polygon, OL stores rings as [[x1,y1], [x2,y2], ..., [x1,y1], rubber-band] + // Subtract 2: 1 for closing vertex + 1 for rubber-band + const exteriorRing = rawCoords[0] + numVertecies = Math.max(0, exteriorRing.length - 2) + } else if (geometryType === 'LineString') { + // For LineString, subtract 1 for rubber-band coordinate + numVertecies = Math.max(0, rawCoords.length - 1) + } + manager.emit('vertexchange', { numVertecies }) } + const onGeometryChange = () => { + // Debounce geometry changes to avoid intermediate states + if (pendingVertexUpdate) { + clearTimeout(pendingVertexUpdate) + } + pendingVertexUpdate = setTimeout(() => { + updateVertexCount() + pendingVertexUpdate = null + }, 5) + } + drawInteraction.on('drawstart', (e) => { sketchFeature = e.feature - sketchFeature.getGeometry().on('change', updateVertexCount) + sketchFeature.getGeometry().on('change', onGeometryChange) }) drawInteraction.on('drawend', (e) => { + if (pendingVertexUpdate) { + clearTimeout(pendingVertexUpdate) + pendingVertexUpdate = null + } const olFeature = e.feature olFeature.setId(String(featureId)) olFeature.setProperties(properties) @@ -60,6 +86,10 @@ export const createDrawMode = ({ map, manager, options }) => { }) drawInteraction.on('drawabort', () => { + if (pendingVertexUpdate) { + clearTimeout(pendingVertexUpdate) + pendingVertexUpdate = null + } manager.emit('cancel') }) @@ -70,9 +100,19 @@ export const createDrawMode = ({ map, manager, options }) => { // Validate minimum points before finishing if (sketchFeature) { const geom = sketchFeature.getGeometry() - const coords = getCoords({ type: geometryType, coordinates: geom.getCoordinates() }) - const min = geometryType === 'Polygon' ? 4 : 3 // +1 for rubber band - if (coords.length < min) return + const rawCoords = geom.getCoordinates() + let numVertecies = 0 + + if (geometryType === 'Polygon' && rawCoords.length > 0) { + const exteriorRing = rawCoords[0] + numVertecies = Math.max(0, exteriorRing.length - 2) + } else if (geometryType === 'LineString') { + numVertecies = Math.max(0, rawCoords.length - 1) + } + + // Need at least 3 vertices for Polygon, 2 for LineString + const minVertices = geometryType === 'Polygon' ? 3 : 2 + if (numVertecies < minVertices) return } drawInteraction.finishDrawing() }, diff --git a/plugins/beta/draw-ol/src/draw/drawInput.js b/plugins/beta/draw-ol/src/draw/drawInput.js index d4af022b..8b8e9b8a 100644 --- a/plugins/beta/draw-ol/src/draw/drawInput.js +++ b/plugins/beta/draw-ol/src/draw/drawInput.js @@ -6,6 +6,11 @@ * and the Done / Add Point / Cancel button wiring. */ +import { coordToPixel, pixelDist } from '../utils/olCoords.js' +import { getCoords } from '../utils/geometryHelpers.js' + +const SNAP_TOLERANCE = 12 // pixels + /** * @param {object} params * @param {import('ol/interaction/Draw').default} params.drawInteraction @@ -16,11 +21,168 @@ export const createDrawInput = ({ drawInteraction, manager, options }) => { const { container, addVertexButtonId, mapProvider } = options let interfaceType = options.interfaceType + let map = null + let sketchFeature = null + let pendingVertexUpdate = null + let lastPlacedCoord = null + + // Track sketch feature from draw events + const onDrawStart = (e) => { + sketchFeature = e.feature + lastPlacedCoord = null + } + + const onDrawEnd = () => { + sketchFeature = null + lastPlacedCoord = null + if (pendingVertexUpdate) { + clearTimeout(pendingVertexUpdate) + pendingVertexUpdate = null + } + } + + drawInteraction.on('drawstart', onDrawStart) + drawInteraction.on('drawend', onDrawEnd) + drawInteraction.on('drawabort', onDrawEnd) + + // Get map reference when drawInteraction is added + const getMap = () => { + if (!map) { + // drawInteraction is added to map in DrawMode; extract it + map = drawInteraction.getMap() + } + return map + } + + // --- Update sketch feature with current center (rubberbanding) --- + const updateSketchRubberbanding = () => { + if (!sketchFeature) return + + const geom = sketchFeature.getGeometry() + const coords = geom.getCoordinates() + if (coords.length === 0) return + + const centerCoord = mapProvider.getCenter() + + // For LineString, update the last (rubber-band) coordinate + if (geom.getType() === 'LineString') { + const updated = [...coords] + updated[updated.length - 1] = centerCoord + geom.setCoordinates(updated) + } + // For Polygon, update the last coordinate in the current ring + else if (geom.getType() === 'Polygon') { + const updated = coords.map((ring, ringIdx) => { + if (ringIdx === 0) { // Only update first ring (exterior) + const ringUpdated = [...ring] + ringUpdated[ringUpdated.length - 1] = centerCoord + return ringUpdated + } + return ring + }) + geom.setCoordinates(updated) + } + } + + // --- Check if close enough to first vertex to close shape --- + const isCloseToFirstVertex = (map, currentCoord, sketchCoords, geometryType) => { + if (geometryType !== 'Polygon' || sketchCoords.length < 4) return false + + const firstCoord = sketchCoords[0] + const currentPixel = coordToPixel(map, currentCoord) + const firstPixel = coordToPixel(map, firstCoord) + + if (!currentPixel || !firstPixel) return false + return pixelDist(currentPixel, firstPixel) < SNAP_TOLERANCE + } + + // --- Update vertex count display after appending coordinates --- + const updateDisplayedVertexCount = () => { + if (!sketchFeature) return + const geom = sketchFeature.getGeometry() + const rawCoords = geom.getCoordinates() + + let numVertecies = 0 + + if (geom.getType() === 'Polygon' && rawCoords.length > 0) { + // For Polygon, OL stores rings as [[x1,y1], [x2,y2], ..., [x1,y1], rubber-band] + // We need to subtract: 1 for closing vertex + 1 for rubber-band = 2 total + const exteriorRing = rawCoords[0] + numVertecies = Math.max(0, exteriorRing.length - 2) + console.log('Polygon vertex count:', { ringLength: exteriorRing.length, numVertecies, ring: exteriorRing }) + } else if (geom.getType() === 'LineString') { + // For LineString, OL stores coords with trailing rubber-band: [v1, v2, ..., vN, rubber] + // Subtract 1 for the rubber-band coordinate + numVertecies = Math.max(0, rawCoords.length - 1) + console.log('LineString vertex count:', { coordsLength: rawCoords.length, numVertecies }) + } + + manager.emit('vertexchange', { numVertecies }) + } // --- Place a vertex at the current map center (crosshair position) --- const placeVertex = () => { + const map = getMap() const coord = mapProvider.getCenter() + + if (sketchFeature) { + const geom = sketchFeature.getGeometry() + const rawCoords = geom.getCoordinates() + + // For Polygon: rawCoords is array of rings, get exterior ring coords + // For LineString: rawCoords is the coord array directly + let sketchCoords = rawCoords + if (geom.getType() === 'Polygon') { + sketchCoords = rawCoords[0] || [] + } + + // Check if same coord placed twice consecutively (without moving crosshair) + // For polygons: need at least 2 user vertices before allowing double-tap close + // For lines: need at least 1 user vertex before allowing double-tap close + if (lastPlacedCoord && + lastPlacedCoord[0] === coord[0] && + lastPlacedCoord[1] === coord[1]) { + // Check minimum vertices before finishing + let numVertecies = 0 + if (geom.getType() === 'Polygon' && sketchCoords.length > 0) { + numVertecies = Math.max(0, sketchCoords.length - 2) + } else if (geom.getType() === 'LineString') { + numVertecies = Math.max(0, sketchCoords.length - 1) + } + + const minForFinish = geom.getType() === 'Polygon' ? 2 : 1 + if (numVertecies >= minForFinish) { + drawInteraction.finishDrawing() + lastPlacedCoord = null + return + } + } + + // Check if close to first vertex (for polygon closure via proximity) + if (geom.getType() === 'Polygon' && sketchCoords.length >= 4) { + const firstCoord = sketchCoords[0] + const currentPixel = coordToPixel(map, coord) + const firstPixel = coordToPixel(map, firstCoord) + if (currentPixel && firstPixel && pixelDist(currentPixel, firstPixel) < SNAP_TOLERANCE) { + drawInteraction.finishDrawing() + lastPlacedCoord = null + return + } + } + } + drawInteraction.appendCoordinates([coord]) + lastPlacedCoord = coord + + // Cancel any pending update and schedule a new one + // This ensures we only emit the final calculated count after OL finishes updating + if (pendingVertexUpdate) { + clearTimeout(pendingVertexUpdate) + } + pendingVertexUpdate = setTimeout(() => { + updateDisplayedVertexCount() + pendingVertexUpdate = null + }, 10) } // --- Event handlers --- @@ -51,19 +213,32 @@ export const createDrawInput = ({ drawInteraction, manager, options }) => { interfaceType = 'touch' } + // Update rubberbanding line as user moves around in keyboard/touch modes + const onPointerMove = () => { + updateSketchRubberbanding() + } + window.addEventListener('keydown', onKeydown) window.addEventListener('click', onButtonClick) container.addEventListener('pointerdown', onPointerdown) container.addEventListener('touchstart', onTouchstart, { passive: true }) + container.addEventListener('pointermove', onPointerMove) return { getInterfaceType: () => interfaceType, destroy () { + if (pendingVertexUpdate) { + clearTimeout(pendingVertexUpdate) + } window.removeEventListener('keydown', onKeydown) window.removeEventListener('click', onButtonClick) container.removeEventListener('pointerdown', onPointerdown) container.removeEventListener('touchstart', onTouchstart) + container.removeEventListener('pointermove', onPointerMove) + drawInteraction.un('drawstart', onDrawStart) + drawInteraction.un('drawend', onDrawEnd) + drawInteraction.un('drawabort', onDrawEnd) } } } diff --git a/plugins/beta/draw-ol/src/events.js b/plugins/beta/draw-ol/src/events.js index 72cddfb6..5efff4e9 100644 --- a/plugins/beta/draw-ol/src/events.js +++ b/plugins/beta/draw-ol/src/events.js @@ -63,7 +63,7 @@ export function attachEvents ({ pluginState, mapProvider, buttonConfig, eventBus } const onVertexChange = (e) => { - dispatch({ type: 'SET_SELECTED_VERTEX_INDEX', payload: { index: -1, numVertecies: e.numVertecies } }) + dispatch({ type: 'SET_VERTEX_COUNT', payload: e.numVertecies }) } const onUndoChange = (length) => { diff --git a/plugins/beta/draw-ol/src/index.js b/plugins/beta/draw-ol/src/index.js new file mode 100644 index 00000000..0b7cf676 --- /dev/null +++ b/plugins/beta/draw-ol/src/index.js @@ -0,0 +1,12 @@ +import './draw.scss' + +export default function createPlugin (options = {}) { + return { + ...options, + id: 'draw', + load: async () => { + const module = (await import(/* webpackChunkName: "im-draw-ol-plugin" */ './manifest.js')).manifest + return module + } + } +} diff --git a/plugins/beta/draw-ol/src/manifest.js b/plugins/beta/draw-ol/src/manifest.js new file mode 100644 index 00000000..d0e284da --- /dev/null +++ b/plugins/beta/draw-ol/src/manifest.js @@ -0,0 +1,105 @@ +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' + +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', + hiddenWhen: ({ pluginState }) => !pluginState.mode, + ...createButtonSlots(true) + }, + { + id: 'drawAddPoint', + label: 'Add point', + variant: 'primary', + hiddenWhen: ({ appState, pluginState }) => + !['draw_polygon', 'draw_line'].includes(pluginState.mode) || appState.interfaceType !== 'touch', + ...createButtonSlots(true) + }, + { + id: 'drawDone', + label: 'Done', + variant: 'primary', + hiddenWhen: ({ pluginState }) => + !['draw_polygon', 'draw_line', 'edit_vertex'].includes(pluginState.mode), + enableWhen: ({ pluginState }) => { + if (pluginState.mode === 'draw_polygon') return pluginState.numVertecies >= 3 + if (pluginState.mode === 'draw_line') return pluginState.numVertecies >= 2 + if (pluginState.mode === 'edit_vertex') return true + return false + }, + ...createButtonSlots(true) + }, + { + id: 'drawMenu', + label: 'Menu', + iconId: 'menu', + 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 }) => pluginState.undoStackLength > 0 + }, + { + id: 'drawDeletePoint', + label: 'Delete point', + iconId: 'trash', + enableWhen: ({ pluginState }) => + pluginState.selectedVertexIndex >= 0 && pluginState.numVertecies > 2, + hiddenWhen: ({ pluginState }) => pluginState.mode !== 'edit_vertex' + } + ], + mobile: { slot: 'bottom-right' }, + tablet: { slot: 'top-middle' }, + desktop: { slot: 'top-middle' } + } + ], + + icons: [ + { + id: 'menu', + svgContent: '' + }, + { + id: 'undo', + svgContent: '' + }, + { + id: 'trash', + svgContent: '' + } + ], + + api: { + newPolygon, + newLine, + editFeature, + addFeature, + deleteFeature + } +} diff --git a/plugins/beta/draw-ol/src/reducer.js b/plugins/beta/draw-ol/src/reducer.js index a2ae6ba0..d6201a2e 100644 --- a/plugins/beta/draw-ol/src/reducer.js +++ b/plugins/beta/draw-ol/src/reducer.js @@ -19,7 +19,12 @@ const actions = { SET_SELECTED_VERTEX_INDEX: (state, payload) => ({ ...state, selectedVertexIndex: payload.index, - numVertecies: payload.numVertecies + numVertecies: payload.numVertecies !== undefined ? payload.numVertecies : state.numVertecies + }), + + SET_VERTEX_COUNT: (state, payload) => ({ + ...state, + numVertecies: payload }), SET_UNDO_STACK_LENGTH: (state, payload) => ({ diff --git a/rollup.esm.mjs b/rollup.esm.mjs index 19ddaee0..be600dec 100644 --- a/rollup.esm.mjs +++ b/rollup.esm.mjs @@ -290,6 +290,12 @@ const ALL_BUILDS = [ outDir: 'plugins/beta/draw-es/dist/esm', manualChunks: (id) => { if (id.includes('/manifest')) return 'im-draw-es-plugin' } }, + { + 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' } + }, { entryPath: './plugins/beta/frame/src/index.js', outDir: 'plugins/beta/frame/dist/esm', diff --git a/webpack.dev.mjs b/webpack.dev.mjs index 882e38bb..cba4190f 100755 --- a/webpack.dev.mjs +++ b/webpack.dev.mjs @@ -19,6 +19,7 @@ export default { entry: { index: path.join(__dirname, 'demo/js/index.js'), draw: path.join(__dirname, 'demo/js/draw.js'), + 'draw-ol': path.join(__dirname, 'demo/js/draw-ol.js'), farming: path.join(__dirname, 'demo/js/farming.js'), planning: path.join(__dirname, 'demo/js/planning.js'), 'planning-ol': path.join(__dirname, 'demo/js/planning-ol.js'), From a286af8de4b324b5b353da440c0b0d04426cc6aa Mon Sep 17 00:00:00 2001 From: Dan Leech Date: Thu, 14 May 2026 15:53:24 +0100 Subject: [PATCH 03/26] Touch and keyboard fixes plus styling --- demo/js/draw-ol.js | 24 ++-- demo/js/planning-ol.js | 2 +- plugins/beta/draw-ol/src/core/styles.js | 78 +++++------- plugins/beta/draw-ol/src/draw/drawInput.js | 38 +++--- plugins/beta/draw-ol/src/edit/EditMode.js | 117 ++++++++++++++---- .../beta/draw-ol/src/edit/keyboardHandler.js | 55 +++++--- .../beta/draw-ol/src/edit/midpointLayer.js | 10 +- plugins/beta/draw-ol/src/edit/touchHandler.js | 57 +++++++-- .../beta/draw-ol/src/edit/vertexHitTest.js | 17 +-- plugins/beta/draw-ol/src/edit/vertexLayer.js | 44 +++++++ .../beta/openlayers/src/utils/tileLayers.js | 4 +- src/App/controls/keyboardActions.js | 4 +- 12 files changed, 306 insertions(+), 144 deletions(-) create mode 100644 plugins/beta/draw-ol/src/edit/vertexLayer.js diff --git a/demo/js/draw-ol.js b/demo/js/draw-ol.js index 107c4a99..e78845b8 100644 --- a/demo/js/draw-ol.js +++ b/demo/js/draw-ol.js @@ -32,7 +32,7 @@ const interactiveMap = new InteractiveMap('map', { containerHeight: '650px', transformRequest: transformVtsRequest27700, enableZoomControls: true, - readMapText: true, + // readMapText: true, plugins: [ mapStylesPlugin({ mapStyles: ngdMapStyles27700 @@ -115,23 +115,23 @@ interactiveMap.on('datasets:ready', function () { let selectedFeatureIds = [] interactiveMap.on('draw:ready', function () { - // drawPlugin.addFeature({ - // id: 'test1234', - // type: 'Feature', - // geometry: {'type':'Polygon','coordinates':[[[337612,504612],[337592,504595],[337575,504583],[337570,504582],[337560,504582],[337554,504590],[337559,504596],[337568,504604],[337572,504610],[337582,504611],[337585,504610],[337602,504612],[337603,504607],[337605,504605],[337609,504605],[337612,504612]],[[337598,504609],[337587,504605],[337577,504605],[337572,504607],[337573,504610],[337575,504613],[337580,504613],[337586,504612],[337593,504613],[337597,504611],[337598,504609]]]}, - // stroke: 'rgba(0,112,60,1)', - // fill: 'rgba(0,112,60,0.2)', - // strokeWidth: 2 - // }) - // drawPlugin.editFeature('test1234') + drawPlugin.addFeature({ + id: 'test1234', + type: 'Feature', + geometry: {'type':'Polygon','coordinates':[[[337612,504612],[337592,504595],[337575,504583],[337570,504582],[337560,504582],[337554,504590],[337559,504596],[337568,504604],[337572,504610],[337582,504611],[337585,504610],[337602,504612],[337603,504607],[337605,504605],[337609,504605],[337612,504612]],[[337598,504609],[337587,504605],[337577,504605],[337572,504607],[337573,504610],[337575,504613],[337580,504613],[337586,504612],[337593,504613],[337597,504611],[337598,504609]]]}, + stroke: 'rgba(0,112,60,1)', + fill: 'rgba(0,112,60,0.2)', + strokeWidth: 2 + }) + drawPlugin.editFeature('test1234') }) interactiveMap.on('draw:started', function (e) { - console.log('draw:started') + interactiveMap.toggleButtonState('geometryActions', 'hidden', true) }) interactiveMap.on('draw:editstart', function (e) { - console.log('draw:editstart', e) + interactiveMap.toggleButtonState('geometryActions', 'hidden', true) }) interactiveMap.on('draw:created', function (e) { diff --git a/demo/js/planning-ol.js b/demo/js/planning-ol.js index 2283d61d..87c48dd7 100644 --- a/demo/js/planning-ol.js +++ b/demo/js/planning-ol.js @@ -47,7 +47,7 @@ const interactiveMap = new InteractiveMap('map', { hasExitButton: true, plugins: [ mapStylesPlugin({ - mapStyles: ngdMapStyles27700, + mapStyles: vtsMapStyles27700, // ngdMapStyles27700, manifest: { buttons: [{ id: 'mapStyles', desktop: { slot: 'right-top', showLabel: false } }], panels: [{ id: 'mapStyles', desktop: { slot: 'map-styles-button', width: '400px', modal: true } }] diff --git a/plugins/beta/draw-ol/src/core/styles.js b/plugins/beta/draw-ol/src/core/styles.js index e7a7bd39..907b185e 100644 --- a/plugins/beta/draw-ol/src/core/styles.js +++ b/plugins/beta/draw-ol/src/core/styles.js @@ -4,87 +4,71 @@ import Stroke from 'ol/style/Stroke.js' import CircleStyle from 'ol/style/Circle.js' const COLOR = { - primary: '#3b82f6', // vertex stroke, sketch line, default feature stroke - selected: '#f97316', // selected vertex - midpoint: '#94a3b8', // midpoint handle + primary: '#1a65a6', white: '#ffffff', - sketchFill: 'rgba(59,130,246,0.08)', - featureFill: 'rgba(59,130,246,0.1)' + black: '#000000', + sketchFill: 'rgba(26,101,166,0.08)', + featureFill: 'rgba(26,101,166,0.1)' } // --- Shared style instances (stateless, reused across renders) --- -const vertexStyle = new Style({ +// Vertex: solid filled circle, r=6 +export const vertexStyle = new Style({ image: new CircleStyle({ radius: 6, - fill: new Fill({ color: COLOR.white }), - stroke: new Stroke({ color: COLOR.primary, width: 2 }) + fill: new Fill({ color: COLOR.primary }) }) }) -const selectedVertexStyle = new Style({ - image: new CircleStyle({ - radius: 7, - fill: new Fill({ color: COLOR.white }), - stroke: new Stroke({ color: COLOR.selected, width: 2.5 }) - }) -}) +// Selected vertex: primary circle + 2px white ring + 3px black outer ring (painted bottom to top) +export const selectedVertexStyle = [ + new Style({ image: new CircleStyle({ radius: 11, fill: new Fill({ color: COLOR.black }) }) }), + new Style({ image: new CircleStyle({ radius: 8, fill: new Fill({ color: COLOR.white }) }) }), + new Style({ image: new CircleStyle({ radius: 6, fill: new Fill({ color: COLOR.primary }) }) }) +] -const midpointStyle = new Style({ +// Midpoint: solid filled circle, r=4 +export const midpointStyle = new Style({ image: new CircleStyle({ radius: 4, - fill: new Fill({ color: COLOR.white }), - stroke: new Stroke({ color: COLOR.midpoint, width: 1.5 }) + fill: new Fill({ color: COLOR.primary }) }) }) +// Selected midpoint: primary circle + 2px white ring + 3px black outer ring (painted bottom to top) +export const selectedMidpointStyle = [ + new Style({ image: new CircleStyle({ radius: 9, fill: new Fill({ color: COLOR.black }) }) }), + new Style({ image: new CircleStyle({ radius: 6, fill: new Fill({ color: COLOR.white }) }) }), + new Style({ image: new CircleStyle({ radius: 4, fill: new Fill({ color: COLOR.primary }) }) }) +] + +// Style applied directly to the OL feature while in edit mode, overriding its stored colours +export const editFeatureStyle = new Style({ + stroke: new Stroke({ color: COLOR.primary, width: 2 }), + fill: new Fill({ color: COLOR.featureFill }) +}) + const sketchLineStyle = new Style({ - stroke: new Stroke({ color: COLOR.primary, width: 2, lineDash: [6, 4] }), + stroke: new Stroke({ color: COLOR.primary, width: 2 }), fill: new Fill({ color: COLOR.sketchFill }) }) const sketchPointStyle = new Style({ image: new CircleStyle({ radius: 5, - fill: new Fill({ color: COLOR.primary }), - stroke: new Stroke({ color: COLOR.white, width: 1.5 }) + fill: new Fill({ color: COLOR.primary }) }) }) // --- Style functions --- -/** - * Style for OL Draw interaction's sketch overlay. - * Receives a sketch feature (Point, LineString, or Polygon). - */ export const createSketchStyle = () => (feature) => { return feature.getGeometry().getType() === 'Point' ? [sketchPointStyle] : [sketchLineStyle] } -/** - * Style for OL Modify interaction's vertex overlay. - * Uses a mutable `state` ref so EditMode can update selectedCoord - * without recreating the style function. - * - * @param {{ selectedCoord: number[]|null }} state - */ -export const createEditStyle = (state) => (feature) => { - if (feature.getGeometry().getType() !== 'Point') return [sketchLineStyle] - const [ex, ey] = feature.getGeometry().getCoordinates() - const sel = state.selectedCoord - const isSelected = sel && Math.abs(ex - sel[0]) < 1 && Math.abs(ey - sel[1]) < 1 - return [isSelected ? selectedVertexStyle : vertexStyle] -} - -/** Style for the midpoint overlay layer (always the same). */ -export const getMidpointStyle = () => midpointStyle - -/** - * Style for completed features in the main VectorLayer. - * Reads stroke/fill/strokeWidth from feature properties if set. - */ export const createFeatureStyle = () => (feature) => { const p = feature.getProperties() return [new Style({ diff --git a/plugins/beta/draw-ol/src/draw/drawInput.js b/plugins/beta/draw-ol/src/draw/drawInput.js index 8b8e9b8a..1d74c763 100644 --- a/plugins/beta/draw-ol/src/draw/drawInput.js +++ b/plugins/beta/draw-ol/src/draw/drawInput.js @@ -45,16 +45,17 @@ export const createDrawInput = ({ drawInteraction, manager, options }) => { drawInteraction.on('drawend', onDrawEnd) drawInteraction.on('drawabort', onDrawEnd) - // Get map reference when drawInteraction is added + // Get map reference — drawInteraction is already added to map before createDrawInput is called const getMap = () => { if (!map) { - // drawInteraction is added to map in DrawMode; extract it map = drawInteraction.getMap() } return map } - // --- Update sketch feature with current center (rubberbanding) --- + const olMap = drawInteraction.getMap() + + // --- Update sketch feature rubber band to current map centre --- const updateSketchRubberbanding = () => { if (!sketchFeature) return @@ -64,26 +65,32 @@ export const createDrawInput = ({ drawInteraction, manager, options }) => { const centerCoord = mapProvider.getCenter() - // For LineString, update the last (rubber-band) coordinate if (geom.getType() === 'LineString') { const updated = [...coords] updated[updated.length - 1] = centerCoord geom.setCoordinates(updated) - } - // For Polygon, update the last coordinate in the current ring - else if (geom.getType() === 'Polygon') { + } else if (geom.getType() === 'Polygon') { const updated = coords.map((ring, ringIdx) => { - if (ringIdx === 0) { // Only update first ring (exterior) - const ringUpdated = [...ring] - ringUpdated[ringUpdated.length - 1] = centerCoord - return ringUpdated - } - return ring + if (ringIdx !== 0) return ring + const ringUpdated = [...ring] + ringUpdated[ringUpdated.length - 1] = centerCoord + return ringUpdated }) geom.setCoordinates(updated) } } + // change:center fires on each animation frame as OL updates the view centre, + // keeping the rubber band in sync during keyboard pan. + const onCenterChange = () => { + if (interfaceType === 'pointer') return + updateSketchRubberbanding() + } + + if (olMap) { + olMap.getView().on('change:center', onCenterChange) + } + // --- Check if close enough to first vertex to close shape --- const isCloseToFirstVertex = (map, currentCoord, sketchCoords, geometryType) => { if (geometryType !== 'Polygon' || sketchCoords.length < 4) return false @@ -213,8 +220,8 @@ export const createDrawInput = ({ drawInteraction, manager, options }) => { interfaceType = 'touch' } - // Update rubberbanding line as user moves around in keyboard/touch modes const onPointerMove = () => { + if (interfaceType === 'pointer') { return } updateSketchRubberbanding() } @@ -231,6 +238,9 @@ export const createDrawInput = ({ drawInteraction, manager, options }) => { if (pendingVertexUpdate) { clearTimeout(pendingVertexUpdate) } + if (olMap) { + olMap.getView().un('change:center', onCenterChange) + } window.removeEventListener('keydown', onKeydown) window.removeEventListener('click', onButtonClick) container.removeEventListener('pointerdown', onPointerdown) diff --git a/plugins/beta/draw-ol/src/edit/EditMode.js b/plugins/beta/draw-ol/src/edit/EditMode.js index c79dd948..abf7acb0 100644 --- a/plugins/beta/draw-ol/src/edit/EditMode.js +++ b/plugins/beta/draw-ol/src/edit/EditMode.js @@ -1,13 +1,14 @@ import Modify from 'ol/interaction/Modify.js' import Collection from 'ol/Collection.js' -import { createEditStyle } from '../core/styles.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' +import { editFeatureStyle } from '../core/styles.js' /** * Edit vertex mode — handles edit_vertex. @@ -25,6 +26,9 @@ export const createEditMode = ({ map, manager, options }) => { const olFeature = store.getOL(featureId) if (!olFeature) return null + const originalFeatureStyle = olFeature.getStyle() + olFeature.setStyle(editFeatureStyle) + // Mutable state shared across sub-handlers const state = { olFeature, @@ -32,59 +36,77 @@ export const createEditMode = ({ map, manager, options }) => { selectedVertexType: null, vertecies: [], midpoints: [], - interfaceType: interfaceType ?? 'pointer', - // Used by createEditStyle to highlight the selected vertex - selectedCoord: null + interfaceType: interfaceType ?? 'pointer' } 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) { - const coord = state.vertecies[state.selectedVertexIndex] ?? null - state.selectedCoord = coord + vertexLayer.setSelected(state.selectedVertexIndex) + midpointLayer.setSelected( + state.selectedVertexType === 'midpoint' + ? state.selectedVertexIndex - state.vertecies.length + : -1 + ) + if (state.selectedVertexIndex < 0) onDeselect?.() manager.emit('vertexselection', { index: state.selectedVertexType === 'vertex' ? state.selectedVertexIndex : -1, numVertecies: state.vertecies.length }) } if (updates.vertecies !== undefined) { - midpointLayer.update(olFeature.getGeometry().toJSON?.() ?? { + const plainGeom = { type: olFeature.getGeometry().getType(), coordinates: olFeature.getGeometry().getCoordinates() - }) + } + midpointLayer.update(plainGeom) + vertexLayer.update(plainGeom) state.midpoints = midpointLayer.getCoords() - // Trigger Modify overlay re-render + onUpdate?.() map.render() } } - const syncGeom = () => { + // 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.vertecies = getCoords(plainGeom) state.midpoints = getMidpoints(plainGeom) midpointLayer.update(plainGeom) + vertexLayer.update(plainGeom) + } + + const syncGeom = () => { + updateLayersFromGeom() manager.emit('vertexchange', { numVertecies: state.vertecies.length }) manager.emit('update', store.toGeoJSON(olFeature)) } - // --- Style state ref shared with the style function --- - const styleState = { selectedCoord: null } - const editStyleFn = createEditStyle(styleState) + // 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 modifyInteraction = new Modify({ features: collection, - style: editStyleFn, - pixelTolerance: 12 + style: () => [], // vertex circles rendered by vertexLayer instead + pixelTolerance: 12, + // Only activate when clicking on a vertex or midpoint circle, not anywhere on a segment + condition: (mapBrowserEvent) => { + const olPixel = map.getEventPixel(mapBrowserEvent.originalEvent) + return findNearest(map, state.vertecies, state.midpoints, { x: olPixel[0], y: olPixel[1] }) !== null + } }) map.addInteraction(modifyInteraction) // Track move start for undo let modifyStartCoords = null - let modifyStartIndex = -1 modifyInteraction.on('modifystart', () => { if (state.interfaceType === 'touch') return @@ -97,12 +119,13 @@ export const createEditMode = ({ map, manager, options }) => { syncGeom() if (!prevCoords) return - // Detect what changed const newCoords = state.vertecies if (newCoords.length > prevCoords.length) { - // Midpoint drag inserted a vertex — find it + // Midpoint drag inserted a vertex — find it and select it const insertedIdx = newCoords.findIndex((c, i) => !prevCoords[i] || c[0] !== prevCoords[i][0]) - undoStack.push({ type: 'insert_vertex', vertexIndex: Math.max(0, insertedIdx) }) + 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) { @@ -113,11 +136,12 @@ export const createEditMode = ({ map, manager, options }) => { modifyStartCoords = null }) - // --- Midpoint layer --- + // --- Vertex + midpoint layers (always-visible handles) --- const midpointLayer = createMidpointLayer(map) + const vertexLayer = createVertexLayer(map) syncGeom() // initial populate - // --- Pointer hit detection (click selects vertex or midpoint) --- + // --- Pointer hit detection --- const onPointerdown = (e) => { if (e.pointerType === 'touch') { state.interfaceType = 'touch' @@ -130,13 +154,28 @@ export const createEditMode = ({ map, manager, options }) => { const olPixel = map.getEventPixel(e) const pixel = { x: olPixel[0], y: olPixel[1] } const hit = findNearest(map, state.vertecies, state.midpoints, pixel) - if (hit) { - setState({ selectedVertexIndex: hit.index, selectedVertexType: hit.type }) - styleState.selectedCoord = state.selectedCoord + if (hit?.type === 'vertex') { + setState({ selectedVertexIndex: hit.index, selectedVertexType: 'vertex' }) + } + } + + // click fires after OL Modify finishes, so state.vertecies 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.vertecies, 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 }) } } container.addEventListener('pointerdown', onPointerdown) + container.addEventListener('click', onContainerClick) // --- Button click (delete vertex) --- const onButtonClick = (e) => { @@ -155,7 +194,6 @@ export const createEditMode = ({ map, manager, options }) => { undoStack.push({ type: 'delete_vertex', ...result }) syncGeom() setState({ selectedVertexIndex: -1, selectedVertexType: null }) - styleState.selectedCoord = null } const doUndo = () => { @@ -167,7 +205,7 @@ export const createEditMode = ({ map, manager, options }) => { selectedVertexIndex: newIndex, selectedVertexType: newIndex >= 0 ? 'vertex' : null }) - styleState.selectedCoord = newIndex >= 0 ? state.vertecies[newIndex] : null + onUpdate?.() } // --- Touch handler --- @@ -180,8 +218,31 @@ export const createEditMode = ({ map, manager, options }) => { undoStack.push({ type: 'move_vertex', vertexIndex, previousCoord }) syncGeom() 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.vertecies.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() } + } // --- Keyboard handler --- const keyboardHandler = createKeyboardHandler({ @@ -215,10 +276,14 @@ export const createEditMode = ({ map, manager, options }) => { deleteVertex: doDeleteVertex, destroy () { + olFeature.setStyle(originalFeatureStyle) + olFeature.getGeometry().un('change', onGeometryChange) container.removeEventListener('pointerdown', onPointerdown) + container.removeEventListener('click', onContainerClick) window.removeEventListener('click', onButtonClick) map.removeInteraction(modifyInteraction) midpointLayer.remove() + vertexLayer.remove() touchHandler.destroy() keyboardHandler.destroy() } diff --git a/plugins/beta/draw-ol/src/edit/keyboardHandler.js b/plugins/beta/draw-ol/src/edit/keyboardHandler.js index 0c021a36..723af9fa 100644 --- a/plugins/beta/draw-ol/src/edit/keyboardHandler.js +++ b/plugins/beta/draw-ol/src/edit/keyboardHandler.js @@ -9,11 +9,15 @@ const STEP_PX = 5 /** * Keyboard handler for edit mode. * - * Space — select nearest vertex to safe-zone center - * Alt+Arrow — navigate vertices / midpoints spatially - * Arrow — nudge selected vertex (Shift = fine, plain = coarse) - * Delete — delete selected vertex - * Ctrl/Cmd+Z — undo + * 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 }} @@ -22,7 +26,6 @@ export const createKeyboardHandler = ({ map, container, getState, setState, onVertexMoved, onInserted, onDeleted, onUndo }) => { - // Accumulate keyboard nudge moves for a single undo entry let keyMoveStart = null let keyMoveIndex = null @@ -69,24 +72,39 @@ export const createKeyboardHandler = ({ const { selectedVertexIndex, selectedVertexType, vertecies, 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') { - // Nudge on midpoint = insert vertex at midpoint, then move it - const localIdx = selectedVertexIndex - vertecies.length + // Insert the midpoint as a vertex, then immediately move it in the pressed direction. + // This matches the ML behaviour: midpoints stay as midpoints until actually moved. const result = insertAtMidpoint(olFeature, midpoints, selectedVertexIndex, vertecies.length) if (!result) return - onInserted({ insertedIndex: result.insertedIndex }) - setState({ selectedVertexIndex: result.insertedIndex, selectedVertexType: 'vertex' }) + onInserted({ insertedIndex: result.insertedIndex }) // pushes insert_vertex undo + syncGeom + + // After syncGeom in onInserted, state.vertecies is updated with the new vertex + const updatedVertecies = getState().vertecies + const insertedCoord = updatedVertecies[result.insertedIndex] + if (!insertedCoord) return + + // keyMoveStart at the midpoint position so keyup undo restores there + keyMoveStart = [...insertedCoord] + keyMoveIndex = result.insertedIndex + + const movedCoord = nudgeCoord(map, insertedCoord, dx, dy) + moveVertex(olFeature, result.insertedIndex, movedCoord) + setState({ + selectedVertexIndex: result.insertedIndex, + selectedVertexType: 'vertex', + vertecies: updatedVertecies.map((c, i) => i === result.insertedIndex ? movedCoord : c) + }) return } if (selectedVertexIndex < 0 || !vertecies[selectedVertexIndex]) 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] - const current = vertecies[selectedVertexIndex] - if (!keyMoveStart) { keyMoveStart = [...current] keyMoveIndex = selectedVertexIndex @@ -98,7 +116,7 @@ export const createKeyboardHandler = ({ } const onKeydown = (e) => { - if (document.activeElement !== container) return + if (!container.contains(document.activeElement)) return if (e.key === ' ' && getState().selectedVertexIndex < 0) { e.preventDefault() @@ -106,7 +124,7 @@ export const createKeyboardHandler = ({ return } - if (e.altKey && ARROW_KEYS.has(e.key) && getState().selectedVertexIndex >= 0) { + if (e.altKey && ARROW_KEYS.has(e.key)) { e.preventDefault() e.stopPropagation() navigateTo(e.key) @@ -130,9 +148,8 @@ export const createKeyboardHandler = ({ } const onKeyup = (e) => { - if (document.activeElement !== container) return + if (!container.contains(document.activeElement)) return - // Commit accumulated keyboard nudge as single undo entry if (ARROW_KEYS.has(e.key) && keyMoveStart && keyMoveIndex != null) { onVertexMoved({ vertexIndex: keyMoveIndex, previousCoord: keyMoveStart }) keyMoveStart = null diff --git a/plugins/beta/draw-ol/src/edit/midpointLayer.js b/plugins/beta/draw-ol/src/edit/midpointLayer.js index db158979..3b68082d 100644 --- a/plugins/beta/draw-ol/src/edit/midpointLayer.js +++ b/plugins/beta/draw-ol/src/edit/midpointLayer.js @@ -3,7 +3,7 @@ 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' -import { getMidpointStyle } from '../core/styles.js' +import { midpointStyle, selectedMidpointStyle } from '../core/styles.js' /** * Manages a dedicated overlay layer for midpoint handles in edit mode. @@ -11,10 +11,11 @@ import { getMidpointStyle } from '../core/styles.js' * only appear when the pointer is near a segment). */ export const createMidpointLayer = (map) => { + let selectedIndex = -1 const source = new VectorSource() const layer = new VectorLayer({ source, - style: getMidpointStyle(), + style: (feature) => feature.get('midpointIndex') === selectedIndex ? selectedMidpointStyle : [midpointStyle], zIndex: 101 }) map.addLayer(layer) @@ -36,6 +37,11 @@ export const createMidpointLayer = (map) => { }, /** Current midpoint coordinates in order. */ + setSelected (index) { + selectedIndex = index + source.changed() + }, + getCoords () { return source.getFeatures() .sort((a, b) => a.get('midpointIndex') - b.get('midpointIndex')) diff --git a/plugins/beta/draw-ol/src/edit/touchHandler.js b/plugins/beta/draw-ol/src/edit/touchHandler.js index 2fbac742..e178a458 100644 --- a/plugins/beta/draw-ol/src/edit/touchHandler.js +++ b/plugins/beta/draw-ol/src/edit/touchHandler.js @@ -1,6 +1,11 @@ import { coordToPixel, pixelToCoord } from '../utils/olCoords.js' import { createTouchTarget, showTouchTarget, hideTouchTarget, isOnTouchTarget } from '../utils/touchTarget.js' import { moveVertex } from './vertexOps.js' +import { findNearest } from './vertexHitTest.js' + +const TAP_MOVE_THRESHOLD = 10 // px — movement beyond this is a drag, not a tap +const TAP_TIME_THRESHOLD = 400 // ms +const TOUCH_TOLERANCE = 24 // px — larger hit area for touch vs pointer /** * Touch vertex drag handler for edit mode. @@ -8,11 +13,12 @@ import { moveVertex } from './vertexOps.js' * Shows an SVG offset target below the finger when a vertex is selected in * touch mode, allowing accurate repositioning without finger occlusion. * Drag moves the vertex directly (bypasses OL Modify). + * Tap on a vertex or midpoint selects it via the onTap callback. * - * @param {{ map, container, olFeature, getState, setState, onVertexMoved }} - * @returns {{ onTap, updateTargetPosition, hide, destroy }} + * @param {{ map, container, olFeature, getState, setState, onVertexMoved, onTap }} + * @returns {{ updateTargetPosition, hide, destroy }} */ -export const createTouchHandler = ({ map, container, getState, setState, onVertexMoved }) => { +export const createTouchHandler = ({ map, container, getState, setState, onVertexMoved, onTap }) => { const targetEl = createTouchTarget(container) // Per-drag state @@ -20,6 +26,7 @@ export const createTouchHandler = ({ map, container, getState, setState, onVerte let dragStartIndex = null // vertex index being dragged let vertexTouchDelta = null // offset from touch point to vertex pixel let targetTouchDelta = null // offset from touch point to target element + let tapStart = null // { x, y, time, onTarget } — for tap detection // --- Target positioning --- @@ -36,19 +43,24 @@ export const createTouchHandler = ({ map, container, getState, setState, onVerte // --- Touch event handlers --- const onTouchstart = (e) => { - if (!isOnTouchTarget(e.target)) return + 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, vertecies } = getState() const vertex = vertecies[selectedVertexIndex] if (!vertex) return - const touch = { x: e.touches[0].clientX, y: e.touches[0].clientY } + const t = { x: touch.clientX, y: touch.clientY } const vertexPx = coordToPixel(map, vertex) - const style = window.getComputedStyle(targetEl) + const style = getComputedStyle(targetEl) dragStartCoord = [...vertex] dragStartIndex = selectedVertexIndex - vertexTouchDelta = { x: touch.x - vertexPx.x, y: touch.y - vertexPx.y } - targetTouchDelta = { x: touch.x - parseFloat(style.left), y: touch.y - parseFloat(style.top) } + vertexTouchDelta = { x: t.x - vertexPx.x, y: t.y - vertexPx.y } + targetTouchDelta = { x: t.x - Number.parseFloat(style.left), y: t.y - Number.parseFloat(style.top) } e.preventDefault() } @@ -60,18 +72,39 @@ export const createTouchHandler = ({ map, container, getState, setState, onVerte const touch = { x: e.touches[0].clientX, y: e.touches[0].clientY } const newVertexPx = { x: touch.x - vertexTouchDelta.x, y: touch.y - vertexTouchDelta.y } const newCoord = pixelToCoord(map, newVertexPx) - const olFeature = getState().olFeature + const { olFeature, vertecies } = getState() if (!olFeature) return moveVertex(olFeature, dragStartIndex, newCoord) - setState({ vertecies: getState().vertecies.map((c, i) => i === dragStartIndex ? newCoord : c) }) + setState({ vertecies: vertecies.map((c, i) => i === dragStartIndex ? newCoord : c) }) showTouchTarget(targetEl, { x: touch.x - targetTouchDelta.x, y: touch.y - targetTouchDelta.y }) } const onTouchend = (e) => { - if (dragStartIndex == null) return + const wasDragging = dragStartIndex != null + + if (!wasDragging) { + if (tapStart && !tapStart.onTarget && e.changedTouches.length > 0) { + const t = e.changedTouches[0] + const dx = t.clientX - tapStart.x + const dy = t.clientY - tapStart.y + const dt = Date.now() - tapStart.time + + if (Math.sqrt(dx * dx + dy * dy) < TAP_MOVE_THRESHOLD && dt < TAP_TIME_THRESHOLD) { + const rect = map.getViewport().getBoundingClientRect() + const pixel = { x: t.clientX - rect.left, y: t.clientY - rect.top } + const { vertecies, midpoints } = getState() + const hit = findNearest(map, vertecies, midpoints, pixel, TOUCH_TOLERANCE) + onTap?.(hit) + e.preventDefault() + } + } + tapStart = null + return + } + + tapStart = null - const olFeature = getState().olFeature const { vertecies } = getState() const finalCoord = vertecies[dragStartIndex] diff --git a/plugins/beta/draw-ol/src/edit/vertexHitTest.js b/plugins/beta/draw-ol/src/edit/vertexHitTest.js index d4125c9e..0ef32f75 100644 --- a/plugins/beta/draw-ol/src/edit/vertexHitTest.js +++ b/plugins/beta/draw-ol/src/edit/vertexHitTest.js @@ -8,11 +8,12 @@ const PIXEL_TOLERANCE = 12 * @param {import('ol/Map').default} map * @param {number[][]} vertecies - flat coordinate array [[e,n], ...] * @param {{ x: number, y: number }} pixel + * @param {number} [tolerance] * @returns {{ index: number, type: 'vertex' } | null} */ -export const findNearestVertex = (map, vertecies, pixel) => { +export const findNearestVertex = (map, vertecies, pixel, tolerance = PIXEL_TOLERANCE) => { let bestIdx = -1 - let bestDist = PIXEL_TOLERANCE + let bestDist = tolerance vertecies.forEach((coord, i) => { const px = coordToPixel(map, coord) @@ -34,11 +35,12 @@ export const findNearestVertex = (map, vertecies, pixel) => { * @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) => { +export const findNearestMidpoint = (map, midpoints, pixel, vertexCount, tolerance = PIXEL_TOLERANCE) => { let bestIdx = -1 - let bestDist = PIXEL_TOLERANCE + let bestDist = tolerance midpoints.forEach((coord, i) => { const px = coordToPixel(map, coord) @@ -57,9 +59,10 @@ export const findNearestMidpoint = (map, midpoints, pixel, vertexCount) => { * 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, vertecies, midpoints, pixel) => { - return findNearestVertex(map, vertecies, pixel) ?? - findNearestMidpoint(map, midpoints, pixel, vertecies.length) +export const findNearest = (map, vertecies, midpoints, pixel, tolerance = PIXEL_TOLERANCE) => { + return findNearestVertex(map, vertecies, pixel, tolerance) ?? + findNearestMidpoint(map, midpoints, pixel, vertecies.length, tolerance) } diff --git a/plugins/beta/draw-ol/src/edit/vertexLayer.js b/plugins/beta/draw-ol/src/edit/vertexLayer.js new file mode 100644 index 00000000..e7d4fc41 --- /dev/null +++ b/plugins/beta/draw-ol/src/edit/vertexLayer.js @@ -0,0 +1,44 @@ +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' +import { vertexStyle, selectedVertexStyle } from '../core/styles.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 and highlights the selected vertex. + */ +export const createVertexLayer = (map) => { + let selectedIndex = -1 + const source = new VectorSource() + + const layer = new VectorLayer({ + source, + style: (feature) => feature.get('vertexIndex') === selectedIndex ? selectedVertexStyle : [vertexStyle], + 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() + }, + + remove () { + source.clear() + map.removeLayer(layer) + } + } +} diff --git a/providers/beta/openlayers/src/utils/tileLayers.js b/providers/beta/openlayers/src/utils/tileLayers.js index cd4edb32..00aeffdd 100644 --- a/providers/beta/openlayers/src/utils/tileLayers.js +++ b/providers/beta/openlayers/src/utils/tileLayers.js @@ -93,7 +93,7 @@ export async function createVectorTileLayer (url, transformRequest) { projection: CRS, tileGrid }) - const layer = new VectorTileLayer({ source, declutter: true }) + const layer = new VectorTileLayer({ source, declutter: true, renderMode: 'vector' }) stylefunction(layer, styleJson, sourceId, resolutions, spritesJson, sprite.pngUrl) @@ -125,7 +125,7 @@ export async function createOGCVectorTileLayer (url, transformRequest) { const tileGrid = new TileGrid({ resolutions, origin, tileSize }) const source = new OGCVectorTile({ url: tilesUrl, format, tileGrid, projection: CRS }) - const layer = new VectorTileLayer({ source, declutter: true }) + const layer = new VectorTileLayer({ source, declutter: true, renderMode: 'vector' }) stylefunction(layer, styleJson, sourceId, resolutions, spritesJson, sprite.pngUrl) diff --git a/src/App/controls/keyboardActions.js b/src/App/controls/keyboardActions.js index 2ae26e80..4bbe21fc 100755 --- a/src/App/controls/keyboardActions.js +++ b/src/App/controls/keyboardActions.js @@ -50,7 +50,7 @@ export const createKeyboardActions = (mapProvider, announce, { }, highlightNextLabel: (e) => { - if (!readMapText && mapProvider.highlightNextLabel) { + if (!readMapText || !mapProvider.highlightNextLabel) { return } const label = mapProvider.highlightNextLabel(e.key) @@ -58,7 +58,7 @@ export const createKeyboardActions = (mapProvider, announce, { }, highlightLabelAtCenter: () => { - if (!readMapText && mapProvider.highlightNextLabel) { + if (!readMapText || !mapProvider.highlightLabelAtCenter) { return } const label = mapProvider.highlightLabelAtCenter() From 1c216d300cea0d5499c32bbf71e5dc346f019529 Mon Sep 17 00:00:00 2001 From: Dan Leech Date: Fri, 15 May 2026 13:27:26 +0100 Subject: [PATCH 04/26] Keyboard rubberband fix --- plugins/beta/draw-ol/src/api/newLine.js | 3 +- plugins/beta/draw-ol/src/api/newPolygon.js | 3 +- plugins/beta/draw-ol/src/draw/DrawMode.js | 5 +- plugins/beta/draw-ol/src/draw/drawInput.js | 88 ++++++++++++++-------- plugins/beta/draw-ol/src/reducer.js | 6 +- 5 files changed, 70 insertions(+), 35 deletions(-) diff --git a/plugins/beta/draw-ol/src/api/newLine.js b/plugins/beta/draw-ol/src/api/newLine.js index e5b25602..12fbc1d6 100644 --- a/plugins/beta/draw-ol/src/api/newLine.js +++ b/plugins/beta/draw-ol/src/api/newLine.js @@ -6,7 +6,7 @@ * @param {object} options - { snapLayers, stroke, fill, strokeWidth, properties } */ export const newLine = ( - { appState, appConfig, pluginConfig, pluginState, mapProvider, services }, + { appState, appConfig, mapState, pluginConfig, pluginState, mapProvider, services }, featureId, options = {} ) => { @@ -38,6 +38,7 @@ export const newLine = ( geometryType: 'LineString', properties, mapProvider, + crossHair: mapState.crossHair, ...modeOptions }) diff --git a/plugins/beta/draw-ol/src/api/newPolygon.js b/plugins/beta/draw-ol/src/api/newPolygon.js index 1641e0d3..fa1cf3c6 100644 --- a/plugins/beta/draw-ol/src/api/newPolygon.js +++ b/plugins/beta/draw-ol/src/api/newPolygon.js @@ -6,7 +6,7 @@ * @param {object} options - { snapLayers, stroke, fill, strokeWidth, properties } */ export const newPolygon = ( - { appState, appConfig, pluginConfig, pluginState, mapProvider, services }, + { appState, appConfig, mapState, pluginConfig, pluginState, mapProvider, services }, featureId, options = {} ) => { @@ -38,6 +38,7 @@ export const newPolygon = ( geometryType: 'Polygon', properties, mapProvider, + crossHair: mapState.crossHair, ...modeOptions }) diff --git a/plugins/beta/draw-ol/src/draw/DrawMode.js b/plugins/beta/draw-ol/src/draw/DrawMode.js index fc9c186b..ff23bff9 100644 --- a/plugins/beta/draw-ol/src/draw/DrawMode.js +++ b/plugins/beta/draw-ol/src/draw/DrawMode.js @@ -18,7 +18,8 @@ export const createDrawMode = ({ map, manager, options }) => { container, interfaceType, addVertexButtonId, - mapProvider + mapProvider, + crossHair } = options const drawInteraction = new Draw({ @@ -93,7 +94,7 @@ export const createDrawMode = ({ map, manager, options }) => { manager.emit('cancel') }) - const input = createDrawInput({ drawInteraction, manager, options: { container, interfaceType, addVertexButtonId, mapProvider } }) + const input = createDrawInput({ drawInteraction, manager, options: { container, interfaceType, addVertexButtonId, mapProvider, crossHair } }) return { done () { diff --git a/plugins/beta/draw-ol/src/draw/drawInput.js b/plugins/beta/draw-ol/src/draw/drawInput.js index 1d74c763..a839b8e2 100644 --- a/plugins/beta/draw-ol/src/draw/drawInput.js +++ b/plugins/beta/draw-ol/src/draw/drawInput.js @@ -7,19 +7,19 @@ */ import { coordToPixel, pixelDist } from '../utils/olCoords.js' -import { getCoords } from '../utils/geometryHelpers.js' const SNAP_TOLERANCE = 12 // pixels +const ARROW_KEYS = new Set(['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown']) /** * @param {object} params * @param {import('ol/interaction/Draw').default} params.drawInteraction * @param {import('../core/OLDrawManager').OLDrawManager} params.manager - * @param {object} params.options - { container, interfaceType, addVertexButtonId, mapProvider } + * @param {object} params.options - { container, interfaceType, addVertexButtonId, mapProvider, crossHair } * @returns {{ destroy: () => void }} */ export const createDrawInput = ({ drawInteraction, manager, options }) => { - const { container, addVertexButtonId, mapProvider } = options + const { container, addVertexButtonId, mapProvider, crossHair } = options let interfaceType = options.interfaceType let map = null let sketchFeature = null @@ -55,35 +55,55 @@ export const createDrawInput = ({ drawInteraction, manager, options }) => { const olMap = drawInteraction.getMap() + // OL's CanvasVectorLayerRenderer skips re-rendering when viewHints[ANIMATING] > 0 and + // updateWhileAnimating is false (the default). Without this, geometry updates in precompose + // are ignored during keyboard pan animation — the overlay uses a cached render. + const overlayLayer = typeof drawInteraction.getOverlay === 'function' ? drawInteraction.getOverlay() : null + if (overlayLayer) { + overlayLayer.updateWhileAnimating_ = true + } + // --- Update sketch feature rubber band to current map centre --- - const updateSketchRubberbanding = () => { + // Accepts an optional pre-computed coord; falls back to mapProvider.getCenter() otherwise. + const updateSketchRubberbanding = (centerCoord) => { if (!sketchFeature) return const geom = sketchFeature.getGeometry() const coords = geom.getCoordinates() if (coords.length === 0) return - const centerCoord = mapProvider.getCenter() + const center = centerCoord ?? mapProvider.getCenter() if (geom.getType() === 'LineString') { const updated = [...coords] - updated[updated.length - 1] = centerCoord + updated[updated.length - 1] = center geom.setCoordinates(updated) } else if (geom.getType() === 'Polygon') { const updated = coords.map((ring, ringIdx) => { if (ringIdx !== 0) return ring const ringUpdated = [...ring] - ringUpdated[ringUpdated.length - 1] = centerCoord + ringUpdated[ringUpdated.length - 1] = center return ringUpdated }) geom.setCoordinates(updated) } + + // OL's Draw interaction keeps a separate sketchPoint_ feature (the dot at the rubber-band + // tip). It only updates via pointer events, so during keyboard pan it stays frozen at the + // last mouse position. Move it to match the polygon/line rubber-band endpoint. + const sketchPoint = drawInteraction.sketchPoint_ + if (sketchPoint) { + sketchPoint.getGeometry().setCoordinates(center) + } } - // change:center fires on each animation frame as OL updates the view centre, - // keeping the rubber band in sync during keyboard pan. + // OL's view.animate() calls applyTargetState_() each rAF → fires change:center with the + // interpolated center each frame. mapProvider.getCenter() returns the raw (non-padding- + // adjusted) center, which is what renders at the safezone/crosshair CSS position. + // Using frameState.viewState.center would give the padding-adjusted center, which renders + // at the container's 50%/50% — offset from the crosshair when OL view padding is set. const onCenterChange = () => { - if (interfaceType === 'pointer') return + if (interfaceType === 'pointer') { return } updateSketchRubberbanding() } @@ -91,18 +111,6 @@ export const createDrawInput = ({ drawInteraction, manager, options }) => { olMap.getView().on('change:center', onCenterChange) } - // --- Check if close enough to first vertex to close shape --- - const isCloseToFirstVertex = (map, currentCoord, sketchCoords, geometryType) => { - if (geometryType !== 'Polygon' || sketchCoords.length < 4) return false - - const firstCoord = sketchCoords[0] - const currentPixel = coordToPixel(map, currentCoord) - const firstPixel = coordToPixel(map, firstCoord) - - if (!currentPixel || !firstPixel) return false - return pixelDist(currentPixel, firstPixel) < SNAP_TOLERANCE - } - // --- Update vertex count display after appending coordinates --- const updateDisplayedVertexCount = () => { if (!sketchFeature) return @@ -116,12 +124,10 @@ export const createDrawInput = ({ drawInteraction, manager, options }) => { // We need to subtract: 1 for closing vertex + 1 for rubber-band = 2 total const exteriorRing = rawCoords[0] numVertecies = Math.max(0, exteriorRing.length - 2) - console.log('Polygon vertex count:', { ringLength: exteriorRing.length, numVertecies, ring: exteriorRing }) } else if (geom.getType() === 'LineString') { // For LineString, OL stores coords with trailing rubber-band: [v1, v2, ..., vN, rubber] // Subtract 1 for the rubber-band coordinate numVertecies = Math.max(0, rawCoords.length - 1) - console.log('LineString vertex count:', { coordsLength: rawCoords.length, numVertecies }) } manager.emit('vertexchange', { numVertecies }) @@ -194,10 +200,21 @@ export const createDrawInput = ({ drawInteraction, manager, options }) => { // --- Event handlers --- const onKeydown = (e) => { - if (document.activeElement !== container) return + if (document.activeElement !== container) { return } + if (ARROW_KEYS.has(e.key)) { + if (interfaceType !== 'keyboard') { + interfaceType = 'keyboard' + crossHair?.fixAtCenter() + updateSketchRubberbanding() + } + return + } if (e.key === 'Enter') { e.preventDefault() - interfaceType = 'keyboard' + if (interfaceType !== 'keyboard') { + interfaceType = 'keyboard' + crossHair?.fixAtCenter() + } placeVertex() } } @@ -209,18 +226,29 @@ export const createDrawInput = ({ drawInteraction, manager, options }) => { } } - // Track interface type so DrawMode can show/hide crosshair correctly const onPointerdown = (e) => { - if (e.pointerType !== 'touch') { + if (e.pointerType !== 'touch' && interfaceType !== 'pointer') { interfaceType = 'pointer' + crossHair?.hide() } } const onTouchstart = () => { - interfaceType = 'touch' + if (interfaceType !== 'touch') { + interfaceType = 'touch' + crossHair?.fixAtCenter() + updateSketchRubberbanding() + } } - const onPointerMove = () => { + const onPointerMove = (e) => { + if (e.pointerType === 'mouse') { + if (interfaceType !== 'pointer') { + interfaceType = 'pointer' + crossHair?.hide() + } + return // OL's Draw interaction handles mouse rubber-banding natively + } if (interfaceType === 'pointer') { return } updateSketchRubberbanding() } diff --git a/plugins/beta/draw-ol/src/reducer.js b/plugins/beta/draw-ol/src/reducer.js index d6201a2e..fb8e1af7 100644 --- a/plugins/beta/draw-ol/src/reducer.js +++ b/plugins/beta/draw-ol/src/reducer.js @@ -8,7 +8,11 @@ const initialState = { } const actions = { - SET_MODE: (state, payload) => ({ ...state, mode: payload }), + SET_MODE: (state, payload) => ({ + ...state, + mode: payload, + numVertecies: ['draw_polygon', 'draw_line'].includes(payload) ? 0 : state.numVertecies + }), SET_FEATURE: (state, payload) => ({ ...state, From 001dbdd2c0d2c862c8676d7ed4905eae6c16585f Mon Sep 17 00:00:00 2001 From: Dan Leech Date: Fri, 15 May 2026 15:05:07 +0100 Subject: [PATCH 05/26] Sketch marker removal --- demo/js/draw-ol.js | 18 +++---- demo/js/mapStyles.js | 2 + plugins/beta/draw-ol/src/draw/drawInput.js | 55 ++++++++++++++++++---- 3 files changed, 58 insertions(+), 17 deletions(-) diff --git a/demo/js/draw-ol.js b/demo/js/draw-ol.js index e78845b8..df07f3d8 100644 --- a/demo/js/draw-ol.js +++ b/demo/js/draw-ol.js @@ -115,15 +115,15 @@ interactiveMap.on('datasets:ready', function () { let selectedFeatureIds = [] interactiveMap.on('draw:ready', function () { - drawPlugin.addFeature({ - id: 'test1234', - type: 'Feature', - geometry: {'type':'Polygon','coordinates':[[[337612,504612],[337592,504595],[337575,504583],[337570,504582],[337560,504582],[337554,504590],[337559,504596],[337568,504604],[337572,504610],[337582,504611],[337585,504610],[337602,504612],[337603,504607],[337605,504605],[337609,504605],[337612,504612]],[[337598,504609],[337587,504605],[337577,504605],[337572,504607],[337573,504610],[337575,504613],[337580,504613],[337586,504612],[337593,504613],[337597,504611],[337598,504609]]]}, - stroke: 'rgba(0,112,60,1)', - fill: 'rgba(0,112,60,0.2)', - strokeWidth: 2 - }) - drawPlugin.editFeature('test1234') + // drawPlugin.addFeature({ + // id: 'test1234', + // type: 'Feature', + // geometry: {'type':'Polygon','coordinates':[[[337612,504612],[337592,504595],[337575,504583],[337570,504582],[337560,504582],[337554,504590],[337559,504596],[337568,504604],[337572,504610],[337582,504611],[337585,504610],[337602,504612],[337603,504607],[337605,504605],[337609,504605],[337612,504612]],[[337598,504609],[337587,504605],[337577,504605],[337572,504607],[337573,504610],[337575,504613],[337580,504613],[337586,504612],[337593,504613],[337597,504611],[337598,504609]]]}, + // stroke: 'rgba(0,112,60,1)', + // fill: 'rgba(0,112,60,0.2)', + // strokeWidth: 2 + // }) + // drawPlugin.editFeature('test1234') }) interactiveMap.on('draw:started', function (e) { diff --git a/demo/js/mapStyles.js b/demo/js/mapStyles.js index 39c0d2d6..b85cd965 100755 --- a/demo/js/mapStyles.js +++ b/demo/js/mapStyles.js @@ -117,6 +117,7 @@ const ngdMapStyles27700 = [{ id: 'outdoor', label: 'Outdoor', type: 'ogc-vt', + renderMode: 'vector', url: `${process.env.NGD_OUTDOOR_URL_27700}?key=${process.env.OS_CLIENT_ID}`, thumbnail: OUTDOOR_THUMBNAIL, logo: OS_LOGO, @@ -129,6 +130,7 @@ const ngdMapStyles27700 = [{ id: BW_ID, label: BW_LABEL, type: 'ogc-vt', + renderMode: 'vector', url: `${process.env.NGD_BLACK_AND_WHITE_URL_27700}?key=${process.env.OS_CLIENT_ID}`, thumbnail: BW_THUMBNAIL, logo: OS_LOGO_BLACK, diff --git a/plugins/beta/draw-ol/src/draw/drawInput.js b/plugins/beta/draw-ol/src/draw/drawInput.js index a839b8e2..2b7c521a 100644 --- a/plugins/beta/draw-ol/src/draw/drawInput.js +++ b/plugins/beta/draw-ol/src/draw/drawInput.js @@ -25,16 +25,48 @@ export const createDrawInput = ({ drawInteraction, manager, options }) => { let sketchFeature = null let pendingVertexUpdate = null let lastPlacedCoord = null + let sketchGeom = null + let lastStableVertexCount = 0 + + // Detects when OL places a vertex via mouse click (stable coord count increases). + // Always tracks lastStableVertexCount so switching input modes mid-draw doesn't + // cause a spurious "new vertex" detection. Only updates lastPlacedCoord for pointer mode. + const onSketchGeomChange = () => { + if (!sketchFeature) return + const geom = sketchFeature.getGeometry() + const rawCoords = geom.getCoordinates() + let stableCount = 0 + let newVertexCoord = null + if (geom.getType() === 'Polygon' && rawCoords.length > 0) { + stableCount = Math.max(0, rawCoords[0].length - 2) + if (stableCount > lastStableVertexCount) newVertexCoord = rawCoords[0][stableCount - 1] + } else if (geom.getType() === 'LineString') { + stableCount = Math.max(0, rawCoords.length - 1) + if (stableCount > lastStableVertexCount) newVertexCoord = rawCoords[stableCount - 1] + } + if (stableCount > lastStableVertexCount) { + lastStableVertexCount = stableCount + if (interfaceType === 'pointer' && newVertexCoord) lastPlacedCoord = newVertexCoord + } + } // Track sketch feature from draw events const onDrawStart = (e) => { sketchFeature = e.feature lastPlacedCoord = null + lastStableVertexCount = 0 + sketchGeom = e.feature.getGeometry() + sketchGeom.on('change', onSketchGeomChange) } const onDrawEnd = () => { + if (sketchGeom) { + sketchGeom.un('change', onSketchGeomChange) + sketchGeom = null + } sketchFeature = null lastPlacedCoord = null + lastStableVertexCount = 0 if (pendingVertexUpdate) { clearTimeout(pendingVertexUpdate) pendingVertexUpdate = null @@ -88,13 +120,6 @@ export const createDrawInput = ({ drawInteraction, manager, options }) => { geom.setCoordinates(updated) } - // OL's Draw interaction keeps a separate sketchPoint_ feature (the dot at the rubber-band - // tip). It only updates via pointer events, so during keyboard pan it stays frozen at the - // last mouse position. Move it to match the polygon/line rubber-band endpoint. - const sketchPoint = drawInteraction.sketchPoint_ - if (sketchPoint) { - sketchPoint.getGeometry().setCoordinates(center) - } } // OL's view.animate() calls applyTargetState_() each rAF → fires change:center with the @@ -185,6 +210,14 @@ export const createDrawInput = ({ drawInteraction, manager, options }) => { } drawInteraction.appendCoordinates([coord]) + + // Move the confirmation dot to the placed vertex; OL only updates it via pointer events + // so during keyboard/touch it stays frozen unless we move it explicitly here. + const sketchPoint = drawInteraction.sketchPoint_ + if (sketchPoint) { + sketchPoint.getGeometry().setCoordinates(coord) + } + lastPlacedCoord = coord // Cancel any pending update and schedule a new one @@ -247,7 +280,13 @@ export const createDrawInput = ({ drawInteraction, manager, options }) => { interfaceType = 'pointer' crossHair?.hide() } - return // OL's Draw interaction handles mouse rubber-banding natively + // OL moves sketchPoint_ to the cursor on every pointermove; re-anchor it to the last + // placed vertex so the dot only moves when a vertex is actually placed. + if (lastPlacedCoord && sketchFeature) { + const sp = drawInteraction.sketchPoint_ + if (sp) sp.getGeometry().setCoordinates(lastPlacedCoord) + } + return } if (interfaceType === 'pointer') { return } updateSketchRubberbanding() From ef8e905ec3ecd8b1d6057f4897be4c241265562e Mon Sep 17 00:00:00 2001 From: Dan Leech Date: Fri, 15 May 2026 15:46:05 +0100 Subject: [PATCH 06/26] Interface change fixs --- demo/js/draw-ol.js | 18 +++++------ plugins/beta/draw-ol/src/DrawInit.jsx | 7 ++++ .../beta/draw-ol/src/core/OLDrawManager.js | 4 +++ plugins/beta/draw-ol/src/edit/EditMode.js | 32 ++++++++++++++++--- .../beta/draw-ol/src/edit/keyboardHandler.js | 4 ++- 5 files changed, 51 insertions(+), 14 deletions(-) diff --git a/demo/js/draw-ol.js b/demo/js/draw-ol.js index df07f3d8..e78845b8 100644 --- a/demo/js/draw-ol.js +++ b/demo/js/draw-ol.js @@ -115,15 +115,15 @@ interactiveMap.on('datasets:ready', function () { let selectedFeatureIds = [] interactiveMap.on('draw:ready', function () { - // drawPlugin.addFeature({ - // id: 'test1234', - // type: 'Feature', - // geometry: {'type':'Polygon','coordinates':[[[337612,504612],[337592,504595],[337575,504583],[337570,504582],[337560,504582],[337554,504590],[337559,504596],[337568,504604],[337572,504610],[337582,504611],[337585,504610],[337602,504612],[337603,504607],[337605,504605],[337609,504605],[337612,504612]],[[337598,504609],[337587,504605],[337577,504605],[337572,504607],[337573,504610],[337575,504613],[337580,504613],[337586,504612],[337593,504613],[337597,504611],[337598,504609]]]}, - // stroke: 'rgba(0,112,60,1)', - // fill: 'rgba(0,112,60,0.2)', - // strokeWidth: 2 - // }) - // drawPlugin.editFeature('test1234') + drawPlugin.addFeature({ + id: 'test1234', + type: 'Feature', + geometry: {'type':'Polygon','coordinates':[[[337612,504612],[337592,504595],[337575,504583],[337570,504582],[337560,504582],[337554,504590],[337559,504596],[337568,504604],[337572,504610],[337582,504611],[337585,504610],[337602,504612],[337603,504607],[337605,504605],[337609,504605],[337612,504612]],[[337598,504609],[337587,504605],[337577,504605],[337572,504607],[337573,504610],[337575,504613],[337580,504613],[337586,504612],[337593,504613],[337597,504611],[337598,504609]]]}, + stroke: 'rgba(0,112,60,1)', + fill: 'rgba(0,112,60,0.2)', + strokeWidth: 2 + }) + drawPlugin.editFeature('test1234') }) interactiveMap.on('draw:started', function (e) { diff --git a/plugins/beta/draw-ol/src/DrawInit.jsx b/plugins/beta/draw-ol/src/DrawInit.jsx index 92423692..b7e5a754 100644 --- a/plugins/beta/draw-ol/src/DrawInit.jsx +++ b/plugins/beta/draw-ol/src/DrawInit.jsx @@ -33,6 +33,13 @@ export const DrawInit = ({ appState, appConfig, mapState, pluginConfig, pluginSt } }, [pluginState.mode, appState.interfaceType]) + // Keep edit mode in sync with the global interface type so the touch + // offset target hides immediately when the user switches to mouse/keyboard. + useEffect(() => { + if (pluginState.mode !== 'edit_vertex' || !mapProvider.draw) return + mapProvider.draw.setInterfaceType(appState.interfaceType) + }, [appState.interfaceType, pluginState.mode]) + // Re-attach events when state changes useEffect(() => { if (!mapProvider.draw) return diff --git a/plugins/beta/draw-ol/src/core/OLDrawManager.js b/plugins/beta/draw-ol/src/core/OLDrawManager.js index 90bb177f..5119cc31 100644 --- a/plugins/beta/draw-ol/src/core/OLDrawManager.js +++ b/plugins/beta/draw-ol/src/core/OLDrawManager.js @@ -85,6 +85,10 @@ export class OLDrawManager { this._modeInstance?.deleteVertex() } + setInterfaceType (type) { + this._modeInstance?.setInterfaceType?.(type) + } + // --- Feature store delegation --- get (id) { return this.store.get(id) } diff --git a/plugins/beta/draw-ol/src/edit/EditMode.js b/plugins/beta/draw-ol/src/edit/EditMode.js index abf7acb0..8e2dafcc 100644 --- a/plugins/beta/draw-ol/src/edit/EditMode.js +++ b/plugins/beta/draw-ol/src/edit/EditMode.js @@ -97,8 +97,11 @@ export const createEditMode = ({ map, manager, options }) => { 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 + // 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: (mapBrowserEvent) => { + if (state.interfaceType === 'touch') return false const olPixel = map.getEventPixel(mapBrowserEvent.originalEvent) return findNearest(map, state.vertecies, state.midpoints, { x: olPixel[0], y: olPixel[1] }) !== null } @@ -145,11 +148,9 @@ export const createEditMode = ({ map, manager, options }) => { const onPointerdown = (e) => { if (e.pointerType === 'touch') { state.interfaceType = 'touch' - modifyInteraction.setActive(false) return } state.interfaceType = 'pointer' - modifyInteraction.setActive(true) const olPixel = map.getEventPixel(e) const pixel = { x: olPixel[0], y: olPixel[1] } @@ -174,7 +175,17 @@ export const createEditMode = ({ map, manager, options }) => { } } + // 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 === 'pointer') return + state.interfaceType = 'pointer' + touchHandler.hide() + } + container.addEventListener('pointerdown', onPointerdown) + container.addEventListener('pointerenter', onPointerMove) + container.addEventListener('pointermove', onPointerMove) container.addEventListener('click', onContainerClick) // --- Button click (delete vertex) --- @@ -259,10 +270,21 @@ export const createEditMode = ({ map, manager, options }) => { syncGeom() }, onDeleted: doDeleteVertex, - onUndo: doUndo + onUndo: doUndo, + onKeyboardActive () { + if (state.interfaceType === 'keyboard') return + state.interfaceType = 'keyboard' + touchHandler.hide() + } }) return { + setInterfaceType (type) { + if (type === state.interfaceType) return + state.interfaceType = type + if (type !== 'touch') touchHandler.hide() + }, + done () { manager.emit('editfinish', store.toGeoJSON(olFeature)) }, @@ -279,6 +301,8 @@ export const createEditMode = ({ map, manager, options }) => { olFeature.setStyle(originalFeatureStyle) olFeature.getGeometry().un('change', onGeometryChange) container.removeEventListener('pointerdown', onPointerdown) + container.removeEventListener('pointerenter', onPointerMove) + container.removeEventListener('pointermove', onPointerMove) container.removeEventListener('click', onContainerClick) window.removeEventListener('click', onButtonClick) map.removeInteraction(modifyInteraction) diff --git a/plugins/beta/draw-ol/src/edit/keyboardHandler.js b/plugins/beta/draw-ol/src/edit/keyboardHandler.js index 723af9fa..a290d8fb 100644 --- a/plugins/beta/draw-ol/src/edit/keyboardHandler.js +++ b/plugins/beta/draw-ol/src/edit/keyboardHandler.js @@ -24,7 +24,8 @@ const STEP_PX = 5 */ export const createKeyboardHandler = ({ map, container, getState, setState, - onVertexMoved, onInserted, onDeleted, onUndo + onVertexMoved, onInserted, onDeleted, onUndo, + onKeyboardActive }) => { let keyMoveStart = null let keyMoveIndex = null @@ -117,6 +118,7 @@ export const createKeyboardHandler = ({ const onKeydown = (e) => { if (!container.contains(document.activeElement)) return + onKeyboardActive?.() if (e.key === ' ' && getState().selectedVertexIndex < 0) { e.preventDefault() From fee7f1cf2315d6807ba35c0b57b9c57d0a98687c Mon Sep 17 00:00:00 2001 From: Dan Leech Date: Fri, 15 May 2026 16:19:36 +0100 Subject: [PATCH 07/26] Offset target size chnage basics --- plugins/beta/draw-ol/src/edit/EditMode.js | 10 ++++++++++ plugins/beta/draw-ol/src/edit/touchHandler.js | 16 ++++++++-------- 2 files changed, 18 insertions(+), 8 deletions(-) diff --git a/plugins/beta/draw-ol/src/edit/EditMode.js b/plugins/beta/draw-ol/src/edit/EditMode.js index 8e2dafcc..53ddc357 100644 --- a/plugins/beta/draw-ol/src/edit/EditMode.js +++ b/plugins/beta/draw-ol/src/edit/EditMode.js @@ -255,6 +255,15 @@ export const createEditMode = ({ map, manager, options }) => { 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, @@ -305,6 +314,7 @@ export const createEditMode = ({ map, manager, options }) => { container.removeEventListener('pointermove', onPointerMove) container.removeEventListener('click', onContainerClick) window.removeEventListener('click', onButtonClick) + map.un('change:size', onMapSizeChange) map.removeInteraction(modifyInteraction) midpointLayer.remove() vertexLayer.remove() diff --git a/plugins/beta/draw-ol/src/edit/touchHandler.js b/plugins/beta/draw-ol/src/edit/touchHandler.js index e178a458..075faec3 100644 --- a/plugins/beta/draw-ol/src/edit/touchHandler.js +++ b/plugins/beta/draw-ol/src/edit/touchHandler.js @@ -53,14 +53,14 @@ export const createTouchHandler = ({ map, container, getState, setState, onVerte const vertex = vertecies[selectedVertexIndex] if (!vertex) return - const t = { x: touch.clientX, y: touch.clientY } + const tOl = map.getEventPixel({ clientX: touch.clientX, clientY: touch.clientY }) const vertexPx = coordToPixel(map, vertex) const style = getComputedStyle(targetEl) dragStartCoord = [...vertex] dragStartIndex = selectedVertexIndex - vertexTouchDelta = { x: t.x - vertexPx.x, y: t.y - vertexPx.y } - targetTouchDelta = { x: t.x - Number.parseFloat(style.left), y: t.y - Number.parseFloat(style.top) } + vertexTouchDelta = { x: tOl[0] - vertexPx.x, y: tOl[1] - vertexPx.y } + targetTouchDelta = { x: tOl[0] - Number.parseFloat(style.left), y: tOl[1] - Number.parseFloat(style.top) } e.preventDefault() } @@ -69,15 +69,15 @@ export const createTouchHandler = ({ map, container, getState, setState, onVerte if (!isOnTouchTarget(e.target) || dragStartIndex == null) return e.preventDefault() - const touch = { x: e.touches[0].clientX, y: e.touches[0].clientY } - const newVertexPx = { x: touch.x - vertexTouchDelta.x, y: touch.y - vertexTouchDelta.y } + const tOl = map.getEventPixel({ clientX: e.touches[0].clientX, clientY: e.touches[0].clientY }) + const newVertexPx = { x: tOl[0] - vertexTouchDelta.x, y: tOl[1] - vertexTouchDelta.y } const newCoord = pixelToCoord(map, newVertexPx) const { olFeature, vertecies } = getState() if (!olFeature) return moveVertex(olFeature, dragStartIndex, newCoord) setState({ vertecies: vertecies.map((c, i) => i === dragStartIndex ? newCoord : c) }) - showTouchTarget(targetEl, { x: touch.x - targetTouchDelta.x, y: touch.y - targetTouchDelta.y }) + showTouchTarget(targetEl, { x: tOl[0] - targetTouchDelta.x, y: tOl[1] - targetTouchDelta.y }) } const onTouchend = (e) => { @@ -91,8 +91,8 @@ export const createTouchHandler = ({ map, container, getState, setState, onVerte const dt = Date.now() - tapStart.time if (Math.sqrt(dx * dx + dy * dy) < TAP_MOVE_THRESHOLD && dt < TAP_TIME_THRESHOLD) { - const rect = map.getViewport().getBoundingClientRect() - const pixel = { x: t.clientX - rect.left, y: t.clientY - rect.top } + const tOl = map.getEventPixel({ clientX: t.clientX, clientY: t.clientY }) + const pixel = { x: tOl[0], y: tOl[1] } const { vertecies, midpoints } = getState() const hit = findNearest(map, vertecies, midpoints, pixel, TOUCH_TOLERANCE) onTap?.(hit) From 09e9cce955e7f1b7b36ed925fef3e95928d93c29 Mon Sep 17 00:00:00 2001 From: Dan Leech Date: Mon, 18 May 2026 09:03:39 +0100 Subject: [PATCH 08/26] Map size offset marker fix --- plugins/beta/draw-ol/src/edit/touchHandler.js | 99 +++++++++++-------- 1 file changed, 59 insertions(+), 40 deletions(-) diff --git a/plugins/beta/draw-ol/src/edit/touchHandler.js b/plugins/beta/draw-ol/src/edit/touchHandler.js index 075faec3..e61a509f 100644 --- a/plugins/beta/draw-ol/src/edit/touchHandler.js +++ b/plugins/beta/draw-ol/src/edit/touchHandler.js @@ -3,32 +3,54 @@ import { createTouchTarget, showTouchTarget, hideTouchTarget, isOnTouchTarget } import { moveVertex } from './vertexOps.js' import { findNearest } from './vertexHitTest.js' -const TAP_MOVE_THRESHOLD = 10 // px — movement beyond this is a drag, not a tap -const TAP_TIME_THRESHOLD = 400 // ms -const TOUCH_TOLERANCE = 24 // px — larger hit area for touch vs pointer +const TAP_MOVE_THRESHOLD = 10 +const TAP_TIME_THRESHOLD = 400 +const TOUCH_TOLERANCE = 24 /** * 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. * - * Shows an SVG offset target below the finger when a vertex is selected in - * touch mode, allowing accurate repositioning without finger occlusion. - * Drag moves the vertex directly (bypasses OL Modify). - * Tap on a vertex or midpoint selects it via the onTap callback. - * - * @param {{ map, container, olFeature, getState, setState, onVertexMoved, onTap }} + * @param {{ map, container, getState, setState, onVertexMoved, onTap }} options * @returns {{ updateTargetPosition, hide, destroy }} */ export const createTouchHandler = ({ map, container, getState, setState, onVertexMoved, onTap }) => { + // SVG is a child of container (outside ol-viewport) so touches on it never + // reach OL's DragPan — no capture or stopPropagation needed. const targetEl = createTouchTarget(container) - // Per-drag state - let dragStartCoord = null // vertex coordinate at touch start - let dragStartIndex = null // vertex index being dragged - let vertexTouchDelta = null // offset from touch point to vertex pixel - let targetTouchDelta = null // offset from touch point to target element - let tapStart = null // { x, y, time, onTarget } — for tap detection + let dragStartCoord = null + let dragStartIndex = null + let vertexTouchDelta = null + let targetTouchDelta = null + let tapStart = null + + // 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). + // cssTx converts between them: containerCSS = olPx × scale + offset. + let cssTx = { scale: 1, ox: 0, oy: 0 } + + 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 + cssTx = { + scale: vpScale / cScale, + ox: (vpRect.left - cRect.left) / cScale, + oy: (vpRect.top - cRect.top) / cScale + } + } + + 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 }) - // --- Target positioning --- + const onSizeChange = () => { syncCssTx(); map.once('postrender', updateTargetPosition) } + map.on('change:size', onSizeChange) + syncCssTx() const updateTargetPosition = () => { const { selectedVertexIndex, vertecies } = getState() @@ -36,48 +58,52 @@ export const createTouchHandler = ({ map, container, getState, setState, onVerte hideTouchTarget(targetEl) return } - const pixel = coordToPixel(map, vertecies[selectedVertexIndex]) - showTouchTarget(targetEl, pixel) + showTouchTarget(targetEl, olToCSS(coordToPixel(map, vertecies[selectedVertexIndex]))) } - // --- Touch event handlers --- + // Reposition on every render — keeps target anchored during pinch-zoom and pan. + // Skipped during drag since touchmove handles position directly. + const onPostrender = () => { + const { selectedVertexIndex } = getState() + if (selectedVertexIndex >= 0 && dragStartIndex == null) { updateTargetPosition() } + } + map.on('postrender', onPostrender) 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 + if (!onTarget) { return } const { selectedVertexIndex, vertecies } = getState() const vertex = vertecies[selectedVertexIndex] - if (!vertex) return + 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] - Number.parseFloat(style.left), y: tOl[1] - Number.parseFloat(style.top) } - + targetTouchDelta = { x: tOl[0] - svgOlPx.x, y: tOl[1] - svgOlPx.y } e.preventDefault() } const onTouchmove = (e) => { - if (!isOnTouchTarget(e.target) || dragStartIndex == null) return + if (!isOnTouchTarget(e.target) || dragStartIndex == null) { return } e.preventDefault() const tOl = map.getEventPixel({ clientX: e.touches[0].clientX, clientY: e.touches[0].clientY }) - const newVertexPx = { x: tOl[0] - vertexTouchDelta.x, y: tOl[1] - vertexTouchDelta.y } - const newCoord = pixelToCoord(map, newVertexPx) + const newCoord = pixelToCoord(map, { x: tOl[0] - vertexTouchDelta.x, y: tOl[1] - vertexTouchDelta.y }) const { olFeature, vertecies } = getState() - if (!olFeature) return + if (!olFeature) { return } moveVertex(olFeature, dragStartIndex, newCoord) setState({ vertecies: vertecies.map((c, i) => i === dragStartIndex ? newCoord : c) }) - showTouchTarget(targetEl, { x: tOl[0] - targetTouchDelta.x, y: tOl[1] - targetTouchDelta.y }) + showTouchTarget(targetEl, olToCSS({ x: tOl[0] - targetTouchDelta.x, y: tOl[1] - targetTouchDelta.y })) } const onTouchend = (e) => { @@ -89,12 +115,10 @@ export const createTouchHandler = ({ map, container, getState, setState, onVerte const dx = t.clientX - tapStart.x const dy = t.clientY - tapStart.y const dt = Date.now() - tapStart.time - if (Math.sqrt(dx * dx + dy * dy) < TAP_MOVE_THRESHOLD && dt < TAP_TIME_THRESHOLD) { const tOl = map.getEventPixel({ clientX: t.clientX, clientY: t.clientY }) - const pixel = { x: tOl[0], y: tOl[1] } - const { vertecies, midpoints } = getState() - const hit = findNearest(map, vertecies, midpoints, pixel, TOUCH_TOLERANCE) + const tapState = getState() + const hit = findNearest(map, tapState.vertecies, tapState.midpoints, { x: tOl[0], y: tOl[1] }, TOUCH_TOLERANCE) onTap?.(hit) e.preventDefault() } @@ -104,17 +128,11 @@ export const createTouchHandler = ({ map, container, getState, setState, onVerte } tapStart = null - const { vertecies } = getState() const finalCoord = vertecies[dragStartIndex] - if (finalCoord && dragStartCoord) { - onVertexMoved({ - vertexIndex: dragStartIndex, - previousCoord: dragStartCoord - }) + onVertexMoved({ vertexIndex: dragStartIndex, previousCoord: dragStartCoord }) } - dragStartCoord = null dragStartIndex = null vertexTouchDelta = null @@ -129,8 +147,9 @@ export const createTouchHandler = ({ map, container, getState, setState, onVerte return { updateTargetPosition, hide () { hideTouchTarget(targetEl) }, - destroy () { + map.un('change:size', onSizeChange) + map.un('postrender', onPostrender) container.removeEventListener('touchstart', onTouchstart) container.removeEventListener('touchmove', onTouchmove) container.removeEventListener('touchend', onTouchend) From e384439b7ddd1f3f34770b484efd797c06a6c34f Mon Sep 17 00:00:00 2001 From: Dan Leech Date: Mon, 18 May 2026 11:59:25 +0100 Subject: [PATCH 09/26] Minor touch/keyboard draw fixes --- plugins/beta/draw-ol/src/core/styles.js | 13 ++--- plugins/beta/draw-ol/src/edit/EditMode.js | 47 +++++++++++++++---- .../beta/draw-ol/src/edit/keyboardHandler.js | 20 ++++++-- .../beta/draw-ol/src/edit/midpointLayer.js | 26 ++-------- plugins/beta/draw-ol/src/edit/touchHandler.js | 6 +-- plugins/beta/draw-ol/src/edit/vertexLayer.js | 18 ++----- 6 files changed, 73 insertions(+), 57 deletions(-) diff --git a/plugins/beta/draw-ol/src/core/styles.js b/plugins/beta/draw-ol/src/core/styles.js index 907b185e..5ed3d1cd 100644 --- a/plugins/beta/draw-ol/src/core/styles.js +++ b/plugins/beta/draw-ol/src/core/styles.js @@ -21,10 +21,11 @@ export const vertexStyle = new Style({ }) }) -// Selected vertex: primary circle + 2px white ring + 3px black outer ring (painted bottom to top) +// Selected vertex: primary core r=6, white gap r=6-8, black outer ring r=8-11. +// Two styles instead of three: fill+stroke share one canvas arc so all ring edges +// are drawn in a single call, avoiding sub-pixel drift at fractional CSS scales (e.g. 1.5×). export const selectedVertexStyle = [ - new Style({ image: new CircleStyle({ radius: 11, fill: new Fill({ color: COLOR.black }) }) }), - new Style({ image: new CircleStyle({ radius: 8, fill: new Fill({ color: COLOR.white }) }) }), + new Style({ image: new CircleStyle({ radius: 9.5, fill: new Fill({ color: COLOR.white }), stroke: new Stroke({ color: COLOR.black, width: 3 }) }) }), new Style({ image: new CircleStyle({ radius: 6, fill: new Fill({ color: COLOR.primary }) }) }) ] @@ -36,10 +37,10 @@ export const midpointStyle = new Style({ }) }) -// Selected midpoint: primary circle + 2px white ring + 3px black outer ring (painted bottom to top) +// Selected midpoint: primary core r=4, white gap r=4-6, black outer ring r=6-9. +// Same two-style pattern as selectedVertexStyle. export const selectedMidpointStyle = [ - new Style({ image: new CircleStyle({ radius: 9, fill: new Fill({ color: COLOR.black }) }) }), - new Style({ image: new CircleStyle({ radius: 6, fill: new Fill({ color: COLOR.white }) }) }), + new Style({ image: new CircleStyle({ radius: 7.5, fill: new Fill({ color: COLOR.white }), stroke: new Stroke({ color: COLOR.black, width: 3 }) }) }), new Style({ image: new CircleStyle({ radius: 4, fill: new Fill({ color: COLOR.primary }) }) }) ] diff --git a/plugins/beta/draw-ol/src/edit/EditMode.js b/plugins/beta/draw-ol/src/edit/EditMode.js index 53ddc357..2d422d30 100644 --- a/plugins/beta/draw-ol/src/edit/EditMode.js +++ b/plugins/beta/draw-ol/src/edit/EditMode.js @@ -1,5 +1,9 @@ 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' @@ -8,7 +12,7 @@ import { findNearest } from './vertexHitTest.js' import { deleteVertex, insertAtMidpoint } from './vertexOps.js' import { applyUndo } from './undoOps.js' import { getCoords, getMidpoints } from '../utils/geometryHelpers.js' -import { editFeatureStyle } from '../core/styles.js' +import { editFeatureStyle, selectedVertexStyle, selectedMidpointStyle } from '../core/styles.js' /** * Edit vertex mode — handles edit_vertex. @@ -46,13 +50,8 @@ export const createEditMode = ({ map, manager, options }) => { const setState = (updates) => { Object.assign(state, updates) if (updates.selectedVertexIndex !== undefined) { - vertexLayer.setSelected(state.selectedVertexIndex) - midpointLayer.setSelected( - state.selectedVertexType === 'midpoint' - ? state.selectedVertexIndex - state.vertecies.length - : -1 - ) - if (state.selectedVertexIndex < 0) onDeselect?.() + if (state.selectedVertexIndex < 0) { onDeselect?.() } + updateActiveLayer() manager.emit('vertexselection', { index: state.selectedVertexType === 'vertex' ? state.selectedVertexIndex : -1, numVertecies: state.vertecies.length @@ -66,6 +65,7 @@ export const createEditMode = ({ map, manager, options }) => { midpointLayer.update(plainGeom) vertexLayer.update(plainGeom) state.midpoints = midpointLayer.getCoords() + updateActiveLayer() onUpdate?.() map.render() } @@ -79,6 +79,7 @@ export const createEditMode = ({ map, manager, options }) => { state.midpoints = getMidpoints(plainGeom) midpointLayer.update(plainGeom) vertexLayer.update(plainGeom) + updateActiveLayer() } const syncGeom = () => { @@ -142,6 +143,32 @@ export const createEditMode = ({ map, manager, options }) => { // --- Vertex + midpoint layers (always-visible handles) --- const midpointLayer = createMidpointLayer(map) const vertexLayer = createVertexLayer(map) + + // --- 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, vertecies, midpoints } = state + if (selectedVertexIndex < 0) { return } + let coord, style + if (selectedVertexType === 'vertex') { + coord = vertecies[selectedVertexIndex] + style = selectedVertexStyle + } else if (selectedVertexType === 'midpoint') { + coord = midpoints[selectedVertexIndex - vertecies.length] + style = selectedMidpointStyle + } else { + return + } + if (!coord) { return } + const f = new Feature({ geometry: new Point(coord) }) + f.setStyle(style) + activeSource.addFeature(f) + } + syncGeom() // initial populate // --- Pointer hit detection --- @@ -267,7 +294,6 @@ export const createEditMode = ({ map, manager, options }) => { // --- Keyboard handler --- const keyboardHandler = createKeyboardHandler({ map, - container, getState, setState, onVertexMoved ({ vertexIndex, previousCoord }) { @@ -284,6 +310,7 @@ export const createEditMode = ({ map, manager, options }) => { if (state.interfaceType === 'keyboard') return state.interfaceType = 'keyboard' touchHandler.hide() + container.focus({ preventScroll: true }) } }) @@ -316,6 +343,8 @@ export const createEditMode = ({ map, manager, options }) => { window.removeEventListener('click', onButtonClick) map.un('change:size', onMapSizeChange) map.removeInteraction(modifyInteraction) + activeSource.clear() + map.removeLayer(activeLayer) midpointLayer.remove() vertexLayer.remove() touchHandler.destroy() diff --git a/plugins/beta/draw-ol/src/edit/keyboardHandler.js b/plugins/beta/draw-ol/src/edit/keyboardHandler.js index a290d8fb..45c2947e 100644 --- a/plugins/beta/draw-ol/src/edit/keyboardHandler.js +++ b/plugins/beta/draw-ol/src/edit/keyboardHandler.js @@ -23,7 +23,7 @@ const STEP_PX = 5 * @returns {{ destroy }} */ export const createKeyboardHandler = ({ - map, container, getState, setState, + map, getState, setState, onVertexMoved, onInserted, onDeleted, onUndo, onKeyboardActive }) => { @@ -116,8 +116,22 @@ export const createKeyboardHandler = ({ setState({ vertecies: vertecies.map((c, i) => i === selectedVertexIndex ? newCoord : c) }) } + const isTextInput = () => { + const el = document.activeElement + return el?.tagName === 'INPUT' || el?.tagName === 'TEXTAREA' || el?.isContentEditable + } + const onKeydown = (e) => { - if (!container.contains(document.activeElement)) return + if (isTextInput()) { return } + + if (e.key === 'Escape' && getState().selectedVertexIndex >= 0) { + e.preventDefault() + keyMoveStart = null + keyMoveIndex = null + setState({ selectedVertexIndex: -1, selectedVertexType: null }) + return + } + onKeyboardActive?.() if (e.key === ' ' && getState().selectedVertexIndex < 0) { @@ -150,7 +164,7 @@ export const createKeyboardHandler = ({ } const onKeyup = (e) => { - if (!container.contains(document.activeElement)) return + if (isTextInput()) { return } if (ARROW_KEYS.has(e.key) && keyMoveStart && keyMoveIndex != null) { onVertexMoved({ vertexIndex: keyMoveIndex, previousCoord: keyMoveStart }) diff --git a/plugins/beta/draw-ol/src/edit/midpointLayer.js b/plugins/beta/draw-ol/src/edit/midpointLayer.js index 3b68082d..f27a12fa 100644 --- a/plugins/beta/draw-ol/src/edit/midpointLayer.js +++ b/plugins/beta/draw-ol/src/edit/midpointLayer.js @@ -3,28 +3,20 @@ 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' -import { midpointStyle, selectedMidpointStyle } from '../core/styles.js' +import { midpointStyle } from '../core/styles.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). + * 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) => { - let selectedIndex = -1 const source = new VectorSource() - const layer = new VectorLayer({ - source, - style: (feature) => feature.get('midpointIndex') === selectedIndex ? selectedMidpointStyle : [midpointStyle], - zIndex: 101 - }) + const layer = new VectorLayer({ source, style: () => [midpointStyle], zIndex: 101 }) map.addLayer(layer) return { - /** - * Recompute and render midpoints from a geometry object. - * @param {{ type: string, coordinates: any }} geom - plain GeoJSON geometry - */ update (geom) { source.clear() const midpoints = getMidpoints(geom) @@ -36,22 +28,12 @@ export const createMidpointLayer = (map) => { source.addFeatures(features) }, - /** Current midpoint coordinates in order. */ - setSelected (index) { - selectedIndex = index - source.changed() - }, - getCoords () { return source.getFeatures() .sort((a, b) => a.get('midpointIndex') - b.get('midpointIndex')) .map(f => f.getGeometry().getCoordinates()) }, - clear () { - source.clear() - }, - remove () { source.clear() map.removeLayer(layer) diff --git a/plugins/beta/draw-ol/src/edit/touchHandler.js b/plugins/beta/draw-ol/src/edit/touchHandler.js index e61a509f..e6ab1c9b 100644 --- a/plugins/beta/draw-ol/src/edit/touchHandler.js +++ b/plugins/beta/draw-ol/src/edit/touchHandler.js @@ -64,8 +64,8 @@ export const createTouchHandler = ({ map, container, getState, setState, onVerte // Reposition on every render — keeps target anchored during pinch-zoom and pan. // Skipped during drag since touchmove handles position directly. const onPostrender = () => { - const { selectedVertexIndex } = getState() - if (selectedVertexIndex >= 0 && dragStartIndex == null) { updateTargetPosition() } + const { selectedVertexIndex, interfaceType } = getState() + if (selectedVertexIndex >= 0 && dragStartIndex == null && interfaceType === 'touch') { updateTargetPosition() } } map.on('postrender', onPostrender) @@ -115,7 +115,7 @@ export const createTouchHandler = ({ map, container, getState, setState, onVerte const dx = t.clientX - tapStart.x const dy = t.clientY - tapStart.y const dt = Date.now() - tapStart.time - if (Math.sqrt(dx * dx + dy * dy) < TAP_MOVE_THRESHOLD && dt < TAP_TIME_THRESHOLD) { + if (Math.hypot(dx, dy) < TAP_MOVE_THRESHOLD && dt < TAP_TIME_THRESHOLD) { const tOl = map.getEventPixel({ clientX: t.clientX, clientY: t.clientY }) const tapState = getState() const hit = findNearest(map, tapState.vertecies, tapState.midpoints, { x: tOl[0], y: tOl[1] }, TOUCH_TOLERANCE) diff --git a/plugins/beta/draw-ol/src/edit/vertexLayer.js b/plugins/beta/draw-ol/src/edit/vertexLayer.js index e7d4fc41..d036e7ce 100644 --- a/plugins/beta/draw-ol/src/edit/vertexLayer.js +++ b/plugins/beta/draw-ol/src/edit/vertexLayer.js @@ -3,22 +3,17 @@ 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' -import { vertexStyle, selectedVertexStyle } from '../core/styles.js' +import { vertexStyle } from '../core/styles.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 and highlights the selected vertex. + * 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) => { - let selectedIndex = -1 const source = new VectorSource() - - const layer = new VectorLayer({ - source, - style: (feature) => feature.get('vertexIndex') === selectedIndex ? selectedVertexStyle : [vertexStyle], - zIndex: 102 - }) + const layer = new VectorLayer({ source, style: () => [vertexStyle], zIndex: 102 }) map.addLayer(layer) return { @@ -31,11 +26,6 @@ export const createVertexLayer = (map) => { }) }, - setSelected (index) { - selectedIndex = index - source.changed() - }, - remove () { source.clear() map.removeLayer(layer) From ea58952c8ba0aaca386585b8cdb6c2cbb82d9902 Mon Sep 17 00:00:00 2001 From: Dan Leech Date: Mon, 18 May 2026 13:25:11 +0100 Subject: [PATCH 10/26] Medium size active vertext marker fix --- plugins/beta/draw-ol/src/core/styles.js | 31 +++++++++++-------- plugins/beta/draw-ol/src/edit/EditMode.js | 11 ++++++- .../beta/draw-ol/src/edit/midpointLayer.js | 12 ++++++- plugins/beta/draw-ol/src/edit/touchHandler.js | 4 +-- plugins/beta/draw-ol/src/edit/vertexLayer.js | 12 ++++++- 5 files changed, 52 insertions(+), 18 deletions(-) diff --git a/plugins/beta/draw-ol/src/core/styles.js b/plugins/beta/draw-ol/src/core/styles.js index 5ed3d1cd..6f8ac7c2 100644 --- a/plugins/beta/draw-ol/src/core/styles.js +++ b/plugins/beta/draw-ol/src/core/styles.js @@ -21,13 +21,23 @@ export const vertexStyle = new Style({ }) }) -// Selected vertex: primary core r=6, white gap r=6-8, black outer ring r=8-11. -// Two styles instead of three: fill+stroke share one canvas arc so all ring edges -// are drawn in a single call, avoiding sub-pixel drift at fractional CSS scales (e.g. 1.5×). -export const selectedVertexStyle = [ - new Style({ image: new CircleStyle({ radius: 9.5, fill: new Fill({ color: COLOR.white }), stroke: new Stroke({ color: COLOR.black, width: 3 }) }) }), - new Style({ image: new CircleStyle({ radius: 6, fill: new Fill({ color: COLOR.primary }) }) }) -] +// 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 selectedVertexRadii = { outer: 11, mid: 8, inner: 6 } +const selectedMidpointRadii = { outer: 9, mid: 6, inner: 4 } + +const makeRingRenderer = ({ outer, mid, inner }) => (pixelCoordinates, state) => { + const ctx = state.context + const pr = state.pixelRatio + const [cx, cy] = /** @type {number[]} */ (pixelCoordinates) + ctx.save() + ctx.beginPath(); ctx.arc(cx, cy, outer * pr, 0, Math.PI * 2); ctx.fillStyle = COLOR.black; ctx.fill() + ctx.beginPath(); ctx.arc(cx, cy, mid * pr, 0, Math.PI * 2); ctx.fillStyle = COLOR.white; ctx.fill() + ctx.beginPath(); ctx.arc(cx, cy, inner * pr, 0, Math.PI * 2); ctx.fillStyle = COLOR.primary; ctx.fill() + ctx.restore() +} + +export const selectedVertexStyle = new Style({ renderer: makeRingRenderer(selectedVertexRadii) }) // Midpoint: solid filled circle, r=4 export const midpointStyle = new Style({ @@ -37,12 +47,7 @@ export const midpointStyle = new Style({ }) }) -// Selected midpoint: primary core r=4, white gap r=4-6, black outer ring r=6-9. -// Same two-style pattern as selectedVertexStyle. -export const selectedMidpointStyle = [ - new Style({ image: new CircleStyle({ radius: 7.5, fill: new Fill({ color: COLOR.white }), stroke: new Stroke({ color: COLOR.black, width: 3 }) }) }), - new Style({ image: new CircleStyle({ radius: 4, fill: new Fill({ color: COLOR.primary }) }) }) -] +export const selectedMidpointStyle = new Style({ renderer: makeRingRenderer(selectedMidpointRadii) }) // Style applied directly to the OL feature while in edit mode, overriding its stored colours export const editFeatureStyle = new Style({ diff --git a/plugins/beta/draw-ol/src/edit/EditMode.js b/plugins/beta/draw-ol/src/edit/EditMode.js index 2d422d30..8859dbda 100644 --- a/plugins/beta/draw-ol/src/edit/EditMode.js +++ b/plugins/beta/draw-ol/src/edit/EditMode.js @@ -50,6 +50,10 @@ export const createEditMode = ({ map, manager, options }) => { 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.vertecies.length : -1 + ) if (state.selectedVertexIndex < 0) { onDeselect?.() } updateActiveLayer() manager.emit('vertexselection', { @@ -175,6 +179,7 @@ export const createEditMode = ({ map, manager, options }) => { const onPointerdown = (e) => { if (e.pointerType === 'touch') { state.interfaceType = 'touch' + touchHandler.updateTargetPosition() return } state.interfaceType = 'pointer' @@ -318,7 +323,11 @@ export const createEditMode = ({ map, manager, options }) => { setInterfaceType (type) { if (type === state.interfaceType) return state.interfaceType = type - if (type !== 'touch') touchHandler.hide() + if (type === 'touch') { + touchHandler.updateTargetPosition() + } else { + touchHandler.hide() + } }, done () { diff --git a/plugins/beta/draw-ol/src/edit/midpointLayer.js b/plugins/beta/draw-ol/src/edit/midpointLayer.js index f27a12fa..4ea0bfe7 100644 --- a/plugins/beta/draw-ol/src/edit/midpointLayer.js +++ b/plugins/beta/draw-ol/src/edit/midpointLayer.js @@ -12,8 +12,13 @@ import { midpointStyle } from '../core/styles.js' * rendered by the separate active-selection layer in EditMode (zIndex 103). */ export const createMidpointLayer = (map) => { + let selectedIndex = -1 const source = new VectorSource() - const layer = new VectorLayer({ source, style: () => [midpointStyle], zIndex: 101 }) + const layer = new VectorLayer({ + source, + style: (feature) => feature.get('midpointIndex') === selectedIndex ? null : [midpointStyle], + zIndex: 101 + }) map.addLayer(layer) return { @@ -28,6 +33,11 @@ export const createMidpointLayer = (map) => { source.addFeatures(features) }, + setSelected (index) { + selectedIndex = index + source.changed() + }, + getCoords () { return source.getFeatures() .sort((a, b) => a.get('midpointIndex') - b.get('midpointIndex')) diff --git a/plugins/beta/draw-ol/src/edit/touchHandler.js b/plugins/beta/draw-ol/src/edit/touchHandler.js index e6ab1c9b..bf079eb1 100644 --- a/plugins/beta/draw-ol/src/edit/touchHandler.js +++ b/plugins/beta/draw-ol/src/edit/touchHandler.js @@ -53,8 +53,8 @@ export const createTouchHandler = ({ map, container, getState, setState, onVerte syncCssTx() const updateTargetPosition = () => { - const { selectedVertexIndex, vertecies } = getState() - if (selectedVertexIndex < 0 || !vertecies[selectedVertexIndex]) { + const { selectedVertexIndex, vertecies, interfaceType } = getState() + if (selectedVertexIndex < 0 || !vertecies[selectedVertexIndex] || interfaceType !== 'touch') { hideTouchTarget(targetEl) return } diff --git a/plugins/beta/draw-ol/src/edit/vertexLayer.js b/plugins/beta/draw-ol/src/edit/vertexLayer.js index d036e7ce..5f444794 100644 --- a/plugins/beta/draw-ol/src/edit/vertexLayer.js +++ b/plugins/beta/draw-ol/src/edit/vertexLayer.js @@ -12,8 +12,13 @@ import { vertexStyle } from '../core/styles.js' * the separate active-selection layer in EditMode (zIndex 103). */ export const createVertexLayer = (map) => { + let selectedIndex = -1 const source = new VectorSource() - const layer = new VectorLayer({ source, style: () => [vertexStyle], zIndex: 102 }) + const layer = new VectorLayer({ + source, + style: (feature) => feature.get('vertexIndex') === selectedIndex ? null : [vertexStyle], + zIndex: 102 + }) map.addLayer(layer) return { @@ -26,6 +31,11 @@ export const createVertexLayer = (map) => { }) }, + setSelected (index) { + selectedIndex = index + source.changed() + }, + remove () { source.clear() map.removeLayer(layer) From a43120850e856cd224afcc7ac180c6fdc4e71ab9 Mon Sep 17 00:00:00 2001 From: Dan Leech Date: Mon, 18 May 2026 14:41:11 +0100 Subject: [PATCH 11/26] Light and dark colour support added --- demo/js/draw-ol.js | 4 +- demo/js/mapStyles.js | 3 + plugins/beta/draw-ml/src/mapboxDraw.js | 5 + .../src/modes/editVertex/touchHandlers.js | 12 +- .../beta/draw-ml/src/modes/editVertexMode.js | 2 + plugins/beta/draw-ol/src/DrawInit.jsx | 28 +-- plugins/beta/draw-ol/src/api/addFeature.js | 11 +- plugins/beta/draw-ol/src/api/newLine.js | 16 +- plugins/beta/draw-ol/src/api/newPolygon.js | 16 +- .../beta/draw-ol/src/core/OLDrawManager.js | 27 ++- plugins/beta/draw-ol/src/core/styles.js | 123 ++++++------- plugins/beta/draw-ol/src/defaults.js | 9 + plugins/beta/draw-ol/src/draw/DrawMode.js | 164 +++++++----------- plugins/beta/draw-ol/src/edit/EditMode.js | 114 ++++++++---- .../beta/draw-ol/src/edit/midpointLayer.js | 11 +- plugins/beta/draw-ol/src/edit/touchHandler.js | 154 ++++++++-------- plugins/beta/draw-ol/src/edit/vertexLayer.js | 11 +- plugins/beta/draw-ol/src/olDraw.js | 18 +- .../src/utils/flattenStyleProperties.js | 47 +++++ .../beta/draw-ol/src/utils/resolveColors.js | 36 ++++ plugins/beta/draw-ol/src/utils/touchTarget.js | 32 +++- 21 files changed, 520 insertions(+), 323 deletions(-) create mode 100644 plugins/beta/draw-ol/src/defaults.js create mode 100644 plugins/beta/draw-ol/src/utils/flattenStyleProperties.js create mode 100644 plugins/beta/draw-ol/src/utils/resolveColors.js diff --git a/demo/js/draw-ol.js b/demo/js/draw-ol.js index e78845b8..6a93ecda 100644 --- a/demo/js/draw-ol.js +++ b/demo/js/draw-ol.js @@ -1,6 +1,6 @@ // InteractiveMap with OpenLayers provider and draw-ol plugin import InteractiveMap from '../../src/index.js' -import { ngdMapStyles27700 } from './mapStyles.js' +import { vtsMapStyles27700, ngdMapStyles27700 } from './mapStyles.js' import { transformGeocodeRequest, transformVtsRequest27700 } from './auth.js' // Providers import openLayersProvider from '/providers/beta/openlayers/src/index.js' @@ -35,7 +35,7 @@ const interactiveMap = new InteractiveMap('map', { // readMapText: true, plugins: [ mapStylesPlugin({ - mapStyles: ngdMapStyles27700 + mapStyles: vtsMapStyles27700 // ngdMapStyles27700 }), searchPlugin({ transformRequest: transformGeocodeRequest, diff --git a/demo/js/mapStyles.js b/demo/js/mapStyles.js index b85cd965..6b07f31b 100755 --- a/demo/js/mapStyles.js +++ b/demo/js/mapStyles.js @@ -88,6 +88,7 @@ const vtsMapStyles27700 = [{ id: 'outdoor', label: 'Outdoor', url: process.env.VTS_OUTDOOR_URL_27700, + renderMode: 'vector', thumbnail: OUTDOOR_THUMBNAIL, logo: OS_LOGO, logoAltText: OS_LOGO_ALT, @@ -97,6 +98,7 @@ const vtsMapStyles27700 = [{ id: 'dark', label: 'Dark', url: process.env.VTS_DARK_URL_27700, + renderMode: 'vector', mapColorScheme: 'dark', appColorScheme: 'dark', thumbnail: DARK_THUMBNAIL, @@ -107,6 +109,7 @@ const vtsMapStyles27700 = [{ id: BW_ID, label: BW_LABEL, url: process.env.VTS_BLACK_AND_WHITE_URL_27700, + renderMode: 'vector', thumbnail: BW_THUMBNAIL, logo: OS_LOGO_BLACK, logoAltText: OS_LOGO_ALT, diff --git a/plugins/beta/draw-ml/src/mapboxDraw.js b/plugins/beta/draw-ml/src/mapboxDraw.js index 2fee2ef7..b914249c 100755 --- a/plugins/beta/draw-ml/src/mapboxDraw.js +++ b/plugins/beta/draw-ml/src/mapboxDraw.js @@ -6,6 +6,7 @@ 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. @@ -90,6 +91,7 @@ export const createMapboxDraw = ({ mapStyle, mapProvider, events, eventBus, snap // 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 (also stored on map for mode access) @@ -107,8 +109,11 @@ export const createMapboxDraw = ({ mapStyle, mapProvider, events, eventBus, snap // --- 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) diff --git a/plugins/beta/draw-ml/src/modes/editVertex/touchHandlers.js b/plugins/beta/draw-ml/src/modes/editVertex/touchHandlers.js index 81994e2d..e1ef6cdc 100644 --- a/plugins/beta/draw-ml/src/modes/editVertex/touchHandlers.js +++ b/plugins/beta/draw-ml/src/modes/editVertex/touchHandlers.js @@ -7,11 +7,18 @@ 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]') @@ -20,6 +27,7 @@ export const touchHandlers = { el = state.container.querySelector('[data-touch-vertex-target]') } state.touchVertexTarget = el + applyTouchVertexColors(el, this.map._drawCurrentMapStyle) }, updateTouchVertexTarget (state, point) { diff --git a/plugins/beta/draw-ml/src/modes/editVertexMode.js b/plugins/beta/draw-ml/src/modes/editVertexMode.js index a03158e9..017981c9 100755 --- a/plugins/beta/draw-ml/src/modes/editVertexMode.js +++ b/plugins/beta/draw-ml/src/modes/editVertexMode.js @@ -63,6 +63,7 @@ export const EditVertexMode = { } this._ctx.store.render() } + this.map._drawEditContainer = options.container this.addTouchVertexTarget(state) // Clear any snap indicator when entering edit mode @@ -441,6 +442,7 @@ export const EditVertexMode = { }, onStop (state) { + this.map._drawEditContainer = null const h = this.handlers state.container.removeEventListener('pointerdown', h.pointerdown) state.container.removeEventListener('pointermove', h.pointermove) diff --git a/plugins/beta/draw-ol/src/DrawInit.jsx b/plugins/beta/draw-ol/src/DrawInit.jsx index b7e5a754..d7d37b1d 100644 --- a/plugins/beta/draw-ol/src/DrawInit.jsx +++ b/plugins/beta/draw-ol/src/DrawInit.jsx @@ -12,9 +12,11 @@ export const DrawInit = ({ appState, appConfig, mapState, pluginConfig, pluginSt useEffect(() => { const inModeWhitelist = pluginConfig.includeModes?.includes(appState.mode) ?? true const inExcludeModes = pluginConfig.excludeModes?.includes(appState.mode) ?? false - if (!mapState.isMapReady || !inModeWhitelist || inExcludeModes) return + if (!mapState.isMapReady || !inModeWhitelist || inExcludeModes) { + return undefined + } - const { remove } = createOLDraw({ mapProvider, events: EVENTS, eventBus }) + const { remove } = createOLDraw({ mapProvider, events: EVENTS, eventBus, pluginConfig, mapStyle: mapState.mapStyle }) pluginState.dispatch({ type: 'SET_MODE', payload: null }) eventBus.emit('draw:ready') @@ -24,25 +26,31 @@ export const DrawInit = ({ appState, appConfig, mapState, pluginConfig, pluginSt // Show crosshair when entering draw mode on touch/keyboard useEffect(() => { - if (['draw_polygon', 'draw_line'].includes(pluginState.mode) && isTouchOrKeyboard) { - const wasVisible = crossHair.isVisible - crossHair.fixAtCenter() - return () => { - if (!wasVisible) crossHair.hide() - } + if (!['draw_polygon', 'draw_line'].includes(pluginState.mode) || !isTouchOrKeyboard) { + return undefined + } + const wasVisible = crossHair.isVisible + crossHair.fixAtCenter() + return () => { + if (!wasVisible) { crossHair.hide() } } }, [pluginState.mode, appState.interfaceType]) // Keep edit mode in sync with the global interface type so the touch // offset target hides immediately when the user switches to mouse/keyboard. useEffect(() => { - if (pluginState.mode !== 'edit_vertex' || !mapProvider.draw) return + if (pluginState.mode !== 'edit_vertex' || !mapProvider.draw) { + return undefined + } mapProvider.draw.setInterfaceType(appState.interfaceType) + return undefined }, [appState.interfaceType, pluginState.mode]) // Re-attach events when state changes useEffect(() => { - if (!mapProvider.draw) return + if (!mapProvider.draw) { + return undefined + } return attachEvents({ appState, diff --git a/plugins/beta/draw-ol/src/api/addFeature.js b/plugins/beta/draw-ol/src/api/addFeature.js index f580fb6f..fa1c2109 100644 --- a/plugins/beta/draw-ol/src/api/addFeature.js +++ b/plugins/beta/draw-ol/src/api/addFeature.js @@ -1,3 +1,5 @@ +import { flattenStyleProperties } from '../utils/flattenStyleProperties.js' + /** * Add a pre-drawn feature (GeoJSON) to the draw layer. * The feature will be stored and available for editing. @@ -9,17 +11,16 @@ export const addFeature = ({ mapProvider, services }, feature) => { const { draw } = mapProvider const { eventBus } = services - if (!draw) return + if (!draw) { + return + } - // Extract style properties from top level const { stroke, fill, strokeWidth, properties, ...featureRest } = feature const flatFeature = { ...featureRest, properties: { ...properties, - ...(stroke && { stroke }), - ...(fill && { fill }), - ...(strokeWidth && { strokeWidth }) + ...flattenStyleProperties({ stroke, fill, strokeWidth }) } } diff --git a/plugins/beta/draw-ol/src/api/newLine.js b/plugins/beta/draw-ol/src/api/newLine.js index 12fbc1d6..c07b863b 100644 --- a/plugins/beta/draw-ol/src/api/newLine.js +++ b/plugins/beta/draw-ol/src/api/newLine.js @@ -1,3 +1,5 @@ +import { flattenStyleProperties } from '../utils/flattenStyleProperties.js' + /** * Programmatically start drawing a new line. * @@ -6,7 +8,7 @@ * @param {object} options - { snapLayers, stroke, fill, strokeWidth, properties } */ export const newLine = ( - { appState, appConfig, mapState, pluginConfig, pluginState, mapProvider, services }, + { appState, appConfig, mapState, pluginState, mapProvider, services }, featureId, options = {} ) => { @@ -14,20 +16,16 @@ export const newLine = ( const { draw } = mapProvider const { eventBus } = services - if (!draw) return + if (!draw) { + return + } eventBus.emit('draw:started', { mode: 'draw_line' }) - // Snap layers (for later when snap is implemented) - const snapLayers = options.snapLayers ?? pluginConfig.snapLayers ?? null - - // Extract style properties and merge with custom properties const { stroke, fill, strokeWidth, properties: customProperties, ...modeOptions } = options const properties = { ...customProperties, - ...(stroke && { stroke }), - ...(fill && { fill }), - ...(strokeWidth && { strokeWidth }) + ...flattenStyleProperties({ stroke, fill, strokeWidth }) } draw.changeMode('draw_line', { diff --git a/plugins/beta/draw-ol/src/api/newPolygon.js b/plugins/beta/draw-ol/src/api/newPolygon.js index fa1cf3c6..0e3becf3 100644 --- a/plugins/beta/draw-ol/src/api/newPolygon.js +++ b/plugins/beta/draw-ol/src/api/newPolygon.js @@ -1,3 +1,5 @@ +import { flattenStyleProperties } from '../utils/flattenStyleProperties.js' + /** * Programmatically start drawing a new polygon. * @@ -6,7 +8,7 @@ * @param {object} options - { snapLayers, stroke, fill, strokeWidth, properties } */ export const newPolygon = ( - { appState, appConfig, mapState, pluginConfig, pluginState, mapProvider, services }, + { appState, appConfig, mapState, pluginState, mapProvider, services }, featureId, options = {} ) => { @@ -14,20 +16,16 @@ export const newPolygon = ( const { draw } = mapProvider const { eventBus } = services - if (!draw) return + if (!draw) { + return + } eventBus.emit('draw:started', { mode: 'draw_polygon' }) - // Snap layers (for later when snap is implemented) - const snapLayers = options.snapLayers ?? pluginConfig.snapLayers ?? null - - // Extract style properties and merge with custom properties const { stroke, fill, strokeWidth, properties: customProperties, ...modeOptions } = options const properties = { ...customProperties, - ...(stroke && { stroke }), - ...(fill && { fill }), - ...(strokeWidth && { strokeWidth }) + ...flattenStyleProperties({ stroke, fill, strokeWidth }) } draw.changeMode('draw_polygon', { diff --git a/plugins/beta/draw-ol/src/core/OLDrawManager.js b/plugins/beta/draw-ol/src/core/OLDrawManager.js index 5119cc31..47a4f881 100644 --- a/plugins/beta/draw-ol/src/core/OLDrawManager.js +++ b/plugins/beta/draw-ol/src/core/OLDrawManager.js @@ -1,7 +1,8 @@ import VectorLayer from 'ol/layer/Vector.js' import { createFeatureStore } from './featureStore.js' import { createUndoStack } from './undoStack.js' -import { createFeatureStyle } from './styles.js' +import { createStyles } from './styles.js' +import { resolveColors } from '../utils/resolveColors.js' /** * Mode machine for the OL draw plugin. @@ -14,8 +15,9 @@ import { createFeatureStyle } from './styles.js' * listening to the manager's internal events. */ export class OLDrawManager { - constructor (map) { + constructor (map, pluginConfig = {}) { this._map = map + this._pluginConfig = pluginConfig this._mode = 'disabled' this._modeInstance = null this._listeners = new Map() @@ -23,18 +25,33 @@ export class OLDrawManager { this.store = createFeatureStore() this.undoStack = createUndoStack((length) => this.emit('undochange', length)) + this.colors = resolveColors(null, pluginConfig) + this.styles = createStyles(this.colors) + this._layer = new VectorLayer({ source: this.store.source, - style: createFeatureStyle(), + style: this.styles.createFeatureStyle(), zIndex: 100 }) 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.emit('styleschanged', this.styles) + } + // --- Internal event bus --- on (type, handler) { - if (!this._listeners.has(type)) this._listeners.set(type, new Set()) + if (!this._listeners.has(type)) { + this._listeners.set(type, new Set()) + } this._listeners.get(type).add(handler) } @@ -59,6 +76,8 @@ export class OLDrawManager { } else if (modeName === 'edit_vertex') { const { createEditMode } = await import('../edit/EditMode.js') this._modeInstance = createEditMode({ map: this._map, manager: this, options }) + } else { + // disabled — no mode instance needed } } diff --git a/plugins/beta/draw-ol/src/core/styles.js b/plugins/beta/draw-ol/src/core/styles.js index 6f8ac7c2..0736c586 100644 --- a/plugins/beta/draw-ol/src/core/styles.js +++ b/plugins/beta/draw-ol/src/core/styles.js @@ -3,82 +3,89 @@ import Fill from 'ol/style/Fill.js' import Stroke from 'ol/style/Stroke.js' import CircleStyle from 'ol/style/Circle.js' -const COLOR = { - primary: '#1a65a6', - white: '#ffffff', - black: '#000000', - sketchFill: 'rgba(26,101,166,0.08)', - featureFill: 'rgba(26,101,166,0.1)' -} - -// --- Shared style instances (stateless, reused across renders) --- - -// Vertex: solid filled circle, r=6 -export const vertexStyle = new Style({ - image: new CircleStyle({ - radius: 6, - fill: new Fill({ color: COLOR.primary }) - }) -}) - -// 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 selectedVertexRadii = { outer: 11, mid: 8, inner: 6 } const selectedMidpointRadii = { outer: 9, mid: 6, inner: 4 } -const makeRingRenderer = ({ outer, mid, inner }) => (pixelCoordinates, state) => { +// 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) => (pixelCoordinates, state) => { const ctx = state.context const pr = state.pixelRatio const [cx, cy] = /** @type {number[]} */ (pixelCoordinates) ctx.save() - ctx.beginPath(); ctx.arc(cx, cy, outer * pr, 0, Math.PI * 2); ctx.fillStyle = COLOR.black; ctx.fill() - ctx.beginPath(); ctx.arc(cx, cy, mid * pr, 0, Math.PI * 2); ctx.fillStyle = COLOR.white; ctx.fill() - ctx.beginPath(); ctx.arc(cx, cy, inner * pr, 0, Math.PI * 2); ctx.fillStyle = COLOR.primary; ctx.fill() + ctx.beginPath(); ctx.arc(cx, cy, outer * pr, 0, Math.PI * 2); ctx.fillStyle = colors.halo; ctx.fill() + ctx.beginPath(); ctx.arc(cx, cy, mid * pr, 0, Math.PI * 2); ctx.fillStyle = colors.background; ctx.fill() + ctx.beginPath(); ctx.arc(cx, cy, inner * pr, 0, Math.PI * 2); ctx.fillStyle = colors.primary; ctx.fill() ctx.restore() } -export const selectedVertexStyle = new Style({ renderer: makeRingRenderer(selectedVertexRadii) }) +const capitalize = (s) => s.charAt(0).toUpperCase() + s.slice(1) -// Midpoint: solid filled circle, r=4 -export const midpointStyle = new Style({ - image: new CircleStyle({ - radius: 4, - fill: new Fill({ color: COLOR.primary }) +/** + * 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.primary }) + }) }) -}) -export const selectedMidpointStyle = new Style({ renderer: makeRingRenderer(selectedMidpointRadii) }) + const selectedVertexStyle = new Style({ renderer: makeRingRenderer(selectedVertexRadii, colors) }) -// Style applied directly to the OL feature while in edit mode, overriding its stored colours -export const editFeatureStyle = new Style({ - stroke: new Stroke({ color: COLOR.primary, width: 2 }), - fill: new Fill({ color: COLOR.featureFill }) -}) + const midpointStyle = new Style({ + image: new CircleStyle({ + radius: 4, + fill: new Fill({ color: colors.primary }) + }) + }) -const sketchLineStyle = new Style({ - stroke: new Stroke({ color: COLOR.primary, width: 2 }), - fill: new Fill({ color: COLOR.sketchFill }) -}) + const selectedMidpointStyle = new Style({ renderer: makeRingRenderer(selectedMidpointRadii, colors) }) -const sketchPointStyle = new Style({ - image: new CircleStyle({ - radius: 5, - fill: new Fill({ color: COLOR.primary }) + const editFeatureStyle = new Style({ + stroke: new Stroke({ color: colors.primary, width: 2 }), + fill: new Fill({ color: colors.fill }) }) -}) -// --- Style functions --- + const sketchLineStyle = new Style({ + stroke: new Stroke({ color: colors.primary, width: 2 }), + fill: new Fill({ color: colors.sketchFill }) + }) -export const createSketchStyle = () => (feature) => { - return feature.getGeometry().getType() === 'Point' - ? [sketchPointStyle] - : [sketchLineStyle] -} + const sketchPointStyle = new Style({ + image: new CircleStyle({ + radius: 5, + fill: new Fill({ color: colors.primary }) + }) + }) + + 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.stroke + const fill = (id && p[`fill${capitalize(id)}`]) || p.fill || colors.fill + const strokeWidth = p.strokeWidth || colors.strokeWidth + return [new Style({ + stroke: new Stroke({ color: stroke, width: strokeWidth }), + fill: new Fill({ color: fill }) + })] + } -export const createFeatureStyle = () => (feature) => { - const p = feature.getProperties() - return [new Style({ - stroke: new Stroke({ color: p.stroke || COLOR.primary, width: p.strokeWidth || 2 }), - fill: new Fill({ color: p.fill || COLOR.featureFill }) - })] + return { + vertexStyle, + selectedVertexStyle, + midpointStyle, + selectedMidpointStyle, + editFeatureStyle, + createSketchStyle, + createFeatureStyle + } } diff --git a/plugins/beta/draw-ol/src/defaults.js b/plugins/beta/draw-ol/src/defaults.js new file mode 100644 index 00000000..85255153 --- /dev/null +++ b/plugins/beta/draw-ol/src/defaults.js @@ -0,0 +1,9 @@ +export const DEFAULTS = { + primary: { light: '#1a65a6', dark: '#ffffff' }, + halo: { light: '#000000', dark: '#ffffff' }, + background: { light: '#ffffff', dark: 'rgba(11,12,12,1)' }, + stroke: '#1a65a6', + strokeWidth: 2, + fill: 'rgba(26,101,166,0.1)', + sketchFill: { light: 'rgba(26,101,166,0.08)', dark: 'rgba(74,158,224,0.08)' } +} diff --git a/plugins/beta/draw-ol/src/draw/DrawMode.js b/plugins/beta/draw-ol/src/draw/DrawMode.js index ff23bff9..75217b16 100644 --- a/plugins/beta/draw-ol/src/draw/DrawMode.js +++ b/plugins/beta/draw-ol/src/draw/DrawMode.js @@ -1,136 +1,104 @@ import Draw from 'ol/interaction/Draw.js' -import { createSketchStyle } from '../core/styles.js' import { createDrawInput } from './drawInput.js' -/** - * Draw mode — handles draw_polygon and draw_line. - * - * OL's Draw interaction handles all pointer/mouse behaviour natively. - * drawInput.js handles touch/keyboard/button input. - * - * @returns {{ done, cancel, undo, destroy }} - */ -export const createDrawMode = ({ map, manager, options }) => { - const { - geometryType, // 'Polygon' | 'LineString' - featureId, - properties = {}, - container, - interfaceType, - addVertexButtonId, - mapProvider, - crossHair - } = options - - const drawInteraction = new Draw({ - type: geometryType, - style: createSketchStyle(), - stopClick: true, - // minPoints defaults: 3 for Polygon, 2 for LineString — OL handles this - // snapTolerance: how close to first point to auto-close polygon - snapTolerance: 12 - }) - map.addInteraction(drawInteraction) +const DEBOUNCE_MS = 5 +const MIN_POLYGON_VERTICES = 3 +const MIN_LINE_VERTICES = 2 + +const countSketchVertices = (geometryType, rawCoords) => { + if (geometryType === 'Polygon' && rawCoords.length > 0) { + return Math.max(0, rawCoords[0].length - 2) + } else if (geometryType === 'LineString') { + return Math.max(0, rawCoords.length - 1) + } else { + return 0 + } +} - // Track vertex count for the Done button enabled state +const wireDrawEvents = ({ drawInteraction, geometryType, featureId, properties, manager }) => { let sketchFeature = null let pendingVertexUpdate = null - const updateVertexCount = () => { - if (!sketchFeature) return - const geom = sketchFeature.getGeometry() - const rawCoords = geom.getCoordinates() - - let numVertecies = 0 - - if (geometryType === 'Polygon' && rawCoords.length > 0) { - // For Polygon, OL stores rings as [[x1,y1], [x2,y2], ..., [x1,y1], rubber-band] - // Subtract 2: 1 for closing vertex + 1 for rubber-band - const exteriorRing = rawCoords[0] - numVertecies = Math.max(0, exteriorRing.length - 2) - } else if (geometryType === 'LineString') { - // For LineString, subtract 1 for rubber-band coordinate - numVertecies = Math.max(0, rawCoords.length - 1) + const clearPending = () => { + if (pendingVertexUpdate) { + clearTimeout(pendingVertexUpdate) + pendingVertexUpdate = null } - - manager.emit('vertexchange', { numVertecies }) } - const onGeometryChange = () => { - // Debounce geometry changes to avoid intermediate states - if (pendingVertexUpdate) { - clearTimeout(pendingVertexUpdate) + const updateVertexCount = () => { + if (!sketchFeature) { + return } - pendingVertexUpdate = setTimeout(() => { - updateVertexCount() - pendingVertexUpdate = null - }, 5) + const rawCoords = sketchFeature.getGeometry().getCoordinates() + manager.emit('vertexchange', { numVertecies: countSketchVertices(geometryType, rawCoords) }) } drawInteraction.on('drawstart', (e) => { sketchFeature = e.feature - sketchFeature.getGeometry().on('change', onGeometryChange) + sketchFeature.getGeometry().on('change', () => { + clearPending() + pendingVertexUpdate = setTimeout(() => { updateVertexCount(); pendingVertexUpdate = null }, DEBOUNCE_MS) + }) }) drawInteraction.on('drawend', (e) => { - if (pendingVertexUpdate) { - clearTimeout(pendingVertexUpdate) - pendingVertexUpdate = null - } + clearPending() const olFeature = e.feature olFeature.setId(String(featureId)) olFeature.setProperties(properties) manager.store.source.addFeature(olFeature) - const geojson = manager.store.toGeoJSON(olFeature) - manager.emit('create', geojson) - // Mode switches to disabled in events.js after receiving 'create' + manager.emit('create', manager.store.toGeoJSON(olFeature)) }) drawInteraction.on('drawabort', () => { - if (pendingVertexUpdate) { - clearTimeout(pendingVertexUpdate) - pendingVertexUpdate = null - } + clearPending() manager.emit('cancel') }) + return { + getSketchFeature: () => sketchFeature, + updateVertexCount, + clear () { sketchFeature = null } + } +} + +/** + * Draw mode — handles draw_polygon and draw_line. + * + * OL's Draw interaction handles all pointer/mouse behaviour natively. + * drawInput.js handles touch/keyboard/button input. + * + * @returns {{ done, cancel, undo, destroy }} + */ +export const createDrawMode = ({ map, manager, options }) => { + const { geometryType, featureId, properties = {}, container, interfaceType, addVertexButtonId, mapProvider, crossHair } = options + + const drawInteraction = new Draw({ + type: geometryType, + style: manager.styles.createSketchStyle(), + stopClick: true, + snapTolerance: 12 + }) + map.addInteraction(drawInteraction) + + const handlers = wireDrawEvents({ drawInteraction, geometryType, featureId, properties, manager }) const input = createDrawInput({ drawInteraction, manager, options: { container, interfaceType, addVertexButtonId, mapProvider, crossHair } }) return { done () { - // Validate minimum points before finishing - if (sketchFeature) { - const geom = sketchFeature.getGeometry() - const rawCoords = geom.getCoordinates() - let numVertecies = 0 - - if (geometryType === 'Polygon' && rawCoords.length > 0) { - const exteriorRing = rawCoords[0] - numVertecies = Math.max(0, exteriorRing.length - 2) - } else if (geometryType === 'LineString') { - numVertecies = Math.max(0, rawCoords.length - 1) + const sketch = handlers.getSketchFeature() + if (sketch) { + const numVertecies = countSketchVertices(geometryType, sketch.getGeometry().getCoordinates()) + const minVertices = geometryType === 'Polygon' ? MIN_POLYGON_VERTICES : MIN_LINE_VERTICES + if (numVertecies < minVertices) { + return } - - // Need at least 3 vertices for Polygon, 2 for LineString - const minVertices = geometryType === 'Polygon' ? 3 : 2 - if (numVertecies < minVertices) return } drawInteraction.finishDrawing() }, - - cancel () { - drawInteraction.abortDrawing() - }, - - undo () { - drawInteraction.removeLastPoint() - updateVertexCount() - }, - - destroy () { - input.destroy() - map.removeInteraction(drawInteraction) - sketchFeature = null - } + cancel () { drawInteraction.abortDrawing() }, + undo () { drawInteraction.removeLastPoint(); handlers.updateVertexCount() }, + destroy () { input.destroy(); map.removeInteraction(drawInteraction); handlers.clear() } } } diff --git a/plugins/beta/draw-ol/src/edit/EditMode.js b/plugins/beta/draw-ol/src/edit/EditMode.js index 8859dbda..f6f07709 100644 --- a/plugins/beta/draw-ol/src/edit/EditMode.js +++ b/plugins/beta/draw-ol/src/edit/EditMode.js @@ -12,7 +12,6 @@ import { findNearest } from './vertexHitTest.js' import { deleteVertex, insertAtMidpoint } from './vertexOps.js' import { applyUndo } from './undoOps.js' import { getCoords, getMidpoints } from '../utils/geometryHelpers.js' -import { editFeatureStyle, selectedVertexStyle, selectedMidpointStyle } from '../core/styles.js' /** * Edit vertex mode — handles edit_vertex. @@ -28,10 +27,12 @@ export const createEditMode = ({ map, manager, options }) => { const { store, undoStack } = manager const olFeature = store.getOL(featureId) - if (!olFeature) return null + if (!olFeature) { + return null + } const originalFeatureStyle = olFeature.getStyle() - olFeature.setStyle(editFeatureStyle) + olFeature.setStyle(manager.styles.editFeatureStyle) // Mutable state shared across sub-handlers const state = { @@ -54,7 +55,9 @@ export const createEditMode = ({ map, manager, options }) => { midpointLayer.setSelected( state.selectedVertexType === 'midpoint' ? state.selectedVertexIndex - state.vertecies.length : -1 ) - if (state.selectedVertexIndex < 0) { onDeselect?.() } + if (state.selectedVertexIndex < 0) { + onDeselect?.() + } updateActiveLayer() manager.emit('vertexselection', { index: state.selectedVertexType === 'vertex' ? state.selectedVertexIndex : -1, @@ -98,6 +101,13 @@ export const createEditMode = ({ map, manager, options }) => { // --- 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.vertecies, state.midpoints, { x: olPixel[0], y: olPixel[1] }) !== null + } const modifyInteraction = new Modify({ features: collection, style: () => [], // vertex circles rendered by vertexLayer instead @@ -105,11 +115,7 @@ export const createEditMode = ({ map, manager, options }) => { // 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: (mapBrowserEvent) => { - if (state.interfaceType === 'touch') return false - const olPixel = map.getEventPixel(mapBrowserEvent.originalEvent) - return findNearest(map, state.vertecies, state.midpoints, { x: olPixel[0], y: olPixel[1] }) !== null - } + condition: modifyCondition }) map.addInteraction(modifyInteraction) @@ -117,15 +123,21 @@ export const createEditMode = ({ map, manager, options }) => { let modifyStartCoords = null modifyInteraction.on('modifystart', () => { - if (state.interfaceType === 'touch') return + if (state.interfaceType === 'touch') { + return + } modifyStartCoords = state.vertecies.map(c => [...c]) }) modifyInteraction.on('modifyend', () => { - if (state.interfaceType === 'touch') return + if (state.interfaceType === 'touch') { + return + } const prevCoords = modifyStartCoords syncGeom() - if (!prevCoords) return + if (!prevCoords) { + return + } const newCoords = state.vertecies if (newCoords.length > prevCoords.length) { @@ -140,13 +152,15 @@ export const createEditMode = ({ map, manager, options }) => { 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) - const vertexLayer = createVertexLayer(map) + 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() @@ -156,18 +170,22 @@ export const createEditMode = ({ map, manager, options }) => { const updateActiveLayer = () => { activeSource.clear() const { selectedVertexIndex, selectedVertexType, vertecies, midpoints } = state - if (selectedVertexIndex < 0) { return } + if (selectedVertexIndex < 0) { + return + } let coord, style if (selectedVertexType === 'vertex') { coord = vertecies[selectedVertexIndex] - style = selectedVertexStyle + style = manager.styles.selectedVertexStyle } else if (selectedVertexType === 'midpoint') { coord = midpoints[selectedVertexIndex - vertecies.length] - style = selectedMidpointStyle + style = manager.styles.selectedMidpointStyle } else { return } - if (!coord) { return } + if (!coord) { + return + } const f = new Feature({ geometry: new Point(coord) }) f.setStyle(style) activeSource.addFeature(f) @@ -175,6 +193,16 @@ export const createEditMode = ({ map, manager, options }) => { 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') { @@ -194,7 +222,9 @@ export const createEditMode = ({ map, manager, options }) => { // click fires after OL Modify finishes, so state.vertecies reflects any insertions/moves const onContainerClick = (e) => { - if (state.interfaceType === 'touch') { return } + if (state.interfaceType === 'touch') { + return + } const olPixel = map.getEventPixel(e) const pixel = { x: olPixel[0], y: olPixel[1] } const hit = findNearest(map, state.vertecies, state.midpoints, pixel) @@ -209,8 +239,12 @@ export const createEditMode = ({ map, manager, options }) => { // 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 === 'pointer') return + if (e.pointerType !== 'mouse') { + return + } + if (state.interfaceType === 'pointer') { + return + } state.interfaceType = 'pointer' touchHandler.hide() } @@ -226,14 +260,18 @@ export const createEditMode = ({ map, manager, options }) => { doDeleteVertex() } } - window.addEventListener('click', onButtonClick) + globalThis.addEventListener('click', onButtonClick) // --- Operations --- const doDeleteVertex = () => { - if (state.selectedVertexType !== 'vertex' || state.selectedVertexIndex < 0) return + if (state.selectedVertexType !== 'vertex' || state.selectedVertexIndex < 0) { + return + } const result = deleteVertex(olFeature, state.selectedVertexIndex) - if (!result) return + if (!result) { + return + } undoStack.push({ type: 'delete_vertex', ...result }) syncGeom() setState({ selectedVertexIndex: -1, selectedVertexType: null }) @@ -241,7 +279,9 @@ export const createEditMode = ({ map, manager, options }) => { const doUndo = () => { const op = undoStack.pop() - if (!op) return + if (!op) { + return + } const newIndex = applyUndo(olFeature, op) syncGeom() setState({ @@ -257,6 +297,7 @@ export const createEditMode = ({ map, manager, options }) => { container, getState, setState, + colors: manager.colors, onVertexMoved ({ vertexIndex, previousCoord }) { undoStack.push({ type: 'move_vertex', vertexIndex, previousCoord }) syncGeom() @@ -274,7 +315,9 @@ export const createEditMode = ({ map, manager, options }) => { } if (hit.type === 'midpoint') { const result = insertAtMidpoint(olFeature, state.midpoints, hit.index, state.vertecies.length) - if (!result) { return } + if (!result) { + return + } undoStack.push({ type: 'insert_vertex', vertexIndex: result.insertedIndex }) syncGeom() setState({ selectedVertexIndex: result.insertedIndex, selectedVertexType: 'vertex' }) @@ -284,14 +327,18 @@ export const createEditMode = ({ map, manager, options }) => { }) onDeselect = () => touchHandler.hide() onUpdate = () => { - if (state.interfaceType === 'touch') { touchHandler.updateTargetPosition() } + 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 + if (state.interfaceType !== 'touch' || state.selectedVertexIndex < 0) { + return + } map.once('postrender', () => touchHandler.updateTargetPosition()) } map.on('change:size', onMapSizeChange) @@ -312,7 +359,9 @@ export const createEditMode = ({ map, manager, options }) => { onDeleted: doDeleteVertex, onUndo: doUndo, onKeyboardActive () { - if (state.interfaceType === 'keyboard') return + if (state.interfaceType === 'keyboard') { + return + } state.interfaceType = 'keyboard' touchHandler.hide() container.focus({ preventScroll: true }) @@ -321,7 +370,9 @@ export const createEditMode = ({ map, manager, options }) => { return { setInterfaceType (type) { - if (type === state.interfaceType) return + if (type === state.interfaceType) { + return + } state.interfaceType = type if (type === 'touch') { touchHandler.updateTargetPosition() @@ -345,11 +396,12 @@ export const createEditMode = ({ map, manager, options }) => { 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) - window.removeEventListener('click', onButtonClick) + globalThis.removeEventListener('click', onButtonClick) map.un('change:size', onMapSizeChange) map.removeInteraction(modifyInteraction) activeSource.clear() diff --git a/plugins/beta/draw-ol/src/edit/midpointLayer.js b/plugins/beta/draw-ol/src/edit/midpointLayer.js index 4ea0bfe7..34f2358a 100644 --- a/plugins/beta/draw-ol/src/edit/midpointLayer.js +++ b/plugins/beta/draw-ol/src/edit/midpointLayer.js @@ -3,7 +3,6 @@ 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' -import { midpointStyle } from '../core/styles.js' /** * Manages a dedicated overlay layer for midpoint handles in edit mode. @@ -11,12 +10,13 @@ import { midpointStyle } from '../core/styles.js' * 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) => { +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 : [midpointStyle], + style: (feature) => feature.get('midpointIndex') === selectedIndex ? null : [currentStyle], zIndex: 101 }) map.addLayer(layer) @@ -38,6 +38,11 @@ export const createMidpointLayer = (map) => { source.changed() }, + updateStyle (newMidpointStyle) { + currentStyle = newMidpointStyle + source.changed() + }, + getCoords () { return source.getFeatures() .sort((a, b) => a.get('midpointIndex') - b.get('midpointIndex')) diff --git a/plugins/beta/draw-ol/src/edit/touchHandler.js b/plugins/beta/draw-ol/src/edit/touchHandler.js index bf079eb1..c66e4466 100644 --- a/plugins/beta/draw-ol/src/edit/touchHandler.js +++ b/plugins/beta/draw-ol/src/edit/touchHandler.js @@ -1,5 +1,5 @@ import { coordToPixel, pixelToCoord } from '../utils/olCoords.js' -import { createTouchTarget, showTouchTarget, hideTouchTarget, isOnTouchTarget } from '../utils/touchTarget.js' +import { createTouchTarget, applyTouchTargetColors, showTouchTarget, hideTouchTarget, isOnTouchTarget } from '../utils/touchTarget.js' import { moveVertex } from './vertexOps.js' import { findNearest } from './vertexHitTest.js' @@ -7,84 +7,25 @@ const TAP_MOVE_THRESHOLD = 10 const TAP_TIME_THRESHOLD = 400 const TOUCH_TOLERANCE = 24 -/** - * 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 }} options - * @returns {{ updateTargetPosition, hide, destroy }} - */ -export const createTouchHandler = ({ map, container, getState, setState, onVertexMoved, onTap }) => { - // SVG is a child of container (outside ol-viewport) so touches on it never - // reach OL's DragPan — no capture or stopPropagation needed. - const targetEl = createTouchTarget(container) - +const wireTouchEvents = ({ container, map, targetEl, olToCSS, cssToOl, getState, setState, onVertexMoved, onTap }) => { let dragStartCoord = null let dragStartIndex = null let vertexTouchDelta = null let targetTouchDelta = null let tapStart = null - // 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). - // cssTx converts between them: containerCSS = olPx × scale + offset. - let cssTx = { scale: 1, ox: 0, oy: 0 } - - 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 - cssTx = { - scale: vpScale / cScale, - ox: (vpRect.left - cRect.left) / cScale, - oy: (vpRect.top - cRect.top) / cScale - } - } - - 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 onSizeChange = () => { syncCssTx(); map.once('postrender', updateTargetPosition) } - map.on('change:size', onSizeChange) - syncCssTx() - - const updateTargetPosition = () => { - const { selectedVertexIndex, vertecies, interfaceType } = getState() - if (selectedVertexIndex < 0 || !vertecies[selectedVertexIndex] || interfaceType !== 'touch') { - hideTouchTarget(targetEl) - return - } - showTouchTarget(targetEl, olToCSS(coordToPixel(map, vertecies[selectedVertexIndex]))) - } - - // 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 && dragStartIndex == null && interfaceType === 'touch') { updateTargetPosition() } - } - map.on('postrender', onPostrender) - 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, vertecies } = getState() const vertex = vertecies[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 } @@ -95,12 +36,10 @@ export const createTouchHandler = ({ map, container, getState, setState, onVerte 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 newCoord = pixelToCoord(map, { x: tOl[0] - vertexTouchDelta.x, y: tOl[1] - vertexTouchDelta.y }) const { olFeature, vertecies } = getState() if (!olFeature) { return } - moveVertex(olFeature, dragStartIndex, newCoord) setState({ vertecies: vertecies.map((c, i) => i === dragStartIndex ? newCoord : c) }) showTouchTarget(targetEl, olToCSS({ x: tOl[0] - targetTouchDelta.x, y: tOl[1] - targetTouchDelta.y })) @@ -108,35 +47,26 @@ export const createTouchHandler = ({ map, container, getState, setState, onVerte const onTouchend = (e) => { const wasDragging = dragStartIndex != null - if (!wasDragging) { if (tapStart && !tapStart.onTarget && e.changedTouches.length > 0) { const t = e.changedTouches[0] - const dx = t.clientX - tapStart.x - const dy = t.clientY - tapStart.y const dt = Date.now() - tapStart.time - if (Math.hypot(dx, dy) < TAP_MOVE_THRESHOLD && dt < TAP_TIME_THRESHOLD) { + 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() - const hit = findNearest(map, tapState.vertecies, tapState.midpoints, { x: tOl[0], y: tOl[1] }, TOUCH_TOLERANCE) - onTap?.(hit) + onTap?.(findNearest(map, tapState.vertecies, tapState.midpoints, { x: tOl[0], y: tOl[1] }, TOUCH_TOLERANCE)) e.preventDefault() } } tapStart = null return } - tapStart = null const { vertecies } = getState() - const finalCoord = vertecies[dragStartIndex] - if (finalCoord && dragStartCoord) { + if (vertecies[dragStartIndex] && dragStartCoord) { onVertexMoved({ vertexIndex: dragStartIndex, previousCoord: dragStartCoord }) } - dragStartCoord = null - dragStartIndex = null - vertexTouchDelta = null - targetTouchDelta = null + dragStartCoord = null; dragStartIndex = null; vertexTouchDelta = null; targetTouchDelta = null e.preventDefault() } @@ -144,15 +74,81 @@ export const createTouchHandler = ({ map, container, getState, setState, onVerte 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 }) => { + 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 }) + + const updateTargetPosition = () => { + const { selectedVertexIndex, vertecies, interfaceType } = getState() + if (selectedVertexIndex < 0 || !vertecies[selectedVertexIndex] || interfaceType !== 'touch') { + hideTouchTarget(targetEl) + return + } + showTouchTarget(targetEl, olToCSS(coordToPixel(map, vertecies[selectedVertexIndex]))) + } + + // 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) - container.removeEventListener('touchstart', onTouchstart) - container.removeEventListener('touchmove', onTouchmove) - container.removeEventListener('touchend', onTouchend) + touchEvents.destroy() hideTouchTarget(targetEl) } } diff --git a/plugins/beta/draw-ol/src/edit/vertexLayer.js b/plugins/beta/draw-ol/src/edit/vertexLayer.js index 5f444794..057acbb0 100644 --- a/plugins/beta/draw-ol/src/edit/vertexLayer.js +++ b/plugins/beta/draw-ol/src/edit/vertexLayer.js @@ -3,7 +3,6 @@ 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' -import { vertexStyle } from '../core/styles.js' /** * Always-visible vertex handle layer for edit mode. @@ -11,12 +10,13 @@ import { vertexStyle } from '../core/styles.js' * 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) => { +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 : [vertexStyle], + style: (feature) => feature.get('vertexIndex') === selectedIndex ? null : [currentStyle], zIndex: 102 }) map.addLayer(layer) @@ -36,6 +36,11 @@ export const createVertexLayer = (map) => { source.changed() }, + updateStyle (newVertexStyle) { + currentStyle = newVertexStyle + source.changed() + }, + remove () { source.clear() map.removeLayer(layer) diff --git a/plugins/beta/draw-ol/src/olDraw.js b/plugins/beta/draw-ol/src/olDraw.js index 03b22716..73c966f3 100644 --- a/plugins/beta/draw-ol/src/olDraw.js +++ b/plugins/beta/draw-ol/src/olDraw.js @@ -2,25 +2,35 @@ import { OLDrawManager } from './core/OLDrawManager.js' /** * Creates the OLDrawManager, attaches it to mapProvider, and wires - * any app-level events (e.g. MAP_SET_SIZE for scale-aware touch targets). + * 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 }) => { +export const createOLDraw = ({ mapProvider, events, eventBus, pluginConfig = {}, mapStyle = null }) => { const { map } = mapProvider - const manager = new OLDrawManager(map) + const manager = new OLDrawManager(map, pluginConfig) + + if (mapStyle) { + manager.setMapStyle(mapStyle) + } mapProvider.draw = manager const handleSetMapSize = (size) => { - // Scale factor informs touch target pixel offsets 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-ol/src/utils/flattenStyleProperties.js b/plugins/beta/draw-ol/src/utils/flattenStyleProperties.js new file mode 100644 index 00000000..cfd3df61 --- /dev/null +++ b/plugins/beta/draw-ol/src/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-ol/src/utils/resolveColors.js b/plugins/beta/draw-ol/src/utils/resolveColors.js new file mode 100644 index 00000000..5a41117a --- /dev/null +++ b/plugins/beta/draw-ol/src/utils/resolveColors.js @@ -0,0 +1,36 @@ +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 { + primary: r('primary'), + halo: r('halo'), + background: r('background'), + stroke: r('stroke'), + strokeWidth: pluginConfig.strokeWidth ?? DEFAULTS.strokeWidth, + fill: r('fill'), + sketchFill: r('sketchFill'), + mapStyleId: styleId + } +} diff --git a/plugins/beta/draw-ol/src/utils/touchTarget.js b/plugins/beta/draw-ol/src/utils/touchTarget.js index a9299eee..ff2cb95f 100644 --- a/plugins/beta/draw-ol/src/utils/touchTarget.js +++ b/plugins/beta/draw-ol/src/utils/touchTarget.js @@ -2,14 +2,19 @@ * 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 = ` ` @@ -22,20 +27,35 @@ export const createTouchTarget = (container) => { return el } +export const applyTouchTargetColors = (el, colors) => { + if (!el) { + return + } + el.style.setProperty('--draw-halo', colors.halo) + el.style.setProperty('--draw-bg', colors.background) + el.style.setProperty('--draw-primary', colors.primary) +} + export const showTouchTarget = (el, pixel) => { - if (!pixel || !el) return + 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' + 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 + if (!el) { + return false + } const parent = el.parentNode - return (parent instanceof window.SVGElement) || (parent?.ownerSVGElement != null) + return (parent instanceof globalThis.SVGElement) || (parent?.ownerSVGElement != null) } From 4d3698ef3a1963e6e9363e2e7c5be8640bd8dc34 Mon Sep 17 00:00:00 2001 From: Dan Leech Date: Mon, 18 May 2026 14:55:13 +0100 Subject: [PATCH 12/26] Undo delete vertex bug fix --- plugins/beta/draw-ol/src/edit/EditMode.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/beta/draw-ol/src/edit/EditMode.js b/plugins/beta/draw-ol/src/edit/EditMode.js index f6f07709..7ff31200 100644 --- a/plugins/beta/draw-ol/src/edit/EditMode.js +++ b/plugins/beta/draw-ol/src/edit/EditMode.js @@ -272,7 +272,7 @@ export const createEditMode = ({ map, manager, options }) => { if (!result) { return } - undoStack.push({ type: 'delete_vertex', ...result }) + undoStack.push({ type: 'delete_vertex', vertexIndex: result.deletedIndex, deletedCoord: result.deletedCoord }) syncGeom() setState({ selectedVertexIndex: -1, selectedVertexType: null }) } From 6b17b5a5394e400bfb80e7af3f037c5ab421a90d Mon Sep 17 00:00:00 2001 From: Dan Leech Date: Mon, 18 May 2026 16:56:15 +0100 Subject: [PATCH 13/26] Snap basics added --- demo/js/draw-ol.js | 2 +- plugins/beta/draw-ol/src/DrawInit.jsx | 1 + .../beta/draw-ol/src/core/OLDrawManager.js | 13 +- plugins/beta/draw-ol/src/draw/DrawMode.js | 130 +++++---- plugins/beta/draw-ol/src/draw/drawInput.js | 259 ++++-------------- plugins/beta/draw-ol/src/edit/EditMode.js | 4 +- .../beta/draw-ol/src/edit/keyboardHandler.js | 26 +- plugins/beta/draw-ol/src/edit/touchHandler.js | 11 +- plugins/beta/draw-ol/src/events.js | 12 +- plugins/beta/draw-ol/src/manifest.js | 90 +++--- plugins/beta/draw-ol/src/reducer.js | 23 +- plugins/beta/draw-ol/src/snap/snapEngine.js | 115 ++++++++ plugins/beta/draw-ol/src/snap/snapGeometry.js | 199 ++++++++++++++ .../beta/draw-ol/src/snap/snapIndicator.js | 75 +++++ .../beta/draw-ol/src/snap/snapInteraction.js | 55 ++++ plugins/beta/draw-ol/src/snap/snapManager.js | 78 ++++++ .../beta/openlayers/src/utils/tileLayers.js | 6 +- 17 files changed, 769 insertions(+), 330 deletions(-) create mode 100644 plugins/beta/draw-ol/src/snap/snapEngine.js create mode 100644 plugins/beta/draw-ol/src/snap/snapGeometry.js create mode 100644 plugins/beta/draw-ol/src/snap/snapIndicator.js create mode 100644 plugins/beta/draw-ol/src/snap/snapInteraction.js create mode 100644 plugins/beta/draw-ol/src/snap/snapManager.js diff --git a/demo/js/draw-ol.js b/demo/js/draw-ol.js index 6a93ecda..9ba5530a 100644 --- a/demo/js/draw-ol.js +++ b/demo/js/draw-ol.js @@ -25,7 +25,7 @@ const interactiveMap = new InteractiveMap('map', { }), mapLabel: 'Map showing Carlisle (OpenLayers)', minZoom: 6, - maxZoom: 20, + maxZoom: 22, autoColorScheme: true, center: [337584, 504538], zoom: 14, diff --git a/plugins/beta/draw-ol/src/DrawInit.jsx b/plugins/beta/draw-ol/src/DrawInit.jsx index d7d37b1d..5a4e2ec1 100644 --- a/plugins/beta/draw-ol/src/DrawInit.jsx +++ b/plugins/beta/draw-ol/src/DrawInit.jsx @@ -19,6 +19,7 @@ export const DrawInit = ({ appState, appConfig, mapState, pluginConfig, pluginSt const { remove } = createOLDraw({ mapProvider, events: EVENTS, eventBus, pluginConfig, mapStyle: mapState.mapStyle }) pluginState.dispatch({ type: 'SET_MODE', payload: null }) + pluginState.dispatch({ type: 'SET_HAS_SNAP_LAYERS', payload: pluginConfig.snapLayers?.length > 0 }) eventBus.emit('draw:ready') return () => remove() diff --git a/plugins/beta/draw-ol/src/core/OLDrawManager.js b/plugins/beta/draw-ol/src/core/OLDrawManager.js index 47a4f881..d346e5f7 100644 --- a/plugins/beta/draw-ol/src/core/OLDrawManager.js +++ b/plugins/beta/draw-ol/src/core/OLDrawManager.js @@ -3,6 +3,7 @@ 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' /** * Mode machine for the OL draw plugin. @@ -27,6 +28,7 @@ export class OLDrawManager { this.colors = resolveColors(null, pluginConfig) this.styles = createStyles(this.colors) + this.snap = createSnapManager(map, pluginConfig.snapLayers ?? null) this._layer = new VectorLayer({ source: this.store.source, @@ -70,15 +72,20 @@ export class OLDrawManager { this._modeInstance = null this._mode = modeName + 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 }) + 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 }) + 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 () { @@ -120,6 +127,8 @@ export class OLDrawManager { 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-ol/src/draw/DrawMode.js b/plugins/beta/draw-ol/src/draw/DrawMode.js index 75217b16..3410b871 100644 --- a/plugins/beta/draw-ol/src/draw/DrawMode.js +++ b/plugins/beta/draw-ol/src/draw/DrawMode.js @@ -1,104 +1,102 @@ import Draw from 'ol/interaction/Draw.js' import { createDrawInput } from './drawInput.js' +import { getCoords } from '../utils/geometryHelpers.js' -const DEBOUNCE_MS = 5 -const MIN_POLYGON_VERTICES = 3 -const MIN_LINE_VERTICES = 2 +/** + * Draw mode — handles draw_polygon and draw_line. + * + * OL's Draw interaction handles all pointer/mouse behaviour natively. + * drawInput.js handles touch/keyboard/button input. + * + * @returns {{ done, cancel, undo, destroy }} + */ +export const createDrawMode = ({ map, manager, options }) => { + const { + geometryType, // 'Polygon' | 'LineString' + featureId, + properties = {}, + container, + interfaceType, + addVertexButtonId, + mapProvider, + snap + } = options -const countSketchVertices = (geometryType, rawCoords) => { - if (geometryType === 'Polygon' && rawCoords.length > 0) { - return Math.max(0, rawCoords[0].length - 2) - } else if (geometryType === 'LineString') { - return Math.max(0, rawCoords.length - 1) - } else { - return 0 - } -} + const drawInteraction = new Draw({ + type: geometryType, + style: manager.styles.createSketchStyle(), + stopClick: true, + // minPoints defaults: 3 for Polygon, 2 for LineString — OL handles this + // snapTolerance: how close to first point to auto-close polygon + snapTolerance: 12 + }) + map.addInteraction(drawInteraction) -const wireDrawEvents = ({ drawInteraction, geometryType, featureId, properties, manager }) => { + // Track vertex count for the Done button enabled state let sketchFeature = null - let pendingVertexUpdate = null - - const clearPending = () => { - if (pendingVertexUpdate) { - clearTimeout(pendingVertexUpdate) - pendingVertexUpdate = null - } - } const updateVertexCount = () => { if (!sketchFeature) { return } - const rawCoords = sketchFeature.getGeometry().getCoordinates() - manager.emit('vertexchange', { numVertecies: countSketchVertices(geometryType, rawCoords) }) + const geom = sketchFeature.getGeometry() + const coords = getCoords({ type: geometryType, coordinates: geom.getCoordinates() }) + // OL always keeps a trailing rubber-band coordinate; subtract 1 + const numVertecies = Math.max(0, coords.length - 1) + manager.emit('vertexchange', { numVertecies }) } drawInteraction.on('drawstart', (e) => { sketchFeature = e.feature - sketchFeature.getGeometry().on('change', () => { - clearPending() - pendingVertexUpdate = setTimeout(() => { updateVertexCount(); pendingVertexUpdate = null }, DEBOUNCE_MS) - }) + sketchFeature.getGeometry().on('change', updateVertexCount) }) drawInteraction.on('drawend', (e) => { - clearPending() const olFeature = e.feature olFeature.setId(String(featureId)) olFeature.setProperties(properties) manager.store.source.addFeature(olFeature) - manager.emit('create', manager.store.toGeoJSON(olFeature)) + const geojson = manager.store.toGeoJSON(olFeature) + manager.emit('create', geojson) + // Mode switches to disabled in events.js after receiving 'create' }) drawInteraction.on('drawabort', () => { - clearPending() manager.emit('cancel') }) - return { - getSketchFeature: () => sketchFeature, - updateVertexCount, - clear () { sketchFeature = null } - } -} - -/** - * Draw mode — handles draw_polygon and draw_line. - * - * OL's Draw interaction handles all pointer/mouse behaviour natively. - * drawInput.js handles touch/keyboard/button input. - * - * @returns {{ done, cancel, undo, destroy }} - */ -export const createDrawMode = ({ map, manager, options }) => { - const { geometryType, featureId, properties = {}, container, interfaceType, addVertexButtonId, mapProvider, crossHair } = options - - const drawInteraction = new Draw({ - type: geometryType, - style: manager.styles.createSketchStyle(), - stopClick: true, - snapTolerance: 12 - }) - map.addInteraction(drawInteraction) - - const handlers = wireDrawEvents({ drawInteraction, geometryType, featureId, properties, manager }) - const input = createDrawInput({ drawInteraction, manager, options: { container, interfaceType, addVertexButtonId, mapProvider, crossHair } }) + const input = createDrawInput({ drawInteraction, manager, options: { container, interfaceType, addVertexButtonId, mapProvider, snap, onUndo: () => { + drawInteraction.removeLastPoint() + updateVertexCount() + } } }) return { done () { - const sketch = handlers.getSketchFeature() - if (sketch) { - const numVertecies = countSketchVertices(geometryType, sketch.getGeometry().getCoordinates()) - const minVertices = geometryType === 'Polygon' ? MIN_POLYGON_VERTICES : MIN_LINE_VERTICES - if (numVertecies < minVertices) { + // Validate minimum points before finishing + if (sketchFeature) { + const geom = sketchFeature.getGeometry() + const coords = getCoords({ type: geometryType, coordinates: geom.getCoordinates() }) + const min = geometryType === 'Polygon' ? 4 : 3 // +1 for rubber band + if (coords.length < min) { return } } drawInteraction.finishDrawing() }, - cancel () { drawInteraction.abortDrawing() }, - undo () { drawInteraction.removeLastPoint(); handlers.updateVertexCount() }, - destroy () { input.destroy(); map.removeInteraction(drawInteraction); handlers.clear() } + + cancel () { + drawInteraction.abortDrawing() + }, + + undo () { + drawInteraction.removeLastPoint() + updateVertexCount() + }, + + destroy () { + input.destroy() + map.removeInteraction(drawInteraction) + sketchFeature = null + } } } diff --git a/plugins/beta/draw-ol/src/draw/drawInput.js b/plugins/beta/draw-ol/src/draw/drawInput.js index 2b7c521a..07b2d610 100644 --- a/plugins/beta/draw-ol/src/draw/drawInput.js +++ b/plugins/beta/draw-ol/src/draw/drawInput.js @@ -7,6 +7,7 @@ */ import { coordToPixel, pixelDist } from '../utils/olCoords.js' +import { getCoords } from '../utils/geometryHelpers.js' const SNAP_TOLERANCE = 12 // pixels const ARROW_KEYS = new Set(['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown']) @@ -15,67 +16,19 @@ const ARROW_KEYS = new Set(['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown']) * @param {object} params * @param {import('ol/interaction/Draw').default} params.drawInteraction * @param {import('../core/OLDrawManager').OLDrawManager} params.manager - * @param {object} params.options - { container, interfaceType, addVertexButtonId, mapProvider, crossHair } + * @param {object} params.options - { container, interfaceType, addVertexButtonId, mapProvider, snap } * @returns {{ destroy: () => void }} */ export const createDrawInput = ({ drawInteraction, manager, options }) => { - const { container, addVertexButtonId, mapProvider, crossHair } = options + const { container, addVertexButtonId, mapProvider, snap, onUndo } = options let interfaceType = options.interfaceType let map = null - let sketchFeature = null - let pendingVertexUpdate = null - let lastPlacedCoord = null - let sketchGeom = null - let lastStableVertexCount = 0 - - // Detects when OL places a vertex via mouse click (stable coord count increases). - // Always tracks lastStableVertexCount so switching input modes mid-draw doesn't - // cause a spurious "new vertex" detection. Only updates lastPlacedCoord for pointer mode. - const onSketchGeomChange = () => { - if (!sketchFeature) return - const geom = sketchFeature.getGeometry() - const rawCoords = geom.getCoordinates() - let stableCount = 0 - let newVertexCoord = null - if (geom.getType() === 'Polygon' && rawCoords.length > 0) { - stableCount = Math.max(0, rawCoords[0].length - 2) - if (stableCount > lastStableVertexCount) newVertexCoord = rawCoords[0][stableCount - 1] - } else if (geom.getType() === 'LineString') { - stableCount = Math.max(0, rawCoords.length - 1) - if (stableCount > lastStableVertexCount) newVertexCoord = rawCoords[stableCount - 1] - } - if (stableCount > lastStableVertexCount) { - lastStableVertexCount = stableCount - if (interfaceType === 'pointer' && newVertexCoord) lastPlacedCoord = newVertexCoord - } - } - // Track sketch feature from draw events - const onDrawStart = (e) => { - sketchFeature = e.feature - lastPlacedCoord = null - lastStableVertexCount = 0 - sketchGeom = e.feature.getGeometry() - sketchGeom.on('change', onSketchGeomChange) - } - - const onDrawEnd = () => { - if (sketchGeom) { - sketchGeom.un('change', onSketchGeomChange) - sketchGeom = null - } - sketchFeature = null - lastPlacedCoord = null - lastStableVertexCount = 0 - if (pendingVertexUpdate) { - clearTimeout(pendingVertexUpdate) - pendingVertexUpdate = null - } - } - - drawInteraction.on('drawstart', onDrawStart) - drawInteraction.on('drawend', onDrawEnd) - drawInteraction.on('drawabort', onDrawEnd) + // Track the current sketch feature via draw events (OL 10 has no public getSketchFeature()) + let sketchFeature = null + drawInteraction.on('drawstart', (e) => { sketchFeature = e.feature }) + drawInteraction.on('drawend', () => { sketchFeature = null }) + drawInteraction.on('drawabort', () => { sketchFeature = null }) // Get map reference — drawInteraction is already added to map before createDrawInput is called const getMap = () => { @@ -85,83 +38,67 @@ export const createDrawInput = ({ drawInteraction, manager, options }) => { return map } - const olMap = drawInteraction.getMap() + // Listen to view centre changes for keyboard/touch rubberbanding. + // pointermove alone won't fire when arrow keys pan the map. + const onCenterChange = () => { + updateSketchRubberbanding() + } - // OL's CanvasVectorLayerRenderer skips re-rendering when viewHints[ANIMATING] > 0 and - // updateWhileAnimating is false (the default). Without this, geometry updates in precompose - // are ignored during keyboard pan animation — the overlay uses a cached render. - const overlayLayer = typeof drawInteraction.getOverlay === 'function' ? drawInteraction.getOverlay() : null - if (overlayLayer) { - overlayLayer.updateWhileAnimating_ = true + const olMap = drawInteraction.getMap() + const olView = olMap?.getView() + if (olView) { + olView.on('change:center', onCenterChange) } - // --- Update sketch feature rubber band to current map centre --- - // Accepts an optional pre-computed coord; falls back to mapProvider.getCenter() otherwise. - const updateSketchRubberbanding = (centerCoord) => { + // --- Update sketch feature with current center (rubberbanding) --- + const updateSketchRubberbanding = () => { if (!sketchFeature) return const geom = sketchFeature.getGeometry() const coords = geom.getCoordinates() if (coords.length === 0) return - const center = centerCoord ?? mapProvider.getCenter() + const raw = mapProvider.getCenter() + const centerCoord = (interfaceType !== 'pointer' && snap) ? snap.apply(raw) : raw + // For LineString, update the last (rubber-band) coordinate if (geom.getType() === 'LineString') { const updated = [...coords] - updated[updated.length - 1] = center + updated[updated.length - 1] = centerCoord geom.setCoordinates(updated) - } else if (geom.getType() === 'Polygon') { + } + // For Polygon, update the last coordinate in the current ring + else if (geom.getType() === 'Polygon') { const updated = coords.map((ring, ringIdx) => { - if (ringIdx !== 0) return ring - const ringUpdated = [...ring] - ringUpdated[ringUpdated.length - 1] = center - return ringUpdated + if (ringIdx === 0) { // Only update first ring (exterior) + const ringUpdated = [...ring] + ringUpdated[ringUpdated.length - 1] = centerCoord + return ringUpdated + } + return ring }) geom.setCoordinates(updated) } - - } - - // OL's view.animate() calls applyTargetState_() each rAF → fires change:center with the - // interpolated center each frame. mapProvider.getCenter() returns the raw (non-padding- - // adjusted) center, which is what renders at the safezone/crosshair CSS position. - // Using frameState.viewState.center would give the padding-adjusted center, which renders - // at the container's 50%/50% — offset from the crosshair when OL view padding is set. - const onCenterChange = () => { - if (interfaceType === 'pointer') { return } - updateSketchRubberbanding() } - if (olMap) { - olMap.getView().on('change:center', onCenterChange) - } - - // --- Update vertex count display after appending coordinates --- - const updateDisplayedVertexCount = () => { - if (!sketchFeature) return - const geom = sketchFeature.getGeometry() - const rawCoords = geom.getCoordinates() + // --- Check if close enough to first vertex to close shape --- + const isCloseToFirstVertex = (map, currentCoord, sketchCoords, geometryType) => { + if (geometryType !== 'Polygon' || sketchCoords.length < 4) return false - let numVertecies = 0 + const firstCoord = sketchCoords[0] + const currentPixel = coordToPixel(map, currentCoord) + const firstPixel = coordToPixel(map, firstCoord) - if (geom.getType() === 'Polygon' && rawCoords.length > 0) { - // For Polygon, OL stores rings as [[x1,y1], [x2,y2], ..., [x1,y1], rubber-band] - // We need to subtract: 1 for closing vertex + 1 for rubber-band = 2 total - const exteriorRing = rawCoords[0] - numVertecies = Math.max(0, exteriorRing.length - 2) - } else if (geom.getType() === 'LineString') { - // For LineString, OL stores coords with trailing rubber-band: [v1, v2, ..., vN, rubber] - // Subtract 1 for the rubber-band coordinate - numVertecies = Math.max(0, rawCoords.length - 1) - } - - manager.emit('vertexchange', { numVertecies }) + if (!currentPixel || !firstPixel) return false + return pixelDist(currentPixel, firstPixel) < SNAP_TOLERANCE } // --- Place a vertex at the current map center (crosshair position) --- const placeVertex = () => { const map = getMap() - const coord = mapProvider.getCenter() + const raw = mapProvider.getCenter() + const coord = (interfaceType !== 'pointer' && snap) ? snap.apply(raw) : raw + snap?.hideIndicator() if (sketchFeature) { const geom = sketchFeature.getGeometry() @@ -174,82 +111,29 @@ export const createDrawInput = ({ drawInteraction, manager, options }) => { sketchCoords = rawCoords[0] || [] } - // Check if same coord placed twice consecutively (without moving crosshair) - // For polygons: need at least 2 user vertices before allowing double-tap close - // For lines: need at least 1 user vertex before allowing double-tap close - if (lastPlacedCoord && - lastPlacedCoord[0] === coord[0] && - lastPlacedCoord[1] === coord[1]) { - // Check minimum vertices before finishing - let numVertecies = 0 - if (geom.getType() === 'Polygon' && sketchCoords.length > 0) { - numVertecies = Math.max(0, sketchCoords.length - 2) - } else if (geom.getType() === 'LineString') { - numVertecies = Math.max(0, sketchCoords.length - 1) - } - - const minForFinish = geom.getType() === 'Polygon' ? 2 : 1 - if (numVertecies >= minForFinish) { - drawInteraction.finishDrawing() - lastPlacedCoord = null - return - } - } - - // Check if close to first vertex (for polygon closure via proximity) - if (geom.getType() === 'Polygon' && sketchCoords.length >= 4) { - const firstCoord = sketchCoords[0] - const currentPixel = coordToPixel(map, coord) - const firstPixel = coordToPixel(map, firstCoord) - if (currentPixel && firstPixel && pixelDist(currentPixel, firstPixel) < SNAP_TOLERANCE) { - drawInteraction.finishDrawing() - lastPlacedCoord = null - return - } + // Check if close to first vertex (for polygon closure) + if (isCloseToFirstVertex(map, coord, sketchCoords, geom.getType())) { + drawInteraction.finishDrawing() + return } } drawInteraction.appendCoordinates([coord]) - - // Move the confirmation dot to the placed vertex; OL only updates it via pointer events - // so during keyboard/touch it stays frozen unless we move it explicitly here. - const sketchPoint = drawInteraction.sketchPoint_ - if (sketchPoint) { - sketchPoint.getGeometry().setCoordinates(coord) - } - - lastPlacedCoord = coord - - // Cancel any pending update and schedule a new one - // This ensures we only emit the final calculated count after OL finishes updating - if (pendingVertexUpdate) { - clearTimeout(pendingVertexUpdate) - } - pendingVertexUpdate = setTimeout(() => { - updateDisplayedVertexCount() - pendingVertexUpdate = null - }, 10) } // --- Event handlers --- const onKeydown = (e) => { - if (document.activeElement !== container) { return } - if (ARROW_KEYS.has(e.key)) { - if (interfaceType !== 'keyboard') { - interfaceType = 'keyboard' - crossHair?.fixAtCenter() - updateSketchRubberbanding() - } - return - } + if (!container.contains(document.activeElement)) { return } + if (ARROW_KEYS.has(e.key)) { interfaceType = 'keyboard'; return } if (e.key === 'Enter') { e.preventDefault() - if (interfaceType !== 'keyboard') { - interfaceType = 'keyboard' - crossHair?.fixAtCenter() - } + interfaceType = 'keyboard' placeVertex() } + if (e.key === 'z' && (e.metaKey || e.ctrlKey)) { + e.preventDefault() + onUndo?.() + } } // Button click covers both Add Point button and any element inside it @@ -259,35 +143,18 @@ export const createDrawInput = ({ drawInteraction, manager, options }) => { } } + // Track interface type so DrawMode can show/hide crosshair correctly const onPointerdown = (e) => { - if (e.pointerType !== 'touch' && interfaceType !== 'pointer') { + if (e.pointerType !== 'touch') { interfaceType = 'pointer' - crossHair?.hide() } } const onTouchstart = () => { - if (interfaceType !== 'touch') { - interfaceType = 'touch' - crossHair?.fixAtCenter() - updateSketchRubberbanding() - } + interfaceType = 'touch' } - const onPointerMove = (e) => { - if (e.pointerType === 'mouse') { - if (interfaceType !== 'pointer') { - interfaceType = 'pointer' - crossHair?.hide() - } - // OL moves sketchPoint_ to the cursor on every pointermove; re-anchor it to the last - // placed vertex so the dot only moves when a vertex is actually placed. - if (lastPlacedCoord && sketchFeature) { - const sp = drawInteraction.sketchPoint_ - if (sp) sp.getGeometry().setCoordinates(lastPlacedCoord) - } - return - } + const onPointerMove = () => { if (interfaceType === 'pointer') { return } updateSketchRubberbanding() } @@ -302,20 +169,14 @@ export const createDrawInput = ({ drawInteraction, manager, options }) => { getInterfaceType: () => interfaceType, destroy () { - if (pendingVertexUpdate) { - clearTimeout(pendingVertexUpdate) - } - if (olMap) { - olMap.getView().un('change:center', onCenterChange) + if (olView) { + olView.un('change:center', onCenterChange) } window.removeEventListener('keydown', onKeydown) window.removeEventListener('click', onButtonClick) container.removeEventListener('pointerdown', onPointerdown) container.removeEventListener('touchstart', onTouchstart) container.removeEventListener('pointermove', onPointerMove) - drawInteraction.un('drawstart', onDrawStart) - drawInteraction.un('drawend', onDrawEnd) - drawInteraction.un('drawabort', onDrawEnd) } } } diff --git a/plugins/beta/draw-ol/src/edit/EditMode.js b/plugins/beta/draw-ol/src/edit/EditMode.js index 7ff31200..f43ac6b6 100644 --- a/plugins/beta/draw-ol/src/edit/EditMode.js +++ b/plugins/beta/draw-ol/src/edit/EditMode.js @@ -23,7 +23,7 @@ import { getCoords, getMidpoints } from '../utils/geometryHelpers.js' * @returns {{ done, cancel, undo, deleteVertex: fn, destroy }} */ export const createEditMode = ({ map, manager, options }) => { - const { featureId, container, interfaceType, deleteVertexButtonId } = options + const { featureId, container, interfaceType, deleteVertexButtonId, snap } = options const { store, undoStack } = manager const olFeature = store.getOL(featureId) @@ -298,6 +298,7 @@ export const createEditMode = ({ map, manager, options }) => { getState, setState, colors: manager.colors, + snap, onVertexMoved ({ vertexIndex, previousCoord }) { undoStack.push({ type: 'move_vertex', vertexIndex, previousCoord }) syncGeom() @@ -348,6 +349,7 @@ export const createEditMode = ({ map, manager, options }) => { map, getState, setState, + snap, onVertexMoved ({ vertexIndex, previousCoord }) { undoStack.push({ type: 'move_vertex', vertexIndex, previousCoord }) syncGeom() diff --git a/plugins/beta/draw-ol/src/edit/keyboardHandler.js b/plugins/beta/draw-ol/src/edit/keyboardHandler.js index 45c2947e..9d05d5cf 100644 --- a/plugins/beta/draw-ol/src/edit/keyboardHandler.js +++ b/plugins/beta/draw-ol/src/edit/keyboardHandler.js @@ -1,6 +1,7 @@ import { coordToPixel, nudgeCoord } from '../utils/olCoords.js' import { spatialNavigate } from '../utils/spatial.js' import { moveVertex, insertAtMidpoint } from './vertexOps.js' +import { SNAP_RADIUS_PX } from '../snap/snapEngine.js' const ARROW_KEYS = new Set(['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown']) const NUDGE_PX = 1 @@ -25,7 +26,7 @@ const STEP_PX = 5 export const createKeyboardHandler = ({ map, getState, setState, onVertexMoved, onInserted, onDeleted, onUndo, - onKeyboardActive + onKeyboardActive, snap }) => { let keyMoveStart = null let keyMoveIndex = null @@ -93,7 +94,8 @@ export const createKeyboardHandler = ({ keyMoveStart = [...insertedCoord] keyMoveIndex = result.insertedIndex - const movedCoord = nudgeCoord(map, insertedCoord, dx, dy) + const nudgedCoord = nudgeCoord(map, insertedCoord, dx, dy) + const movedCoord = snap ? snap.apply(nudgedCoord) : nudgedCoord moveVertex(olFeature, result.insertedIndex, movedCoord) setState({ selectedVertexIndex: result.insertedIndex, @@ -111,7 +113,24 @@ export const createKeyboardHandler = ({ keyMoveIndex = selectedVertexIndex } - const newCoord = nudgeCoord(map, current, dx, dy) + const nudgedCoord = nudgeCoord(map, current, dx, dy) + let newCoord = snap ? snap.apply(nudgedCoord) : nudgedCoord + snap?.hideIndicator() + + // Escape if snap is preventing sufficient progress in the intended direction. + // Covers vertex-stuck (newCoord === current) and edge-hugging (vertex slides + // along edge instead of moving away from it). + if (snap) { + const nudgeVec = [nudgedCoord[0] - current[0], nudgedCoord[1] - current[1]] + const actualVec = [newCoord[0] - current[0], newCoord[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 escape = SNAP_RADIUS_PX + 1 + newCoord = nudgeCoord(map, current, dx !== 0 ? Math.sign(dx) * escape : 0, dy !== 0 ? Math.sign(dy) * escape : 0) + } + } + moveVertex(olFeature, selectedVertexIndex, newCoord) setState({ vertecies: vertecies.map((c, i) => i === selectedVertexIndex ? newCoord : c) }) } @@ -167,6 +186,7 @@ export const createKeyboardHandler = ({ if (isTextInput()) { return } if (ARROW_KEYS.has(e.key) && keyMoveStart && keyMoveIndex != null) { + snap?.hideIndicator() onVertexMoved({ vertexIndex: keyMoveIndex, previousCoord: keyMoveStart }) keyMoveStart = null keyMoveIndex = null diff --git a/plugins/beta/draw-ol/src/edit/touchHandler.js b/plugins/beta/draw-ol/src/edit/touchHandler.js index c66e4466..43117f46 100644 --- a/plugins/beta/draw-ol/src/edit/touchHandler.js +++ b/plugins/beta/draw-ol/src/edit/touchHandler.js @@ -7,7 +7,7 @@ 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 }) => { +const wireTouchEvents = ({ container, map, targetEl, olToCSS, cssToOl, getState, setState, onVertexMoved, onTap, snap }) => { let dragStartCoord = null let dragStartIndex = null let vertexTouchDelta = null @@ -37,7 +37,9 @@ const wireTouchEvents = ({ container, map, targetEl, olToCSS, cssToOl, getState, if (!isOnTouchTarget(e.target) || dragStartIndex == null) { return } e.preventDefault() const tOl = map.getEventPixel({ clientX: e.touches[0].clientX, clientY: e.touches[0].clientY }) - const newCoord = pixelToCoord(map, { x: tOl[0] - vertexTouchDelta.x, y: tOl[1] - vertexTouchDelta.y }) + 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, vertecies } = getState() if (!olFeature) { return } moveVertex(olFeature, dragStartIndex, newCoord) @@ -66,6 +68,7 @@ const wireTouchEvents = ({ container, map, targetEl, olToCSS, cssToOl, getState, if (vertecies[dragStartIndex] && dragStartCoord) { onVertexMoved({ vertexIndex: dragStartIndex, previousCoord: dragStartCoord }) } + snap?.hideIndicator() dragStartCoord = null; dragStartIndex = null; vertexTouchDelta = null; targetTouchDelta = null e.preventDefault() } @@ -92,7 +95,7 @@ const wireTouchEvents = ({ container, map, targetEl, olToCSS, cssToOl, getState, * @param {{ map, container, getState, setState, onVertexMoved, onTap, colors }} options * @returns {{ updateTargetPosition, updateColors, hide, destroy }} */ -export const createTouchHandler = ({ map, container, getState, setState, onVertexMoved, onTap, colors }) => { +export const createTouchHandler = ({ map, container, getState, setState, onVertexMoved, onTap, colors, snap }) => { const targetEl = createTouchTarget(container) applyTouchTargetColors(targetEl, colors) @@ -116,7 +119,7 @@ export const createTouchHandler = ({ map, container, getState, setState, onVerte }) } - const touchEvents = wireTouchEvents({ container, map, targetEl, olToCSS, cssToOl, getState, setState, onVertexMoved, onTap }) + const touchEvents = wireTouchEvents({ container, map, targetEl, olToCSS, cssToOl, getState, setState, onVertexMoved, onTap, snap }) const updateTargetPosition = () => { const { selectedVertexIndex, vertecies, interfaceType } = getState() diff --git a/plugins/beta/draw-ol/src/events.js b/plugins/beta/draw-ol/src/events.js index 5efff4e9..ef106256 100644 --- a/plugins/beta/draw-ol/src/events.js +++ b/plugins/beta/draw-ol/src/events.js @@ -3,7 +3,7 @@ * Mirrors draw-ml/events.js structure but uses manager.on/off instead of map.on/off. */ export function attachEvents ({ pluginState, mapProvider, buttonConfig, eventBus }) { - const { drawDone, drawCancel, drawUndo, drawDeletePoint } = buttonConfig + const { drawDone, drawCancel, drawUndo, drawDeletePoint, drawSnap } = buttonConfig const { draw } = mapProvider const { dispatch, feature, tempFeature } = pluginState @@ -39,6 +39,12 @@ export function attachEvents ({ pluginState, mapProvider, buttonConfig, eventBus draw.deleteVertex() } + const handleSnap = () => { + const newSnapState = !pluginState.snap + dispatch({ type: 'TOGGLE_SNAP' }) + draw.snap?.setActive(newSnapState) + } + // --- Manager event handlers --- const onCreate = (geojsonFeature) => { @@ -63,7 +69,7 @@ export function attachEvents ({ pluginState, mapProvider, buttonConfig, eventBus } const onVertexChange = (e) => { - dispatch({ type: 'SET_VERTEX_COUNT', payload: e.numVertecies }) + dispatch({ type: 'SET_SELECTED_VERTEX_INDEX', payload: { index: -1, numVertecies: e.numVertecies } }) } const onUndoChange = (length) => { @@ -80,6 +86,7 @@ export function attachEvents ({ pluginState, mapProvider, buttonConfig, eventBus drawCancel.onClick = handleCancel drawUndo.onClick = handleUndo if (drawDeletePoint) drawDeletePoint.onClick = handleDeleteVertex + if (drawSnap) drawSnap.onClick = handleSnap draw.on('create', onCreate) draw.on('editfinish', onEditFinish) @@ -94,6 +101,7 @@ export function attachEvents ({ pluginState, mapProvider, buttonConfig, eventBus drawCancel.onClick = null drawUndo.onClick = null if (drawDeletePoint) drawDeletePoint.onClick = null + if (drawSnap) drawSnap.onClick = null draw.off('create', onCreate) draw.off('editfinish', onEditFinish) diff --git a/plugins/beta/draw-ol/src/manifest.js b/plugins/beta/draw-ol/src/manifest.js index d0e284da..d6991632 100644 --- a/plugins/beta/draw-ol/src/manifest.js +++ b/plugins/beta/draw-ol/src/manifest.js @@ -40,12 +40,17 @@ export const manifest = { id: 'drawDone', label: 'Done', variant: 'primary', - hiddenWhen: ({ pluginState }) => - !['draw_polygon', 'draw_line', 'edit_vertex'].includes(pluginState.mode), + hiddenWhen: ({ pluginState }) => !['draw_polygon', 'draw_line', 'edit_vertex'].includes(pluginState.mode), enableWhen: ({ pluginState }) => { - if (pluginState.mode === 'draw_polygon') return pluginState.numVertecies >= 3 - if (pluginState.mode === 'draw_line') return pluginState.numVertecies >= 2 - if (pluginState.mode === 'edit_vertex') return true + if (pluginState.mode === 'draw_polygon') { + return pluginState.numVertecies >= 3 // NOSONAR + } + if (pluginState.mode === 'draw_line') { + return pluginState.numVertecies >= 2 // NOSONAR + } + if (pluginState.mode === 'edit_vertex') { + return true + } return false }, ...createButtonSlots(true) @@ -54,46 +59,55 @@ export const manifest = { id: 'drawMenu', label: 'Menu', iconId: 'menu', - 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 }) => pluginState.undoStackLength > 0 - }, - { - id: 'drawDeletePoint', - label: 'Delete point', - iconId: 'trash', - enableWhen: ({ pluginState }) => - pluginState.selectedVertexIndex >= 0 && pluginState.numVertecies > 2, - hiddenWhen: ({ pluginState }) => pluginState.mode !== 'edit_vertex' + 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.numVertecies > 0 + return pluginState.undoStackLength > 0 } - ], + }, { + id: 'drawDeletePoint', + label: 'Delete point', + iconId: 'trash', + enableWhen: ({ pluginState }) => pluginState.selectedVertexIndex >= 0 && pluginState.numVertecies > 2, + hiddenWhen: ({ pluginState }) => pluginState.mode !== 'edit_vertex' + }, { + id: 'drawSnap', + label: 'Snap to feature', + iconId: 'magnet', + hiddenWhen: ({ pluginState }) => !pluginState.mode || !pluginState.hasSnapLayers, + pressedWhen: ({ pluginState }) => !!pluginState.snap + }], mobile: { slot: 'bottom-right' }, tablet: { slot: 'top-middle' }, desktop: { slot: 'top-middle' } } ], - icons: [ - { - id: 'menu', - svgContent: '' - }, - { - id: 'undo', - svgContent: '' - }, - { - id: 'trash', - svgContent: '' - } - ], + 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, diff --git a/plugins/beta/draw-ol/src/reducer.js b/plugins/beta/draw-ol/src/reducer.js index fb8e1af7..9439f177 100644 --- a/plugins/beta/draw-ol/src/reducer.js +++ b/plugins/beta/draw-ol/src/reducer.js @@ -4,15 +4,13 @@ const initialState = { tempFeature: null, selectedVertexIndex: -1, numVertecies: null, - undoStackLength: 0 + undoStackLength: 0, + snap: false, + hasSnapLayers: false } const actions = { - SET_MODE: (state, payload) => ({ - ...state, - mode: payload, - numVertecies: ['draw_polygon', 'draw_line'].includes(payload) ? 0 : state.numVertecies - }), + SET_MODE: (state, payload) => ({ ...state, mode: payload }), SET_FEATURE: (state, payload) => ({ ...state, @@ -23,18 +21,17 @@ const actions = { SET_SELECTED_VERTEX_INDEX: (state, payload) => ({ ...state, selectedVertexIndex: payload.index, - numVertecies: payload.numVertecies !== undefined ? payload.numVertecies : state.numVertecies - }), - - SET_VERTEX_COUNT: (state, payload) => ({ - ...state, - numVertecies: payload + numVertecies: payload.numVertecies }), SET_UNDO_STACK_LENGTH: (state, payload) => ({ ...state, undoStackLength: payload - }) + }), + + TOGGLE_SNAP: (state) => ({ ...state, snap: !state.snap }), + + SET_HAS_SNAP_LAYERS: (state, payload) => ({ ...state, hasSnapLayers: !!payload }) } export { initialState, actions } diff --git a/plugins/beta/draw-ol/src/snap/snapEngine.js b/plugins/beta/draw-ol/src/snap/snapEngine.js new file mode 100644 index 00000000..4a96f3f5 --- /dev/null +++ b/plugins/beta/draw-ol/src/snap/snapEngine.js @@ -0,0 +1,115 @@ +/** + * Snap candidate engine. + * + * Accepts two kinds of entry in snapLayers: + * - string → a VectorTile style-layer name (matched via feature.get('layer')) + * All VectorTileLayers on the map are searched; only features whose + * style-layer name is in the set are tested. + * - OL VectorLayer instance → all features in that layer's source are tested. + * + * query() is synchronous and uses: + * - VectorSource.getFeaturesInExtent() for OL vector layers (internal rBush, fast) + * - map.forEachFeatureAtPixel() for VectorTile layers (rendered tile data) + */ + +import VectorLayer from 'ol/layer/Vector.js' +import VectorTileLayer from 'ol/layer/VectorTile.js' +import { testOLFeature, testRenderFeature } from './snapGeometry.js' + +export const SNAP_RADIUS_PX = 12 + +export const createSnapEngine = (map, snapLayers = []) => { + const vtLayerNames = new Set() + const olLayers = [] + + for (const entry of snapLayers) { + if (typeof entry === 'string') { + vtLayerNames.add(entry) + } else if (entry instanceof VectorLayer) { + olLayers.push(entry) + } + } + + // Lazily collected — VectorTileLayers are stable after map setup + let cachedVTLayers = null + const getVTLayers = () => { + if (!cachedVTLayers) { + cachedVTLayers = [] + map.getLayers().forEach(l => { + if (l instanceof VectorTileLayer) { + cachedVTLayers.push(l) + } + }) + } + return cachedVTLayers + } + + /** + * Find the nearest snap candidate to coord within radiusPx screen pixels. + * @param {number[]} coord - map coordinate [x, y] + * @param {number} radiusPx - tolerance in screen pixels + * @returns {{ type: 'vertex'|'edge', coord: number[] } | null} + */ + 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 + + const update = (r) => { + if (!r) return + if (!best) { best = r; return } + if (best.type === 'vertex' && r.type === 'edge') return + if (best.type === 'edge' && r.type === 'vertex') { best = r; return } + if (r.distSq < best.distSq) best = r + } + + // --- OL VectorLayer sources --- + for (const layer of olLayers) { + const source = layer.getSource() + if (!source) { + continue + } + for (const feature of source.getFeaturesInExtent(ext)) { + update(testOLFeature(feature, coord, toleranceSq)) + } + } + + // --- VectorTile layers --- + if (vtLayerNames.size > 0) { + const vtLayers = getVTLayers() + if (vtLayers.length > 0) { + const pixel = map.getPixelFromCoordinate(coord) + if (pixel) { + map.forEachFeatureAtPixel( + pixel, + (feature, _layer) => { + if (!vtLayerNames.has(feature.get('mapbox-layer')?.id)) { + return + } + update(testRenderFeature(feature, coord, toleranceSq)) + }, + { + hitTolerance: radiusPx, + layerFilter: (l) => vtLayers.includes(l) + } + ) + } + } + } + + return best ? { type: best.type, coord: best.coord } : null + } + + return { query } +} diff --git a/plugins/beta/draw-ol/src/snap/snapGeometry.js b/plugins/beta/draw-ol/src/snap/snapGeometry.js new file mode 100644 index 00000000..366a8230 --- /dev/null +++ b/plugins/beta/draw-ol/src/snap/snapGeometry.js @@ -0,0 +1,199 @@ +/** + * Pure geometry helpers for snap candidate testing. + * No OL imports, no side effects — only coordinate math. + * + * All coordinates are [x, y] pairs in map projection units. + */ + +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 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 +} + +/** + * Test all vertices and edges of a coordinate ring/line for snap candidates. + * coords: [[x,y], ...] — OL ring (first === last for closed rings) + * isClosedRing: if true, OL has duplicated the first coord as last; skip it and wrap edges + */ +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) { + const candidate = { type: 'vertex', coord: [v[0], v[1]], distSq: dSq } + if (better(best, candidate)) { + best = candidate + } + } + } + + 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) { + const candidate = { type: 'edge', coord: pt, distSq: dSq } + if (better(best, candidate)) { + best = candidate + } + } + } + + return best +} + +/** + * Test flat coordinate array (stride 2) from a VectorTile RenderFeature. + * flat: number[] — [x0,y0,x1,y1,...] + * start/end: index range within flat + * isClosedRing: VTile polygon rings — first coord is NOT duplicated at end (unlike OL Vector) + * so treat all coords as unique vertices and add a closing edge back to first + */ +const testFlatCoords = (flat, start, end, query, toleranceSq, isClosedRing) => { + let best = null + const numPairs = (end - start) / 2 + const edgeCount = isClosedRing ? numPairs : numPairs - 1 + + 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) { + const candidate = { type: 'vertex', coord: v, distSq: dSq } + if (better(best, candidate)) { + best = candidate + } + } + } + + 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) { + const candidate = { type: 'edge', coord: pt, distSq: dSq } + if (better(best, candidate)) { + best = candidate + } + } + } + + return best +} + +/** + * Test an OL Feature (from a VectorSource) against query coord. + * Handles Point, LineString, LinearRing, Polygon, MultiLineString, MultiPolygon. + * + * @returns {{ type: 'vertex'|'edge', coord: number[], distSq: number } | null} + */ +export const testOLFeature = (feature, query, toleranceSq) => { + const geom = feature.getGeometry() + if (!geom) { + return null + } + const type = geom.getType() + let best = null + + const update = (r) => { + if (better(best, r)) { + best = r + } + } + + if (type === 'Point') { + const c = geom.getCoordinates() + const dSq = dist2(query, c) + if (dSq <= toleranceSq) { + update({ type: 'vertex', coord: [c[0], c[1]], distSq: dSq }) + } + } else if (type === 'LineString' || type === 'LinearRing') { + update(testCoords(geom.getCoordinates(), query, toleranceSq, type === 'LinearRing')) + } else if (type === 'Polygon') { + for (const ring of geom.getCoordinates()) { + update(testCoords(ring, query, toleranceSq, true)) + } + } else if (type === 'MultiLineString') { + for (const line of geom.getCoordinates()) { + update(testCoords(line, query, toleranceSq, false)) + } + } else if (type === 'MultiPolygon') { + for (const polygon of geom.getCoordinates()) { + for (const ring of polygon) { + update(testCoords(ring, query, toleranceSq, true)) + } + } + } else { + // No action + } + + return best +} + +/** + * Test an OL RenderFeature (from a VectorTileSource) against query coord. + * Handles Point, LineString, Polygon, MultiLineString. + * + * @returns {{ type: 'vertex'|'edge', coord: number[], distSq: number } | null} + */ +export const testRenderFeature = (feature, query, toleranceSq) => { + const type = feature.getType() + const flat = feature.getFlatCoordinates() + let best = null + + const update = (r) => { + if (better(best, r)) { + best = r + } + } + + if (type === 'Point') { + const dSq = dist2(query, flat) + if (dSq <= toleranceSq) { + update({ type: 'vertex', coord: [flat[0], flat[1]], distSq: dSq }) + } + } else if (type === 'LineString') { + update(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) { + update(testFlatCoords(flat, start, end, query, toleranceSq, isClosedRing)) + start = end + } + } else { + // No action + } + + return best +} diff --git a/plugins/beta/draw-ol/src/snap/snapIndicator.js b/plugins/beta/draw-ol/src/snap/snapIndicator.js new file mode 100644 index 00000000..b5c9a46e --- /dev/null +++ b/plugins/beta/draw-ol/src/snap/snapIndicator.js @@ -0,0 +1,75 @@ +/** + * Snap indicator — a single OL VectorLayer that shows a circle at the active + * snap candidate position. + * + * Vertex snap → orange semi-transparent circle + * Edge snap → blue semi-transparent circle + * + * 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 VERTEX_COLOR = 'rgba(230, 120, 0, 0.55)' +const EDGE_COLOR = 'rgba(0, 100, 220, 0.55)' + +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 STYLES = { + vertex: new Style({ renderer: makeRenderer(VERTEX_COLOR) }), + edge: new Style({ renderer: makeRenderer(EDGE_COLOR) }) +} + +export const createSnapIndicator = (map) => { + 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 + }, + + remove () { + source.clear() + map.removeLayer(layer) + } + } +} diff --git a/plugins/beta/draw-ol/src/snap/snapInteraction.js b/plugins/beta/draw-ol/src/snap/snapInteraction.js new file mode 100644 index 00000000..22f5a7a3 --- /dev/null +++ b/plugins/beta/draw-ol/src/snap/snapInteraction.js @@ -0,0 +1,55 @@ +/** + * 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' +import { SNAP_RADIUS_PX } from './snapEngine.js' + +const SNAP_EVENTS = new Set(['pointermove', 'pointerdrag', 'pointerdown', 'pointerup', 'singleclick', 'click']) + +export const createSnapInteraction = (engine, indicator) => { + const interaction = new Interaction({ + handleEvent (mapBrowserEvent) { + if (!interaction.getActive()) { + return true + } + + const { type } = mapBrowserEvent + + if (type === 'pointerout' || type === 'pointerleave') { + indicator.hide() + return true + } + + if (!SNAP_EVENTS.has(type)) { + return true + } + + const result = engine.query(mapBrowserEvent.coordinate, SNAP_RADIUS_PX) + if (result) { + mapBrowserEvent.coordinate = result.coord.slice() + } + + // Only show indicator during free mouse movement — hide during drag and clicks + if (type === 'pointermove') { + result ? indicator.show(result.coord, result.type) : indicator.hide() + } else if (type === 'pointerdrag') { + indicator.hide() + } + + return true + } + }) + + return interaction +} diff --git a/plugins/beta/draw-ol/src/snap/snapManager.js b/plugins/beta/draw-ol/src/snap/snapManager.js new file mode 100644 index 00000000..b422204a --- /dev/null +++ b/plugins/beta/draw-ol/src/snap/snapManager.js @@ -0,0 +1,78 @@ +/** + * 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, SNAP_RADIUS_PX } from './snapEngine.js' +import { createSnapIndicator } from './snapIndicator.js' +import { createSnapInteraction } from './snapInteraction.js' + +export const createSnapManager = (map, snapLayers) => { + if (!snapLayers?.length) { + return null + } + + const engine = createSnapEngine(map, snapLayers) + const indicator = createSnapIndicator(map) + const interaction = createSnapInteraction(engine, indicator) + + 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. + */ + apply (coord) { + if (!active) return coord + const result = engine.query(coord, SNAP_RADIUS_PX) + if (result) { + indicator.show(result.coord, result.type) + return result.coord + } + indicator.hide() + return coord + }, + + hideIndicator () { + 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. + */ + reattach () { + map.removeInteraction(interaction) + map.addInteraction(interaction) + }, + + destroy () { + map.removeInteraction(interaction) + indicator.remove() + } + } +} diff --git a/providers/beta/openlayers/src/utils/tileLayers.js b/providers/beta/openlayers/src/utils/tileLayers.js index f100aec3..4eeb7dd1 100644 --- a/providers/beta/openlayers/src/utils/tileLayers.js +++ b/providers/beta/openlayers/src/utils/tileLayers.js @@ -5,7 +5,11 @@ import OGCVectorTile from 'ol/source/OGCVectorTile.js' import MVT from 'ol/format/MVT.js' import TileGrid from 'ol/tilegrid/TileGrid.js' import TileState from 'ol/TileState.js' -import { stylefunction } from 'ol-mapbox-style' +import { stylefunction, recordStyleLayer } from 'ol-mapbox-style' + +// Enable style-layer name recording so feature.get('mapbox-layer').id returns the +// style layer name — required for snap layer filtering by style layer name. +recordStyleLayer(true) import { TILE_GRID_RESOLUTIONS, TILE_GRID_ORIGIN, TILE_SIZE } from '../defaults.js' const CRS = 'EPSG:27700' From 4e6f080fdb293c47d86d97ad14bb663f44a49a1d Mon Sep 17 00:00:00 2001 From: Dan Leech Date: Mon, 18 May 2026 17:22:46 +0100 Subject: [PATCH 14/26] Closing coord related bug fixes --- plugins/beta/draw-ol/src/edit/keyboardHandler.js | 13 +++++++++---- plugins/beta/draw-ol/src/edit/undoOps.js | 9 +++++++++ plugins/beta/draw-ol/src/edit/vertexOps.js | 6 ++++++ plugins/beta/draw-ol/src/manifest.js | 16 +++++++++------- .../beta/draw-ol/src/utils/geometryHelpers.js | 14 ++++++++------ 5 files changed, 41 insertions(+), 17 deletions(-) diff --git a/plugins/beta/draw-ol/src/edit/keyboardHandler.js b/plugins/beta/draw-ol/src/edit/keyboardHandler.js index 9d05d5cf..c1e65f90 100644 --- a/plugins/beta/draw-ol/src/edit/keyboardHandler.js +++ b/plugins/beta/draw-ol/src/edit/keyboardHandler.js @@ -135,13 +135,18 @@ export const createKeyboardHandler = ({ setState({ vertecies: vertecies.map((c, i) => i === selectedVertexIndex ? newCoord : c) }) } - const isTextInput = () => { + const appViewport = map.getViewport().closest('[role="application"]') ?? map.getViewport() + + const isInteractiveElementFocused = () => { const el = document.activeElement - return el?.tagName === 'INPUT' || el?.tagName === 'TEXTAREA' || el?.isContentEditable + if (!el || el === document.body) return false + if (appViewport.contains(el)) return false + const tag = el.tagName + return tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'BUTTON' || tag === 'SELECT' || tag === 'A' || el.isContentEditable || el.hasAttribute('tabindex') } const onKeydown = (e) => { - if (isTextInput()) { return } + if (isInteractiveElementFocused()) { return } if (e.key === 'Escape' && getState().selectedVertexIndex >= 0) { e.preventDefault() @@ -183,7 +188,7 @@ export const createKeyboardHandler = ({ } const onKeyup = (e) => { - if (isTextInput()) { return } + if (isInteractiveElementFocused()) { return } if (ARROW_KEYS.has(e.key) && keyMoveStart && keyMoveIndex != null) { snap?.hideIndicator() diff --git a/plugins/beta/draw-ol/src/edit/undoOps.js b/plugins/beta/draw-ol/src/edit/undoOps.js index ae1c2d12..694b6817 100644 --- a/plugins/beta/draw-ol/src/edit/undoOps.js +++ b/plugins/beta/draw-ol/src/edit/undoOps.js @@ -20,6 +20,9 @@ export const undoMoveVertex = (olFeature, op) => { 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 } @@ -34,6 +37,9 @@ export const undoInsertVertex = (olFeature, op) => { 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 } @@ -58,6 +64,9 @@ export const undoDeleteVertex = (olFeature, op) => { 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 } diff --git a/plugins/beta/draw-ol/src/edit/vertexOps.js b/plugins/beta/draw-ol/src/edit/vertexOps.js index 70a99803..9a4c42d4 100644 --- a/plugins/beta/draw-ol/src/edit/vertexOps.js +++ b/plugins/beta/draw-ol/src/edit/vertexOps.js @@ -26,6 +26,9 @@ export const deleteVertex = (olFeature, selectedIndex) => { 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 } @@ -81,5 +84,8 @@ export const moveVertex = (olFeature, index, newCoord) => { 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-ol/src/manifest.js b/plugins/beta/draw-ol/src/manifest.js index d6991632..66d6ee56 100644 --- a/plugins/beta/draw-ol/src/manifest.js +++ b/plugins/beta/draw-ol/src/manifest.js @@ -66,21 +66,23 @@ export const manifest = { 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.numVertecies > 0 + if (['draw_polygon', 'draw_line'].includes(pluginState.mode)) { + return pluginState.numVertecies > 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 }) => pluginState.selectedVertexIndex >= 0 && pluginState.numVertecies > 2, hiddenWhen: ({ pluginState }) => pluginState.mode !== 'edit_vertex' - }, { - id: 'drawSnap', - label: 'Snap to feature', - iconId: 'magnet', - hiddenWhen: ({ pluginState }) => !pluginState.mode || !pluginState.hasSnapLayers, - pressedWhen: ({ pluginState }) => !!pluginState.snap }], mobile: { slot: 'bottom-right' }, tablet: { slot: 'top-middle' }, diff --git a/plugins/beta/draw-ol/src/utils/geometryHelpers.js b/plugins/beta/draw-ol/src/utils/geometryHelpers.js index b9f0ea47..83241941 100644 --- a/plugins/beta/draw-ol/src/utils/geometryHelpers.js +++ b/plugins/beta/draw-ol/src/utils/geometryHelpers.js @@ -8,9 +8,9 @@ export const getCoords = (geom) => { if (!geom?.coordinates) return [] switch (geom.type) { case 'LineString': return geom.coordinates - case 'Polygon': return geom.coordinates.flat(1) + case 'Polygon': return geom.coordinates.flatMap(ring => ring.slice(0, -1)) case 'MultiLineString': return geom.coordinates.flat(1) - case 'MultiPolygon': return geom.coordinates.flat(2) + case 'MultiPolygon': return geom.coordinates.flatMap(poly => poly.flatMap(ring => ring.slice(0, -1))) default: return [] } } @@ -30,8 +30,9 @@ export const getRingSegments = (geom) => { break case 'Polygon': geom.coordinates.forEach((ring, i) => { - segments.push({ start, length: ring.length, path: [i], closed: true }) - start += ring.length + const len = ring.length - 1 + segments.push({ start, length: len, path: [i], closed: true }) + start += len }) break case 'MultiLineString': @@ -43,8 +44,9 @@ export const getRingSegments = (geom) => { case 'MultiPolygon': geom.coordinates.forEach((polygon, pi) => { polygon.forEach((ring, ri) => { - segments.push({ start, length: ring.length, path: [pi, ri], closed: true }) - start += ring.length + const len = ring.length - 1 + segments.push({ start, length: len, path: [pi, ri], closed: true }) + start += len }) }) break From 46462d6c1ff7555ebfb3244b9405ab7b015068cb Mon Sep 17 00:00:00 2001 From: Dan Leech Date: Mon, 18 May 2026 17:43:52 +0100 Subject: [PATCH 15/26] Colour props refactored --- plugins/beta/draw-ml/src/defaults.js | 24 +++--- plugins/beta/draw-ml/src/mapboxSnap.js | 2 +- plugins/beta/draw-ml/src/styles.js | 81 ++++++++++--------- .../beta/draw-ol/src/core/OLDrawManager.js | 3 +- plugins/beta/draw-ol/src/core/styles.js | 28 +++---- plugins/beta/draw-ol/src/defaults.js | 16 ++-- plugins/beta/draw-ol/src/snap/snapEngine.js | 17 ++-- .../beta/draw-ol/src/snap/snapIndicator.js | 26 +++--- plugins/beta/draw-ol/src/snap/snapManager.js | 8 +- .../beta/draw-ol/src/utils/resolveColors.js | 14 ++-- plugins/beta/draw-ol/src/utils/touchTarget.js | 6 +- 11 files changed, 120 insertions(+), 105 deletions(-) diff --git a/plugins/beta/draw-ml/src/defaults.js b/plugins/beta/draw-ml/src/defaults.js index eba47f70..41ddd95a 100644 --- a/plugins/beta/draw-ml/src/defaults.js +++ b/plugins/beta/draw-ml/src/defaults.js @@ -1,17 +1,17 @@ export const DEFAULTS = { - editColorsForeground: { light: 'rgba(29,112,184,1)', dark: '#ffffff' }, - editColorsBackground: { light: '#ffffff', dark: 'rgba(11,12,12,1)' }, - editColorsHalo: { light: 'rgba(11,12,12,1)', dark: '#ffffff' }, - splitInvalidColors: { light: 'rgba(29,112,184,1)', dark: 'rgba(29,112,184,1)' }, - splitValidColors: { light: 'rgba(29,112,184,1)', dark: 'rgba(29,112,184,1)' }, - stroke: 'rgba(212,53,28,1)', + 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)' }, + shapeStroke: 'rgba(212,53,28,1)', strokeWidth: 2, - fill: 'rgba(212,53,28,0.1)', + shapeFill: 'rgba(212,53,28,0.1)', snapLayers: [], - snapColors: { - vertex: 'rgba(212,53,28,1)', - midpoint: 'rgba(40,161,151,1)', - edge: 'rgba(29,112,184,1)' - }, + snapVertex: 'rgba(212,53,28,1)', + snapMidpoint: 'rgba(40,161,151,1)', + snapEdge: 'rgba(29,112,184,1)', snapRadius: 10 } diff --git a/plugins/beta/draw-ml/src/mapboxSnap.js b/plugins/beta/draw-ml/src/mapboxSnap.js index 5bcf49c5..4f616df7 100644 --- a/plugins/beta/draw-ml/src/mapboxSnap.js +++ b/plugins/beta/draw-ml/src/mapboxSnap.js @@ -184,7 +184,7 @@ export function initMapLibreSnap (map, draw, snapOptions = {}) { } = snapOptions // Apply global patches to MapboxSnap prototype - applyMapboxSnapPatches({ ...DEFAULTS.snapColors, ...colors }) + applyMapboxSnapPatches({ vertex: DEFAULTS.snapVertex, midpoint: DEFAULTS.snapMidpoint, edge: DEFAULTS.snapEdge, ...colors }) // Clean up old snap instance's source and layer function cleanupOldSnap () { diff --git a/plugins/beta/draw-ml/src/styles.js b/plugins/beta/draw-ml/src/styles.js index 7f39ae9f..eaad6faf 100755 --- a/plugins/beta/draw-ml/src/styles.js +++ b/plugins/beta/draw-ml/src/styles.js @@ -3,11 +3,11 @@ import { DEFAULTS } from './defaults.js' const getColorScheme = (mapStyle) => mapStyle.mapColorScheme ?? 'light' -const getUserProp = (mapStyle, prop) => [ +const getUserProp = (mapStyle, prop, defaultsKey = prop) => [ 'coalesce', ['get', `user_${prop}${mapStyle.id.charAt(0).toUpperCase() + mapStyle.id.slice(1)}`], ['get', `user_${prop}`], - DEFAULTS[prop] + DEFAULTS[defaultsKey] ] // Inactive lines and fills @@ -15,7 +15,7 @@ const fillInactive = (mapStyle) => ({ id: 'fill-inactive', type: 'fill', filter: ['all', ['==', '$type', 'Polygon'], ['==', 'active', 'false']], - paint: { 'fill-color': getUserProp(mapStyle, 'fill') } + paint: { 'fill-color': getUserProp(mapStyle, 'fill', 'shapeFill') } }) const strokeInactive = (mapStyle) => ({ @@ -24,25 +24,25 @@ const strokeInactive = (mapStyle) => ({ 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'), + 'line-color': getUserProp(mapStyle, 'stroke', 'shapeStroke'), 'line-width': getUserProp(mapStyle, 'strokeWidth') } }) // Active lines and fills -const fillActive = (foregroundColor) => ({ +const fillActive = (editStrokeColor) => ({ id: 'fill-active', type: 'fill', filter: ['all', ['==', '$type', 'Polygon'], ['==', 'active', 'true']], - paint: { 'fill-color': foregroundColor, 'fill-opacity': 0.1 } + paint: { 'fill-color': editStrokeColor, 'fill-opacity': 0.1 } }) -const strokeActive = (foregroundColor) => ({ +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': foregroundColor, 'line-width': 2, 'line-opacity': 1 } + paint: { 'line-color': editStrokeColor, 'line-width': 2, 'line-opacity': 1 } }) // Splitter line @@ -72,63 +72,63 @@ const drawValidSplitter = (splitValidColor) => ({ }) // Dashed preview line -const drawPreviewLine = (foregroundColor) => ({ +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': foregroundColor, '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 } }) // Vertex layers -const vertex = (foregroundColor) => ({ +const vertex = (editVertexColor) => ({ id: 'vertex', type: 'circle', filter: ['all', ['==', '$type', 'Point'], ['==', 'meta', 'vertex']], - paint: { 'circle-radius': 6, 'circle-color': foregroundColor } + paint: { 'circle-radius': 6, 'circle-color': editVertexColor } }) -const vertexHalo = (backgroundColor, haloColor) => ({ +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': backgroundColor, 'circle-stroke-color': haloColor } + paint: { 'circle-radius': 8, 'circle-stroke-width': 3, 'circle-color': editHaloColor, 'circle-stroke-color': editActiveColor } }) -const vertexActive = (foregroundColor) => ({ +const vertexActive = (editVertexColor) => ({ id: 'vertex-active', type: 'circle', filter: ['all', ['==', '$type', 'Point'], ['==', 'meta', 'vertex'], ['==', 'active', 'true']], - paint: { 'circle-radius': 6, 'circle-color': foregroundColor } + paint: { 'circle-radius': 6, 'circle-color': editVertexColor } }) // Midpoints -const midpoint = (foregroundColor) => ({ +const midpoint = (editMidpointColor) => ({ id: 'midpoint', type: 'circle', filter: ['all', ['==', '$type', 'Point'], ['==', 'meta', 'midpoint']], - paint: { 'circle-radius': 4, 'circle-color': foregroundColor } + paint: { 'circle-radius': 4, 'circle-color': editMidpointColor } }) -const midpointHalo = (backgroundColor, haloColor) => ({ +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': backgroundColor, 'circle-stroke-color': haloColor } + paint: { 'circle-radius': 6, 'circle-stroke-width': 3, 'circle-color': editHaloColor, 'circle-stroke-color': editActiveColor } }) -const midpointActive = (foregroundColor) => ({ +const midpointActive = (editMidpointColor) => ({ id: 'midpoint-active', type: 'circle', filter: ['all', ['==', '$type', 'Point'], ['==', 'meta', 'midpoint'], ['==', 'active', 'true']], - paint: { 'circle-radius': 4, 'circle-color': foregroundColor } + paint: { 'circle-radius': 4, 'circle-color': editMidpointColor } }) -const circle = (foregroundColor) => ({ +const circle = (editStrokeColor) => ({ id: 'circle', type: 'line', filter: ['==', 'id', 'circle'], - paint: { 'line-color': foregroundColor, 'line-width': 2, 'line-opacity': 0.8 } + paint: { 'line-color': editStrokeColor, 'line-width': 2, 'line-opacity': 0.8 } }) const touchVertexIndicator = () => ({ @@ -139,27 +139,30 @@ const touchVertexIndicator = () => ({ }) const createDrawStyles = (mapStyle) => { - const foregroundColor = DEFAULTS.editColorsForeground[getColorScheme(mapStyle)] - const backgroundColor = DEFAULTS.editColorsBackground[getColorScheme(mapStyle)] - const haloColor = DEFAULTS.editColorsHalo[getColorScheme(mapStyle)] - const splitInvalidColor = DEFAULTS.splitInvalidColors[getColorScheme(mapStyle)] - const splitValidColor = DEFAULTS.splitValidColors[getColorScheme(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(foregroundColor), - strokeActive(foregroundColor), + fillActive(editStrokeColor), + strokeActive(editStrokeColor), strokeInactive(mapStyle), drawInvalidSplitter(splitInvalidColor), drawValidSplitter(splitValidColor), - drawPreviewLine(foregroundColor), - midpoint(foregroundColor), - midpointHalo(backgroundColor, haloColor), - midpointActive(foregroundColor), - vertex(foregroundColor), - vertexHalo(backgroundColor, haloColor), - vertexActive(foregroundColor), - circle(foregroundColor), + drawPreviewLine(editStrokeColor), + midpoint(editMidpointColor), + midpointHalo(editHaloColor, editActiveColor), + midpointActive(editMidpointColor), + vertex(editVertexColor), + vertexHalo(editHaloColor, editActiveColor), + vertexActive(editVertexColor), + circle(editStrokeColor), touchVertexIndicator() ] } diff --git a/plugins/beta/draw-ol/src/core/OLDrawManager.js b/plugins/beta/draw-ol/src/core/OLDrawManager.js index d346e5f7..8a8b4be8 100644 --- a/plugins/beta/draw-ol/src/core/OLDrawManager.js +++ b/plugins/beta/draw-ol/src/core/OLDrawManager.js @@ -28,7 +28,7 @@ export class OLDrawManager { this.colors = resolveColors(null, pluginConfig) this.styles = createStyles(this.colors) - this.snap = createSnapManager(map, pluginConfig.snapLayers ?? null) + this.snap = createSnapManager(map, pluginConfig.snapLayers ?? null, this.colors) this._layer = new VectorLayer({ source: this.store.source, @@ -45,6 +45,7 @@ export class OLDrawManager { 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) } diff --git a/plugins/beta/draw-ol/src/core/styles.js b/plugins/beta/draw-ol/src/core/styles.js index 0736c586..df7801f6 100644 --- a/plugins/beta/draw-ol/src/core/styles.js +++ b/plugins/beta/draw-ol/src/core/styles.js @@ -8,14 +8,14 @@ const selectedMidpointRadii = { outer: 9, mid: 6, inner: 4 } // 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) => (pixelCoordinates, state) => { +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() - ctx.beginPath(); ctx.arc(cx, cy, outer * pr, 0, Math.PI * 2); ctx.fillStyle = colors.halo; ctx.fill() - ctx.beginPath(); ctx.arc(cx, cy, mid * pr, 0, Math.PI * 2); ctx.fillStyle = colors.background; ctx.fill() - ctx.beginPath(); ctx.arc(cx, cy, inner * pr, 0, Math.PI * 2); ctx.fillStyle = colors.primary; ctx.fill() + ctx.beginPath(); ctx.arc(cx, cy, outer * pr, 0, Math.PI * 2); ctx.fillStyle = colors.editActive; ctx.fill() + ctx.beginPath(); ctx.arc(cx, cy, mid * pr, 0, Math.PI * 2); ctx.fillStyle = colors.editHalo; ctx.fill() + ctx.beginPath(); ctx.arc(cx, cy, inner * pr, 0, Math.PI * 2); ctx.fillStyle = colors[innerKey]; ctx.fill() ctx.restore() } @@ -32,35 +32,35 @@ export const createStyles = (colors) => { const vertexStyle = new Style({ image: new CircleStyle({ radius: 6, - fill: new Fill({ color: colors.primary }) + fill: new Fill({ color: colors.editVertex }) }) }) - const selectedVertexStyle = new Style({ renderer: makeRingRenderer(selectedVertexRadii, colors) }) + const selectedVertexStyle = new Style({ renderer: makeRingRenderer(selectedVertexRadii, colors, 'editVertex') }) const midpointStyle = new Style({ image: new CircleStyle({ radius: 4, - fill: new Fill({ color: colors.primary }) + fill: new Fill({ color: colors.editMidpoint }) }) }) - const selectedMidpointStyle = new Style({ renderer: makeRingRenderer(selectedMidpointRadii, colors) }) + const selectedMidpointStyle = new Style({ renderer: makeRingRenderer(selectedMidpointRadii, colors, 'editMidpoint') }) const editFeatureStyle = new Style({ - stroke: new Stroke({ color: colors.primary, width: 2 }), - fill: new Fill({ color: colors.fill }) + stroke: new Stroke({ color: colors.editStroke, width: 2 }), + fill: new Fill({ color: colors.shapeFill }) }) const sketchLineStyle = new Style({ - stroke: new Stroke({ color: colors.primary, width: 2 }), + stroke: new Stroke({ color: colors.editStroke, width: 2 }), fill: new Fill({ color: colors.sketchFill }) }) const sketchPointStyle = new Style({ image: new CircleStyle({ radius: 5, - fill: new Fill({ color: colors.primary }) + fill: new Fill({ color: colors.editVertex }) }) }) @@ -70,8 +70,8 @@ export const createStyles = (colors) => { const createFeatureStyle = () => (feature) => { const p = feature.getProperties() const id = colors.mapStyleId - const stroke = (id && p[`stroke${capitalize(id)}`]) || p.stroke || colors.stroke - const fill = (id && p[`fill${capitalize(id)}`]) || p.fill || colors.fill + 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 }), diff --git a/plugins/beta/draw-ol/src/defaults.js b/plugins/beta/draw-ol/src/defaults.js index 85255153..0291a05f 100644 --- a/plugins/beta/draw-ol/src/defaults.js +++ b/plugins/beta/draw-ol/src/defaults.js @@ -1,9 +1,13 @@ export const DEFAULTS = { - primary: { light: '#1a65a6', dark: '#ffffff' }, - halo: { light: '#000000', dark: '#ffffff' }, - background: { light: '#ffffff', dark: 'rgba(11,12,12,1)' }, - stroke: '#1a65a6', + editStroke: { light: '#1a65a6', dark: '#ffffff' }, + editVertex: { light: '#1a65a6', dark: '#ffffff' }, + editMidpoint: { light: '#1a65a6', dark: '#ffffff' }, + editActive: { light: '#000000', dark: '#ffffff' }, + editHalo: { light: '#ffffff', dark: 'rgba(11,12,12,1)' }, + shapeStroke: '#1a65a6', strokeWidth: 2, - fill: 'rgba(26,101,166,0.1)', - sketchFill: { light: 'rgba(26,101,166,0.08)', dark: 'rgba(74,158,224,0.08)' } + shapeFill: 'rgba(26,101,166,0.1)', + sketchFill: { light: 'rgba(26,101,166,0.08)', dark: 'rgba(74,158,224,0.08)' }, + snapVertex: 'rgba(212,53,28,0.5)', + snapEdge: 'rgba(29,112,184,0.5)' } diff --git a/plugins/beta/draw-ol/src/snap/snapEngine.js b/plugins/beta/draw-ol/src/snap/snapEngine.js index 4a96f3f5..6a59248d 100644 --- a/plugins/beta/draw-ol/src/snap/snapEngine.js +++ b/plugins/beta/draw-ol/src/snap/snapEngine.js @@ -30,18 +30,13 @@ export const createSnapEngine = (map, snapLayers = []) => { } } - // Lazily collected — VectorTileLayers are stable after map setup - let cachedVTLayers = null + // Collected on each query — VectorTileLayers are replaced when the map style changes const getVTLayers = () => { - if (!cachedVTLayers) { - cachedVTLayers = [] - map.getLayers().forEach(l => { - if (l instanceof VectorTileLayer) { - cachedVTLayers.push(l) - } - }) - } - return cachedVTLayers + const layers = [] + map.getLayers().forEach(l => { + if (l instanceof VectorTileLayer) layers.push(l) + }) + return layers } /** diff --git a/plugins/beta/draw-ol/src/snap/snapIndicator.js b/plugins/beta/draw-ol/src/snap/snapIndicator.js index b5c9a46e..2326bda5 100644 --- a/plugins/beta/draw-ol/src/snap/snapIndicator.js +++ b/plugins/beta/draw-ol/src/snap/snapIndicator.js @@ -2,8 +2,8 @@ * Snap indicator — a single OL VectorLayer that shows a circle at the active * snap candidate position. * - * Vertex snap → orange semi-transparent circle - * Edge snap → blue semi-transparent circle + * 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. @@ -16,8 +16,6 @@ import Point from 'ol/geom/Point.js' import { Style } from 'ol/style.js' const RADIUS_PX = 10 -const VERTEX_COLOR = 'rgba(230, 120, 0, 0.55)' -const EDGE_COLOR = 'rgba(0, 100, 220, 0.55)' const makeRenderer = (color) => (coords, state) => { const ctx = state.context @@ -28,16 +26,17 @@ const makeRenderer = (color) => (coords, state) => { ctx.fill() } -const STYLES = { - vertex: new Style({ renderer: makeRenderer(VERTEX_COLOR) }), - edge: new Style({ renderer: makeRenderer(EDGE_COLOR) }) -} +const makeStyles = (colors) => ({ + vertex: new Style({ renderer: makeRenderer(colors.snapVertex) }), + edge: new Style({ renderer: makeRenderer(colors.snapEdge) }) +}) -export const createSnapIndicator = (map) => { +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, + style: (f) => styles[f.get('snapType')] ?? null, zIndex: 200, updateWhileAnimating: true, updateWhileInteracting: true @@ -55,7 +54,7 @@ export const createSnapIndicator = (map) => { source.changed() } else { source.addFeature(feature) - showing = true + showing = true } }, @@ -67,6 +66,11 @@ export const createSnapIndicator = (map) => { showing = false }, + updateColors (newColors) { + styles = makeStyles(newColors) + if (showing) source.changed() + }, + remove () { source.clear() map.removeLayer(layer) diff --git a/plugins/beta/draw-ol/src/snap/snapManager.js b/plugins/beta/draw-ol/src/snap/snapManager.js index b422204a..4b4037d9 100644 --- a/plugins/beta/draw-ol/src/snap/snapManager.js +++ b/plugins/beta/draw-ol/src/snap/snapManager.js @@ -16,13 +16,13 @@ import { createSnapEngine, SNAP_RADIUS_PX } from './snapEngine.js' import { createSnapIndicator } from './snapIndicator.js' import { createSnapInteraction } from './snapInteraction.js' -export const createSnapManager = (map, snapLayers) => { +export const createSnapManager = (map, snapLayers, colors) => { if (!snapLayers?.length) { return null } const engine = createSnapEngine(map, snapLayers) - const indicator = createSnapIndicator(map) + const indicator = createSnapIndicator(map, colors) const interaction = createSnapInteraction(engine, indicator) map.addInteraction(interaction) @@ -70,6 +70,10 @@ export const createSnapManager = (map, snapLayers) => { map.addInteraction(interaction) }, + updateColors (newColors) { + indicator.updateColors(newColors) + }, + destroy () { map.removeInteraction(interaction) indicator.remove() diff --git a/plugins/beta/draw-ol/src/utils/resolveColors.js b/plugins/beta/draw-ol/src/utils/resolveColors.js index 5a41117a..ade46fc8 100644 --- a/plugins/beta/draw-ol/src/utils/resolveColors.js +++ b/plugins/beta/draw-ol/src/utils/resolveColors.js @@ -24,13 +24,17 @@ export const resolveColors = (mapStyle, pluginConfig = {}) => { const r = (key) => resolveVariant(pluginConfig[key] ?? DEFAULTS[key], scheme, styleId) return { - primary: r('primary'), - halo: r('halo'), - background: r('background'), - stroke: r('stroke'), + editStroke: r('editStroke'), + editVertex: r('editVertex'), + editMidpoint: r('editMidpoint'), + editActive: r('editActive'), + editHalo: r('editHalo'), + shapeStroke: r('shapeStroke'), strokeWidth: pluginConfig.strokeWidth ?? DEFAULTS.strokeWidth, - fill: r('fill'), + shapeFill: r('shapeFill'), sketchFill: r('sketchFill'), + snapVertex: r('snapVertex'), + snapEdge: r('snapEdge'), mapStyleId: styleId } } diff --git a/plugins/beta/draw-ol/src/utils/touchTarget.js b/plugins/beta/draw-ol/src/utils/touchTarget.js index ff2cb95f..663ff499 100644 --- a/plugins/beta/draw-ol/src/utils/touchTarget.js +++ b/plugins/beta/draw-ol/src/utils/touchTarget.js @@ -31,9 +31,9 @@ export const applyTouchTargetColors = (el, colors) => { if (!el) { return } - el.style.setProperty('--draw-halo', colors.halo) - el.style.setProperty('--draw-bg', colors.background) - el.style.setProperty('--draw-primary', colors.primary) + 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) => { From b1976f820a908d13069cf59a32fa914467e38d25 Mon Sep 17 00:00:00 2001 From: Dan Leech Date: Mon, 18 May 2026 17:46:25 +0100 Subject: [PATCH 16/26] Default prop order amend --- plugins/beta/draw-ml/src/defaults.js | 6 +++--- plugins/beta/draw-ol/src/defaults.js | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/plugins/beta/draw-ml/src/defaults.js b/plugins/beta/draw-ml/src/defaults.js index 41ddd95a..27dbab31 100644 --- a/plugins/beta/draw-ml/src/defaults.js +++ b/plugins/beta/draw-ml/src/defaults.js @@ -7,11 +7,11 @@ export const DEFAULTS = { 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: 'rgba(212,53,28,1)', - strokeWidth: 2, shapeFill: 'rgba(212,53,28,0.1)', - snapLayers: [], + strokeWidth: 2, snapVertex: 'rgba(212,53,28,1)', snapMidpoint: 'rgba(40,161,151,1)', snapEdge: 'rgba(29,112,184,1)', - snapRadius: 10 + snapRadius: 10, + snapLayers: [] } diff --git a/plugins/beta/draw-ol/src/defaults.js b/plugins/beta/draw-ol/src/defaults.js index 0291a05f..964e8918 100644 --- a/plugins/beta/draw-ol/src/defaults.js +++ b/plugins/beta/draw-ol/src/defaults.js @@ -5,8 +5,8 @@ export const DEFAULTS = { editActive: { light: '#000000', dark: '#ffffff' }, editHalo: { light: '#ffffff', dark: 'rgba(11,12,12,1)' }, shapeStroke: '#1a65a6', - strokeWidth: 2, shapeFill: 'rgba(26,101,166,0.1)', + strokeWidth: 2, sketchFill: { light: 'rgba(26,101,166,0.08)', dark: 'rgba(74,158,224,0.08)' }, snapVertex: 'rgba(212,53,28,0.5)', snapEdge: 'rgba(29,112,184,0.5)' From 3f324f2c7ad46cbde6841b1dfa38509d6e6a22f5 Mon Sep 17 00:00:00 2001 From: Dan Leech Date: Tue, 19 May 2026 09:01:55 +0100 Subject: [PATCH 17/26] Default colours refactor --- plugins/beta/draw-ol/src/core/OLDrawManager.js | 3 ++- plugins/beta/draw-ol/src/core/styles.js | 2 +- plugins/beta/draw-ol/src/defaults.js | 9 ++++++--- plugins/beta/draw-ol/src/edit/keyboardHandler.js | 5 ++--- plugins/beta/draw-ol/src/edit/touchHandler.js | 4 +++- plugins/beta/draw-ol/src/snap/snapInteraction.js | 5 ++--- plugins/beta/draw-ol/src/snap/snapManager.js | 12 +++++++----- plugins/beta/draw-ol/src/utils/resolveColors.js | 1 - 8 files changed, 23 insertions(+), 18 deletions(-) diff --git a/plugins/beta/draw-ol/src/core/OLDrawManager.js b/plugins/beta/draw-ol/src/core/OLDrawManager.js index 8a8b4be8..a0558392 100644 --- a/plugins/beta/draw-ol/src/core/OLDrawManager.js +++ b/plugins/beta/draw-ol/src/core/OLDrawManager.js @@ -4,6 +4,7 @@ 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. @@ -28,7 +29,7 @@ export class OLDrawManager { this.colors = resolveColors(null, pluginConfig) this.styles = createStyles(this.colors) - this.snap = createSnapManager(map, pluginConfig.snapLayers ?? null, this.colors) + this.snap = createSnapManager(map, pluginConfig.snapLayers ?? null, this.colors, pluginConfig.snapRadius ?? DEFAULTS.snapRadius) this._layer = new VectorLayer({ source: this.store.source, diff --git a/plugins/beta/draw-ol/src/core/styles.js b/plugins/beta/draw-ol/src/core/styles.js index df7801f6..74af177e 100644 --- a/plugins/beta/draw-ol/src/core/styles.js +++ b/plugins/beta/draw-ol/src/core/styles.js @@ -54,7 +54,7 @@ export const createStyles = (colors) => { const sketchLineStyle = new Style({ stroke: new Stroke({ color: colors.editStroke, width: 2 }), - fill: new Fill({ color: colors.sketchFill }) + fill: new Fill({ color: colors.shapeFill }) }) const sketchPointStyle = new Style({ diff --git a/plugins/beta/draw-ol/src/defaults.js b/plugins/beta/draw-ol/src/defaults.js index 964e8918..002e5f7e 100644 --- a/plugins/beta/draw-ol/src/defaults.js +++ b/plugins/beta/draw-ol/src/defaults.js @@ -2,12 +2,15 @@ export const DEFAULTS = { editStroke: { light: '#1a65a6', dark: '#ffffff' }, editVertex: { light: '#1a65a6', dark: '#ffffff' }, editMidpoint: { light: '#1a65a6', dark: '#ffffff' }, - editActive: { light: '#000000', 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, - sketchFill: { light: 'rgba(26,101,166,0.08)', dark: 'rgba(74,158,224,0.08)' }, snapVertex: 'rgba(212,53,28,0.5)', - snapEdge: 'rgba(29,112,184,0.5)' + snapMidpoint: 'rgba(40,161,151,1)', + snapEdge: 'rgba(29,112,184,0.5)', + snapRadius: 10 } diff --git a/plugins/beta/draw-ol/src/edit/keyboardHandler.js b/plugins/beta/draw-ol/src/edit/keyboardHandler.js index c1e65f90..28a929ef 100644 --- a/plugins/beta/draw-ol/src/edit/keyboardHandler.js +++ b/plugins/beta/draw-ol/src/edit/keyboardHandler.js @@ -1,7 +1,6 @@ import { coordToPixel, nudgeCoord } from '../utils/olCoords.js' import { spatialNavigate } from '../utils/spatial.js' import { moveVertex, insertAtMidpoint } from './vertexOps.js' -import { SNAP_RADIUS_PX } from '../snap/snapEngine.js' const ARROW_KEYS = new Set(['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown']) const NUDGE_PX = 1 @@ -126,8 +125,8 @@ export const createKeyboardHandler = ({ 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 escape = SNAP_RADIUS_PX + 1 - newCoord = nudgeCoord(map, current, dx !== 0 ? Math.sign(dx) * escape : 0, dy !== 0 ? Math.sign(dy) * escape : 0) + const escapePx = snap.snapRadius + 1 + newCoord = nudgeCoord(map, current, dx === 0 ? 0 : Math.sign(dx) * escapePx, dy === 0 ? 0 : Math.sign(dy) * escapePx) } } diff --git a/plugins/beta/draw-ol/src/edit/touchHandler.js b/plugins/beta/draw-ol/src/edit/touchHandler.js index 43117f46..ba46a6ba 100644 --- a/plugins/beta/draw-ol/src/edit/touchHandler.js +++ b/plugins/beta/draw-ol/src/edit/touchHandler.js @@ -127,7 +127,9 @@ export const createTouchHandler = ({ map, container, getState, setState, onVerte hideTouchTarget(targetEl) return } - showTouchTarget(targetEl, olToCSS(coordToPixel(map, vertecies[selectedVertexIndex]))) + const px = coordToPixel(map, vertecies[selectedVertexIndex]) + if (!px) { hideTouchTarget(targetEl); return } + showTouchTarget(targetEl, olToCSS(px)) } // Reposition on every render — keeps target anchored during pinch-zoom and pan. diff --git a/plugins/beta/draw-ol/src/snap/snapInteraction.js b/plugins/beta/draw-ol/src/snap/snapInteraction.js index 22f5a7a3..c37ad4c6 100644 --- a/plugins/beta/draw-ol/src/snap/snapInteraction.js +++ b/plugins/beta/draw-ol/src/snap/snapInteraction.js @@ -13,11 +13,10 @@ */ import Interaction from 'ol/interaction/Interaction.js' -import { SNAP_RADIUS_PX } from './snapEngine.js' const SNAP_EVENTS = new Set(['pointermove', 'pointerdrag', 'pointerdown', 'pointerup', 'singleclick', 'click']) -export const createSnapInteraction = (engine, indicator) => { +export const createSnapInteraction = (engine, indicator, snapRadius) => { const interaction = new Interaction({ handleEvent (mapBrowserEvent) { if (!interaction.getActive()) { @@ -35,7 +34,7 @@ export const createSnapInteraction = (engine, indicator) => { return true } - const result = engine.query(mapBrowserEvent.coordinate, SNAP_RADIUS_PX) + const result = engine.query(mapBrowserEvent.coordinate, snapRadius) if (result) { mapBrowserEvent.coordinate = result.coord.slice() } diff --git a/plugins/beta/draw-ol/src/snap/snapManager.js b/plugins/beta/draw-ol/src/snap/snapManager.js index 4b4037d9..41de24da 100644 --- a/plugins/beta/draw-ol/src/snap/snapManager.js +++ b/plugins/beta/draw-ol/src/snap/snapManager.js @@ -12,18 +12,18 @@ * snap.destroy() — full cleanup */ -import { createSnapEngine, SNAP_RADIUS_PX } from './snapEngine.js' +import { createSnapEngine } from './snapEngine.js' import { createSnapIndicator } from './snapIndicator.js' import { createSnapInteraction } from './snapInteraction.js' -export const createSnapManager = (map, snapLayers, colors) => { +export const createSnapManager = (map, snapLayers, colors, snapRadius) => { if (!snapLayers?.length) { return null } const engine = createSnapEngine(map, snapLayers) const indicator = createSnapIndicator(map, colors) - const interaction = createSnapInteraction(engine, indicator) + const interaction = createSnapInteraction(engine, indicator, snapRadius) map.addInteraction(interaction) interaction.setActive(false) // matches reducer initial state: snap: false @@ -36,9 +36,11 @@ export const createSnapManager = (map, snapLayers, colors) => { * 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, SNAP_RADIUS_PX) + if (!active) { return coord } + const result = engine.query(coord, snapRadius) if (result) { indicator.show(result.coord, result.type) return result.coord diff --git a/plugins/beta/draw-ol/src/utils/resolveColors.js b/plugins/beta/draw-ol/src/utils/resolveColors.js index ade46fc8..2c3dffe5 100644 --- a/plugins/beta/draw-ol/src/utils/resolveColors.js +++ b/plugins/beta/draw-ol/src/utils/resolveColors.js @@ -32,7 +32,6 @@ export const resolveColors = (mapStyle, pluginConfig = {}) => { shapeStroke: r('shapeStroke'), strokeWidth: pluginConfig.strokeWidth ?? DEFAULTS.strokeWidth, shapeFill: r('shapeFill'), - sketchFill: r('sketchFill'), snapVertex: r('snapVertex'), snapEdge: r('snapEdge'), mapStyleId: styleId From 3c0f8d05e8a5cdc8df7075f6f70dc7201d7bbeed Mon Sep 17 00:00:00 2001 From: Dan Leech Date: Tue, 19 May 2026 10:47:25 +0100 Subject: [PATCH 18/26] Code refactoring --- plugins/beta/draw-ol/src/api/editFeature.js | 21 +- .../beta/draw-ol/src/core/OLDrawManager.js | 19 +- plugins/beta/draw-ol/src/core/featureStore.js | 24 +- plugins/beta/draw-ol/src/core/styles.js | 13 +- plugins/beta/draw-ol/src/draw/DrawMode.js | 23 +- plugins/beta/draw-ol/src/draw/drawInput.js | 42 ++- plugins/beta/draw-ol/src/edit/EditMode.js | 38 +- .../beta/draw-ol/src/edit/keyboardHandler.js | 324 +++++++++--------- plugins/beta/draw-ol/src/edit/touchHandler.js | 41 ++- plugins/beta/draw-ol/src/edit/undoOps.js | 24 +- .../beta/draw-ol/src/edit/vertexHitTest.js | 20 +- plugins/beta/draw-ol/src/edit/vertexOps.js | 18 +- plugins/beta/draw-ol/src/events.js | 2 +- plugins/beta/draw-ol/src/manifest.js | 10 +- plugins/beta/draw-ol/src/reducer.js | 4 +- plugins/beta/draw-ol/src/snap/snapEngine.js | 87 +++-- plugins/beta/draw-ol/src/snap/snapGeometry.js | 147 ++++---- .../beta/draw-ol/src/snap/snapIndicator.js | 4 +- .../beta/draw-ol/src/snap/snapInteraction.js | 2 + plugins/beta/draw-ol/src/snap/snapManager.js | 8 +- .../beta/openlayers/src/utils/tileLayers.js | 2 +- 21 files changed, 491 insertions(+), 382 deletions(-) diff --git a/plugins/beta/draw-ol/src/api/editFeature.js b/plugins/beta/draw-ol/src/api/editFeature.js index 2af32b55..10bd6a24 100644 --- a/plugins/beta/draw-ol/src/api/editFeature.js +++ b/plugins/beta/draw-ol/src/api/editFeature.js @@ -7,7 +7,7 @@ * @returns {boolean} true if edit mode entered, false if feature not found */ export const editFeature = ( - { appState, appConfig, mapState, pluginConfig, pluginState, mapProvider, services }, + { appState, appConfig, pluginConfig, pluginState, mapProvider, services }, featureId, options = {} ) => { @@ -15,17 +15,23 @@ export const editFeature = ( const { draw } = mapProvider const { eventBus } = services - if (!draw) return false + if (!draw) { + return false + } - // Feature must exist before entering edit mode const existingFeature = draw.get(featureId) - if (!existingFeature) return false + if (!existingFeature) { + return false + } - const mode = existingFeature.geometry.type === 'LineString' ? 'edit_line' : 'edit_polygon' eventBus.emit('draw:editstart', { mode: 'edit_vertex' }) - // Snap layers (for later when snap is implemented) - const snapLayers = options.snapLayers ?? pluginConfig.snapLayers ?? null + const snapLayers = options.snapLayers === undefined + ? (pluginConfig.snapLayers ?? null) + : options.snapLayers + + draw.snap?.setSnapLayers(snapLayers) + dispatch({ type: 'SET_HAS_SNAP_LAYERS', payload: snapLayers?.length > 0 }) draw.changeMode('edit_vertex', { container: appState.layoutRefs.viewportRef.current, @@ -34,7 +40,6 @@ export const editFeature = ( featureId }) - // Store the feature for cancel/restore dispatch({ type: 'SET_FEATURE', payload: { feature: existingFeature, tempFeature: existingFeature } diff --git a/plugins/beta/draw-ol/src/core/OLDrawManager.js b/plugins/beta/draw-ol/src/core/OLDrawManager.js index a0558392..c596b25c 100644 --- a/plugins/beta/draw-ol/src/core/OLDrawManager.js +++ b/plugins/beta/draw-ol/src/core/OLDrawManager.js @@ -119,10 +119,21 @@ export class OLDrawManager { // --- 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() } + 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 --- diff --git a/plugins/beta/draw-ol/src/core/featureStore.js b/plugins/beta/draw-ol/src/core/featureStore.js index 76d672f9..5d3aa6e7 100644 --- a/plugins/beta/draw-ol/src/core/featureStore.js +++ b/plugins/beta/draw-ol/src/core/featureStore.js @@ -17,10 +17,17 @@ export const createFeatureStore = () => { /** 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 = source.getFeatureById(geojsonFeature.id) - if (existing) source.removeFeature(existing) + const existing = this.getOL(geojsonFeature.id) + if (existing) { + source.removeFeature(existing) + } const olFeature = format.readFeature(geojsonFeature) source.addFeature(olFeature) return olFeature @@ -28,19 +35,16 @@ export const createFeatureStore = () => { /** Get a GeoJSON feature by ID, or null. */ get (id) { - const feature = source.getFeatureById(String(id)) + const feature = this.getOL(id) return feature ? format.writeFeatureObject(feature) : null }, - /** Get the raw OL Feature by ID, or null. */ - getOL (id) { - return source.getFeatureById(String(id)) ?? null - }, - /** Remove a feature by ID. */ remove (id) { - const feature = source.getFeatureById(String(id)) - if (feature) source.removeFeature(feature) + const feature = this.getOL(id) + if (feature) { + source.removeFeature(feature) + } }, /** Remove all features. */ diff --git a/plugins/beta/draw-ol/src/core/styles.js b/plugins/beta/draw-ol/src/core/styles.js index 74af177e..d00b246b 100644 --- a/plugins/beta/draw-ol/src/core/styles.js +++ b/plugins/beta/draw-ol/src/core/styles.js @@ -6,6 +6,13 @@ 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) => { @@ -13,9 +20,9 @@ const makeRingRenderer = ({ outer, mid, inner }, colors, innerKey) => (pixelCoor const pr = state.pixelRatio const [cx, cy] = /** @type {number[]} */ (pixelCoordinates) ctx.save() - ctx.beginPath(); ctx.arc(cx, cy, outer * pr, 0, Math.PI * 2); ctx.fillStyle = colors.editActive; ctx.fill() - ctx.beginPath(); ctx.arc(cx, cy, mid * pr, 0, Math.PI * 2); ctx.fillStyle = colors.editHalo; ctx.fill() - ctx.beginPath(); ctx.arc(cx, cy, inner * pr, 0, Math.PI * 2); ctx.fillStyle = colors[innerKey]; ctx.fill() + 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() } diff --git a/plugins/beta/draw-ol/src/draw/DrawMode.js b/plugins/beta/draw-ol/src/draw/DrawMode.js index 3410b871..00f9d5d7 100644 --- a/plugins/beta/draw-ol/src/draw/DrawMode.js +++ b/plugins/beta/draw-ol/src/draw/DrawMode.js @@ -42,8 +42,8 @@ export const createDrawMode = ({ map, manager, options }) => { const geom = sketchFeature.getGeometry() const coords = getCoords({ type: geometryType, coordinates: geom.getCoordinates() }) // OL always keeps a trailing rubber-band coordinate; subtract 1 - const numVertecies = Math.max(0, coords.length - 1) - manager.emit('vertexchange', { numVertecies }) + const numVertices = Math.max(0, coords.length - 1) + manager.emit('vertexchange', { numVertices }) } drawInteraction.on('drawstart', (e) => { @@ -65,10 +65,21 @@ export const createDrawMode = ({ map, manager, options }) => { manager.emit('cancel') }) - const input = createDrawInput({ drawInteraction, manager, options: { container, interfaceType, addVertexButtonId, mapProvider, snap, onUndo: () => { - drawInteraction.removeLastPoint() - updateVertexCount() - } } }) + const input = createDrawInput({ + drawInteraction, + manager, + options: { + container, + interfaceType, + addVertexButtonId, + mapProvider, + snap, + onUndo: () => { + drawInteraction.removeLastPoint() + updateVertexCount() + } + } + }) return { done () { diff --git a/plugins/beta/draw-ol/src/draw/drawInput.js b/plugins/beta/draw-ol/src/draw/drawInput.js index 07b2d610..f84d939a 100644 --- a/plugins/beta/draw-ol/src/draw/drawInput.js +++ b/plugins/beta/draw-ol/src/draw/drawInput.js @@ -7,7 +7,6 @@ */ import { coordToPixel, pixelDist } from '../utils/olCoords.js' -import { getCoords } from '../utils/geometryHelpers.js' const SNAP_TOLERANCE = 12 // pixels const ARROW_KEYS = new Set(['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown']) @@ -19,7 +18,7 @@ const ARROW_KEYS = new Set(['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown']) * @param {object} params.options - { container, interfaceType, addVertexButtonId, mapProvider, snap } * @returns {{ destroy: () => void }} */ -export const createDrawInput = ({ drawInteraction, manager, options }) => { +export const createDrawInput = ({ drawInteraction, options }) => { const { container, addVertexButtonId, mapProvider, snap, onUndo } = options let interfaceType = options.interfaceType let map = null @@ -52,11 +51,15 @@ export const createDrawInput = ({ drawInteraction, manager, options }) => { // --- Update sketch feature with current center (rubberbanding) --- const updateSketchRubberbanding = () => { - if (!sketchFeature) return + if (!sketchFeature) { + return + } const geom = sketchFeature.getGeometry() const coords = geom.getCoordinates() - if (coords.length === 0) return + if (coords.length === 0) { + return + } const raw = mapProvider.getCenter() const centerCoord = (interfaceType !== 'pointer' && snap) ? snap.apply(raw) : raw @@ -66,9 +69,8 @@ export const createDrawInput = ({ drawInteraction, manager, options }) => { const updated = [...coords] updated[updated.length - 1] = centerCoord geom.setCoordinates(updated) - } - // For Polygon, update the last coordinate in the current ring - else if (geom.getType() === 'Polygon') { + } else if (geom.getType() === 'Polygon') { + // For Polygon, update the last coordinate in the current ring const updated = coords.map((ring, ringIdx) => { if (ringIdx === 0) { // Only update first ring (exterior) const ringUpdated = [...ring] @@ -78,24 +80,29 @@ export const createDrawInput = ({ drawInteraction, manager, options }) => { return ring }) geom.setCoordinates(updated) + } else { + // No action } } // --- Check if close enough to first vertex to close shape --- const isCloseToFirstVertex = (map, currentCoord, sketchCoords, geometryType) => { - if (geometryType !== 'Polygon' || sketchCoords.length < 4) return false + if (geometryType !== 'Polygon' || sketchCoords.length < 4) { + return false + } const firstCoord = sketchCoords[0] const currentPixel = coordToPixel(map, currentCoord) const firstPixel = coordToPixel(map, firstCoord) - if (!currentPixel || !firstPixel) return false + if (!currentPixel || !firstPixel) { + return false + } return pixelDist(currentPixel, firstPixel) < SNAP_TOLERANCE } // --- Place a vertex at the current map center (crosshair position) --- const placeVertex = () => { - const map = getMap() const raw = mapProvider.getCenter() const coord = (interfaceType !== 'pointer' && snap) ? snap.apply(raw) : raw snap?.hideIndicator() @@ -112,7 +119,7 @@ export const createDrawInput = ({ drawInteraction, manager, options }) => { } // Check if close to first vertex (for polygon closure) - if (isCloseToFirstVertex(map, coord, sketchCoords, geom.getType())) { + if (isCloseToFirstVertex(getMap(), coord, sketchCoords, geom.getType())) { drawInteraction.finishDrawing() return } @@ -123,8 +130,13 @@ export const createDrawInput = ({ drawInteraction, manager, options }) => { // --- Event handlers --- const onKeydown = (e) => { - if (!container.contains(document.activeElement)) { return } - if (ARROW_KEYS.has(e.key)) { interfaceType = 'keyboard'; return } + if (!container.contains(document.activeElement)) { + return + } + if (ARROW_KEYS.has(e.key)) { + interfaceType = 'keyboard' + return + } if (e.key === 'Enter') { e.preventDefault() interfaceType = 'keyboard' @@ -155,7 +167,9 @@ export const createDrawInput = ({ drawInteraction, manager, options }) => { } const onPointerMove = () => { - if (interfaceType === 'pointer') { return } + if (interfaceType === 'pointer') { + return + } updateSketchRubberbanding() } diff --git a/plugins/beta/draw-ol/src/edit/EditMode.js b/plugins/beta/draw-ol/src/edit/EditMode.js index f43ac6b6..fb24b90d 100644 --- a/plugins/beta/draw-ol/src/edit/EditMode.js +++ b/plugins/beta/draw-ol/src/edit/EditMode.js @@ -39,21 +39,21 @@ export const createEditMode = ({ map, manager, options }) => { olFeature, selectedVertexIndex: -1, selectedVertexType: null, - vertecies: [], + vertices: [], midpoints: [], interfaceType: interfaceType ?? 'pointer' } 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 + 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.vertecies.length : -1 + state.selectedVertexType === 'midpoint' ? state.selectedVertexIndex - state.vertices.length : -1 ) if (state.selectedVertexIndex < 0) { onDeselect?.() @@ -61,10 +61,10 @@ export const createEditMode = ({ map, manager, options }) => { updateActiveLayer() manager.emit('vertexselection', { index: state.selectedVertexType === 'vertex' ? state.selectedVertexIndex : -1, - numVertecies: state.vertecies.length + numVertices: state.vertices.length }) } - if (updates.vertecies !== undefined) { + if (updates.vertices !== undefined) { const plainGeom = { type: olFeature.getGeometry().getType(), coordinates: olFeature.getGeometry().getCoordinates() @@ -82,7 +82,7 @@ export const createEditMode = ({ map, manager, options }) => { const updateLayersFromGeom = () => { const geom = olFeature.getGeometry() const plainGeom = { type: geom.getType(), coordinates: geom.getCoordinates() } - state.vertecies = getCoords(plainGeom) + state.vertices = getCoords(plainGeom) state.midpoints = getMidpoints(plainGeom) midpointLayer.update(plainGeom) vertexLayer.update(plainGeom) @@ -91,7 +91,7 @@ export const createEditMode = ({ map, manager, options }) => { const syncGeom = () => { updateLayersFromGeom() - manager.emit('vertexchange', { numVertecies: state.vertecies.length }) + manager.emit('vertexchange', { numVertices: state.vertices.length }) manager.emit('update', store.toGeoJSON(olFeature)) } @@ -106,11 +106,11 @@ export const createEditMode = ({ map, manager, options }) => { return false } const olPixel = map.getEventPixel(mapBrowserEvent.originalEvent) - return findNearest(map, state.vertecies, state.midpoints, { x: olPixel[0], y: olPixel[1] }) !== null + 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 + 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 @@ -126,7 +126,7 @@ export const createEditMode = ({ map, manager, options }) => { if (state.interfaceType === 'touch') { return } - modifyStartCoords = state.vertecies.map(c => [...c]) + modifyStartCoords = state.vertices.map(c => [...c]) }) modifyInteraction.on('modifyend', () => { @@ -139,7 +139,7 @@ export const createEditMode = ({ map, manager, options }) => { return } - const newCoords = state.vertecies + 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]) @@ -169,16 +169,16 @@ export const createEditMode = ({ map, manager, options }) => { const updateActiveLayer = () => { activeSource.clear() - const { selectedVertexIndex, selectedVertexType, vertecies, midpoints } = state + const { selectedVertexIndex, selectedVertexType, vertices, midpoints } = state if (selectedVertexIndex < 0) { return } let coord, style if (selectedVertexType === 'vertex') { - coord = vertecies[selectedVertexIndex] + coord = vertices[selectedVertexIndex] style = manager.styles.selectedVertexStyle } else if (selectedVertexType === 'midpoint') { - coord = midpoints[selectedVertexIndex - vertecies.length] + coord = midpoints[selectedVertexIndex - vertices.length] style = manager.styles.selectedMidpointStyle } else { return @@ -214,20 +214,20 @@ export const createEditMode = ({ map, manager, options }) => { const olPixel = map.getEventPixel(e) const pixel = { x: olPixel[0], y: olPixel[1] } - const hit = findNearest(map, state.vertecies, state.midpoints, pixel) + 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.vertecies reflects any insertions/moves + // 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.vertecies, state.midpoints, pixel) + const hit = findNearest(map, state.vertices, state.midpoints, pixel) if (hit?.type === 'vertex') { setState({ selectedVertexIndex: hit.index, selectedVertexType: 'vertex' }) } else if (hit?.type === 'midpoint') { @@ -315,7 +315,7 @@ export const createEditMode = ({ map, manager, options }) => { return } if (hit.type === 'midpoint') { - const result = insertAtMidpoint(olFeature, state.midpoints, hit.index, state.vertecies.length) + const result = insertAtMidpoint(olFeature, state.midpoints, hit.index, state.vertices.length) if (!result) { return } diff --git a/plugins/beta/draw-ol/src/edit/keyboardHandler.js b/plugins/beta/draw-ol/src/edit/keyboardHandler.js index 28a929ef..5a10bde4 100644 --- a/plugins/beta/draw-ol/src/edit/keyboardHandler.js +++ b/plugins/beta/draw-ol/src/edit/keyboardHandler.js @@ -3,201 +3,197 @@ 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 -/** - * 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 = ({ - map, getState, setState, - onVertexMoved, onInserted, onDeleted, onUndo, - onKeyboardActive, snap -}) => { - let keyMoveStart = null - let keyMoveIndex = null - - const selectNearest = () => { - const { vertecies, midpoints } = getState() - if (!vertecies.length) return - - const centerCoord = map.getView().getCenter() - const centerPx = coordToPixel(map, centerCoord) - if (!centerPx) return - - const allPixels = [ - ...vertecies.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) - const type = idx < vertecies.length ? 'vertex' : 'midpoint' - setState({ selectedVertexIndex: idx, selectedVertexType: type }) +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) => { - const { selectedVertexIndex, vertecies, midpoints } = getState() - if (!vertecies.length) return - - const allCoords = [...vertecies, ...midpoints] - const allPixels = allCoords - .map(c => coordToPixel(map, c)) - .filter(Boolean) - .map(p => [p.x, p.y]) +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' }) +} - const startPx = selectedVertexIndex >= 0 - ? allPixels[selectedVertexIndex] - : (() => { const c = coordToPixel(map, map.getView().getCenter()); return c ? [c.x, c.y] : null })() +// 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 +} - if (!startPx) return +const wireNudge = ({ map, snap, getState, setState, onInserted }) => { + const keyMove = { start: null, index: null } - const idx = spatialNavigate(startPx, allPixels, direction) - const type = idx < vertecies.length ? 'vertex' : 'midpoint' - setState({ selectedVertexIndex: idx, selectedVertexType: type }) + // 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, vertecies, midpoints, olFeature } = getState() - if (!olFeature) return - + 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') { - // Insert the midpoint as a vertex, then immediately move it in the pressed direction. - // This matches the ML behaviour: midpoints stay as midpoints until actually moved. - const result = insertAtMidpoint(olFeature, midpoints, selectedVertexIndex, vertecies.length) - if (!result) return - onInserted({ insertedIndex: result.insertedIndex }) // pushes insert_vertex undo + syncGeom - - // After syncGeom in onInserted, state.vertecies is updated with the new vertex - const updatedVertecies = getState().vertecies - const insertedCoord = updatedVertecies[result.insertedIndex] - if (!insertedCoord) return - - // keyMoveStart at the midpoint position so keyup undo restores there - keyMoveStart = [...insertedCoord] - keyMoveIndex = 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', - vertecies: updatedVertecies.map((c, i) => i === result.insertedIndex ? movedCoord : c) - }) + nudgeMidpoint(olFeature, midpoints, selectedVertexIndex, vertices, dx, dy) return } - - if (selectedVertexIndex < 0 || !vertecies[selectedVertexIndex]) return - - const current = vertecies[selectedVertexIndex] - if (!keyMoveStart) { - keyMoveStart = [...current] - keyMoveIndex = selectedVertexIndex + 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) - let newCoord = snap ? snap.apply(nudgedCoord) : nudgedCoord + const snappedCoord = snap ? snap.apply(nudgedCoord) : nudgedCoord snap?.hideIndicator() - - // Escape if snap is preventing sufficient progress in the intended direction. - // Covers vertex-stuck (newCoord === current) and edge-hugging (vertex slides - // along edge instead of moving away from it). - if (snap) { - const nudgeVec = [nudgedCoord[0] - current[0], nudgedCoord[1] - current[1]] - const actualVec = [newCoord[0] - current[0], newCoord[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 - newCoord = nudgeCoord(map, current, dx === 0 ? 0 : Math.sign(dx) * escapePx, dy === 0 ? 0 : Math.sign(dy) * escapePx) - } - } - + const newCoord = resolveSnappedCoord(snap, map, current, nudgedCoord, snappedCoord, dx, dy) moveVertex(olFeature, selectedVertexIndex, newCoord) - setState({ vertecies: vertecies.map((c, i) => i === selectedVertexIndex ? newCoord : c) }) + setState({ vertices: vertices.map((c, i) => i === selectedVertexIndex ? newCoord : c) }) } - const appViewport = map.getViewport().closest('[role="application"]') ?? map.getViewport() + return { nudge, keyMove } +} - const isInteractiveElementFocused = () => { - const el = document.activeElement - if (!el || el === document.body) return false - if (appViewport.contains(el)) return false - const tag = el.tagName - return tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'BUTTON' || tag === 'SELECT' || tag === 'A' || el.isContentEditable || el.hasAttribute('tabindex') +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 onKeydown = (e) => { - if (isInteractiveElementFocused()) { return } - - if (e.key === 'Escape' && getState().selectedVertexIndex >= 0) { - e.preventDefault() - keyMoveStart = null - keyMoveIndex = null - setState({ selectedVertexIndex: -1, selectedVertexType: null }) - return - } - - onKeyboardActive?.() - - if (e.key === ' ' && getState().selectedVertexIndex < 0) { - e.preventDefault() - selectNearest() - return - } +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) - if (e.altKey && ARROW_KEYS.has(e.key)) { + const handleArrowKey = (e) => { + if (e.altKey) { e.preventDefault() e.stopPropagation() - navigateTo(e.key) - return - } - - if (!e.altKey && ARROW_KEYS.has(e.key) && getState().selectedVertexIndex >= 0) { + navigateTo(e.key, map, getState, setState) + } else if (getState().selectedVertexIndex >= 0) { e.preventDefault() e.stopPropagation() nudge(e) - return + } else { + // No action: arrow with no selection and no alt modifier } + } - if (e.key === 'z' && (e.metaKey || e.ctrlKey)) { - const tag = document.activeElement?.tagName - if (tag === 'INPUT' || tag === 'TEXTAREA') return + const handleKey = (e) => { + onKeyboardActive?.() + if (e.key === ' ' && getState().selectedVertexIndex < 0) { e.preventDefault() - e.stopPropagation() - onUndo() + 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 onKeyup = (e) => { - if (isInteractiveElementFocused()) { return } - - if (ARROW_KEYS.has(e.key) && keyMoveStart && keyMoveIndex != null) { - snap?.hideIndicator() - onVertexMoved({ vertexIndex: keyMoveIndex, previousCoord: keyMoveStart }) - keyMoveStart = null - keyMoveIndex = null + 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) + } } + } - if (e.key === 'Delete') { - onDeleted() + 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() + } } } @@ -211,3 +207,21 @@ export const createKeyboardHandler = ({ } } } + +/** + * 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-ol/src/edit/touchHandler.js b/plugins/beta/draw-ol/src/edit/touchHandler.js index ba46a6ba..e7b41ca2 100644 --- a/plugins/beta/draw-ol/src/edit/touchHandler.js +++ b/plugins/beta/draw-ol/src/edit/touchHandler.js @@ -18,10 +18,14 @@ const wireTouchEvents = ({ container, map, targetEl, olToCSS, cssToOl, getState, 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, vertecies } = getState() - const vertex = vertecies[selectedVertexIndex] - if (!vertex) { return } + 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) @@ -34,16 +38,20 @@ const wireTouchEvents = ({ container, map, targetEl, olToCSS, cssToOl, getState, } const onTouchmove = (e) => { - if (!isOnTouchTarget(e.target) || dragStartIndex == null) { return } + 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, vertecies } = getState() - if (!olFeature) { return } + const { olFeature, vertices } = getState() + if (!olFeature) { + return + } moveVertex(olFeature, dragStartIndex, newCoord) - setState({ vertecies: vertecies.map((c, i) => i === dragStartIndex ? newCoord : c) }) + setState({ vertices: vertices.map((c, i) => i === dragStartIndex ? newCoord : c) }) showTouchTarget(targetEl, olToCSS({ x: tOl[0] - targetTouchDelta.x, y: tOl[1] - targetTouchDelta.y })) } @@ -56,7 +64,7 @@ const wireTouchEvents = ({ container, map, targetEl, olToCSS, cssToOl, getState, 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.vertecies, tapState.midpoints, { x: tOl[0], y: tOl[1] }, TOUCH_TOLERANCE)) + onTap?.(findNearest(map, tapState.vertices, tapState.midpoints, { x: tOl[0], y: tOl[1] }, TOUCH_TOLERANCE)) e.preventDefault() } } @@ -64,8 +72,8 @@ const wireTouchEvents = ({ container, map, targetEl, olToCSS, cssToOl, getState, return } tapStart = null - const { vertecies } = getState() - if (vertecies[dragStartIndex] && dragStartCoord) { + const { vertices } = getState() + if (vertices[dragStartIndex] && dragStartCoord) { onVertexMoved({ vertexIndex: dragStartIndex, previousCoord: dragStartCoord }) } snap?.hideIndicator() @@ -122,13 +130,16 @@ export const createTouchHandler = ({ map, container, getState, setState, onVerte const touchEvents = wireTouchEvents({ container, map, targetEl, olToCSS, cssToOl, getState, setState, onVertexMoved, onTap, snap }) const updateTargetPosition = () => { - const { selectedVertexIndex, vertecies, interfaceType } = getState() - if (selectedVertexIndex < 0 || !vertecies[selectedVertexIndex] || interfaceType !== 'touch') { + 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 } - const px = coordToPixel(map, vertecies[selectedVertexIndex]) - if (!px) { hideTouchTarget(targetEl); return } showTouchTarget(targetEl, olToCSS(px)) } diff --git a/plugins/beta/draw-ol/src/edit/undoOps.js b/plugins/beta/draw-ol/src/edit/undoOps.js index 694b6817..0ae197ad 100644 --- a/plugins/beta/draw-ol/src/edit/undoOps.js +++ b/plugins/beta/draw-ol/src/edit/undoOps.js @@ -16,7 +16,9 @@ export const undoMoveVertex = (olFeature, op) => { const geojsonGeom = { type: geom.getType(), coordinates: geom.getCoordinates() } const segments = getRingSegments(geojsonGeom) const result = getSegmentForIndex(segments, vertexIndex) - if (!result) return -1 + if (!result) { + return -1 + } const ring = getModifiableCoords(geojsonGeom, result.segment.path) ring[result.localIdx] = [...previousCoord] @@ -33,7 +35,9 @@ 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 + if (!result) { + return -1 + } const ring = getModifiableCoords(geojsonGeom, result.segment.path) ring.splice(result.localIdx, 1) @@ -60,7 +64,9 @@ export const undoDeleteVertex = (olFeature, op) => { } } } - if (!result) return -1 + if (!result) { + return -1 + } const ring = getModifiableCoords(geojsonGeom, result.segment.path) ring.splice(result.localIdx, 0, [...deletedCoord]) @@ -77,9 +83,13 @@ export const undoDeleteVertex = (olFeature, op) => { */ 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 + 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-ol/src/edit/vertexHitTest.js b/plugins/beta/draw-ol/src/edit/vertexHitTest.js index 0ef32f75..34a991d4 100644 --- a/plugins/beta/draw-ol/src/edit/vertexHitTest.js +++ b/plugins/beta/draw-ol/src/edit/vertexHitTest.js @@ -6,18 +6,20 @@ const PIXEL_TOLERANCE = 12 * Find the nearest vertex to a screen pixel within tolerance. * * @param {import('ol/Map').default} map - * @param {number[][]} vertecies - flat coordinate array [[e,n], ...] + * @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, vertecies, pixel, tolerance = PIXEL_TOLERANCE) => { +export const findNearestVertex = (map, vertices, pixel, tolerance = PIXEL_TOLERANCE) => { let bestIdx = -1 let bestDist = tolerance - vertecies.forEach((coord, i) => { + vertices.forEach((coord, i) => { const px = coordToPixel(map, coord) - if (!px) return + if (!px) { + return + } const d = pixelDist(px, pixel) if (d < bestDist) { bestDist = d @@ -44,7 +46,9 @@ export const findNearestMidpoint = (map, midpoints, pixel, vertexCount, toleranc midpoints.forEach((coord, i) => { const px = coordToPixel(map, coord) - if (!px) return + if (!px) { + return + } const d = pixelDist(px, pixel) if (d < bestDist) { bestDist = d @@ -62,7 +66,7 @@ export const findNearestMidpoint = (map, midpoints, pixel, vertexCount, toleranc * @param {number} [tolerance] * @returns {{ index: number, type: 'vertex'|'midpoint' } | null} */ -export const findNearest = (map, vertecies, midpoints, pixel, tolerance = PIXEL_TOLERANCE) => { - return findNearestVertex(map, vertecies, pixel, tolerance) ?? - findNearestMidpoint(map, midpoints, pixel, vertecies.length, tolerance) +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-ol/src/edit/vertexOps.js b/plugins/beta/draw-ol/src/edit/vertexOps.js index 9a4c42d4..e35b9cdb 100644 --- a/plugins/beta/draw-ol/src/edit/vertexOps.js +++ b/plugins/beta/draw-ol/src/edit/vertexOps.js @@ -17,11 +17,15 @@ export const deleteVertex = (olFeature, selectedIndex) => { const coords = getCoords(geojsonGeom) const segments = getRingSegments(geojsonGeom) const result = getSegmentForIndex(segments, selectedIndex) - if (!result) return null + if (!result) { + return null + } const { segment } = result const minVertices = segment.closed ? 3 : 2 - if (segment.length <= minVertices) return null + if (segment.length <= minVertices) { + return null + } const deletedCoord = [...coords[selectedIndex]] const ring = getModifiableCoords(geojsonGeom, segment.path) @@ -41,13 +45,15 @@ export const deleteVertex = (olFeature, selectedIndex) => { * @param {number[][]} midpoints - current midpoint array (from midpointLayer) * @param {number} midpointFlatIndex - flat index (vertexCount + midpointOffset) * @param {number} vertexCount - number of actual vertices - * @param {number[][]} vertecies - current vertex array + * @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 + if (!midCoord) { + return null + } const geom = olFeature.getGeometry() const geojsonGeom = { type: geom.getType(), coordinates: geom.getCoordinates() } @@ -80,7 +86,9 @@ export const moveVertex = (olFeature, index, newCoord) => { const geojsonGeom = { type: geom.getType(), coordinates: geom.getCoordinates() } const segments = getRingSegments(geojsonGeom) const result = getSegmentForIndex(segments, index) - if (!result) return + if (!result) { + return + } const ring = getModifiableCoords(geojsonGeom, result.segment.path) ring[result.localIdx] = [...newCoord] diff --git a/plugins/beta/draw-ol/src/events.js b/plugins/beta/draw-ol/src/events.js index ef106256..ad232b75 100644 --- a/plugins/beta/draw-ol/src/events.js +++ b/plugins/beta/draw-ol/src/events.js @@ -69,7 +69,7 @@ export function attachEvents ({ pluginState, mapProvider, buttonConfig, eventBus } const onVertexChange = (e) => { - dispatch({ type: 'SET_SELECTED_VERTEX_INDEX', payload: { index: -1, numVertecies: e.numVertecies } }) + dispatch({ type: 'SET_SELECTED_VERTEX_INDEX', payload: { index: -1, numVertices: e.numVertices } }) } const onUndoChange = (length) => { diff --git a/plugins/beta/draw-ol/src/manifest.js b/plugins/beta/draw-ol/src/manifest.js index 66d6ee56..92ab136e 100644 --- a/plugins/beta/draw-ol/src/manifest.js +++ b/plugins/beta/draw-ol/src/manifest.js @@ -43,10 +43,10 @@ export const manifest = { hiddenWhen: ({ pluginState }) => !['draw_polygon', 'draw_line', 'edit_vertex'].includes(pluginState.mode), enableWhen: ({ pluginState }) => { if (pluginState.mode === 'draw_polygon') { - return pluginState.numVertecies >= 3 // NOSONAR + return pluginState.numVertices >= 3 // NOSONAR } if (pluginState.mode === 'draw_line') { - return pluginState.numVertecies >= 2 // NOSONAR + return pluginState.numVertices >= 2 // NOSONAR } if (pluginState.mode === 'edit_vertex') { return true @@ -67,11 +67,11 @@ export const manifest = { hiddenWhen: ({ pluginState }) => !['draw_polygon', 'draw_line', 'edit_vertex'].includes(pluginState.mode), enableWhen: ({ pluginState }) => { if (['draw_polygon', 'draw_line'].includes(pluginState.mode)) { - return pluginState.numVertecies > 0 + return pluginState.numVertices > 0 } return pluginState.undoStackLength > 0 } - }, { + }, { id: 'drawSnap', label: 'Snap to feature', iconId: 'magnet', @@ -81,7 +81,7 @@ export const manifest = { id: 'drawDeletePoint', label: 'Delete point', iconId: 'trash', - enableWhen: ({ pluginState }) => pluginState.selectedVertexIndex >= 0 && pluginState.numVertecies > 2, + enableWhen: ({ pluginState }) => pluginState.selectedVertexIndex >= 0 && pluginState.numVertices > 2, hiddenWhen: ({ pluginState }) => pluginState.mode !== 'edit_vertex' }], mobile: { slot: 'bottom-right' }, diff --git a/plugins/beta/draw-ol/src/reducer.js b/plugins/beta/draw-ol/src/reducer.js index 9439f177..a443d66e 100644 --- a/plugins/beta/draw-ol/src/reducer.js +++ b/plugins/beta/draw-ol/src/reducer.js @@ -3,7 +3,7 @@ const initialState = { feature: null, tempFeature: null, selectedVertexIndex: -1, - numVertecies: null, + numVertices: null, undoStackLength: 0, snap: false, hasSnapLayers: false @@ -21,7 +21,7 @@ const actions = { SET_SELECTED_VERTEX_INDEX: (state, payload) => ({ ...state, selectedVertexIndex: payload.index, - numVertecies: payload.numVertecies + numVertices: payload.numVertices }), SET_UNDO_STACK_LENGTH: (state, payload) => ({ diff --git a/plugins/beta/draw-ol/src/snap/snapEngine.js b/plugins/beta/draw-ol/src/snap/snapEngine.js index 6a59248d..b958236f 100644 --- a/plugins/beta/draw-ol/src/snap/snapEngine.js +++ b/plugins/beta/draw-ol/src/snap/snapEngine.js @@ -16,28 +16,42 @@ import VectorLayer from 'ol/layer/Vector.js' import VectorTileLayer from 'ol/layer/VectorTile.js' import { testOLFeature, testRenderFeature } from './snapGeometry.js' -export const SNAP_RADIUS_PX = 12 +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 = []) => { - const vtLayerNames = new Set() - const olLayers = [] + let vtLayerNames = new Set() + let olLayers = [] - for (const entry of snapLayers) { - if (typeof entry === 'string') { - vtLayerNames.add(entry) - } else if (entry instanceof VectorLayer) { - olLayers.push(entry) + 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 + } } } - // Collected on each query — VectorTileLayers are replaced when the map style changes - const getVTLayers = () => { - const layers = [] - map.getLayers().forEach(l => { - if (l instanceof VectorTileLayer) layers.push(l) - }) - return layers - } + setLayers(snapLayers) /** * Find the nearest snap candidate to coord within radiusPx screen pixels. @@ -61,14 +75,6 @@ export const createSnapEngine = (map, snapLayers = []) => { let best = null - const update = (r) => { - if (!r) return - if (!best) { best = r; return } - if (best.type === 'vertex' && r.type === 'edge') return - if (best.type === 'edge' && r.type === 'vertex') { best = r; return } - if (r.distSq < best.distSq) best = r - } - // --- OL VectorLayer sources --- for (const layer of olLayers) { const source = layer.getSource() @@ -76,35 +82,28 @@ export const createSnapEngine = (map, snapLayers = []) => { continue } for (const feature of source.getFeaturesInExtent(ext)) { - update(testOLFeature(feature, coord, toleranceSq)) + best = pickBest(best, testOLFeature(feature, coord, toleranceSq)) } } // --- VectorTile layers --- if (vtLayerNames.size > 0) { - const vtLayers = getVTLayers() - if (vtLayers.length > 0) { - const pixel = map.getPixelFromCoordinate(coord) - if (pixel) { - map.forEachFeatureAtPixel( - pixel, - (feature, _layer) => { - if (!vtLayerNames.has(feature.get('mapbox-layer')?.id)) { - return - } - update(testRenderFeature(feature, coord, toleranceSq)) - }, - { - hitTolerance: radiusPx, - layerFilter: (l) => vtLayers.includes(l) - } - ) - } + const vtLayers = getVTLayers(map) + const pixel = vtLayers.length > 0 ? map.getPixelFromCoordinate(coord) : null + if (pixel) { + map.forEachFeatureAtPixel( + pixel, + (feature, _layer) => { + if (!vtLayerNames.has(feature.get('mapbox-layer')?.id)) { return } + best = pickBest(best, testRenderFeature(feature, coord, toleranceSq)) + }, + { hitTolerance: radiusPx, layerFilter: (l) => vtLayers.includes(l) } + ) } } return best ? { type: best.type, coord: best.coord } : null } - return { query } + return { query, setLayers } } diff --git a/plugins/beta/draw-ol/src/snap/snapGeometry.js b/plugins/beta/draw-ol/src/snap/snapGeometry.js index 366a8230..14432ed6 100644 --- a/plugins/beta/draw-ol/src/snap/snapGeometry.js +++ b/plugins/beta/draw-ol/src/snap/snapGeometry.js @@ -22,12 +22,22 @@ const closestPointOnSegment = (p, a, b) => { 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 + 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 + if (a.type === 'edge' && b.type === 'vertex') { + return true + } + if (a.type === 'vertex' && b.type === 'edge') { + return false + } return b.distSq < a.distSq } @@ -45,10 +55,7 @@ const testCoords = (coords, query, toleranceSq, isClosedRing) => { const v = coords[i] const dSq = dist2(query, v) if (dSq <= toleranceSq) { - const candidate = { type: 'vertex', coord: [v[0], v[1]], distSq: dSq } - if (better(best, candidate)) { - best = candidate - } + best = bestOf(best, { type: 'vertex', coord: [v[0], v[1]], distSq: dSq }) } } @@ -58,10 +65,7 @@ const testCoords = (coords, query, toleranceSq, isClosedRing) => { const pt = closestPointOnSegment(query, a, b) const dSq = dist2(query, pt) if (dSq <= toleranceSq) { - const candidate = { type: 'edge', coord: pt, distSq: dSq } - if (better(best, candidate)) { - best = candidate - } + best = bestOf(best, { type: 'edge', coord: pt, distSq: dSq }) } } @@ -75,23 +79,8 @@ const testCoords = (coords, query, toleranceSq, isClosedRing) => { * isClosedRing: VTile polygon rings — first coord is NOT duplicated at end (unlike OL Vector) * so treat all coords as unique vertices and add a closing edge back to first */ -const testFlatCoords = (flat, start, end, query, toleranceSq, isClosedRing) => { +const getBestEdge = (flat, start, numPairs, edgeCount, query, toleranceSq) => { let best = null - const numPairs = (end - start) / 2 - const edgeCount = isClosedRing ? numPairs : numPairs - 1 - - 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) { - const candidate = { type: 'vertex', coord: v, distSq: dSq } - if (better(best, candidate)) { - best = candidate - } - } - } - for (let i = 0; i < edgeCount; i++) { const ai = start + i * 2 const bi = start + ((i + 1) % numPairs) * 2 @@ -100,63 +89,81 @@ const testFlatCoords = (flat, start, end, query, toleranceSq, isClosedRing) => { const pt = closestPointOnSegment(query, a, b) const dSq = dist2(query, pt) if (dSq <= toleranceSq) { - const candidate = { type: 'edge', coord: pt, distSq: dSq } - if (better(best, candidate)) { - best = candidate - } + best = bestOf(best, { type: 'edge', coord: pt, distSq: dSq }) } } - return best } -/** - * Test an OL Feature (from a VectorSource) against query coord. - * Handles Point, LineString, LinearRing, Polygon, MultiLineString, MultiPolygon. - * - * @returns {{ type: 'vertex'|'edge', coord: number[], distSq: number } | null} - */ -export const testOLFeature = (feature, query, toleranceSq) => { - const geom = feature.getGeometry() - if (!geom) { - return null - } - const type = geom.getType() +const getBestPair = (flat, start, numPairs, edgeCount, query, toleranceSq) => { let best = null - - const update = (r) => { - if (better(best, r)) { - best = r + 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 bestOf(best, getBestEdge(flat, start, numPairs, edgeCount, query, toleranceSq)) +} - if (type === 'Point') { +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) - if (dSq <= toleranceSq) { - update({ type: 'vertex', coord: [c[0], c[1]], distSq: dSq }) - } - } else if (type === 'LineString' || type === 'LinearRing') { - update(testCoords(geom.getCoordinates(), query, toleranceSq, type === 'LinearRing')) - } else if (type === 'Polygon') { + 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()) { - update(testCoords(ring, query, toleranceSq, true)) + best = bestOf(best, testCoords(ring, query, toleranceSq, true)) } - } else if (type === 'MultiLineString') { + return best + }, + MultiLineString (geom, query, toleranceSq) { + let best = null for (const line of geom.getCoordinates()) { - update(testCoords(line, query, toleranceSq, false)) + best = bestOf(best, testCoords(line, query, toleranceSq, false)) } - } else if (type === 'MultiPolygon') { + return best + }, + MultiPolygon (geom, query, toleranceSq) { + let best = null for (const polygon of geom.getCoordinates()) { for (const ring of polygon) { - update(testCoords(ring, query, toleranceSq, true)) + best = bestOf(best, testCoords(ring, query, toleranceSq, true)) } } - } else { - // No action + return best } +} - return best +/** + * Test an OL Feature (from a VectorSource) against query coord. + * Handles Point, LineString, LinearRing, Polygon, MultiLineString, MultiPolygon. + * + * @returns {{ type: 'vertex'|'edge', coord: number[], distSq: number } | null} + */ +export const testOLFeature = (feature, query, toleranceSq) => { + const geom = feature.getGeometry() + if (!geom) { + return null + } + const handler = olGeomHandlers[geom.getType()] + return handler ? handler(geom, query, toleranceSq) : null } /** @@ -170,25 +177,19 @@ export const testRenderFeature = (feature, query, toleranceSq) => { const flat = feature.getFlatCoordinates() let best = null - const update = (r) => { - if (better(best, r)) { - best = r - } - } - if (type === 'Point') { const dSq = dist2(query, flat) if (dSq <= toleranceSq) { - update({ type: 'vertex', coord: [flat[0], flat[1]], distSq: dSq }) + best = bestOf(best, { type: 'vertex', coord: [flat[0], flat[1]], distSq: dSq }) } } else if (type === 'LineString') { - update(testFlatCoords(flat, 0, flat.length, query, toleranceSq, false)) + best = bestOf(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) { - update(testFlatCoords(flat, start, end, query, toleranceSq, isClosedRing)) + best = bestOf(best, testFlatCoords(flat, start, end, query, toleranceSq, isClosedRing)) start = end } } else { diff --git a/plugins/beta/draw-ol/src/snap/snapIndicator.js b/plugins/beta/draw-ol/src/snap/snapIndicator.js index 2326bda5..de75e26f 100644 --- a/plugins/beta/draw-ol/src/snap/snapIndicator.js +++ b/plugins/beta/draw-ol/src/snap/snapIndicator.js @@ -68,7 +68,9 @@ export const createSnapIndicator = (map, colors) => { updateColors (newColors) { styles = makeStyles(newColors) - if (showing) source.changed() + if (showing) { + source.changed() + } }, remove () { diff --git a/plugins/beta/draw-ol/src/snap/snapInteraction.js b/plugins/beta/draw-ol/src/snap/snapInteraction.js index c37ad4c6..25bdfd0e 100644 --- a/plugins/beta/draw-ol/src/snap/snapInteraction.js +++ b/plugins/beta/draw-ol/src/snap/snapInteraction.js @@ -44,6 +44,8 @@ export const createSnapInteraction = (engine, indicator, snapRadius) => { result ? indicator.show(result.coord, result.type) : indicator.hide() } else if (type === 'pointerdrag') { indicator.hide() + } else { + // No action } return true diff --git a/plugins/beta/draw-ol/src/snap/snapManager.js b/plugins/beta/draw-ol/src/snap/snapManager.js index 41de24da..57479bcf 100644 --- a/plugins/beta/draw-ol/src/snap/snapManager.js +++ b/plugins/beta/draw-ol/src/snap/snapManager.js @@ -39,7 +39,9 @@ export const createSnapManager = (map, snapLayers, colors, snapRadius) => { snapRadius, apply (coord) { - if (!active) { return coord } + if (!active) { + return coord + } const result = engine.query(coord, snapRadius) if (result) { indicator.show(result.coord, result.type) @@ -67,6 +69,10 @@ export const createSnapManager = (map, snapLayers, colors, snapRadius) => { * 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) diff --git a/providers/beta/openlayers/src/utils/tileLayers.js b/providers/beta/openlayers/src/utils/tileLayers.js index 4eeb7dd1..81b85ea3 100644 --- a/providers/beta/openlayers/src/utils/tileLayers.js +++ b/providers/beta/openlayers/src/utils/tileLayers.js @@ -6,11 +6,11 @@ import MVT from 'ol/format/MVT.js' import TileGrid from 'ol/tilegrid/TileGrid.js' import TileState from 'ol/TileState.js' import { stylefunction, recordStyleLayer } from 'ol-mapbox-style' +import { TILE_GRID_RESOLUTIONS, TILE_GRID_ORIGIN, TILE_SIZE } from '../defaults.js' // Enable style-layer name recording so feature.get('mapbox-layer').id returns the // style layer name — required for snap layer filtering by style layer name. recordStyleLayer(true) -import { TILE_GRID_RESOLUTIONS, TILE_GRID_ORIGIN, TILE_SIZE } from '../defaults.js' const CRS = 'EPSG:27700' From 8a27311cb51fa0a75506215de7b13c2fd5f06a26 Mon Sep 17 00:00:00 2001 From: Dan Leech Date: Tue, 19 May 2026 10:52:16 +0100 Subject: [PATCH 19/26] Mouse wheel ruberband fix --- plugins/beta/draw-ol/src/draw/drawInput.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/plugins/beta/draw-ol/src/draw/drawInput.js b/plugins/beta/draw-ol/src/draw/drawInput.js index f84d939a..f7423e98 100644 --- a/plugins/beta/draw-ol/src/draw/drawInput.js +++ b/plugins/beta/draw-ol/src/draw/drawInput.js @@ -40,7 +40,9 @@ export const createDrawInput = ({ drawInteraction, options }) => { // Listen to view centre changes for keyboard/touch rubberbanding. // pointermove alone won't fire when arrow keys pan the map. const onCenterChange = () => { - updateSketchRubberbanding() + if (interfaceType !== 'pointer') { + updateSketchRubberbanding() + } } const olMap = drawInteraction.getMap() From e59c74ea5a60eaa2ef8ad2eb7617946751fb01aa Mon Sep 17 00:00:00 2001 From: Dan Leech Date: Tue, 19 May 2026 11:06:05 +0100 Subject: [PATCH 20/26] Keyboard and touch finish drawing fix --- plugins/beta/draw-ol/src/draw/drawInput.js | 25 +++++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/plugins/beta/draw-ol/src/draw/drawInput.js b/plugins/beta/draw-ol/src/draw/drawInput.js index f7423e98..94acd1ba 100644 --- a/plugins/beta/draw-ol/src/draw/drawInput.js +++ b/plugins/beta/draw-ol/src/draw/drawInput.js @@ -25,9 +25,19 @@ export const createDrawInput = ({ drawInteraction, options }) => { // Track the current sketch feature via draw events (OL 10 has no public getSketchFeature()) let sketchFeature = null - drawInteraction.on('drawstart', (e) => { sketchFeature = e.feature }) - drawInteraction.on('drawend', () => { sketchFeature = null }) - drawInteraction.on('drawabort', () => { 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 + }) // Get map reference — drawInteraction is already added to map before createDrawInput is called const getMap = () => { @@ -120,6 +130,13 @@ export const createDrawInput = ({ drawInteraction, options }) => { sketchCoords = rawCoords[0] || [] } + // Same position placed twice without moving → finish/close + if (lastPlacedCoord && lastPlacedCoord[0] === coord[0] && lastPlacedCoord[1] === coord[1]) { + drawInteraction.finishDrawing() + lastPlacedCoord = null + return + } + // Check if close to first vertex (for polygon closure) if (isCloseToFirstVertex(getMap(), coord, sketchCoords, geom.getType())) { drawInteraction.finishDrawing() @@ -128,6 +145,7 @@ export const createDrawInput = ({ drawInteraction, options }) => { } drawInteraction.appendCoordinates([coord]) + lastPlacedCoord = coord } // --- Event handlers --- @@ -161,6 +179,7 @@ export const createDrawInput = ({ drawInteraction, options }) => { const onPointerdown = (e) => { if (e.pointerType !== 'touch') { interfaceType = 'pointer' + lastPlacedCoord = null } } From aed6ef8887f30d96230cd79d86070eb5687fe084 Mon Sep 17 00:00:00 2001 From: Dan Leech Date: Tue, 19 May 2026 11:17:37 +0100 Subject: [PATCH 21/26] Function complexity reduced --- plugins/beta/draw-ol/src/draw/drawInput.js | 268 ++++++++++----------- 1 file changed, 125 insertions(+), 143 deletions(-) diff --git a/plugins/beta/draw-ol/src/draw/drawInput.js b/plugins/beta/draw-ol/src/draw/drawInput.js index 94acd1ba..cf00d059 100644 --- a/plugins/beta/draw-ol/src/draw/drawInput.js +++ b/plugins/beta/draw-ol/src/draw/drawInput.js @@ -11,21 +11,124 @@ import { coordToPixel, pixelDist } from '../utils/olCoords.js' const SNAP_TOLERANCE = 12 // pixels const ARROW_KEYS = new Set(['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown']) +const isCloseToFirstVertex = (map, coord, sketchCoords, geometryType) => { + if (geometryType !== 'Polygon' || sketchCoords.length < 4) { + 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, + updateRubberbanding, placeVertex +}) => { + const onCenterChange = () => { + if (getInterfaceType() !== 'pointer') { + 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('pointer') + clearLastCoord() + } + } + + const onTouchstart = () => { + setInterfaceType('touch') + } + + const onPointerMove = () => { + if (getInterfaceType() === 'pointer') { + 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) + } + } +} + /** * @param {object} params * @param {import('ol/interaction/Draw').default} params.drawInteraction - * @param {import('../core/OLDrawManager').OLDrawManager} params.manager * @param {object} params.options - { container, interfaceType, addVertexButtonId, mapProvider, snap } - * @returns {{ destroy: () => void }} + * @returns {{ getInterfaceType: () => string, destroy: () => void }} */ export const createDrawInput = ({ drawInteraction, options }) => { const { container, addVertexButtonId, mapProvider, snap, onUndo } = options let interfaceType = options.interfaceType - let map = null - - // Track the current sketch feature via draw events (OL 10 has no public getSketchFeature()) let sketchFeature = null let lastPlacedCoord = null + drawInteraction.on('drawstart', (e) => { sketchFeature = e.feature lastPlacedCoord = null @@ -39,179 +142,58 @@ export const createDrawInput = ({ drawInteraction, options }) => { lastPlacedCoord = null }) - // Get map reference — drawInteraction is already added to map before createDrawInput is called - const getMap = () => { - if (!map) { - map = drawInteraction.getMap() - } - return map - } - - // Listen to view centre changes for keyboard/touch rubberbanding. - // pointermove alone won't fire when arrow keys pan the map. - const onCenterChange = () => { - if (interfaceType !== 'pointer') { - updateSketchRubberbanding() - } - } - - const olMap = drawInteraction.getMap() - const olView = olMap?.getView() - if (olView) { - olView.on('change:center', onCenterChange) - } - - // --- Update sketch feature with current center (rubberbanding) --- - const updateSketchRubberbanding = () => { + const updateRubberbanding = () => { if (!sketchFeature) { return } - const geom = sketchFeature.getGeometry() const coords = geom.getCoordinates() - if (coords.length === 0) { + if (!coords.length) { return } - const raw = mapProvider.getCenter() const centerCoord = (interfaceType !== 'pointer' && snap) ? snap.apply(raw) : raw - - // For LineString, update the last (rubber-band) coordinate - if (geom.getType() === 'LineString') { - const updated = [...coords] - updated[updated.length - 1] = centerCoord - geom.setCoordinates(updated) - } else if (geom.getType() === 'Polygon') { - // For Polygon, update the last coordinate in the current ring - const updated = coords.map((ring, ringIdx) => { - if (ringIdx === 0) { // Only update first ring (exterior) - const ringUpdated = [...ring] - ringUpdated[ringUpdated.length - 1] = centerCoord - return ringUpdated - } - return ring - }) - geom.setCoordinates(updated) - } else { - // No action - } - } - - // --- Check if close enough to first vertex to close shape --- - const isCloseToFirstVertex = (map, currentCoord, sketchCoords, geometryType) => { - if (geometryType !== 'Polygon' || sketchCoords.length < 4) { - return false - } - - const firstCoord = sketchCoords[0] - const currentPixel = coordToPixel(map, currentCoord) - const firstPixel = coordToPixel(map, firstCoord) - - if (!currentPixel || !firstPixel) { - return false - } - return pixelDist(currentPixel, firstPixel) < SNAP_TOLERANCE + applyRubberbanding(geom, centerCoord) } - // --- Place a vertex at the current map center (crosshair position) --- const placeVertex = () => { const raw = mapProvider.getCenter() const coord = (interfaceType !== 'pointer' && snap) ? snap.apply(raw) : raw snap?.hideIndicator() - if (sketchFeature) { const geom = sketchFeature.getGeometry() const rawCoords = geom.getCoordinates() - - // For Polygon: rawCoords is array of rings, get exterior ring coords - // For LineString: rawCoords is the coord array directly - let sketchCoords = rawCoords - if (geom.getType() === 'Polygon') { - sketchCoords = rawCoords[0] || [] - } - - // Same position placed twice without moving → finish/close + const sketchCoords = geom.getType() === 'Polygon' ? (rawCoords[0] || []) : rawCoords if (lastPlacedCoord && lastPlacedCoord[0] === coord[0] && lastPlacedCoord[1] === coord[1]) { drawInteraction.finishDrawing() lastPlacedCoord = null return } - - // Check if close to first vertex (for polygon closure) - if (isCloseToFirstVertex(getMap(), coord, sketchCoords, geom.getType())) { + if (isCloseToFirstVertex(drawInteraction.getMap(), coord, sketchCoords, geom.getType())) { drawInteraction.finishDrawing() return } } - drawInteraction.appendCoordinates([coord]) lastPlacedCoord = coord } - // --- Event handlers --- - const onKeydown = (e) => { - if (!container.contains(document.activeElement)) { - return - } - if (ARROW_KEYS.has(e.key)) { - interfaceType = 'keyboard' - return - } - if (e.key === 'Enter') { - e.preventDefault() - interfaceType = 'keyboard' - placeVertex() - } - if (e.key === 'z' && (e.metaKey || e.ctrlKey)) { - e.preventDefault() - onUndo?.() - } - } - - // Button click covers both Add Point button and any element inside it - const onButtonClick = (e) => { - if (addVertexButtonId && e.target.closest(`#${addVertexButtonId}`)) { - placeVertex() - } - } - - // Track interface type so DrawMode can show/hide crosshair correctly - const onPointerdown = (e) => { - if (e.pointerType !== 'touch') { - interfaceType = 'pointer' - lastPlacedCoord = null - } - } - - const onTouchstart = () => { - interfaceType = 'touch' - } - - const onPointerMove = () => { - if (interfaceType === 'pointer') { - return - } - updateSketchRubberbanding() - } - - window.addEventListener('keydown', onKeydown) - window.addEventListener('click', onButtonClick) - container.addEventListener('pointerdown', onPointerdown) - container.addEventListener('touchstart', onTouchstart, { passive: true }) - container.addEventListener('pointermove', onPointerMove) + const events = wireInputEvents({ + container, + addVertexButtonId, + olView: drawInteraction.getMap()?.getView(), + onUndo, + getInterfaceType: () => interfaceType, + setInterfaceType: (t) => { interfaceType = t }, + clearLastCoord: () => { lastPlacedCoord = null }, + updateRubberbanding, + placeVertex + }) return { getInterfaceType: () => interfaceType, - destroy () { - if (olView) { - olView.un('change:center', onCenterChange) - } - window.removeEventListener('keydown', onKeydown) - window.removeEventListener('click', onButtonClick) - container.removeEventListener('pointerdown', onPointerdown) - container.removeEventListener('touchstart', onTouchstart) - container.removeEventListener('pointermove', onPointerMove) + events.destroy() } } } From 7ea7b7bf07469361f52bc5ef8123781a98f00e52 Mon Sep 17 00:00:00 2001 From: Dan Leech Date: Tue, 19 May 2026 11:31:05 +0100 Subject: [PATCH 22/26] Drag vertex delete fix --- plugins/beta/draw-ol/src/edit/EditMode.js | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/plugins/beta/draw-ol/src/edit/EditMode.js b/plugins/beta/draw-ol/src/edit/EditMode.js index fb24b90d..208acc80 100644 --- a/plugins/beta/draw-ol/src/edit/EditMode.js +++ b/plugins/beta/draw-ol/src/edit/EditMode.js @@ -282,13 +282,18 @@ export const createEditMode = ({ map, manager, options }) => { if (!op) { return } - const newIndex = applyUndo(olFeature, op) + 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 }) - onUpdate?.() + if (previousIndex >= 0 && newIndex >= 0) { + onUpdate?.() + } } // --- Touch handler --- @@ -302,6 +307,7 @@ export const createEditMode = ({ map, manager, options }) => { onVertexMoved ({ vertexIndex, previousCoord }) { undoStack.push({ type: 'move_vertex', vertexIndex, previousCoord }) syncGeom() + setState({ selectedVertexIndex: vertexIndex, selectedVertexType: 'vertex' }) touchHandler.updateTargetPosition() }, onTap (hit) { @@ -353,6 +359,7 @@ export const createEditMode = ({ map, manager, options }) => { 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 }) From f41ad16df94a5623c4247bb1309446f13a5cadc3 Mon Sep 17 00:00:00 2001 From: Dan Leech Date: Tue, 19 May 2026 11:37:23 +0100 Subject: [PATCH 23/26] Minor consistentcy fix --- plugins/beta/draw-ml/src/reducer.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/plugins/beta/draw-ml/src/reducer.js b/plugins/beta/draw-ml/src/reducer.js index ade5296d..9497306f 100755 --- a/plugins/beta/draw-ml/src/reducer.js +++ b/plugins/beta/draw-ml/src/reducer.js @@ -11,10 +11,13 @@ const initialState = { undoStackLength: 0 } +const DRAW_MODES = new Set(['draw_polygon', 'draw_line']) + const setMode = (state, payload) => { return { ...state, - mode: payload + mode: payload, + numVertecies: DRAW_MODES.has(payload) ? 0 : state.numVertecies } } From c7fbd7d3e7652c8f98e3df5c46f9cc68214670c5 Mon Sep 17 00:00:00 2001 From: Dan Leech Date: Tue, 19 May 2026 11:44:09 +0100 Subject: [PATCH 24/26] Demo line spacing fix --- demo/DemoMapMarkerPanel.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/demo/DemoMapMarkerPanel.js b/demo/DemoMapMarkerPanel.js index 8dee04fe..aaf7d6b4 100644 --- a/demo/DemoMapMarkerPanel.js +++ b/demo/DemoMapMarkerPanel.js @@ -51,7 +51,7 @@ function MapInner () { map.addPanel(PANEL_ID, { focus: false, label: 'Marker', - html: '

Information about the selected marker

', + html: '

Information about the selected marker

', mobile: { slot: 'drawer', dismissible: true }, tablet: { slot: 'left-top', dismissible: true, width: '280px' }, desktop: { slot: 'left-top', dismissible: true, width: '280px' } From 060ac28c3a20fd4e47feed7be34aad0d0415c03f Mon Sep 17 00:00:00 2001 From: Dan Leech Date: Tue, 19 May 2026 11:47:17 +0100 Subject: [PATCH 25/26] Test fix --- providers/beta/openlayers/src/utils/tileLayers.test.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/providers/beta/openlayers/src/utils/tileLayers.test.js b/providers/beta/openlayers/src/utils/tileLayers.test.js index b24a9b48..96fcf79e 100644 --- a/providers/beta/openlayers/src/utils/tileLayers.test.js +++ b/providers/beta/openlayers/src/utils/tileLayers.test.js @@ -2,7 +2,7 @@ import XYZ from 'ol/source/XYZ.js' import TileGrid from 'ol/tilegrid/TileGrid.js' import VectorTileSource from 'ol/source/VectorTile.js' import VectorTileLayer from 'ol/layer/VectorTile.js' -import { stylefunction } from 'ol-mapbox-style' +import { stylefunction, recordStyleLayer } from 'ol-mapbox-style' import { createTileSource, createVectorTileLayer } from './tileLayers.js' import { TILE_GRID_RESOLUTIONS, TILE_GRID_ORIGIN, TILE_SIZE } from '../defaults.js' @@ -18,7 +18,7 @@ jest.mock('ol/TileState.js', () => ({ __esModule: true, default: { ERROR: 'error jest.mock('ol/source/VectorTile.js', () => ({ __esModule: true, default: jest.fn(() => mockVectorTileSourceInstance) })) jest.mock('ol/layer/VectorTile.js', () => ({ __esModule: true, default: jest.fn(() => mockVectorTileLayerInstance) })) jest.mock('ol/format/MVT.js', () => ({ __esModule: true, default: jest.fn(() => mockMVTInstance) })) -jest.mock('ol-mapbox-style', () => ({ __esModule: true, stylefunction: jest.fn() })) +jest.mock('ol-mapbox-style', () => ({ __esModule: true, stylefunction: jest.fn(), recordStyleLayer: jest.fn() })) const flushPromises = () => new Promise(resolve => setTimeout(resolve, 0)) From e49479ae30969cd97785da515267c7e4e1ededbb Mon Sep 17 00:00:00 2001 From: Dan Leech Date: Tue, 19 May 2026 11:55:40 +0100 Subject: [PATCH 26/26] Lint fix --- providers/beta/openlayers/src/utils/tileLayers.test.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/providers/beta/openlayers/src/utils/tileLayers.test.js b/providers/beta/openlayers/src/utils/tileLayers.test.js index 96fcf79e..9bf77102 100644 --- a/providers/beta/openlayers/src/utils/tileLayers.test.js +++ b/providers/beta/openlayers/src/utils/tileLayers.test.js @@ -2,7 +2,7 @@ import XYZ from 'ol/source/XYZ.js' import TileGrid from 'ol/tilegrid/TileGrid.js' import VectorTileSource from 'ol/source/VectorTile.js' import VectorTileLayer from 'ol/layer/VectorTile.js' -import { stylefunction, recordStyleLayer } from 'ol-mapbox-style' +import { stylefunction } from 'ol-mapbox-style' import { createTileSource, createVectorTileLayer } from './tileLayers.js' import { TILE_GRID_RESOLUTIONS, TILE_GRID_ORIGIN, TILE_SIZE } from '../defaults.js'