diff --git a/src/layers/ContextMenu.tsx b/src/layers/ContextMenu.tsx index 816184a0..f402695d 100644 --- a/src/layers/ContextMenu.tsx +++ b/src/layers/ContextMenu.tsx @@ -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 @@ -19,6 +21,8 @@ const overlay = new Overlay({ export default function ContextMenu({ map, route, queryPoints }: ContextMenuProps) { const [menuCoordinate, setMenuCoordinate] = useState(null) + // set when the menu was opened on a query point marker, adds a 'delete' entry to the menu + const [markedQueryPoint, setMarkedQueryPoint] = useState(null) const container = useRef(null) // mirror of menuCoordinate for use in the map listeners which are registered only once const isOpen = useRef(false) @@ -26,17 +30,26 @@ export default function ContextMenu({ map, route, queryPoints }: ContextMenuProp // 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 @@ -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) } @@ -100,10 +118,6 @@ export default function ContextMenu({ map, route, queryPoints }: ContextMenuProp } }, [map]) - useEffect(() => { - overlay.setPosition(menuCoordinate ? fromLonLat([menuCoordinate.lng, menuCoordinate.lat]) : undefined) - }, [menuCoordinate]) - return (
{menuCoordinate && ( @@ -111,6 +125,7 @@ export default function ContextMenu({ map, route, queryPoints }: ContextMenuProp coordinate={menuCoordinate!} queryPoints={queryPoints} route={route} + markedQueryPoint={markedQueryPoint} onSelect={closeContextMenu} /> )} diff --git a/src/layers/UseBackgroundLayer.tsx b/src/layers/UseBackgroundLayer.tsx index 376d866b..60afb051 100644 --- a/src/layers/UseBackgroundLayer.tsx +++ b/src/layers/UseBackgroundLayer.tsx @@ -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 () => { diff --git a/src/layers/UsePOIsLayer.tsx b/src/layers/UsePOIsLayer.tsx index ce9a504e..2f2eb643 100644 --- a/src/layers/UsePOIsLayer.tsx +++ b/src/layers/UsePOIsLayer.tsx @@ -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 diff --git a/src/layers/UsePathsLayer.tsx b/src/layers/UsePathsLayer.tsx index 474016a2..1f1446e4 100644 --- a/src/layers/UsePathsLayer.tsx +++ b/src/layers/UsePathsLayer.tsx @@ -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, @@ -27,6 +35,7 @@ export default function usePathsLayer( ) { useEffect(() => { removeCurrentPathLayers(map) + removeRouteDragInteractions(map) if (showPaths) { addUnselectedPathsLayer( map, @@ -34,9 +43,11 @@ export default function usePathsLayer( ) addSelectedPathsLayer(map, selectedPath) addAccessNetworkLayer(map, selectedPath, queryPoints) + addRouteDragInteraction(map, selectedPath, queryPoints) } return () => { removeCurrentPathLayers(map) + removeRouteDragInteractions(map) } }, [map, paths, selectedPath, showPaths, queryPoints]) } @@ -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]) @@ -149,26 +151,27 @@ 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, }) @@ -176,6 +179,163 @@ function addSelectedPathsLayer(map: Map, selectedPath: Path) { 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() diff --git a/src/layers/UseQueryPointsLayer.tsx b/src/layers/UseQueryPointsLayer.tsx index 398807c6..6d9a3ae9 100644 --- a/src/layers/UseQueryPointsLayer.tsx +++ b/src/layers/UseQueryPointsLayer.tsx @@ -3,16 +3,35 @@ import { QueryPoint, QueryPointType } from '@/stores/QueryStore' import { useEffect } from 'react' import VectorLayer from 'ol/layer/Vector' import VectorSource from 'ol/source/Vector' -import { Geometry, Point } from 'ol/geom' +import { Geometry, LineString, Point } from 'ol/geom' import { fromLonLat, toLonLat } from 'ol/proj' import { Modify } from 'ol/interaction' import Dispatcher from '@/stores/Dispatcher' import { SetPoint } from '@/actions/Actions' import { coordinateToText } from '@/Converters' -import { Icon, Style } from 'ol/style' +import { Icon, Stroke, Style } from 'ol/style' import { createSvg } from '@/layers/createMarkerSVG' const MARKER_SIZE = 35 +export const VIA_MARKER_SIZE = 23 + +// thin dashed line, used for the access network lines and from the old to the new location while dragging +// markers or the route +export const dashedLineStroke = new Stroke({ + color: 'rgba(143,183,241,0.9)', + width: 5, + lineDash: [1, 10], + lineCap: 'round', + lineJoin: 'round', +}) + +// the query point marker feature at the given pixel, if any +export function markerFeatureAtPixel(map: Map, pixel: number[], hitTolerance: number) { + return map.forEachFeatureAtPixel(pixel, f => f, { + layerFilter: l => l.get('gh:query_points'), + hitTolerance, + }) as Feature | undefined +} export default function useQueryPointsLayer(map: Map, queryPoints: QueryPoint[]) { useEffect(() => { @@ -36,19 +55,18 @@ function removeQueryPoints(map: Map) { function addQueryPointsLayer(map: Map, queryPoints: QueryPoint[]) { const features: Feature[] = queryPoints + .filter(point => point.isInitialized) .map((point, i) => { - return { index: i, point: point } - }) - .filter(indexPoint => indexPoint.point.isInitialized) - .map((indexPoint, i) => { const feature = new Feature({ - geometry: new Point(fromLonLat([indexPoint.point.coordinate.lng, indexPoint.point.coordinate.lat])), + geometry: new Point(fromLonLat([point.coordinate.lng, point.coordinate.lat])), }) - feature.set('gh:query_point', indexPoint.point) + const isVia = point.type == QueryPointType.Via + feature.set('gh:query_point', point) feature.set('gh:marker_props', { - color: indexPoint.point.color, - number: indexPoint.point.type == QueryPointType.Via ? i : undefined, - size: MARKER_SIZE, + color: point.color, + // a number is only displayed for via points and turns the marker into a circle + number: isVia ? i : undefined, + size: isVia ? VIA_MARKER_SIZE : MARKER_SIZE, }) return feature }) @@ -61,14 +79,21 @@ function addQueryPointsLayer(map: Map, queryPoints: QueryPoint[]) { queryPointsLayer.setZIndex(3) const cachedStyles: { [id: string]: Style } = {} queryPointsLayer.setStyle(feature => { + // hidden while it is dragged along the route, the dragged (numbered) circle replaces it, see UsePathsLayer + if (feature.get('gh:hidden')) return [] const props = feature.get('gh:marker_props') - const key = props.number + '-' + props.color + '-' + props.size + const isVia = props.number !== undefined + // transparent when dragging + const dragging = isVia && feature.get('gh:dragging') === true + const key = props.number + '-' + props.color + '-' + props.size + '-' + dragging let style = cachedStyles[key] if (style) return style style = new Style({ image: new Icon({ src: 'data:image/svg+xml;utf8,' + createSvg(props), - displacement: [0, MARKER_SIZE / 2], + // the via circle is centered on the coordinate, the marker points to it with its tip + displacement: isVia ? [0, 0] : [0, MARKER_SIZE / 2], + opacity: dragging ? 0.5 : 1, }), }) cachedStyles[key] = style @@ -88,16 +113,45 @@ function removeDragInteractions(map: Map) { function addDragInteractions(map: Map, queryPointsLayer: VectorLayer) { let tmp = queryPointsLayer.getSource() if (tmp == null) throw new Error('source must not be null') // typescript requires this + // the dashed line from the old to the new location while dragging, like when via markers are dragged with + // the route drag interaction (UsePathsLayer) + const dragLineStyle = new Style({ stroke: dashedLineStroke }) + let downPosition: number[] = [] + let dragging = false const modify = new Modify({ hitDetection: queryPointsLayer, source: tmp, - style: [], + style: feature => { + if (!dragging) return [] + const position = (feature.getGeometry() as Point).getCoordinates() + dragLineStyle.setGeometry(new LineString([downPosition, position])) + return dragLineStyle + }, + // Via markers are dragged with the route drag interaction instead, which bends the route like when + // creating a new via point (see UsePathsLayer). Only when no (drag-able) route is shown, e.g. because + // the request failed, this interaction drags via markers as a fallback. + condition: e => { + const routeDrag = map + .getInteractions() + .getArray() + .some(i => i.get('gh:drag_path_interaction')) + if (!routeDrag) return true + return markerFeatureAtPixel(map, e.pixel, 2)?.get('gh:marker_props')?.number === undefined + }, }) modify.on('modifystart', e => { - map.getViewport().style.cursor = 'grabbing' + dragging = true + const point = e.features.getArray()[0].get('gh:query_point') + downPosition = fromLonLat([point.coordinate.lng, point.coordinate.lat]) + // for via circles (no-route fallback) the cursor is hidden like when dragging the route + const isVia = e.features.getArray().some(f => f.get('gh:marker_props')?.number !== undefined) + map.getViewport().style.cursor = isVia ? 'none' : 'grabbing' + e.features.getArray().forEach(f => f.set('gh:dragging', true)) }) modify.on('modifyend', e => { + dragging = false map.getViewport().style.cursor = 'default' + e.features.getArray().forEach(f => f.set('gh:dragging', false)) const feature = (e as any).features.getArray()[0] const point = feature.get('gh:query_point') const coordinateLonLat = toLonLat(feature.getGeometry().getCoordinates()) diff --git a/src/layers/createMarkerSVG.ts b/src/layers/createMarkerSVG.ts index 527c7e6b..f7895b5a 100644 --- a/src/layers/createMarkerSVG.ts +++ b/src/layers/createMarkerSVG.ts @@ -8,6 +8,26 @@ interface MarkerProps { size?: number } +// depending on the number of digits the font must be smaller so that e.g. '10' still fits into the circle +export function circleFontSize(number: string) { + if (number.length <= 1) return 230 + return number.length === 2 ? 170 : 120 +} + +// draws a circle with a thick colored ring and a white center, used for via points. If a number is given it is +// displayed inside the circle. +export function createCircle({ color, number, size = 0 }: MarkerProps) { + return ` + ${ + number === undefined + ? '' + : `${number}` + } + ` +} + export function createPOI(pathD: string) { return ` @@ -25,17 +45,12 @@ export function createPOIMarker(pathD: string) { // todo: this is mostly duplicated from `Marker.tsx`, but we use a more elongated shape (MARKER_PATH). // To use `Marker.tsx` we would probably need to add ol.Overlays, i.e. create a div for each marker and insert the svg from `Marker.tsx`. export function createSvg({ color, number, size = 0 }: MarkerProps) { + if (number !== undefined) return createCircle({ color, number, size }) return `` + ` } // todo: for some weird reason the markers are not shown when the color is given in hex format #012345 diff --git a/src/map/ContextMenuContent.module.css b/src/map/ContextMenuContent.module.css index c49163f8..ea1a7b78 100644 --- a/src/map/ContextMenuContent.module.css +++ b/src/map/ContextMenuContent.module.css @@ -7,6 +7,27 @@ button.entry div { margin-bottom: 2px; padding-right: 8px; + width: 20px; + display: flex; + justify-content: center; + align-items: center; + box-sizing: content-box; +} + +/* the marker icon of the 'delete' entry is overlaid with a red cross */ +.crossedMarker { + position: relative; +} + +.deleteCross { + position: absolute; + top: -4px; + /* slightly larger than the marker icon and moved a bit to the left so it does not completely hide it */ + left: -8px; + width: calc(100% + 8px); + height: calc(100% + 8px); + /* a white halo keeps the cross visible on the equally red destination marker */ + filter: drop-shadow(0 0 1px white); } .entry { @@ -34,3 +55,11 @@ button.entry div { .entry:disabled { color: lightgray; } + +/* an entry that starts a new group of entries, separated by a horizontal line */ +.entryWithDivider { + composes: entry; + border-top: 1px solid lightgray; + margin-top: 0.4em; + padding-top: 0.8em; +} diff --git a/src/map/ContextMenuContent.tsx b/src/map/ContextMenuContent.tsx index 657b2b59..4bb386fc 100644 --- a/src/map/ContextMenuContent.tsx +++ b/src/map/ContextMenuContent.tsx @@ -3,27 +3,31 @@ import { coordinateToText } from '@/Converters' import styles from './ContextMenuContent.module.css' import QueryStore, { QueryPoint, QueryPointType } from '@/stores/QueryStore' import Dispatcher from '@/stores/Dispatcher' -import { AddPoint, SetPoint, MoveMapToPoint } from '@/actions/Actions' +import { AddPoint, RemovePoint, SetPoint, MoveMapToPoint } from '@/actions/Actions' import { RouteStoreState } from '@/stores/RouteStore' import { findNextWayPoint } from '@/map/findNextWayPoint' import { tr } from '@/translation/Translation' -import { MarkerComponent } from '@/map/Marker' +import { CircleComponent, MarkerComponent } from '@/map/Marker' import { Coordinate } from '@/utils' +import Cross from '@/sidebar/times-solid-thin.svg' export function ContextMenuContent({ coordinate, queryPoints, route, + markedQueryPoint, onSelect, }: { coordinate: Coordinate queryPoints: QueryPoint[] route: RouteStoreState + // the query point of the marker the menu was opened on (if any), for it a 'delete' entry is shown + markedQueryPoint: QueryPoint | null onSelect: () => void }) { - const dispatchAddPoint = function (coordinate: Coordinate) { + const dispatchAddPoint = function (index: number, coordinate: Coordinate) { onSelect() - Dispatcher.dispatch(new AddPoint(queryPoints.length, coordinate, true, false)) + Dispatcher.dispatch(new AddPoint(index, coordinate, true, false)) } const dispatchSetPoint = function (point: QueryPoint, coordinate: Coordinate) { @@ -42,29 +46,21 @@ export function ContextMenuContent({ } const setViaPoint = function (points: QueryPoint[], route: RouteStoreState) { - const viaPoints = points.filter(point => point.type === QueryPointType.Via) - const point = viaPoints.find(point => !point.isInitialized) - onSelect() - + const point = points.find(point => point.type === QueryPointType.Via && !point.isInitialized) if (point) { dispatchSetPoint(point, coordinate) - } else { - const routes = route.routingResult.paths.map(p => { - return { - coordinates: p.points.coordinates.map(pos => { - return { lat: pos[1], lng: pos[0] } - }), - wayPoints: p.snapped_waypoints.coordinates.map(pos => { - return { lat: pos[1], lng: pos[0] } - }), - } - }) - // note that we can use the index returned by findNextWayPoint no matter which route alternative was found - // to be closest to the clicked location, because for every route the n-th snapped_waypoint corresponds to - // the n-th query point - const index = findNextWayPoint(routes, coordinate).nextWayPoint - Dispatcher.dispatch(new AddPoint(index, coordinate, true, false)) + return } + const toCoordinate = (pos: number[]) => ({ lng: pos[0], lat: pos[1] }) + const routes = route.routingResult.paths.map(p => ({ + coordinates: p.points.coordinates.map(toCoordinate), + wayPoints: p.snapped_waypoints.coordinates.map(toCoordinate), + })) + // note that we can use the index returned by findNextWayPoint no matter which route alternative was found + // to be closest to the clicked location, because for every route the n-th snapped_waypoint corresponds to + // the n-th query point. Without a route, e.g. because the request failed, insert before the destination. + const index = routes.length === 0 ? points.length - 1 : findNextWayPoint(routes, coordinate).nextWayPoint + dispatchAddPoint(index, coordinate) } const disableViaPoint = function (points: QueryPoint[]) { @@ -86,48 +82,94 @@ export function ContextMenuContent({ } const showAddLocation = queryPoints.length >= 2 && queryPoints[1].isInitialized + const deletePoint = function (point: QueryPoint) { + onSelect() + // with only two points the search boxes are kept and just the marker is cleared + if (queryPoints.length > 2) Dispatcher.dispatch(new RemovePoint(point)) + else Dispatcher.dispatch(new SetPoint({ ...point, queryText: '', isInitialized: false }, false)) + } + + const deleteLabel = function (point: QueryPoint) { + const name = + point.type === QueryPointType.From + ? tr('from_hint') + : point.type === QueryPointType.To + ? tr('to_hint') + : // same number as shown in the via marker + queryPoints.filter(p => p.isInitialized).findIndex(p => p.id === point.id) + return `${tr('delete')} '${name}'` + } + return (
- {showAddLocation && ( - )} - - - + {!markedQueryPoint && ( + <> + {showAddLocation && ( + + )} + + + + + )}