Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
528da69
initial version
karussell Aug 20, 2026
c7200ea
simplify
karussell Aug 20, 2026
8fa6345
transparent when dragging
karussell Aug 20, 2026
0562e5c
drag the route to add a via point
karussell Aug 20, 2026
ff7f63f
do not show transparent dragging circle near via markers
karussell Aug 20, 2026
72a33a7
simplify
karussell Aug 20, 2026
b5cdce8
try more consistent drag styling
karussell Aug 20, 2026
08f9f55
simplify and make really same
karussell Aug 20, 2026
fdcdad0
show via point number when dragging
karussell Aug 20, 2026
90eefe7
and a bit more consistency
karussell Aug 20, 2026
566e10f
dragging should only make line to source coordinates dashed
karussell Aug 20, 2026
053f3cb
add 'remove marker' to context menu
karussell Aug 20, 2026
9ab7f74
same dashed line when dragging for start/destination marker
karussell Aug 20, 2026
89edfe9
Merge branch 'master' into via_marker
karussell Aug 20, 2026
60b6791
fix delete i18n
karussell Aug 20, 2026
b7adf0d
fix bug for when dragging via markers
karussell Aug 20, 2026
cb41039
improve hovering lagging
karussell Aug 20, 2026
96a0ed7
Revert "improve hovering lagging"
karussell Aug 20, 2026
11d518e
reduce context menu when removing; cleanup
karussell Aug 20, 2026
2fa41cf
add via point on route via single click
karussell Aug 20, 2026
05ce45a
double click on route (eg used for zoom on mobile) shouldnt create vi…
karussell Aug 20, 2026
215b201
click on an overlapping alternative route part should still create vi…
karussell Aug 20, 2026
876bd9c
avoid problems with POI context menu vs creating markers on a route
karussell Aug 20, 2026
f1b27c4
long touch on route shouldnt create via markers and just open ctx menu
karussell Aug 20, 2026
95a804e
more consistent reset of gh:dragging
karussell Aug 21, 2026
4763f84
bug fix if route with error gets a new via point
karussell Aug 21, 2026
38aa207
simplify
karussell Aug 21, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 23 additions & 8 deletions src/layers/ContextMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,12 @@ import { Feature, Map, MapBrowserEvent, Overlay } from 'ol'
import { ContextMenuContent } from '@/map/ContextMenuContent'
import { useEffect, useRef, useState } from 'react'
import { QueryPoint } from '@/stores/QueryStore'
import { fromLonLat, toLonLat } from 'ol/proj'
import { toLonLat } from 'ol/proj'
import styles from '@/layers/ContextMenu.module.css'
import { RouteStoreState } from '@/stores/RouteStore'
import { Coordinate } from '@/utils'
import { markerFeatureAtPixel } from '@/layers/UseQueryPointsLayer'
import { viaPointClickKey } from '@/layers/UsePathsLayer'

