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' }
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..9ba5530a
--- /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 { vtsMapStyles27700, 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: 22,
+ autoColorScheme: true,
+ center: [337584, 504538],
+ zoom: 14,
+ containerHeight: '650px',
+ transformRequest: transformVtsRequest27700,
+ enableZoomControls: true,
+ // readMapText: true,
+ plugins: [
+ mapStylesPlugin({
+ mapStyles: vtsMapStyles27700 // 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) {
+ interactiveMap.toggleButtonState('geometryActions', 'hidden', true)
+})
+
+interactiveMap.on('draw:editstart', function (e) {
+ interactiveMap.toggleButtonState('geometryActions', 'hidden', true)
+})
+
+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/demo/js/mapStyles.js b/demo/js/mapStyles.js
index 39c0d2d6..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,
@@ -117,6 +120,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 +133,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/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-ml/src/defaults.js b/plugins/beta/draw-ml/src/defaults.js
index eba47f70..27dbab31 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)',
+ shapeFill: 'rgba(212,53,28,0.1)',
strokeWidth: 2,
- fill: '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)'
- },
- snapRadius: 10
+ snapVertex: 'rgba(212,53,28,1)',
+ snapMidpoint: 'rgba(40,161,151,1)',
+ snapEdge: 'rgba(29,112,184,1)',
+ snapRadius: 10,
+ snapLayers: []
}
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/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/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-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
}
}
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/DrawInit.jsx b/plugins/beta/draw-ol/src/DrawInit.jsx
new file mode 100644
index 00000000..5a4e2ec1
--- /dev/null
+++ b/plugins/beta/draw-ol/src/DrawInit.jsx
@@ -0,0 +1,67 @@
+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 undefined
+ }
+
+ 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()
+ }, [mapState.isMapReady, appState.mode])
+
+ // Show crosshair when entering draw mode on touch/keyboard
+ useEffect(() => {
+ 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 undefined
+ }
+ mapProvider.draw.setInterfaceType(appState.interfaceType)
+ return undefined
+ }, [appState.interfaceType, pluginState.mode])
+
+ // Re-attach events when state changes
+ useEffect(() => {
+ if (!mapProvider.draw) {
+ return undefined
+ }
+
+ return attachEvents({
+ appState,
+ appConfig,
+ mapState,
+ mapProvider,
+ buttonConfig,
+ pluginState,
+ events: EVENTS,
+ eventBus
+ })
+ }, [mapProvider, appState, pluginState])
+}
diff --git a/plugins/beta/draw-ol/src/api/addFeature.js b/plugins/beta/draw-ol/src/api/addFeature.js
new file mode 100644
index 00000000..fa1c2109
--- /dev/null
+++ b/plugins/beta/draw-ol/src/api/addFeature.js
@@ -0,0 +1,29 @@
+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.
+ *
+ * @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
+ }
+
+ const { stroke, fill, strokeWidth, properties, ...featureRest } = feature
+ const flatFeature = {
+ ...featureRest,
+ properties: {
+ ...properties,
+ ...flattenStyleProperties({ stroke, fill, strokeWidth })
+ }
+ }
+
+ draw.add(flatFeature)
+ eventBus.emit('draw:add', flatFeature)
+}
diff --git a/plugins/beta/draw-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..10bd6a24
--- /dev/null
+++ b/plugins/beta/draw-ol/src/api/editFeature.js
@@ -0,0 +1,50 @@
+/**
+ * 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, pluginConfig, pluginState, mapProvider, services },
+ featureId,
+ options = {}
+) => {
+ const { dispatch } = pluginState
+ const { draw } = mapProvider
+ const { eventBus } = services
+
+ if (!draw) {
+ return false
+ }
+
+ const existingFeature = draw.get(featureId)
+ if (!existingFeature) {
+ return false
+ }
+
+ eventBus.emit('draw:editstart', { mode: 'edit_vertex' })
+
+ 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,
+ deleteVertexButtonId: `${appConfig.id}-draw-delete-point`,
+ interfaceType: appState.interfaceType,
+ featureId
+ })
+
+ 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..c07b863b
--- /dev/null
+++ b/plugins/beta/draw-ol/src/api/newLine.js
@@ -0,0 +1,44 @@
+import { flattenStyleProperties } from '../utils/flattenStyleProperties.js'
+
+/**
+ * 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, mapState, pluginState, mapProvider, services },
+ featureId,
+ options = {}
+) => {
+ const { dispatch } = pluginState
+ const { draw } = mapProvider
+ const { eventBus } = services
+
+ if (!draw) {
+ return
+ }
+
+ eventBus.emit('draw:started', { mode: 'draw_line' })
+
+ const { stroke, fill, strokeWidth, properties: customProperties, ...modeOptions } = options
+ const properties = {
+ ...customProperties,
+ ...flattenStyleProperties({ stroke, fill, strokeWidth })
+ }
+
+ draw.changeMode('draw_line', {
+ container: appState.layoutRefs.viewportRef.current,
+ interfaceType: appState.interfaceType,
+ addVertexButtonId: `${appConfig.id}-draw-add-point`,
+ featureId,
+ geometryType: 'LineString',
+ properties,
+ mapProvider,
+ crossHair: mapState.crossHair,
+ ...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..0e3becf3
--- /dev/null
+++ b/plugins/beta/draw-ol/src/api/newPolygon.js
@@ -0,0 +1,44 @@
+import { flattenStyleProperties } from '../utils/flattenStyleProperties.js'
+
+/**
+ * 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, mapState, pluginState, mapProvider, services },
+ featureId,
+ options = {}
+) => {
+ const { dispatch } = pluginState
+ const { draw } = mapProvider
+ const { eventBus } = services
+
+ if (!draw) {
+ return
+ }
+
+ eventBus.emit('draw:started', { mode: 'draw_polygon' })
+
+ const { stroke, fill, strokeWidth, properties: customProperties, ...modeOptions } = options
+ const properties = {
+ ...customProperties,
+ ...flattenStyleProperties({ stroke, fill, strokeWidth })
+ }
+
+ draw.changeMode('draw_polygon', {
+ container: appState.layoutRefs.viewportRef.current,
+ interfaceType: appState.interfaceType,
+ addVertexButtonId: `${appConfig.id}-draw-add-point`,
+ featureId,
+ geometryType: 'Polygon',
+ properties,
+ mapProvider,
+ crossHair: mapState.crossHair,
+ ...modeOptions
+ })
+
+ dispatch({ type: 'SET_MODE', payload: 'draw_polygon' })
+}
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..c596b25c
--- /dev/null
+++ b/plugins/beta/draw-ol/src/core/OLDrawManager.js
@@ -0,0 +1,149 @@
+import VectorLayer from 'ol/layer/Vector.js'
+import { createFeatureStore } from './featureStore.js'
+import { createUndoStack } from './undoStack.js'
+import { createStyles } from './styles.js'
+import { resolveColors } from '../utils/resolveColors.js'
+import { createSnapManager } from '../snap/snapManager.js'
+import { DEFAULTS } from '../defaults.js'
+
+/**
+ * Mode machine for the OL draw plugin.
+ *
+ * Owns the VectorSource/Layer, undo stack, and current mode instance.
+ * Exposes a minimal on/off/emit event bus for internal plugin communication
+ * (separate from the public eventBus used for consumer-facing events).
+ *
+ * Consumer-facing events are always emitted via eventBus by events.js after
+ * listening to the manager's internal events.
+ */
+export class OLDrawManager {
+ constructor (map, pluginConfig = {}) {
+ this._map = map
+ this._pluginConfig = pluginConfig
+ this._mode = 'disabled'
+ this._modeInstance = null
+ this._listeners = new Map()
+
+ this.store = createFeatureStore()
+ this.undoStack = createUndoStack((length) => this.emit('undochange', length))
+
+ this.colors = resolveColors(null, pluginConfig)
+ this.styles = createStyles(this.colors)
+ this.snap = createSnapManager(map, pluginConfig.snapLayers ?? null, this.colors, pluginConfig.snapRadius ?? DEFAULTS.snapRadius)
+
+ this._layer = new VectorLayer({
+ source: this.store.source,
+ style: this.styles.createFeatureStyle(),
+ zIndex: 100
+ })
+ map.addLayer(this._layer)
+ }
+
+ // --- Color / style updates ---
+
+ setMapStyle (mapStyle) {
+ this.colors = resolveColors(mapStyle, this._pluginConfig)
+ this.styles = createStyles(this.colors)
+ this._layer.setStyle(this.styles.createFeatureStyle())
+ this.store.source.changed()
+ this.snap?.updateColors(this.colors)
+ this.emit('styleschanged', this.styles)
+ }
+
+ // --- Internal event bus ---
+
+ on (type, handler) {
+ if (!this._listeners.has(type)) {
+ this._listeners.set(type, new Set())
+ }
+ this._listeners.get(type).add(handler)
+ }
+
+ off (type, handler) {
+ this._listeners.get(type)?.delete(handler)
+ }
+
+ emit (type, detail) {
+ this._listeners.get(type)?.forEach(h => h(detail))
+ }
+
+ // --- Mode machine ---
+
+ async changeMode (modeName, options = {}) {
+ this._modeInstance?.destroy()
+ 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: modeOptions })
+ } else if (modeName === 'edit_vertex') {
+ const { createEditMode } = await import('../edit/EditMode.js')
+ this._modeInstance = createEditMode({ map: this._map, manager: this, options: modeOptions })
+ } else {
+ // disabled — no mode instance needed
+ }
+ // Reattach snap interaction after mode's interactions are added so it
+ // processes pointermove first (OL: last-added interaction = first to handle events).
+ this.snap?.reattach()
+ }
+
+ getMode () {
+ return this._mode
+ }
+
+ // --- High-level operations called by events.js ---
+
+ done () {
+ this._modeInstance?.done()
+ }
+
+ cancel () {
+ this._modeInstance?.cancel()
+ this.changeMode('disabled')
+ }
+
+ undo () {
+ this._modeInstance?.undo()
+ }
+
+ deleteVertex () {
+ this._modeInstance?.deleteVertex()
+ }
+
+ setInterfaceType (type) {
+ this._modeInstance?.setInterfaceType?.(type)
+ }
+
+ // --- Feature store delegation ---
+
+ get (id) {
+ return this.store.get(id)
+ }
+
+ add (geojsonFeature) {
+ return this.store.add(geojsonFeature)
+ }
+
+ delete (id) {
+ return this.store.remove(id)
+ }
+
+ deleteAll () {
+ return this.store.clear()
+ }
+
+ // --- Cleanup ---
+
+ remove () {
+ this._modeInstance?.destroy()
+ this._modeInstance = null
+ this.snap?.destroy()
+ this.snap = null
+ this.store.clear()
+ this._map.removeLayer(this._layer)
+ this._listeners.clear()
+ }
+}
diff --git a/plugins/beta/draw-ol/src/core/featureStore.js b/plugins/beta/draw-ol/src/core/featureStore.js
new file mode 100644
index 00000000..5d3aa6e7
--- /dev/null
+++ b/plugins/beta/draw-ol/src/core/featureStore.js
@@ -0,0 +1,65 @@
+import VectorSource from 'ol/source/Vector.js'
+import GeoJSON from 'ol/format/GeoJSON.js'
+
+const PROJECTION = 'EPSG:27700'
+
+// No coordinate transformation: data and map both use BNG.
+const format = new GeoJSON({ dataProjection: PROJECTION, featureProjection: PROJECTION })
+
+/**
+ * Wraps an OL VectorSource with a GeoJSON-oriented API keyed by feature ID.
+ * All GeoJSON in and out uses EPSG:27700 coordinates (BNG easting/northing).
+ */
+export const createFeatureStore = () => {
+ const source = new VectorSource()
+
+ return {
+ /** The underlying VectorSource, passed to OL interactions and layers. */
+ source,
+
+ /** Get the raw OL Feature by ID, or null. */
+ getOL (id) {
+ return source.getFeatureById(String(id)) ?? null
+ },
+
+ /** Add or replace a GeoJSON feature. Returns the OL Feature. */
+ add (geojsonFeature) {
+ const existing = this.getOL(geojsonFeature.id)
+ if (existing) {
+ source.removeFeature(existing)
+ }
+ const olFeature = format.readFeature(geojsonFeature)
+ source.addFeature(olFeature)
+ return olFeature
+ },
+
+ /** Get a GeoJSON feature by ID, or null. */
+ get (id) {
+ const feature = this.getOL(id)
+ return feature ? format.writeFeatureObject(feature) : null
+ },
+
+ /** Remove a feature by ID. */
+ remove (id) {
+ const feature = this.getOL(id)
+ if (feature) {
+ source.removeFeature(feature)
+ }
+ },
+
+ /** Remove all features. */
+ clear () {
+ source.clear()
+ },
+
+ /** Convert an OL Feature to a GeoJSON object. */
+ toGeoJSON (olFeature) {
+ return format.writeFeatureObject(olFeature)
+ },
+
+ /** Convert a GeoJSON object to an OL Feature (no side effects). */
+ fromGeoJSON (geojsonFeature) {
+ return format.readFeature(geojsonFeature)
+ }
+ }
+}
diff --git a/plugins/beta/draw-ol/src/core/styles.js b/plugins/beta/draw-ol/src/core/styles.js
new file mode 100644
index 00000000..d00b246b
--- /dev/null
+++ b/plugins/beta/draw-ol/src/core/styles.js
@@ -0,0 +1,98 @@
+import Style from 'ol/style/Style.js'
+import Fill from 'ol/style/Fill.js'
+import Stroke from 'ol/style/Stroke.js'
+import CircleStyle from 'ol/style/Circle.js'
+
+const selectedVertexRadii = { outer: 11, mid: 8, inner: 6 }
+const selectedMidpointRadii = { outer: 9, mid: 6, inner: 4 }
+
+const fillArc = (ctx, cx, cy, radius, fillStyle) => {
+ ctx.beginPath()
+ ctx.arc(cx, cy, radius, 0, Math.PI * 2)
+ ctx.fillStyle = fillStyle
+ ctx.fill()
+}
+
+// Custom renderer draws all arcs at the same (cx,cy) so concentric rings never
+// drift at fractional CSS scales (e.g. 1.5×) the way separate drawImage calls can.
+const makeRingRenderer = ({ outer, mid, inner }, colors, innerKey) => (pixelCoordinates, state) => {
+ const ctx = state.context
+ const pr = state.pixelRatio
+ const [cx, cy] = /** @type {number[]} */ (pixelCoordinates)
+ ctx.save()
+ fillArc(ctx, cx, cy, outer * pr, colors.editActive)
+ fillArc(ctx, cx, cy, mid * pr, colors.editHalo)
+ fillArc(ctx, cx, cy, inner * pr, colors[innerKey])
+ ctx.restore()
+}
+
+const capitalize = (s) => s.charAt(0).toUpperCase() + s.slice(1)
+
+/**
+ * Create all draw-ol style instances for the given resolved color set.
+ *
+ * @param {object} colors - Output of resolveColors()
+ * @returns {{ vertexStyle, selectedVertexStyle, midpointStyle, selectedMidpointStyle,
+ * editFeatureStyle, createSketchStyle, createFeatureStyle }}
+ */
+export const createStyles = (colors) => {
+ const vertexStyle = new Style({
+ image: new CircleStyle({
+ radius: 6,
+ fill: new Fill({ color: colors.editVertex })
+ })
+ })
+
+ const selectedVertexStyle = new Style({ renderer: makeRingRenderer(selectedVertexRadii, colors, 'editVertex') })
+
+ const midpointStyle = new Style({
+ image: new CircleStyle({
+ radius: 4,
+ fill: new Fill({ color: colors.editMidpoint })
+ })
+ })
+
+ const selectedMidpointStyle = new Style({ renderer: makeRingRenderer(selectedMidpointRadii, colors, 'editMidpoint') })
+
+ const editFeatureStyle = new Style({
+ stroke: new Stroke({ color: colors.editStroke, width: 2 }),
+ fill: new Fill({ color: colors.shapeFill })
+ })
+
+ const sketchLineStyle = new Style({
+ stroke: new Stroke({ color: colors.editStroke, width: 2 }),
+ fill: new Fill({ color: colors.shapeFill })
+ })
+
+ const sketchPointStyle = new Style({
+ image: new CircleStyle({
+ radius: 5,
+ fill: new Fill({ color: colors.editVertex })
+ })
+ })
+
+ const createSketchStyle = () => (feature) =>
+ feature.getGeometry().getType() === 'Point' ? [sketchPointStyle] : [sketchLineStyle]
+
+ const createFeatureStyle = () => (feature) => {
+ const p = feature.getProperties()
+ const id = colors.mapStyleId
+ const stroke = (id && p[`stroke${capitalize(id)}`]) || p.stroke || colors.shapeStroke
+ const fill = (id && p[`fill${capitalize(id)}`]) || p.fill || colors.shapeFill
+ const strokeWidth = p.strokeWidth || colors.strokeWidth
+ return [new Style({
+ stroke: new Stroke({ color: stroke, width: strokeWidth }),
+ fill: new Fill({ color: fill })
+ })]
+ }
+
+ return {
+ vertexStyle,
+ selectedVertexStyle,
+ midpointStyle,
+ selectedMidpointStyle,
+ editFeatureStyle,
+ createSketchStyle,
+ createFeatureStyle
+ }
+}
diff --git a/plugins/beta/draw-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/defaults.js b/plugins/beta/draw-ol/src/defaults.js
new file mode 100644
index 00000000..002e5f7e
--- /dev/null
+++ b/plugins/beta/draw-ol/src/defaults.js
@@ -0,0 +1,16 @@
+export const DEFAULTS = {
+ editStroke: { light: '#1a65a6', dark: '#ffffff' },
+ editVertex: { light: '#1a65a6', dark: '#ffffff' },
+ editMidpoint: { light: '#1a65a6', dark: '#ffffff' },
+ editHalo: { light: '#ffffff', dark: 'rgba(11,12,12,1)' },
+ editActive: { light: '#000000', dark: '#ffffff' },
+ splitInvalid: { light: 'rgba(29,112,184,1)', dark: 'rgba(29,112,184,1)' },
+ splitValid: { light: 'rgba(29,112,184,1)', dark: 'rgba(29,112,184,1)' },
+ shapeStroke: '#1a65a6',
+ shapeFill: 'rgba(26,101,166,0.1)',
+ strokeWidth: 2,
+ snapVertex: 'rgba(212,53,28,0.5)',
+ snapMidpoint: 'rgba(40,161,151,1)',
+ snapEdge: 'rgba(29,112,184,0.5)',
+ snapRadius: 10
+}
diff --git a/plugins/beta/draw-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
new file mode 100644
index 00000000..00f9d5d7
--- /dev/null
+++ b/plugins/beta/draw-ol/src/draw/DrawMode.js
@@ -0,0 +1,113 @@
+import Draw from 'ol/interaction/Draw.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,
+ snap
+ } = options
+
+ 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)
+
+ // 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 numVertices = Math.max(0, coords.length - 1)
+ manager.emit('vertexchange', { numVertices })
+ }
+
+ 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,
+ snap,
+ onUndo: () => {
+ drawInteraction.removeLastPoint()
+ updateVertexCount()
+ }
+ }
+ })
+
+ 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..cf00d059
--- /dev/null
+++ b/plugins/beta/draw-ol/src/draw/drawInput.js
@@ -0,0 +1,199 @@
+/**
+ * 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.
+ */
+
+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 {object} params.options - { container, interfaceType, addVertexButtonId, mapProvider, snap }
+ * @returns {{ getInterfaceType: () => string, destroy: () => void }}
+ */
+export const createDrawInput = ({ drawInteraction, options }) => {
+ const { container, addVertexButtonId, mapProvider, snap, onUndo } = options
+ let interfaceType = options.interfaceType
+ let sketchFeature = null
+ let lastPlacedCoord = null
+
+ drawInteraction.on('drawstart', (e) => {
+ sketchFeature = e.feature
+ lastPlacedCoord = null
+ })
+ drawInteraction.on('drawend', () => {
+ sketchFeature = null
+ lastPlacedCoord = null
+ })
+ drawInteraction.on('drawabort', () => {
+ sketchFeature = null
+ lastPlacedCoord = null
+ })
+
+ const updateRubberbanding = () => {
+ if (!sketchFeature) {
+ return
+ }
+ const geom = sketchFeature.getGeometry()
+ const coords = geom.getCoordinates()
+ if (!coords.length) {
+ return
+ }
+ const raw = mapProvider.getCenter()
+ const centerCoord = (interfaceType !== 'pointer' && snap) ? snap.apply(raw) : raw
+ applyRubberbanding(geom, centerCoord)
+ }
+
+ 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()
+ const sketchCoords = geom.getType() === 'Polygon' ? (rawCoords[0] || []) : rawCoords
+ if (lastPlacedCoord && lastPlacedCoord[0] === coord[0] && lastPlacedCoord[1] === coord[1]) {
+ drawInteraction.finishDrawing()
+ lastPlacedCoord = null
+ return
+ }
+ if (isCloseToFirstVertex(drawInteraction.getMap(), coord, sketchCoords, geom.getType())) {
+ drawInteraction.finishDrawing()
+ return
+ }
+ }
+ drawInteraction.appendCoordinates([coord])
+ lastPlacedCoord = coord
+ }
+
+ 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 () {
+ events.destroy()
+ }
+ }
+}
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..208acc80
--- /dev/null
+++ b/plugins/beta/draw-ol/src/edit/EditMode.js
@@ -0,0 +1,424 @@
+import Modify from 'ol/interaction/Modify.js'
+import Collection from 'ol/Collection.js'
+import VectorSource from 'ol/source/Vector.js'
+import VectorLayer from 'ol/layer/Vector.js'
+import Feature from 'ol/Feature.js'
+import Point from 'ol/geom/Point.js'
+import { createMidpointLayer } from './midpointLayer.js'
+import { createVertexLayer } from './vertexLayer.js'
+import { createTouchHandler } from './touchHandler.js'
+import { createKeyboardHandler } from './keyboardHandler.js'
+import { findNearest } from './vertexHitTest.js'
+import { deleteVertex, insertAtMidpoint } from './vertexOps.js'
+import { applyUndo } from './undoOps.js'
+import { getCoords, getMidpoints } from '../utils/geometryHelpers.js'
+
+/**
+ * Edit vertex mode — handles edit_vertex.
+ *
+ * OL Modify handles pointer/mouse vertex dragging natively.
+ * touchHandler covers touch drag via the SVG offset target.
+ * keyboardHandler covers keyboard navigation and nudging.
+ *
+ * @returns {{ done, cancel, undo, deleteVertex: fn, destroy }}
+ */
+export const createEditMode = ({ map, manager, options }) => {
+ const { featureId, container, interfaceType, deleteVertexButtonId, snap } = options
+ const { store, undoStack } = manager
+
+ const olFeature = store.getOL(featureId)
+ if (!olFeature) {
+ return null
+ }
+
+ const originalFeatureStyle = olFeature.getStyle()
+ olFeature.setStyle(manager.styles.editFeatureStyle)
+
+ // Mutable state shared across sub-handlers
+ const state = {
+ olFeature,
+ selectedVertexIndex: -1,
+ selectedVertexType: null,
+ vertices: [],
+ midpoints: [],
+ interfaceType: interfaceType ?? '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) {
+ vertexLayer.setSelected(state.selectedVertexType === 'vertex' ? state.selectedVertexIndex : -1)
+ midpointLayer.setSelected(
+ state.selectedVertexType === 'midpoint' ? state.selectedVertexIndex - state.vertices.length : -1
+ )
+ if (state.selectedVertexIndex < 0) {
+ onDeselect?.()
+ }
+ updateActiveLayer()
+ manager.emit('vertexselection', {
+ index: state.selectedVertexType === 'vertex' ? state.selectedVertexIndex : -1,
+ numVertices: state.vertices.length
+ })
+ }
+ if (updates.vertices !== undefined) {
+ const plainGeom = {
+ type: olFeature.getGeometry().getType(),
+ coordinates: olFeature.getGeometry().getCoordinates()
+ }
+ midpointLayer.update(plainGeom)
+ vertexLayer.update(plainGeom)
+ state.midpoints = midpointLayer.getCoords()
+ updateActiveLayer()
+ onUpdate?.()
+ map.render()
+ }
+ }
+
+ // Lightweight per-frame update during drag — updates layers without emitting events
+ const updateLayersFromGeom = () => {
+ const geom = olFeature.getGeometry()
+ const plainGeom = { type: geom.getType(), coordinates: geom.getCoordinates() }
+ state.vertices = getCoords(plainGeom)
+ state.midpoints = getMidpoints(plainGeom)
+ midpointLayer.update(plainGeom)
+ vertexLayer.update(plainGeom)
+ updateActiveLayer()
+ }
+
+ const syncGeom = () => {
+ updateLayersFromGeom()
+ manager.emit('vertexchange', { numVertices: state.vertices.length })
+ manager.emit('update', store.toGeoJSON(olFeature))
+ }
+
+ // Keep overlay layers in sync on every geometry change (e.g. during pointer drag)
+ const onGeometryChange = () => updateLayersFromGeom()
+ olFeature.getGeometry().on('change', onGeometryChange)
+
+ // --- OL Modify (handles pointer vertex drag + midpoint insertion natively) ---
+ const collection = new Collection([olFeature])
+ const modifyCondition = (mapBrowserEvent) => {
+ if (state.interfaceType === 'touch') {
+ return false
+ }
+ const olPixel = map.getEventPixel(mapBrowserEvent.originalEvent)
+ return findNearest(map, state.vertices, state.midpoints, { x: olPixel[0], y: olPixel[1] }) !== null
+ }
+ const modifyInteraction = new Modify({
+ features: collection,
+ style: () => [], // vertex circles rendered by vertexLayer instead
+ pixelTolerance: 12,
+ // Only activate when clicking on a vertex or midpoint circle, not anywhere on a segment.
+ // Touch drags are handled by touchHandler; returning false here lets them pass through to
+ // DragPan (touchHandler uses preventDefault on the offset target to stop unwanted panning).
+ condition: modifyCondition
+ })
+ map.addInteraction(modifyInteraction)
+
+ // Track move start for undo
+ let modifyStartCoords = null
+
+ modifyInteraction.on('modifystart', () => {
+ if (state.interfaceType === 'touch') {
+ return
+ }
+ modifyStartCoords = state.vertices.map(c => [...c])
+ })
+
+ modifyInteraction.on('modifyend', () => {
+ if (state.interfaceType === 'touch') {
+ return
+ }
+ const prevCoords = modifyStartCoords
+ syncGeom()
+ if (!prevCoords) {
+ return
+ }
+
+ const newCoords = state.vertices
+ if (newCoords.length > prevCoords.length) {
+ // Midpoint drag inserted a vertex — find it and select it
+ const insertedIdx = newCoords.findIndex((c, i) => !prevCoords[i] || c[0] !== prevCoords[i][0])
+ const idx = Math.max(0, insertedIdx)
+ undoStack.push({ type: 'insert_vertex', vertexIndex: idx })
+ setState({ selectedVertexIndex: idx, selectedVertexType: 'vertex' })
+ } else if (newCoords.length === prevCoords.length) {
+ const movedIdx = newCoords.findIndex((c, i) => c[0] !== prevCoords[i][0] || c[1] !== prevCoords[i][1])
+ if (movedIdx >= 0) {
+ undoStack.push({ type: 'move_vertex', vertexIndex: movedIdx, previousCoord: prevCoords[movedIdx] })
+ setState({ selectedVertexIndex: movedIdx, selectedVertexType: 'vertex' })
+ }
+ } else {
+ // no change in vertex count (shouldn't happen, but satisfies linter)
+ }
+ modifyStartCoords = null
+ })
+
+ // --- Vertex + midpoint layers (always-visible handles) ---
+ const midpointLayer = createMidpointLayer(map, manager.styles.midpointStyle)
+ const vertexLayer = createVertexLayer(map, manager.styles.vertexStyle)
+
+ // --- Active selection overlay — always on top of vertex and midpoint layers ---
+ const activeSource = new VectorSource()
+ const activeLayer = new VectorLayer({ source: activeSource, zIndex: 103 })
+ map.addLayer(activeLayer)
+
+ const updateActiveLayer = () => {
+ activeSource.clear()
+ const { selectedVertexIndex, selectedVertexType, vertices, midpoints } = state
+ if (selectedVertexIndex < 0) {
+ return
+ }
+ let coord, style
+ if (selectedVertexType === 'vertex') {
+ coord = vertices[selectedVertexIndex]
+ style = manager.styles.selectedVertexStyle
+ } else if (selectedVertexType === 'midpoint') {
+ coord = midpoints[selectedVertexIndex - vertices.length]
+ style = manager.styles.selectedMidpointStyle
+ } else {
+ return
+ }
+ if (!coord) {
+ return
+ }
+ const f = new Feature({ geometry: new Point(coord) })
+ f.setStyle(style)
+ activeSource.addFeature(f)
+ }
+
+ syncGeom() // initial populate
+
+ // --- Style hot-swap when map style changes ---
+ const onStylesChanged = (styles) => {
+ olFeature.setStyle(styles.editFeatureStyle)
+ vertexLayer.updateStyle(styles.vertexStyle)
+ midpointLayer.updateStyle(styles.midpointStyle)
+ updateActiveLayer()
+ touchHandler.updateColors(manager.colors)
+ }
+ manager.on('styleschanged', onStylesChanged)
+
+ // --- Pointer hit detection ---
+ const onPointerdown = (e) => {
+ if (e.pointerType === 'touch') {
+ state.interfaceType = 'touch'
+ touchHandler.updateTargetPosition()
+ return
+ }
+ state.interfaceType = 'pointer'
+
+ const olPixel = map.getEventPixel(e)
+ const pixel = { x: olPixel[0], y: olPixel[1] }
+ const hit = findNearest(map, state.vertices, state.midpoints, pixel)
+ if (hit?.type === 'vertex') {
+ setState({ selectedVertexIndex: hit.index, selectedVertexType: 'vertex' })
+ }
+ }
+
+ // click fires after OL Modify finishes, so state.vertices reflects any insertions/moves
+ const onContainerClick = (e) => {
+ if (state.interfaceType === 'touch') {
+ return
+ }
+ const olPixel = map.getEventPixel(e)
+ const pixel = { x: olPixel[0], y: olPixel[1] }
+ const hit = findNearest(map, state.vertices, state.midpoints, pixel)
+ if (hit?.type === 'vertex') {
+ setState({ selectedVertexIndex: hit.index, selectedVertexType: 'vertex' })
+ } else if (hit?.type === 'midpoint') {
+ // modifyend already selected the inserted vertex — nothing to do here
+ } else {
+ setState({ selectedVertexIndex: -1, selectedVertexType: null })
+ }
+ }
+
+ // Switch to pointer mode and hide the touch target as soon as the mouse moves.
+ const onPointerMove = (e) => {
+ if (e.pointerType !== 'mouse') {
+ return
+ }
+ if (state.interfaceType === '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) ---
+ const onButtonClick = (e) => {
+ if (deleteVertexButtonId && e.target.closest(`#${deleteVertexButtonId}`)) {
+ doDeleteVertex()
+ }
+ }
+ globalThis.addEventListener('click', onButtonClick)
+
+ // --- Operations ---
+
+ const doDeleteVertex = () => {
+ if (state.selectedVertexType !== 'vertex' || state.selectedVertexIndex < 0) {
+ return
+ }
+ const result = deleteVertex(olFeature, state.selectedVertexIndex)
+ if (!result) {
+ return
+ }
+ undoStack.push({ type: 'delete_vertex', vertexIndex: result.deletedIndex, deletedCoord: result.deletedCoord })
+ syncGeom()
+ setState({ selectedVertexIndex: -1, selectedVertexType: null })
+ }
+
+ const doUndo = () => {
+ const op = undoStack.pop()
+ if (!op) {
+ return
+ }
+ const previousIndex = state.selectedVertexIndex
+ const restoredIndex = applyUndo(olFeature, op)
+ syncGeom()
+ // Only re-select if a vertex was already active — undo must not create a new selection
+ const newIndex = previousIndex >= 0 ? restoredIndex : -1
+ setState({
+ selectedVertexIndex: newIndex,
+ selectedVertexType: newIndex >= 0 ? 'vertex' : null
+ })
+ if (previousIndex >= 0 && newIndex >= 0) {
+ onUpdate?.()
+ }
+ }
+
+ // --- Touch handler ---
+ const touchHandler = createTouchHandler({
+ map,
+ container,
+ getState,
+ setState,
+ colors: manager.colors,
+ snap,
+ onVertexMoved ({ vertexIndex, previousCoord }) {
+ undoStack.push({ type: 'move_vertex', vertexIndex, previousCoord })
+ syncGeom()
+ setState({ selectedVertexIndex: vertexIndex, selectedVertexType: 'vertex' })
+ touchHandler.updateTargetPosition()
+ },
+ onTap (hit) {
+ if (!hit) {
+ setState({ selectedVertexIndex: -1, selectedVertexType: null })
+ return
+ }
+ if (hit.type === 'vertex') {
+ setState({ selectedVertexIndex: hit.index, selectedVertexType: 'vertex' })
+ touchHandler.updateTargetPosition()
+ return
+ }
+ if (hit.type === 'midpoint') {
+ const result = insertAtMidpoint(olFeature, state.midpoints, hit.index, state.vertices.length)
+ if (!result) {
+ return
+ }
+ undoStack.push({ type: 'insert_vertex', vertexIndex: result.insertedIndex })
+ syncGeom()
+ setState({ selectedVertexIndex: result.insertedIndex, selectedVertexType: 'vertex' })
+ touchHandler.updateTargetPosition()
+ }
+ }
+ })
+ onDeselect = () => touchHandler.hide()
+ onUpdate = () => {
+ if (state.interfaceType === 'touch') {
+ touchHandler.updateTargetPosition()
+ }
+ }
+
+ // Reposition the touch target after OL re-renders with the new size.
+ // change:size fires before the render, so we wait for postrender to get
+ // correct pixel coords from getPixelFromCoordinate.
+ const onMapSizeChange = () => {
+ if (state.interfaceType !== 'touch' || state.selectedVertexIndex < 0) {
+ return
+ }
+ map.once('postrender', () => touchHandler.updateTargetPosition())
+ }
+ map.on('change:size', onMapSizeChange)
+
+ // --- Keyboard handler ---
+ const keyboardHandler = createKeyboardHandler({
+ map,
+ getState,
+ setState,
+ snap,
+ onVertexMoved ({ vertexIndex, previousCoord }) {
+ undoStack.push({ type: 'move_vertex', vertexIndex, previousCoord })
+ syncGeom()
+ setState({ selectedVertexIndex: vertexIndex, selectedVertexType: 'vertex' })
+ },
+ onInserted ({ insertedIndex }) {
+ undoStack.push({ type: 'insert_vertex', vertexIndex: insertedIndex })
+ syncGeom()
+ },
+ onDeleted: doDeleteVertex,
+ onUndo: doUndo,
+ onKeyboardActive () {
+ if (state.interfaceType === 'keyboard') {
+ return
+ }
+ state.interfaceType = 'keyboard'
+ touchHandler.hide()
+ container.focus({ preventScroll: true })
+ }
+ })
+
+ return {
+ setInterfaceType (type) {
+ if (type === state.interfaceType) {
+ return
+ }
+ state.interfaceType = type
+ if (type === 'touch') {
+ touchHandler.updateTargetPosition()
+ } else {
+ touchHandler.hide()
+ }
+ },
+
+ done () {
+ manager.emit('editfinish', store.toGeoJSON(olFeature))
+ },
+
+ cancel () {
+ // Restore original feature from store (re-read from initial state)
+ // The original was stored as tempFeature in reducer — events.js handles restore
+ },
+
+ undo: doUndo,
+ deleteVertex: doDeleteVertex,
+
+ destroy () {
+ olFeature.setStyle(originalFeatureStyle)
+ olFeature.getGeometry().un('change', onGeometryChange)
+ manager.off('styleschanged', onStylesChanged)
+ container.removeEventListener('pointerdown', onPointerdown)
+ container.removeEventListener('pointerenter', onPointerMove)
+ container.removeEventListener('pointermove', onPointerMove)
+ container.removeEventListener('click', onContainerClick)
+ globalThis.removeEventListener('click', onButtonClick)
+ map.un('change:size', onMapSizeChange)
+ map.removeInteraction(modifyInteraction)
+ activeSource.clear()
+ map.removeLayer(activeLayer)
+ midpointLayer.remove()
+ vertexLayer.remove()
+ touchHandler.destroy()
+ keyboardHandler.destroy()
+ }
+ }
+}
diff --git a/plugins/beta/draw-ol/src/edit/keyboardHandler.js b/plugins/beta/draw-ol/src/edit/keyboardHandler.js
new file mode 100644
index 00000000..5a10bde4
--- /dev/null
+++ b/plugins/beta/draw-ol/src/edit/keyboardHandler.js
@@ -0,0 +1,227 @@
+import { coordToPixel, nudgeCoord } from '../utils/olCoords.js'
+import { spatialNavigate } from '../utils/spatial.js'
+import { moveVertex, insertAtMidpoint } from './vertexOps.js'
+
+const ARROW_KEYS = new Set(['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'])
+const INTERACTIVE_TAGS = new Set(['INPUT', 'TEXTAREA', 'BUTTON', 'SELECT', 'A'])
+const NUDGE_PX = 1
+const STEP_PX = 5
+
+const selectNearest = (map, getState, setState) => {
+ const { vertices, midpoints } = getState()
+ if (!vertices.length) {
+ return
+ }
+ const centerCoord = map.getView().getCenter()
+ const centerPx = coordToPixel(map, centerCoord)
+ if (!centerPx) {
+ return
+ }
+ const allPixels = [
+ ...vertices.map(c => coordToPixel(map, c)),
+ ...midpoints.map(c => coordToPixel(map, c))
+ ].filter(Boolean).map(p => [p.x, p.y])
+ const idx = spatialNavigate([centerPx.x, centerPx.y], allPixels, undefined)
+ setState({ selectedVertexIndex: idx, selectedVertexType: idx < vertices.length ? 'vertex' : 'midpoint' })
+}
+
+const navigateTo = (direction, map, getState, setState) => {
+ const { selectedVertexIndex, vertices, midpoints } = getState()
+ if (!vertices.length) {
+ return
+ }
+ const allCoords = [...vertices, ...midpoints]
+ const allPixels = allCoords.map(c => coordToPixel(map, c)).filter(Boolean).map(p => [p.x, p.y])
+ const startPx = selectedVertexIndex >= 0
+ ? allPixels[selectedVertexIndex]
+ : (() => {
+ const c = coordToPixel(map, map.getView().getCenter())
+ return c ? [c.x, c.y] : null
+ })()
+ if (!startPx) {
+ return
+ }
+ const idx = spatialNavigate(startPx, allPixels, direction)
+ setState({ selectedVertexIndex: idx, selectedVertexType: idx < vertices.length ? 'vertex' : 'midpoint' })
+}
+
+// Returns snappedCoord, but escapes snap if it prevents sufficient progress in the nudge direction.
+// Covers vertex-stuck (snap holds position) and edge-hugging (vertex slides along edge).
+const resolveSnappedCoord = (snap, map, current, nudgedCoord, snappedCoord, dx, dy) => {
+ if (!snap) {
+ return snappedCoord
+ }
+ const nudgeVec = [nudgedCoord[0] - current[0], nudgedCoord[1] - current[1]]
+ const actualVec = [snappedCoord[0] - current[0], snappedCoord[1] - current[1]]
+ const nudgeLenSq = nudgeVec[0] ** 2 + nudgeVec[1] ** 2
+ const dot = actualVec[0] * nudgeVec[0] + actualVec[1] * nudgeVec[1]
+ if (nudgeLenSq > 0 && dot / nudgeLenSq < 0.5) {
+ const escapePx = snap.snapRadius + 1
+ return nudgeCoord(map, current, dx === 0 ? 0 : Math.sign(dx) * escapePx, dy === 0 ? 0 : Math.sign(dy) * escapePx)
+ }
+ return snappedCoord
+}
+
+const wireNudge = ({ map, snap, getState, setState, onInserted }) => {
+ const keyMove = { start: null, index: null }
+
+ // Insert the midpoint as a vertex then move it — midpoints stay as midpoints until actually moved.
+ const nudgeMidpoint = (olFeature, midpoints, selectedVertexIndex, vertices, dx, dy) => {
+ const result = insertAtMidpoint(olFeature, midpoints, selectedVertexIndex, vertices.length)
+ if (!result) {
+ return
+ }
+ onInserted({ insertedIndex: result.insertedIndex }) // pushes insert_vertex undo + syncGeom
+ const updatedVertices = getState().vertices
+ const insertedCoord = updatedVertices[result.insertedIndex]
+ if (!insertedCoord) {
+ return
+ }
+ keyMove.start = [...insertedCoord]
+ keyMove.index = result.insertedIndex
+ const nudgedCoord = nudgeCoord(map, insertedCoord, dx, dy)
+ const movedCoord = snap ? snap.apply(nudgedCoord) : nudgedCoord
+ moveVertex(olFeature, result.insertedIndex, movedCoord)
+ setState({
+ selectedVertexIndex: result.insertedIndex,
+ selectedVertexType: 'vertex',
+ vertices: updatedVertices.map((c, i) => i === result.insertedIndex ? movedCoord : c)
+ })
+ }
+
+ const nudge = (e) => {
+ const { selectedVertexIndex, selectedVertexType, vertices, midpoints, olFeature } = getState()
+ if (!olFeature) {
+ return
+ }
+ const step = e.shiftKey ? NUDGE_PX : STEP_PX
+ const offsets = { ArrowUp: [0, -step], ArrowDown: [0, step], ArrowLeft: [-step, 0], ArrowRight: [step, 0] }
+ const [dx, dy] = offsets[e.key]
+ if (selectedVertexType === 'midpoint') {
+ nudgeMidpoint(olFeature, midpoints, selectedVertexIndex, vertices, dx, dy)
+ return
+ }
+ if (selectedVertexIndex < 0 || !vertices[selectedVertexIndex]) {
+ return
+ }
+ const current = vertices[selectedVertexIndex]
+ if (!keyMove.start) {
+ keyMove.start = [...current]
+ keyMove.index = selectedVertexIndex
+ }
+ const nudgedCoord = nudgeCoord(map, current, dx, dy)
+ const snappedCoord = snap ? snap.apply(nudgedCoord) : nudgedCoord
+ snap?.hideIndicator()
+ const newCoord = resolveSnappedCoord(snap, map, current, nudgedCoord, snappedCoord, dx, dy)
+ moveVertex(olFeature, selectedVertexIndex, newCoord)
+ setState({ vertices: vertices.map((c, i) => i === selectedVertexIndex ? newCoord : c) })
+ }
+
+ return { nudge, keyMove }
+}
+
+const isInteractiveElementFocused = (appViewport) => {
+ const el = document.activeElement
+ if (!el || el === document.body) {
+ return false
+ }
+ if (appViewport.contains(el)) {
+ return false
+ }
+ const tag = el.tagName
+ return INTERACTIVE_TAGS.has(tag) || el.isContentEditable || el.hasAttribute('tabindex')
+}
+
+const wireKeyboardEvents = ({ map, snap, getState, setState, onVertexMoved, onInserted, onDeleted, onUndo, onKeyboardActive }) => {
+ const { nudge, keyMove } = wireNudge({ map, snap, getState, setState, onInserted })
+ const appViewport = map.getViewport().closest('[role="application"]') ?? map.getViewport()
+ const isFocused = () => isInteractiveElementFocused(appViewport)
+
+ const handleArrowKey = (e) => {
+ if (e.altKey) {
+ e.preventDefault()
+ e.stopPropagation()
+ navigateTo(e.key, map, getState, setState)
+ } else if (getState().selectedVertexIndex >= 0) {
+ e.preventDefault()
+ e.stopPropagation()
+ nudge(e)
+ } else {
+ // No action: arrow with no selection and no alt modifier
+ }
+ }
+
+ const handleKey = (e) => {
+ onKeyboardActive?.()
+ if (e.key === ' ' && getState().selectedVertexIndex < 0) {
+ e.preventDefault()
+ selectNearest(map, getState, setState)
+ } else if (ARROW_KEYS.has(e.key)) {
+ handleArrowKey(e)
+ } else if (e.key === 'z' && (e.metaKey || e.ctrlKey)) {
+ const tag = document.activeElement?.tagName
+ if (!INTERACTIVE_TAGS.has(tag)) {
+ e.preventDefault()
+ e.stopPropagation()
+ onUndo()
+ }
+ } else {
+ // No action
+ }
+ }
+
+ const onKeydown = (e) => {
+ if (!isFocused()) {
+ if (e.key === 'Escape' && getState().selectedVertexIndex >= 0) {
+ e.preventDefault()
+ keyMove.start = null
+ keyMove.index = null
+ setState({ selectedVertexIndex: -1, selectedVertexType: null })
+ } else {
+ handleKey(e)
+ }
+ }
+ }
+
+ const onKeyup = (e) => {
+ if (!isFocused()) {
+ if (ARROW_KEYS.has(e.key) && keyMove.start && keyMove.index != null) {
+ snap?.hideIndicator()
+ onVertexMoved({ vertexIndex: keyMove.index, previousCoord: keyMove.start })
+ keyMove.start = null
+ keyMove.index = null
+ }
+ if (e.key === 'Delete') {
+ onDeleted()
+ }
+ }
+ }
+
+ window.addEventListener('keydown', onKeydown, { capture: true })
+ window.addEventListener('keyup', onKeyup, { capture: true })
+
+ return {
+ destroy () {
+ window.removeEventListener('keydown', onKeydown, { capture: true })
+ window.removeEventListener('keyup', onKeyup, { capture: true })
+ }
+ }
+}
+
+/**
+ * Keyboard handler for edit mode.
+ *
+ * Space — select nearest vertex or midpoint to crosshair (only when nothing selected)
+ * Alt+Arrow — navigate to next vertex/midpoint in that direction (requires selection)
+ * Arrow — move selected vertex; if midpoint selected, inserts it as a vertex and moves it
+ * Shift+Arrow — same but fine nudge (1px vs 5px)
+ * Delete — delete selected vertex (no-op on midpoints)
+ * Ctrl/Cmd+Z — undo
+ *
+ * Midpoints remain midpoints until moved — navigating to a midpoint (Space/Alt+Arrow) does not
+ * convert it. Only pressing a plain/Shift arrow converts it.
+ *
+ * @param {{ map, container, getState, setState, onVertexMoved, onInserted, onDeleted, onUndo }}
+ * @returns {{ destroy }}
+ */
+export const createKeyboardHandler = (options) => wireKeyboardEvents(options)
diff --git a/plugins/beta/draw-ol/src/edit/midpointLayer.js b/plugins/beta/draw-ol/src/edit/midpointLayer.js
new file mode 100644
index 00000000..34f2358a
--- /dev/null
+++ b/plugins/beta/draw-ol/src/edit/midpointLayer.js
@@ -0,0 +1,57 @@
+import VectorSource from 'ol/source/Vector.js'
+import VectorLayer from 'ol/layer/Vector.js'
+import Feature from 'ol/Feature.js'
+import Point from 'ol/geom/Point.js'
+import { getMidpoints } from '../utils/geometryHelpers.js'
+
+/**
+ * Manages a dedicated overlay layer for midpoint handles in edit mode.
+ * Midpoints are always visible (unlike OL Modify's native midpoints which
+ * only appear when the pointer is near a segment). The selected midpoint is
+ * rendered by the separate active-selection layer in EditMode (zIndex 103).
+ */
+export const createMidpointLayer = (map, midpointStyle) => {
+ let currentStyle = midpointStyle
+ let selectedIndex = -1
+ const source = new VectorSource()
+ const layer = new VectorLayer({
+ source,
+ style: (feature) => feature.get('midpointIndex') === selectedIndex ? null : [currentStyle],
+ zIndex: 101
+ })
+ map.addLayer(layer)
+
+ return {
+ update (geom) {
+ source.clear()
+ const midpoints = getMidpoints(geom)
+ const features = midpoints.map((coord, i) => {
+ const f = new Feature({ geometry: new Point(coord) })
+ f.set('midpointIndex', i)
+ return f
+ })
+ source.addFeatures(features)
+ },
+
+ setSelected (index) {
+ selectedIndex = index
+ source.changed()
+ },
+
+ updateStyle (newMidpointStyle) {
+ currentStyle = newMidpointStyle
+ source.changed()
+ },
+
+ getCoords () {
+ return source.getFeatures()
+ .sort((a, b) => a.get('midpointIndex') - b.get('midpointIndex'))
+ .map(f => f.getGeometry().getCoordinates())
+ },
+
+ remove () {
+ source.clear()
+ map.removeLayer(layer)
+ }
+ }
+}
diff --git a/plugins/beta/draw-ol/src/edit/touchHandler.js b/plugins/beta/draw-ol/src/edit/touchHandler.js
new file mode 100644
index 00000000..e7b41ca2
--- /dev/null
+++ b/plugins/beta/draw-ol/src/edit/touchHandler.js
@@ -0,0 +1,171 @@
+import { coordToPixel, pixelToCoord } from '../utils/olCoords.js'
+import { createTouchTarget, applyTouchTargetColors, showTouchTarget, hideTouchTarget, isOnTouchTarget } from '../utils/touchTarget.js'
+import { moveVertex } from './vertexOps.js'
+import { findNearest } from './vertexHitTest.js'
+
+const TAP_MOVE_THRESHOLD = 10
+const TAP_TIME_THRESHOLD = 400
+const TOUCH_TOLERANCE = 24
+
+const wireTouchEvents = ({ container, map, targetEl, olToCSS, cssToOl, getState, setState, onVertexMoved, onTap, snap }) => {
+ let dragStartCoord = null
+ let dragStartIndex = null
+ let vertexTouchDelta = null
+ let targetTouchDelta = null
+ let tapStart = null
+
+ const onTouchstart = (e) => {
+ const touch = e.touches[0]
+ const onTarget = isOnTouchTarget(e.target)
+ tapStart = { x: touch.clientX, y: touch.clientY, time: Date.now(), onTarget }
+ if (!onTarget) {
+ return
+ }
+ const { selectedVertexIndex, vertices } = getState()
+ const vertex = vertices[selectedVertexIndex]
+ if (!vertex) {
+ return
+ }
+ const tOl = map.getEventPixel({ clientX: touch.clientX, clientY: touch.clientY })
+ const vertexPx = coordToPixel(map, vertex)
+ const style = getComputedStyle(targetEl)
+ const svgOlPx = cssToOl({ x: Number.parseFloat(style.left), y: Number.parseFloat(style.top) })
+ dragStartCoord = [...vertex]
+ dragStartIndex = selectedVertexIndex
+ vertexTouchDelta = { x: tOl[0] - vertexPx.x, y: tOl[1] - vertexPx.y }
+ targetTouchDelta = { x: tOl[0] - svgOlPx.x, y: tOl[1] - svgOlPx.y }
+ e.preventDefault()
+ }
+
+ const onTouchmove = (e) => {
+ if (!isOnTouchTarget(e.target) || dragStartIndex == null) {
+ return
+ }
+ e.preventDefault()
+ const tOl = map.getEventPixel({ clientX: e.touches[0].clientX, clientY: e.touches[0].clientY })
+ const rawCoord = pixelToCoord(map, { x: tOl[0] - vertexTouchDelta.x, y: tOl[1] - vertexTouchDelta.y })
+ const newCoord = snap ? snap.apply(rawCoord) : rawCoord
+ snap?.hideIndicator()
+ const { olFeature, vertices } = getState()
+ if (!olFeature) {
+ return
+ }
+ moveVertex(olFeature, dragStartIndex, newCoord)
+ setState({ vertices: vertices.map((c, i) => i === dragStartIndex ? newCoord : c) })
+ showTouchTarget(targetEl, olToCSS({ x: tOl[0] - targetTouchDelta.x, y: tOl[1] - targetTouchDelta.y }))
+ }
+
+ const onTouchend = (e) => {
+ const wasDragging = dragStartIndex != null
+ if (!wasDragging) {
+ if (tapStart && !tapStart.onTarget && e.changedTouches.length > 0) {
+ const t = e.changedTouches[0]
+ const dt = Date.now() - tapStart.time
+ if (Math.hypot(t.clientX - tapStart.x, t.clientY - tapStart.y) < TAP_MOVE_THRESHOLD && dt < TAP_TIME_THRESHOLD) {
+ const tOl = map.getEventPixel({ clientX: t.clientX, clientY: t.clientY })
+ const tapState = getState()
+ onTap?.(findNearest(map, tapState.vertices, tapState.midpoints, { x: tOl[0], y: tOl[1] }, TOUCH_TOLERANCE))
+ e.preventDefault()
+ }
+ }
+ tapStart = null
+ return
+ }
+ tapStart = null
+ const { vertices } = getState()
+ if (vertices[dragStartIndex] && dragStartCoord) {
+ onVertexMoved({ vertexIndex: dragStartIndex, previousCoord: dragStartCoord })
+ }
+ snap?.hideIndicator()
+ dragStartCoord = null; dragStartIndex = null; vertexTouchDelta = null; targetTouchDelta = null
+ e.preventDefault()
+ }
+
+ container.addEventListener('touchstart', onTouchstart, { passive: false })
+ container.addEventListener('touchmove', onTouchmove, { passive: false })
+ container.addEventListener('touchend', onTouchend, { passive: false })
+
+ return {
+ isDragging: () => dragStartIndex != null,
+ destroy () {
+ container.removeEventListener('touchstart', onTouchstart)
+ container.removeEventListener('touchmove', onTouchmove)
+ container.removeEventListener('touchend', onTouchend)
+ }
+ }
+}
+
+/**
+ * Touch vertex drag handler for edit mode.
+ * Shows an SVG offset target below the finger so the vertex can be repositioned
+ * without finger occlusion. Tap on a vertex or midpoint selects it via onTap.
+ *
+ * @param {{ map, container, getState, setState, onVertexMoved, onTap, colors }} options
+ * @returns {{ updateTargetPosition, updateColors, hide, destroy }}
+ */
+export const createTouchHandler = ({ map, container, getState, setState, onVertexMoved, onTap, colors, snap }) => {
+ const targetEl = createTouchTarget(container)
+ applyTouchTargetColors(targetEl, colors)
+
+ // OL pixel space is relative to ol-viewport at its pre-scale CSS size.
+ // Container CSS space is larger when a CSS transform scales up ol-viewport
+ // (e.g. scale(1.5) at medium map size makes OL pixels 1.5× smaller than CSS pixels).
+ const cssTx = { scale: 1, ox: 0, oy: 0 }
+ const olToCSS = (p) => ({ x: p.x * cssTx.scale + cssTx.ox, y: p.y * cssTx.scale + cssTx.oy })
+ const cssToOl = (p) => ({ x: (p.x - cssTx.ox) / cssTx.scale, y: (p.y - cssTx.oy) / cssTx.scale })
+
+ const syncCssTx = () => {
+ const vpEl = map.getViewport()
+ const vpRect = vpEl.getBoundingClientRect()
+ const cRect = container.getBoundingClientRect()
+ const vpScale = vpEl.offsetWidth > 0 ? vpRect.width / vpEl.offsetWidth : 1
+ const cScale = container.offsetWidth > 0 ? cRect.width / container.offsetWidth : 1
+ Object.assign(cssTx, {
+ scale: vpScale / cScale,
+ ox: (vpRect.left - cRect.left) / cScale,
+ oy: (vpRect.top - cRect.top) / cScale
+ })
+ }
+
+ const touchEvents = wireTouchEvents({ container, map, targetEl, olToCSS, cssToOl, getState, setState, onVertexMoved, onTap, snap })
+
+ const updateTargetPosition = () => {
+ const { selectedVertexIndex, vertices, interfaceType } = getState()
+ if (selectedVertexIndex < 0 || !vertices[selectedVertexIndex] || interfaceType !== 'touch') {
+ hideTouchTarget(targetEl)
+ return
+ }
+ const px = coordToPixel(map, vertices[selectedVertexIndex])
+ if (!px) {
+ hideTouchTarget(targetEl)
+ return
+ }
+ showTouchTarget(targetEl, olToCSS(px))
+ }
+
+ // Reposition on every render — keeps target anchored during pinch-zoom and pan.
+ // Skipped during drag since touchmove handles position directly.
+ const onPostrender = () => {
+ const { selectedVertexIndex, interfaceType } = getState()
+ if (selectedVertexIndex >= 0 && !touchEvents.isDragging() && interfaceType === 'touch') {
+ updateTargetPosition()
+ }
+ }
+ map.on('postrender', onPostrender)
+
+ const onSizeChange = () => { syncCssTx(); map.once('postrender', updateTargetPosition) }
+ map.on('change:size', onSizeChange)
+ syncCssTx()
+
+ return {
+ updateTargetPosition,
+ updateColors (newColors) { applyTouchTargetColors(targetEl, newColors) },
+ hide () { hideTouchTarget(targetEl) },
+ destroy () {
+ map.un('change:size', onSizeChange)
+ map.un('postrender', onPostrender)
+ touchEvents.destroy()
+ hideTouchTarget(targetEl)
+ }
+ }
+}
diff --git a/plugins/beta/draw-ol/src/edit/undoOps.js b/plugins/beta/draw-ol/src/edit/undoOps.js
new file mode 100644
index 00000000..0ae197ad
--- /dev/null
+++ b/plugins/beta/draw-ol/src/edit/undoOps.js
@@ -0,0 +1,95 @@
+import {
+ getRingSegments,
+ getSegmentForIndex,
+ getModifiableCoords
+} from '../utils/geometryHelpers.js'
+
+/**
+ * Undo handlers for edit mode.
+ * Each operation receives the OL feature and the saved op payload,
+ * mutates the geometry, and returns the vertex index to re-select (or -1).
+ */
+
+export const undoMoveVertex = (olFeature, op) => {
+ const { vertexIndex, previousCoord } = op
+ const geom = olFeature.getGeometry()
+ const geojsonGeom = { type: geom.getType(), coordinates: geom.getCoordinates() }
+ const segments = getRingSegments(geojsonGeom)
+ const result = getSegmentForIndex(segments, vertexIndex)
+ if (!result) {
+ return -1
+ }
+
+ const ring = getModifiableCoords(geojsonGeom, result.segment.path)
+ ring[result.localIdx] = [...previousCoord]
+ if (result.segment.closed && result.localIdx === 0) {
+ ring[ring.length - 1] = [...previousCoord]
+ }
+ geom.setCoordinates(geojsonGeom.coordinates)
+ return vertexIndex
+}
+
+export const undoInsertVertex = (olFeature, op) => {
+ const { vertexIndex } = op
+ const geom = olFeature.getGeometry()
+ const geojsonGeom = { type: geom.getType(), coordinates: geom.getCoordinates() }
+ const segments = getRingSegments(geojsonGeom)
+ const result = getSegmentForIndex(segments, vertexIndex)
+ if (!result) {
+ return -1
+ }
+
+ const ring = getModifiableCoords(geojsonGeom, result.segment.path)
+ ring.splice(result.localIdx, 1)
+ if (result.segment.closed) {
+ ring[ring.length - 1] = [...ring[0]]
+ }
+ geom.setCoordinates(geojsonGeom.coordinates)
+ return -1
+}
+
+export const undoDeleteVertex = (olFeature, op) => {
+ const { vertexIndex, deletedCoord } = op
+ const geom = olFeature.getGeometry()
+ const geojsonGeom = { type: geom.getType(), coordinates: geom.getCoordinates() }
+ const segments = getRingSegments(geojsonGeom)
+
+ let result = getSegmentForIndex(segments, vertexIndex)
+ // Vertex might be at a segment boundary after deletion shifted indices
+ if (!result) {
+ for (const seg of segments) {
+ if (vertexIndex === seg.start + seg.length) {
+ result = { segment: seg, localIdx: seg.length }
+ break
+ }
+ }
+ }
+ if (!result) {
+ return -1
+ }
+
+ const ring = getModifiableCoords(geojsonGeom, result.segment.path)
+ ring.splice(result.localIdx, 0, [...deletedCoord])
+ if (result.segment.closed) {
+ ring[ring.length - 1] = [...ring[0]]
+ }
+ geom.setCoordinates(geojsonGeom.coordinates)
+ return vertexIndex
+}
+
+/**
+ * Dispatch the correct undo handler based on operation type.
+ * @returns {number} vertex index to re-select after undo, or -1 for none
+ */
+export const applyUndo = (olFeature, op) => {
+ switch (op.type) {
+ case 'move_vertex':
+ return undoMoveVertex(olFeature, op)
+ case 'insert_vertex':
+ return undoInsertVertex(olFeature, op)
+ case 'delete_vertex':
+ return undoDeleteVertex(olFeature, op)
+ default:
+ return -1
+ }
+}
diff --git a/plugins/beta/draw-ol/src/edit/vertexHitTest.js b/plugins/beta/draw-ol/src/edit/vertexHitTest.js
new file mode 100644
index 00000000..34a991d4
--- /dev/null
+++ b/plugins/beta/draw-ol/src/edit/vertexHitTest.js
@@ -0,0 +1,72 @@
+import { coordToPixel, pixelDist } from '../utils/olCoords.js'
+
+const PIXEL_TOLERANCE = 12
+
+/**
+ * Find the nearest vertex to a screen pixel within tolerance.
+ *
+ * @param {import('ol/Map').default} map
+ * @param {number[][]} vertices - flat coordinate array [[e,n], ...]
+ * @param {{ x: number, y: number }} pixel
+ * @param {number} [tolerance]
+ * @returns {{ index: number, type: 'vertex' } | null}
+ */
+export const findNearestVertex = (map, vertices, pixel, tolerance = PIXEL_TOLERANCE) => {
+ let bestIdx = -1
+ let bestDist = tolerance
+
+ vertices.forEach((coord, i) => {
+ const px = coordToPixel(map, coord)
+ if (!px) {
+ return
+ }
+ const d = pixelDist(px, pixel)
+ if (d < bestDist) {
+ bestDist = d
+ bestIdx = i
+ }
+ })
+
+ return bestIdx >= 0 ? { index: bestIdx, type: 'vertex' } : null
+}
+
+/**
+ * Find the nearest midpoint to a screen pixel within tolerance.
+ *
+ * @param {import('ol/Map').default} map
+ * @param {number[][]} midpoints - midpoint coordinate array
+ * @param {{ x: number, y: number }} pixel
+ * @param {number} vertexCount - number of actual vertices (midpoint index offset)
+ * @param {number} [tolerance]
+ * @returns {{ index: number, type: 'midpoint' } | null}
+ */
+export const findNearestMidpoint = (map, midpoints, pixel, vertexCount, tolerance = PIXEL_TOLERANCE) => {
+ let bestIdx = -1
+ let bestDist = tolerance
+
+ midpoints.forEach((coord, i) => {
+ const px = coordToPixel(map, coord)
+ if (!px) {
+ return
+ }
+ const d = pixelDist(px, pixel)
+ if (d < bestDist) {
+ bestDist = d
+ bestIdx = i
+ }
+ })
+
+ return bestIdx >= 0 ? { index: vertexCount + bestIdx, type: 'midpoint' } : null
+}
+
+/**
+ * Find the nearest vertex or midpoint to a pixel.
+ * Vertices take priority when equidistant.
+ *
+ * @param {number} [tolerance]
+ * @returns {{ index: number, type: 'vertex'|'midpoint' } | null}
+ */
+export const findNearest = (map, vertices, midpoints, pixel, tolerance = PIXEL_TOLERANCE) => {
+ return findNearestVertex(map, vertices, pixel, tolerance) ??
+ findNearestMidpoint(map, midpoints, pixel, vertices.length, tolerance)
+}
diff --git a/plugins/beta/draw-ol/src/edit/vertexLayer.js b/plugins/beta/draw-ol/src/edit/vertexLayer.js
new file mode 100644
index 00000000..057acbb0
--- /dev/null
+++ b/plugins/beta/draw-ol/src/edit/vertexLayer.js
@@ -0,0 +1,49 @@
+import VectorSource from 'ol/source/Vector.js'
+import VectorLayer from 'ol/layer/Vector.js'
+import Feature from 'ol/Feature.js'
+import Point from 'ol/geom/Point.js'
+import { getCoords } from '../utils/geometryHelpers.js'
+
+/**
+ * Always-visible vertex handle layer for edit mode.
+ * OL Modify's built-in vertex handles only appear on hover; this layer
+ * keeps circles visible at all times. The selected vertex is rendered by
+ * the separate active-selection layer in EditMode (zIndex 103).
+ */
+export const createVertexLayer = (map, vertexStyle) => {
+ let currentStyle = vertexStyle
+ let selectedIndex = -1
+ const source = new VectorSource()
+ const layer = new VectorLayer({
+ source,
+ style: (feature) => feature.get('vertexIndex') === selectedIndex ? null : [currentStyle],
+ zIndex: 102
+ })
+ map.addLayer(layer)
+
+ return {
+ update (geom) {
+ source.clear()
+ getCoords(geom).forEach((coord, i) => {
+ const f = new Feature({ geometry: new Point(coord) })
+ f.set('vertexIndex', i)
+ source.addFeature(f)
+ })
+ },
+
+ setSelected (index) {
+ selectedIndex = index
+ source.changed()
+ },
+
+ updateStyle (newVertexStyle) {
+ currentStyle = newVertexStyle
+ source.changed()
+ },
+
+ remove () {
+ source.clear()
+ map.removeLayer(layer)
+ }
+ }
+}
diff --git a/plugins/beta/draw-ol/src/edit/vertexOps.js b/plugins/beta/draw-ol/src/edit/vertexOps.js
new file mode 100644
index 00000000..e35b9cdb
--- /dev/null
+++ b/plugins/beta/draw-ol/src/edit/vertexOps.js
@@ -0,0 +1,99 @@
+import {
+ getCoords,
+ getRingSegments,
+ getSegmentForIndex,
+ getModifiableCoords
+} from '../utils/geometryHelpers.js'
+
+/**
+ * Delete the vertex at `selectedIndex` from the OL feature's geometry.
+ * Respects minimum vertex counts (3 for closed rings, 2 for lines).
+ *
+ * @returns {{ deletedIndex: number, deletedCoord: number[] } | null} undo payload, or null if not deleted
+ */
+export const deleteVertex = (olFeature, selectedIndex) => {
+ const geom = olFeature.getGeometry()
+ const geojsonGeom = { type: geom.getType(), coordinates: geom.getCoordinates() }
+ const coords = getCoords(geojsonGeom)
+ const segments = getRingSegments(geojsonGeom)
+ const result = getSegmentForIndex(segments, selectedIndex)
+ if (!result) {
+ return null
+ }
+
+ const { segment } = result
+ const minVertices = segment.closed ? 3 : 2
+ if (segment.length <= minVertices) {
+ return null
+ }
+
+ const deletedCoord = [...coords[selectedIndex]]
+ const ring = getModifiableCoords(geojsonGeom, segment.path)
+ ring.splice(result.localIdx, 1)
+ if (segment.closed) {
+ ring[ring.length - 1] = [...ring[0]]
+ }
+
+ geom.setCoordinates(geojsonGeom.coordinates)
+ return { deletedIndex: selectedIndex, deletedCoord }
+}
+
+/**
+ * Insert a vertex after `afterIndex` in the OL feature's geometry.
+ * Used when the user activates a midpoint (touch tap or keyboard insert).
+ *
+ * @param {number[][]} midpoints - current midpoint array (from midpointLayer)
+ * @param {number} midpointFlatIndex - flat index (vertexCount + midpointOffset)
+ * @param {number} vertexCount - number of actual vertices
+ * @param {number[][]} vertices - current vertex array
+ * @returns {{ insertedIndex: number } | null}
+ */
+export const insertAtMidpoint = (olFeature, midpoints, midpointFlatIndex, vertexCount) => {
+ const midpointLocalIdx = midpointFlatIndex - vertexCount
+ const midCoord = midpoints[midpointLocalIdx]
+ if (!midCoord) {
+ return null
+ }
+
+ const geom = olFeature.getGeometry()
+ const geojsonGeom = { type: geom.getType(), coordinates: geom.getCoordinates() }
+ const segments = getRingSegments(geojsonGeom)
+
+ // Map midpoint local index to insertion position in the coordinate array
+ let midpointCounter = 0
+ for (const seg of segments) {
+ const segMidpoints = seg.closed ? seg.length : seg.length - 1
+ if (midpointLocalIdx < midpointCounter + segMidpoints) {
+ const localMidIdx = midpointLocalIdx - midpointCounter
+ const insertLocalIdx = localMidIdx + 1
+ const insertGlobalIdx = seg.start + insertLocalIdx
+ const ring = getModifiableCoords(geojsonGeom, seg.path)
+ ring.splice(insertLocalIdx, 0, [...midCoord])
+ geom.setCoordinates(geojsonGeom.coordinates)
+ return { insertedIndex: insertGlobalIdx }
+ }
+ midpointCounter += segMidpoints
+ }
+
+ return null
+}
+
+/**
+ * Move vertex at `index` to `newCoord` in the OL feature's geometry.
+ */
+export const moveVertex = (olFeature, index, newCoord) => {
+ const geom = olFeature.getGeometry()
+ const geojsonGeom = { type: geom.getType(), coordinates: geom.getCoordinates() }
+ const segments = getRingSegments(geojsonGeom)
+ const result = getSegmentForIndex(segments, index)
+ if (!result) {
+ return
+ }
+
+ const ring = getModifiableCoords(geojsonGeom, result.segment.path)
+ ring[result.localIdx] = [...newCoord]
+ if (result.segment.closed && result.localIdx === 0) {
+ ring[ring.length - 1] = [...newCoord]
+ }
+ geom.setCoordinates(geojsonGeom.coordinates)
+}
diff --git a/plugins/beta/draw-ol/src/events.js b/plugins/beta/draw-ol/src/events.js
new file mode 100644
index 00000000..ad232b75
--- /dev/null
+++ b/plugins/beta/draw-ol/src/events.js
@@ -0,0 +1,114 @@
+/**
+ * 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, drawSnap } = 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()
+ }
+
+ const handleSnap = () => {
+ const newSnapState = !pluginState.snap
+ dispatch({ type: 'TOGGLE_SNAP' })
+ draw.snap?.setActive(newSnapState)
+ }
+
+ // --- 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, numVertices: e.numVertices } })
+ }
+
+ 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
+ if (drawSnap) drawSnap.onClick = handleSnap
+
+ draw.on('create', onCreate)
+ draw.on('editfinish', onEditFinish)
+ draw.on('cancel', onCancel)
+ draw.on('vertexselection', onVertexSelection)
+ draw.on('vertexchange', onVertexChange)
+ draw.on('undochange', onUndoChange)
+ draw.on('update', onUpdate)
+
+ return () => {
+ drawDone.onClick = null
+ drawCancel.onClick = null
+ drawUndo.onClick = null
+ if (drawDeletePoint) drawDeletePoint.onClick = null
+ if (drawSnap) drawSnap.onClick = null
+
+ draw.off('create', onCreate)
+ draw.off('editfinish', onEditFinish)
+ draw.off('cancel', onCancel)
+ draw.off('vertexselection', onVertexSelection)
+ draw.off('vertexchange', onVertexChange)
+ draw.off('undochange', onUndoChange)
+ draw.off('update', onUpdate)
+ }
+}
diff --git a/plugins/beta/draw-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..92ab136e
--- /dev/null
+++ b/plugins/beta/draw-ol/src/manifest.js
@@ -0,0 +1,121 @@
+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.numVertices >= 3 // NOSONAR
+ }
+ if (pluginState.mode === 'draw_line') {
+ return pluginState.numVertices >= 2 // NOSONAR
+ }
+ if (pluginState.mode === 'edit_vertex') {
+ return true
+ }
+ return false
+ },
+ ...createButtonSlots(true)
+ },
+ {
+ id: 'drawMenu',
+ label: 'Menu',
+ iconId: 'menu',
+ hiddenWhen: ({ pluginState }) => !['draw_polygon', 'draw_line', 'edit_vertex'].includes(pluginState.mode),
+ menuItems: [{
+ id: 'drawUndo',
+ label: 'Undo',
+ iconId: 'undo',
+ hiddenWhen: ({ pluginState }) => !['draw_polygon', 'draw_line', 'edit_vertex'].includes(pluginState.mode),
+ enableWhen: ({ pluginState }) => {
+ if (['draw_polygon', 'draw_line'].includes(pluginState.mode)) {
+ return pluginState.numVertices > 0
+ }
+ return pluginState.undoStackLength > 0
+ }
+ }, {
+ id: 'drawSnap',
+ label: 'Snap to feature',
+ iconId: 'magnet',
+ hiddenWhen: ({ pluginState }) => !pluginState.mode || !pluginState.hasSnapLayers,
+ pressedWhen: ({ pluginState }) => !!pluginState.snap
+ }, {
+ id: 'drawDeletePoint',
+ label: 'Delete point',
+ iconId: 'trash',
+ enableWhen: ({ pluginState }) => pluginState.selectedVertexIndex >= 0 && pluginState.numVertices > 2,
+ hiddenWhen: ({ pluginState }) => pluginState.mode !== 'edit_vertex'
+ }],
+ mobile: { slot: 'bottom-right' },
+ tablet: { slot: 'top-middle' },
+ desktop: { slot: 'top-middle' }
+ }
+ ],
+
+ keyboardShortcuts: [{
+ id: 'drawStart',
+ group: 'Drawing',
+ title: 'Edit vertex',
+ command: 'Spacebar'
+ }],
+
+ icons: [{
+ id: 'menu',
+ svgContent: ''
+ }, {
+ id: 'undo',
+ svgContent: ''
+ }, {
+ id: 'magnet',
+ svgContent: ''
+ }, {
+ id: 'trash',
+ svgContent: ''
+ }],
+
+ api: {
+ newPolygon,
+ newLine,
+ editFeature,
+ addFeature,
+ deleteFeature
+ }
+}
diff --git a/plugins/beta/draw-ol/src/olDraw.js b/plugins/beta/draw-ol/src/olDraw.js
new file mode 100644
index 00000000..73c966f3
--- /dev/null
+++ b/plugins/beta/draw-ol/src/olDraw.js
@@ -0,0 +1,38 @@
+import { OLDrawManager } from './core/OLDrawManager.js'
+
+/**
+ * Creates the OLDrawManager, attaches it to mapProvider, and wires
+ * app-level events (MAP_SET_SIZE for scale-aware touch targets,
+ * MAP_SET_STYLE for dynamic color updates).
+ *
+ * @returns {{ remove: () => void }}
+ */
+export const createOLDraw = ({ mapProvider, events, eventBus, pluginConfig = {}, mapStyle = null }) => {
+ const { map } = mapProvider
+ const manager = new OLDrawManager(map, pluginConfig)
+
+ if (mapStyle) {
+ manager.setMapStyle(mapStyle)
+ }
+
+ mapProvider.draw = manager
+
+ const handleSetMapSize = (size) => {
+ mapProvider.drawScale = { small: 1, medium: 1.5, large: 2 }[size] ?? 1
+ }
+ eventBus.on(events.MAP_SET_SIZE, handleSetMapSize)
+
+ const handleSetMapStyle = (newMapStyle) => {
+ manager.setMapStyle(newMapStyle)
+ }
+ eventBus.on(events.MAP_SET_STYLE, handleSetMapStyle)
+
+ return {
+ remove () {
+ eventBus.off(events.MAP_SET_SIZE, handleSetMapSize)
+ eventBus.off(events.MAP_SET_STYLE, handleSetMapStyle)
+ manager.remove()
+ mapProvider.draw = null
+ }
+ }
+}
diff --git a/plugins/beta/draw-ol/src/reducer.js b/plugins/beta/draw-ol/src/reducer.js
new file mode 100644
index 00000000..a443d66e
--- /dev/null
+++ b/plugins/beta/draw-ol/src/reducer.js
@@ -0,0 +1,37 @@
+const initialState = {
+ mode: null,
+ feature: null,
+ tempFeature: null,
+ selectedVertexIndex: -1,
+ numVertices: null,
+ undoStackLength: 0,
+ snap: false,
+ hasSnapLayers: false
+}
+
+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,
+ numVertices: payload.numVertices
+ }),
+
+ 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..b958236f
--- /dev/null
+++ b/plugins/beta/draw-ol/src/snap/snapEngine.js
@@ -0,0 +1,109 @@
+/**
+ * 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'
+
+const pickBest = (a, b) => {
+ if (!b) { return a }
+ if (!a) { return b }
+ if (a.type === 'vertex' && b.type === 'edge') { return a }
+ if (a.type === 'edge' && b.type === 'vertex') { return b }
+ return b.distSq < a.distSq ? b : a
+}
+
+// Collected on each query — VectorTileLayers are replaced when the map style changes
+const getVTLayers = (map) => {
+ const layers = []
+ map.getLayers().forEach(l => {
+ if (l instanceof VectorTileLayer) { layers.push(l) }
+ })
+ return layers
+}
+
+export const createSnapEngine = (map, snapLayers = []) => {
+ let vtLayerNames = new Set()
+ let olLayers = []
+
+ const setLayers = (layers) => {
+ vtLayerNames = new Set()
+ olLayers = []
+ for (const entry of layers ?? []) {
+ if (typeof entry === 'string') {
+ vtLayerNames.add(entry)
+ } else if (entry instanceof VectorLayer) {
+ olLayers.push(entry)
+ } else {
+ // unsupported layer type — skip
+ }
+ }
+ }
+
+ setLayers(snapLayers)
+
+ /**
+ * 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
+
+ // --- OL VectorLayer sources ---
+ for (const layer of olLayers) {
+ const source = layer.getSource()
+ if (!source) {
+ continue
+ }
+ for (const feature of source.getFeaturesInExtent(ext)) {
+ best = pickBest(best, testOLFeature(feature, coord, toleranceSq))
+ }
+ }
+
+ // --- VectorTile layers ---
+ if (vtLayerNames.size > 0) {
+ 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, setLayers }
+}
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..14432ed6
--- /dev/null
+++ b/plugins/beta/draw-ol/src/snap/snapGeometry.js
@@ -0,0 +1,200 @@
+/**
+ * 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 bestOf = (current, candidate) => better(current, candidate) ? candidate : current
+
+const better = (a, b) => {
+ if (!a) {
+ return !!b
+ }
+ if (!b) {
+ return false
+ }
+ // Vertex always beats edge — only compare distance within the same type
+ if (a.type === 'edge' && b.type === 'vertex') {
+ return true
+ }
+ if (a.type === 'vertex' && b.type === 'edge') {
+ return false
+ }
+ return b.distSq < a.distSq
+}
+
+/**
+ * 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) {
+ best = bestOf(best, { type: 'vertex', coord: [v[0], v[1]], distSq: dSq })
+ }
+ }
+
+ for (let i = 0; i < edgeCount; i++) {
+ const a = coords[i]
+ const b = coords[(i + 1) % n]
+ const pt = closestPointOnSegment(query, a, b)
+ const dSq = dist2(query, pt)
+ if (dSq <= toleranceSq) {
+ best = bestOf(best, { type: 'edge', coord: pt, distSq: dSq })
+ }
+ }
+
+ return best
+}
+
+/**
+ * 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 getBestEdge = (flat, start, numPairs, edgeCount, query, toleranceSq) => {
+ let best = null
+ for (let i = 0; i < edgeCount; i++) {
+ const ai = start + i * 2
+ const bi = start + ((i + 1) % numPairs) * 2
+ const a = [flat[ai], flat[ai + 1]]
+ const b = [flat[bi], flat[bi + 1]]
+ const pt = closestPointOnSegment(query, a, b)
+ const dSq = dist2(query, pt)
+ if (dSq <= toleranceSq) {
+ best = bestOf(best, { type: 'edge', coord: pt, distSq: dSq })
+ }
+ }
+ return best
+}
+
+const getBestPair = (flat, start, numPairs, edgeCount, query, toleranceSq) => {
+ let best = null
+ for (let i = 0; i < numPairs; i++) {
+ const xi = start + i * 2
+ const v = [flat[xi], flat[xi + 1]]
+ const dSq = dist2(query, v)
+ if (dSq <= toleranceSq) {
+ best = bestOf(best, { type: 'vertex', coord: v, distSq: dSq })
+ }
+ }
+ return bestOf(best, getBestEdge(flat, start, numPairs, edgeCount, query, toleranceSq))
+}
+
+const testFlatCoords = (flat, start, end, query, toleranceSq, isClosedRing) => {
+ const numPairs = (end - start) / 2
+ const edgeCount = isClosedRing ? numPairs : numPairs - 1
+ return getBestPair(flat, start, numPairs, edgeCount, query, toleranceSq)
+}
+
+const olGeomHandlers = {
+ Point (geom, query, toleranceSq) {
+ const c = geom.getCoordinates()
+ const dSq = dist2(query, c)
+ return dSq <= toleranceSq ? { type: 'vertex', coord: [c[0], c[1]], distSq: dSq } : null
+ },
+ LineString (geom, query, toleranceSq) {
+ return testCoords(geom.getCoordinates(), query, toleranceSq, false)
+ },
+ LinearRing (geom, query, toleranceSq) {
+ return testCoords(geom.getCoordinates(), query, toleranceSq, true)
+ },
+ Polygon (geom, query, toleranceSq) {
+ let best = null
+ for (const ring of geom.getCoordinates()) {
+ best = bestOf(best, testCoords(ring, query, toleranceSq, true))
+ }
+ return best
+ },
+ MultiLineString (geom, query, toleranceSq) {
+ let best = null
+ for (const line of geom.getCoordinates()) {
+ best = bestOf(best, testCoords(line, query, toleranceSq, false))
+ }
+ return best
+ },
+ MultiPolygon (geom, query, toleranceSq) {
+ let best = null
+ for (const polygon of geom.getCoordinates()) {
+ for (const ring of polygon) {
+ best = bestOf(best, testCoords(ring, query, toleranceSq, true))
+ }
+ }
+ return best
+ }
+}
+
+/**
+ * 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
+}
+
+/**
+ * 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
+
+ if (type === 'Point') {
+ const dSq = dist2(query, flat)
+ if (dSq <= toleranceSq) {
+ best = bestOf(best, { type: 'vertex', coord: [flat[0], flat[1]], distSq: dSq })
+ }
+ } else if (type === 'LineString') {
+ 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) {
+ best = bestOf(best, 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..de75e26f
--- /dev/null
+++ b/plugins/beta/draw-ol/src/snap/snapIndicator.js
@@ -0,0 +1,81 @@
+/**
+ * Snap indicator — a single OL VectorLayer that shows a circle at the active
+ * snap candidate position.
+ *
+ * Vertex snap → orange semi-transparent circle (snapVertex color)
+ * Edge snap → blue semi-transparent circle (snapEdge color)
+ *
+ * Uses Style.renderer (single canvas call) so the circle renders correctly
+ * at fractional CSS scale factors.
+ */
+
+import VectorLayer from 'ol/layer/Vector.js'
+import VectorSource from 'ol/source/Vector.js'
+import Feature from 'ol/Feature.js'
+import Point from 'ol/geom/Point.js'
+import { Style } from 'ol/style.js'
+
+const RADIUS_PX = 10
+
+const makeRenderer = (color) => (coords, state) => {
+ const ctx = state.context
+ const [cx, cy] = coords
+ ctx.beginPath()
+ ctx.arc(cx, cy, RADIUS_PX * state.pixelRatio, 0, Math.PI * 2)
+ ctx.fillStyle = color
+ ctx.fill()
+}
+
+const makeStyles = (colors) => ({
+ vertex: new Style({ renderer: makeRenderer(colors.snapVertex) }),
+ edge: new Style({ renderer: makeRenderer(colors.snapEdge) })
+})
+
+export const createSnapIndicator = (map, colors) => {
+ let styles = makeStyles(colors)
+ const source = new VectorSource()
+ const layer = new VectorLayer({
+ source,
+ style: (f) => styles[f.get('snapType')] ?? null,
+ zIndex: 200,
+ updateWhileAnimating: true,
+ updateWhileInteracting: true
+ })
+ map.addLayer(layer)
+
+ const feature = new Feature()
+ let showing = false
+
+ return {
+ show (coord, type) {
+ feature.setGeometry(new Point(coord))
+ feature.set('snapType', type, true)
+ if (showing) {
+ source.changed()
+ } else {
+ source.addFeature(feature)
+ showing = true
+ }
+ },
+
+ hide () {
+ if (!showing) {
+ return
+ }
+ source.clear()
+ showing = false
+ },
+
+ updateColors (newColors) {
+ styles = makeStyles(newColors)
+ if (showing) {
+ source.changed()
+ }
+ },
+
+ remove () {
+ source.clear()
+ map.removeLayer(layer)
+ }
+ }
+}
diff --git a/plugins/beta/draw-ol/src/snap/snapInteraction.js b/plugins/beta/draw-ol/src/snap/snapInteraction.js
new file mode 100644
index 00000000..25bdfd0e
--- /dev/null
+++ b/plugins/beta/draw-ol/src/snap/snapInteraction.js
@@ -0,0 +1,56 @@
+/**
+ * Custom OL Interaction that intercepts pointer events and rewrites
+ * mapBrowserEvent.coordinate to the nearest snap candidate before any draw
+ * or modify interaction sees it.
+ *
+ * Coordinate snapping applies to all pointer events (pointermove, pointerdown,
+ * pointerup, singleclick) so that both rubberbanding and vertex placement are snapped.
+ * The visual indicator is only updated on pointermove.
+ *
+ * Must be added to the map AFTER the Draw/Modify interaction so it is processed
+ * first (OL iterates interactions in reverse-add order).
+ * snapManager.reattach() handles this after each mode change.
+ */
+
+import Interaction from 'ol/interaction/Interaction.js'
+
+const SNAP_EVENTS = new Set(['pointermove', 'pointerdrag', 'pointerdown', 'pointerup', 'singleclick', 'click'])
+
+export const createSnapInteraction = (engine, indicator, snapRadius) => {
+ 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, snapRadius)
+ 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()
+ } else {
+ // No action
+ }
+
+ 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..57479bcf
--- /dev/null
+++ b/plugins/beta/draw-ol/src/snap/snapManager.js
@@ -0,0 +1,90 @@
+/**
+ * Snap manager — orchestrates the snap engine, indicator, and OL interaction.
+ *
+ * Returned as manager.snap; null when no snapLayers are configured.
+ *
+ * Interface used by draw and edit modes:
+ * snap.apply(coord) — query + show/hide indicator; returns snapped coord or original
+ * snap.hideIndicator() — explicit hide (e.g. on touch/keyboard commit)
+ * snap.setActive(bool) — enable / disable (wired to the UI snap toggle)
+ * snap.reattach() — re-add the OL interaction after a mode change so it stays
+ * last-added (= first to process pointermove events)
+ * snap.destroy() — full cleanup
+ */
+
+import { createSnapEngine } from './snapEngine.js'
+import { createSnapIndicator } from './snapIndicator.js'
+import { createSnapInteraction } from './snapInteraction.js'
+
+export const createSnapManager = (map, snapLayers, colors, snapRadius) => {
+ if (!snapLayers?.length) {
+ return null
+ }
+
+ const engine = createSnapEngine(map, snapLayers)
+ const indicator = createSnapIndicator(map, colors)
+ const interaction = createSnapInteraction(engine, indicator, snapRadius)
+
+ map.addInteraction(interaction)
+ interaction.setActive(false) // matches reducer initial state: snap: false
+
+ let active = false
+
+ return {
+ /**
+ * Apply snap at coord. Updates the indicator and returns the snapped
+ * coordinate, or the original coordinate when no snap candidate is found.
+ * Returns original coord unchanged when snap is disabled.
+ */
+ snapRadius,
+
+ apply (coord) {
+ if (!active) {
+ return coord
+ }
+ const result = engine.query(coord, snapRadius)
+ if (result) {
+ indicator.show(result.coord, result.type)
+ return result.coord
+ }
+ indicator.hide()
+ return coord
+ },
+
+ hideIndicator () {
+ indicator.hide()
+ },
+
+ setActive (value) {
+ active = value
+ interaction.setActive(value)
+ if (!value) {
+ indicator.hide()
+ }
+ },
+
+ /**
+ * Remove and re-add the OL interaction so it sits at the top of the
+ * interaction stack (last-added = first to handle pointermove).
+ * Call after each changeMode() so the interaction always runs before
+ * the newly added Draw or Modify interaction.
+ */
+ setSnapLayers (layers) {
+ engine.setLayers(layers === null || layers === undefined ? (snapLayers ?? []) : layers)
+ },
+
+ reattach () {
+ map.removeInteraction(interaction)
+ map.addInteraction(interaction)
+ },
+
+ updateColors (newColors) {
+ indicator.updateColors(newColors)
+ },
+
+ destroy () {
+ map.removeInteraction(interaction)
+ indicator.remove()
+ }
+ }
+}
diff --git a/plugins/beta/draw-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/geometryHelpers.js b/plugins/beta/draw-ol/src/utils/geometryHelpers.js
new file mode 100644
index 00000000..83241941
--- /dev/null
+++ b/plugins/beta/draw-ol/src/utils/geometryHelpers.js
@@ -0,0 +1,97 @@
+/**
+ * Pure geometry helpers for multi-ring/multi-part geometry support.
+ * Handles coordinate transformations between flat arrays and hierarchical GeoJSON.
+ * Supports Polygon, MultiPolygon, LineString, MultiLineString.
+ */
+
+export const getCoords = (geom) => {
+ if (!geom?.coordinates) return []
+ switch (geom.type) {
+ case 'LineString': return geom.coordinates
+ case 'Polygon': return geom.coordinates.flatMap(ring => ring.slice(0, -1))
+ case 'MultiLineString': return geom.coordinates.flat(1)
+ case 'MultiPolygon': return geom.coordinates.flatMap(poly => poly.flatMap(ring => ring.slice(0, -1)))
+ default: return []
+ }
+}
+
+/**
+ * Segment metadata for each ring or part.
+ * { start, length, path, closed }
+ */
+export const getRingSegments = (geom) => {
+ if (!geom?.coordinates) return []
+ const segments = []
+ let start = 0
+
+ switch (geom.type) {
+ case 'LineString':
+ segments.push({ start: 0, length: geom.coordinates.length, path: [], closed: false })
+ break
+ case 'Polygon':
+ geom.coordinates.forEach((ring, i) => {
+ const len = ring.length - 1
+ segments.push({ start, length: len, path: [i], closed: true })
+ start += len
+ })
+ break
+ case 'MultiLineString':
+ geom.coordinates.forEach((line, i) => {
+ segments.push({ start, length: line.length, path: [i], closed: false })
+ start += line.length
+ })
+ break
+ case 'MultiPolygon':
+ geom.coordinates.forEach((polygon, pi) => {
+ polygon.forEach((ring, ri) => {
+ const len = ring.length - 1
+ segments.push({ start, length: len, path: [pi, ri], closed: true })
+ start += len
+ })
+ })
+ break
+ default:
+ break
+ }
+
+ return segments
+}
+
+/** Find which segment a flat vertex index belongs to. */
+export const getSegmentForIndex = (segments, flatIdx) => {
+ for (const seg of segments) {
+ if (flatIdx >= seg.start && flatIdx < seg.start + seg.length) {
+ return { segment: seg, localIdx: flatIdx - seg.start }
+ }
+ }
+ return null
+}
+
+/** Return a reference to the coordinate array at a hierarchical path. */
+export const getModifiableCoords = (geojsonGeometry, path) => {
+ let coords = geojsonGeometry.coordinates
+ for (const idx of path) {
+ coords = coords[idx]
+ }
+ return coords
+}
+
+/** Compute midpoints for all segments, respecting open/closed ring boundaries. */
+export const getMidpoints = (geom) => {
+ const coords = getCoords(geom)
+ const segments = getRingSegments(geom)
+ if (!coords.length || !segments.length) return []
+
+ const midpoints = []
+ for (const seg of segments) {
+ const count = seg.closed ? seg.length : seg.length - 1
+ for (let i = 0; i < count; i++) {
+ const idx = seg.start + i
+ const nextIdx = seg.start + ((i + 1) % seg.length)
+ const [x1, y1] = coords[idx]
+ const [x2, y2] = coords[nextIdx]
+ midpoints.push([(x1 + x2) / 2, (y1 + y2) / 2])
+ }
+ }
+ return midpoints
+}
diff --git a/plugins/beta/draw-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/resolveColors.js b/plugins/beta/draw-ol/src/utils/resolveColors.js
new file mode 100644
index 00000000..2c3dffe5
--- /dev/null
+++ b/plugins/beta/draw-ol/src/utils/resolveColors.js
@@ -0,0 +1,39 @@
+import { DEFAULTS } from '../defaults.js'
+
+const resolveVariant = (value, scheme, styleId) => {
+ if (typeof value !== 'object' || value === null) { return value }
+ if (styleId && value[styleId] !== undefined) { return value[styleId] }
+ if (value[scheme] !== undefined) { return value[scheme] }
+ if (value.light !== undefined) { return value.light }
+ return Object.values(value)[0]
+}
+
+/**
+ * Resolve all draw-ol colors for the given map style and plugin config overrides.
+ *
+ * Values in pluginConfig may be plain strings or variant objects (e.g. { light: '...', dark: '...' }).
+ * Variant resolution order: exact style ID match → color scheme → 'light' fallback → first value.
+ *
+ * @param {object|null} mapStyle - Current map style object (has .id and .mapColorScheme)
+ * @param {object} pluginConfig - Plugin-level user overrides (may override any DEFAULTS key)
+ * @returns {object} Flat color values ready for use in createStyles()
+ */
+export const resolveColors = (mapStyle, pluginConfig = {}) => {
+ const scheme = mapStyle?.mapColorScheme ?? 'light'
+ const styleId = mapStyle?.id ?? null
+ const r = (key) => resolveVariant(pluginConfig[key] ?? DEFAULTS[key], scheme, styleId)
+
+ return {
+ editStroke: r('editStroke'),
+ editVertex: r('editVertex'),
+ editMidpoint: r('editMidpoint'),
+ editActive: r('editActive'),
+ editHalo: r('editHalo'),
+ shapeStroke: r('shapeStroke'),
+ strokeWidth: pluginConfig.strokeWidth ?? DEFAULTS.strokeWidth,
+ shapeFill: r('shapeFill'),
+ snapVertex: r('snapVertex'),
+ snapEdge: r('snapEdge'),
+ mapStyleId: styleId
+ }
+}
diff --git a/plugins/beta/draw-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..663ff499
--- /dev/null
+++ b/plugins/beta/draw-ol/src/utils/touchTarget.js
@@ -0,0 +1,61 @@
+/**
+ * SVG offset vertex target — shown below the finger in touch edit mode
+ * so the user can accurately reposition a vertex without finger occlusion.
+ * This module handles DOM management only; drag logic lives in touchHandler.js.
+ *
+ * The SVG uses CSS custom properties so colors update when the map style changes:
+ * --draw-halo outer ring (black on light, white on dark)
+ * --draw-bg inner background (white on light, near-black on dark)
+ * --draw-primary arrow icons and centre dot (blue on light, white on dark)
+ */
+
+const SVG_HTML = `
+
+`
+
+export const createTouchTarget = (container) => {
+ let el = container.querySelector('[data-im-draw-touch-target]')
+ if (!el) {
+ container.insertAdjacentHTML('beforeend', SVG_HTML)
+ el = container.querySelector('[data-im-draw-touch-target]')
+ }
+ return el
+}
+
+export const applyTouchTargetColors = (el, colors) => {
+ if (!el) {
+ return
+ }
+ el.style.setProperty('--draw-halo', colors.editActive)
+ el.style.setProperty('--draw-bg', colors.editHalo)
+ el.style.setProperty('--draw-primary', colors.editVertex)
+}
+
+export const showTouchTarget = (el, pixel) => {
+ if (!pixel || !el) {
+ return
+ }
+ el.style.left = `${pixel.x}px`
+ el.style.top = `${pixel.y}px`
+ el.style.display = 'block'
+}
+
+export const hideTouchTarget = (el) => {
+ if (el) {
+ el.style.display = 'none'
+ }
+}
+
+/** True when the event target is part of the SVG touch target element. */
+export const isOnTouchTarget = (el) => {
+ if (!el) {
+ return false
+ }
+ const parent = el.parentNode
+ return (parent instanceof globalThis.SVGElement) || (parent?.ownerSVGElement != null)
+}
diff --git a/providers/beta/openlayers/src/utils/tileLayers.js b/providers/beta/openlayers/src/utils/tileLayers.js
index f100aec3..81b85ea3 100644
--- a/providers/beta/openlayers/src/utils/tileLayers.js
+++ b/providers/beta/openlayers/src/utils/tileLayers.js
@@ -5,9 +5,13 @@ 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'
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)
+
const CRS = 'EPSG:27700'
export function fetchWithTransform (url, resourceType, transformRequest) {
diff --git a/providers/beta/openlayers/src/utils/tileLayers.test.js b/providers/beta/openlayers/src/utils/tileLayers.test.js
index b24a9b48..9bf77102 100644
--- a/providers/beta/openlayers/src/utils/tileLayers.test.js
+++ b/providers/beta/openlayers/src/utils/tileLayers.test.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))
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/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()
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'),