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'),