From ca99201317e35691de565883dd0bd46422c090cd Mon Sep 17 00:00:00 2001 From: latin-panda <66472237+latin-panda@users.noreply.github.com> Date: Tue, 20 Jan 2026 22:05:42 +0700 Subject: [PATCH 1/8] removes long press --- .../src/components/common/map/useMapBlock.ts | 2 +- .../common/map/useMapInteractions.ts | 121 +++++------------- 2 files changed, 36 insertions(+), 87 deletions(-) diff --git a/packages/web-forms/src/components/common/map/useMapBlock.ts b/packages/web-forms/src/components/common/map/useMapBlock.ts index 67f6d2cc1..e08e5d44c 100644 --- a/packages/web-forms/src/components/common/map/useMapBlock.ts +++ b/packages/web-forms/src/components/common/map/useMapBlock.ts @@ -193,7 +193,7 @@ export function useMapBlock(config: MapBlockConfig, events: MapBlockEvents) { } if (currentMode.interactions.longPress) { - mapInteractions.setupLongPressPoint(featuresSource, (feature) => + mapInteractions.setupTapToAddVertex(featuresSource, (feature) => handlePointPlacement(feature) ); diff --git a/packages/web-forms/src/components/common/map/useMapInteractions.ts b/packages/web-forms/src/components/common/map/useMapInteractions.ts index 842576a44..56c9fc7aa 100644 --- a/packages/web-forms/src/components/common/map/useMapInteractions.ts +++ b/packages/web-forms/src/components/common/map/useMapInteractions.ts @@ -10,16 +10,13 @@ import { getFlatCoordinates, getVertexIndex, } from '@/components/common/map/vertex-geometry.ts'; -import type { TimerID } from '@getodk/common/types/timers.ts'; import { Collection, Map, MapBrowserEvent } from 'ol'; import type { Coordinate } from 'ol/coordinate'; import Feature from 'ol/Feature'; import { LineString, Point, Polygon } from 'ol/geom'; import { Modify, Translate } from 'ol/interaction'; -import PointerInteraction from 'ol/interaction/Pointer'; import VectorLayer from 'ol/layer/Vector'; import WebGLVectorLayer from 'ol/layer/WebGLVector'; -import type { Pixel } from 'ol/pixel'; import type VectorSource from 'ol/source/Vector'; import { shallowRef } from 'vue'; @@ -35,7 +32,7 @@ export interface UseMapInteractions { removeMapInteractions: () => void; savePreviousFeatureState: (feature: Feature | null) => void; setupFeatureDrag: (layer: VectorLayer, onDrag: (feature: Feature) => void) => void; - setupLongPressPoint: (source: VectorSource, onLongPress: (feature: Feature) => void) => void; + setupTapToAddVertex: (source: VectorSource, onAdd: (feature: Feature) => void) => void; setupMapVisibilityObserver: (mapContainer: HTMLElement, onMapNotVisible: () => void) => void; setupVertexDrag: (source: VectorSource, onDrag: (feature: Feature) => void) => void; teardownMap: () => void; @@ -45,8 +42,7 @@ export interface UseMapInteractions { ) => void; } -const LONG_PRESS_TIME = 1300; -const LONG_PRESS_HIT_TOLERANCE = 20; +const ADD_VERTEX_HIT_TOLERANCE = 20; const SELECT_HIT_TOLERANCE = 15; export function useMapInteractions( @@ -55,7 +51,7 @@ export function useMapInteractions( drawFeatureType: DrawFeatureType | undefined ): UseMapInteractions { const currentLocationObserver = shallowRef(); - const pointerInteraction = shallowRef(); + const tapListener = shallowRef<((event: MapBrowserEvent) => void) | undefined>(); const translateInteraction = shallowRef(); const modifyInteraction = shallowRef(); const previousFeatureState = shallowRef(); @@ -76,7 +72,7 @@ export function useMapInteractions( const removeMapInteractions = () => { toggleSelectEvent(false); - removeLongPressPoint(); + removeTapToAddVertex(); removeFeatureDrag(); removePhantomMiddlePoint(); }; @@ -165,107 +161,60 @@ export function useMapInteractions( } }; - const resolveFeatureForLongPress = ( + const resolveFeatureForTapToAdd = ( coordinate: Coordinate, resolution: number, feature: Feature | undefined ) => { if (drawFeatureType === DRAW_FEATURE_TYPES.SHAPE) { - return addShapeVertex(resolution, coordinate, feature, LONG_PRESS_HIT_TOLERANCE); + return addShapeVertex(resolution, coordinate, feature, ADD_VERTEX_HIT_TOLERANCE); } if (drawFeatureType === DRAW_FEATURE_TYPES.TRACE) { - return addTraceVertex(resolution, coordinate, feature, LONG_PRESS_HIT_TOLERANCE); + return addTraceVertex(resolution, coordinate, feature, ADD_VERTEX_HIT_TOLERANCE); } return new Feature({ geometry: new Point(coordinate) }); }; - const addVertexOnLongPress = ( - source: VectorSource, - coordinate: Coordinate, - onLongPress: (feature: Feature) => void - ) => { - const resolution = mapInstance.getView().getResolution() ?? 1; - const feature = source.getFeatures()?.[0]; - savePreviousFeatureState(feature ?? null); - const updatedFeature = resolveFeatureForLongPress(coordinate, resolution, feature)!; - - if (!drawFeatureType && !source.isEmpty()) { - source.clear(true); - } - - if (source.isEmpty()) { - source.addFeature(updatedFeature); - } - - onLongPress(updatedFeature); - }; + const preventContextMenu = (e: Event) => e.preventDefault(); - const isPressInHitTolerance = (pixel: number[] | undefined, startPixel: Pixel | null) => { - if (!startPixel?.length || !pixel || pixel.length < 2) { - return false; + const setupTapToAddVertex = (source: VectorSource, onAdd: (feature: Feature) => void) => { + if (tapListener.value) { + return; } - const [eventX, eventY] = pixel as [number, number]; - const [startX, startY] = startPixel as [number, number]; - const distanceX = Math.abs(eventX - startX); - const distanceY = Math.abs(eventY - startY); - - return distanceX <= LONG_PRESS_HIT_TOLERANCE && distanceY <= LONG_PRESS_HIT_TOLERANCE; - }; + const listener = (event: MapBrowserEvent) => { + if (event.dragging) { + return; + } - const preventContextMenu = (e: Event) => e.preventDefault(); + const resolution = mapInstance.getView().getResolution() ?? 1; + const feature = source.getFeatures()?.[0]; + savePreviousFeatureState(feature ?? null); + const updatedFeature = resolveFeatureForTapToAdd(event.coordinate, resolution, feature)!; - const setupLongPressPoint = (source: VectorSource, onLongPress: (feature: Feature) => void) => { - if (pointerInteraction.value) { - return; - } + if (!drawFeatureType && !source.isEmpty()) { + source.clear(true); + } - const viewport = mapInstance.getViewport(); - let timer: TimerID | undefined; - let startPixel: Pixel | null = null; - const upListener = () => clearAndRemoveListeners(); - const moveListener = (moveEvent: MapBrowserEvent) => { - if (!startPixel || !isPressInHitTolerance(moveEvent.pixel, startPixel)) { - clearAndRemoveListeners(); + if (source.isEmpty()) { + source.addFeature(updatedFeature); } - }; - const clearAndRemoveListeners = () => { - clearTimeout(timer); - timer = undefined; - startPixel = null; - mapInstance.un('pointermove', moveListener); - viewport.removeEventListener('pointerup', upListener); - }; - pointerInteraction.value = new PointerInteraction({ - handleDownEvent: (event) => { - if (timer) { - clearAndRemoveListeners(); - return false; - } - startPixel = event.pixel; - setCursor('pointer'); - mapInstance.on('pointermove', moveListener); - viewport.addEventListener('pointerup', upListener); - - timer = setTimeout(() => { - clearAndRemoveListeners(); - addVertexOnLongPress(source, event.coordinate, onLongPress); - }, LONG_PRESS_TIME); - return false; - }, - }); + onAdd(updatedFeature); + }; - mapInstance.addInteraction(pointerInteraction.value); - viewport.addEventListener('contextmenu', preventContextMenu); + tapListener.value = listener; + mapInstance.on('click', listener); + mapInstance.getViewport().addEventListener('contextmenu', preventContextMenu); }; - const removeLongPressPoint = () => { - if (pointerInteraction.value) { - mapInstance.removeInteraction(pointerInteraction.value); - pointerInteraction.value = undefined; + const removeTapToAddVertex = () => { + if (tapListener.value) { + mapInstance.un('click', tapListener.value); + tapListener.value = undefined; + setCursor('default'); } }; @@ -359,7 +308,7 @@ export function useMapInteractions( removeMapInteractions, savePreviousFeatureState, setupFeatureDrag, - setupLongPressPoint, + setupTapToAddVertex, setupMapVisibilityObserver, setupVertexDrag, teardownMap, From cf60ffee5ff922fa7aa2da5b0123a592518f72d5 Mon Sep 17 00:00:00 2001 From: latin-panda <66472237+latin-panda@users.noreply.github.com> Date: Tue, 20 Jan 2026 23:44:50 +0700 Subject: [PATCH 2/8] prevent adding points in area or in vertex --- .../common/map/useMapInteractions.ts | 33 ++++++++++++--- .../components/common/map/vertex-geometry.ts | 42 +++++++++++++++++++ 2 files changed, 70 insertions(+), 5 deletions(-) diff --git a/packages/web-forms/src/components/common/map/useMapInteractions.ts b/packages/web-forms/src/components/common/map/useMapInteractions.ts index 56c9fc7aa..220ab63ec 100644 --- a/packages/web-forms/src/components/common/map/useMapInteractions.ts +++ b/packages/web-forms/src/components/common/map/useMapInteractions.ts @@ -9,6 +9,8 @@ import { addTraceVertex, getFlatCoordinates, getVertexIndex, + isNearVertex, + isOnFeatureEdge, } from '@/components/common/map/vertex-geometry.ts'; import { Collection, Map, MapBrowserEvent } from 'ol'; import type { Coordinate } from 'ol/coordinate'; @@ -17,6 +19,7 @@ import { LineString, Point, Polygon } from 'ol/geom'; import { Modify, Translate } from 'ol/interaction'; import VectorLayer from 'ol/layer/Vector'; import WebGLVectorLayer from 'ol/layer/WebGLVector'; +import type { Pixel } from 'ol/pixel'; import type VectorSource from 'ol/source/Vector'; import { shallowRef } from 'vue'; @@ -56,6 +59,13 @@ export function useMapInteractions( const modifyInteraction = shallowRef(); const previousFeatureState = shallowRef(); + const getVectorFeaturesAtPixel = (pixel: Pixel) => { + return mapInstance.getFeaturesAtPixel(pixel, { + layerFilter: (layer) => layer instanceof VectorLayer, + hitTolerance: SELECT_HIT_TOLERANCE, + }); + }; + const setupMapVisibilityObserver = (mapContainer: HTMLElement, onMapNotVisible: () => void) => { if ('IntersectionObserver' in window) { currentLocationObserver.value = new IntersectionObserver( @@ -108,10 +118,7 @@ export function useMapInteractions( event: MapBrowserEvent, onSelect: (feature: Feature | undefined, selectedVertexIndex: number | undefined) => void ): void => { - const hitFeatures = mapInstance.getFeaturesAtPixel(event.pixel, { - layerFilter: (layer) => layer instanceof VectorLayer, - hitTolerance: SELECT_HIT_TOLERANCE, - }); + const hitFeatures = getVectorFeaturesAtPixel(event.pixel); const selectedFeature = hitFeatures?.find((item) => { const geometry = item.getGeometry(); @@ -189,10 +196,26 @@ export function useMapInteractions( return; } + const eventCoords = event.coordinate; const resolution = mapInstance.getView().getResolution() ?? 1; + const hitFeatures = getVectorFeaturesAtPixel(event.pixel); + const targetFeature = hitFeatures.find((f) => source.hasFeature(f as Feature)) as + | Feature + | undefined; + + if (targetFeature) { + if (isNearVertex(targetFeature, eventCoords, resolution, ADD_VERTEX_HIT_TOLERANCE)) { + return; + } + + if (!isOnFeatureEdge(targetFeature, eventCoords, resolution, ADD_VERTEX_HIT_TOLERANCE)) { + return; + } + } + const feature = source.getFeatures()?.[0]; savePreviousFeatureState(feature ?? null); - const updatedFeature = resolveFeatureForTapToAdd(event.coordinate, resolution, feature)!; + const updatedFeature = resolveFeatureForTapToAdd(eventCoords, resolution, feature)!; if (!drawFeatureType && !source.isEmpty()) { source.clear(true); diff --git a/packages/web-forms/src/components/common/map/vertex-geometry.ts b/packages/web-forms/src/components/common/map/vertex-geometry.ts index 926915816..d006947c2 100644 --- a/packages/web-forms/src/components/common/map/vertex-geometry.ts +++ b/packages/web-forms/src/components/common/map/vertex-geometry.ts @@ -91,6 +91,48 @@ const isOnLine = (squaredDist: number, resolution: number, hitTolerance: number) return squaredDist <= tolerance ** 2; }; +export const isNearVertex = ( + feature: Feature | undefined, + point: Coordinate, + resolution: number, + hitTolerance: number +): boolean => { + const geometry = feature?.getGeometry(); + if (!geometry || (!(geometry instanceof LineString) && !(geometry instanceof Polygon))) { + return false; + } + + return getFlatCoordinates(geometry).some((vertex) => { + const { deltaX, deltaY } = getDeltaBetweenPoints(point, vertex); + const squaredDist = getDistanceBetweenPoints(deltaX, deltaY); + return isOnLine(squaredDist, resolution, hitTolerance); + }); +}; + +export const isOnFeatureEdge = ( + feature: Feature | undefined, + point: Coordinate, + resolution: number, + hitTolerance: number +): boolean => { + const geometry = feature?.getGeometry(); + if (geometry instanceof LineString) { + return true; + } + + if (geometry instanceof Polygon) { + const ring = getFlatCoordinates(geometry); + if (ring.length < 2) { + return false; + } + + const { squaredDist } = getClosestSegmentAndIndex(ring, point); + return isOnLine(squaredDist, resolution, hitTolerance); + } + + return false; +}; + export const addTraceVertex = ( resolution: number, newVertex: Coordinate, From b8623105c498f70c7e92412be70aa36f29df8ae4 Mon Sep 17 00:00:00 2001 From: latin-panda <66472237+latin-panda@users.noreply.github.com> Date: Wed, 21 Jan 2026 01:08:30 +0700 Subject: [PATCH 3/8] Fixes selection --- .../components/common/map/useMapFeatures.ts | 1 + .../common/map/useMapInteractions.ts | 29 ++++++++++++------- 2 files changed, 20 insertions(+), 10 deletions(-) diff --git a/packages/web-forms/src/components/common/map/useMapFeatures.ts b/packages/web-forms/src/components/common/map/useMapFeatures.ts index 050476010..e922eef3d 100644 --- a/packages/web-forms/src/components/common/map/useMapFeatures.ts +++ b/packages/web-forms/src/components/common/map/useMapFeatures.ts @@ -84,6 +84,7 @@ export function useMapFeatures( const selectFeature = (feature: Feature | undefined, vertexIndex?: number) => { if (capabilities.canSelectFeatureOrVertex) { selectedFeature.value?.set(IS_SELECTED_PROPERTY, false); + selectedFeature.value?.set(SELECTED_VERTEX_INDEX_PROPERTY, undefined); feature?.set(SELECTED_VERTEX_INDEX_PROPERTY, vertexIndex); feature?.set(IS_SELECTED_PROPERTY, true); } diff --git a/packages/web-forms/src/components/common/map/useMapInteractions.ts b/packages/web-forms/src/components/common/map/useMapInteractions.ts index 220ab63ec..f440936a4 100644 --- a/packages/web-forms/src/components/common/map/useMapInteractions.ts +++ b/packages/web-forms/src/components/common/map/useMapInteractions.ts @@ -120,25 +120,34 @@ export function useMapInteractions( ): void => { const hitFeatures = getVectorFeaturesAtPixel(event.pixel); - const selectedFeature = hitFeatures?.find((item) => { + const targetFeature = hitFeatures?.find((item) => { const geometry = item.getGeometry(); return geometry instanceof Polygon || geometry instanceof LineString; }) as Feature | undefined; - if (!selectedFeature) { + if (!targetFeature) { onSelect?.(undefined, undefined); return; } - const coords = getFlatCoordinates(selectedFeature.getGeometry()); - if (coords.length === 1) { - onSelect?.(selectedFeature, 0); - return; - } + const isSinglePoint = getFlatCoordinates(targetFeature.getGeometry()).length === 1; + const hitVertexFeature = hitFeatures.find((item) => { + return item.getGeometry() instanceof Point; + }) as Feature | undefined; + + const targetVertexIndex = isSinglePoint ? 0 : getVertexIndex(targetFeature, hitVertexFeature); + const isTapOnVertex = targetVertexIndex != null; + const currentVertexIndex = targetFeature.get(SELECTED_VERTEX_INDEX_PROPERTY) as number; + const isSameVertex = isTapOnVertex && currentVertexIndex === targetVertexIndex; - const vertexToSelect = hitFeatures.find((item) => item.getGeometry() instanceof Point); - const index = getVertexIndex(selectedFeature, vertexToSelect as Feature | undefined); - onSelect?.(selectedFeature, index); + const isCurrentFeatureSelected = !!targetFeature.get(IS_SELECTED_PROPERTY); + const isSameBody = !isTapOnVertex && isCurrentFeatureSelected; + + if (isSameVertex || isSameBody) { + onSelect?.(undefined, undefined); + } else { + onSelect?.(targetFeature, targetVertexIndex); + } }; const toggleSelectEvent = ( From 20ba4711b4b0103848176ae3586b5b4597ac1ad9 Mon Sep 17 00:00:00 2001 From: latin-panda <66472237+latin-panda@users.noreply.github.com> Date: Tue, 27 Jan 2026 00:03:53 +0700 Subject: [PATCH 4/8] removes double click zoom and fixes status bar message --- .../src/components/common/map/MapBlock.vue | 4 +- .../components/common/map/MapStatusBar.vue | 39 +++++++++++-------- .../src/components/common/map/useMapBlock.ts | 4 +- 3 files changed, 27 insertions(+), 20 deletions(-) diff --git a/packages/web-forms/src/components/common/map/MapBlock.vue b/packages/web-forms/src/components/common/map/MapBlock.vue index eecdd4925..46617ec85 100644 --- a/packages/web-forms/src/components/common/map/MapBlock.vue +++ b/packages/web-forms/src/components/common/map/MapBlock.vue @@ -182,8 +182,8 @@ const saveAdvancedPanelCoords = (newCoords: Coordinate) => { :class="{ 'map-message': true, 'above-secondary-controls': showSecondaryControls }" > - Press and drag to move a point - Long press to place a point + Tap to place a point + Press and drag to move a point diff --git a/packages/web-forms/src/components/common/map/MapStatusBar.vue b/packages/web-forms/src/components/common/map/MapStatusBar.vue index 24b3e402e..4e74804b4 100644 --- a/packages/web-forms/src/components/common/map/MapStatusBar.vue +++ b/packages/web-forms/src/components/common/map/MapStatusBar.vue @@ -133,6 +133,19 @@ const savedStatus = computed(() => { return { message: 'Point saved', icon: 'mdiCheckCircle', highlight: true }; }); + +const displayState = computed(() => { + const baseState = savedStatus.value ?? noSavedStatus.value; + const displayText = selectedVertexInfo.value.length + ? selectedVertexInfo.value + : baseState.message; + + return { + icon: baseState.icon, + highlight: !!baseState.highlight, + text: displayText, + }; +});