From c3bae907e0e1ff63330b47ffec90114fbcb0d502 Mon Sep 17 00:00:00 2001 From: Dan Leech Date: Fri, 6 Feb 2026 10:08:33 +0000 Subject: [PATCH 01/18] Interact event and resize bug fix --- plugins/beta/draw-ml/src/mapboxSnap.js | 6 ++++-- plugins/interact/src/hooks/useInteractionHandlers.js | 9 ++++++--- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/plugins/beta/draw-ml/src/mapboxSnap.js b/plugins/beta/draw-ml/src/mapboxSnap.js index ddbd98a7..d5ba14b9 100644 --- a/plugins/beta/draw-ml/src/mapboxSnap.js +++ b/plugins/beta/draw-ml/src/mapboxSnap.js @@ -140,6 +140,8 @@ function applyMapboxSnapPatches(colors) { function pollUntil(checkFn, onSuccess) { (function poll() { const result = checkFn() + // null signals to stop polling, falsy continues polling + if (result === null) return result ? onSuccess(result) : requestAnimationFrame(poll) })() } @@ -267,7 +269,7 @@ export function initMapLibreSnap(map, draw, snapOptions = {}) { // Handle style changes - re-patch source and ensure snap layer exists map.on('style.load', () => { pollUntil( - () => map.getSource('mapbox-gl-draw-hot'), + () => map._removed ? null : map.getSource('mapbox-gl-draw-hot'), (source) => { patchSourceData(source) @@ -312,7 +314,7 @@ export function initMapLibreSnap(map, draw, snapOptions = {}) { // Initial setup - poll until draw source exists pollUntil( - () => map.getSource('mapbox-gl-draw-hot'), + () => map._removed ? null : map.getSource('mapbox-gl-draw-hot'), createSnap ) } diff --git a/plugins/interact/src/hooks/useInteractionHandlers.js b/plugins/interact/src/hooks/useInteractionHandlers.js index 855d32a5..bd9c7041 100755 --- a/plugins/interact/src/hooks/useInteractionHandlers.js +++ b/plugins/interact/src/hooks/useInteractionHandlers.js @@ -62,9 +62,12 @@ export const useInteractionHandlers = ({ useEffect(() => { // Skip if features exist but bounds not yet calculated const awaitingBounds = selectedFeatures.length > 0 && !selectionBounds - if (awaitingBounds || selectedFeatures === lastEmittedSelectionChange.current) { - return - } + if (awaitingBounds) return + + // Skip if selection was already empty and remains empty + const prev = lastEmittedSelectionChange.current + const wasEmpty = prev === null || prev.length === 0 + if (wasEmpty && selectedFeatures.length === 0) return eventBus.emit('interact:selectionchange', { selectedFeatures, From 47fbd4955483a89935e299c8d7dcdf06e0c0e739 Mon Sep 17 00:00:00 2001 From: Dan Leech Date: Fri, 6 Feb 2026 12:25:17 +0000 Subject: [PATCH 02/18] Behaviour fix and docs update --- demo/js/index.js | 1 + docs/api.md | 8 + providers/maplibre/src/utils/labels.js | 294 +++++++++++------- sonar-project.properties | 19 +- .../components/KeyboardHelp/KeyboardHelp.jsx | 4 +- .../KeyboardHelp/KeyboardHelp.test.jsx | 9 + src/App/controls/keyboardShortcuts.js | 6 +- src/App/initialiseApp.js | 4 +- src/App/registry/keyboardShortcutRegistry.js | 14 +- .../registry/keyboardShortcutRegistry.test.js | 28 ++ src/InteractiveMap/InteractiveMap.js | 60 +++- src/InteractiveMap/InteractiveMap.test.js | 77 ++++- src/InteractiveMap/behaviourController.js | 14 +- .../behaviourController.test.js | 67 +++- src/InteractiveMap/historyManager.js | 17 +- src/InteractiveMap/historyManager.test.js | 43 ++- src/config/appConfig.js | 10 +- src/config/appConfig.test.js | 20 +- src/config/defaults.js | 1 + src/types.js | 5 + src/utils/detectBreakpoint.js | 153 +++++---- 21 files changed, 616 insertions(+), 238 deletions(-) diff --git a/demo/js/index.js b/demo/js/index.js index 7cf280c3..0792254a 100755 --- a/demo/js/index.js +++ b/demo/js/index.js @@ -106,6 +106,7 @@ var interactiveMap = new InteractiveMap('map', { containerHeight: '650px', transformRequest: transformTileRequest, enableZoomControls: true, + readMapText: true, // enableFullscreen: true, // hasExitButton: true, // markers: [{ diff --git a/docs/api.md b/docs/api.md index 104a7ea4..389dc0e2 100644 --- a/docs/api.md +++ b/docs/api.md @@ -388,6 +388,14 @@ See [PluginDescriptor](./plugins/plugin-descriptor.md) for full details. --- +### `preserveStateOnClose` +**Type:** `boolean` +**Default:** `false` + +Controls whether closing the map (via the browser back button or the exit map button when `hasExitButton` is `true` and the map is fullscreen) destroys the map instance or hides it while preserving its current state. Set to `true` to keep the map state intact, which is useful for implementations like a toggle map view list view pattern. + +--- + ### `readMapText` **Type:** `boolean` **Default:** `false` diff --git a/providers/maplibre/src/utils/labels.js b/providers/maplibre/src/utils/labels.js index 001cdba7..5d1e1442 100755 --- a/providers/maplibre/src/utils/labels.js +++ b/providers/maplibre/src/utils/labels.js @@ -2,8 +2,9 @@ import { spatialNavigate } from './spatial.js' import { calculateLinearTextSize } from './calculateLinearTextSize.js' const HIGHLIGHT_SCALE_FACTOR = 1.5 +const HIGHLIGHT_LABEL_SOURCE = 'highlighted-label' -function getGeometryCenter(geometry) { +export function getGeometryCenter(geometry) { const { type, coordinates } = geometry if (type === 'Point') { return coordinates @@ -23,13 +24,12 @@ function getGeometryCenter(geometry) { return null } -function evalInterpolate(expr, zoom) { +export function evalInterpolate(expr, zoom) { if (typeof expr === 'number') { return expr } if (!Array.isArray(expr) || expr[0] !== 'interpolate') { return calculateLinearTextSize(expr, zoom) - // throw new Error('Only interpolate expressions supported') } const [, , input, ...stops] = expr if (input[0] !== 'zoom') { @@ -39,7 +39,7 @@ function evalInterpolate(expr, zoom) { const z0 = stops[i] const v0 = stops[i + 1] const z1 = stops[i + 2] - const v1 = stops[i + 3] + const v1 = stops[i + 3] // NOSONAR: array index offset for interpolation pairs if (zoom <= z0) { return v0 } @@ -50,166 +50,220 @@ function evalInterpolate(expr, zoom) { return stops[stops.length - 1] } -export function createMapLabelNavigator(map, mapColorScheme, events, eventBus) { - let isDarkStyle = mapColorScheme === 'dark' - let labels = [] - let currentPixel = null - let highlightLayerId = null - let highlightedExpr = null - let highlightedFeature = null - - const colors = { - get current() { - if (isDarkStyle) { - return { text: '#ffffff', halo: '#000000' } - } - return { text: '#000000', halo: '#ffffff' } - } +export function getHighlightColors(isDarkStyle) { + if (isDarkStyle) { + return { text: '#ffffff', halo: '#000000' } } + return { text: '#000000', halo: '#ffffff' } +} - const initLabelSource = () => { - if (!map.getSource('highlighted-label')) { - map.addSource('highlighted-label', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } }) - } +export function extractTextPropertyName(textField) { + if (typeof textField === 'string') { + return /^{(.+)}$/.exec(textField)?.[1] } + if (Array.isArray(textField)) { + return textField.find(e => Array.isArray(e) && e[0] === 'get')?.[1] + } + return null +} - map.getStyle().layers.filter(l => l.layout?.['symbol-placement'] === 'line').forEach(l => { - map.setLayoutProperty(l.id, 'symbol-placement', 'line-center') - }) - initLabelSource() +export function buildLabelFromFeature(feature, layer, propName, map) { + const center = getGeometryCenter(feature.geometry) + if (!center) { + return null + } + const projected = map.project({ lng: center[0], lat: center[1] }) + return { text: feature.properties[propName], x: projected.x, y: projected.y, feature, layer } +} - eventBus?.on(events.MAP_SET_STYLE, style => { - map.once('styledata', () => map.once('idle', () => { - map.getStyle().layers.filter(l => l.layout?.['symbol-placement'] === 'line').forEach(l => { - map.setLayoutProperty(l.id, 'symbol-placement', 'line-center') - }) - initLabelSource() - isDarkStyle = style?.mapColorScheme === 'dark' - })) +export function buildLabelsFromLayers(map, symbolLayers, features) { + return symbolLayers.flatMap(layer => { + const textField = layer.layout?.['text-field'] + const propName = extractTextPropertyName(textField) + if (!propName) { + return [] + } + return features + .filter(f => f.layer.id === layer.id && f.properties?.[propName]) + .map(f => buildLabelFromFeature(f, layer, propName, map)) + .filter(Boolean) }) +} - function refreshLabels() { - const symbolLayers = map.getStyle().layers.filter(l => l.type === 'symbol') - const features = map.queryRenderedFeatures({ layers: symbolLayers.map(l => l.id) }) - labels = symbolLayers.flatMap(layer => { - const textField = layer.layout?.['text-field'] - const propName = typeof textField === 'string' - ? textField.match(/^{(.+)}$/)?.[1] - : Array.isArray(textField) - ? textField.find(e => Array.isArray(e) && e[0] === 'get')?.[1] - : null - if (!propName) { - return [] - } - return features.filter(f => f.layer.id === layer.id && f.properties?.[propName]).map(f => { - const center = getGeometryCenter(f.geometry) - if (!center) { - return null - } - const projected = map.project({ lng: center[0], lat: center[1] }) - return { text: f.properties[propName], x: projected.x, y: projected.y, feature: f, layer } - }).filter(Boolean) - }) - } +export function findClosestLabel(labels, centerPoint) { + return labels.reduce((best, label) => { + const dist = (label.x - centerPoint.x) ** 2 + (label.y - centerPoint.y) ** 2 + if (!best || dist < best.dist) { + return { label, dist } + } + return best + }, null)?.label +} - function removeHighlight() { - if (highlightLayerId && map.getLayer(highlightLayerId)) { - try { - map.removeLayer(highlightLayerId) - } - catch {} - highlightLayerId = null - highlightedExpr = null - highlightedFeature = null +export function createHighlightLayerConfig(sourceLayer, highlightSize, colors) { + return { + id: `highlight-${sourceLayer.id}`, + type: sourceLayer.type, + source: HIGHLIGHT_LABEL_SOURCE, + layout: { + ...sourceLayer.layout, + 'text-size': highlightSize, + 'text-allow-overlap': true, + 'text-ignore-placement': true, + 'text-max-angle': 90 + }, + paint: { + ...sourceLayer.paint, + 'text-color': colors.text, + 'text-halo-color': colors.halo, + 'text-halo-width': 3, + 'text-halo-blur': 1, + 'text-opacity': 1 } } +} - function highlight(labelData) { - if (!labelData?.feature?.layer) { - return +export function removeHighlightLayer(map, state) { + if (state.highlightLayerId && map.getLayer(state.highlightLayerId)) { + try { + map.removeLayer(state.highlightLayerId) } - removeHighlight() - const { feature, layer } = labelData - highlightLayerId = `highlight-${layer.id}` - - const { id, type, properties, geometry } = feature - highlightedFeature = { id, type, properties, geometry } - map.getSource('highlighted-label').setData(highlightedFeature) - highlightedExpr = layer.layout['text-size'] - const zoom = map.getZoom() - const baseSize = evalInterpolate(highlightedExpr, zoom) - const highlightSize = baseSize * HIGHLIGHT_SCALE_FACTOR - map.addLayer({ - id: highlightLayerId, - type: layer.type, - source: 'highlighted-label', - layout: { ...layer.layout, 'text-size': highlightSize, 'text-allow-overlap': true, 'text-ignore-placement': true, 'text-max-angle': 90 }, - paint: { ...layer.paint, 'text-color': colors.current.text, 'text-halo-color': colors.current.halo, 'text-halo-width': 3, 'text-halo-blur': 1, 'text-opacity': 1 } + catch {} + state.highlightLayerId = null + state.highlightedExpr = null + } +} + +export function applyHighlight(map, labelData, state) { + if (!labelData?.feature?.layer) { + return + } + removeHighlightLayer(map, state) + const { feature, layer } = labelData + state.highlightLayerId = `highlight-${layer.id}` + + const { id, type, properties, geometry } = feature + map.getSource(HIGHLIGHT_LABEL_SOURCE).setData({ id, type, properties, geometry }) + state.highlightedExpr = layer.layout['text-size'] + + const zoom = map.getZoom() + const baseSize = evalInterpolate(state.highlightedExpr, zoom) + const highlightSize = baseSize * HIGHLIGHT_SCALE_FACTOR + const colors = getHighlightColors(state.isDarkStyle) + const layerConfig = createHighlightLayerConfig(layer, highlightSize, colors) + + map.addLayer(layerConfig) + map.moveLayer(state.highlightLayerId) +} + +export function navigateToNextLabel(direction, state) { + if (!state.currentPixel) { + return null + } + const filtered = state.labels + .map((l, i) => ({ pixel: [l.x, l.y], index: i })) + .filter(l => l.pixel[0] !== state.currentPixel.x || l.pixel[1] !== state.currentPixel.y) + if (!filtered.length) { + return null + } + const pixelArray = filtered.map(l => l.pixel) + let nextFilteredIndex = spatialNavigate(direction, [state.currentPixel.x, state.currentPixel.y], pixelArray) + if (nextFilteredIndex == null || nextFilteredIndex < 0 || nextFilteredIndex >= filtered.length) { + nextFilteredIndex = 0 + } + return state.labels[filtered[nextFilteredIndex].index] +} + +function initLabelSource(map) { + if (!map.getSource(HIGHLIGHT_LABEL_SOURCE)) { + map.addSource(HIGHLIGHT_LABEL_SOURCE, { type: 'geojson', data: { type: 'FeatureCollection', features: [] } }) + } +} + +function setLineCenterPlacement(map) { + map.getStyle().layers + .filter(l => l.layout?.['symbol-placement'] === 'line') + .forEach(l => map.setLayoutProperty(l.id, 'symbol-placement', 'line-center')) +} + +function setSymbolTextOpacity(map) { + map.getStyle().layers + .filter(l => l.type === 'symbol') + .forEach(layer => { + map.setPaintProperty(layer.id, 'text-opacity', ['case', ['boolean', ['feature-state', 'highlighted'], false], 0, 1]) }) - map.moveLayer(highlightLayerId) +} + +export function createMapLabelNavigator(map, mapColorScheme, events, eventBus) { + const state = { + isDarkStyle: mapColorScheme === 'dark', + labels: [], + currentPixel: null, + highlightLayerId: null, + highlightedExpr: null } + setLineCenterPlacement(map) + initLabelSource(map) + + eventBus?.on(events.MAP_SET_STYLE, style => { + map.once('styledata', () => map.once('idle', () => { + setLineCenterPlacement(map) + initLabelSource(map) + state.isDarkStyle = style?.mapColorScheme === 'dark' + })) + }) + map.on('zoom', () => { - if (highlightLayerId && highlightedExpr) { - const zoom = map.getZoom() - const baseSize = evalInterpolate(highlightedExpr, zoom) - map.setLayoutProperty(highlightLayerId, 'text-size', baseSize * HIGHLIGHT_SCALE_FACTOR) + if (state.highlightLayerId && state.highlightedExpr) { + const baseSize = evalInterpolate(state.highlightedExpr, map.getZoom()) + map.setLayoutProperty(state.highlightLayerId, 'text-size', baseSize * HIGHLIGHT_SCALE_FACTOR) } }) + function refreshLabels() { + const symbolLayers = map.getStyle().layers.filter(l => l.type === 'symbol') + const features = map.queryRenderedFeatures({ layers: symbolLayers.map(l => l.id) }) + state.labels = buildLabelsFromLayers(map, symbolLayers, features) + } + function highlightCenter() { refreshLabels() - if (!labels.length) { + if (!state.labels.length) { return null } const centerPoint = map.project(map.getCenter()) - const closest = labels.reduce((best, label) => { - const dist = (label.x - centerPoint.x) ** 2 + (label.y - centerPoint.y) ** 2 - if (!best || dist < best.dist) { - return { label, dist } - } - return best - }, null)?.label + const closest = findClosestLabel(state.labels, centerPoint) if (closest) { - currentPixel = { x: closest.x, y: closest.y } + state.currentPixel = { x: closest.x, y: closest.y } } - highlight(closest) + applyHighlight(map, closest, state) return `${closest.text} (${closest.layer.id})` } function highlightNext(direction) { refreshLabels() - if (!labels.length) { + if (!state.labels.length) { return null } - if (!currentPixel) { + if (!state.currentPixel) { return highlightCenter() } - const filtered = labels - .map((l, i) => ({ pixel: [l.x, l.y], index: i })) - .filter(l => l.pixel[0] !== currentPixel.x || l.pixel[1] !== currentPixel.y) - if (!filtered.length) { + const labelData = navigateToNextLabel(direction, state) + if (!labelData) { return null } - const pixelArray = filtered.map(l => l.pixel) - let nextFilteredIndex = spatialNavigate(direction, [currentPixel.x, currentPixel.y], pixelArray) - if (nextFilteredIndex == null || nextFilteredIndex < 0 || nextFilteredIndex >= filtered.length) { - nextFilteredIndex = 0 - } - const labelData = labels[filtered[nextFilteredIndex].index] - currentPixel = { x: labelData.x, y: labelData.y } - highlight(labelData) + state.currentPixel = { x: labelData.x, y: labelData.y } + applyHighlight(map, labelData, state) return `${labelData.text} (${labelData.layer.id})` } - map.getStyle().layers.filter(l => l.type === 'symbol').forEach(layer => { - map.setPaintProperty(layer.id, 'text-opacity', ['case', ['boolean', ['feature-state', 'highlighted'], false], 0, 1]) - }) + setSymbolTextOpacity(map) return { refreshLabels, highlightNextLabel: highlightNext, highlightLabelAtCenter: highlightCenter, - clearHighlightedLabel: removeHighlight + clearHighlightedLabel: () => removeHighlightLayer(map, state) } } diff --git a/sonar-project.properties b/sonar-project.properties index c6e42061..f250107f 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -16,8 +16,23 @@ sonar.tests=src,plugins,providers sonar.test.inclusions=**/*.test.*,**/__mocks__/**,**/__stubs__/** sonar.cpd.exclusions=**/*.test.*,**/__mocks__/**,**/__stubs__/** -sonar.issue.ignore.multicriteria=reactPropsJs,reactPropsJsx +# Ignored rules + +# S6774: React props validation - using TypeScript/JSDoc for prop types instead +sonar.issue.ignore.multicriteria=reactPropsJs,reactPropsJsx,preferGlobalThisJs,preferGlobalThisJsx,preferAtJs,preferAtJsx sonar.issue.ignore.multicriteria.reactPropsJs.ruleKey=javascript:S6774 sonar.issue.ignore.multicriteria.reactPropsJs.resourceKey=**/*.js sonar.issue.ignore.multicriteria.reactPropsJsx.ruleKey=javascript:S6774 -sonar.issue.ignore.multicriteria.reactPropsJsx.resourceKey=**/*.jsx \ No newline at end of file +sonar.issue.ignore.multicriteria.reactPropsJsx.resourceKey=**/*.jsx + +# S7764: Prefer globalThis over window - globalThis cannot be polyfilled for older browsers +sonar.issue.ignore.multicriteria.preferGlobalThisJs.ruleKey=javascript:S7764 +sonar.issue.ignore.multicriteria.preferGlobalThisJs.resourceKey=**/*.js +sonar.issue.ignore.multicriteria.preferGlobalThisJsx.ruleKey=javascript:S7764 +sonar.issue.ignore.multicriteria.preferGlobalThisJsx.resourceKey=**/*.jsx + +# S7755: Prefer .at() over [array.length - index] - .at() is ES2022 and requires polyfilling +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 diff --git a/src/App/components/KeyboardHelp/KeyboardHelp.jsx b/src/App/components/KeyboardHelp/KeyboardHelp.jsx index bb5f7ddf..7e159995 100755 --- a/src/App/components/KeyboardHelp/KeyboardHelp.jsx +++ b/src/App/components/KeyboardHelp/KeyboardHelp.jsx @@ -1,11 +1,13 @@ // src/components/KeyboardHelp.jsx import React from 'react' +import { useConfig } from '../../store/configContext' import { getKeyboardShortcuts } from '../../registry/keyboardShortcutRegistry.js' // eslint-disable-next-line camelcase, react/jsx-pascal-case // sonarjs/disable-next-line function-name export const KeyboardHelp = () => { - const groups = getKeyboardShortcuts().reduce((acc, shortcut) => { + const appConfig = useConfig() + const groups = getKeyboardShortcuts(appConfig).reduce((acc, shortcut) => { acc[shortcut.group] = acc[shortcut.group] || [] acc[shortcut.group].push(shortcut) return acc diff --git a/src/App/components/KeyboardHelp/KeyboardHelp.test.jsx b/src/App/components/KeyboardHelp/KeyboardHelp.test.jsx index 1e74e8a8..53f808fe 100755 --- a/src/App/components/KeyboardHelp/KeyboardHelp.test.jsx +++ b/src/App/components/KeyboardHelp/KeyboardHelp.test.jsx @@ -3,12 +3,21 @@ import React from 'react' import { render, screen, within } from '@testing-library/react' import { KeyboardHelp } from './KeyboardHelp' import { getKeyboardShortcuts } from '../../registry/keyboardShortcutRegistry.js' +import { useConfig } from '../../store/configContext' jest.mock('../../registry/keyboardShortcutRegistry.js', () => ({ getKeyboardShortcuts: jest.fn() })) +jest.mock('../../store/configContext', () => ({ + useConfig: jest.fn() +})) + describe('KeyboardHelp', () => { + beforeEach(() => { + useConfig.mockReturnValue({}) + }) + afterEach(() => { jest.clearAllMocks() }) diff --git a/src/App/controls/keyboardShortcuts.js b/src/App/controls/keyboardShortcuts.js index 71f602a3..c9f52018 100755 --- a/src/App/controls/keyboardShortcuts.js +++ b/src/App/controls/keyboardShortcuts.js @@ -48,13 +48,15 @@ export const coreShortcuts = [ group: 'Labels', title: 'Highlight label at centre', command: 'Alt + Enter', - enabled: false + enabled: false, + requiredConfig: ['readMapText'] }, { id: 'highlightNextLabel', group: 'Labels', title: 'Highlight nearby label', command: 'Alt + , , or ', - enabled: false + enabled: false, + requiredConfig: ['readMapText'] } ] diff --git a/src/App/initialiseApp.js b/src/App/initialiseApp.js index 657daef1..a905b63e 100755 --- a/src/App/initialiseApp.js +++ b/src/App/initialiseApp.js @@ -1,6 +1,6 @@ import { createRoot } from 'react-dom/client' import { EVENTS as events } from '../config/events.js' -import { appConfig } from '../config/appConfig.js' +import { defaultAppConfig } from '../config/appConfig.js' import { createButtonRegistry } from './registry/buttonRegistry.js' import { createPanelRegistry } from './registry/panelRegistry.js' import { createControlRegistry } from './registry/controlRegistry.js' @@ -59,7 +59,7 @@ export async function initialiseApp (rootElement, { // Register default appConfig as a plugin registerPlugin({ id: 'appConfig', - manifest: appConfig + manifest: defaultAppConfig }) // Create root if not already present diff --git a/src/App/registry/keyboardShortcutRegistry.js b/src/App/registry/keyboardShortcutRegistry.js index c553eef7..b00c5ec3 100755 --- a/src/App/registry/keyboardShortcutRegistry.js +++ b/src/App/registry/keyboardShortcutRegistry.js @@ -21,8 +21,18 @@ export const setProviderSupportedShortcuts = (ids = []) => { providerSupportedIds = new Set(ids) } -export const getKeyboardShortcuts = () => { - const filteredCore = coreShortcuts.filter(s => providerSupportedIds.has(s.id)) +export const getKeyboardShortcuts = (appConfig = {}) => { + const filteredCore = coreShortcuts.filter(s => { + // Must be supported by provider + if (!providerSupportedIds.has(s.id)) { + return false + } + // Check requiredConfig - all specified config values must be truthy + if (s.requiredConfig) { + return s.requiredConfig.every(key => appConfig[key]) + } + return true + }) return [ ...filteredCore, // supported core shortcuts diff --git a/src/App/registry/keyboardShortcutRegistry.test.js b/src/App/registry/keyboardShortcutRegistry.test.js index 6f593f5e..0aea5531 100755 --- a/src/App/registry/keyboardShortcutRegistry.test.js +++ b/src/App/registry/keyboardShortcutRegistry.test.js @@ -70,4 +70,32 @@ describe('keyboardShortcutRegistry', () => { const shortcuts = getKeyboardShortcuts() expect(shortcuts).toEqual([]) }) + + test('getKeyboardShortcuts filters by requiredConfig when appConfig provided', () => { + jest.resetModules() + jest.doMock('../controls/keyboardShortcuts.js', () => ({ + coreShortcuts: [ + { id: 'always', description: 'Always shown' }, + { id: 'conditional', description: 'Conditional', requiredConfig: ['featureEnabled'] } + ] + })) + const module = require('./keyboardShortcutRegistry.js') + module.setProviderSupportedShortcuts(['always', 'conditional']) + + // Without config, conditional shortcut is excluded + expect(module.getKeyboardShortcuts()).toEqual([ + { id: 'always', description: 'Always shown' } + ]) + + // With config false, conditional shortcut is excluded + expect(module.getKeyboardShortcuts({ featureEnabled: false })).toEqual([ + { id: 'always', description: 'Always shown' } + ]) + + // With config true, conditional shortcut is included + expect(module.getKeyboardShortcuts({ featureEnabled: true })).toEqual([ + { id: 'always', description: 'Always shown' }, + { id: 'conditional', description: 'Conditional', requiredConfig: ['featureEnabled'] } + ]) + }) }) diff --git a/src/InteractiveMap/InteractiveMap.js b/src/InteractiveMap/InteractiveMap.js index 93119a80..e9ae284a 100755 --- a/src/InteractiveMap/InteractiveMap.js +++ b/src/InteractiveMap/InteractiveMap.js @@ -22,6 +22,7 @@ import { createInterfaceDetector, getInterfaceType } from '../utils/detectInterf import { createReverseGeocode } from '../services/reverseGeocode.js' import { EVENTS as events } from '../config/events.js' import { createEventBus } from '../services/eventBus.js' +import { toggleInertElements } from '../utils/toggleInertElements.js' /** * Main entry point for the Interactive Map component. @@ -33,6 +34,7 @@ export default class InteractiveMap { _breakpointDetector = null _interfaceDetectorCleanup = null _hybridBehaviourCleanup = null + _isHidden = false // tracks if map is hidden but preserved (hybrid mode) /** * Create a new InteractiveMap instance. @@ -98,12 +100,20 @@ export default class InteractiveMap { } _handleButtonClick (e) { - this.loadApp() history.pushState({ isBack: true }, '', e.currentTarget.getAttribute('href')) + if (this._isHidden) { + this.showApp() + } else { + this.loadApp() + } } _handleExitClick () { - this.removeApp() + if (this.config.preserveStateOnClose) { + this.hideApp() + } else { + this.removeApp() + } // Remove the map param from the URL using regex to prevent encoding const key = this.config.mapViewParamKey const href = location.href @@ -190,6 +200,52 @@ export default class InteractiveMap { this.eventBus.emit(events.MAP_DESTROY, { mapId: this.id }) } + /** + * Hide the map application without destroying it (preserves state). + * Used in hybrid mode when resizing below breakpoint. + * + * @internal Not intended for end-user use. + */ + hideApp () { + this._isHidden = true + this.rootEl.style.display = 'none' + + // Restore inert elements before focusing button + toggleInertElements({ containerEl: this.rootEl, isFullscreen: false }) + + if (this._openButton) { + this._openButton.removeAttribute('style') + this._openButton.focus() + } + + // Remove fullscreen classes + document.documentElement.classList.remove('im-is-fullscreen') + this.rootEl.classList.remove('im-is-fullscreen') + + // Reset page title (remove prepended map title) + const parts = document.title.split(': ') + if (parts.length > 1) { + document.title = parts[parts.length - 1] + } + } + + /** + * Show a previously hidden map application. + * Used in hybrid mode when resizing above breakpoint or clicking button. + * + * @internal Not intended for end-user use. + */ + showApp () { + this._isHidden = false + this.rootEl.style.display = '' + + if (this._openButton) { + this._openButton.style.display = 'none' + } + + updateDOMState(this) + } + /** * Destroy the map instance and clean up all resources. * diff --git a/src/InteractiveMap/InteractiveMap.test.js b/src/InteractiveMap/InteractiveMap.test.js index d08882ef..5411dcf4 100755 --- a/src/InteractiveMap/InteractiveMap.test.js +++ b/src/InteractiveMap/InteractiveMap.test.js @@ -268,20 +268,23 @@ describe('InteractiveMap Core Functionality', () => { expect(() => map.destroy()).not.toThrow() }) - it('_handleExitClick removes app and calls replaceState', () => { + it('_handleExitClick removes app when preserveStateOnClose is false', () => { const replaceStateSpy = jest.spyOn(history, 'replaceState').mockImplementation(() => {}) const map = new InteractiveMap('map', { behaviour: 'buttonFirst', mapProvider: mapProviderMock, - mapViewParamKey: 'mv' + mapViewParamKey: 'mv', + preserveStateOnClose: false }) const removeAppSpy = jest.spyOn(map, 'removeApp').mockImplementation(() => {}) + const hideAppSpy = jest.spyOn(map, 'hideApp').mockImplementation(() => {}) map._handleExitClick() expect(removeAppSpy).toHaveBeenCalled() + expect(hideAppSpy).not.toHaveBeenCalled() expect(replaceStateSpy).toHaveBeenCalledWith( history.state, '', @@ -289,8 +292,78 @@ describe('InteractiveMap Core Functionality', () => { ) removeAppSpy.mockRestore() + hideAppSpy.mockRestore() replaceStateSpy.mockRestore() }) + + it('_handleExitClick hides app when preserveStateOnClose is true', () => { + const replaceStateSpy = jest.spyOn(history, 'replaceState').mockImplementation(() => {}) + + const map = new InteractiveMap('map', { + behaviour: 'buttonFirst', + mapProvider: mapProviderMock, + mapViewParamKey: 'mv', + preserveStateOnClose: true + }) + + const removeAppSpy = jest.spyOn(map, 'removeApp').mockImplementation(() => {}) + const hideAppSpy = jest.spyOn(map, 'hideApp').mockImplementation(() => {}) + + map._handleExitClick() + + expect(hideAppSpy).toHaveBeenCalled() + expect(removeAppSpy).not.toHaveBeenCalled() + expect(replaceStateSpy).toHaveBeenCalled() + + removeAppSpy.mockRestore() + hideAppSpy.mockRestore() + replaceStateSpy.mockRestore() + }) + + it('_handleButtonClick calls showApp when map is hidden', async () => { + const map = new InteractiveMap('map', { behaviour: 'buttonFirst', mapProvider: mapProviderMock }) + map._isHidden = true + const showAppSpy = jest.spyOn(map, 'showApp').mockImplementation(() => {}) + const loadAppSpy = jest.spyOn(map, 'loadApp').mockResolvedValue() + const pushStateSpy = jest.spyOn(history, 'pushState').mockImplementation(() => {}) + const fakeEvent = { currentTarget: { getAttribute: jest.fn().mockReturnValue('/?mv=map') } } + + await openButtonCallback(fakeEvent) + + expect(showAppSpy).toHaveBeenCalled() + expect(loadAppSpy).not.toHaveBeenCalled() + expect(pushStateSpy).toHaveBeenCalled() + + showAppSpy.mockRestore() + loadAppSpy.mockRestore() + pushStateSpy.mockRestore() + }) + + it('hideApp sets _isHidden and hides element', () => { + const map = new InteractiveMap('map', { behaviour: 'buttonFirst', mapProvider: mapProviderMock }) + map._openButton = mockButtonInstance + + map.hideApp() + + expect(map._isHidden).toBe(true) + expect(map.rootEl.style.display).toBe('none') + expect(mockButtonInstance.removeAttribute).toHaveBeenCalledWith('style') + expect(mockButtonInstance.focus).toHaveBeenCalled() + }) + + it('showApp sets _isHidden false and shows element', () => { + const map = new InteractiveMap('map', { behaviour: 'buttonFirst', mapProvider: mapProviderMock }) + map._isHidden = true + map._openButton = mockButtonInstance + map.rootEl.style.display = 'none' + + map.showApp() + + expect(map._isHidden).toBe(false) + expect(map.rootEl.style.display).toBe('') + expect(mockButtonInstance.style.display).toBe('none') + expect(updateDOMState).toHaveBeenCalledWith(map) + }) }) describe('InteractiveMap Public API Methods', () => { diff --git a/src/InteractiveMap/behaviourController.js b/src/InteractiveMap/behaviourController.js index dd45758d..03cc3018 100755 --- a/src/InteractiveMap/behaviourController.js +++ b/src/InteractiveMap/behaviourController.js @@ -1,5 +1,6 @@ import { getQueryParam } from '../utils/queryString.js' import { isHybridFullscreen } from '../utils/getIsFullscreen.js' +import { updateDOMState } from './domStateManager.js' import defaults from '../config/defaults.js' // ----------------------------------------------------------------------------- @@ -56,9 +57,18 @@ function setupBehavior (mapInstance) { const handleChange = () => { if (shouldLoadComponent(mapInstance.config)) { - mapInstance.loadApp() + if (mapInstance._isHidden) { + mapInstance.showApp() + } else if (!mapInstance._root) { + mapInstance.loadApp() + } else { + // Map is showing - update DOM state for fullscreen/inline transition + updateDOMState(mapInstance) + } } else { - mapInstance.removeApp() + if (mapInstance._root) { + mapInstance.hideApp() + } } } diff --git a/src/InteractiveMap/behaviourController.test.js b/src/InteractiveMap/behaviourController.test.js index 1d9b0314..033af75c 100755 --- a/src/InteractiveMap/behaviourController.test.js +++ b/src/InteractiveMap/behaviourController.test.js @@ -4,8 +4,10 @@ import { setupBehavior, shouldLoadComponent } from './behaviourController.js' import * as queryString from '../utils/queryString.js' +import { updateDOMState } from './domStateManager.js' jest.mock('../utils/queryString.js') +jest.mock('./domStateManager.js', () => ({ updateDOMState: jest.fn() })) describe('shouldLoadComponent', () => { beforeEach(() => { @@ -60,8 +62,12 @@ describe('setupBehavior', () => { mockMapInstance = { config: { hybridWidth: null, maxMobileWidth: 640 }, _breakpointDetector: mockBreakpointDetector, + _root: null, + _isHidden: false, loadApp: jest.fn(), - removeApp: jest.fn() + removeApp: jest.fn(), + hideApp: jest.fn(), + showApp: jest.fn() } // Default: viewport is wide window.matchMedia = jest.fn().mockImplementation(() => ({ @@ -145,4 +151,63 @@ describe('setupBehavior', () => { const cleanup = setupBehavior(mockMapInstance) expect(cleanup).toBeNull() }) + + describe('hybrid behaviour handleChange', () => { + let handleChange + + beforeEach(() => { + const mockAddEventListener = jest.fn((event, cb) => { handleChange = cb }) + window.matchMedia = jest.fn().mockImplementation(() => ({ + matches: false, + addEventListener: mockAddEventListener, + removeEventListener: jest.fn() + })) + mockMapInstance.config = { id: 'test', behaviour: 'hybrid', hybridWidth: null, maxMobileWidth: 640 } + setupBehavior(mockMapInstance) + }) + + it('calls showApp when map is hidden and should load', () => { + mockMapInstance._isHidden = true + queryString.getQueryParam.mockReturnValue(null) // wide viewport, should load + + handleChange() + + expect(mockMapInstance.showApp).toHaveBeenCalled() + expect(mockMapInstance.loadApp).not.toHaveBeenCalled() + }) + + it('calls loadApp when map has no root and should load', () => { + mockMapInstance._isHidden = false + mockMapInstance._root = null + queryString.getQueryParam.mockReturnValue(null) + + handleChange() + + expect(mockMapInstance.loadApp).toHaveBeenCalled() + expect(mockMapInstance.showApp).not.toHaveBeenCalled() + }) + + it('calls updateDOMState when map is showing and should load', () => { + mockMapInstance._isHidden = false + mockMapInstance._root = {} // has root + queryString.getQueryParam.mockReturnValue(null) + + handleChange() + + expect(updateDOMState).toHaveBeenCalledWith(mockMapInstance) + expect(mockMapInstance.loadApp).not.toHaveBeenCalled() + expect(mockMapInstance.showApp).not.toHaveBeenCalled() + }) + + it('calls hideApp when should not load and has root', () => { + mockMapInstance._root = {} + // Simulate narrow viewport where shouldLoadComponent returns false + window.matchMedia = jest.fn().mockImplementation(() => ({ matches: true })) + queryString.getQueryParam.mockReturnValue(null) + + handleChange() + + expect(mockMapInstance.hideApp).toHaveBeenCalled() + }) + }) }) diff --git a/src/InteractiveMap/historyManager.js b/src/InteractiveMap/historyManager.js index 0330dae9..f13e05d2 100755 --- a/src/InteractiveMap/historyManager.js +++ b/src/InteractiveMap/historyManager.js @@ -28,11 +28,20 @@ function handlePopstate () { const isHybridVisible = mapInstance.config.behaviour === 'hybrid' && !isHybridFullscreen(mapInstance.config) const isOpen = mapInstance.rootEl?.children.length - if (shouldBeOpen && !isOpen) { - mapInstance.loadApp?.() + if (shouldBeOpen && (!isOpen || mapInstance._isHidden)) { + if (mapInstance._isHidden) { + mapInstance.showApp?.() + } else { + mapInstance.loadApp?.() + } } else if (!shouldBeOpen && isOpen && !isHybridVisible) { - mapInstance.removeApp?.() - mapInstance.openButton?.focus?.() + if (mapInstance.config.preserveStateOnClose) { + mapInstance.hideApp?.() + } else { + mapInstance.removeApp?.() + } + } else { + // No action } } } diff --git a/src/InteractiveMap/historyManager.test.js b/src/InteractiveMap/historyManager.test.js index f3c94e6c..2a340009 100755 --- a/src/InteractiveMap/historyManager.test.js +++ b/src/InteractiveMap/historyManager.test.js @@ -13,19 +13,25 @@ describe('historyManager', () => { beforeEach(() => { component1 = { id: 'map', - config: { behaviour: 'buttonFirst', hybridWidth: null, maxMobileWidth: 640 }, + config: { behaviour: 'buttonFirst', hybridWidth: null, maxMobileWidth: 640, preserveStateOnClose: false }, rootEl: document.createElement('div'), loadApp: jest.fn(), removeApp: jest.fn(), - openButton: { focus: jest.fn() } + hideApp: jest.fn(), + showApp: jest.fn(), + openButton: { focus: jest.fn() }, + _isHidden: false } component2 = { id: 'list', - config: { behaviour: 'hybrid', hybridWidth: null, maxMobileWidth: 640 }, + config: { behaviour: 'hybrid', hybridWidth: null, maxMobileWidth: 640, preserveStateOnClose: false }, rootEl: document.createElement('div'), loadApp: jest.fn(), removeApp: jest.fn(), - openButton: { focus: jest.fn() } + hideApp: jest.fn(), + showApp: jest.fn(), + openButton: { focus: jest.fn() }, + _isHidden: false } popstateEvent = new PopStateEvent('popstate') jest.clearAllMocks() @@ -63,7 +69,8 @@ describe('historyManager', () => { expect(component1.loadApp).not.toHaveBeenCalled() }) - it('removes component and focuses button when view param does not match', () => { + it('removes component when view param does not match and preserveStateOnClose is false', () => { + component1.config.preserveStateOnClose = false component1.rootEl.appendChild(document.createElement('div')) historyManager.register(component1) queryString.getQueryParam.mockReturnValue(null) @@ -71,7 +78,19 @@ describe('historyManager', () => { window.dispatchEvent(popstateEvent) expect(component1.removeApp).toHaveBeenCalled() - expect(component1.openButton.focus).toHaveBeenCalled() + expect(component1.hideApp).not.toHaveBeenCalled() + }) + + it('hides component when view param does not match and preserveStateOnClose is true', () => { + component1.config.preserveStateOnClose = true + component1.rootEl.appendChild(document.createElement('div')) + historyManager.register(component1) + queryString.getQueryParam.mockReturnValue(null) + + window.dispatchEvent(popstateEvent) + + expect(component1.hideApp).toHaveBeenCalled() + expect(component1.removeApp).not.toHaveBeenCalled() }) it('does not remove hybrid component when viewport is wide (inline mode)', () => { @@ -87,6 +106,7 @@ describe('historyManager', () => { }) it('removes hybrid component when viewport is narrow and view does not match', () => { + component2.config.preserveStateOnClose = false component2.rootEl.appendChild(document.createElement('div')) historyManager.register(component2) queryString.getQueryParam.mockReturnValue(null) @@ -98,6 +118,17 @@ describe('historyManager', () => { expect(component2.removeApp).toHaveBeenCalled() }) + it('calls showApp when view param matches and component is hidden', () => { + component1._isHidden = true + historyManager.register(component1) + queryString.getQueryParam.mockReturnValue('map') + + window.dispatchEvent(popstateEvent) + + expect(component1.showApp).toHaveBeenCalled() + expect(component1.loadApp).not.toHaveBeenCalled() + }) + it('uses hybridWidth for media query when provided', () => { component2.config.hybridWidth = 768 component2.rootEl.appendChild(document.createElement('div')) diff --git a/src/config/appConfig.js b/src/config/appConfig.js index aadf4bdc..a1ae97e9 100755 --- a/src/config/appConfig.js +++ b/src/config/appConfig.js @@ -18,12 +18,12 @@ const exitButtonSlots = { } // Default app buttons, panels and icons -export const appConfig = { +export const defaultAppConfig = { buttons: [{ id: 'exit', label: 'Exit', iconId: 'close', - onClick: (e, { services }) => services.closeApp(), + onClick: (_e, { services }) => services.closeApp(), excludeWhen: ({ appConfig, appState }) => !appConfig.hasExitButton || !(appState.isFullscreen && (new URL(window.location.href)).searchParams.has(appConfig.mapViewParamKey)), mobile: exitButtonSlots, tablet: exitButtonSlots, @@ -32,7 +32,7 @@ export const appConfig = { id: 'fullscreen', label: () => `${document.fullscreenElement ? 'Exit' : 'Enter'} fullscreen`, iconId: () => document.fullscreenElement ? 'minimise' : 'maximise', - onClick: (e, { appState }) => { + onClick: (_e, { appState }) => { const container = appState.layoutRefs.appContainerRef.current document.fullscreenElement ? document.exitFullscreen() : container.requestFullscreen() }, @@ -45,7 +45,7 @@ export const appConfig = { group: 'zoom', label: 'Zoom in', iconId: 'plus', - onClick: (e, { mapProvider, appConfig }) => mapProvider.zoomIn(appConfig.zoomDelta), + onClick: (_e, { mapProvider, appConfig }) => mapProvider.zoomIn(appConfig.zoomDelta), excludeWhen: ({ appState, appConfig }) => !appConfig.enableZoomControls || appState.interfaceType === 'touch', enableWhen: ({ mapState }) => !mapState.isAtMaxZoom, mobile: buttonSlots, @@ -56,7 +56,7 @@ export const appConfig = { group: 'zoom', label: 'Zoom out', iconId: 'minus', - onClick: (e, { mapProvider, appConfig }) => mapProvider.zoomOut(appConfig.zoomDelta), + onClick: (_e, { mapProvider, appConfig }) => mapProvider.zoomOut(appConfig.zoomDelta), excludeWhen: ({ appState, appConfig }) => !appConfig.enableZoomControls || appState.interfaceType === 'touch', enableWhen: ({ mapState }) => !mapState.isAtMinZoom, mobile: buttonSlots, diff --git a/src/config/appConfig.test.js b/src/config/appConfig.test.js index e137ef9a..0c821fb5 100755 --- a/src/config/appConfig.test.js +++ b/src/config/appConfig.test.js @@ -1,14 +1,14 @@ import { render } from '@testing-library/react' -import { appConfig } from './appConfig' +import { defaultAppConfig } from './appConfig' -describe('appConfig', () => { +describe('defaultAppConfig', () => { const appState = { layoutRefs: { appContainerRef: { current: document.createElement('div') } }, isFullscreen: false } - const buttons = appConfig.buttons + const buttons = defaultAppConfig.buttons const fullscreenBtn = buttons.find(b => b.id === 'fullscreen') const exitBtn = buttons.find(b => b.id === 'exit') it('renders KeyboardHelp panel', () => { - const panel = appConfig.panels.find(p => p.id === 'keyboardHelp') + const panel = defaultAppConfig.panels.find(p => p.id === 'keyboardHelp') const { container } = render(panel.render()) expect(container.querySelector('.im-c-keyboard-help')).toBeInTheDocument() }) @@ -16,11 +16,11 @@ describe('appConfig', () => { it('evaluates dynamic button properties', () => { // label expect(typeof fullscreenBtn.label).toBe('function') - expect(fullscreenBtn.label({ appState, appConfig })).toMatch(/fullscreen/) + expect(fullscreenBtn.label({ appState, appConfig: defaultAppConfig })).toMatch(/fullscreen/) // iconId expect(typeof fullscreenBtn.iconId).toBe('function') - expect(fullscreenBtn.iconId({ appState, appConfig })).toBe('maximise') + expect(fullscreenBtn.iconId({ appState, appConfig: defaultAppConfig })).toBe('maximise') // excludeWhen expect(exitBtn.excludeWhen({ appConfig: { hasExitButton: false } })).toBe(true) @@ -50,13 +50,13 @@ describe('appConfig', () => { // Not in fullscreen Object.defineProperty(document, 'fullscreenElement', { value: null, writable: true }) - expect(fullscreenBtn.label({ appState, appConfig })).toBe('Enter fullscreen') - expect(fullscreenBtn.iconId({ appState, appConfig })).toBe('maximise') + expect(fullscreenBtn.label({ appState, appConfig: defaultAppConfig })).toBe('Enter fullscreen') + expect(fullscreenBtn.iconId({ appState, appConfig: defaultAppConfig })).toBe('maximise') // In fullscreen Object.defineProperty(document, 'fullscreenElement', { value: containerMock, writable: true }) - expect(fullscreenBtn.label({ appState, appConfig })).toBe('Exit fullscreen') - expect(fullscreenBtn.iconId({ appState, appConfig })).toBe('minimise') + expect(fullscreenBtn.label({ appState, appConfig: defaultAppConfig })).toBe('Exit fullscreen') + expect(fullscreenBtn.iconId({ appState, appConfig: defaultAppConfig })).toBe('minimise') }) it('calls exit button onClick correctly', () => { diff --git a/src/config/defaults.js b/src/config/defaults.js index 5978516e..c956661d 100755 --- a/src/config/defaults.js +++ b/src/config/defaults.js @@ -33,6 +33,7 @@ const defaults = { nudgeZoomDelta: 0.1, panDelta: 100, pageTitle: 'Map view', + preserveStateOnClose: false, readMapText: false, reverseGeocodeProvider: null, zoomDelta: 1 diff --git a/src/types.js b/src/types.js index 148cb14b..4dae6abd 100644 --- a/src/types.js +++ b/src/types.js @@ -568,6 +568,11 @@ * @property {PluginDescriptor[]} [plugins] * Plugins to load. * + * @property {boolean} [preserveStateOnClose=false] + * Whether to preserve the map state when closed via back button or exit button. + * When true, the map is hidden but not destroyed, preserving markers, zoom, etc. + * Useful for list/map toggle scenarios. Only applies to 'hybrid' and 'buttonFirst' behaviours. + * * @property {boolean} [readMapText=false] * Whether map text labels can be selected and read aloud by assistive technologies. * diff --git a/src/utils/detectBreakpoint.js b/src/utils/detectBreakpoint.js index 8341ff5a..baf85da1 100755 --- a/src/utils/detectBreakpoint.js +++ b/src/utils/detectBreakpoint.js @@ -1,106 +1,105 @@ -function createBreakpointDetector ({ maxMobileWidth, minDesktopWidth, containerEl }) { - let lastBreakpoint = 'unknown' - const listeners = new Set() - let cleanup = null - - const getBreakpointType = (width) => { - if (width <= maxMobileWidth) { - return 'mobile' - } - if (width >= minDesktopWidth) { - return 'desktop' - } - return 'tablet' +function getBreakpointType(width, maxMobileWidth, minDesktopWidth) { + if (width <= maxMobileWidth) { + return 'mobile' } - - const notifyListeners = (type) => { - if (type !== lastBreakpoint) { - lastBreakpoint = type // Set synchronously BEFORE RAF - requestAnimationFrame(() => { - // Double-check it hasn't changed again - if (lastBreakpoint === type) { - listeners.forEach(fn => fn(type)) - } - }) - } + if (width >= minDesktopWidth) { + return 'desktop' } + return 'tablet' +} - // Container-based detection - if (containerEl) { - containerEl.style.containerType = 'inline-size' - - // Set initial detection BEFORE observing to prevent double notification - const initialWidth = containerEl.getBoundingClientRect().width - const initialType = getBreakpointType(initialWidth) - containerEl.setAttribute('data-breakpoint', initialType) - lastBreakpoint = initialType // Set this directly, don't notify yet +function createContainerDetector(containerEl, getType, notifyListeners) { + containerEl.style.containerType = 'inline-size' - const observer = new ResizeObserver((entries) => { - const width = entries[0]?.borderBoxSize?.[0]?.inlineSize || entries[0]?.contentRect.width - const type = getBreakpointType(width) - containerEl.setAttribute('data-breakpoint', type) - notifyListeners(type) - }) + const initialWidth = containerEl.getBoundingClientRect().width + const initialType = getType(initialWidth) + containerEl.dataset.breakpoint = initialType - observer.observe(containerEl) + const observer = new ResizeObserver((entries) => { + const width = entries[0]?.borderBoxSize?.[0]?.inlineSize || entries[0]?.contentRect.width + const type = getType(width) + containerEl.dataset.breakpoint = type + notifyListeners(type) + }) - // Now notify listeners after observer is set up - notifyListeners(initialType) + observer.observe(containerEl) - cleanup = () => { + return { + initialType, + cleanup: () => { observer.disconnect() containerEl.style.containerType = '' - containerEl.removeAttribute('data-breakpoint') + delete containerEl.dataset.breakpoint } - } else { - // Viewport-based fallback - const mq = { - mobile: window.matchMedia(`(max-width: ${maxMobileWidth}px)`), - desktop: window.matchMedia(`(min-width: ${minDesktopWidth}px)`) - } - - const detect = () => { - let type + } +} - if (mq.mobile.matches) { - type = 'mobile' - } else if (mq.desktop.matches) { - type = 'desktop' - } else { - type = 'tablet' - } +function createViewportDetector(maxMobileWidth, minDesktopWidth, notifyListeners) { + const mq = { + mobile: window.matchMedia(`(max-width: ${maxMobileWidth}px)`), + desktop: window.matchMedia(`(min-width: ${minDesktopWidth}px)`) + } - notifyListeners(type) + const detect = () => { + let type = 'tablet' + if (mq.mobile.matches) { + type = 'mobile' + } else if (mq.desktop.matches) { + type = 'desktop' } + notifyListeners(type) + } - mq.mobile.addEventListener('change', detect) - mq.desktop.addEventListener('change', detect) - detect() + mq.mobile.addEventListener('change', detect) + mq.desktop.addEventListener('change', detect) + detect() - cleanup = () => { + return { + cleanup: () => { mq.mobile.removeEventListener('change', detect) mq.desktop.removeEventListener('change', detect) } } +} - const subscribe = (fn) => { - listeners.add(fn) - return () => listeners.delete(fn) - } +function createBreakpointDetector({ maxMobileWidth, minDesktopWidth, containerEl }) { + let lastBreakpoint = 'unknown' + const listeners = new Set() - const getBreakpoint = () => { - return lastBreakpoint === 'unknown' ? 'desktop' : lastBreakpoint + const notifyListeners = (type) => { + if (type !== lastBreakpoint) { + lastBreakpoint = type + requestAnimationFrame(() => { + if (lastBreakpoint === type) { + listeners.forEach(fn => fn(type)) + } + }) + } } - const destroy = () => { - cleanup?.() - listeners.clear() + const getType = (width) => getBreakpointType(width, maxMobileWidth, minDesktopWidth) + + let cleanup + if (containerEl) { + const detector = createContainerDetector(containerEl, getType, notifyListeners) + lastBreakpoint = detector.initialType + notifyListeners(detector.initialType) + cleanup = detector.cleanup + } else { + const detector = createViewportDetector(maxMobileWidth, minDesktopWidth, notifyListeners) + cleanup = detector.cleanup } return { - subscribe, - getBreakpoint, - destroy + subscribe: (fn) => { + listeners.add(fn) + return () => listeners.delete(fn) + }, + getBreakpoint: () => lastBreakpoint, + destroy: () => { + cleanup?.() + listeners.clear() + } } } From 4a475cbafcd83e527be8521421724d375e74d31c Mon Sep 17 00:00:00 2001 From: Dan Leech Date: Fri, 6 Feb 2026 12:28:55 +0000 Subject: [PATCH 03/18] Lint fix --- src/utils/detectBreakpoint.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/utils/detectBreakpoint.js b/src/utils/detectBreakpoint.js index baf85da1..d4463798 100755 --- a/src/utils/detectBreakpoint.js +++ b/src/utils/detectBreakpoint.js @@ -1,4 +1,4 @@ -function getBreakpointType(width, maxMobileWidth, minDesktopWidth) { +function getBreakpointType (width, maxMobileWidth, minDesktopWidth) { if (width <= maxMobileWidth) { return 'mobile' } @@ -8,7 +8,7 @@ function getBreakpointType(width, maxMobileWidth, minDesktopWidth) { return 'tablet' } -function createContainerDetector(containerEl, getType, notifyListeners) { +function createContainerDetector (containerEl, getType, notifyListeners) { containerEl.style.containerType = 'inline-size' const initialWidth = containerEl.getBoundingClientRect().width @@ -34,7 +34,7 @@ function createContainerDetector(containerEl, getType, notifyListeners) { } } -function createViewportDetector(maxMobileWidth, minDesktopWidth, notifyListeners) { +function createViewportDetector (maxMobileWidth, minDesktopWidth, notifyListeners) { const mq = { mobile: window.matchMedia(`(max-width: ${maxMobileWidth}px)`), desktop: window.matchMedia(`(min-width: ${minDesktopWidth}px)`) @@ -62,7 +62,7 @@ function createViewportDetector(maxMobileWidth, minDesktopWidth, notifyListeners } } -function createBreakpointDetector({ maxMobileWidth, minDesktopWidth, containerEl }) { +function createBreakpointDetector ({ maxMobileWidth, minDesktopWidth, containerEl }) { let lastBreakpoint = 'unknown' const listeners = new Set() From 05d2ea7afd56a19faec7316743491aab42fe704e Mon Sep 17 00:00:00 2001 From: Dan Leech Date: Fri, 6 Feb 2026 15:42:04 +0000 Subject: [PATCH 04/18] Sonar fixes --- demo/js/index.js | 7 +-- demo/js/planning.js | 2 +- .../src/hooks/useInteractionHandlers.js | 8 ++- src/InteractiveMap/behaviourController.js | 8 +-- src/InteractiveMap/historyManager.js | 53 ++++++++++++++----- src/utils/detectBreakpoint.js | 2 + 6 files changed, 57 insertions(+), 23 deletions(-) diff --git a/demo/js/index.js b/demo/js/index.js index 0792254a..a09b73db 100755 --- a/demo/js/index.js +++ b/demo/js/index.js @@ -22,8 +22,10 @@ var interactPlugin = createInteractPlugin({ },{ layerId: 'linked-parcels', // idProperty: 'id' + },{ + layerId: 'OS/TopographicArea_1/Agricultural Land' }], - interactionMode: 'auto', // 'auto', 'select', 'marker' // defaults to 'marker' + interactionMode: 'select', // 'auto', 'select', 'marker' // defaults to 'marker' multiSelect: true, contiguous: true, // excludeModes: ['draw'] @@ -150,7 +152,6 @@ interactiveMap.on('app:ready', function (e) { }) interactiveMap.on('map:ready', function (e) { - // console.log('map:ready') // framePlugin.addFrame('test', { // aspectRatio: 1 // }) @@ -179,7 +180,7 @@ interactiveMap.on('draw:ready', function () { // drawPlugin.split('test1234', { // snapLayers: ['OS/TopographicArea_1/Agricultural Land'] // }) - // drawPlugin.newLine('test', { + // drawPlugin.newPolygon('test', { // snapLayers: ['OS/TopographicArea_1/Agricultural Land'] // }) // drawPlugin.editFeature('test1234') diff --git a/demo/js/planning.js b/demo/js/planning.js index 75db6fe6..2fdb62e1 100755 --- a/demo/js/planning.js +++ b/demo/js/planning.js @@ -131,7 +131,7 @@ const interactiveMap = new InteractiveMap('map', { // search }) -interactiveMap.on('map:ready', function (e) { +interactiveMap.on('app:ready', function (e) { interactiveMap.addButton('menu', { label: 'Menu', panelId: 'menu', diff --git a/plugins/interact/src/hooks/useInteractionHandlers.js b/plugins/interact/src/hooks/useInteractionHandlers.js index bd9c7041..af216b21 100755 --- a/plugins/interact/src/hooks/useInteractionHandlers.js +++ b/plugins/interact/src/hooks/useInteractionHandlers.js @@ -62,12 +62,16 @@ export const useInteractionHandlers = ({ useEffect(() => { // Skip if features exist but bounds not yet calculated const awaitingBounds = selectedFeatures.length > 0 && !selectionBounds - if (awaitingBounds) return + if (awaitingBounds) { + return + } // Skip if selection was already empty and remains empty const prev = lastEmittedSelectionChange.current const wasEmpty = prev === null || prev.length === 0 - if (wasEmpty && selectedFeatures.length === 0) return + if (wasEmpty && selectedFeatures.length === 0) { + return + } eventBus.emit('interact:selectionchange', { selectedFeatures, diff --git a/src/InteractiveMap/behaviourController.js b/src/InteractiveMap/behaviourController.js index 03cc3018..0ca44b07 100755 --- a/src/InteractiveMap/behaviourController.js +++ b/src/InteractiveMap/behaviourController.js @@ -59,16 +59,16 @@ function setupBehavior (mapInstance) { if (shouldLoadComponent(mapInstance.config)) { if (mapInstance._isHidden) { mapInstance.showApp() - } else if (!mapInstance._root) { + } else if (mapInstance._root == null) { mapInstance.loadApp() } else { // Map is showing - update DOM state for fullscreen/inline transition updateDOMState(mapInstance) } + } else if (mapInstance._root) { + mapInstance.hideApp() } else { - if (mapInstance._root) { - mapInstance.hideApp() - } + // No action } } diff --git a/src/InteractiveMap/historyManager.js b/src/InteractiveMap/historyManager.js index f13e05d2..ef9ae58c 100755 --- a/src/InteractiveMap/historyManager.js +++ b/src/InteractiveMap/historyManager.js @@ -6,6 +6,40 @@ import defaults from '../config/defaults.js' // Internal helpers // ----------------------------------------------------------------------------- +/** + * Opens the map application for a given map instance. + * + * If the map instance was previously hidden, it restores the existing app. + * Otherwise, it loads the app for the first time. + * + * @param {MapInstance} mapInstance + * The map instance whose application should be opened. + */ +function openMap(mapInstance) { + if (mapInstance._isHidden) { + mapInstance.showApp?.() + } else { + mapInstance.loadApp?.() + } +} + +/** + * Closes the map application for a given map instance. + * + * Depending on the configuration, the map state is either preserved + * by hiding the app or fully removed from the DOM. + * + * @param {MapInstance} mapInstance + * The map instance whose application should be closed. + */ +function closeMap(mapInstance) { + if (mapInstance.config.preserveStateOnClose) { + mapInstance.hideApp?.() + } else { + mapInstance.removeApp?.() + } +} + /** * Handles the `popstate` event triggered by browser back/forward navigation. * @@ -29,19 +63,12 @@ function handlePopstate () { const isOpen = mapInstance.rootEl?.children.length if (shouldBeOpen && (!isOpen || mapInstance._isHidden)) { - if (mapInstance._isHidden) { - mapInstance.showApp?.() - } else { - mapInstance.loadApp?.() - } - } else if (!shouldBeOpen && isOpen && !isHybridVisible) { - if (mapInstance.config.preserveStateOnClose) { - mapInstance.hideApp?.() - } else { - mapInstance.removeApp?.() - } - } else { - // No action + openMap(mapInstance) + continue + } + + if (!shouldBeOpen && isOpen && !isHybridVisible) { + closeMap(mapInstance) } } } diff --git a/src/utils/detectBreakpoint.js b/src/utils/detectBreakpoint.js index d4463798..9106b02f 100755 --- a/src/utils/detectBreakpoint.js +++ b/src/utils/detectBreakpoint.js @@ -46,6 +46,8 @@ function createViewportDetector (maxMobileWidth, minDesktopWidth, notifyListener type = 'mobile' } else if (mq.desktop.matches) { type = 'desktop' + } else { + // No action } notifyListeners(type) } From 4782da7aec24b987155382cb912c919f5fb5768e Mon Sep 17 00:00:00 2001 From: Dan Leech Date: Fri, 6 Feb 2026 15:43:32 +0000 Subject: [PATCH 05/18] Lint fixes --- src/InteractiveMap/historyManager.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/InteractiveMap/historyManager.js b/src/InteractiveMap/historyManager.js index ef9ae58c..baca10d8 100755 --- a/src/InteractiveMap/historyManager.js +++ b/src/InteractiveMap/historyManager.js @@ -15,7 +15,7 @@ import defaults from '../config/defaults.js' * @param {MapInstance} mapInstance * The map instance whose application should be opened. */ -function openMap(mapInstance) { +function openMap (mapInstance) { if (mapInstance._isHidden) { mapInstance.showApp?.() } else { @@ -32,7 +32,7 @@ function openMap(mapInstance) { * @param {MapInstance} mapInstance * The map instance whose application should be closed. */ -function closeMap(mapInstance) { +function closeMap (mapInstance) { if (mapInstance.config.preserveStateOnClose) { mapInstance.hideApp?.() } else { From 7d6f3dd4416e38919ec6b21eaa93a585b3fb6410 Mon Sep 17 00:00:00 2001 From: Dan Leech Date: Sun, 8 Feb 2026 17:17:14 +0000 Subject: [PATCH 06/18] Minor fixes --- demo/js/index.js | 26 ++++++++++--------- plugins/beta/draw-ml/src/defaults.js | 1 + .../beta/draw-ml/src/modes/editVertexMode.js | 8 +++--- 3 files changed, 19 insertions(+), 16 deletions(-) diff --git a/demo/js/index.js b/demo/js/index.js index a09b73db..62fb9d84 100755 --- a/demo/js/index.js +++ b/demo/js/index.js @@ -155,7 +155,7 @@ interactiveMap.on('map:ready', function (e) { // framePlugin.addFrame('test', { // aspectRatio: 1 // }) - interactPlugin.enable() + // interactPlugin.enable() }) interactiveMap.on('datasets:ready', () => { @@ -167,23 +167,25 @@ interactiveMap.on('datasets:ready', () => { }) 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, - // } - // }) + 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, + } + }) // 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:create', function (e) { 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/modes/editVertexMode.js b/plugins/beta/draw-ml/src/modes/editVertexMode.js index 8d3a8c46..18c14742 100755 --- a/plugins/beta/draw-ml/src/modes/editVertexMode.js +++ b/plugins/beta/draw-ml/src/modes/editVertexMode.js @@ -5,7 +5,7 @@ import { getSnapRadius, triggerSnapAtPoint, clearSnapIndicator, clearSnapState } from '../utils/snapHelpers.js' -const ARROW_KEYS = ['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'] +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 @@ -164,7 +164,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 +210,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 +234,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 From 9b20b3f6675b5d6e359a364c164e766a5121dd62 Mon Sep 17 00:00:00 2001 From: Dan Leech Date: Sun, 8 Feb 2026 17:32:26 +0000 Subject: [PATCH 07/18] Edit vertex undo button fix --- plugins/beta/draw-ml/src/api/editFeature.js | 1 + 1 file changed, 1 insertion(+) diff --git a/plugins/beta/draw-ml/src/api/editFeature.js b/plugins/beta/draw-ml/src/api/editFeature.js index ac5fd6cf..a767d02b 100644 --- a/plugins/beta/draw-ml/src/api/editFeature.js +++ b/plugins/beta/draw-ml/src/api/editFeature.js @@ -32,6 +32,7 @@ export const editFeature = ({ appState, appConfig, mapState, pluginState, mapPro 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], From a9a498da5bec1c917fc541d9ae2fbfda2de4c7cf Mon Sep 17 00:00:00 2001 From: Dan Leech Date: Mon, 9 Feb 2026 10:54:27 +0000 Subject: [PATCH 08/18] Multiploygon basics --- demo/js/index.js | 15 +- .../beta/draw-ml/src/modes/editVertexMode.js | 345 ++++++++++++++---- 2 files changed, 286 insertions(+), 74 deletions(-) diff --git a/demo/js/index.js b/demo/js/index.js index 62fb9d84..e335e0d4 100755 --- a/demo/js/index.js +++ b/demo/js/index.js @@ -139,7 +139,7 @@ var interactiveMap = new InteractiveMap('map', { showMarker: false, // isExpanded: true }), - useLocationPlugin(), + // useLocationPlugin(), interactPlugin, framePlugin, drawPlugin @@ -170,22 +170,23 @@ 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]]] }, + 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]]] }, properties: { stroke: 'rgba(0,112,60,1)', fill: 'rgba(0,112,60,0.2)', strokeWidth: 2, } }) - // drawPlugin.split('test1234', { + drawPlugin.split('test1234', { + snapLayers: ['OS/TopographicArea_1/Agricultural Land'] + }) + // drawPlugin.newPolygon('test', { // snapLayers: ['OS/TopographicArea_1/Agricultural Land'] // }) - // drawPlugin.newPolygon('test', { + // drawPlugin.editFeature('test1234', { // snapLayers: ['OS/TopographicArea_1/Agricultural Land'] // }) - drawPlugin.editFeature('test1234', { - snapLayers: ['OS/TopographicArea_1/Agricultural Land'] - }) }) interactiveMap.on('draw:create', function (e) { diff --git a/plugins/beta/draw-ml/src/modes/editVertexMode.js b/plugins/beta/draw-ml/src/modes/editVertexMode.js index 18c14742..f640d720 100755 --- a/plugins/beta/draw-ml/src/modes/editVertexMode.js +++ b/plugins/beta/draw-ml/src/modes/editVertexMode.js @@ -19,12 +19,98 @@ 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 flat coordinates array from feature for all geometry types +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 [] + } +} + +// Helper to get ring/part segments with metadata for multi-ring/multi-part support +// Returns array of {start, length, path, closed} for each segment +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 +} + +// Helper to find which segment a flat vertex index belongs to +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 +} + +// Helper to get modifiable coordinate array at a specific path +const getModifiableCoords = (geojson, path) => { + let coords = geojson.geometry.coordinates + for (const idx of path) { + coords = coords[idx] + } + return coords +} -// 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 +// Helper to convert coord_path from mapbox-gl-draw to flat vertex index +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] +} export const EditVertexMode = { ...DirectSelect, @@ -41,6 +127,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 +140,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 +211,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', { @@ -264,11 +370,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 +386,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 +395,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 }) } }, @@ -392,12 +500,12 @@ export const EditVertexMode = { 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) + const feature = this.getFeature(state.featureId) + const idx = coordPathToFlatIndex(feature, coordPath) this.changeMode(state, { selectedVertexIndex: idx, - selectedVertexType: 'vertex', coordPath + selectedVertexType: 'vertex', + coordPath }) } else if (meta === 'midpoint') { this.insertVertex({ ...state, selectedVertexIndex: this.getVertexIndexFromMidpoint(state, coordPath), selectedVertexType: 'midpoint' }) @@ -513,10 +621,37 @@ export const EditVertexMode = { }, // Utility methods + 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) { - // Use cached featureType or look it up - const type = state.featureType || this.getFeature(state.featureId)?.type - return type === 'LineString' ? `${idx}` : `0.${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) { @@ -531,16 +666,26 @@ export const EditVertexMode = { getMidpoints(featureId) { const feature = this.getFeature(featureId) const coords = getCoords(feature) - if (!coords) { + const segments = getRingSegments(feature) + if (!coords?.length || !segments.length) { 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]) + // Create midpoints within each segment, respecting boundaries + for (const seg of segments) { + const segEnd = seg.start + seg.length + // Always use length - 1 to skip the closing coordinate in closed rings + const count = seg.length - 1 + for (let i = 0; i < count; i++) { + const idx = seg.start + i + const nextIdx = seg.start + ((i + 1) % seg.length) + if (nextIdx < segEnd) { + const [x1, y1] = coords[idx] + const [x2, y2] = coords[nextIdx] + midpoints.push([(x1 + x2) / 2, (y1 + y2) / 2]) + } + } } return midpoints }, @@ -565,19 +710,30 @@ export const EditVertexMode = { }, 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) + 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.length - 1 + midpointOffset += segMidpoints } - return state.vertecies.length + ((afterIdx - 1 + state.vertecies.length) % state.vertecies.length) + + // Fallback + return state.vertecies.length }, addTouchVertexTarget(state) { @@ -633,13 +789,37 @@ export const EditVertexMode = { 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]) + 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.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: newIdx }) - this.changeMode(state, { selectedVertexIndex: newIdx, selectedVertexType: 'vertex', coordPath: this.getCoordPath(state, newIdx) }) + 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 = {}) { @@ -649,8 +829,15 @@ export const EditVertexMode = { coord = { lng: snap.snapCoords[0], lat: snap.snapCoords[1] } } } - const geojson = this.getFeature(state.featureId).toGeoJSON() - getModifiableCoords(geojson)[state.selectedVertexIndex] = [coord.lng, coord.lat] + + 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) @@ -658,10 +845,17 @@ export const EditVertexMode = { }, 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) { + 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: 4 for closed rings (3 + closing coord), 2 for lines + const minVertices = segment.closed ? 4 : 2 + if (segment.length <= minVertices) { return } @@ -669,9 +863,14 @@ export const EditVertexMode = { 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() + // 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', @@ -680,7 +879,8 @@ export const EditVertexMode = { position: deletedPosition }) - this.changeMode(state, { selectedVertexIndex: nextIdx, selectedVertexType: 'vertex', coordPath: this.getCoordPath(state, nextIdx) }) + // Clear selection after delete (simpler than tracking ring boundaries) + this.changeMode(state, { selectedVertexIndex: -1, selectedVertexType: null }) }, // Prevent selecting other features @@ -714,12 +914,15 @@ export const EditVertexMode = { undoMoveVertex(state, op) { const { vertexIndex, previousPosition, featureId } = op const feature = this.getFeature(featureId) - if (!feature) { - return - } + if (!feature) return const geojson = feature.toGeoJSON() - getModifiableCoords(geojson)[vertexIndex] = previousPosition + 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 @@ -732,14 +935,19 @@ export const EditVertexMode = { undoInsertVertex(state, op) { const { vertexIndex, featureId } = op const feature = this.getFeature(featureId) - if (!feature) { - return - } + if (!feature) return const geojson = feature.toGeoJSON() - getModifiableCoords(geojson).splice(vertexIndex, 1) + 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 }) }, @@ -747,12 +955,15 @@ export const EditVertexMode = { undoDeleteVertex(state, op) { const { vertexIndex, position, featureId } = op const feature = this.getFeature(featureId) - if (!feature) { - return - } + if (!feature) return const geojson = feature.toGeoJSON() - getModifiableCoords(geojson).splice(vertexIndex, 0, position) + const segments = getRingSegments(feature) + const result = getSegmentForIndex(segments, vertexIndex) + 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 From d41794ea5d6aac3d5c8b73cdd323885674e64ba1 Mon Sep 17 00:00:00 2001 From: Dan Leech Date: Mon, 9 Feb 2026 14:44:23 +0000 Subject: [PATCH 09/18] Draw bug fixes --- demo/js/index.js | 14 +- .../src/modes/editVertex/geometryHelpers.js | 135 +++++++++++++++ .../beta/draw-ml/src/modes/editVertexMode.js | 157 +++++------------- 3 files changed, 186 insertions(+), 120 deletions(-) create mode 100644 plugins/beta/draw-ml/src/modes/editVertex/geometryHelpers.js diff --git a/demo/js/index.js b/demo/js/index.js index e335e0d4..288deb8c 100755 --- a/demo/js/index.js +++ b/demo/js/index.js @@ -155,7 +155,7 @@ interactiveMap.on('map:ready', function (e) { // framePlugin.addFrame('test', { // aspectRatio: 1 // }) - // interactPlugin.enable() + interactPlugin.enable() }) interactiveMap.on('datasets:ready', () => { @@ -178,10 +178,10 @@ interactiveMap.on('draw:ready', function () { strokeWidth: 2, } }) - drawPlugin.split('test1234', { - snapLayers: ['OS/TopographicArea_1/Agricultural Land'] - }) - // drawPlugin.newPolygon('test', { + // drawPlugin.split('test1234', { + // snapLayers: ['OS/TopographicArea_1/Agricultural Land'] + // }) + // drawPlugin.newLine('test', { // snapLayers: ['OS/TopographicArea_1/Agricultural Land'] // }) // drawPlugin.editFeature('test1234', { @@ -190,11 +190,11 @@ interactiveMap.on('draw:ready', function () { }) 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('interact:done', function (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/editVertexMode.js b/plugins/beta/draw-ml/src/modes/editVertexMode.js index f640d720..ad654aa3 100755 --- a/plugins/beta/draw-ml/src/modes/editVertexMode.js +++ b/plugins/beta/draw-ml/src/modes/editVertexMode.js @@ -4,6 +4,13 @@ import { getSnapInstance, isSnapActive, isSnapEnabled, getSnapLngLat, getSnapRadius, triggerSnapAtPoint, clearSnapIndicator, clearSnapState } from '../utils/snapHelpers.js' +import { + getCoords, + getRingSegments, + getSegmentForIndex, + getModifiableCoords, + coordPathToFlatIndex +} from './editVertex/geometryHelpers.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] } @@ -19,99 +26,6 @@ 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 for all geometry types -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 [] - } -} - -// Helper to get ring/part segments with metadata for multi-ring/multi-part support -// Returns array of {start, length, path, closed} for each segment -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 -} - -// Helper to find which segment a flat vertex index belongs to -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 -} - -// Helper to get modifiable coordinate array at a specific path -const getModifiableCoords = (geojson, path) => { - let coords = geojson.geometry.coordinates - for (const idx of path) { - coords = coords[idx] - } - return coords -} - -// Helper to convert coord_path from mapbox-gl-draw to flat vertex index -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] -} - export const EditVertexMode = { ...DirectSelect, @@ -615,8 +529,6 @@ export const EditVertexMode = { this.undoInsertVertex(state, op) } else if (op.type === 'delete_vertex') { this.undoDeleteVertex(state, op) - } else { - // No action } }, @@ -674,17 +586,15 @@ export const EditVertexMode = { const midpoints = [] // Create midpoints within each segment, respecting boundaries for (const seg of segments) { - const segEnd = seg.start + seg.length - // Always use length - 1 to skip the closing coordinate in closed rings - const count = seg.length - 1 + // 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) - if (nextIdx < segEnd) { - const [x1, y1] = coords[idx] - const [x2, y2] = coords[nextIdx] - midpoints.push([(x1 + x2) / 2, (y1 + y2) / 2]) - } + const [x1, y1] = coords[idx] + const [x2, y2] = coords[nextIdx] + midpoints.push([(x1 + x2) / 2, (y1 + y2) / 2]) } } return midpoints @@ -728,7 +638,7 @@ export const EditVertexMode = { return state.vertecies.length + midpointOffset + localMidpointIdx } // Count midpoints in this segment (must match getMidpoints calculation) - const segMidpoints = seg.length - 1 + const segMidpoints = seg.closed ? seg.length : seg.length - 1 midpointOffset += segMidpoints } @@ -802,7 +712,7 @@ export const EditVertexMode = { let midpointCounter = 0 for (const seg of segments) { // Must match getMidpoints calculation - const segMidpoints = seg.length - 1 + const segMidpoints = seg.closed ? seg.length : seg.length - 1 if (midIdx < midpointCounter + segMidpoints) { insertSegment = seg localInsertIdx = (midIdx - midpointCounter) + 1 @@ -846,15 +756,19 @@ export const EditVertexMode = { deleteVertex(state) { const feature = this.getFeature(state.featureId) - if (!feature) return + if (!feature) { + return + } const segments = getRingSegments(feature) const result = getSegmentForIndex(segments, state.selectedVertexIndex) - if (!result) return + if (!result) { + return + } const { segment } = result - // Minimum vertices per segment: 4 for closed rings (3 + closing coord), 2 for lines - const minVertices = segment.closed ? 4 : 2 + // 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 } @@ -879,7 +793,7 @@ export const EditVertexMode = { position: deletedPosition }) - // Clear selection after delete (simpler than tracking ring boundaries) + // Clear selection after delete this.changeMode(state, { selectedVertexIndex: -1, selectedVertexType: null }) }, @@ -955,12 +869,29 @@ export const EditVertexMode = { undoDeleteVertex(state, op) { const { vertexIndex, position, featureId } = op const feature = this.getFeature(featureId) - if (!feature) return + if (!feature) { + return + } const geojson = feature.toGeoJSON() const segments = getRingSegments(feature) - const result = getSegmentForIndex(segments, vertexIndex) - if (!result) return + + // 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) From bdb2a4cb55d5d4fbea6d82b0bfe7ef68f4347dc1 Mon Sep 17 00:00:00 2001 From: Dan Leech Date: Mon, 9 Feb 2026 17:41:55 +0000 Subject: [PATCH 10/18] Demo interact idProperty addition --- demo/js/index.js | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/demo/js/index.js b/demo/js/index.js index 288deb8c..60052654 100755 --- a/demo/js/index.js +++ b/demo/js/index.js @@ -23,7 +23,8 @@ var interactPlugin = createInteractPlugin({ layerId: 'linked-parcels', // idProperty: 'id' },{ - layerId: 'OS/TopographicArea_1/Agricultural Land' + layerId: 'OS/TopographicArea_1/Agricultural Land', + idProperty: 'TOID' }], interactionMode: 'select', // 'auto', 'select', 'marker' // defaults to 'marker' multiSelect: true, @@ -155,7 +156,7 @@ interactiveMap.on('map:ready', function (e) { // framePlugin.addFrame('test', { // aspectRatio: 1 // }) - interactPlugin.enable() + // interactPlugin.enable() }) interactiveMap.on('datasets:ready', () => { @@ -184,9 +185,9 @@ interactiveMap.on('draw:ready', function () { // drawPlugin.newLine('test', { // snapLayers: ['OS/TopographicArea_1/Agricultural Land'] // }) - // drawPlugin.editFeature('test1234', { - // snapLayers: ['OS/TopographicArea_1/Agricultural Land'] - // }) + drawPlugin.editFeature('test1234', { + snapLayers: ['OS/TopographicArea_1/Agricultural Land'] + }) }) interactiveMap.on('draw:create', function (e) { @@ -210,7 +211,7 @@ interactiveMap.on('interact:selectionchange', function (e) { }) interactiveMap.on('interact:markerchange', function (e) { - console.log('interact:markerchange', e) + // console.log('interact:markerchange', e) }) // Update selected feature From f881a0c37c6fd3a1ffda4ac21d276c83c9196b37 Mon Sep 17 00:00:00 2001 From: Dan Leech Date: Tue, 10 Feb 2026 14:07:25 +0000 Subject: [PATCH 11/18] Draw additions and interact fix --- demo/js/index.js | 115 ++++++++++++++++-- plugins/beta/draw-ml/src/api/addFeature.js | 18 ++- plugins/beta/draw-ml/src/api/editFeature.js | 20 ++- plugins/beta/draw-ml/src/api/newLine.js | 38 ++++-- plugins/beta/draw-ml/src/api/newPolygon.js | 40 ++++-- .../beta/draw-ml/src/modes/createDrawMode.js | 7 +- .../src/utils/flattenStyleProperties.js | 49 ++++++++ .../src/hooks/useInteractionHandlers.js | 6 + plugins/interact/src/reducer.js | 8 +- plugins/interact/src/reducer.test.js | 6 +- .../maplibre/src/utils/highlightFeatures.js | 6 + .../MapButton/MapButton.module.scss | 16 +++ 12 files changed, 288 insertions(+), 41 deletions(-) create mode 100644 plugins/beta/draw-ml/src/utils/flattenStyleProperties.js diff --git a/demo/js/index.js b/demo/js/index.js index 60052654..f3a947ba 100755 --- a/demo/js/index.js +++ b/demo/js/index.js @@ -25,6 +25,12 @@ var interactPlugin = createInteractPlugin({ },{ 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, @@ -33,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 }) @@ -153,10 +159,11 @@ interactiveMap.on('app:ready', function (e) { }) interactiveMap.on('map:ready', function (e) { + console.log(e.map) // framePlugin.addFrame('test', { // aspectRatio: 1 // }) - // interactPlugin.enable() + interactPlugin.enable() }) interactiveMap.on('datasets:ready', () => { @@ -167,17 +174,80 @@ interactiveMap.on('datasets:ready', () => { // }) }) +// Ref to the selected feature +var selectedFeatureId = null + interactiveMap.on('draw:ready', function () { + 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: (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: (e) => { + e.target.setAttribute('aria-pressed', true) + drawPlugin.newLine(crypto.randomUUID(), { + stroke: '#99704a' + }) + } + }) + 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: (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: (e) => { + if (e.target.getAttribute('aria-disabled') === 'true') { + return + } + drawPlugin.deleteFeature(selectedFeatureId) + } + }) 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]]] }, - properties: { - stroke: 'rgba(0,112,60,1)', - fill: 'rgba(0,112,60,0.2)', - strokeWidth: 2, - } + stroke: 'rgba(0,112,60,1)', + fill: 'rgba(0,112,60,0.2)', + strokeWidth: 2 }) // drawPlugin.split('test1234', { // snapLayers: ['OS/TopographicArea_1/Agricultural Land'] @@ -185,9 +255,14 @@ interactiveMap.on('draw:ready', function () { // drawPlugin.newLine('test', { // snapLayers: ['OS/TopographicArea_1/Agricultural Land'] // }) - drawPlugin.editFeature('test1234', { - snapLayers: ['OS/TopographicArea_1/Agricultural Land'] - }) + // 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) { @@ -198,16 +273,32 @@ interactiveMap.on('draw:update', function (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) { console.log('interact:done', 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) + const 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) { 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 a767d02b..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,19 +14,29 @@ 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', { 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/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/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/interact/src/hooks/useInteractionHandlers.js b/plugins/interact/src/hooks/useInteractionHandlers.js index af216b21..c6638a3c 100755 --- a/plugins/interact/src/hooks/useInteractionHandlers.js +++ b/plugins/interact/src/hooks/useInteractionHandlers.js @@ -18,6 +18,12 @@ export const useInteractionHandlers = ({ const allFeatures = getFeaturesAtPoint(mapProvider, point) 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/providers/maplibre/src/utils/highlightFeatures.js b/providers/maplibre/src/utils/highlightFeatures.js index 9bb1b686..6ebcd383 100755 --- a/providers/maplibre/src/utils/highlightFeatures.js +++ b/providers/maplibre/src/utils/highlightFeatures.js @@ -89,6 +89,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 +102,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/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'); } From d8afed2a406de710fae538a96412e0ae2310a3fb Mon Sep 17 00:00:00 2001 From: Dan Leech Date: Tue, 10 Feb 2026 14:54:21 +0000 Subject: [PATCH 12/18] EditVertex mode refactor --- demo/js/index.js | 11 +- .../draw-ml/src/modes/editVertex/helpers.js | 2 + .../src/modes/editVertex/touchHandlers.js | 134 +++++ .../src/modes/editVertex/undoHandlers.js | 133 +++++ .../src/modes/editVertex/vertexOperations.js | 141 +++++ .../src/modes/editVertex/vertexQueries.js | 121 ++++ .../beta/draw-ml/src/modes/editVertexMode.js | 521 +----------------- 7 files changed, 551 insertions(+), 512 deletions(-) create mode 100644 plugins/beta/draw-ml/src/modes/editVertex/helpers.js create mode 100644 plugins/beta/draw-ml/src/modes/editVertex/touchHandlers.js create mode 100644 plugins/beta/draw-ml/src/modes/editVertex/undoHandlers.js create mode 100644 plugins/beta/draw-ml/src/modes/editVertex/vertexOperations.js create mode 100644 plugins/beta/draw-ml/src/modes/editVertex/vertexQueries.js diff --git a/demo/js/index.js b/demo/js/index.js index f3a947ba..e19a687f 100755 --- a/demo/js/index.js +++ b/demo/js/index.js @@ -159,11 +159,12 @@ interactiveMap.on('app:ready', function (e) { }) interactiveMap.on('map:ready', function (e) { - console.log(e.map) // framePlugin.addFrame('test', { // aspectRatio: 1 // }) - interactPlugin.enable() + interactPlugin.enable({ + debug: true + }) }) interactiveMap.on('datasets:ready', () => { @@ -205,7 +206,7 @@ interactiveMap.on('draw:ready', function () { onClick: (e) => { e.target.setAttribute('aria-pressed', true) drawPlugin.newLine(crypto.randomUUID(), { - stroke: '#99704a' + stroke: { outdoor: '#99704a', dark: '#ffffff' } }) } }) @@ -238,6 +239,10 @@ interactiveMap.on('draw:ready', function () { 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({ 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 ad654aa3..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' -import { - getCoords, - getRingSegments, - getSegmentForIndex, - getModifiableCoords, - coordPathToFlatIndex -} from './editVertex/geometryHelpers.js' +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 export const EditVertexMode = { ...DirectSelect, + ...undoHandlers, + ...touchHandlers, + ...vertexOperations, + ...vertexQueries, onSetup(options) { const state = DirectSelect.onSetup.call(this, options) @@ -366,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') { - 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 }) - }, - onDrag(state, e) { if (state.interfaceType === 'touch') { return @@ -515,286 +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) - } - }, - - // Utility methods - 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 - }, - - 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 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 }) + clickNoTarget(state) { + this.changeMode(state, { selectedVertexIndex: -1, selectedVertexType: null, isPanEnabled: true }) }, // Prevent selecting other features @@ -805,114 +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() - 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) - }, - onStop(state) { const h = this.handlers state.container.removeEventListener('pointerdown', h.pointerdown) From 18cd9c02335fe78d0b4d897df8552e4bcffe1640 Mon Sep 17 00:00:00 2001 From: Dan Leech Date: Tue, 10 Feb 2026 15:33:15 +0000 Subject: [PATCH 13/18] Interact select line tolerance addition --- plugins/interact/src/defaults.js | 1 + .../src/hooks/useInteractionHandlers.js | 4 +- plugins/interact/src/utils/featureQueries.js | 4 +- providers/beta/esri/src/esriProvider.js | 2 +- providers/maplibre/src/maplibreProvider.js | 7 +- .../maplibre/src/utils/highlightFeatures.js | 17 ++++- providers/maplibre/src/utils/queryFeatures.js | 73 +++++++++++++++++++ 7 files changed, 97 insertions(+), 11 deletions(-) create mode 100644 providers/maplibre/src/utils/queryFeatures.js 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 c6638a3c..b4c8cebe 100755 --- a/plugins/interact/src/hooks/useInteractionHandlers.js +++ b/plugins/interact/src/hooks/useInteractionHandlers.js @@ -9,13 +9,13 @@ 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 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/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/maplibreProvider.js b/providers/maplibre/src/maplibreProvider.js index 60bb4b2c..b3e1dcda 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. @@ -266,10 +267,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 6ebcd383..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] 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) +} From 422d379710da224eeaac5b2203c7adde1b95f09f Mon Sep 17 00:00:00 2001 From: Dan Leech Date: Tue, 10 Feb 2026 15:59:06 +0000 Subject: [PATCH 14/18] Minor draw/snap performance fix --- demo/js/index.js | 12 ++++++------ plugins/beta/draw-ml/src/mapboxSnap.js | 22 ++++++++++++---------- 2 files changed, 18 insertions(+), 16 deletions(-) diff --git a/demo/js/index.js b/demo/js/index.js index e19a687f..b34eb22d 100755 --- a/demo/js/index.js +++ b/demo/js/index.js @@ -167,7 +167,7 @@ interactiveMap.on('map:ready', function (e) { }) }) -interactiveMap.on('datasets:ready', () => { +interactiveMap.on('datasets:ready', function () { // datasetsPlugin.hideFeatures({ // featureIds: [1148, 1134], // idProperty: 'gid', @@ -187,7 +187,7 @@ interactiveMap.on('draw:ready', function () { mobile: { slot: 'right-top' }, tablet: { slot: 'right-top' }, desktop: { slot: 'right-top' }, - onClick: (e) => { + onClick: function (e) { e.target.setAttribute('aria-pressed', true) drawPlugin.newPolygon(crypto.randomUUID(), { stroke: '#e6c700', @@ -203,7 +203,7 @@ interactiveMap.on('draw:ready', function () { mobile: { slot: 'right-top' }, tablet: { slot: 'right-top' }, desktop: { slot: 'right-top' }, - onClick: (e) => { + onClick: function (e) { e.target.setAttribute('aria-pressed', true) drawPlugin.newLine(crypto.randomUUID(), { stroke: { outdoor: '#99704a', dark: '#ffffff' } @@ -218,7 +218,7 @@ interactiveMap.on('draw:ready', function () { mobile: { slot: 'right-top' }, tablet: { slot: 'right-top' }, desktop: { slot: 'right-top' }, - onClick: (e) => { + onClick: function (e) { if (e.target.getAttribute('aria-disabled') === 'true') { return } @@ -234,7 +234,7 @@ interactiveMap.on('draw:ready', function () { mobile: { slot: 'right-top' }, tablet: { slot: 'right-top' }, desktop: { slot: 'right-top' }, - onClick: (e) => { + onClick: function (e) { if (e.target.getAttribute('aria-disabled') === 'true') { return } @@ -298,7 +298,7 @@ interactiveMap.on('interact:cancel', function (e) { }) interactiveMap.on('interact:selectionchange', function (e) { - const singleFeature = e.selectedFeatures.length === 1 + var singleFeature = e.selectedFeatures.length === 1 selectedFeatureId = singleFeature ? e.selectedFeatures?.[0]?.featureId : null interactiveMap.toggleButtonState('drawPolygon', 'disabled', !!singleFeature) interactiveMap.toggleButtonState('drawLine', 'disabled', !!singleFeature) 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') + } } }) From cbf719ca1bcf85a6017ab77b07c1fd021beda2ee Mon Sep 17 00:00:00 2001 From: Dan Leech Date: Tue, 10 Feb 2026 17:13:40 +0000 Subject: [PATCH 15/18] Browser compatibility first fixes --- package-lock.json | 12 ++--- package.json | 2 +- plugins/beta/frame/src/Frame.jsx | 2 +- plugins/search/src/events/fetchSuggestions.js | 2 +- .../search/src/utils/parseOsNamesResults.js | 6 +-- providers/maplibre/src/index.js | 6 +-- sonar-project.properties | 8 ++- src/App/hooks/useResizeObserver.js | 2 +- src/InteractiveMap/InteractiveMap.js | 2 + src/InteractiveMap/polyfills.js | 18 +++++++ src/InteractiveMap/polyfills.test.js | 53 +++++++++++++++++++ src/utils/detectBreakpoint.js | 2 +- src/utils/detectInterfaceType.js | 8 +-- src/utils/mapStateSync.js | 6 +-- src/utils/queryString.js | 2 +- src/utils/stringToKebab.js | 2 +- 16 files changed, 104 insertions(+), 29 deletions(-) create mode 100644 src/InteractiveMap/polyfills.js create mode 100644 src/InteractiveMap/polyfills.test.js diff --git a/package-lock.json b/package-lock.json index a12c78bb..27d0e17a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -58,7 +58,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", @@ -19323,12 +19323,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..c94c490f 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", 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/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/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/maplibre/src/index.js b/providers/maplibre/src/index.js index 62a452f1..5f0a8515 100755 --- a/providers/maplibre/src/index.js +++ b/providers/maplibre/src/index.js @@ -31,14 +31,10 @@ export default function (config = {}) { const maplibre = await import(/* webpackChunkName: "im-maplibre-framework" */ 'maplibre-gl') mapFramework = maplibre } else { - const [maplibreLegacy, resizeObserver] = await Promise.all([ + const [maplibreLegacy] = 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 } diff --git a/sonar-project.properties b/sonar-project.properties index f250107f..ee344ca7 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -35,4 +35,10 @@ 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 \ No newline at end of file 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/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..89c61785 --- /dev/null +++ b/src/InteractiveMap/polyfills.js @@ -0,0 +1,18 @@ +import ResizeObserver from 'resize-observer-polyfill' + +// ResizeObserver +if (typeof window !== 'undefined' && !('ResizeObserver' in window)) { + window.ResizeObserver = ResizeObserver +} + +// Object.fromEntries +if (!Object.fromEntries) { + Object.fromEntries = function (entries) { + const obj = {} + for (let i = 0; i < entries.length; i++) { + const entry = entries[i] + obj[entry[0]] = entry[1] + } + return obj + } +} diff --git a/src/InteractiveMap/polyfills.test.js b/src/InteractiveMap/polyfills.test.js new file mode 100644 index 00000000..16381c55 --- /dev/null +++ b/src/InteractiveMap/polyfills.test.js @@ -0,0 +1,53 @@ +// polyfills.test.js +describe('Polyfills', () => { + beforeEach(() => { + // remove globals to simulate missing polyfills + delete window.ResizeObserver + delete Object.fromEntries + }) + + test('should polyfill ResizeObserver', () => { + // Side-effect import + jest.isolateModules(() => { + require('./polyfills.js') // adjust path + }) + + expect(window.ResizeObserver).toBeDefined() + expect(typeof window.ResizeObserver).toBe('function') + expect(window.ResizeObserver.prototype.observe).toBeDefined() + expect(window.ResizeObserver.prototype.disconnect).toBeDefined() + }) + + test('should polyfill Object.fromEntries', () => { + jest.isolateModules(() => { + require('./polyfills.js') + }) + + expect(Object.fromEntries).toBeDefined() + const entries = [['a', 1], ['b', 2]] + expect(Object.fromEntries(entries)).toEqual({ a: 1, b: 2 }) + }) + + test('should not overwrite existing window.ResizeObserver', () => { + const fakeObserver = jest.fn() + window.ResizeObserver = fakeObserver + + jest.isolateModules(() => { + require('./polyfills.js') + }) + + // Should remain unchanged + expect(window.ResizeObserver).toBe(fakeObserver) + }) + + test('should not overwrite existing Object.fromEntries', () => { + const fakeFromEntries = jest.fn() + Object.fromEntries = fakeFromEntries + + jest.isolateModules(() => { + require('./polyfills.js') + }) + + expect(Object.fromEntries).toBe(fakeFromEntries) + }) +}) 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() } From 61e893fda8e5a9e1abac0567b69966c33e628ca2 Mon Sep 17 00:00:00 2001 From: Dan Leech Date: Wed, 11 Feb 2026 10:50:55 +0000 Subject: [PATCH 16/18] Browser compatibility fixes --- package-lock.json | 7 + package.json | 1 + plugins/beta/map-styles/src/mapStyles.scss | 5 +- plugins/search/src/search.scss | 5 +- providers/maplibre/src/index.js | 35 +++-- providers/maplibre/src/maplibreProvider.js | 1 - .../components/Viewport/Viewport.module.scss | 4 +- src/App/layout/layout.module.scss | 10 +- src/InteractiveMap/polyfills.js | 45 ++++-- src/InteractiveMap/polyfills.test.js | 142 +++++++++++++----- webpack.dev.mjs | 4 +- 11 files changed, 190 insertions(+), 69 deletions(-) diff --git a/package-lock.json b/package-lock.json index 27d0e17a..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", @@ -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", diff --git a/package.json b/package.json index c94c490f..6f8ee001 100755 --- a/package.json +++ b/package.json @@ -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/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/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/providers/maplibre/src/index.js b/providers/maplibre/src/index.js index 5f0a8515..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,24 +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] = await Promise.all([ - import(/* webpackChunkName: "im-maplibre-legacy-framework" */ 'maplibre-gl-legacy'), - import(/* webpackChunkName: "im-maplibre-legacy-framework" */ 'core-js/es/array/flat.js') - ]) - 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 b3e1dcda..def8d5e0 100755 --- a/providers/maplibre/src/maplibreProvider.js +++ b/providers/maplibre/src/maplibreProvider.js @@ -76,7 +76,6 @@ export default class MapLibreProvider { map.fitBounds(bounds, { duration: 0 }) } - applyPreventDefaultFix(map) cleanCanvas(map) 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/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/polyfills.js b/src/InteractiveMap/polyfills.js index 89c61785..83f63d96 100644 --- a/src/InteractiveMap/polyfills.js +++ b/src/InteractiveMap/polyfills.js @@ -1,18 +1,39 @@ -import ResizeObserver from 'resize-observer-polyfill' +// 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 -// ResizeObserver -if (typeof window !== 'undefined' && !('ResizeObserver' in window)) { - window.ResizeObserver = ResizeObserver +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 + } + } } -// Object.fromEntries -if (!Object.fromEntries) { - Object.fromEntries = function (entries) { - const obj = {} - for (let i = 0; i < entries.length; i++) { - const entry = entries[i] - obj[entry[0]] = entry[1] +// 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 obj + return _createObjectURL(blob) } } diff --git a/src/InteractiveMap/polyfills.test.js b/src/InteractiveMap/polyfills.test.js index 16381c55..a09aecb6 100644 --- a/src/InteractiveMap/polyfills.test.js +++ b/src/InteractiveMap/polyfills.test.js @@ -1,53 +1,127 @@ -// polyfills.test.js 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(() => { - // remove globals to simulate missing polyfills - delete window.ResizeObserver - delete Object.fromEntries + jest.resetModules() }) - test('should polyfill ResizeObserver', () => { - // Side-effect import - jest.isolateModules(() => { - require('./polyfills.js') // adjust path + afterEach(() => { + Object.defineProperty(crypto, 'randomUUID', { + value: originalCryptoUUID, + configurable: true, + writable: true }) - - expect(window.ResizeObserver).toBeDefined() - expect(typeof window.ResizeObserver).toBe('function') - expect(window.ResizeObserver.prototype.observe).toBeDefined() - expect(window.ResizeObserver.prototype.disconnect).toBeDefined() + + URL.createObjectURL = originalCreateObjectURL + + if (originalThrowIfAborted) { + signalProto.throwIfAborted = originalThrowIfAborted + } else { + delete signalProto.throwIfAborted + } }) - test('should polyfill Object.fromEntries', () => { - jest.isolateModules(() => { - require('./polyfills.js') - }) + const load = () => jest.isolateModules(() => require('./polyfills.js')) - expect(Object.fromEntries).toBeDefined() - const entries = [['a', 1], ['b', 2]] - expect(Object.fromEntries(entries)).toEqual({ a: 1, b: 2 }) + // 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) }) - test('should not overwrite existing window.ResizeObserver', () => { - const fakeObserver = jest.fn() - window.ResizeObserver = fakeObserver + 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) + }) - jest.isolateModules(() => { - require('./polyfills.js') + 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) }) - // Should remain unchanged - expect(window.ResizeObserver).toBe(fakeObserver) + 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('should not overwrite existing Object.fromEntries', () => { - const fakeFromEntries = jest.fn() - Object.fromEntries = fakeFromEntries + 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() + }) - jest.isolateModules(() => { - require('./polyfills.js') + 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) }) - expect(Object.fromEntries).toBe(fakeFromEntries) + 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) + }) }) -}) +}) \ No newline at end of file diff --git a/webpack.dev.mjs b/webpack.dev.mjs index 7003a858..b2f333b5 100755 --- a/webpack.dev.mjs +++ b/webpack.dev.mjs @@ -16,7 +16,7 @@ export default { index: path.join(__dirname, 'demo/js/index.js'), forms: path.join(__dirname, 'demo/js/forms.js'), farming: path.join(__dirname, 'demo/js/farming.js'), - planning: path.join(__dirname, 'demo/js/planning.js') + // planning: path.join(__dirname, 'demo/js/planning.js') }, output: { path: path.resolve(__dirname, 'public'), @@ -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'], From 68f7507447c6d02b546a22f90f51d1d5e14ce244 Mon Sep 17 00:00:00 2001 From: Dan Leech Date: Wed, 11 Feb 2026 10:55:17 +0000 Subject: [PATCH 17/18] Webpack planning reinstated --- webpack.dev.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/webpack.dev.mjs b/webpack.dev.mjs index b2f333b5..d2fce37c 100755 --- a/webpack.dev.mjs +++ b/webpack.dev.mjs @@ -16,7 +16,7 @@ export default { index: path.join(__dirname, 'demo/js/index.js'), forms: path.join(__dirname, 'demo/js/forms.js'), farming: path.join(__dirname, 'demo/js/farming.js'), - // planning: path.join(__dirname, 'demo/js/planning.js') + planning: path.join(__dirname, 'demo/js/planning.js') }, output: { path: path.resolve(__dirname, 'public'), From 5febd15b82d25a9085838d16f06b56c291cde756 Mon Sep 17 00:00:00 2001 From: Dan Leech Date: Wed, 11 Feb 2026 11:02:54 +0000 Subject: [PATCH 18/18] Lint fixes --- src/InteractiveMap/polyfills.test.js | 30 ++++++++++++++-------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/src/InteractiveMap/polyfills.test.js b/src/InteractiveMap/polyfills.test.js index a09aecb6..92177c0b 100644 --- a/src/InteractiveMap/polyfills.test.js +++ b/src/InteractiveMap/polyfills.test.js @@ -1,6 +1,6 @@ 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) @@ -16,9 +16,9 @@ describe('Polyfills', () => { configurable: true, writable: true }) - + URL.createObjectURL = originalCreateObjectURL - + if (originalThrowIfAborted) { signalProto.throwIfAborted = originalThrowIfAborted } else { @@ -44,7 +44,7 @@ describe('Polyfills', () => { }) load() - + expect(typeof crypto.randomUUID).toBe('function') expect(crypto.randomUUID()).toMatch(UUID_RE) }) @@ -71,7 +71,7 @@ describe('Polyfills', () => { expect(crypto.randomUUID).toBe(fake) }) }) - + describe('AbortSignal.throwIfAborted', () => { test('throws AbortError when aborted (True branch)', () => { delete signalProto.throwIfAborted @@ -91,22 +91,22 @@ describe('Polyfills', () => { 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) }) @@ -115,13 +115,13 @@ describe('Polyfills', () => { 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) }) }) -}) \ No newline at end of file +})