diff --git a/demo/js/index.js b/demo/js/index.js
index a09b73db..7d3e5b1d 100755
--- a/demo/js/index.js
+++ b/demo/js/index.js
@@ -23,7 +23,14 @@ var interactPlugin = createInteractPlugin({
layerId: 'linked-parcels',
// idProperty: 'id'
},{
- layerId: 'OS/TopographicArea_1/Agricultural Land'
+ layerId: 'OS/TopographicArea_1/Agricultural Land',
+ idProperty: 'TOID'
+ },{
+ layerId: 'fill-inactive.cold',
+ idProperty: 'id'
+ },{
+ layerId: 'stroke-inactive.cold',
+ idProperty: 'id'
}],
interactionMode: 'select', // 'auto', 'select', 'marker' // defaults to 'marker'
multiSelect: true,
@@ -32,10 +39,10 @@ var interactPlugin = createInteractPlugin({
})
var drawPlugin = createDrawPlugin({
- //snapLayers: ['OS/TopographicLine/Building Outline']
+ snapLayers: ['OS/TopographicArea_1/Agricultural Land', 'OS/TopographicLine/Building Outline']
})
-let framePlugin = createFramePlugin({
+var framePlugin = createFramePlugin({
aspectRatio: 1.5
})
@@ -139,7 +146,7 @@ var interactiveMap = new InteractiveMap('map', {
showMarker: false,
// isExpanded: true
}),
- useLocationPlugin(),
+ // useLocationPlugin(),
interactPlugin,
framePlugin,
drawPlugin
@@ -155,10 +162,12 @@ interactiveMap.on('map:ready', function (e) {
// framePlugin.addFrame('test', {
// aspectRatio: 1
// })
- interactPlugin.enable()
+ interactPlugin.enable({
+ debug: true
+ })
})
-interactiveMap.on('datasets:ready', () => {
+interactiveMap.on('datasets:ready', function () {
// datasetsPlugin.hideFeatures({
// featureIds: [1148, 1134],
// idProperty: 'gid',
@@ -166,32 +175,117 @@ interactiveMap.on('datasets:ready', () => {
// })
})
+// Ref to the selected feature
+var selectedFeatureId = null
+
interactiveMap.on('draw:ready', function () {
- // drawPlugin.addFeature({
- // id: 'test1234',
- // type: 'Feature',
- // geometry: { type: 'Polygon', coordinates: [[[-2.9406643378873127,54.918060570259456],[-2.9092219779267054,54.91564249172612],[-2.904350626383433,54.90329530000005],[-2.909664828067463,54.89540129642464],[-2.9225074821353587,54.88979816151294],[-2.937121536764323,54.88826989853317],[-2.95682836800691,54.88916139231736],[-2.965463945742613,54.898966521920045],[-2.966349646023133,54.910805898763385],[-2.9406643378873127,54.918060570259456]]] },
- // properties: {
- // stroke: 'rgba(0,112,60,1)',
- // fill: 'rgba(0,112,60,0.2)',
- // strokeWidth: 2,
- // }
- // })
+ interactiveMap.addButton('drawPolygon', {
+ label: 'Draw polygon',
+ group: 'Drawing tools',
+ iconSvgContent: '',
+ isPressed: false,
+ mobile: { slot: 'right-top' },
+ tablet: { slot: 'right-top' },
+ desktop: { slot: 'right-top' },
+ onClick: function (e) {
+ e.target.setAttribute('aria-pressed', true)
+ drawPlugin.newPolygon(crypto.randomUUID(), {
+ stroke: '#e6c700',
+ fill: 'rgba(255, 221, 0, 0.1)'
+ })
+ }
+ })
+ interactiveMap.addButton('drawLine', {
+ label: 'Draw line',
+ group: 'Drawing tools',
+ iconSvgContent: '',
+ isPressed: false,
+ mobile: { slot: 'right-top' },
+ tablet: { slot: 'right-top' },
+ desktop: { slot: 'right-top' },
+ onClick: function (e) {
+ e.target.setAttribute('aria-pressed', true)
+ drawPlugin.newLine(crypto.randomUUID(), {
+ stroke: { outdoor: '#99704a', dark: '#ffffff' }
+ })
+ }
+ })
+ interactiveMap.addButton('editFeature', {
+ label: 'Edit feature',
+ group: 'Drawing tools',
+ iconSvgContent: '',
+ isDisabled: true,
+ mobile: { slot: 'right-top' },
+ tablet: { slot: 'right-top' },
+ desktop: { slot: 'right-top' },
+ onClick: function (e) {
+ if (e.target.getAttribute('aria-disabled') === 'true') {
+ return
+ }
+ interactPlugin.disable()
+ drawPlugin.editFeature(selectedFeatureId)
+ }
+ })
+ interactiveMap.addButton('deleteFeature', {
+ label: 'Delete feature',
+ group: 'Drawing tools',
+ iconSvgContent: '',
+ isDisabled: true,
+ mobile: { slot: 'right-top' },
+ tablet: { slot: 'right-top' },
+ desktop: { slot: 'right-top' },
+ onClick: function (e) {
+ if (e.target.getAttribute('aria-disabled') === 'true') {
+ return
+ }
+ drawPlugin.deleteFeature(selectedFeatureId)
+ interactiveMap.toggleButtonState('drawPolygon', 'disabled', false)
+ interactiveMap.toggleButtonState('drawLine', 'disabled', false)
+ interactiveMap.toggleButtonState('editFeature', 'disabled', true)
+ interactiveMap.toggleButtonState('deleteFeature', 'disabled', true)
+ }
+ })
+ drawPlugin.addFeature({
+ id: 'test1234',
+ type: 'Feature',
+ geometry: {'type':'Polygon','coordinates':[[[-2.8792962,54.7095463],[-2.8773445,54.7089363],[-2.8755615,54.7080257],[-2.8750521,54.7079797],[-2.8740651,54.7079522],[-2.8734760,54.7086512],[-2.8739855,54.7091846],[-2.8748292,54.7098284],[-2.8752749,54.7103526],[-2.8762460,54.7104170],[-2.8765803,54.7103342],[-2.8783315,54.7105366],[-2.8784429,54.7101319],[-2.8786499,54.7099571],[-2.8791275,54.7099112],[-2.8792962,54.7095463]],[[-2.8779654,54.7097916],[-2.8768886,54.7094843],[-2.8758538,54.7094200],[-2.8754081,54.7096223],[-2.8754559,54.7099442],[-2.8756947,54.7102201],[-2.8761404,54.7102569],[-2.8767236,54.7101963],[-2.8774559,54.7102606],[-2.8778698,54.7101135],[-2.8779654,54.7097916]]]},
+ // geometry: { type: 'Polygon', coordinates: [[[-2.9406643378873127,54.918060570259456],[-2.9092219779267054,54.91564249172612],[-2.904350626383433,54.90329530000005],[-2.909664828067463,54.89540129642464],[-2.9225074821353587,54.88979816151294],[-2.937121536764323,54.88826989853317],[-2.95682836800691,54.88916139231736],[-2.965463945742613,54.898966521920045],[-2.966349646023133,54.910805898763385],[-2.9406643378873127,54.918060570259456]]] },
+ stroke: 'rgba(0,112,60,1)',
+ fill: 'rgba(0,112,60,0.2)',
+ strokeWidth: 2
+ })
// drawPlugin.split('test1234', {
// snapLayers: ['OS/TopographicArea_1/Agricultural Land']
// })
// drawPlugin.newPolygon('test', {
// snapLayers: ['OS/TopographicArea_1/Agricultural Land']
// })
- // drawPlugin.editFeature('test1234')
+ // drawPlugin.editFeature('test1234', {
+ // snapLayers: ['OS/TopographicArea_1/Agricultural Land']
+ // })
+})
+
+interactiveMap.on('draw:start', function (e) {
+ console.log('draw:start')
+ interactPlugin.disable()
})
interactiveMap.on('draw:create', function (e) {
- // console.log('draw:create', e)
+ console.log('draw:create')
})
interactiveMap.on('draw:update', function (e) {
- // console.log('draw:update', e)
+ console.log('draw:update')
+})
+
+interactiveMap.on('draw:done', function (e) {
+ console.log('draw:done')
+ interactPlugin.enable()
+})
+
+interactiveMap.on('draw:cancel', function (e) {
+ console.log('draw:cancel')
+ interactPlugin.enable()
})
interactiveMap.on('interact:done', function (e) {
@@ -200,14 +294,20 @@ interactiveMap.on('interact:done', function (e) {
interactiveMap.on('interact:cancel', function (e) {
console.log('interact:cancel', e)
+ interactPlugin.enable()
})
interactiveMap.on('interact:selectionchange', function (e) {
- console.log('interact:selectionchange', e)
+ var singleFeature = e.selectedFeatures.length === 1
+ selectedFeatureId = singleFeature ? e.selectedFeatures?.[0]?.featureId : null
+ interactiveMap.toggleButtonState('drawPolygon', 'disabled', !!singleFeature)
+ interactiveMap.toggleButtonState('drawLine', 'disabled', !!singleFeature)
+ interactiveMap.toggleButtonState('editFeature', 'disabled', !singleFeature)
+ interactiveMap.toggleButtonState('deleteFeature', 'disabled', !singleFeature)
})
interactiveMap.on('interact:markerchange', function (e) {
- console.log('interact:markerchange', e)
+ // console.log('interact:markerchange', e)
})
// Update selected feature
diff --git a/package-lock.json b/package-lock.json
index a12c78bb..4642457e 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -18,6 +18,7 @@
"@turf/line-intersect": "^7.3.3",
"@turf/point-to-line-distance": "^7.3.3",
"@turf/polygon-to-line": "^7.3.3",
+ "abortcontroller-polyfill": "^1.7.8",
"core-js": "^3.44.0",
"govuk-frontend": "^5.13.0",
"maplibre-gl": "^5.15.0",
@@ -58,7 +59,7 @@
"react": "^19.2.0",
"react-dom": "^19.2.0",
"remove-files-webpack-plugin": "^1.5.0",
- "resize-observer": "^1.0.4",
+ "resize-observer-polyfill": "^1.5.1",
"rimraf": "^6.1.0",
"sass": "^1.89.2",
"sass-loader": "^16.0.5",
@@ -6536,6 +6537,12 @@
"node": ">=18.0.0"
}
},
+ "node_modules/abortcontroller-polyfill": {
+ "version": "1.7.8",
+ "resolved": "https://registry.npmjs.org/abortcontroller-polyfill/-/abortcontroller-polyfill-1.7.8.tgz",
+ "integrity": "sha512-9f1iZ2uWh92VcrU9Y8x+LdM4DLj75VE0MJB8zuF1iUnroEptStw+DQ8EQPMUdfe5k+PkB1uUfDQfWbhstH8LrQ==",
+ "license": "MIT"
+ },
"node_modules/accepts": {
"version": "1.3.8",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
@@ -19323,12 +19330,12 @@
"dev": true,
"license": "MIT"
},
- "node_modules/resize-observer": {
- "version": "1.0.4",
- "resolved": "https://registry.npmjs.org/resize-observer/-/resize-observer-1.0.4.tgz",
- "integrity": "sha512-AQ2MdkWTng9d6JtjHvljiQR949qdae91pjSNugGGeOFzKIuLHvoZIYhUTjePla5hCFDwQHrnkciAIzjzdsTZew==",
+ "node_modules/resize-observer-polyfill": {
+ "version": "1.5.1",
+ "resolved": "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz",
+ "integrity": "sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==",
"dev": true,
- "license": "Apache-2.0"
+ "license": "MIT"
},
"node_modules/resolve": {
"version": "1.22.11",
diff --git a/package.json b/package.json
index f756090e..6f8ee001 100755
--- a/package.json
+++ b/package.json
@@ -85,7 +85,7 @@
"react": "^19.2.0",
"react-dom": "^19.2.0",
"remove-files-webpack-plugin": "^1.5.0",
- "resize-observer": "^1.0.4",
+ "resize-observer-polyfill": "^1.5.1",
"rimraf": "^6.1.0",
"sass": "^1.89.2",
"sass-loader": "^16.0.5",
@@ -124,6 +124,7 @@
"@turf/line-intersect": "^7.3.3",
"@turf/point-to-line-distance": "^7.3.3",
"@turf/polygon-to-line": "^7.3.3",
+ "abortcontroller-polyfill": "^1.7.8",
"core-js": "^3.44.0",
"govuk-frontend": "^5.13.0",
"maplibre-gl": "^5.15.0",
diff --git a/plugins/beta/draw-ml/src/api/addFeature.js b/plugins/beta/draw-ml/src/api/addFeature.js
index dd72e1fb..6d2a9122 100644
--- a/plugins/beta/draw-ml/src/api/addFeature.js
+++ b/plugins/beta/draw-ml/src/api/addFeature.js
@@ -1,3 +1,5 @@
+import { flattenStyleProperties } from '../utils/flattenStyleProperties.js'
+
export const addFeature = ({ mapProvider, services }, feature) => {
const { draw } = mapProvider
const { eventBus } = services
@@ -6,10 +8,20 @@ export const addFeature = ({ mapProvider, services }, feature) => {
return
}
+ // Extract style props from top level, flatten variants, merge with custom properties
+ const { stroke, fill, strokeWidth, properties, ...rest } = feature
+ const flatFeature = {
+ ...rest,
+ properties: {
+ ...properties,
+ ...flattenStyleProperties({ stroke, fill, strokeWidth })
+ }
+ }
+
// --- Add feature to draw instance
- draw.add(feature, {
+ draw.add(flatFeature, {
userProperties: true
})
- eventBus.emit('draw:add', feature)
-}
\ No newline at end of file
+ eventBus.emit('draw:add', flatFeature)
+}
diff --git a/plugins/beta/draw-ml/src/api/editFeature.js b/plugins/beta/draw-ml/src/api/editFeature.js
index ac5fd6cf..1b99d1b2 100644
--- a/plugins/beta/draw-ml/src/api/editFeature.js
+++ b/plugins/beta/draw-ml/src/api/editFeature.js
@@ -6,7 +6,7 @@ import { getSnapInstance } from '../utils/snapHelpers.js'
* @param {string} featureId - ID of the feature to edit
* @param {object} options - Options including snapLayers
*/
-export const editFeature = ({ appState, appConfig, mapState, pluginState, mapProvider }, featureId, options = {}) => {
+export const editFeature = ({ appState, appConfig, mapState, pluginConfig, pluginState, mapProvider }, featureId, options = {}) => {
const { dispatch } = pluginState
const { draw, map } = mapProvider
@@ -14,24 +14,35 @@ export const editFeature = ({ appState, appConfig, mapState, pluginState, mapPro
return
}
+ // Determin snapLayers from pluginConfig or runtime config
+ let snapLayers = null
+ if (options.snapLayers !== undefined) {
+ snapLayers = options.snapLayers
+ } else if (pluginConfig.snapLayers !== undefined) {
+ snapLayers = pluginConfig.snapLayers
+ } else {
+ snapLayers = null
+ }
+
// Set per-call snap layers if provided
const snap = getSnapInstance(map)
if (snap?.setSnapLayers) {
- snap.setSnapLayers(options.snapLayers || null)
- } else if (options.snapLayers) {
+ snap.setSnapLayers(snapLayers)
+ } else if (snapLayers) {
// Snap instance not ready yet - store for later
- map._pendingSnapLayers = options.snapLayers
+ map._pendingSnapLayers = snapLayers
} else {
// No action
}
// Update state so UI can react to snap layer availability
- dispatch({ type: 'SET_HAS_SNAP_LAYERS', payload: options.snapLayers?.length > 0 })
+ dispatch({ type: 'SET_HAS_SNAP_LAYERS', payload: snapLayers?.length > 0 })
// Change mode to edit_vertex
draw.changeMode('edit_vertex', {
container: appState.layoutRefs.viewportRef.current,
deleteVertexButtonId: `${appConfig.id}-draw-delete-point`,
+ undoButtonId: `${appConfig.id}-draw-undo`,
isPanEnabled: appState.interfaceType !== 'keyboard',
interfaceType: appState.interfaceType,
scale: { small: 1, medium: 1.5, large: 2 }[mapState.mapSize],
diff --git a/plugins/beta/draw-ml/src/api/newLine.js b/plugins/beta/draw-ml/src/api/newLine.js
index 4b036298..f7ec04e3 100644
--- a/plugins/beta/draw-ml/src/api/newLine.js
+++ b/plugins/beta/draw-ml/src/api/newLine.js
@@ -1,32 +1,54 @@
import { getSnapInstance } from '../utils/snapHelpers.js'
+import { flattenStyleProperties } from '../utils/flattenStyleProperties.js'
/**
* Programmatically create a new line
* @param {object} context - plugin context
* @param {string} featureId - ID for the new feature
- * @param {object} options - Options including snapLayers.
+ * @param {object} options - Options including snapLayers, stroke, fill, strokeWidth, properties.
*/
-export const newLine = ({ appState, appConfig, pluginState, mapProvider }, featureId, options = {}) => {
+export const newLine = ({ appState, appConfig, pluginConfig, pluginState, mapProvider, services }, featureId, options = {}) => {
const { dispatch } = pluginState
const { draw, map } = mapProvider
+ const { eventBus } = services
if (!draw) {
return
}
+ // Emit draw:start
+ eventBus.emit('draw:start', { mode: 'draw_line' })
+
+ // Determin snapLayers from pluginConfig or runtime config
+ let snapLayers = null
+ if (options.snapLayers !== undefined) {
+ snapLayers = options.snapLayers
+ } else if (pluginConfig.snapLayers !== undefined) {
+ snapLayers = pluginConfig.snapLayers
+ } else {
+ snapLayers = null
+ }
+
// Set per-call snap layers if provided
const snap = getSnapInstance(map)
if (snap?.setSnapLayers) {
- snap.setSnapLayers(options.snapLayers || null)
- } else if (options.snapLayers) {
+ snap.setSnapLayers(snapLayers)
+ } else if (snapLayers) {
// Snap instance not ready yet - store for later
- map._pendingSnapLayers = options.snapLayers
+ map._pendingSnapLayers = snapLayers
} else {
// No action
}
// Update state so UI can react to snap layer availability
- dispatch({ type: 'SET_HAS_SNAP_LAYERS', payload: options.snapLayers?.length > 0 })
+ dispatch({ type: 'SET_HAS_SNAP_LAYERS', payload: snapLayers?.length > 0 })
+
+ // Extract style props and flatten variants into properties
+ const { stroke, fill, strokeWidth, properties: customProperties, ...modeOptions } = options
+ const properties = {
+ ...customProperties,
+ ...flattenStyleProperties({ stroke, fill, strokeWidth })
+ }
// Change mode to draw_line
draw.changeMode('draw_line', {
@@ -35,7 +57,9 @@ export const newLine = ({ appState, appConfig, pluginState, mapProvider }, featu
addVertexButtonId: `${appConfig.id}-draw-add-point`,
interfaceType: appState.interfaceType,
getSnapEnabled: () => mapProvider.snapEnabled === true,
- featureId
+ featureId,
+ ...modeOptions,
+ properties
})
// Set mode to draw_line
diff --git a/plugins/beta/draw-ml/src/api/newPolygon.js b/plugins/beta/draw-ml/src/api/newPolygon.js
index 3cd5bd17..fc47de78 100644
--- a/plugins/beta/draw-ml/src/api/newPolygon.js
+++ b/plugins/beta/draw-ml/src/api/newPolygon.js
@@ -1,32 +1,54 @@
import { getSnapInstance } from '../utils/snapHelpers.js'
+import { flattenStyleProperties } from '../utils/flattenStyleProperties.js'
/**
* Programmatically create a new polygon
* @param {object} context - plugin context
* @param {string} featureId - ID for the new feature
- * @param {object} options - Options including snapLayers.
+ * @param {object} options - Options including snapLayers, stroke, fill, strokeWidth, properties.
*/
-export const newPolygon = ({ appState, appConfig, pluginState, mapProvider }, featureId, options = {}) => {
+export const newPolygon = ({ appState, appConfig, pluginConfig, pluginState, mapProvider, services }, featureId, options = {}) => {
const { dispatch } = pluginState
const { draw, map } = mapProvider
+ const { eventBus } = services
if (!draw) {
return
}
+ // Emit draw:start
+ eventBus.emit('draw:start', { mode: 'draw_polygon' })
+
+ // Determin snapLayers from pluginConfig or runtime config
+ let snapLayers = null
+ if (options.snapLayers !== undefined) {
+ snapLayers = options.snapLayers
+ } else if (pluginConfig.snapLayers !== undefined) {
+ snapLayers = pluginConfig.snapLayers
+ } else {
+ snapLayers = null
+ }
+
// Set per-call snap layers if provided
const snap = getSnapInstance(map)
if (snap?.setSnapLayers) {
- snap.setSnapLayers(options.snapLayers || null)
- } else if (options.snapLayers) {
+ snap.setSnapLayers(snapLayers)
+ } else if (snapLayers) {
// Snap instance not ready yet - store for later
- map._pendingSnapLayers = options.snapLayers
+ map._pendingSnapLayers = snapLayers
} else {
// No action
}
// Update state so UI can react to snap layer availability
- dispatch({ type: 'SET_HAS_SNAP_LAYERS', payload: options.snapLayers?.length > 0 })
+ dispatch({ type: 'SET_HAS_SNAP_LAYERS', payload: snapLayers?.length > 0 })
+
+ // Extract style props and flatten variants into properties
+ const { stroke, fill, strokeWidth, properties: customProperties, ...modeOptions } = options
+ const properties = {
+ ...customProperties,
+ ...flattenStyleProperties({ stroke, fill, strokeWidth })
+ }
// Change mode to draw_polygon
draw.changeMode('draw_polygon', {
@@ -35,9 +57,11 @@ export const newPolygon = ({ appState, appConfig, pluginState, mapProvider }, fe
addVertexButtonId: `${appConfig.id}-draw-add-point`,
interfaceType: appState.interfaceType,
getSnapEnabled: () => mapProvider.snapEnabled === true,
- featureId
+ featureId,
+ ...modeOptions,
+ properties
})
// Set mode to draw_polygon
dispatch({ type: 'SET_MODE', payload: 'draw_polygon' })
-}
\ No newline at end of file
+}
diff --git a/plugins/beta/draw-ml/src/defaults.js b/plugins/beta/draw-ml/src/defaults.js
index 59a498d4..cac5db78 100644
--- a/plugins/beta/draw-ml/src/defaults.js
+++ b/plugins/beta/draw-ml/src/defaults.js
@@ -7,6 +7,7 @@ export const DEFAULTS = {
stroke: 'rgba(212,53,28,1)',
strokeWidth: 2,
fill: 'rgba(212,53,28,0.1)',
+ snapLayers: [],
snapColors: {
vertex: 'rgba(212,53,28,1)',
midpoint: 'rgba(40,161,151,1)',
diff --git a/plugins/beta/draw-ml/src/mapboxSnap.js b/plugins/beta/draw-ml/src/mapboxSnap.js
index d5ba14b9..4a38ac64 100644
--- a/plugins/beta/draw-ml/src/mapboxSnap.js
+++ b/plugins/beta/draw-ml/src/mapboxSnap.js
@@ -112,9 +112,9 @@ function applyMapboxSnapPatches(colors) {
return r
}
- // Skip when disabled, clean up internal arrays to prevent memory accumulation
+ // Skip when disabled or zooming, clean up internal arrays to prevent memory accumulation
proto.snapToClosestPoint = function(e) {
- if (!this.status) {
+ if (!this.status || this.map?._isZooming) {
return
}
try {
@@ -297,18 +297,20 @@ export function initMapLibreSnap(map, draw, snapOptions = {}) {
)
})
- // Hide snap indicator during zoom
+ // Suppress snap processing during zoom (indicator freezes in place)
map.on('zoomstart', () => {
- if (map.getLayer(SNAP_HELPER_LAYER)) {
- map.setLayoutProperty(SNAP_HELPER_LAYER, 'visibility', 'none')
- }
+ map._isZooming = true
})
map.on('zoomend', () => {
- // Only show indicator if snap is enabled
- const snap = map._snapInstance
- if (map.getLayer(SNAP_HELPER_LAYER) && snap?.status) {
- map.setLayoutProperty(SNAP_HELPER_LAYER, 'visibility', 'visible')
+ map._isZooming = false
+ // Force hide then re-show to reset indicator at new zoom level (Safari fix)
+ if (map.getLayer(SNAP_HELPER_LAYER)) {
+ map.setLayoutProperty(SNAP_HELPER_LAYER, 'visibility', 'none')
+ const snap = map._snapInstance
+ if (snap?.status) {
+ map.setLayoutProperty(SNAP_HELPER_LAYER, 'visibility', 'visible')
+ }
}
})
diff --git a/plugins/beta/draw-ml/src/modes/createDrawMode.js b/plugins/beta/draw-ml/src/modes/createDrawMode.js
index 6319fc4a..e14558b4 100644
--- a/plugins/beta/draw-ml/src/modes/createDrawMode.js
+++ b/plugins/beta/draw-ml/src/modes/createDrawMode.js
@@ -226,7 +226,8 @@ export const createDrawMode = (ParentMode, config) => {
interfaceType: state.interfaceType,
vertexMarkerId: state.vertexMarkerId,
addVertexButtonId: state.addVertexButtonId,
- getSnapEnabled: state.getSnapEnabled
+ getSnapEnabled: state.getSnapEnabled,
+ properties: state.properties
})
return true
}
@@ -236,7 +237,7 @@ export const createDrawMode = (ParentMode, config) => {
const initialCoords = [[center.lng, center.lat], [center.lng, center.lat]]
const newFeature = this.newFeature({
type: 'Feature',
- properties: {},
+ properties: state.properties || {},
geometry: {
type: geometryType,
coordinates: [initialCoords]
@@ -343,7 +344,7 @@ export const createDrawMode = (ParentMode, config) => {
const feature = e.features[0]
draw.delete(feature.id)
feature.id = state.featureId
- draw.add(feature)
+ draw.add(feature, { userProperties: true })
},
onVertexButtonClick(state, e) {
diff --git a/plugins/beta/draw-ml/src/modes/editVertex/geometryHelpers.js b/plugins/beta/draw-ml/src/modes/editVertex/geometryHelpers.js
new file mode 100644
index 00000000..47e9e330
--- /dev/null
+++ b/plugins/beta/draw-ml/src/modes/editVertex/geometryHelpers.js
@@ -0,0 +1,135 @@
+/**
+ * Pure geometry helper functions for multi-ring/multi-part geometry support.
+ * These functions handle coordinate transformations between flat arrays and
+ * hierarchical GeoJSON structures for Polygon, MultiPolygon, LineString, and MultiLineString.
+ */
+
+/**
+ * Get flat coordinates array from feature for all geometry types.
+ * Flattens all rings/parts into a single array for unified vertex navigation.
+ *
+ * @param {Object} feature - GeoJSON geometry object
+ * @returns {Array<[number, number]>} Flat array of all coordinates
+ */
+export const getCoords = (feature) => {
+ if (!feature?.coordinates) return []
+ switch (feature.type) {
+ case 'LineString':
+ return feature.coordinates
+ case 'Polygon':
+ return feature.coordinates.flat(1)
+ case 'MultiLineString':
+ return feature.coordinates.flat(1)
+ case 'MultiPolygon':
+ return feature.coordinates.flat(2)
+ default:
+ return []
+ }
+}
+
+/**
+ * Get segment metadata for multi-ring/multi-part geometries.
+ * Each segment represents a ring (for Polygon) or part (for Multi*).
+ *
+ * @param {Object} feature - GeoJSON geometry object
+ * @returns {Array<{start: number, length: number, path: number[], closed: boolean}>}
+ * Array of segment metadata objects where:
+ * - start: Starting index in flat coordinates array
+ * - length: Number of coordinates in this segment
+ * - path: Hierarchical path indices to reach this segment in GeoJSON
+ * - closed: Whether this segment is closed (true for Polygon rings)
+ */
+export const getRingSegments = (feature) => {
+ if (!feature?.coordinates) return []
+ const segments = []
+ let start = 0
+
+ switch (feature.type) {
+ case 'LineString':
+ segments.push({ start: 0, length: feature.coordinates.length, path: [], closed: false })
+ break
+ case 'Polygon':
+ feature.coordinates.forEach((ring, ringIdx) => {
+ segments.push({ start, length: ring.length, path: [ringIdx], closed: true })
+ start += ring.length
+ })
+ break
+ case 'MultiLineString':
+ feature.coordinates.forEach((line, lineIdx) => {
+ segments.push({ start, length: line.length, path: [lineIdx], closed: false })
+ start += line.length
+ })
+ break
+ case 'MultiPolygon':
+ feature.coordinates.forEach((polygon, polyIdx) => {
+ polygon.forEach((ring, ringIdx) => {
+ segments.push({ start, length: ring.length, path: [polyIdx, ringIdx], closed: true })
+ start += ring.length
+ })
+ })
+ break
+ default:
+ break
+ }
+
+ return segments
+}
+
+/**
+ * Find which segment a flat vertex index belongs to.
+ *
+ * @param {Array} segments - Array of segment metadata from getRingSegments
+ * @param {number} flatIdx - Flat vertex index
+ * @returns {{segment: Object, localIdx: number}|null}
+ * Object with segment metadata and local index within that segment, or null if not found
+ */
+export const getSegmentForIndex = (segments, flatIdx) => {
+ for (const seg of segments) {
+ if (flatIdx >= seg.start && flatIdx < seg.start + seg.length) {
+ return { segment: seg, localIdx: flatIdx - seg.start }
+ }
+ }
+ return null
+}
+
+/**
+ * Get modifiable coordinate array at a specific hierarchical path.
+ * Returns a reference to the actual coordinate array in the GeoJSON structure.
+ *
+ * @param {Object} geojson - Full GeoJSON feature object
+ * @param {number[]} path - Hierarchical path indices (e.g., [0] for first ring, [1, 0] for second polygon's first ring)
+ * @returns {Array<[number, number]>} Reference to coordinate array at path
+ */
+export const getModifiableCoords = (geojson, path) => {
+ let coords = geojson.geometry.coordinates
+ for (const idx of path) {
+ coords = coords[idx]
+ }
+ return coords
+}
+
+/**
+ * Convert mapbox-gl-draw coord_path string to flat vertex index.
+ * coord_path format: "ringIdx.vertexIdx" for Polygon, "polyIdx.ringIdx.vertexIdx" for MultiPolygon, etc.
+ *
+ * @param {Object} feature - GeoJSON geometry object
+ * @param {string} coordPath - coord_path string from mapbox-gl-draw
+ * @returns {number} Flat vertex index in the unified coordinate array
+ */
+export const coordPathToFlatIndex = (feature, coordPath) => {
+ const parts = coordPath.split('.').map(Number)
+ const segments = getRingSegments(feature)
+
+ // Match coord_path to segment
+ for (const seg of segments) {
+ // Check if path matches (compare all but last element which is the local vertex index)
+ const pathMatches = seg.path.every((val, idx) => val === parts[idx])
+ if (pathMatches && parts.length === seg.path.length + 1) {
+ const localIdx = parts[parts.length - 1]
+ return seg.start + localIdx
+ }
+ }
+
+ // Fallback: just use the last number (works for simple geometries)
+ return parts[parts.length - 1]
+}
diff --git a/plugins/beta/draw-ml/src/modes/editVertex/helpers.js b/plugins/beta/draw-ml/src/modes/editVertex/helpers.js
new file mode 100644
index 00000000..29e29e09
--- /dev/null
+++ b/plugins/beta/draw-ml/src/modes/editVertex/helpers.js
@@ -0,0 +1,2 @@
+export const scalePoint = (point, scale) => ({ x: point.x * scale, y: point.y * scale })
+export const isOnSVG = (el) => el instanceof window.SVGElement || el.ownerSVGElement
diff --git a/plugins/beta/draw-ml/src/modes/editVertex/touchHandlers.js b/plugins/beta/draw-ml/src/modes/editVertex/touchHandlers.js
new file mode 100644
index 00000000..0083cc03
--- /dev/null
+++ b/plugins/beta/draw-ml/src/modes/editVertex/touchHandlers.js
@@ -0,0 +1,134 @@
+import {
+ getSnapInstance, isSnapEnabled, triggerSnapAtPoint, getSnapLngLat,
+ clearSnapState, clearSnapIndicator
+} from '../../utils/snapHelpers.js'
+import { coordPathToFlatIndex } from './geometryHelpers.js'
+import { isOnSVG } from './helpers.js'
+
+const touchVertexTarget = `
+
+`
+
+export const touchHandlers = {
+ addTouchVertexTarget(state) {
+ let el = state.container.querySelector('[data-touch-vertex-target]')
+ if (!el) {
+ state.container.insertAdjacentHTML('beforeend', touchVertexTarget)
+ el = state.container.querySelector('[data-touch-vertex-target]')
+ }
+ state.touchVertexTarget = el
+ },
+
+ updateTouchVertexTarget(state, point) {
+ if (point && state.interfaceType === 'touch' && state.selectedVertexIndex >= 0) {
+ Object.assign(state.touchVertexTarget.style, { display: 'block', top: `${point.y}px`, left: `${point.x}px` })
+ } else {
+ state.touchVertexTarget.style.display = 'none'
+ }
+ },
+
+ hideTouchVertexIndicator(state) {
+ state.touchVertexTarget.style.display = 'none'
+ },
+
+ onPointerevent(state, e) {
+ state.interfaceType = e.pointerType === 'touch' ? 'touch' : 'pointer'
+ state.isPanEnabled = true
+ if (e.pointerType === 'touch' && e.type === 'pointermove' && !isOnSVG(e.target.parentNode) && !state._ignorePointermoveDeselect) {
+ this.changeMode(state, { selectedVertexIndex: -1, selectedVertexType: null, coordPath: null })
+ }
+ },
+
+ // Empty stubs required by DirectSelect
+ onTouchStart() {},
+ onTouchMove() {},
+ onTouchEnd() {},
+
+ onTouchend(state) {
+ clearSnapState(getSnapInstance(this.map))
+ if (state?.featureId) {
+ this.syncVertices(state)
+
+ // Push undo for the move if touch actually moved
+ if (state._touchMoved && state._moveStartPosition && state._moveStartIndex !== undefined) {
+ this.pushUndo({
+ type: 'move_vertex',
+ featureId: state.featureId,
+ vertexIndex: state._moveStartIndex,
+ previousPosition: state._moveStartPosition
+ })
+ }
+ state._moveStartPosition = null
+ state._moveStartIndex = undefined
+ state._touchMoved = false
+ }
+ },
+
+ onTap(state, e) {
+ // Hide snap indicator on any tap
+ const snap = getSnapInstance(this.map)
+ if (snap) {
+ clearSnapIndicator(snap, this.map)
+ }
+
+ const meta = e.featureTarget?.properties.meta
+ const coordPath = e.featureTarget?.properties.coord_path
+
+ if (meta === 'vertex') {
+ const feature = this.getFeature(state.featureId)
+ const idx = coordPathToFlatIndex(feature, coordPath)
+ this.changeMode(state, {
+ selectedVertexIndex: idx,
+ selectedVertexType: 'vertex',
+ coordPath
+ })
+ } else if (meta === 'midpoint') {
+ this.insertVertex({ ...state, selectedVertexIndex: this.getVertexIndexFromMidpoint(state, coordPath), selectedVertexType: 'midpoint' })
+ } else {
+ this.clickNoTarget(state)
+ }
+ },
+
+ onTouchstart(state, e) {
+ clearSnapState(getSnapInstance(this.map))
+ const vertex = state.vertecies?.[state.selectedVertexIndex]
+ if (!vertex || !isOnSVG(e.target.parentNode)) {
+ return
+ }
+
+ // Save starting position for undo
+ state._moveStartPosition = [...vertex]
+ state._moveStartIndex = state.selectedVertexIndex
+ state._touchMoved = false
+
+ const touch = { x: e.touches[0].clientX, y: e.touches[0].clientY }
+ const style = window.getComputedStyle(state.touchVertexTarget)
+ state.deltaTarget = { x: touch.x - Number.parseFloat(style.left), y: touch.y - Number.parseFloat(style.top) }
+ const vertexPt = this.map.project(vertex)
+ state.deltaVertex = { x: (touch.x / state.scale) - vertexPt.x, y: (touch.y / state.scale) - vertexPt.y }
+ },
+
+ onTouchmove(state, e) {
+ if (state.selectedVertexIndex < 0 || !isOnSVG(e.target.parentNode)) {
+ return
+ }
+
+ state._touchMoved = true
+
+ const touch = { x: e.touches[0].clientX, y: e.touches[0].clientY }
+ const screenPt = { x: (touch.x / state.scale) - state.deltaVertex.x, y: (touch.y / state.scale) - state.deltaVertex.y }
+
+ let finalCoord = this.map.unproject(screenPt)
+ if (isSnapEnabled(state)) {
+ const snap = getSnapInstance(this.map)
+ triggerSnapAtPoint(snap, this.map, screenPt)
+ finalCoord = getSnapLngLat(snap) || finalCoord
+ }
+
+ this.moveVertex(state, finalCoord)
+ this.updateTouchVertexTarget(state, { x: touch.x - state.deltaTarget.x, y: touch.y - state.deltaTarget.y })
+ }
+}
diff --git a/plugins/beta/draw-ml/src/modes/editVertex/undoHandlers.js b/plugins/beta/draw-ml/src/modes/editVertex/undoHandlers.js
new file mode 100644
index 00000000..762f56e3
--- /dev/null
+++ b/plugins/beta/draw-ml/src/modes/editVertex/undoHandlers.js
@@ -0,0 +1,133 @@
+import {
+ getRingSegments,
+ getSegmentForIndex,
+ getModifiableCoords
+} from './geometryHelpers.js'
+import { scalePoint } from './helpers.js'
+
+export const undoHandlers = {
+ // Fire geometry change event (for external listeners)
+ fireGeometryChange(state) {
+ const feature = this.getFeature(state.featureId)
+ if (feature) {
+ this.map.fire('draw.update', {
+ features: [feature.toGeoJSON()],
+ action: 'change_coordinates'
+ })
+ }
+ },
+
+ // Undo support
+ pushUndo(operation) {
+ const undoStack = this.map._undoStack
+ if (!undoStack) {
+ return
+ }
+ undoStack.push(operation)
+ },
+
+ handleUndo(state) {
+ const undoStack = this.map._undoStack
+ if (!undoStack || undoStack.length === 0) {
+ return
+ }
+
+ const op = undoStack.pop()
+
+ if (op.type === 'move_vertex') {
+ this.undoMoveVertex(state, op)
+ } else if (op.type === 'insert_vertex') {
+ this.undoInsertVertex(state, op)
+ } else if (op.type === 'delete_vertex') {
+ this.undoDeleteVertex(state, op)
+ }
+ },
+
+ undoMoveVertex(state, op) {
+ const { vertexIndex, previousPosition, featureId } = op
+ const feature = this.getFeature(featureId)
+ if (!feature) return
+
+ const geojson = feature.toGeoJSON()
+ const segments = getRingSegments(feature)
+ const result = getSegmentForIndex(segments, vertexIndex)
+ if (!result) return
+
+ const coords = getModifiableCoords(geojson, result.segment.path)
+ coords[result.localIdx] = previousPosition
+ this._applyUndoAndSync(state, geojson, featureId)
+
+ // Update touch vertex target position
+ const vertex = state.vertecies[state.selectedVertexIndex]
+ if (vertex) {
+ this.updateTouchVertexTarget(state, scalePoint(this.map.project(vertex), state.scale))
+ }
+ },
+
+ undoInsertVertex(state, op) {
+ const { vertexIndex, featureId } = op
+ const feature = this.getFeature(featureId)
+ if (!feature) return
+
+ const geojson = feature.toGeoJSON()
+ const segments = getRingSegments(feature)
+ const result = getSegmentForIndex(segments, vertexIndex)
+ if (!result) return
+
+ const coords = getModifiableCoords(geojson, result.segment.path)
+ coords.splice(result.localIdx, 1)
+ this._applyUndoAndSync(state, geojson, featureId)
+
+ // Clear DirectSelect's coordinate selection
+ this.clearSelectedCoordinates()
+ this.hideTouchVertexIndicator(state)
+ this.changeMode(state, { selectedVertexIndex: -1, selectedVertexType: null })
+ },
+
+ undoDeleteVertex(state, op) {
+ const { vertexIndex, position, featureId } = op
+ const feature = this.getFeature(featureId)
+ if (!feature) {
+ return
+ }
+
+ const geojson = feature.toGeoJSON()
+ const segments = getRingSegments(feature)
+
+ // Try to find segment containing vertexIndex
+ let result = getSegmentForIndex(segments, vertexIndex)
+
+ // If not found, vertex might be at segment boundary
+ if (!result) {
+ for (const seg of segments) {
+ if (vertexIndex === seg.start + seg.length) {
+ result = { segment: seg, localIdx: seg.length }
+ break
+ }
+ }
+ }
+
+ if (!result) {
+ return
+ }
+
+ const coords = getModifiableCoords(geojson, result.segment.path)
+ coords.splice(result.localIdx, 0, position)
+ this._applyUndoAndSync(state, geojson, featureId)
+
+ // Update touch vertex target to restored vertex position
+ const vertex = state.vertecies[vertexIndex]
+ if (vertex) {
+ this.updateTouchVertexTarget(state, scalePoint(this.map.project(vertex), state.scale))
+ }
+ this.changeMode(state, { selectedVertexIndex: vertexIndex, selectedVertexType: 'vertex', coordPath: this.getCoordPath(state, vertexIndex) })
+ },
+
+ _applyUndoAndSync(state, geojson, featureId) {
+ this._ctx.api.add(geojson)
+ state.vertecies = this.getVerticies(featureId)
+ state.midpoints = this.getMidpoints(featureId)
+ this._ctx.store.render()
+ this.fireGeometryChange(state)
+ }
+}
diff --git a/plugins/beta/draw-ml/src/modes/editVertex/vertexOperations.js b/plugins/beta/draw-ml/src/modes/editVertex/vertexOperations.js
new file mode 100644
index 00000000..e8bf6981
--- /dev/null
+++ b/plugins/beta/draw-ml/src/modes/editVertex/vertexOperations.js
@@ -0,0 +1,141 @@
+import {
+ getCoords,
+ getRingSegments,
+ getSegmentForIndex,
+ getModifiableCoords
+} from './geometryHelpers.js'
+
+const ARROW_OFFSETS = { ArrowUp: [0, -1], ArrowDown: [0, 1], ArrowLeft: [-1, 0], ArrowRight: [1, 0] }
+const NUDGE = 1, STEP = 5
+
+export const vertexOperations = {
+ updateMidpoint(coordinates) {
+ setTimeout(() => {
+ this.map.getSource('mapbox-gl-draw-hot').setData({
+ type: 'Feature',
+ properties: { meta: 'midpoint', active: 'true', id: 'active-midpoint' },
+ geometry: { type: 'Point', coordinates }
+ })
+ }, 0)
+ },
+
+ updateVertex(state, direction) {
+ const [idx, type] = this.getVertexOrMidpoint(state, direction)
+ if (idx < 0 || !type) {
+ return
+ }
+ this.changeMode(state, { selectedVertexIndex: idx, selectedVertexType: type, ...(type === 'vertex' && { coordPath: this.getCoordPath(state, idx) }) })
+ },
+
+ getOffset(coord, e) {
+ const pt = this.map.project(coord)
+ const offset = e?.shiftKey ? NUDGE : STEP
+ const [dx, dy] = e ? ARROW_OFFSETS[e.key].map(v => v * offset) : [0, 0]
+ return this.map.unproject({ x: pt.x + dx, y: pt.y + dy })
+ },
+
+ getNewCoord(state, e) {
+ return this.getOffset(getCoords(this.getFeature(state.featureId))[state.selectedVertexIndex], e)
+ },
+
+ insertVertex(state, e) {
+ const midIdx = state.selectedVertexIndex - state.vertecies.length
+ const newCoord = this.getOffset(state.midpoints[midIdx], e)
+ const feature = this.getFeature(state.featureId)
+ const geojson = feature.toGeoJSON()
+
+ // Find which segment this midpoint belongs to and calculate insertion position
+ const segments = getRingSegments(feature)
+ let globalInsertIdx = midIdx + 1
+ let insertSegment = null
+ let localInsertIdx = 0
+
+ // Map midpoint index to segment and local position
+ let midpointCounter = 0
+ for (const seg of segments) {
+ // Must match getMidpoints calculation
+ const segMidpoints = seg.closed ? seg.length : seg.length - 1
+ if (midIdx < midpointCounter + segMidpoints) {
+ insertSegment = seg
+ localInsertIdx = (midIdx - midpointCounter) + 1
+ globalInsertIdx = seg.start + localInsertIdx
+ break
+ }
+ midpointCounter += segMidpoints
+ }
+
+ if (!insertSegment) return
+
+ const coords = getModifiableCoords(geojson, insertSegment.path)
+ coords.splice(localInsertIdx, 0, [newCoord.lng, newCoord.lat])
+ this._ctx.api.add(geojson)
+
+ this.pushUndo({ type: 'insert_vertex', featureId: state.featureId, vertexIndex: globalInsertIdx })
+ this.changeMode(state, { selectedVertexIndex: globalInsertIdx, selectedVertexType: 'vertex', coordPath: this.getCoordPath(state, globalInsertIdx) })
+ },
+
+ moveVertex(state, coord, options = {}) {
+ if (options.checkSnap && state.enableSnap !== false) {
+ const snap = this.map._snapInstance
+ if (snap?.snapStatus && snap.snapCoords?.length >= 2) {
+ coord = { lng: snap.snapCoords[0], lat: snap.snapCoords[1] }
+ }
+ }
+
+ const feature = this.getFeature(state.featureId)
+ const geojson = feature.toGeoJSON()
+ const segments = getRingSegments(feature)
+ const result = getSegmentForIndex(segments, state.selectedVertexIndex)
+ if (!result) return
+
+ const coords = getModifiableCoords(geojson, result.segment.path)
+ coords[result.localIdx] = [coord.lng, coord.lat]
+ this._ctx.api.add(geojson)
+ state.vertecies = this.getVerticies(state.featureId)
+
+ this.map.fire('draw.geometrychange', state.feature)
+ },
+
+ deleteVertex(state) {
+ const feature = this.getFeature(state.featureId)
+ if (!feature) {
+ return
+ }
+
+ const segments = getRingSegments(feature)
+ const result = getSegmentForIndex(segments, state.selectedVertexIndex)
+ if (!result) {
+ return
+ }
+
+ const { segment } = result
+ // Minimum vertices per segment: 3 for closed rings (mapbox-gl-draw's internal representation), 2 for lines
+ const minVertices = segment.closed ? 3 : 2
+ if (segment.length <= minVertices) {
+ return
+ }
+
+ // Save position for undo before deletion
+ const deletedPosition = [...state.vertecies[state.selectedVertexIndex]]
+ const deletedIndex = state.selectedVertexIndex
+
+ this._ctx.api.trash()
+
+ // Clear DirectSelect's coordinate selection to prevent visual artifacts
+ this.clearSelectedCoordinates()
+ // Force feature re-render to clear vertex highlights
+ feature.changed()
+ this._ctx.store.render()
+
+ // Push undo operation
+ this.pushUndo({
+ type: 'delete_vertex',
+ featureId: state.featureId,
+ vertexIndex: deletedIndex,
+ position: deletedPosition
+ })
+
+ // Clear selection after delete
+ this.changeMode(state, { selectedVertexIndex: -1, selectedVertexType: null })
+ }
+}
diff --git a/plugins/beta/draw-ml/src/modes/editVertex/vertexQueries.js b/plugins/beta/draw-ml/src/modes/editVertex/vertexQueries.js
new file mode 100644
index 00000000..5630cecf
--- /dev/null
+++ b/plugins/beta/draw-ml/src/modes/editVertex/vertexQueries.js
@@ -0,0 +1,121 @@
+import {
+ getCoords,
+ getRingSegments,
+ getSegmentForIndex
+} from './geometryHelpers.js'
+import { spatialNavigate } from '../../utils/spatial.js'
+
+export const vertexQueries = {
+ findVertexIndex(coords, targetCoord, currentIdx) {
+ // Search for vertex, preferring matches near currentIdx to handle duplicate coords (e.g., closing vertices)
+ const matches = []
+ coords.forEach((c, i) => {
+ if (c[0] === targetCoord[0] && c[1] === targetCoord[1]) {
+ matches.push(i)
+ }
+ })
+
+ if (matches.length === 0) return -1
+ if (matches.length === 1) return matches[0]
+
+ // Multiple matches - pick closest to current selection
+ if (currentIdx >= 0) {
+ return matches.reduce((best, idx) =>
+ Math.abs(idx - currentIdx) < Math.abs(best - currentIdx) ? idx : best
+ )
+ }
+ return matches[0]
+ },
+
+ getCoordPath(state, idx) {
+ const feature = this.getFeature(state.featureId)
+ if (!feature) return '0'
+
+ const segments = getRingSegments(feature)
+ const result = getSegmentForIndex(segments, idx)
+ if (!result) return '0'
+
+ const { segment, localIdx } = result
+ return [...segment.path, localIdx].join('.')
+ },
+
+ syncVertices(state) {
+ state.vertecies = this.getVerticies(state.featureId)
+ state.midpoints = this.getMidpoints(state.featureId)
+ },
+
+ getVerticies(featureId) {
+ return getCoords(this.getFeature(featureId)) || []
+ },
+
+ getMidpoints(featureId) {
+ const feature = this.getFeature(featureId)
+ const coords = getCoords(feature)
+ const segments = getRingSegments(feature)
+ if (!coords?.length || !segments.length) {
+ return []
+ }
+
+ const midpoints = []
+ // Create midpoints within each segment, respecting boundaries
+ for (const seg of segments) {
+ // For closed rings, create midpoint between every vertex including last→first
+ // For open lines, create midpoints only between consecutive vertices (no wrap-around)
+ const count = seg.closed ? seg.length : seg.length - 1
+ for (let i = 0; i < count; i++) {
+ const idx = seg.start + i
+ const nextIdx = seg.start + ((i + 1) % seg.length)
+ const [x1, y1] = coords[idx]
+ const [x2, y2] = coords[nextIdx]
+ midpoints.push([(x1 + x2) / 2, (y1 + y2) / 2])
+ }
+ }
+ return midpoints
+ },
+
+ getVertexOrMidpoint(state, direction) {
+ // Ensure vertices and midpoints are populated
+ if (!state.vertecies?.length) {
+ state.vertecies = this.getVerticies(state.featureId)
+ state.midpoints = this.getMidpoints(state.featureId)
+ }
+ if (!state.vertecies?.length) {
+ return [-1, null]
+ }
+ const project = (p) => p ? Object.values(this.map.project(p)) : null
+ const pixels = [...state.vertecies.map(project), ...state.midpoints.map(project)].filter(Boolean)
+ if (!pixels.length) {
+ return [-1, null]
+ }
+ const start = pixels[state.selectedVertexIndex] || Object.values(this.map.project(this.map.getCenter()))
+ const idx = spatialNavigate(start, pixels, direction)
+ return [idx, idx < state.vertecies.length ? 'vertex' : 'midpoint']
+ },
+
+ getVertexIndexFromMidpoint(state, coordPath) {
+ const feature = this.getFeature(state.featureId)
+ const segments = getRingSegments(feature)
+ const parts = coordPath.split('.').map(Number)
+
+ // Find which segment this coord_path belongs to
+ let midpointOffset = 0
+ for (const seg of segments) {
+ const pathMatches = seg.path.every((val, idx) => val === parts[idx])
+ if (pathMatches && parts.length === seg.path.length + 1) {
+ // In DirectSelect, midpoint coord_path represents the insertion index
+ // The midpoint between vertex N and N+1 has coord_path ending in N+1
+ // So our flat midpoint index is one less than the coord_path index
+ const insertionIdx = parts[parts.length - 1]
+ const localMidpointIdx = insertionIdx > 0 ? insertionIdx - 1 : seg.length - 2
+ // Midpoints are indexed after all vertices
+ return state.vertecies.length + midpointOffset + localMidpointIdx
+ }
+ // Count midpoints in this segment (must match getMidpoints calculation)
+ const segMidpoints = seg.closed ? seg.length : seg.length - 1
+ midpointOffset += segMidpoints
+ }
+
+ // Fallback
+ return state.vertecies.length
+ }
+}
diff --git a/plugins/beta/draw-ml/src/modes/editVertexMode.js b/plugins/beta/draw-ml/src/modes/editVertexMode.js
index 8d3a8c46..b4ff3a92 100755
--- a/plugins/beta/draw-ml/src/modes/editVertexMode.js
+++ b/plugins/beta/draw-ml/src/modes/editVertexMode.js
@@ -1,33 +1,24 @@
import DirectSelect from '../../../../../node_modules/@mapbox/mapbox-gl-draw/src/modes/direct_select.js'
-import { spatialNavigate } from '../utils/spatial.js'
import {
getSnapInstance, isSnapActive, isSnapEnabled, getSnapLngLat,
getSnapRadius, triggerSnapAtPoint, clearSnapIndicator, clearSnapState
} from '../utils/snapHelpers.js'
-
-const ARROW_KEYS = ['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown']
+import { getCoords, coordPathToFlatIndex } from './editVertex/geometryHelpers.js'
+import { scalePoint } from './editVertex/helpers.js'
+import { undoHandlers } from './editVertex/undoHandlers.js'
+import { touchHandlers } from './editVertex/touchHandlers.js'
+import { vertexOperations } from './editVertex/vertexOperations.js'
+import { vertexQueries } from './editVertex/vertexQueries.js'
+
+const ARROW_KEYS = new Set(['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'])
const ARROW_OFFSETS = { ArrowUp: [0, -1], ArrowDown: [0, 1], ArrowLeft: [-1, 0], ArrowRight: [1, 0] }
-const NUDGE = 1, STEP = 5
-
-const touchVertexTarget = `
-
-`
-
-const scalePoint = (point, scale) => ({ x: point.x * scale, y: point.y * scale })
-const isOnSVG = (el) => el instanceof window.SVGElement || el.ownerSVGElement
-
-// Helper to get flat coordinates array from feature (handles both Polygon and LineString)
-const getCoords = (feature) => feature?.type === 'LineString' ? feature.coordinates : feature?.coordinates?.flat(1)
-
-// Helper to get the modifiable coordinates array from geojson (for undo operations)
-const getModifiableCoords = (geojson) =>
- geojson.geometry.type === 'Polygon' ? geojson.geometry.coordinates[0] : geojson.geometry.coordinates
export const EditVertexMode = {
...DirectSelect,
+ ...undoHandlers,
+ ...touchHandlers,
+ ...vertexOperations,
+ ...vertexQueries,
onSetup(options) {
const state = DirectSelect.onSetup.call(this, options)
@@ -41,6 +32,7 @@ export const EditVertexMode = {
featureId: state.featureId || options.featureId,
selectedVertexIndex: options.selectedVertexIndex ?? -1,
selectedVertexType: options.selectedVertexType,
+ coordPath: options.coordPath,
scale: options.scale ?? 1
})
@@ -53,7 +45,23 @@ export const EditVertexMode = {
this.setupEventListeners(state)
if (options.selectedVertexType === 'midpoint') {
+ // Clear any vertex selection when switching to midpoint
+ state.selectedCoordPaths = []
+ this.clearSelectedCoordinates()
+ // Force feature re-render to clear vertex highlights
+ if (state.feature) {
+ state.feature.changed()
+ }
+ this._ctx.store.render()
this.updateMidpoint(state.midpoints[options.selectedVertexIndex - state.vertecies.length])
+ } else if (options.selectedVertexIndex === -1) {
+ // Explicitly clear selection when re-entering with no vertex selected
+ state.selectedCoordPaths = []
+ this.clearSelectedCoordinates()
+ if (state.feature) {
+ state.feature.changed()
+ }
+ this._ctx.store.render()
}
this.addTouchVertexTarget(state)
@@ -108,14 +116,17 @@ export const EditVertexMode = {
onSelectionChange(state, e) {
const vertexCoord = e.points[e.points.length - 1]?.geometry.coordinates
- const geom = e.features[0].geometry
- const coords = getCoords(geom)
- const idx = coords.findIndex(c => vertexCoord && c[0] === vertexCoord[0] && c[1] === vertexCoord[1])
// Only update selectedVertexIndex from event if not keyboard mode AND event has valid vertex
- if (state.interfaceType !== 'keyboard' && idx >= 0) {
- state.selectedVertexIndex = idx
+ // For keyboard mode or when we have coordPath, trust the existing selectedVertexIndex
+ if (state.interfaceType !== 'keyboard' && vertexCoord && !state.coordPath) {
+ // No coordPath available - need to search for vertex by coordinates
+ const geom = e.features[0]?.geometry
+ const coords = getCoords(geom)
+ state.selectedVertexIndex = this.findVertexIndex(coords, vertexCoord, state.selectedVertexIndex)
}
+ // If we have coordPath, selectedVertexIndex is already correct from onTap/changeMode
+
state.selectedVertexType ??= state.selectedVertexIndex >= 0 ? 'vertex' : null
this.map.fire('draw.vertexselection', {
@@ -164,7 +175,7 @@ export const EditVertexMode = {
return this.updateVertex(state)
}
- if (!e.altKey && ARROW_KEYS.includes(e.key) && state.selectedVertexIndex >= 0) {
+ if (!e.altKey && ARROW_KEYS.has(e.key) && state.selectedVertexIndex >= 0) {
e.preventDefault()
e.stopPropagation()
if (state.selectedVertexType === 'midpoint') {
@@ -210,7 +221,7 @@ export const EditVertexMode = {
return this.moveVertex(state, newCoord)
}
- if (e.altKey && ARROW_KEYS.includes(e.key) && state.selectedVertexIndex >= 0) {
+ if (e.altKey && ARROW_KEYS.has(e.key) && state.selectedVertexIndex >= 0) {
e.preventDefault()
e.stopPropagation()
return this.updateVertex(state, e.key)
@@ -234,7 +245,7 @@ export const EditVertexMode = {
onKeyup(state, e) {
state.interfaceType = 'keyboard'
- if (ARROW_KEYS.includes(e.key) && state.selectedVertexIndex >= 0) {
+ if (ARROW_KEYS.has(e.key) && state.selectedVertexIndex >= 0) {
e.stopPropagation()
// Push undo for keyboard move sequence
@@ -264,11 +275,13 @@ export const EditVertexMode = {
state.dragMoving = false
DirectSelect.onMouseDown.call(this, state, e)
- // Save starting position for undo (only for vertices, not midpoints)
+ // Update selection state for vertex clicks (so onSelectionChange has correct context)
if (meta === 'vertex' && coordPath) {
- // Extract vertex index from coord_path (e.g., "0.2" -> 2 for Polygon, "2" -> 2 for LineString)
- const parts = coordPath.split('.')
- const vertexIndex = Number.parseInt(parts[parts.length - 1], 10)
+ const feature = this.getFeature(state.featureId)
+ const vertexIndex = coordPathToFlatIndex(feature, coordPath)
+ state.selectedVertexIndex = vertexIndex
+ state.selectedVertexType = 'vertex'
+ state.coordPath = coordPath
const vertex = state.vertecies?.[vertexIndex]
if (vertex) {
state._moveStartPosition = [...vertex]
@@ -278,9 +291,8 @@ export const EditVertexMode = {
}
if (meta === 'midpoint') {
// DirectSelect converts midpoint to vertex - track this as an insert
- // Get the new vertex index (midpoint at position N becomes vertex at N+1)
- const parts = coordPath.split('.')
- const insertedIndex = Number.parseInt(parts[parts.length - 1], 10)
+ const feature = this.getFeature(state.featureId)
+ const insertedIndex = coordPathToFlatIndex(feature, coordPath)
// Track this insertion for undo (will be pushed on mouseUp if drag occurred)
state._insertedVertexIndex = insertedIndex
@@ -288,6 +300,7 @@ export const EditVertexMode = {
state.selectedVertexIndex = this.getVertexIndexFromMidpoint(state, coordPath)
state.selectedVertexType = 'vertex'
+ state.coordPath = null // Clear coordPath for midpoints
this.map.fire('draw.vertexselection', { index: state.selectedVertexIndex, numVertecies: state.vertecies.length })
}
},
@@ -344,108 +357,6 @@ export const EditVertexMode = {
DirectSelect.onMouseUp.call(this, state, e)
},
- onPointerevent(state, e) {
- state.interfaceType = e.pointerType === 'touch' ? 'touch' : 'pointer'
- state.isPanEnabled = true
- if (e.pointerType === 'touch' && e.type === 'pointermove' && !isOnSVG(e.target.parentNode) && !state._ignorePointermoveDeselect) {
- this.changeMode(state, { selectedVertexIndex: -1, selectedVertexType: null, coordPath: null })
- }
- },
-
- // Empty stubs required by DirectSelect
- onTouchStart() {},
- onTouchMove() {},
- onTouchEnd() {},
-
- onTouchend(state) {
- clearSnapState(getSnapInstance(this.map))
- if (state?.featureId) {
- this.syncVertices(state)
-
- // Push undo for the move if touch actually moved
- if (state._touchMoved && state._moveStartPosition && state._moveStartIndex !== undefined) {
- this.pushUndo({
- type: 'move_vertex',
- featureId: state.featureId,
- vertexIndex: state._moveStartIndex,
- previousPosition: state._moveStartPosition
- })
- }
- state._moveStartPosition = null
- state._moveStartIndex = undefined
- state._touchMoved = false
- }
- },
-
- clickNoTarget(state) {
- this.changeMode(state, { selectedVertexIndex: -1, selectedVertexType: null, isPanEnabled: true })
- },
-
- onTap(state, e) {
- // Hide snap indicator on any tap
- const snap = getSnapInstance(this.map)
- if (snap) {
- clearSnapIndicator(snap, this.map)
- }
-
- const meta = e.featureTarget?.properties.meta
- const coordPath = e.featureTarget?.properties.coord_path
-
- if (meta === 'vertex') {
- // Extract vertex index - handle both "0.1" (Polygon) and "1" (LineString) formats
- const parts = coordPath.split('.')
- const idx = Number.parseInt(parts[parts.length - 1], 10)
- this.changeMode(state, {
- selectedVertexIndex: idx,
- selectedVertexType: 'vertex', coordPath
- })
- } else if (meta === 'midpoint') {
- this.insertVertex({ ...state, selectedVertexIndex: this.getVertexIndexFromMidpoint(state, coordPath), selectedVertexType: 'midpoint' })
- } else {
- this.clickNoTarget(state)
- }
- },
-
- onTouchstart(state, e) {
- clearSnapState(getSnapInstance(this.map))
- const vertex = state.vertecies?.[state.selectedVertexIndex]
- if (!vertex || !isOnSVG(e.target.parentNode)) {
- return
- }
-
- // Save starting position for undo
- state._moveStartPosition = [...vertex]
- state._moveStartIndex = state.selectedVertexIndex
- state._touchMoved = false
-
- const touch = { x: e.touches[0].clientX, y: e.touches[0].clientY }
- const style = window.getComputedStyle(state.touchVertexTarget)
- state.deltaTarget = { x: touch.x - Number.parseFloat(style.left), y: touch.y - Number.parseFloat(style.top) }
- const vertexPt = this.map.project(vertex)
- state.deltaVertex = { x: (touch.x / state.scale) - vertexPt.x, y: (touch.y / state.scale) - vertexPt.y }
- },
-
- onTouchmove(state, e) {
- if (state.selectedVertexIndex < 0 || !isOnSVG(e.target.parentNode)) {
- return
- }
-
- state._touchMoved = true
-
- const touch = { x: e.touches[0].clientX, y: e.touches[0].clientY }
- const screenPt = { x: (touch.x / state.scale) - state.deltaVertex.x, y: (touch.y / state.scale) - state.deltaVertex.y }
-
- let finalCoord = this.map.unproject(screenPt)
- if (isSnapEnabled(state)) {
- const snap = getSnapInstance(this.map)
- triggerSnapAtPoint(snap, this.map, screenPt)
- finalCoord = getSnapLngLat(snap) || finalCoord
- }
-
- this.moveVertex(state, finalCoord)
- this.updateTouchVertexTarget(state, { x: touch.x - state.deltaTarget.x, y: touch.y - state.deltaTarget.y })
- },
-
onDrag(state, e) {
if (state.interfaceType === 'touch') {
return
@@ -493,194 +404,8 @@ export const EditVertexMode = {
}
},
- handleUndo(state) {
- const undoStack = this.map._undoStack
- if (!undoStack || undoStack.length === 0) {
- return
- }
-
- const op = undoStack.pop()
-
- if (op.type === 'move_vertex') {
- this.undoMoveVertex(state, op)
- } else if (op.type === 'insert_vertex') {
- this.undoInsertVertex(state, op)
- } else if (op.type === 'delete_vertex') {
- this.undoDeleteVertex(state, op)
- } else {
- // No action
- }
- },
-
- // Utility methods
- getCoordPath(state, idx) {
- // Use cached featureType or look it up
- const type = state.featureType || this.getFeature(state.featureId)?.type
- return type === 'LineString' ? `${idx}` : `0.${idx}`
- },
-
- syncVertices(state) {
- state.vertecies = this.getVerticies(state.featureId)
- state.midpoints = this.getMidpoints(state.featureId)
- },
-
- getVerticies(featureId) {
- return getCoords(this.getFeature(featureId)) || []
- },
-
- getMidpoints(featureId) {
- const feature = this.getFeature(featureId)
- const coords = getCoords(feature)
- if (!coords) {
- return []
- }
-
- // For lines, don't create midpoint between last and first vertex
- const count = feature.type === 'LineString' ? coords.length - 1 : coords.length
- const midpoints = []
- for (let i = 0; i < count; i++) {
- const next = coords[(i + 1) % coords.length]
- midpoints.push([(coords[i][0] + next[0]) / 2, (coords[i][1] + next[1]) / 2])
- }
- return midpoints
- },
-
- getVertexOrMidpoint(state, direction) {
- // Ensure vertices and midpoints are populated
- if (!state.vertecies?.length) {
- state.vertecies = this.getVerticies(state.featureId)
- state.midpoints = this.getMidpoints(state.featureId)
- }
- if (!state.vertecies?.length) {
- return [-1, null]
- }
- const project = (p) => p ? Object.values(this.map.project(p)) : null
- const pixels = [...state.vertecies.map(project), ...state.midpoints.map(project)].filter(Boolean)
- if (!pixels.length) {
- return [-1, null]
- }
- const start = pixels[state.selectedVertexIndex] || Object.values(this.map.project(this.map.getCenter()))
- const idx = spatialNavigate(start, pixels, direction)
- return [idx, idx < state.vertecies.length ? 'vertex' : 'midpoint']
- },
-
- getVertexIndexFromMidpoint(state, coordPath) {
- // Handle both "0.1" (Polygon) and "1" (LineString) formats
- const parts = coordPath.split('.')
- const afterIdx = Number.parseInt(parts[parts.length - 1], 10)
-
- // Get feature type from cache or look it up
- const featureType = state.featureType || this.getFeature(state.featureId)?.type
-
- // For LineString, coord_path is insertion position (1-indexed), so subtract 1 for midpoint array index
- // For Polygon, adjust for ring wrapping
- if (featureType === 'LineString') {
- return state.vertecies.length + (afterIdx - 1)
- }
- return state.vertecies.length + ((afterIdx - 1 + state.vertecies.length) % state.vertecies.length)
- },
-
- addTouchVertexTarget(state) {
- let el = state.container.querySelector('[data-touch-vertex-target]')
- if (!el) {
- state.container.insertAdjacentHTML('beforeend', touchVertexTarget)
- el = state.container.querySelector('[data-touch-vertex-target]')
- }
- state.touchVertexTarget = el
- },
-
- updateTouchVertexTarget(state, point) {
- if (point && state.interfaceType === 'touch' && state.selectedVertexIndex >= 0) {
- Object.assign(state.touchVertexTarget.style, { display: 'block', top: `${point.y}px`, left: `${point.x}px` })
- } else {
- state.touchVertexTarget.style.display = 'none'
- }
- },
-
- hideTouchVertexIndicator(state) {
- state.touchVertexTarget.style.display = 'none'
- },
-
- updateMidpoint(coordinates) {
- setTimeout(() => {
- this.map.getSource('mapbox-gl-draw-hot').setData({
- type: 'Feature',
- properties: { meta: 'midpoint', active: 'true', id: 'active-midpoint' },
- geometry: { type: 'Point', coordinates }
- })
- }, 0)
- },
-
- updateVertex(state, direction) {
- const [idx, type] = this.getVertexOrMidpoint(state, direction)
- if (idx < 0 || !type) {
- return
- }
- this.changeMode(state, { selectedVertexIndex: idx, selectedVertexType: type, ...(type === 'vertex' && { coordPath: this.getCoordPath(state, idx) }) })
- },
-
- getOffset(coord, e) {
- const pt = this.map.project(coord)
- const offset = e?.shiftKey ? NUDGE : STEP
- const [dx, dy] = e ? ARROW_OFFSETS[e.key].map(v => v * offset) : [0, 0]
- return this.map.unproject({ x: pt.x + dx, y: pt.y + dy })
- },
-
- getNewCoord(state, e) {
- return this.getOffset(getCoords(this.getFeature(state.featureId))[state.selectedVertexIndex], e)
- },
-
- insertVertex(state, e) {
- const midIdx = state.selectedVertexIndex - state.vertecies.length
- const newCoord = this.getOffset(state.midpoints[midIdx], e)
- const geojson = this.getFeature(state.featureId).toGeoJSON()
- const newIdx = midIdx + 1
- getModifiableCoords(geojson).splice(newIdx, 0, [newCoord.lng, newCoord.lat])
- this._ctx.api.add(geojson)
-
- this.pushUndo({ type: 'insert_vertex', featureId: state.featureId, vertexIndex: newIdx })
- this.changeMode(state, { selectedVertexIndex: newIdx, selectedVertexType: 'vertex', coordPath: this.getCoordPath(state, newIdx) })
- },
-
- moveVertex(state, coord, options = {}) {
- if (options.checkSnap && state.enableSnap !== false) {
- const snap = this.map._snapInstance
- if (snap?.snapStatus && snap.snapCoords?.length >= 2) {
- coord = { lng: snap.snapCoords[0], lat: snap.snapCoords[1] }
- }
- }
- const geojson = this.getFeature(state.featureId).toGeoJSON()
- getModifiableCoords(geojson)[state.selectedVertexIndex] = [coord.lng, coord.lat]
- this._ctx.api.add(geojson)
- state.vertecies = this.getVerticies(state.featureId)
-
- this.map.fire('draw.geometrychange', state.feature)
- },
-
- deleteVertex(state) {
- const featureType = state.featureType || this.getFeature(state.featureId)?.type
- // Minimum vertices: 3 for Polygon, 2 for LineString
- const minVertices = featureType === 'Polygon' ? 3 : 2
- if (state.vertecies.length <= minVertices) {
- return
- }
-
- // Save position for undo before deletion
- const deletedPosition = [...state.vertecies[state.selectedVertexIndex]]
- const deletedIndex = state.selectedVertexIndex
-
- const nextIdx = state.selectedVertexIndex >= state.vertecies.length - 1 ? state.selectedVertexIndex - 1 : state.selectedVertexIndex
- this._ctx.api.trash()
-
- // Push undo operation
- this.pushUndo({
- type: 'delete_vertex',
- featureId: state.featureId,
- vertexIndex: deletedIndex,
- position: deletedPosition
- })
-
- this.changeMode(state, { selectedVertexIndex: nextIdx, selectedVertexType: 'vertex', coordPath: this.getCoordPath(state, nextIdx) })
+ clickNoTarget(state) {
+ this.changeMode(state, { selectedVertexIndex: -1, selectedVertexType: null, isPanEnabled: true })
},
// Prevent selecting other features
@@ -691,86 +416,6 @@ export const EditVertexMode = {
this._ctx.api.changeMode('edit_vertex', { ...state, ...updates })
},
- // Fire geometry change event (for external listeners)
- fireGeometryChange(state) {
- const feature = this.getFeature(state.featureId)
- if (feature) {
- this.map.fire('draw.update', {
- features: [feature.toGeoJSON()],
- action: 'change_coordinates'
- })
- }
- },
-
- // Undo support
- pushUndo(operation) {
- const undoStack = this.map._undoStack
- if (!undoStack) {
- return
- }
- undoStack.push(operation)
- },
-
- undoMoveVertex(state, op) {
- const { vertexIndex, previousPosition, featureId } = op
- const feature = this.getFeature(featureId)
- if (!feature) {
- return
- }
-
- const geojson = feature.toGeoJSON()
- getModifiableCoords(geojson)[vertexIndex] = previousPosition
- this._applyUndoAndSync(state, geojson, featureId)
-
- // Update touch vertex target position
- const vertex = state.vertecies[state.selectedVertexIndex]
- if (vertex) {
- this.updateTouchVertexTarget(state, scalePoint(this.map.project(vertex), state.scale))
- }
- },
-
- undoInsertVertex(state, op) {
- const { vertexIndex, featureId } = op
- const feature = this.getFeature(featureId)
- if (!feature) {
- return
- }
-
- const geojson = feature.toGeoJSON()
- getModifiableCoords(geojson).splice(vertexIndex, 1)
- this._applyUndoAndSync(state, geojson, featureId)
-
- this.hideTouchVertexIndicator(state)
- this.changeMode(state, { selectedVertexIndex: -1, selectedVertexType: null })
- },
-
- undoDeleteVertex(state, op) {
- const { vertexIndex, position, featureId } = op
- const feature = this.getFeature(featureId)
- if (!feature) {
- return
- }
-
- const geojson = feature.toGeoJSON()
- getModifiableCoords(geojson).splice(vertexIndex, 0, position)
- this._applyUndoAndSync(state, geojson, featureId)
-
- // Update touch vertex target to restored vertex position
- const vertex = state.vertecies[vertexIndex]
- if (vertex) {
- this.updateTouchVertexTarget(state, scalePoint(this.map.project(vertex), state.scale))
- }
- this.changeMode(state, { selectedVertexIndex: vertexIndex, selectedVertexType: 'vertex', coordPath: this.getCoordPath(state, vertexIndex) })
- },
-
- _applyUndoAndSync(state, geojson, featureId) {
- this._ctx.api.add(geojson)
- state.vertecies = this.getVerticies(featureId)
- state.midpoints = this.getMidpoints(featureId)
- this._ctx.store.render()
- this.fireGeometryChange(state)
- },
-
onStop(state) {
const h = this.handlers
state.container.removeEventListener('pointerdown', h.pointerdown)
diff --git a/plugins/beta/draw-ml/src/utils/flattenStyleProperties.js b/plugins/beta/draw-ml/src/utils/flattenStyleProperties.js
new file mode 100644
index 00000000..70f85807
--- /dev/null
+++ b/plugins/beta/draw-ml/src/utils/flattenStyleProperties.js
@@ -0,0 +1,49 @@
+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)
+ // First value as base fallback
+ if (entries.length > 0) {
+ result[key] = entries[0][1]
+ }
+ // Variant properties: e.g. strokeDark, fillOutdoor
+ 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/frame/src/Frame.jsx b/plugins/beta/frame/src/Frame.jsx
index 874ca7ec..4352dee6 100755
--- a/plugins/beta/frame/src/Frame.jsx
+++ b/plugins/beta/frame/src/Frame.jsx
@@ -65,7 +65,7 @@ export function Frame({ appState, mapState, pluginState, mapProvider }) {
})
}
- const observer = new ResizeObserver(updateLayout)
+ const observer = new window.ResizeObserver(updateLayout)
observer.observe(parent)
updateLayout()
diff --git a/plugins/beta/map-styles/src/mapStyles.scss b/plugins/beta/map-styles/src/mapStyles.scss
index 50f9a972..fd987a95 100755
--- a/plugins/beta/map-styles/src/mapStyles.scss
+++ b/plugins/beta/map-styles/src/mapStyles.scss
@@ -70,7 +70,10 @@
.im-c-map-styles__image::after {
content: '';
position: absolute;
- inset: (-3px);
+ top: -3px;
+ right: -3px;
+ bottom: -3px;
+ left: -3px;
outline: 3px solid var(--focus-outline-color);
border: 3px solid var(--focus-border-color);
}
diff --git a/plugins/interact/src/defaults.js b/plugins/interact/src/defaults.js
index 82bee8b6..2f7e8fb6 100755
--- a/plugins/interact/src/defaults.js
+++ b/plugins/interact/src/defaults.js
@@ -1,4 +1,5 @@
export const DEFAULTS = {
+ tolerance: 10, // Used for cross hair and click events
interactionMode: 'marker',
multiSelect: false,
contiguous: false,
diff --git a/plugins/interact/src/hooks/useInteractionHandlers.js b/plugins/interact/src/hooks/useInteractionHandlers.js
index af216b21..b4c8cebe 100755
--- a/plugins/interact/src/hooks/useInteractionHandlers.js
+++ b/plugins/interact/src/hooks/useInteractionHandlers.js
@@ -9,15 +9,21 @@ export const useInteractionHandlers = ({
mapProvider,
}) => {
const { markers } = mapState
- const { dispatch, dataLayers, interactionMode, multiSelect, contiguous, markerColor, selectedFeatures, selectionBounds } = pluginState
+ const { dispatch, dataLayers, interactionMode, multiSelect, contiguous, markerColor, tolerance, selectedFeatures, selectionBounds } = pluginState
const { eventBus } = services
const lastEmittedSelectionChange = useRef(null)
const layerConfigMap = buildLayerConfigMap(dataLayers)
const handleInteraction = useCallback(({ point, coords }) => {
- const allFeatures = getFeaturesAtPoint(mapProvider, point)
+ const allFeatures = getFeaturesAtPoint(mapProvider, point, { radius: tolerance })
const hasDataLayers = dataLayers.length > 0
+ // Debug option to inspect the map style data
+ if (pluginState?.debug) {
+ console.log(`--- Features at ${coords} ---`)
+ console.log(allFeatures)
+ }
+
const canMatchFeature = hasDataLayers && (interactionMode === 'select' || interactionMode === 'auto')
const match = canMatchFeature ? findMatchingFeature(allFeatures, layerConfigMap) : null
diff --git a/plugins/interact/src/reducer.js b/plugins/interact/src/reducer.js
index cba9a6f2..ae390e9e 100755
--- a/plugins/interact/src/reducer.js
+++ b/plugins/interact/src/reducer.js
@@ -21,7 +21,9 @@ const enable = (state, payload) => {
const disable = (state) => {
return {
...state,
- enabled: false
+ enabled: false,
+ selectedFeatures: [],
+ selectionBounds: null
}
}
@@ -47,6 +49,10 @@ const toggleSelectedFeatures = (state, payload) => {
// Replace all selected features if flag is true
if (replaceAll) {
+ // Toggle off if clicking the same already-selected feature
+ if (existingIndex !== -1 && selected.length === 1) {
+ return { ...state, selectedFeatures: [], selectionBounds: null }
+ }
return {
...state,
selectedFeatures: [{ featureId, layerId, idProperty, properties, geometry }],
diff --git a/plugins/interact/src/reducer.test.js b/plugins/interact/src/reducer.test.js
index caf10753..006e78d1 100644
--- a/plugins/interact/src/reducer.test.js
+++ b/plugins/interact/src/reducer.test.js
@@ -28,13 +28,15 @@ describe('ENABLE/DISABLE actions', () => {
expect(result).not.toBe(state)
})
- it('DISABLE sets enabled to false but preserves other state', () => {
- const state = { ...initialState, enabled: true, dataLayers: [1], markerColor: 'red' }
+ it('DISABLE sets enabled to false, clears selection, and preserves other state', () => {
+ const state = { ...initialState, enabled: true, dataLayers: [1], markerColor: 'red', selectedFeatures: [{ featureId: 'f1' }], selectionBounds: [0, 0, 1, 1] }
const result = actions.DISABLE(state)
expect(result.enabled).toBe(false)
expect(result.dataLayers).toEqual([1])
expect(result.markerColor).toBe('red')
+ expect(result.selectedFeatures).toEqual([])
+ expect(result.selectionBounds).toBeNull()
expect(result).not.toBe(state)
})
})
diff --git a/plugins/interact/src/utils/featureQueries.js b/plugins/interact/src/utils/featureQueries.js
index 94d2b2be..259ea78f 100755
--- a/plugins/interact/src/utils/featureQueries.js
+++ b/plugins/interact/src/utils/featureQueries.js
@@ -6,9 +6,9 @@ export const buildLayerConfigMap = dataLayers => {
return map
}
-export const getFeaturesAtPoint = (mapProvider, point) => {
+export const getFeaturesAtPoint = (mapProvider, point, options) => {
try {
- return mapProvider?.getFeaturesAtPoint(point) || []
+ return mapProvider?.getFeaturesAtPoint(point, options) || []
} catch (err) {
console.warn('Feature query failed:', err)
return []
diff --git a/plugins/search/src/events/fetchSuggestions.js b/plugins/search/src/events/fetchSuggestions.js
index ba158bb2..aad70492 100755
--- a/plugins/search/src/events/fetchSuggestions.js
+++ b/plugins/search/src/events/fetchSuggestions.js
@@ -5,7 +5,7 @@ import { fetchDataset } from '../utils/fetchDataset.js'
* Sanitise input query
* Allows letters, numbers, spaces, dashes, commas, full stops
*/
-const sanitiseQuery = (value) => value.replaceAll(/[^a-zA-Z0-9\s\-.,]/g, '').trim()
+const sanitiseQuery = (value) => value.replace(/[^a-zA-Z0-9\s\-.,]/g, '').trim()
/**
* Fetch suggestions from multiple datasets
diff --git a/plugins/search/src/search.scss b/plugins/search/src/search.scss
index d6e853a7..d3bdca19 100755
--- a/plugins/search/src/search.scss
+++ b/plugins/search/src/search.scss
@@ -27,7 +27,10 @@
content: '';
z-index: 1;
position: absolute;
- inset: -1px;
+ top: -1px;
+ right: -1px;
+ bottom: -1px;
+ left: -1px;
border-radius: var(--button-border-radius);
border: 2px solid var(--foreground-color);
pointer-events: none;
diff --git a/plugins/search/src/utils/parseOsNamesResults.js b/plugins/search/src/utils/parseOsNamesResults.js
index 2eeab458..da611256 100755
--- a/plugins/search/src/utils/parseOsNamesResults.js
+++ b/plugins/search/src/utils/parseOsNamesResults.js
@@ -5,7 +5,7 @@ const POINT_BUFFER = 500
const MAX_RESULTS = 8
const isPostcode = (value) => {
- value = value.replaceAll(/\s/g, '')
+ value = value.replace(/\s/g, '')
const regex = /^(([A-Z]{1,2}\d[A-Z\d]?|ASCN|STHL|TDCU|BBND|[BFS]IQQ|PCRN|TKCA) ?\d[A-Z]{2}|BFPO ?\d{1,4}|(KY\d|MSR|VG|AI)[ -]?\d{4}|[A-Z]{2} ?\d{2}|GE ?CX|GIR ?0A{2}|SAN ?TA1)$/i
return regex.test(value)
}
@@ -14,12 +14,12 @@ const removeDuplicates = (results) =>
Array.from(new Map(results.map(r => [r.GAZETTEER_ENTRY.ID, r])).values())
const removeTenuousResults = (results, query) => {
- const words = query.toLowerCase().replaceAll(',', '').split(' ')
+ const words = query.toLowerCase().replace(/,/g, '').split(' ')
return results.filter(l => words.some(w => l.GAZETTEER_ENTRY.NAME1.toLowerCase().includes(w) || isPostcode(query)))
}
const markString = (string, find) => {
- const clean = find.replaceAll(/\s+/g, '')
+ const clean = find.replace(/\s+/g, '')
// Create a pattern where whitespace is optional between every character
// e.g. "ab12cd" -> "a\s* b\s* 1\s* 2\s* c\s* d"
const spacedPattern = clean.split('').join('\\s*')
diff --git a/providers/beta/esri/src/esriProvider.js b/providers/beta/esri/src/esriProvider.js
index 28e823b0..555a5216 100644
--- a/providers/beta/esri/src/esriProvider.js
+++ b/providers/beta/esri/src/esriProvider.js
@@ -169,7 +169,7 @@ export default class EsriProvider {
return [xmin, ymin, xmax, ymax].map(n => Math.round(n * 100) / 100)
}
- getFeaturesAtPoint (point) {
+ getFeaturesAtPoint (point, options) {
return queryVectorTileFeatures(this.view, point)
}
diff --git a/providers/maplibre/src/index.js b/providers/maplibre/src/index.js
index 62a452f1..7ab652cd 100755
--- a/providers/maplibre/src/index.js
+++ b/providers/maplibre/src/index.js
@@ -6,7 +6,25 @@
import { getWebGL } from './utils/detectWebgl.js'
-const isLatest = !!window.globalThis
+/**
+ * Checks whether the browser supports modern ES2020 syntax
+ * (optional chaining `?.` and nullish coalescing `??`), which
+ * Chrome 80+ supports. Safe to use in ES5 bootstrap code.
+ *
+ * @returns {boolean} true if modern syntax is supported, false otherwise
+ */
+function supportsModernMaplibre() {
+ try {
+ // Try compiling ES2020 syntax dynamically
+ new Function('var x = null ?? 5; var y = ({a:1})?.a;')
+ return true
+ }
+ catch (e) {
+ // Exception intentionally ignored; returns false for unsupported syntax
+ void e // NOSONAR
+ return false
+ }
+}
/**
* Creates a MapLibre provider descriptor for lazy-loading the map provider.
@@ -20,28 +38,13 @@ export default function (config = {}) {
const webGL = getWebGL(['webgl2', 'webgl1'])
const isIE = document.documentMode
return {
- isSupported: webGL.isEnabled,
+ isSupported: webGL.isEnabled && supportsModernMaplibre(),
error: (isIE && 'Internet Explorer is not supported') || webGL.error
}
},
/** @returns {Promise} */
load: async () => {
- let mapFramework
- if (isLatest) {
- const maplibre = await import(/* webpackChunkName: "im-maplibre-framework" */ 'maplibre-gl')
- mapFramework = maplibre
- } else {
- const [maplibreLegacy, resizeObserver] = await Promise.all([
- import(/* webpackChunkName: "im-maplibre-legacy-framework" */ 'maplibre-gl-legacy'),
- import(/* webpackChunkName: "im-maplibre-legacy-framework" */ 'resize-observer'),
- import(/* webpackChunkName: "im-maplibre-legacy-framework" */ 'core-js/es/array/flat.js')
- ])
- if (!window.ResizeObserver) {
- resizeObserver.install()
- }
- mapFramework = maplibreLegacy
- }
-
+ const mapFramework = await import(/* webpackChunkName: "im-maplibre-framework" */ 'maplibre-gl')
const MapProvider = (await import(/* webpackChunkName: "im-maplibre-provider" */ './maplibreProvider.js')).default
/** @type {MapProviderConfig} */
diff --git a/providers/maplibre/src/maplibreProvider.js b/providers/maplibre/src/maplibreProvider.js
index 60bb4b2c..def8d5e0 100755
--- a/providers/maplibre/src/maplibreProvider.js
+++ b/providers/maplibre/src/maplibreProvider.js
@@ -10,6 +10,7 @@ import { attachAppEvents } from './appEvents.js'
import { getAreaDimensions, getCardinalMove, getResolution, getPaddedBounds } from './utils/spatial.js'
import { createMapLabelNavigator } from './utils/labels.js'
import { updateHighlightedFeatures } from './utils/highlightFeatures.js'
+import { queryFeatures } from './utils/queryFeatures.js'
/**
* MapLibre GL JS implementation of the MapProvider interface.
@@ -75,7 +76,6 @@ export default class MapLibreProvider {
map.fitBounds(bounds, { duration: 0 })
}
-
applyPreventDefaultFix(map)
cleanCanvas(map)
@@ -266,10 +266,12 @@ export default class MapLibreProvider {
* Query rendered features at a screen pixel position (x from left edge, y from top edge of viewport).
*
* @param {{ x: number, y: number }} point - Screen pixel position.
+ * @param {Object} [options]
+ * @param {number} [options.radius] - Pixel radius to expand the query area. Results sorted closest-first.
* @returns {any[]}
*/
- getFeaturesAtPoint (point) {
- return this.map.queryRenderedFeatures(point)
+ getFeaturesAtPoint (point, options) {
+ return queryFeatures(this.map, point, options)
}
// ==========================
diff --git a/providers/maplibre/src/utils/highlightFeatures.js b/providers/maplibre/src/utils/highlightFeatures.js
index 9bb1b686..313a1a61 100755
--- a/providers/maplibre/src/utils/highlightFeatures.js
+++ b/providers/maplibre/src/utils/highlightFeatures.js
@@ -11,7 +11,7 @@ function updateHighlightedFeatures({ LngLatBounds, map, selectedFeatures, styles
const renderedFeatures = []
// Group features by source
- selectedFeatures?.forEach(({ featureId, layerId, idProperty }) => {
+ selectedFeatures?.forEach(({ featureId, layerId, idProperty, geometry }) => {
const layer = map.getLayer(layerId)
if (!layer) {
@@ -24,9 +24,16 @@ function updateHighlightedFeatures({ LngLatBounds, map, selectedFeatures, styles
featuresBySource[sourceId] = {
ids: new Set(),
idProperty,
- layerId
+ layerId,
+ hasFillGeometry: false
}
}
+
+ // Track whether any selected feature on this source is a polygon
+ if (geometry && (geometry.type === 'Polygon' || geometry.type === 'MultiPolygon')) {
+ featuresBySource[sourceId].hasFillGeometry = true
+ }
+
featuresBySource[sourceId].ids.add(featureId)
})
@@ -50,10 +57,12 @@ function updateHighlightedFeatures({ LngLatBounds, map, selectedFeatures, styles
// Apply highlights for current sources
currentSources.forEach(sourceId => {
- const { ids, idProperty, layerId } = featuresBySource[sourceId]
+ const { ids, idProperty, layerId, hasFillGeometry } = featuresBySource[sourceId]
const baseLayer = map.getLayer(layerId)
const srcLayer = baseLayer.sourceLayer
- const geom = baseLayer.type
+
+ // Use the actual feature geometry to determine highlight type
+ const geom = hasFillGeometry ? 'fill' : baseLayer.type
const base = `highlight-${sourceId}`
const { stroke, strokeWidth, fill } = stylesMap[layerId]
@@ -89,6 +98,10 @@ function updateHighlightedFeatures({ LngLatBounds, map, selectedFeatures, styles
}
if (geom === 'line') {
+ // Clear any fill highlight from a previous polygon selection on the same source
+ if (map.getLayer(`${base}-fill`)) {
+ map.setFilter(`${base}-fill`, ['==', 'id', ''])
+ }
if (!map.getLayer(`${base}-line`)) {
map.addLayer({
id: `${base}-line`,
@@ -98,6 +111,8 @@ function updateHighlightedFeatures({ LngLatBounds, map, selectedFeatures, styles
paint: { 'line-color': stroke, 'line-width': strokeWidth }
})
}
+ map.setPaintProperty(`${base}-line`, 'line-color', stroke)
+ map.setPaintProperty(`${base}-line`, 'line-width', strokeWidth)
map.setFilter(`${base}-line`, filter)
}
diff --git a/providers/maplibre/src/utils/queryFeatures.js b/providers/maplibre/src/utils/queryFeatures.js
new file mode 100644
index 00000000..e5014137
--- /dev/null
+++ b/providers/maplibre/src/utils/queryFeatures.js
@@ -0,0 +1,73 @@
+/**
+ * Flatten geometry coordinates into a flat array of [lng, lat] pairs.
+ *
+ * @param {any} coordinates - GeoJSON coordinates.
+ * @param {string} type - GeoJSON geometry type.
+ * @returns {Array<[number, number]>}
+ */
+const flattenCoords = (coordinates, type) => {
+ if (type === 'Point') {
+ return [coordinates]
+ }
+ if (type === 'MultiPoint' || type === 'LineString') {
+ return coordinates
+ }
+ if (type === 'MultiLineString' || type === 'Polygon') {
+ return coordinates.flat()
+ }
+ return coordinates.flat(2) // MultiPolygon
+}
+
+/**
+ * Calculate the minimum squared screen-pixel distance from a point to a feature's
+ * geometry vertices.
+ *
+ * @param {import('maplibre-gl').Map} map - MapLibre map instance (for projection).
+ * @param {{ x: number, y: number }} point - Screen pixel position.
+ * @param {Object} geometry - GeoJSON geometry object.
+ * @returns {number} Minimum squared pixel distance.
+ */
+const screenDistance = (map, point, geometry) => {
+ const coords = flattenCoords(geometry.coordinates, geometry.type)
+ let min = Infinity
+
+ for (const [lng, lat] of coords) {
+ const { x, y } = map.project([lng, lat])
+ const d = (x - point.x) ** 2 + (y - point.y) ** 2
+ if (d < min) {
+ min = d
+ }
+ }
+
+ return min
+}
+
+/**
+ * Query rendered features at a screen pixel position, optionally expanding
+ * the query area by a pixel radius and sorting results closest-first.
+ *
+ * @param {import('maplibre-gl').Map} map - MapLibre map instance.
+ * @param {{ x: number, y: number }} point - Screen pixel position.
+ * @param {Object} [options]
+ * @param {number} [options.radius] - Pixel radius to expand the query area.
+ * @returns {any[]} Features sorted by proximity when radius is provided.
+ */
+export const queryFeatures = (map, point, options = {}) => {
+ const { radius } = options
+
+ if (!radius) {
+ return map.queryRenderedFeatures(point)
+ }
+
+ const bbox = [
+ [point.x - radius, point.y - radius],
+ [point.x + radius, point.y + radius]
+ ]
+
+ const features = map.queryRenderedFeatures(bbox)
+
+ return features
+ .map(f => ({ f, d: screenDistance(map, point, f.geometry) }))
+ .sort((a, b) => a.d - b.d)
+ .map(({ f }) => f)
+}
diff --git a/sonar-project.properties b/sonar-project.properties
index f250107f..2338e602 100644
--- a/sonar-project.properties
+++ b/sonar-project.properties
@@ -35,4 +35,11 @@ sonar.issue.ignore.multicriteria.preferGlobalThisJsx.resourceKey=**/*.jsx
sonar.issue.ignore.multicriteria.preferAtJs.ruleKey=javascript:S7755
sonar.issue.ignore.multicriteria.preferAtJs.resourceKey=**/*.js
sonar.issue.ignore.multicriteria.preferAtJsx.ruleKey=javascript:S7755
-sonar.issue.ignore.multicriteria.preferAtJsx.resourceKey=**/*.jsx
\ No newline at end of file
+sonar.issue.ignore.multicriteria.preferAtJsx.resourceKey=**/*.jsx
+
+# S6316: Array.prototype.replaceAll may not be supported in all browsers
+sonar.issue.ignore.multicriteria.replaceAllJs.ruleKey=javascript:S6316
+sonar.issue.ignore.multicriteria.replaceAllJs.resourceKey=**/*.js
+sonar.issue.ignore.multicriteria.replaceAllJsx.ruleKey=javascript:S6316
+sonar.issue.ignore.multicriteria.replaceAllJsx.resourceKey=**/*.jsx
+sonar.issue.ignore.multicriteria.preferAtJsx.resourceKey=**/*.jsx
diff --git a/src/App/components/MapButton/MapButton.module.scss b/src/App/components/MapButton/MapButton.module.scss
index fa098afa..8c076a55 100755
--- a/src/App/components/MapButton/MapButton.module.scss
+++ b/src/App/components/MapButton/MapButton.module.scss
@@ -134,6 +134,14 @@
@include tools.border-focus-corner-override($corners: 'top');
}
+ .im-c-button-wrapper--group-middle .im-c-map-button {
+ @include tools.border-focus-corner-override($corners: 'none');
+
+ &::before {
+ clip-path: inset(0 -20px 0 -20px);
+ }
+ }
+
.im-c-button-wrapper--group-end .im-c-map-button {
margin-top: 0;
@include tools.border-focus-corner-override($corners: 'bottom');
@@ -150,6 +158,14 @@
@include tools.border-focus-corner-override($corners: 'left');
}
+ .im-c-button-wrapper--group-middle .im-c-map-button {
+ @include tools.border-focus-corner-override($corners: 'none');
+
+ &::before {
+ clip-path: inset(-20px 0 -20px 0);
+ }
+ }
+
.im-c-button-wrapper--group-end .im-c-map-button {
@include tools.border-focus-corner-override($corners: 'right');
}
diff --git a/src/App/components/Viewport/Viewport.module.scss b/src/App/components/Viewport/Viewport.module.scss
index 89299be7..587dc4bf 100755
--- a/src/App/components/Viewport/Viewport.module.scss
+++ b/src/App/components/Viewport/Viewport.module.scss
@@ -71,7 +71,7 @@
height: 66.66%;
> *:first-child {
- transform: scale(150%);
+ transform: scale(1.5);
}
}
@@ -80,7 +80,7 @@
height: 50%;
> *:first-child {
- transform: scale(200%);
+ transform: scale(2);
}
}
diff --git a/src/App/hooks/useResizeObserver.js b/src/App/hooks/useResizeObserver.js
index 26404cf0..156e2885 100755
--- a/src/App/hooks/useResizeObserver.js
+++ b/src/App/hooks/useResizeObserver.js
@@ -12,7 +12,7 @@ export function useResizeObserver (targetRefs, callback) {
return
}
- const observer = new ResizeObserver(entries => {
+ const observer = new window.ResizeObserver(entries => {
for (const entry of entries) {
const { width, height } = entry.contentRect
const prev = prevSizes.current.get(entry.target) || {}
diff --git a/src/App/layout/layout.module.scss b/src/App/layout/layout.module.scss
index fdf843a5..9bf54ee1 100755
--- a/src/App/layout/layout.module.scss
+++ b/src/App/layout/layout.module.scss
@@ -268,7 +268,10 @@
// Panel overrides
.im-c-panel {
position: absolute;
- inset: 0 0 auto;
+ top: 0;
+ right: 0;
+ bottom: auto;
+ left: 0;
z-index: 1000;
}
@@ -318,7 +321,10 @@
}
[class*="im-c-panel--"][class*="-button"] { // Adjacent to button
- inset: var(--modal-inset);
+ top: var(--modal-inset);
+ right: var(--modal-inset);
+ bottom: var(--modal-inset);
+ left: var(--modal-inset);
}
}
diff --git a/src/InteractiveMap/InteractiveMap.js b/src/InteractiveMap/InteractiveMap.js
index e9ae284a..d7912ae9 100755
--- a/src/InteractiveMap/InteractiveMap.js
+++ b/src/InteractiveMap/InteractiveMap.js
@@ -9,6 +9,8 @@
*/
import '../scss/main.scss'
+// Polyfills to ensure entry point works on all devices
+import './polyfills.js'
import historyManager from './historyManager.js'
import { parseDataProperties } from './parseDataProperties.js'
import { checkDeviceSupport } from './deviceChecker.js'
diff --git a/src/InteractiveMap/polyfills.js b/src/InteractiveMap/polyfills.js
new file mode 100644
index 00000000..83f63d96
--- /dev/null
+++ b/src/InteractiveMap/polyfills.js
@@ -0,0 +1,39 @@
+// crypto.randomUUID
+if (typeof crypto !== 'undefined' && !crypto.randomUUID) {
+ let last = 0
+ crypto.randomUUID = () => {
+ last = Math.max(Date.now(), last + 1)
+ return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c, i) => {
+ const v = i < 12 ? Number.parseInt(last.toString(16).padStart(12, '0')[i], 16) : Math.random() * 16 | 0 // NOSONAR
+ return (c === 'x' ? v : (v & 0x3 | 0x8)).toString(16)
+ })
+ }
+}
+
+// AbortSignal.throwIfAborted
+const needsThrowIfAborted = typeof AbortController !== 'undefined' &&
+ !Object.getPrototypeOf(new AbortController().signal).throwIfAborted
+
+if (needsThrowIfAborted) {
+ const signalProto = Object.getPrototypeOf(new AbortController().signal)
+ signalProto.throwIfAborted = function () {
+ if (this.aborted) {
+ const err = new Error('The operation was aborted.')
+ err.name = 'AbortError'
+ throw err
+ }
+ }
+}
+
+// Inject polyfill into web workers created from blob URLs (e.g. MapLibre GL)
+// Workers have their own global scope so main thread polyfills don't apply
+if (needsThrowIfAborted && typeof URL !== 'undefined' && URL.createObjectURL) {
+ const _createObjectURL = URL.createObjectURL.bind(URL)
+ URL.createObjectURL = (blob) => {
+ if (blob instanceof Blob && blob.type === 'text/javascript') {
+ const p = 'if(typeof AbortController!=="undefined"){var _p=Object.getPrototypeOf(new AbortController().signal);if(!_p.throwIfAborted){_p.throwIfAborted=function(){if(this.aborted){var e=new Error("The operation was aborted.");e.name="AbortError";throw e}}}}\n'
+ blob = new Blob([p, blob], { type: 'text/javascript' })
+ }
+ return _createObjectURL(blob)
+ }
+}
diff --git a/src/InteractiveMap/polyfills.test.js b/src/InteractiveMap/polyfills.test.js
new file mode 100644
index 00000000..92177c0b
--- /dev/null
+++ b/src/InteractiveMap/polyfills.test.js
@@ -0,0 +1,127 @@
+describe('Polyfills', () => {
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/
+
+ const originalCryptoUUID = crypto.randomUUID
+ const originalCreateObjectURL = URL.createObjectURL
+ const signalProto = Object.getPrototypeOf(new AbortController().signal)
+ const originalThrowIfAborted = signalProto.throwIfAborted
+
+ beforeEach(() => {
+ jest.resetModules()
+ })
+
+ afterEach(() => {
+ Object.defineProperty(crypto, 'randomUUID', {
+ value: originalCryptoUUID,
+ configurable: true,
+ writable: true
+ })
+
+ URL.createObjectURL = originalCreateObjectURL
+
+ if (originalThrowIfAborted) {
+ signalProto.throwIfAborted = originalThrowIfAborted
+ } else {
+ delete signalProto.throwIfAborted
+ }
+ })
+
+ const load = () => jest.isolateModules(() => require('./polyfills.js'))
+
+ // Helper to read Blob text in environments without blob.text()
+ const readBlobText = (blob) => new Promise((resolve) => {
+ const reader = new FileReader()
+ reader.onload = () => resolve(reader.result)
+ reader.readAsText(blob)
+ })
+
+ describe('crypto.randomUUID', () => {
+ test('polyfills crypto.randomUUID when missing (Lines 3-8)', () => {
+ Object.defineProperty(crypto, 'randomUUID', {
+ value: undefined,
+ configurable: true,
+ writable: true
+ })
+
+ load()
+
+ expect(typeof crypto.randomUUID).toBe('function')
+ expect(crypto.randomUUID()).toMatch(UUID_RE)
+ })
+
+ test('crypto.randomUUID generates unique UUIDs', () => {
+ Object.defineProperty(crypto, 'randomUUID', {
+ value: undefined,
+ configurable: true,
+ writable: true
+ })
+ load()
+ const ids = new Set(Array.from({ length: 100 }, () => crypto.randomUUID()))
+ expect(ids.size).toBe(100)
+ })
+
+ test('does not overwrite existing crypto.randomUUID', () => {
+ const fake = jest.fn(() => 'fake')
+ Object.defineProperty(crypto, 'randomUUID', {
+ value: fake,
+ configurable: true,
+ writable: true
+ })
+ load()
+ expect(crypto.randomUUID).toBe(fake)
+ })
+ })
+
+ describe('AbortSignal.throwIfAborted', () => {
+ test('throws AbortError when aborted (True branch)', () => {
+ delete signalProto.throwIfAborted
+ load()
+ const ac = new AbortController()
+ ac.abort()
+ expect(() => ac.signal.throwIfAborted()).toThrow('The operation was aborted.')
+ })
+
+ test('does nothing when not aborted (False branch)', () => {
+ delete signalProto.throwIfAborted
+ load()
+ const ac = new AbortController()
+ // This call should execute line 20, see that aborted is false, and return undefined
+ expect(() => ac.signal.throwIfAborted()).not.toThrow()
+ })
+
+ test('wraps URL.createObjectURL for JS blobs', async () => {
+ delete signalProto.throwIfAborted
+
+ const mockCreate = jest.fn(() => 'blob:mock')
+ URL.createObjectURL = mockCreate
+
+ load()
+
+ const content = 'console.log(1)'
+ const blob = new Blob([content], { type: 'text/javascript' })
+ const result = URL.createObjectURL(blob)
+
+ expect(result).toBe('blob:mock')
+ expect(mockCreate).toHaveBeenCalled()
+
+ const passedBlob = mockCreate.mock.calls[0][0]
+ const text = await readBlobText(passedBlob)
+
+ expect(text).toContain('throwIfAborted')
+ expect(text).toContain(content)
+ })
+
+ test('does not wrap URL.createObjectURL for non-JS blobs', () => {
+ delete signalProto.throwIfAborted
+ const mockCreate = jest.fn(() => 'blob:mock')
+ URL.createObjectURL = mockCreate
+
+ load()
+
+ const blob = new Blob(['{}'], { type: 'application/json' })
+ URL.createObjectURL(blob)
+
+ expect(mockCreate).toHaveBeenCalledWith(blob)
+ })
+ })
+})
diff --git a/src/utils/detectBreakpoint.js b/src/utils/detectBreakpoint.js
index 9106b02f..b35005c3 100755
--- a/src/utils/detectBreakpoint.js
+++ b/src/utils/detectBreakpoint.js
@@ -15,7 +15,7 @@ function createContainerDetector (containerEl, getType, notifyListeners) {
const initialType = getType(initialWidth)
containerEl.dataset.breakpoint = initialType
- const observer = new ResizeObserver((entries) => {
+ const observer = new window.ResizeObserver((entries) => {
const width = entries[0]?.borderBoxSize?.[0]?.inlineSize || entries[0]?.contentRect.width
const type = getType(width)
containerEl.dataset.breakpoint = type
diff --git a/src/utils/detectInterfaceType.js b/src/utils/detectInterfaceType.js
index 8c5d374a..9e34bca2 100755
--- a/src/utils/detectInterfaceType.js
+++ b/src/utils/detectInterfaceType.js
@@ -53,14 +53,14 @@ function createInterfaceDetector () {
}
}
- globalThis.addEventListener('pointerdown', handlePointer, { passive: true })
- globalThis.addEventListener('keydown', handleKeyDown, { passive: true })
+ window.addEventListener('pointerdown', handlePointer, { passive: true })
+ window.addEventListener('keydown', handleKeyDown, { passive: true })
// cleanup
return () => {
mql.removeEventListener('change', handleMediaChange)
- globalThis.removeEventListener('pointerdown', handlePointer)
- globalThis.removeEventListener('keydown', handleKeyDown)
+ window.removeEventListener('pointerdown', handlePointer)
+ window.removeEventListener('keydown', handleKeyDown)
}
}
diff --git a/src/utils/mapStateSync.js b/src/utils/mapStateSync.js
index 65630bac..eb18876a 100755
--- a/src/utils/mapStateSync.js
+++ b/src/utils/mapStateSync.js
@@ -11,7 +11,7 @@ const getMapStateFromURL = (id, search) => {
return { center: [lng, lat], zoom }
}
-const setMapStateInURL = (id, state, currentHref = globalThis.location.href) => {
+const setMapStateInURL = (id, state, currentHref = window.location.href) => {
// Use the passed href or the global one
const url = new URL(currentHref || 'http://localhost')
const params = [...new URLSearchParams(url.search)].map(([key, value]) => `${key}=${value}`)
@@ -30,10 +30,10 @@ const setMapStateInURL = (id, state, currentHref = globalThis.location.href) =>
const hash = url.hash || ''
const newUrl = `${url.origin}${url.pathname}?${[...filteredParams, ...newParams].join('&')}${hash}`
- globalThis.history.replaceState(globalThis.history.state, '', newUrl)
+ window.history.replaceState(window.history.state, '', newUrl)
}
-const getInitialMapState = ({ id, center, zoom, bounds }, search = globalThis.location.search) => {
+const getInitialMapState = ({ id, center, zoom, bounds }, search = window.location.search) => {
// Pass search string down to the internal function
const savedState = getMapStateFromURL(id, search)
if (savedState) {
diff --git a/src/utils/queryString.js b/src/utils/queryString.js
index 95709cfa..4373b71c 100755
--- a/src/utils/queryString.js
+++ b/src/utils/queryString.js
@@ -1,4 +1,4 @@
-export const getQueryParam = (name, search = globalThis.location?.search) => {
+export const getQueryParam = (name, search = window.location?.search) => {
const urlParams = new URLSearchParams(search)
return urlParams.get(name)
}
diff --git a/src/utils/stringToKebab.js b/src/utils/stringToKebab.js
index 95ba84c9..e7e85c24 100755
--- a/src/utils/stringToKebab.js
+++ b/src/utils/stringToKebab.js
@@ -1,3 +1,3 @@
export function stringToKebab (str) {
- return str?.replaceAll(/([a-z0-9])([A-Z])/g, '$1-$2').toLowerCase()
+ return str?.replace(/([a-z0-9])([A-Z])/g, '$1-$2').toLowerCase()
}
diff --git a/webpack.dev.mjs b/webpack.dev.mjs
index 7003a858..d2fce37c 100755
--- a/webpack.dev.mjs
+++ b/webpack.dev.mjs
@@ -76,7 +76,7 @@ export default {
{
test: /\.jsx?$/,
loader: 'babel-loader',
- exclude: /node_modules\/(?!(lucide-react))/
+ exclude: /node_modules/
},{
test: /\.s[ac]ss$/i,
use: [MiniCssExtractPlugin.loader, 'css-loader', 'sass-loader'],