diff --git a/demo/js/draw-ol.js b/demo/js/draw-ol.js index 9ba5530a..30051e0d 100644 --- a/demo/js/draw-ol.js +++ b/demo/js/draw-ol.js @@ -9,8 +9,27 @@ import openNamesProvider from '/providers/beta/open-names/src/index.js' import mapStylesPlugin from '/plugins/beta/map-styles/src/index.js' import createDrawPlugin from '/plugins/beta/draw-ol/src/index.js' import searchPlugin from '/plugins/search/src/index.js' +import createInteractPlugin from '/plugins/interact/src/index.js' + +const interactPlugin = createInteractPlugin({ + // layers: [{ + // layerId: 'OS/NGD/lnd_fts_land/Arable Or Grazing Land' + // }], + layers: [{ + layerId: 'draw' + },{ + layerId: 'OS/TopographicArea_1/Agricultural Land', + idProperty: 'TOID' + }], + interactionModes: ['selectMarker', 'selectFeature'], + multiSelect: true, + contiguous: true, + deselectOnClickOutside: true, + // debug: true +}) const drawPlugin = createDrawPlugin({ + // snapLayers: ['OS/NGD/lnd_fts_land/Arable Or Grazing Land'] snapLayers: ['OS/TopographicArea_1/Agricultural Land', 'OS/TopographicLine/Building Outline'] }) @@ -43,6 +62,7 @@ const interactiveMap = new InteractiveMap('map', { width: '300px', showMarker: false, }), + interactPlugin, drawPlugin ] }) @@ -52,6 +72,7 @@ interactiveMap.on('app:ready', function (e) { }) interactiveMap.on('map:ready', function (e) { + interactPlugin.enable() interactiveMap.addButton('geometryActions', { label: 'Draw tools', mobile: { slot: 'bottom-right', order: 3 }, @@ -90,6 +111,7 @@ interactiveMap.on('map:ready', function (e) { return } interactiveMap.toggleButtonState('geometryActions', 'hidden', true) + interactPlugin.disable() } },{ id: 'deleteFeature', @@ -99,6 +121,7 @@ interactiveMap.on('map:ready', function (e) { onClick: function (e) { interactiveMap.toggleButtonState('geometryActions', 'hidden', false) drawPlugin.deleteFeature(selectedFeatureIds) + interactPlugin.clear() interactiveMap.toggleButtonState('drawPolygon', 'disabled', false) interactiveMap.toggleButtonState('drawLine', 'disabled', false) interactiveMap.toggleButtonState('editFeature', 'disabled', true) @@ -108,35 +131,34 @@ interactiveMap.on('map:ready', function (e) { }) }) -interactiveMap.on('datasets:ready', function () { - // datasets ready -}) - let selectedFeatureIds = [] interactiveMap.on('draw:ready', function () { - drawPlugin.addFeature({ - id: 'test1234', - type: 'Feature', - geometry: {'type':'Polygon','coordinates':[[[337612,504612],[337592,504595],[337575,504583],[337570,504582],[337560,504582],[337554,504590],[337559,504596],[337568,504604],[337572,504610],[337582,504611],[337585,504610],[337602,504612],[337603,504607],[337605,504605],[337609,504605],[337612,504612]],[[337598,504609],[337587,504605],[337577,504605],[337572,504607],[337573,504610],[337575,504613],[337580,504613],[337586,504612],[337593,504613],[337597,504611],[337598,504609]]]}, - stroke: 'rgba(0,112,60,1)', - fill: 'rgba(0,112,60,0.2)', - strokeWidth: 2 - }) - drawPlugin.editFeature('test1234') + // drawPlugin.addFeature({ + // id: 'test1234', + // type: 'Feature', + // geometry: {'type':'Polygon','coordinates':[[[337612,504612],[337592,504595],[337575,504583],[337570,504582],[337560,504582],[337554,504590],[337559,504596],[337568,504604],[337572,504610],[337582,504611],[337585,504610],[337602,504612],[337603,504607],[337605,504605],[337609,504605],[337612,504612]],[[337598,504609],[337587,504605],[337577,504605],[337572,504607],[337573,504610],[337575,504613],[337580,504613],[337586,504612],[337593,504613],[337597,504611],[337598,504609]]]}, + // stroke: 'rgba(0,112,60,1)', + // fill: 'rgba(0,112,60,0.2)', + // strokeWidth: 2 + // }) + // drawPlugin.editFeature('test1234') }) interactiveMap.on('draw:started', function (e) { interactiveMap.toggleButtonState('geometryActions', 'hidden', true) + interactPlugin.disable() }) interactiveMap.on('draw:editstart', function (e) { interactiveMap.toggleButtonState('geometryActions', 'hidden', true) + interactPlugin.disable() }) interactiveMap.on('draw:created', function (e) { console.log('draw:created', e) interactiveMap.toggleButtonState('geometryActions', 'hidden', false) + interactPlugin.enable() }) interactiveMap.on('draw:updated', function (e) { @@ -146,9 +168,36 @@ interactiveMap.on('draw:updated', function (e) { interactiveMap.on('draw:edited', function (e) { console.log('draw:edited', e) interactiveMap.toggleButtonState('geometryActions', 'hidden', false) + interactPlugin.enable() }) interactiveMap.on('draw:cancelled', function (e) { console.log('draw:cancelled', e) interactiveMap.toggleButtonState('geometryActions', 'hidden', false) -}) \ No newline at end of file + 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) { + const singleFeature = e.selectedFeatures.length === 1 + const anyFeature = e.selectedFeatures.length > 0 + const isDrawFeature = singleFeature && e.selectedFeatures[0].layerId === 'draw' + const allDrawFeatures = anyFeature && e.selectedFeatures.every(function (f) { return f.layerId === 'draw' }) + selectedFeatureIds = e.selectedFeatures.map(function (f) { return f.featureId }) + interactiveMap.toggleButtonState('drawPolygon', 'disabled', !!singleFeature) + interactiveMap.toggleButtonState('drawLine', 'disabled', !!singleFeature) + interactiveMap.toggleButtonState('editFeature', 'disabled', !isDrawFeature) + interactiveMap.toggleButtonState('deleteFeature', 'disabled', !allDrawFeatures) +}) + +interactiveMap.on('interact:markerchange', function (e) { + // console.log('interact:markerchange', e) +}) diff --git a/docs/assets/css/docusaurus.scss b/docs/assets/css/docusaurus.scss index 1736d38b..fbe82b54 100644 --- a/docs/assets/css/docusaurus.scss +++ b/docs/assets/css/docusaurus.scss @@ -208,4 +208,8 @@ font-size: 1.5rem; line-height: 1.25; margin-bottom: 20px; +} + +.govuk-\!-margin-bottom-0 { + margin-bottom: 0 !important; } \ No newline at end of file diff --git a/plugins/beta/draw-ol/src/core/OLDrawManager.js b/plugins/beta/draw-ol/src/core/OLDrawManager.js index c596b25c..1ca587a2 100644 --- a/plugins/beta/draw-ol/src/core/OLDrawManager.js +++ b/plugins/beta/draw-ol/src/core/OLDrawManager.js @@ -36,6 +36,7 @@ export class OLDrawManager { style: this.styles.createFeatureStyle(), zIndex: 100 }) + this._layer.set('layerId', 'draw') map.addLayer(this._layer) } diff --git a/plugins/beta/draw-ol/src/reducer.js b/plugins/beta/draw-ol/src/reducer.js index a443d66e..57afea18 100644 --- a/plugins/beta/draw-ol/src/reducer.js +++ b/plugins/beta/draw-ol/src/reducer.js @@ -9,29 +9,33 @@ const initialState = { hasSnapLayers: false } -const actions = { - SET_MODE: (state, payload) => ({ ...state, mode: payload }), +const setMode = (state, payload) => ({ ...state, mode: payload }) + +const setFeature = (state, payload) => ({ + ...state, + feature: payload.feature === undefined ? state.feature : payload.feature, + tempFeature: payload.tempFeature === undefined ? state.tempFeature : payload.tempFeature +}) - SET_FEATURE: (state, payload) => ({ - ...state, - feature: payload.feature === undefined ? state.feature : payload.feature, - tempFeature: payload.tempFeature === undefined ? state.tempFeature : payload.tempFeature - }), +const setSelectedVertexIndex = (state, payload) => ({ + ...state, + selectedVertexIndex: payload.index, + numVertices: payload.numVertices +}) - SET_SELECTED_VERTEX_INDEX: (state, payload) => ({ - ...state, - selectedVertexIndex: payload.index, - numVertices: payload.numVertices - }), +const setUndoStackLength = (state, payload) => ({ ...state, undoStackLength: payload }) - SET_UNDO_STACK_LENGTH: (state, payload) => ({ - ...state, - undoStackLength: payload - }), +const toggleSnap = (state) => ({ ...state, snap: !state.snap }) - TOGGLE_SNAP: (state) => ({ ...state, snap: !state.snap }), +const setHasSnapLayers = (state, payload) => ({ ...state, hasSnapLayers: !!payload }) - SET_HAS_SNAP_LAYERS: (state, payload) => ({ ...state, hasSnapLayers: !!payload }) +const actions = { + SET_MODE: setMode, + SET_FEATURE: setFeature, + SET_SELECTED_VERTEX_INDEX: setSelectedVertexIndex, + SET_UNDO_STACK_LENGTH: setUndoStackLength, + TOGGLE_SNAP: toggleSnap, + SET_HAS_SNAP_LAYERS: setHasSnapLayers } export { initialState, actions } diff --git a/plugins/interact/src/defaults.js b/plugins/interact/src/defaults.js index 25b05e5d..26a13fa8 100755 --- a/plugins/interact/src/defaults.js +++ b/plugins/interact/src/defaults.js @@ -2,6 +2,7 @@ export const DEFAULTS = { tolerance: 10, interactionModes: ['selectMarker'], multiSelect: false, + contiguous: false, deselectOnClickOutside: false, marker: {} } diff --git a/plugins/interact/src/hooks/useInteractionHandlers.js b/plugins/interact/src/hooks/useInteractionHandlers.js index 1d34a6f1..13036650 100755 --- a/plugins/interact/src/hooks/useInteractionHandlers.js +++ b/plugins/interact/src/hooks/useInteractionHandlers.js @@ -1,5 +1,5 @@ import { useCallback, useEffect, useRef } from 'react' -import { areAllContiguous } from '../utils/spatial.js' +import { areAllContiguous, isContiguousWithAny } from '../utils/spatial.js' import { getFeaturesAtPoint, findMatchingFeature, buildLayerConfigMap } from '../utils/featureQueries.js' import { scaleFactor } from '../../../../src/config/appConfig.js' import { isStandaloneLabel } from '../../../../src/utils/symbolUtils.js' @@ -16,7 +16,6 @@ import { isStandaloneLabel } from '../../../../src/utils/symbolUtils.js' * @param {number} scale - scaleFactor for the current mapSize (e.g. 1.5 for medium) * @returns {string|null} */ - const findMarkerAtPoint = (markers, point, scale) => { for (const marker of markers.items) { const el = markers.markerRefs?.get(marker.id) @@ -42,13 +41,11 @@ const useSelectionChangeEmitter = (eventBus, selectedFeatures, selectedMarkers, const lastEmittedSelectionChange = useRef(null) useEffect(() => { - // Skip if features exist but bounds not yet calculated const awaitingBounds = selectedFeatures.length > 0 && !selectionBounds if (awaitingBounds) { return } - // Skip if selection was already empty and remains empty const prev = lastEmittedSelectionChange.current const wasEmpty = prev === null || (prev.features.length === 0 && prev.markers.length === 0) if (wasEmpty && selectedFeatures.length === 0 && selectedMarkers.length === 0) { @@ -66,6 +63,109 @@ const useSelectionChangeEmitter = (eventBus, selectedFeatures, selectedMarkers, }, [selectedFeatures, selectedMarkers, selectionBounds]) } +/** + * Given a set of selected features, returns the largest contiguous sub-group + * that stays connected to features[0]. Used when deselecting a feature that + * may act as a bridge between two otherwise disconnected parts of the selection. + * + * Uses flood-fill: starts from features[0] and repeatedly adds any feature that + * touches the growing connected set, until no more can be added. + * + * @param {Array} features - Current selection minus the feature being deselected. + * Each feature must have an enriched `geometry` (set by the provider at selection time). + * @returns {Array|null} The connected sub-group anchored at features[0], + * or null if all features are already contiguous (no trimming needed). + */ +const trimToContiguousGroup = (features) => { + const connected = new Set([0]) + let changed = true + while (changed) { + changed = false + for (let i = 1; i < features.length; i++) { + if (connected.has(i)) { + continue + } + const connectedFeatures = [...connected].map(idx => features[idx]) + if (features[i].geometry?.type && isContiguousWithAny(features[i], connectedFeatures)) { + connected.add(i) + changed = true + } + } + } + if (connected.size === features.length) { + return null + } + return [...connected].map(idx => features[idx]) +} + +const buildTogglePayload = (featureId, multiSelect, config, feature) => ({ + featureId, + multiSelect, + layerId: config.layerId, + idProperty: config.idProperty, + properties: feature.properties, + geometry: feature.geometry +}) + +/** + * Enforces the contiguous selection constraint when a feature is clicked. + * Intercepts the click and dispatches its own action when needed, returning + * true so the caller knows not to dispatch again. + * + * Two cases are handled: + * - Deselecting a feature: if removing it would split the remaining selection + * into disconnected parts, the selection is trimmed to the group that stays + * connected to the first selected feature, rather than allowing a split. + * - Adding a feature: if the new feature does not touch any already-selected + * feature, the entire existing selection is replaced rather than extended, + * keeping the selection contiguous. + * + * Returns false (no-op) when the click is a straightforward toggle that does + * not violate contiguity, leaving the caller to dispatch normally. + * + * @param {{ featureId, feature, config, selectedFeatures, dispatch, multiSelect }} params + * feature.geometry must already be enriched by the provider before calling. + * @returns {boolean} True if this function dispatched; false if the caller should. + */ +const resolveContiguousDispatch = ({ featureId, feature, config, selectedFeatures, dispatch, multiSelect }) => { + if (!selectedFeatures.length) { + return false + } + + const existingIndex = selectedFeatures.findIndex( + f => f.featureId === featureId && f.layerId === config.layerId + ) + + if (existingIndex !== -1) { + // Deselect: trim to first connected group if the removal splits the selection. + if (selectedFeatures.length < 3) { // NOSONAR + return false + } + const remaining = selectedFeatures.filter((_, i) => i !== existingIndex) + const trimmed = trimToContiguousGroup(remaining) + if (!trimmed) { + return false + } + dispatch({ type: 'SET_SELECTED_FEATURES', payload: trimmed }) + return true + } + + // Add: replace selection if the new feature doesn't touch any existing feature. + const validSelected = selectedFeatures.filter(f => f.geometry?.type) + if (!feature.geometry?.type || !validSelected.length) { + return false + } + if (isContiguousWithAny(feature, validSelected)) { + return false + } + + dispatch({ + type: 'TOGGLE_SELECTED_FEATURES', + payload: { ...buildTogglePayload(featureId, multiSelect, config, feature), replaceAll: true } + }) + return true +} + /** * Core interaction hook. Processes map clicks in fixed priority order: * selectMarker → selectFeature → placeMarker (fallback). @@ -81,30 +181,60 @@ const useSelectionChangeEmitter = (eventBus, selectedFeatures, selectedMarkers, * @param {Object} deps.mapProvider - Map provider instance for feature queries * @returns {{ handleInteraction: Function }} */ +const useHandleInteraction = ({ mapProvider, layers, interactionModes, multiSelect, dispatch, markers, layerConfigMap, debug, tolerance, processFeatureMatch, processFallback, scale }) => { + return useCallback(({ point, coords }) => { + const debugFeatures = debug ? getFeaturesAtPoint(mapProvider, point, { radius: tolerance }) : null + if (debugFeatures) { + console.log(`--- Features at ${coords} ---`, debugFeatures) // NOSONAR + } + if (interactionModes.includes('selectMarker')) { + const markerHit = findMarkerAtPoint(markers, point, scale) + if (markerHit) { + dispatch({ type: 'TOGGLE_SELECTED_MARKERS', payload: { markerId: markerHit, multiSelect } }) + return + } + } + if (interactionModes.includes('selectFeature') && layers.length > 0) { + const allFeatures = debugFeatures ?? getFeaturesAtPoint(mapProvider, point, { radius: tolerance }) + const match = findMatchingFeature(allFeatures, layerConfigMap) + if (match) { + processFeatureMatch(match) + return + } + } + processFallback({ coords }) + }, [mapProvider, layers, interactionModes, multiSelect, dispatch, markers, layerConfigMap, debug, tolerance, processFeatureMatch, processFallback, scale]) +} + export const useInteractionHandlers = ({ mapState, pluginState, services, mapProvider }) => { const { markers, mapSize } = mapState - const { dispatch, layers, interactionModes, multiSelect, marker: markerOptions, tolerance, selectedFeatures, selectedMarkers, selectionBounds, deselectOnClickOutside } = pluginState + const { + dispatch, layers, interactionModes, multiSelect, contiguous, + marker: markerOptions, tolerance, selectedFeatures, selectedMarkers, + selectionBounds, deselectOnClickOutside + } = pluginState const { eventBus } = services const layerConfigMap = buildLayerConfigMap(layers) const scale = scaleFactor[mapSize] ?? 1 + const processFeatureMatch = useCallback(({ feature, config }) => { markers.remove('location') const featureId = feature.properties?.[config.idProperty] ?? feature.id if (featureId == null) { return } - dispatch({ - type: 'TOGGLE_SELECTED_FEATURES', - payload: { - featureId, - multiSelect, - layerId: config.layerId, - idProperty: config.idProperty, - properties: feature.properties, - geometry: feature.geometry + const enrichedGeometry = mapProvider.getFeatureGeometry?.(config.layerId, featureId, config.idProperty) + const enrichedFeature = enrichedGeometry ? { ...feature, geometry: enrichedGeometry } : feature + if (contiguous && multiSelect) { + const handled = resolveContiguousDispatch( + { featureId, feature: enrichedFeature, config, selectedFeatures, dispatch, multiSelect } + ) + if (handled) { + return } - }) - }, [markers, dispatch, multiSelect]) + } + dispatch({ type: 'TOGGLE_SELECTED_FEATURES', payload: buildTogglePayload(featureId, multiSelect, config, enrichedFeature) }) + }, [markers, dispatch, multiSelect, contiguous, selectedFeatures, mapProvider]) const processFallback = useCallback(({ coords }) => { const canPlace = interactionModes.includes('placeMarker') @@ -118,31 +248,7 @@ export const useInteractionHandlers = ({ mapState, pluginState, services, mapPro } }, [interactionModes, dispatch, markers, markerOptions, eventBus, deselectOnClickOutside]) - const handleInteraction = useCallback(({ point, coords }) => { - const debugFeatures = pluginState?.debug ? getFeaturesAtPoint(mapProvider, point, { radius: tolerance }) : null - if (debugFeatures) { - console.log(`--- Features at ${coords} ---`, debugFeatures) // NOSONAR - } - - if (interactionModes.includes('selectMarker')) { - const markerHit = findMarkerAtPoint(markers, point, scale) - if (markerHit) { - dispatch({ type: 'TOGGLE_SELECTED_MARKERS', payload: { markerId: markerHit, multiSelect } }) - return - } - } - - if (interactionModes.includes('selectFeature') && layers.length > 0) { - const allFeatures = debugFeatures ?? getFeaturesAtPoint(mapProvider, point, { radius: tolerance }) - const match = findMatchingFeature(allFeatures, layerConfigMap) - if (match) { - processFeatureMatch(match) - return - } - } - - processFallback({ coords }) - }, [ + const handleInteraction = useHandleInteraction({ mapProvider, layers, interactionModes, @@ -150,12 +256,13 @@ export const useInteractionHandlers = ({ mapState, pluginState, services, mapPro dispatch, markers, layerConfigMap, - pluginState?.debug, + debug: pluginState?.debug, tolerance, processFeatureMatch, processFallback, scale - ]) + }) + useSelectionChangeEmitter(eventBus, selectedFeatures, selectedMarkers, selectionBounds) return { handleInteraction } } diff --git a/plugins/interact/src/hooks/useInteractionHandlers.test.js b/plugins/interact/src/hooks/useInteractionHandlers.test.js index 10985d26..364877f0 100644 --- a/plugins/interact/src/hooks/useInteractionHandlers.test.js +++ b/plugins/interact/src/hooks/useInteractionHandlers.test.js @@ -5,15 +5,33 @@ import * as featureQueries from '../utils/featureQueries.js' /* Mocks */ /* ------------------------------------------------------------------ */ -jest.mock('../utils/spatial.js', () => ({ - areAllContiguous: jest.fn(() => false) -})) jest.mock('../utils/featureQueries.js', () => ({ getFeaturesAtPoint: jest.fn(), findMatchingFeature: jest.fn(), buildLayerConfigMap: jest.fn(() => ({})) })) +/* ------------------------------------------------------------------ */ +/* Real geometries — unit squares along the x-axis */ +/* */ +/* geomA geomB geomC geomD (isolated) */ +/* [0-1] [1-2] [2-3] ... [10-11] */ +/* A and B share the x=1 edge (contiguous) */ +/* B and C share the x=2 edge (contiguous) */ +/* A and C do NOT touch (non-contiguous) */ +/* D does not touch any of A, B, or C */ +/* ------------------------------------------------------------------ */ + +const square = (x0, y0, x1, y1) => ({ + type: 'Polygon', + coordinates: [[[x0, y0], [x1, y0], [x1, y1], [x0, y1], [x0, y0]]] +}) + +const geomA = square(0, 0, 1, 1) +const geomB = square(1, 0, 2, 1) // NOSONAR +const geomC = square(2, 0, 3, 1) // NOSONAR +const geomD = square(10, 0, 11, 1) // NOSONAR + /* ------------------------------------------------------------------ */ /* Helpers */ /* ------------------------------------------------------------------ */ @@ -53,7 +71,7 @@ const setup = (pluginOverrides = {}, markerItems = [], markerRefs = new Map()) = services: { eventBus: { emit: jest.fn() } }, - mapProvider: {} + mapProvider: { getFeatureGeometry: jest.fn(() => null) } } const utils = renderHook(() => useInteractionHandlers(deps)) @@ -62,7 +80,7 @@ const setup = (pluginOverrides = {}, markerItems = [], markerRefs = new Map()) = const baseFeature = { properties: { parcelId: 'P1' }, - geometry: { type: 'Polygon' }, + geometry: geomB, layer: { id: 'parcels' } } @@ -336,7 +354,7 @@ it('does not emit selectionchange when features are selected but bounds not yet selectionBounds: null }, services: { eventBus: { emit: jest.fn() } }, - mapProvider: {} + mapProvider: { getFeatureGeometry: jest.fn(() => null) } } renderHook(() => useInteractionHandlers(deps)) @@ -353,7 +371,7 @@ it('emits selectionchange once when bounds exist', () => { selectionBounds: { sw: [0, 0], ne: [1, 1] } }, services: { eventBus: { emit: jest.fn() } }, - mapProvider: {} + mapProvider: { getFeatureGeometry: jest.fn(() => null) } } renderHook(() => useInteractionHandlers(deps)) @@ -378,7 +396,7 @@ it('skips emission when selection remains empty after being cleared', () => { mapState: { markers: { items: [], markerRefs: new Map() } }, pluginState: { selectedFeatures: features, selectedMarkers: [], selectionBounds: { b: 1 } }, services: { eventBus }, - mapProvider: {} + mapProvider: { getFeatureGeometry: jest.fn(() => null) } }), { initialProps: { features: [{ id: 'f1' }] } } ) @@ -418,3 +436,217 @@ it('logs features when debug mode is enabled', () => { logSpy.mockRestore() }) + +/* ------------------------------------------------------------------ */ +/* contiguous enforcement */ +/* existingFeature uses geomA; baseFeature (clicked) uses geomB. */ +/* geomA and geomB share the x=1 edge so they are contiguous. */ +/* ------------------------------------------------------------------ */ + +const existingFeature = { featureId: 'P0', layerId: 'parcels', geometry: geomA } + +describe('contiguous enforcement — selecting features', () => { + it('allows first selection when no features already selected', () => { + const { result, deps } = setup({ contiguous: true, multiSelect: true, selectedFeatures: [] }) + + click(result) + + expect(deps.pluginState.dispatch).toHaveBeenCalledWith( + expect.objectContaining({ type: 'TOGGLE_SELECTED_FEATURES' }) + ) + }) + + it('allows adding a contiguous feature', () => { + // baseFeature has geomB which shares an edge with geomA (existingFeature) + const { result, deps } = setup({ contiguous: true, multiSelect: true, selectedFeatures: [existingFeature] }) + + click(result) + + expect(deps.pluginState.dispatch).toHaveBeenCalledWith( + expect.objectContaining({ type: 'TOGGLE_SELECTED_FEATURES' }) + ) + }) + + it('replaces selection when clicking a non-contiguous feature', () => { + // geomD is isolated from geomA — replaceAll: true replaces rather than extends the selection + featureQueries.findMatchingFeature.mockReturnValue({ + feature: { ...baseFeature, geometry: geomD }, + config: { layerId: 'parcels', idProperty: 'parcelId' } + }) + const { result, deps } = setup({ contiguous: true, multiSelect: true, selectedFeatures: [existingFeature] }) + + click(result) + + expect(deps.pluginState.dispatch).toHaveBeenCalledWith( + expect.objectContaining({ type: 'TOGGLE_SELECTED_FEATURES', payload: expect.objectContaining({ replaceAll: true }) }) + ) + }) + + it('allows deselecting an already-selected feature regardless of contiguity', () => { + const alreadySelected = { featureId: 'P1', layerId: 'parcels', geometry: geomB } + const { result, deps } = setup({ contiguous: true, multiSelect: true, selectedFeatures: [alreadySelected] }) + + click(result) + + expect(deps.pluginState.dispatch).toHaveBeenCalledWith( + expect.objectContaining({ type: 'TOGGLE_SELECTED_FEATURES' }) + ) + }) +}) + +describe('contiguous enforcement — bypass conditions', () => { + it('does not enforce contiguous when contiguous is false', () => { + // geomD is isolated from geomA but contiguous enforcement is disabled + featureQueries.findMatchingFeature.mockReturnValue({ + feature: { ...baseFeature, geometry: geomD }, + config: { layerId: 'parcels', idProperty: 'parcelId' } + }) + const { result, deps } = setup({ contiguous: false, multiSelect: true, selectedFeatures: [existingFeature] }) + + click(result) + + expect(deps.pluginState.dispatch).toHaveBeenCalledWith( + expect.objectContaining({ type: 'TOGGLE_SELECTED_FEATURES' }) + ) + }) + + it('does not enforce contiguous in single-select mode', () => { + // geomD is isolated but contiguous enforcement only applies in multi-select + featureQueries.findMatchingFeature.mockReturnValue({ + feature: { ...baseFeature, geometry: geomD }, + config: { layerId: 'parcels', idProperty: 'parcelId' } + }) + const { result, deps } = setup({ contiguous: true, multiSelect: false, selectedFeatures: [existingFeature] }) + + click(result) + + expect(deps.pluginState.dispatch).toHaveBeenCalledWith( + expect.objectContaining({ type: 'TOGGLE_SELECTED_FEATURES' }) + ) + }) + + it('falls through to normal toggle when selected features have no usable geometry', () => { + const noGeomFeature = { featureId: 'P0', layerId: 'parcels' } + const { result, deps } = setup({ contiguous: true, multiSelect: true, selectedFeatures: [noGeomFeature] }) + + click(result) + + expect(deps.pluginState.dispatch).toHaveBeenCalledWith( + expect.objectContaining({ type: 'TOGGLE_SELECTED_FEATURES', payload: expect.not.objectContaining({ replaceAll: true }) }) + ) + }) +}) + +describe('contiguous enforcement — geometry enrichment', () => { + it('uses the geometry returned by the provider as the dispatched geometry', () => { + const enriched = { type: 'MultiPolygon', coordinates: [[[[0, 0], [1, 0], [1, 1], [0, 0]]]] } + const { result, deps } = setup({ contiguous: false, multiSelect: true, selectedFeatures: [] }) + deps.mapProvider.getFeatureGeometry.mockReturnValue(enriched) + + click(result) + + expect(deps.pluginState.dispatch).toHaveBeenCalledWith( + expect.objectContaining({ payload: expect.objectContaining({ geometry: enriched }) }) + ) + }) + + it('uses stored geometry when provider returns null', () => { + const { result, deps } = setup({ contiguous: false, multiSelect: true, selectedFeatures: [] }) + deps.mapProvider.getFeatureGeometry.mockReturnValue(null) + + click(result) + + expect(deps.pluginState.dispatch).toHaveBeenCalledWith( + expect.objectContaining({ payload: expect.objectContaining({ geometry: baseFeature.geometry }) }) + ) + }) + + it('uses stored geometry when provider has no getFeatureGeometry', () => { + const { result, deps } = setup({ contiguous: false, multiSelect: true, selectedFeatures: [] }) + deps.mapProvider.getFeatureGeometry = undefined + + click(result) + + expect(deps.pluginState.dispatch).toHaveBeenCalledWith( + expect.objectContaining({ payload: expect.objectContaining({ geometry: baseFeature.geometry }) }) + ) + }) + + it('falls through to normal toggle when clicked feature has no geometry', () => { + featureQueries.findMatchingFeature.mockReturnValue({ + feature: { properties: { parcelId: 'P1' }, geometry: null, layer: { id: 'parcels' } }, + config: { layerId: 'parcels', idProperty: 'parcelId' } + }) + const { result, deps } = setup({ contiguous: true, multiSelect: true, selectedFeatures: [existingFeature] }) + + click(result) + + expect(deps.pluginState.dispatch).toHaveBeenCalledWith( + expect.objectContaining({ type: 'TOGGLE_SELECTED_FEATURES' }) + ) + }) +}) + +describe('contiguous enforcement — deselect splits', () => { + // A=[0-1], B=[1-2], C=[2-3]: A-B touch, B-C touch, but A-C do not touch + const featureA = { featureId: 'P0', layerId: 'parcels', geometry: geomA } + const featureB = { featureId: 'P1', layerId: 'parcels', geometry: geomB } + const featureC = { featureId: 'P2', layerId: 'parcels', geometry: geomC } + + it('trims to first contiguous group when deselecting the bridge feature', () => { + // A-B-C selected; deselect B (baseFeature returns P1); remaining [A, C] are non-contiguous + // flood-fill from A cannot reach C — result is trimmed to [A] + const { result, deps } = setup({ + contiguous: true, + multiSelect: true, + selectedFeatures: [featureA, featureB, featureC] + }) + + click(result) // clicks P1 = featureB + + expect(deps.pluginState.dispatch).toHaveBeenCalledWith({ + type: 'SET_SELECTED_FEATURES', + payload: [featureA] + }) + }) + + it('uses normal toggle when deselecting an end feature leaves a contiguous set', () => { + // A-B-C selected; deselect C (override mock to return P2); remaining [A, B] share the x=1 edge + featureQueries.findMatchingFeature.mockReturnValue({ + feature: { properties: { parcelId: 'P2' }, geometry: { type: 'Polygon' }, layer: { id: 'parcels' } }, + config: { layerId: 'parcels', idProperty: 'parcelId' } + }) + const { result, deps } = setup({ + contiguous: true, + multiSelect: true, + selectedFeatures: [featureA, featureB, featureC] + }) + + click(result) // clicks P2 = featureC + + expect(deps.pluginState.dispatch).toHaveBeenCalledWith( + expect.objectContaining({ type: 'TOGGLE_SELECTED_FEATURES' }) + ) + expect(deps.pluginState.dispatch).not.toHaveBeenCalledWith( + expect.objectContaining({ type: 'SET_SELECTED_FEATURES' }) + ) + }) + + it('does not check for split when fewer than 3 features selected', () => { + // Only 2 features — the split-check threshold is < 3, so goes straight to normal toggle + const { result, deps } = setup({ + contiguous: true, + multiSelect: true, + selectedFeatures: [featureA, featureB] + }) + + click(result) // clicks P1 = featureB + + expect(deps.pluginState.dispatch).toHaveBeenCalledWith( + expect.objectContaining({ type: 'TOGGLE_SELECTED_FEATURES' }) + ) + expect(deps.pluginState.dispatch).not.toHaveBeenCalledWith( + expect.objectContaining({ type: 'SET_SELECTED_FEATURES' }) + ) + }) +}) diff --git a/plugins/interact/src/reducer.js b/plugins/interact/src/reducer.js index a17c515e..92d1a927 100755 --- a/plugins/interact/src/reducer.js +++ b/plugins/interact/src/reducer.js @@ -143,6 +143,12 @@ const unselectMarker = (state, { markerId }) => { } } +const setSelectedFeatures = (state, features) => ({ + ...state, + selectedFeatures: features, + selectionBounds: null +}) + const actions = { ENABLE: enable, DISABLE: disable, @@ -150,6 +156,7 @@ const actions = { TOGGLE_SELECTED_MARKERS: toggleSelectedMarkers, UPDATE_SELECTED_BOUNDS: updateSelectedBounds, CLEAR_SELECTED_FEATURES: clearSelectedFeatures, + SET_SELECTED_FEATURES: setSelectedFeatures, SELECT_MARKER: selectMarker, UNSELECT_MARKER: unselectMarker, SET_LISTBOX_ACTIVE: setListboxActive diff --git a/plugins/interact/src/reducer.test.js b/plugins/interact/src/reducer.test.js index 00c547be..6039705b 100644 --- a/plugins/interact/src/reducer.test.js +++ b/plugins/interact/src/reducer.test.js @@ -240,6 +240,21 @@ describe('UNSELECT_MARKER action', () => { }) }) +describe('SET_SELECTED_FEATURES action', () => { + it('replaces selectedFeatures and clears bounds', () => { + const state = { + ...initialState, + selectedFeatures: [{ featureId: 'A' }, { featureId: 'B' }, { featureId: 'C' }], + selectionBounds: [0, 0, 1, 1] + } + const trimmed = [{ featureId: 'A' }] + const result = actions.SET_SELECTED_FEATURES(state, trimmed) + expect(result.selectedFeatures).toEqual(trimmed) + expect(result.selectionBounds).toBeNull() + expect(result).not.toBe(state) + }) +}) + describe('actions object', () => { it('exports all action handlers as functions', () => { expect(Object.keys(actions)).toEqual([ @@ -249,6 +264,7 @@ describe('actions object', () => { 'TOGGLE_SELECTED_MARKERS', 'UPDATE_SELECTED_BOUNDS', 'CLEAR_SELECTED_FEATURES', + 'SET_SELECTED_FEATURES', 'SELECT_MARKER', 'UNSELECT_MARKER', 'SET_LISTBOX_ACTIVE' diff --git a/providers/beta/openlayers/src/appEvents.js b/providers/beta/openlayers/src/appEvents.js index 74799c83..f3c54a2b 100644 --- a/providers/beta/openlayers/src/appEvents.js +++ b/providers/beta/openlayers/src/appEvents.js @@ -8,6 +8,9 @@ export function attachAppEvents ({ mapProvider, transformRequest, events, eventB map.getLayers().setAt(0, layer) onBaseSourceChange(source) eventBus.emit(events.MAP_STYLE_CHANGE, { mapStyleId: mapStyle.id }) + // MAP_DATA_CHANGE is driven by the original source's tileloadend and won't fire + // for the new source, so re-apply highlights directly on the new layer. + mapProvider.reapplyHighlights() } const handleSetPixelRatio = (pixelRatio) => { diff --git a/providers/beta/openlayers/src/appEvents.test.js b/providers/beta/openlayers/src/appEvents.test.js index 6869be83..352239ae 100644 --- a/providers/beta/openlayers/src/appEvents.test.js +++ b/providers/beta/openlayers/src/appEvents.test.js @@ -22,7 +22,7 @@ function makeMap () { } function makeProvider () { - return { mapSize: 'small' } + return { mapSize: 'small', reapplyHighlights: jest.fn() } } describe('attachAppEvents', () => { diff --git a/providers/beta/openlayers/src/openlayersProvider.js b/providers/beta/openlayers/src/openlayersProvider.js index 7a307381..10d7fe4e 100644 --- a/providers/beta/openlayers/src/openlayersProvider.js +++ b/providers/beta/openlayers/src/openlayersProvider.js @@ -8,10 +8,16 @@ import { getViewResolutionConfig, ZOOM_ALIGNMENT } from './utils/zoom.js' import { attachMapEvents } from './mapEvents.js' import { attachAppEvents, createMapStyleLayer } from './appEvents.js' import { getAreaDimensions, getCardinalMove, getExtentFromGeoJSON, getPaddedExtent, isGeometryObscured } from './utils/spatial.js' +import { updateHighlightedFeatures } from './utils/highlightFeatures.js' +import { queryFeatures } from './utils/queryFeatures.js' +import { collectTileFragments } from './utils/vtTileFragments.js' +import { setupHoverCursor } from './utils/hoverCursor.js' +import { applyOpenLayersFixes } from './utils/openLayersFixes.js' + +applyOpenLayersFixes() const CRS = 'EPSG:27700' -// OL view padding is [top, right, bottom, left]; app passes { top, right, bottom, left } const toPaddingArray = (padding) => { if (!padding) { return undefined @@ -111,6 +117,10 @@ export default class OpenLayersProvider { this.appEventHandles = null if (this.map) { + if (this._onHoverMove) { + this.map.un('pointermove', this._onHoverMove) + this._onHoverMove = null + } this.map.setTarget(null) this.map = null } @@ -185,15 +195,42 @@ export default class OpenLayersProvider { return extent.map(n => Math.round(n * 100) / 100) } - getFeaturesAtPoint (_point, _options) { - // Raster tiles have no queryable features - return [] + getFeaturesAtPoint (point, options) { + return queryFeatures(this.map, point, options) + } + + getFeatureGeometry (layerId, featureId, idProperty) { + const fragments = collectTileFragments(this.map, layerId, featureId, idProperty) + if (fragments.length === 0) { return null } + if (fragments.length === 1) { return fragments[0] } + const coords = fragments.flatMap(g => g.type === 'MultiPolygon' ? g.coordinates : [g.coordinates]) + return { type: 'MultiPolygon', coordinates: coords } + } + + setHoverCursor (layerIds) { + if (!this.map) { + return + } + this._onHoverMove = setupHoverCursor(this.map, layerIds, this._onHoverMove) } getVisibleFeatures (_layerIds) { return [] } + updateHighlightedFeatures (selectedFeatures, activeFeatures, stylesMap) { + this._lastHighlightArgs = { selectedFeatures, activeFeatures, stylesMap } + return updateHighlightedFeatures(this.map, selectedFeatures, activeFeatures, stylesMap) + } + + reapplyHighlights () { + if (!this._lastHighlightArgs) { + return + } + const { selectedFeatures, activeFeatures, stylesMap } = this._lastHighlightArgs + updateHighlightedFeatures(this.map, selectedFeatures, activeFeatures, stylesMap) + } + // ========================== // Spatial helpers // ========================== diff --git a/providers/beta/openlayers/src/openlayersProvider.test.js b/providers/beta/openlayers/src/openlayersProvider.test.js index 74afbcf8..c8d8508a 100644 --- a/providers/beta/openlayers/src/openlayersProvider.test.js +++ b/providers/beta/openlayers/src/openlayersProvider.test.js @@ -306,10 +306,6 @@ describe('OpenLayersProvider', () => { expect(provider.getBounds()).toEqual([1.13, 2.46, 3.99, 4]) }) - it('getFeaturesAtPoint returns empty array', () => { - expect(provider.getFeaturesAtPoint()).toEqual([]) - }) - it('getVisibleFeatures returns empty array', () => { expect(provider.getVisibleFeatures()).toEqual([]) }) diff --git a/providers/beta/openlayers/src/utils/highlightFeatures.js b/providers/beta/openlayers/src/utils/highlightFeatures.js new file mode 100644 index 00000000..31f1eb84 --- /dev/null +++ b/providers/beta/openlayers/src/utils/highlightFeatures.js @@ -0,0 +1,245 @@ +import VectorTileLayer from 'ol/layer/VectorTile.js' +import VectorLayer from 'ol/layer/Vector.js' +import VectorSource from 'ol/source/Vector.js' +import Feature from 'ol/Feature.js' +import GeoJSON from 'ol/format/GeoJSON.js' +import Style from 'ol/style/Style.js' +import Stroke from 'ol/style/Stroke.js' +import Fill from 'ol/style/Fill.js' +import { collectTileFragments } from './vtTileFragments.js' + +const CRS = 'EPSG:27700' +const geoJsonFormat = new GeoJSON({ dataProjection: CRS, featureProjection: CRS }) + +const HIGHLIGHT_MARKER = '_highlight' +const HIGHLIGHT_Z = 999 + +const buildHighlightStyles = (styleEntry, isActive) => { + if (!styleEntry) { + return [] + } + const { stroke, selectionStroke, fill, strokeWidth, activeStrokeWidth } = styleEntry + + if (isActive) { + return [ + new Style({ stroke: new Stroke({ color: stroke, width: activeStrokeWidth }), zIndex: HIGHLIGHT_Z }), + new Style({ stroke: new Stroke({ color: selectionStroke, width: strokeWidth }), zIndex: HIGHLIGHT_Z + 1 }) + ] + } + + const styles = [new Style({ stroke: new Stroke({ color: selectionStroke, width: strokeWidth }), zIndex: HIGHLIGHT_Z })] + if (fill && fill !== 'transparent') { + styles.push(new Style({ fill: new Fill({ color: fill }), zIndex: HIGHLIGHT_Z })) + } + return styles +} + +// --------------------------------------------------------------------------- +// VectorTileLayer: style-function wrap +// Evaluated per feature per tile at render time — automatically covers all +// tiles including those that load after selection, matching MapLibre's +// filter-based behaviour. +// --------------------------------------------------------------------------- + +const buildFeatureKeyIndex = (features) => { + const keys = new Set() + const idProps = {} // styleLayerId → idProperty + + for (const { layerId, featureId, idProperty } of features ?? []) { + keys.add(`${layerId}:${featureId}`) + if (idProperty) { + idProps[layerId] = idProperty + } + } + + return { keys, idProps } +} + +const wrapVtLayers = (map, selectedKeys, activeKeys, idPropsMap, stylesMap) => { + map.getLayers().forEach(layer => { + if (!(layer instanceof VectorTileLayer)) { + return + } + + const hasSelection = selectedKeys.size > 0 || activeKeys.size > 0 + + if (!hasSelection) { + if (layer._highlightOriginalStyle) { + layer.setStyle(layer._highlightOriginalStyle) + delete layer._highlightOriginalStyle + } + return + } + + if (!layer._highlightOriginalStyle) { + layer._highlightOriginalStyle = layer.getStyleFunction() + } + const orig = layer._highlightOriginalStyle + + layer.setStyle((feature, resolution) => { + const base = orig(feature, resolution) + const styleLayerId = feature.get('mapbox-layer')?.id + if (!styleLayerId) { + return base + } + + const idProp = idPropsMap[styleLayerId] + const fid = idProp ? feature.get(idProp) : feature.getId() + const key = `${styleLayerId}:${fid}` + + const isActive = activeKeys.has(key) + const isSelected = !isActive && selectedKeys.has(key) + if (!isActive && !isSelected) { + return base + } + + const highlightStyles = buildHighlightStyles(stylesMap?.[styleLayerId], isActive) + if (!highlightStyles.length) { + return base + } + + const baseArr = base ? (Array.isArray(base) ? base : [base]) : [] + return [...baseArr, ...highlightStyles] + }) + // setStyle() calls layer.changed() internally — no source.changed() needed + // (source.changed() works but causes a visible flicker on selection) + }) +} + +// --------------------------------------------------------------------------- +// VectorLayer: overlay approach +// Non-tiled vector layers (e.g. the draw layer) have no tile-boundary issue, +// so a simple overlay Feature works correctly and needs no style-wrap. +// --------------------------------------------------------------------------- + +const getOrCreateHighlightLayer = (map) => { + let layer = null + map.getLayers().forEach(l => { + if (l.get(HIGHLIGHT_MARKER)) { + layer = l + } + }) + if (!layer) { + layer = new VectorLayer({ source: new VectorSource(), zIndex: HIGHLIGHT_Z + 2 }) + layer.set(HIGHLIGHT_MARKER, true) + map.addLayer(layer) + } + return layer +} + +const addVectorHighlights = (source, features, isActive, stylesMap) => { + for (const { layerId, geometry } of features ?? []) { + if (!geometry) { + continue + } + const styles = buildHighlightStyles(stylesMap?.[layerId], isActive) + if (!styles.length) { + continue + } + const olFeature = new Feature({ geometry: geoJsonFormat.readGeometry(geometry) }) + olFeature.setStyle(styles) + source.addFeature(olFeature) + } +} + +// --------------------------------------------------------------------------- +// Bounds +// --------------------------------------------------------------------------- + +const expandBoundsFromGeometry = (geometry, cb) => { + const { type, coordinates } = geometry + const visitCoord = ([x, y]) => cb(x, y) + const visitRing = (ring) => ring.forEach(visitCoord) + + if (type === 'Point') { + visitCoord(coordinates) + } else if (type === 'MultiPoint' || type === 'LineString') { + coordinates.forEach(visitCoord) + } else if (type === 'MultiLineString' || type === 'Polygon') { + coordinates.forEach(visitRing) + } else if (type === 'MultiPolygon') { + coordinates.forEach(poly => poly.forEach(visitRing)) + } +} + +const resolveGeometries = (map, { layerId, featureId, idProperty, geometry }) => { + if (featureId != null) { + const fragments = collectTileFragments(map, layerId, featureId, idProperty) + if (fragments.length > 0) { + return fragments + } + } + return geometry ? [geometry] : [] +} + +const computeBounds = (geometries) => { + if (!geometries.length) { + return null + } + + let minX = Infinity + let minY = Infinity + let maxX = -Infinity + let maxY = -Infinity + + for (const geometry of geometries) { + expandBoundsFromGeometry(geometry, (x, y) => { + if (x < minX) { minX = x } + if (y < minY) { minY = y } + if (x > maxX) { maxX = x } + if (y > maxY) { maxY = y } + }) + } + + return minX === Infinity ? null : [minX, minY, maxX, maxY] +} + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +/** + * Highlight selected/active features. + * - VectorTileLayers: style-function wrap so all tiles (including those that + * load after selection) render the highlight automatically. + * - VectorLayers (draw etc.): overlay Feature at zIndex 1001. + * Returns EPSG:27700 bounds [minX, minY, maxX, maxY] for selected features, or null. + */ +export const updateHighlightedFeatures = (map, selectedFeatures, activeFeatures, stylesMap) => { + if (!map) { + return null + } + + // Determine which layerIds belong to plain VectorLayers vs VT layers + const vectorLayerIds = new Set() + map.getLayers().forEach(l => { + if (l instanceof VectorLayer && !l.get(HIGHLIGHT_MARKER)) { + const id = l.get('layerId') + if (id) { + vectorLayerIds.add(id) + } + } + }) + + // VT layers — style-wrap + const { keys: selectedKeys, idProps: selectedIdProps } = buildFeatureKeyIndex(selectedFeatures) + const { keys: activeKeys, idProps: activeIdProps } = buildFeatureKeyIndex(activeFeatures) + const idPropsMap = { ...selectedIdProps, ...activeIdProps } + wrapVtLayers(map, selectedKeys, activeKeys, idPropsMap, stylesMap) + + // VectorLayers — overlay + const hlLayer = getOrCreateHighlightLayer(map) + const hlSource = hlLayer.getSource() + hlSource.clear() + + const vecSelected = (selectedFeatures ?? []).filter(f => vectorLayerIds.has(f.layerId)) + const vecActive = (activeFeatures ?? []).filter(f => vectorLayerIds.has(f.layerId)) + + // VT features are handled by style-wrap; only add vector features to overlay + addVectorHighlights(hlSource, vecActive, true, stylesMap) + addVectorHighlights(hlSource, vecSelected, false, stylesMap) + + // Bounds from all tile fragments of selected features + const allGeoms = (selectedFeatures ?? []).flatMap(feat => resolveGeometries(map, feat)) + return computeBounds(allGeoms) +} diff --git a/providers/beta/openlayers/src/utils/hoverCursor.js b/providers/beta/openlayers/src/utils/hoverCursor.js new file mode 100644 index 00000000..35679667 --- /dev/null +++ b/providers/beta/openlayers/src/utils/hoverCursor.js @@ -0,0 +1,65 @@ +import VectorTileLayer from 'ol/layer/VectorTile.js' +import VectorLayer from 'ol/layer/Vector.js' + +const HIGHLIGHT_MARKER = '_highlight' +const HIT_TOLERANCE = 8 + +const isInteractiveFeature = (feature, layer, layerSet) => { + if (layer instanceof VectorTileLayer) { + const styleLayerId = feature.get('mapbox-layer')?.id + return Boolean(styleLayerId && layerSet.has(styleLayerId)) + } + if (layer instanceof VectorLayer && !layer.get(HIGHLIGHT_MARKER)) { + const layerId = layer.get('layerId') + return Boolean(layerId && layerSet.has(layerId)) + } + return false +} + +/** + * Attaches a pointermove listener that changes the map cursor to a pointer when + * hovering over any of the specified layers. Only fires for mouse pointer events + * so touch interactions are unaffected. + * + * @param {import('ol/Map').default} map - OL map instance + * @param {string[]} layerIds - Layer IDs to watch + * @param {Function|null} prevHandler - Previous pointermove handler to remove + * @returns {Function|null} The new handler, or null if layerIds is empty + */ +export const setupHoverCursor = (map, layerIds, prevHandler) => { + const viewport = map.getViewport() + + if (prevHandler) { + map.un('pointermove', prevHandler) + } + + if (!layerIds?.length) { + viewport.style.cursor = '' + return null + } + + const layerSet = new Set(layerIds) + let rafId = null + + const handler = (e) => { + if (e.originalEvent?.pointerType !== 'mouse') { + return + } + const pixel = e.pixel + if (rafId !== null) { + cancelAnimationFrame(rafId) + } + rafId = requestAnimationFrame(() => { + rafId = null + const hit = map.forEachFeatureAtPixel( + pixel, + (feature, layer) => isInteractiveFeature(feature, layer, layerSet), + { hitTolerance: HIT_TOLERANCE } + ) + viewport.style.cursor = hit ? 'pointer' : '' + }) + } + + map.on('pointermove', handler) + return handler +} diff --git a/providers/beta/openlayers/src/utils/hoverCursor.test.js b/providers/beta/openlayers/src/utils/hoverCursor.test.js new file mode 100644 index 00000000..399876e9 --- /dev/null +++ b/providers/beta/openlayers/src/utils/hoverCursor.test.js @@ -0,0 +1,235 @@ +import { setupHoverCursor } from './hoverCursor.js' + +import VectorTileLayer from 'ol/layer/VectorTile.js' +import VectorLayer from 'ol/layer/Vector.js' + +jest.mock('ol/layer/VectorTile.js', () => ({ __esModule: true, default: class VectorTileLayer {} })) +jest.mock('ol/layer/Vector.js', () => ({ __esModule: true, default: class VectorLayer {} })) + +const makeViewport = () => ({ style: { cursor: '' } }) + +const makeMap = (forEachResult = undefined) => { + const viewport = makeViewport() + return { + getViewport: () => viewport, + forEachFeatureAtPixel: jest.fn(() => forEachResult), + on: jest.fn(), + un: jest.fn() + } +} + +const makeVTFeature = (styleLayerId) => ({ + get: (key) => key === 'mapbox-layer' ? { id: styleLayerId } : undefined +}) + +const makeVectorFeature = () => ({ get: () => undefined }) + +const makeVTLayer = () => { + const layer = new VectorTileLayer() + layer.get = () => undefined + return layer +} + +const makeVectorLayer = (layerId, isHighlight = false) => { + const layer = new VectorLayer() + layer.get = (key) => { + if (key === '_highlight') return isHighlight ? true : undefined + if (key === 'layerId') return layerId + return undefined + } + return layer +} + +const move = (handler, pointerType = 'mouse') => + handler({ pixel: [10, 10], originalEvent: { pointerType } }) + +describe('setupHoverCursor', () => { + beforeEach(() => { + jest.spyOn(global, 'requestAnimationFrame').mockImplementation(cb => { cb(); return 0 }) + jest.spyOn(global, 'cancelAnimationFrame').mockImplementation(() => {}) + }) + + afterEach(() => { + jest.restoreAllMocks() + }) + + /* ------------------------------------------------------------------ */ + /* Setup / teardown */ + /* ------------------------------------------------------------------ */ + + it('returns null and clears cursor when layerIds is empty', () => { + const map = makeMap() + const result = setupHoverCursor(map, [], null) + expect(result).toBeNull() + expect(map.getViewport().style.cursor).toBe('') + expect(map.on).not.toHaveBeenCalled() + }) + + it('returns null and clears cursor when layerIds is null', () => { + const map = makeMap() + const result = setupHoverCursor(map, null, null) + expect(result).toBeNull() + expect(map.getViewport().style.cursor).toBe('') + }) + + it('removes previous handler before attaching a new one', () => { + const map = makeMap() + const prev = jest.fn() + setupHoverCursor(map, ['layer-a'], prev) + expect(map.un).toHaveBeenCalledWith('pointermove', prev) + }) + + it('removes previous handler when clearing layers', () => { + const map = makeMap() + const prev = jest.fn() + setupHoverCursor(map, [], prev) + expect(map.un).toHaveBeenCalledWith('pointermove', prev) + }) + + it('attaches a pointermove listener and returns the handler', () => { + const map = makeMap() + const handler = setupHoverCursor(map, ['layer-a'], null) + expect(typeof handler).toBe('function') + expect(map.on).toHaveBeenCalledWith('pointermove', handler) + }) + + /* ------------------------------------------------------------------ */ + /* Pointermove — cursor state */ + /* ------------------------------------------------------------------ */ + + it('sets pointer cursor when an interactive VT feature is hit', () => { + const map = makeMap(true) + const handler = setupHoverCursor(map, ['fill-layer'], null) + move(handler) + expect(map.getViewport().style.cursor).toBe('pointer') + }) + + it('clears cursor when no interactive feature is hit', () => { + const viewport = makeViewport() + viewport.style.cursor = 'pointer' + const map = { ...makeMap(undefined), getViewport: () => viewport } + const handler = setupHoverCursor(map, ['fill-layer'], null) + move(handler) + expect(viewport.style.cursor).toBe('') + }) + + it('ignores touch pointer events', () => { + const map = makeMap(true) + const handler = setupHoverCursor(map, ['layer-a'], null) + move(handler, 'touch') + expect(map.forEachFeatureAtPixel).not.toHaveBeenCalled() + }) + + it('ignores pen pointer events', () => { + const map = makeMap(true) + const handler = setupHoverCursor(map, ['layer-a'], null) + move(handler, 'pen') + expect(map.forEachFeatureAtPixel).not.toHaveBeenCalled() + }) + + /* ------------------------------------------------------------------ */ + /* Feature matching — VectorTile layer */ + /* ------------------------------------------------------------------ */ + + it('matches a VT feature whose mapbox-layer id is in the watched set', () => { + const map = makeMap() + const feature = makeVTFeature('roads') + const layer = makeVTLayer() + map.forEachFeatureAtPixel.mockImplementation((pixel, cb) => cb(feature, layer)) + + const handler = setupHoverCursor(map, ['roads'], null) + move(handler) + expect(map.getViewport().style.cursor).toBe('pointer') + }) + + it('does not match a VT feature whose mapbox-layer id is not in the watched set', () => { + const map = makeMap() + const feature = makeVTFeature('other-layer') + const layer = makeVTLayer() + map.forEachFeatureAtPixel.mockImplementation((pixel, cb) => cb(feature, layer)) + + const handler = setupHoverCursor(map, ['roads'], null) + move(handler) + expect(map.getViewport().style.cursor).toBe('') + }) + + /* ------------------------------------------------------------------ */ + /* Feature matching — Vector layer */ + /* ------------------------------------------------------------------ */ + + it('matches a VectorLayer feature whose layerId is in the watched set', () => { + const map = makeMap() + const feature = makeVectorFeature() + const layer = makeVectorLayer('draw') + map.forEachFeatureAtPixel.mockImplementation((pixel, cb) => cb(feature, layer)) + + const handler = setupHoverCursor(map, ['draw'], null) + move(handler) + expect(map.getViewport().style.cursor).toBe('pointer') + }) + + it('does not match a VectorLayer feature whose layerId is not in the watched set', () => { + const map = makeMap() + const feature = makeVectorFeature() + const layer = makeVectorLayer('other') + map.forEachFeatureAtPixel.mockImplementation((pixel, cb) => cb(feature, layer)) + + const handler = setupHoverCursor(map, ['draw'], null) + move(handler) + expect(map.getViewport().style.cursor).toBe('') + }) + + it('skips highlight overlay VectorLayers', () => { + const map = makeMap() + const feature = makeVectorFeature() + const layer = makeVectorLayer('draw', true) + map.forEachFeatureAtPixel.mockImplementation((pixel, cb) => cb(feature, layer)) + + const handler = setupHoverCursor(map, ['draw'], null) + move(handler) + expect(map.getViewport().style.cursor).toBe('') + }) + + /* ------------------------------------------------------------------ */ + /* forEachFeatureAtPixel options */ + /* ------------------------------------------------------------------ */ + + it('passes hitTolerance to forEachFeatureAtPixel', () => { + const map = makeMap() + const handler = setupHoverCursor(map, ['layer-a'], null) + move(handler) + expect(map.forEachFeatureAtPixel).toHaveBeenCalledWith( + [10, 10], + expect.any(Function), + { hitTolerance: 8 } + ) + }) + + /* ------------------------------------------------------------------ */ + /* RAF throttle */ + /* ------------------------------------------------------------------ */ + + it('cancels a pending RAF when a second move fires before the frame', () => { + global.requestAnimationFrame.mockImplementation(() => 42) // don't execute immediately + const map = makeMap(true) + const handler = setupHoverCursor(map, ['layer-a'], null) + move(handler) + move(handler) + expect(global.cancelAnimationFrame).toHaveBeenCalledWith(42) + }) + + it('calls forEachFeatureAtPixel only once per RAF even with multiple moves', () => { + let pendingCb = null + global.requestAnimationFrame.mockImplementation(cb => { pendingCb = cb; return 1 }) + global.cancelAnimationFrame.mockImplementation(() => { pendingCb = null }) + + const map = makeMap(true) + const handler = setupHoverCursor(map, ['layer-a'], null) + move(handler) + move(handler) + move(handler) + + pendingCb?.() + expect(map.forEachFeatureAtPixel).toHaveBeenCalledTimes(1) + }) +}) diff --git a/providers/beta/openlayers/src/utils/openLayersFixes.js b/providers/beta/openlayers/src/utils/openLayersFixes.js new file mode 100644 index 00000000..5b24de2d --- /dev/null +++ b/providers/beta/openlayers/src/utils/openLayersFixes.js @@ -0,0 +1,11 @@ +// OL creates hit-detection canvases without willReadFrequently, causing browser warnings. +// Patching getContext ensures any 2D canvas created after this point gets the flag. +export const applyOpenLayersFixes = () => { + if (typeof HTMLCanvasElement === 'undefined') { + return + } + const _getContext = HTMLCanvasElement.prototype.getContext + HTMLCanvasElement.prototype.getContext = function (type, attrs, ...rest) { // NOSONAR + return _getContext.call(this, type, type === '2d' ? { ...attrs, willReadFrequently: true } : attrs, ...rest) + } +} diff --git a/providers/beta/openlayers/src/utils/openLayersFixes.test.js b/providers/beta/openlayers/src/utils/openLayersFixes.test.js new file mode 100644 index 00000000..0d5e43c2 --- /dev/null +++ b/providers/beta/openlayers/src/utils/openLayersFixes.test.js @@ -0,0 +1,53 @@ +import { applyOpenLayersFixes } from './openLayersFixes.js' + +let origGetContext + +beforeEach(() => { + origGetContext = HTMLCanvasElement.prototype.getContext +}) + +afterEach(() => { + HTMLCanvasElement.prototype.getContext = origGetContext +}) + +const applyFix = (spy) => { + HTMLCanvasElement.prototype.getContext = spy + applyOpenLayersFixes() +} + +describe('applyOpenLayersFixes', () => { + it('adds willReadFrequently: true to 2D context requests', () => { + const spy = jest.fn() + applyFix(spy) + document.createElement('canvas').getContext('2d') + expect(spy).toHaveBeenCalledWith('2d', { willReadFrequently: true }) + }) + + it('willReadFrequently: true wins over an explicit false', () => { + const spy = jest.fn() + applyFix(spy) + document.createElement('canvas').getContext('2d', { willReadFrequently: false }) + expect(spy).toHaveBeenCalledWith('2d', { willReadFrequently: true }) + }) + + it('preserves other 2D context attributes', () => { + const spy = jest.fn() + applyFix(spy) + document.createElement('canvas').getContext('2d', { alpha: false }) + expect(spy).toHaveBeenCalledWith('2d', { alpha: false, willReadFrequently: true }) + }) + + it('does not modify non-2D context requests', () => { + const spy = jest.fn() + applyFix(spy) + document.createElement('canvas').getContext('webgl', { antialias: true }) + expect(spy).toHaveBeenCalledWith('webgl', { antialias: true }) + }) + + it('does nothing in environments without HTMLCanvasElement', () => { + const orig = global.HTMLCanvasElement + delete global.HTMLCanvasElement + expect(() => applyOpenLayersFixes()).not.toThrow() + global.HTMLCanvasElement = orig + }) +}) diff --git a/providers/beta/openlayers/src/utils/queryFeatures.js b/providers/beta/openlayers/src/utils/queryFeatures.js new file mode 100644 index 00000000..04fbccbf --- /dev/null +++ b/providers/beta/openlayers/src/utils/queryFeatures.js @@ -0,0 +1,77 @@ +import VectorTileLayer from 'ol/layer/VectorTile.js' +import VectorLayer from 'ol/layer/Vector.js' +import GeoJSON from 'ol/format/GeoJSON.js' +import { renderFeatureToGeoJSON } from './vtTileFragments.js' + +const CRS = 'EPSG:27700' + +const geoJsonFormat = new GeoJSON({ dataProjection: CRS, featureProjection: CRS }) + +// Mirror MapLibre's fallback: use property hash when feature has no explicit MVT ID. +// This deduplicates tile-split fragments that share the same properties. +const getVtFeatureId = (feature) => { + const id = feature.getId() + if (id !== null && id !== undefined) { + return id + } + const props = { ...feature.getProperties() } + delete props['mapbox-layer'] + return JSON.stringify(props) +} + +export const queryFeatures = (map, point, options = {}) => { + if (!point) { + return [] + } + const { radius = 10 } = options + const pixel = [point.x, point.y] + const results = [] + const seenKeys = new Set() + + map.forEachFeatureAtPixel( + pixel, + (feature, layer) => { + if (layer instanceof VectorTileLayer) { + const mapboxLayer = feature.get('mapbox-layer') + const styleLayerId = mapboxLayer?.id + // background-type layers have no features in MapLibre — skip to match behaviour + if (!styleLayerId || mapboxLayer?.type === 'background') { + return + } + const key = `${styleLayerId}:${getVtFeatureId(feature)}` + if (seenKeys.has(key)) { + return + } + seenKeys.add(key) + results.push({ + id: feature.getId(), + layer: { id: styleLayerId }, + geometry: renderFeatureToGeoJSON(feature), + properties: feature.getProperties() + }) + } else if (layer instanceof VectorLayer) { + const layerId = layer.get('layerId') + if (!layerId || layer.get('_highlight')) { + return + } + const featureId = feature.getId() + const key = `${layerId}:${featureId}` + if (seenKeys.has(key)) { + return + } + seenKeys.add(key) + results.push({ + id: featureId, + layer: { id: layerId }, + geometry: geoJsonFormat.writeGeometryObject(feature.getGeometry()), + properties: feature.getProperties() + }) + } else { + // other layer types (e.g. TileLayer) — skip + } + }, + { hitTolerance: radius } + ) + + return results +} diff --git a/providers/beta/openlayers/src/utils/queryFeatures.test.js b/providers/beta/openlayers/src/utils/queryFeatures.test.js new file mode 100644 index 00000000..a510e7ce --- /dev/null +++ b/providers/beta/openlayers/src/utils/queryFeatures.test.js @@ -0,0 +1,181 @@ +import { queryFeatures } from './queryFeatures.js' + +import VectorTileLayer from 'ol/layer/VectorTile.js' +import VectorLayer from 'ol/layer/Vector.js' +import { renderFeatureToGeoJSON } from './vtTileFragments.js' + +jest.mock('ol/layer/VectorTile.js', () => ({ __esModule: true, default: class VectorTileLayer {} })) +jest.mock('ol/layer/Vector.js', () => ({ __esModule: true, default: class VectorLayer {} })) +jest.mock('ol/format/GeoJSON.js', () => ({ + __esModule: true, + default: jest.fn(() => ({ + writeGeometryObject: jest.fn(geom => ({ type: 'mock', geom })) + })) +})) +jest.mock('./vtTileFragments.js', () => ({ + renderFeatureToGeoJSON: jest.fn(f => ({ type: 'mock-vt', feature: f })) +})) + +const makeMap = (hits = []) => ({ + forEachFeatureAtPixel: jest.fn((pixel, cb, opts) => { + for (const [feature, layer] of hits) cb(feature, layer) + }) +}) + +const makeVTLayer = () => Object.assign(new VectorTileLayer(), { get: () => undefined }) + +const makeVTFeature = ({ id = undefined, styleLayerId = 'roads', type = 'fill', props = {} } = {}) => ({ + getId: () => id, + get: (key) => { + if (key === 'mapbox-layer') return { id: styleLayerId, type } + return undefined + }, + getProperties: () => ({ 'mapbox-layer': { id: styleLayerId }, ...props }) +}) + +const makeVectorLayer = (layerId, isHighlight = false) => Object.assign(new VectorLayer(), { + get: (key) => { + if (key === 'layerId') return layerId + if (key === '_highlight') return isHighlight || undefined + return undefined + } +}) + +const makeVectorFeature = (id = 'f1', geom = { type: 'Point' }, props = {}) => ({ + getId: () => id, + getGeometry: () => geom, + getProperties: () => props +}) + +describe('queryFeatures', () => { + beforeEach(() => jest.clearAllMocks()) + + /* ------------------------------------------------------------------ */ + /* Guard */ + /* ------------------------------------------------------------------ */ + + it('returns empty array when point is null', () => { + expect(queryFeatures(makeMap(), null)).toEqual([]) + }) + + it('returns empty array when point is undefined', () => { + expect(queryFeatures(makeMap())).toEqual([]) + }) + + /* ------------------------------------------------------------------ */ + /* Options */ + /* ------------------------------------------------------------------ */ + + it('passes point.x/y as pixel and radius as hitTolerance', () => { + const map = makeMap() + queryFeatures(map, { x: 20, y: 30 }, { radius: 5 }) + expect(map.forEachFeatureAtPixel).toHaveBeenCalledWith( + [20, 30], + expect.any(Function), + { hitTolerance: 5 } + ) + }) + + it('defaults radius to 10', () => { + const map = makeMap() + queryFeatures(map, { x: 0, y: 0 }) + expect(map.forEachFeatureAtPixel).toHaveBeenCalledWith( + expect.anything(), + expect.any(Function), + { hitTolerance: 10 } + ) + }) + + /* ------------------------------------------------------------------ */ + /* VectorTile layer features */ + /* ------------------------------------------------------------------ */ + + it('returns a result for a VT feature', () => { + const feature = makeVTFeature({ id: 42, styleLayerId: 'roads' }) + const map = makeMap([[feature, makeVTLayer()]]) + const results = queryFeatures(map, { x: 0, y: 0 }) + expect(results).toHaveLength(1) + expect(results[0]).toMatchObject({ id: 42, layer: { id: 'roads' } }) + expect(renderFeatureToGeoJSON).toHaveBeenCalledWith(feature) + }) + + it('skips VT features with no mapbox-layer id', () => { + const feature = { getId: () => 1, get: () => undefined, getProperties: () => ({}) } + const map = makeMap([[feature, makeVTLayer()]]) + expect(queryFeatures(map, { x: 0, y: 0 })).toEqual([]) + }) + + it('skips background-type VT features', () => { + const feature = makeVTFeature({ type: 'background' }) + const map = makeMap([[feature, makeVTLayer()]]) + expect(queryFeatures(map, { x: 0, y: 0 })).toEqual([]) + }) + + it('deduplicates VT features with the same styleLayerId and feature id', () => { + const feature = makeVTFeature({ id: 1, styleLayerId: 'roads' }) + const map = makeMap([[feature, makeVTLayer()], [feature, makeVTLayer()]]) + expect(queryFeatures(map, { x: 0, y: 0 })).toHaveLength(1) + }) + + it('uses property hash as id when VT feature has no explicit id', () => { + const feature = makeVTFeature({ id: undefined, styleLayerId: 'roads', props: { name: 'A' } }) + const map = makeMap([[feature, makeVTLayer()]]) + const results = queryFeatures(map, { x: 0, y: 0 }) + expect(results).toHaveLength(1) + expect(results[0].id).toBeUndefined() + }) + + it('deduplicates id-less VT features with identical properties', () => { + const f1 = makeVTFeature({ id: undefined, styleLayerId: 'roads', props: { name: 'A' } }) + const f2 = makeVTFeature({ id: undefined, styleLayerId: 'roads', props: { name: 'A' } }) + const map = makeMap([[f1, makeVTLayer()], [f2, makeVTLayer()]]) + expect(queryFeatures(map, { x: 0, y: 0 })).toHaveLength(1) + }) + + /* ------------------------------------------------------------------ */ + /* Vector layer features */ + /* ------------------------------------------------------------------ */ + + it('returns a result for a Vector layer feature', () => { + const feature = makeVectorFeature('f1', { type: 'Point' }) + const map = makeMap([[feature, makeVectorLayer('draw')]]) + const results = queryFeatures(map, { x: 0, y: 0 }) + expect(results).toHaveLength(1) + expect(results[0]).toMatchObject({ id: 'f1', layer: { id: 'draw' } }) + }) + + it('skips Vector layer features with no layerId', () => { + const feature = makeVectorFeature('f1') + const map = makeMap([[feature, makeVectorLayer(undefined)]]) + expect(queryFeatures(map, { x: 0, y: 0 })).toEqual([]) + }) + + it('skips highlight overlay Vector layers', () => { + const feature = makeVectorFeature('f1') + const map = makeMap([[feature, makeVectorLayer('draw', true)]]) + expect(queryFeatures(map, { x: 0, y: 0 })).toEqual([]) + }) + + it('deduplicates Vector layer features with the same layerId and feature id', () => { + const feature = makeVectorFeature('f1') + const map = makeMap([[feature, makeVectorLayer('draw')], [feature, makeVectorLayer('draw')]]) + expect(queryFeatures(map, { x: 0, y: 0 })).toHaveLength(1) + }) + + /* ------------------------------------------------------------------ */ + /* Mixed / other layers */ + /* ------------------------------------------------------------------ */ + + it('skips features from other layer types', () => { + const feature = makeVectorFeature('f1') + const map = makeMap([[feature, {}]]) // plain object, not VectorTileLayer or VectorLayer + expect(queryFeatures(map, { x: 0, y: 0 })).toEqual([]) + }) + + it('returns results from both VT and Vector layers in one call', () => { + const vtFeature = makeVTFeature({ id: 1, styleLayerId: 'roads' }) + const vecFeature = makeVectorFeature('f1') + const map = makeMap([[vtFeature, makeVTLayer()], [vecFeature, makeVectorLayer('draw')]]) + expect(queryFeatures(map, { x: 0, y: 0 })).toHaveLength(2) + }) +}) diff --git a/providers/beta/openlayers/src/utils/vtTileFragments.js b/providers/beta/openlayers/src/utils/vtTileFragments.js new file mode 100644 index 00000000..593937c4 --- /dev/null +++ b/providers/beta/openlayers/src/utils/vtTileFragments.js @@ -0,0 +1,86 @@ +import VectorTileLayer from 'ol/layer/VectorTile.js' +import TileState from 'ol/TileState.js' +const toPairs = (flat, start, end) => { + const coords = [] + for (let i = start; i < end; i += 2) { + coords.push([flat[i], flat[i + 1]]) + } + return coords +} + +export const renderFeatureToGeoJSON = (feature) => { + const type = feature.getType() + const flat = feature.getFlatCoordinates() + + if (type === 'Point') { + return { type: 'Point', coordinates: [flat[0], flat[1]] } + } + if (type === 'LineString') { + return { type: 'LineString', coordinates: toPairs(flat, 0, flat.length) } + } + if (type === 'Polygon') { + const ends = feature.getEnds() + let prev = 0 + const rings = ends.map(end => { + const ring = toPairs(flat, prev, end) + prev = end + return ring + }) + return { type: 'Polygon', coordinates: rings } + } + if (type === 'MultiPolygon') { + const endss = feature.getEndss() + let offset = 0 + const polys = endss.map(ends => { + let prev = offset + const rings = ends.map(end => { + const ring = toPairs(flat, prev, end) + prev = end + return ring + }) + offset = ends[ends.length - 1] + return rings + }) + return { type: 'MultiPolygon', coordinates: polys } + } + // MultiLineString / MultiPoint fallback + return { type, coordinates: toPairs(flat, 0, flat.length) } +} + +/** + * Collects all loaded VT tile fragments for a feature across all VectorTileLayers. + * OL clips VT features at tile boundaries, so a single logical feature appears as + * multiple RenderFeature instances (one per tile). This gathers them all so callers + * can work with the full feature geometry rather than a single clipped fragment. + */ +export const collectTileFragments = (map, layerId, featureId, idProperty) => { + const fragments = [] + + map.getLayers().forEach(mapLayer => { + if (!(mapLayer instanceof VectorTileLayer)) { + return + } + const source = mapLayer.getSource() + const sourceTiles = source?.sourceTiles_ + if (!sourceTiles) { + return + } + Object.values(sourceTiles).forEach(tile => { + if (tile.getState() !== TileState.LOADED) { + return + } + tile.getFeatures().forEach(feature => { + if (feature.get('mapbox-layer')?.id !== layerId) { + return + } + const fid = idProperty ? feature.get(idProperty) : feature.getId() + if (String(fid) !== String(featureId)) { + return + } + fragments.push(renderFeatureToGeoJSON(feature)) + }) + }) + }) + + return fragments +}