interface ContextMenuProps {
map: Map
Expand All @@ -19,24 +21,35 @@ const overlay = new Overlay({

export default function ContextMenu({ map, route, queryPoints }: ContextMenuProps) {
const [menuCoordinate, setMenuCoordinate] = useState<Coordinate | null>(null)
// set when the menu was opened on a query point marker, adds a 'delete' entry to the menu
const [markedQueryPoint, setMarkedQueryPoint] = useState<QueryPoint | null>(null)
const container = useRef<HTMLDivElement | null>(null)
// mirror of menuCoordinate for use in the map listeners which are registered only once
const isOpen = useRef(false)
// set when the menu was opened via long touch: OpenLayers fires a 'singleclick' when the finger is lifted
// afterwards (unlike the native 'click' event this is not suppressed by the browser) and it must not close the menu
const openedByLongTouch = useRef(false)

// returns the query point of the marker at the given pixel (if there is one) so the menu can offer deleting it
const queryPointAtPixel = (pixel: number[]): QueryPoint | null =>
markerFeatureAtPixel(map, pixel, 5)?.get('gh:query_point') ?? null

const openContextMenu = (e: any) => {
e.preventDefault()
const coordinate = map.getEventCoordinate(e)
const lonLat = toLonLat(coordinate)
isOpen.current = true
// set the position synchronously (not via an effect), other click listeners check it, see UsePathsLayer
overlay.setPosition(coordinate)
setMarkedQueryPoint(queryPointAtPixel(map.getEventPixel(e)))
setMenuCoordinate({ lng: lonLat[0], lat: lonLat[1] })
}

const closeContextMenu = () => {
isOpen.current = false
overlay.setPosition(undefined)
setMenuCoordinate(null)
setMarkedQueryPoint(null)
}

// 'singleclick' is only fired for a plain click, i.e. not when the map was panned and not for double clicks
Expand All @@ -50,9 +63,14 @@ export default function ContextMenu({ map, route, queryPoints }: ContextMenuProp
closeContextMenu()
return
}
// do not open the menu when clicking interactive features (POIs, paths, markers), they handle clicks themselves
const atFeature = map.getFeaturesAtPixel(e.pixel, { hitTolerance: 5 }).some(f => f instanceof Feature)
if (atFeature) return
// this click adds a via point on the route -> do not open the menu on the new marker
if ((e.originalEvent as any)[viaPointClickKey]) return
// clicking a query point marker opens the menu (with a 'remove' entry), but do not open it when clicking
// other interactive features (POIs, paths), they handle clicks themselves
if (!queryPointAtPixel(e.pixel)) {
const atFeature = map.getFeaturesAtPixel(e.pixel, { hitTolerance: 5 }).some(f => f instanceof Feature)
if (atFeature) return
}
openContextMenu(e.originalEvent)
}

Expand Down Expand Up @@ -100,17 +118,14 @@ export default function ContextMenu({ map, route, queryPoints }: ContextMenuProp
}
}, [map])

useEffect(() => {
overlay.setPosition(menuCoordinate ? fromLonLat([menuCoordinate.lng, menuCoordinate.lat]) : undefined)
}, [menuCoordinate])

return (
<div className={styles.contextMenu} ref={container}>
{menuCoordinate && (
<ContextMenuContent
coordinate={menuCoordinate!}
queryPoints={queryPoints}
route={route}
markedQueryPoint={markedQueryPoint}
onSelect={closeContextMenu}
/>
)}
Expand Down
11 changes: 8 additions & 3 deletions src/layers/UseBackgroundLayer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,14 @@ export default function useBackgroundLayer(map: Map, styleOption: StyleOption) {
useEffect(() => {
const onPointerMove = (evt: any) => {
if (evt.dragging) return // skip expensive hit-test while panning
const features = map.getFeaturesAtPixel(evt.pixel)
const atFeature = features.some(f => f instanceof Feature)
map.getTargetElement().style.cursor = atFeature ? 'pointer' : 'default'
let cursor = 'default'
map.forEachFeatureAtPixel(evt.pixel, (feature, layer) => {
if (!(feature instanceof Feature)) return
// query point markers can be dragged -> hand cursor, other features are just clickable
cursor = layer?.get('gh:query_points') ? 'grab' : 'pointer'
return true // stop at the topmost feature
})
map.getTargetElement().style.cursor = cursor
}
map.on('pointermove', onPointerMove)
return () => {
Expand Down
2 changes: 1 addition & 1 deletion src/layers/UsePOIsLayer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ function removePOIs(map: Map) {
}

function addPOISelection(map: Map) {
const select = new Select()
const select = new Select({ layers: l => l.get('gh:pois') })
map.addInteraction(select)
select.on('select', event => {
const selectedFeatures = event.selected
Expand Down
218 changes: 189 additions & 29 deletions src/layers/UsePathsLayer.tsx
Original file line number Diff line number Diff line change
@@ -1,22 +1,30 @@
import { Feature, Map } from 'ol'
import { unByKey } from 'ol/Observable'
import { Path } from '@/api/graphhopper'
import { useEffect } from 'react'
import VectorLayer from 'ol/layer/Vector'
import VectorSource from 'ol/source/Vector'
import { Stroke, Style } from 'ol/style'
import { fromLonLat } from 'ol/proj'
import { Select } from 'ol/interaction'
import { Icon, Stroke, Style } from 'ol/style'
import { fromLonLat, toLonLat } from 'ol/proj'
import { Modify, Select } from 'ol/interaction'
import { click } from 'ol/events/condition'
import Dispatcher from '@/stores/Dispatcher'
import { SetSelectedPath } from '@/actions/Actions'
import { AddPoint, SetPoint, SetSelectedPath } from '@/actions/Actions'
import { coordinateToText } from '@/Converters'
import { SelectEvent } from 'ol/interaction/Select'
import { QueryPoint } from '@/stores/QueryStore'
import QueryStore, { QueryPoint, QueryPointType } from '@/stores/QueryStore'
import { distance } from 'ol/coordinate'
import LineString from 'ol/geom/LineString'
import { createCircle } from '@/layers/createMarkerSVG'
import { dashedLineStroke, markerFeatureAtPixel, VIA_MARKER_SIZE } from '@/layers/UseQueryPointsLayer'
import { findNextWayPoint } from '@/map/findNextWayPoint'
import Point from 'ol/geom/Point'

const pathsLayerKey = 'pathsLayer'
const selectedPathLayerKey = 'selectedPathLayer'
const accessNetworkLayerKey = 'accessNetworkLayer'
// set on a click's browser event when that click adds a via point, so e.g. the ContextMenu ignores the click
export const viaPointClickKey = 'gh:via_point_click'

export default function usePathsLayer(
map: Map,
Expand All @@ -27,16 +35,19 @@ export default function usePathsLayer(
) {
useEffect(() => {
removeCurrentPathLayers(map)
removeRouteDragInteractions(map)
if (showPaths) {
addUnselectedPathsLayer(
map,
paths.filter(p => p != selectedPath),
)
addSelectedPathsLayer(map, selectedPath)
addAccessNetworkLayer(map, selectedPath, queryPoints)
addRouteDragInteraction(map, selectedPath, queryPoints)
}
return () => {
removeCurrentPathLayers(map)
removeRouteDragInteractions(map)
}
}, [map, paths, selectedPath, showPaths, queryPoints])
}
Expand Down Expand Up @@ -125,19 +136,10 @@ function createBezierLineString(start: number[], end: number[]): LineString {
}

function addAccessNetworkLayer(map: Map, selectedPath: Path, queryPoints: QueryPoint[]) {
const style = new Style({
stroke: new Stroke({
color: 'rgba(143,183,241,0.9)',
width: 5,
lineDash: [1, 10],
lineCap: 'round',
lineJoin: 'round',
}),
})
const layer = new VectorLayer({
source: new VectorSource(),
})
layer.setStyle(style)
layer.setStyle(new Style({ stroke: dashedLineStroke }))
for (let i = 0; i < selectedPath.snapped_waypoints.coordinates.length; i++) {
if (i >= queryPoints.length) break // can happen if deleted too fast
const start = fromLonLat([queryPoints[i].coordinate.lng, queryPoints[i].coordinate.lat])
Expand All @@ -149,33 +151,191 @@ function addAccessNetworkLayer(map: Map, selectedPath: Path, queryPoints: QueryP
map.addLayer(layer)
}

function addSelectedPathsLayer(map: Map, selectedPath: Path) {
const styleArray = [
new Style({
stroke: new Stroke({
color: 'rgba(255,255,255,0.9)',
width: 10,
}),
const selectedPathStyle = [
new Style({
stroke: new Stroke({
color: 'rgba(255,255,255,0.9)',
width: 10,
}),
new Style({
stroke: new Stroke({
color: 'rgba(39,100,200,0.85)',
width: 8,
}),
}),
new Style({
stroke: new Stroke({
color: 'rgba(39,100,200,0.85)',
width: 8,
}),
]
}),
]

function addSelectedPathsLayer(map: Map, selectedPath: Path) {
const layer = new VectorLayer({
source: new VectorSource({
features: [new Feature(new LineString(selectedPath.points.coordinates.map(c => fromLonLat(c))))],
}),
style: styleArray,
style: selectedPathStyle,
opacity: 0.8,
zIndex: 2,
})
layer.set(selectedPathLayerKey, true)
map.addLayer(layer)
}

/**
* Pointing at the selected route pops up a via circle that can be dragged to create a new via point there, and
* dragging a via marker moves it. This uses the Modify interaction which finds the closest segment with a
* spatial index, i.e. hovering stays cheap even for long routes. It works on an invisible copy of the route so
* that the displayed route keeps its style — while dragging, only a dashed line from the old to the new
* location is shown.
*/
function addRouteDragInteraction(map: Map, selectedPath: Path, queryPoints: QueryPoint[]) {
if (selectedPath.points.coordinates.length < 2 || selectedPath.snapped_waypoints.coordinates.length < 2) return
// on pointer down Modify inserts a vertex into this invisible copy to have something to drag — harmless,
// it lies exactly on the line and does not become a via point
const routeLine = new LineString(selectedPath.points.coordinates.map(c => fromLonLat(c)))
const source: VectorSource = new VectorSource({
features: [new Feature(routeLine)],
})
// add the via points too, as Modify only starts close to the route and could not grab a marker
// that is far away from it (large snapping distance)
queryPoints
.filter(p => p.isInitialized && p.type === QueryPointType.Via)
.forEach(p => source.addFeature(new Feature(new Point(fromLonLat([p.coordinate.lng, p.coordinate.lat])))))
// The query point marker at the given pixel, if any. From/to markers are dragged with their own
// interaction (see UseBackgroundLayer+UseQueryPointsLayer), via markers with THIS one, so moving
// them bends the route just like dragging the route itself.
const markerFeatureAt = (pixel: number[]) => markerFeatureAtPixel(map, pixel, 2)
// the transparent via circle, with the number of the dragged via marker (or none when creating a new one)
const circleStyle = (number?: number) =>
new Style({
image: new Icon({
src:
'data:image/svg+xml;utf8,' +
createCircle({
color: QueryStore.getMarkerColor(QueryPointType.Via),
number,
size: VIA_MARKER_SIZE,
}),
opacity: 0.5,
}),
})
const style = circleStyle()
let dragStyle = style
// the dashed line from the old to the new location while dragging
const dragLineStyle = new Style({ stroke: dashedLineStroke })
let dragging = false
const modify = new Modify({
source: source,
style: feature => {
const position = (feature.getGeometry() as Point).getCoordinates()
if (dragging) {
dragLineStyle.setGeometry(new LineString([downPosition, position]))
return [dragStyle, dragLineStyle]
}
return !markerFeatureAt(map.getPixelFromCoordinate(position)) ? style : []
},
condition: e => {
const feature = markerFeatureAt(e.pixel)
return feature === undefined || feature.get('gh:query_point')?.type === QueryPointType.Via
},
})
let downPixel = [0, 0]
let downPosition: number[] = []
let downCoordinate = { lng: 0, lat: 0 }
let grabbedViaFeature: Feature | undefined = undefined
modify.on('modifystart', e => {
dragging = true
downPixel = e.mapBrowserEvent.pixel
downPosition = e.mapBrowserEvent.coordinate
const lonLat = toLonLat(e.mapBrowserEvent.coordinate)
downCoordinate = { lng: lonLat[0], lat: lonLat[1] }
// due to the condition above this can only be a via marker: hide it, the dragged (numbered)
// circle replaces it and the dashed line starts at its exact old location
grabbedViaFeature = markerFeatureAt(downPixel)
grabbedViaFeature?.set('gh:hidden', true)
if (grabbedViaFeature) downPosition = (grabbedViaFeature.getGeometry() as Point).getCoordinates()
const number = grabbedViaFeature?.get('gh:marker_props')?.number
dragStyle = number === undefined ? style : circleStyle(number)
// hide the cursor while dragging for more precise placement, like for via circles
map.getViewport().style.cursor = 'none'
})
modify.on('modifyend', e => {
dragging = false
map.getViewport().style.cursor = 'default'
const grabbedViaPoint = grabbedViaFeature?.get('gh:query_point')
grabbedViaFeature?.set('gh:hidden', false)
grabbedViaFeature = undefined
const pixel = e.mapBrowserEvent.pixel
// clicks are handled below, a drag creates or moves a via point
if (Math.abs(pixel[0] - downPixel[0]) <= 2 && Math.abs(pixel[1] - downPixel[1]) <= 2) return
const lonLat = toLonLat(e.mapBrowserEvent.coordinate)
const coordinate = { lng: lonLat[0], lat: lonLat[1] }
if (grabbedViaPoint) {
// the drag started on a via marker -> move it
Dispatcher.dispatch(
new SetPoint({ ...grabbedViaPoint, coordinate, queryText: coordinateToText(coordinate) }, false),
)
return
}
addViaPoint(coordinate, downCoordinate)
})
// inserts a new via point, into the route leg closest to `near`: for a drag this must be where it
// started — the drop position could be closer to another leg
const addViaPoint = (coordinate: { lng: number; lat: number }, near: { lng: number; lat: number }) => {
const route = {
coordinates: selectedPath.points.coordinates.map(c => ({ lng: c[0], lat: c[1] })),
wayPoints: selectedPath.snapped_waypoints.coordinates.map(c => ({ lng: c[0], lat: c[1] })),
}
const index = findNextWayPoint([route], near).nextWayPoint
Dispatcher.dispatch(new AddPoint(index, coordinate, true, false))
}
// A click on the route adds a via point exactly on it. It is evaluated on 'click' (fired on pointer up,
// i.e. not after panning or dragging) but only added on the matching 'singleclick' (same originalEvent),
// which OpenLayers does not fire for a double click (zoom). Where an alternative route overlaps the
// selected one the via point wins: the click is consumed with stopPropagation before the Select
// interaction could switch routes.
const clickKey = map.on('click', e => {
// while the context menu or a popup is open (e.g. opened via long touch on the route) a click only closes it
if (
map
.getOverlays()
.getArray()
.some(o => o.getPosition() !== undefined)
)
return
// markers (context menu) and POIs (popup) handle clicks themselves
if (markerFeatureAt(e.pixel)) return
if (map.forEachFeatureAtPixel(e.pixel, () => true, { layerFilter: l => l.get('gh:pois') })) return
const closest = routeLine.getClosestPoint(e.coordinate)
const closestPixel = map.getPixelFromCoordinate(closest)
// same distance to the route within which the hover circle is shown (Modify's pixel tolerance)
if (Math.hypot(closestPixel[0] - e.pixel[0], closestPixel[1] - e.pixel[1]) > 10) return
e.stopPropagation()
const lonLat = toLonLat(closest)
const clickLonLat = toLonLat(e.coordinate)
// also tells the ContextMenu to not open on the new marker
;(e.originalEvent as any)[viaPointClickKey] = {
coordinate: { lng: lonLat[0], lat: lonLat[1] },
near: { lng: clickLonLat[0], lat: clickLonLat[1] },
}
})
const singleClickKey = map.on('singleclick', e => {
const viaPointClick = (e.originalEvent as any)[viaPointClickKey]
if (viaPointClick) addViaPoint(viaPointClick.coordinate, viaPointClick.near)
})
modify.set('gh:drag_path_interaction', true)
modify.set('gh:route_click_keys', [clickKey, singleClickKey])
map.addInteraction(modify)
}

function removeRouteDragInteractions(map: Map) {
map.getInteractions()
.getArray()
.filter(i => i.get('gh:drag_path_interaction'))
.forEach(i => {
unByKey(i.get('gh:route_click_keys'))
map.removeInteraction(i)
})
}

function removeSelectPathInteractions(map: Map) {
map.getInteractions()
.getArray()
Expand Down
Loading