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