From f1d0d0237121d3b351102e16f621ad67a5f8af80 Mon Sep 17 00:00:00 2001 From: oblonski Date: Mon, 4 Aug 2025 22:49:35 +0200 Subject: [PATCH 01/14] show current location as blue dot - fix #430 --- src/App.tsx | 20 ++++++++++++++++---- src/index.tsx | 4 ++++ src/map/LocationButton.tsx | 9 ++++++++- src/stores/Stores.ts | 5 +++++ 4 files changed, 33 insertions(+), 5 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index f3c47eb7..544c8d4b 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -11,6 +11,7 @@ import { getQueryStore, getRouteStore, getSettingsStore, + getCurrentLocationStore, } from '@/stores/Stores' import MapComponent from '@/map/MapComponent' import MapOptions from '@/map/MapOptions' @@ -22,6 +23,7 @@ import { QueryStoreState, RequestState } from '@/stores/QueryStore' import { RouteStoreState } from '@/stores/RouteStore' import { MapOptionsStoreState } from '@/stores/MapOptionsStore' import { ErrorStoreState } from '@/stores/ErrorStore' +import { CurrentLocationStoreState } from '@/stores/CurrentLocationStore' import Search from '@/sidebar/search/Search' import ErrorMessage from '@/sidebar/ErrorMessage' import useBackgroundLayer from '@/layers/UseBackgroundLayer' @@ -45,6 +47,7 @@ import useExternalMVTLayer from '@/layers/UseExternalMVTLayer' import LocationButton from '@/map/LocationButton' import { SettingsContext } from '@/contexts/SettingsContext' import usePOIsLayer from '@/layers/UsePOIsLayer' +import useCurrentLocationLayer from '@/layers/UseCurrentLocationLayer' export const POPUP_CONTAINER_ID = 'popup-container' export const SIDEBAR_CONTENT_ID = 'sidebar-content' @@ -59,6 +62,7 @@ export default function App() { const [pathDetails, setPathDetails] = useState(getPathDetailsStore().state) const [mapFeatures, setMapFeatures] = useState(getMapFeatureStore().state) const [pois, setPOIs] = useState(getPOIsStore().state) + const [currentLocation, setCurrentLocation] = useState(getCurrentLocationStore().state) const map = getMap() @@ -72,6 +76,7 @@ export default function App() { const onPathDetailsChanged = () => setPathDetails(getPathDetailsStore().state) const onMapFeaturesChanged = () => setMapFeatures(getMapFeatureStore().state) const onPOIsChanged = () => setPOIs(getPOIsStore().state) + const onCurrentLocationChanged = () => setCurrentLocation(getCurrentLocationStore().state) getSettingsStore().register(onSettingsChanged) getQueryStore().register(onQueryChanged) @@ -82,6 +87,7 @@ export default function App() { getPathDetailsStore().register(onPathDetailsChanged) getMapFeatureStore().register(onMapFeaturesChanged) getPOIsStore().register(onPOIsChanged) + getCurrentLocationStore().register(onCurrentLocationChanged) onQueryChanged() onInfoChanged() @@ -91,6 +97,7 @@ export default function App() { onPathDetailsChanged() onMapFeaturesChanged() onPOIsChanged() + onCurrentLocationChanged() return () => { getSettingsStore().deregister(onSettingsChanged) @@ -102,6 +109,7 @@ export default function App() { getPathDetailsStore().deregister(onPathDetailsChanged) getMapFeatureStore().deregister(onMapFeaturesChanged) getPOIsStore().deregister(onPOIsChanged) + getCurrentLocationStore().deregister(onCurrentLocationChanged) } }, []) @@ -116,6 +124,7 @@ export default function App() { useQueryPointsLayer(map, query.queryPoints) usePathDetailsLayer(map, pathDetails) usePOIsLayer(map, pois) + useCurrentLocationLayer(map, currentLocation) const isSmallScreen = useMediaQuery({ query: '(max-width: 44rem)' }) return ( @@ -138,6 +147,7 @@ export default function App() { error={error} encodedValues={info.encoded_values} drawAreas={settings.drawAreasEnabled} + currentLocation={currentLocation} /> ) : ( )} @@ -163,9 +174,10 @@ interface LayoutProps { error: ErrorStoreState encodedValues: object[] drawAreas: boolean + currentLocation: CurrentLocationStoreState } -function LargeScreenLayout({ query, route, map, error, mapOptions, encodedValues, drawAreas }: LayoutProps) { +function LargeScreenLayout({ query, route, map, error, mapOptions, encodedValues, drawAreas, currentLocation }: LayoutProps) { const [showSidebar, setShowSidebar] = useState(true) const [showCustomModelBox, setShowCustomModelBox] = useState(false) return ( @@ -216,7 +228,7 @@ function LargeScreenLayout({ query, route, map, error, mapOptions, encodedValues
- +
@@ -229,7 +241,7 @@ function LargeScreenLayout({ query, route, map, error, mapOptions, encodedValues ) } -function SmallScreenLayout({ query, route, map, error, mapOptions, encodedValues, drawAreas }: LayoutProps) { +function SmallScreenLayout({ query, route, map, error, mapOptions, encodedValues, drawAreas, currentLocation }: LayoutProps) { return ( <>
@@ -248,7 +260,7 @@ function SmallScreenLayout({ query, route, map, error, mapOptions, encodedValues
- +
diff --git a/src/index.tsx b/src/index.tsx index ee8937a4..81842587 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -13,6 +13,7 @@ import { getQueryStore, getRouteStore, getSettingsStore, + getCurrentLocationStore, setStores, } from '@/stores/Stores' import Dispatcher from '@/stores/Dispatcher' @@ -31,6 +32,7 @@ import MapFeatureStore from '@/stores/MapFeatureStore' import SettingsStore from '@/stores/SettingsStore' import { ErrorAction, InfoReceived } from '@/actions/Actions' import POIsStore from '@/stores/POIsStore' +import CurrentLocationStore from '@/stores/CurrentLocationStore' import { setDistanceFormat } from '@/Converters' import { AddressParseResult } from '@/pois/AddressParseResult' @@ -61,6 +63,7 @@ setStores({ pathDetailsStore: new PathDetailsStore(), mapFeatureStore: new MapFeatureStore(), poisStore: new POIsStore(), + currentLocationStore: new CurrentLocationStore(), }) setMap(createMap()) @@ -75,6 +78,7 @@ Dispatcher.register(getMapOptionsStore()) Dispatcher.register(getPathDetailsStore()) Dispatcher.register(getMapFeatureStore()) Dispatcher.register(getPOIsStore()) +Dispatcher.register(getCurrentLocationStore()) // register map action receiver const smallScreenMediaQuery = window.matchMedia('(max-width: 44rem)') diff --git a/src/map/LocationButton.tsx b/src/map/LocationButton.tsx index 4bbdd108..783389ca 100644 --- a/src/map/LocationButton.tsx +++ b/src/map/LocationButton.tsx @@ -9,13 +9,20 @@ import LocationOn from '@/map/location_on.svg' import { useState } from 'react' import { tr } from '@/translation/Translation' import { getBBoxFromCoord } from '@/utils' +import { ToggleCurrentLocation } from '@/stores/CurrentLocationStore' +import { CurrentLocationStoreState } from '@/stores/CurrentLocationStore' -export default function LocationButton(props: { queryPoints: QueryPoint[] }) { +export default function LocationButton(props: { queryPoints: QueryPoint[]; currentLocation: CurrentLocationStoreState }) { const [locationSearch, setLocationSearch] = useState('synched_map_or_initial') + return (
{ + // First toggle location display + Dispatcher.dispatch(new ToggleCurrentLocation(!props.currentLocation.enabled)) + + // Then handle the location button click for routing/centering setLocationSearch('search') onCurrentLocationButtonClicked(coordinate => { if (coordinate) { diff --git a/src/stores/Stores.ts b/src/stores/Stores.ts index b7ba16d3..2660b7ab 100644 --- a/src/stores/Stores.ts +++ b/src/stores/Stores.ts @@ -7,6 +7,7 @@ import PathDetailsStore from '@/stores/PathDetailsStore' import MapFeatureStore from '@/stores/MapFeatureStore' import SettingsStore from '@/stores/SettingsStore' import POIsStore from '@/stores/POIsStore' +import CurrentLocationStore from '@/stores/CurrentLocationStore' let settingsStore: SettingsStore let queryStore: QueryStore @@ -17,6 +18,7 @@ let mapOptionsStore: MapOptionsStore let pathDetailsStore: PathDetailsStore let mapFeatureStore: MapFeatureStore let poisStore: POIsStore +let currentLocationStore: CurrentLocationStore interface StoresInput { settingsStore: SettingsStore @@ -28,6 +30,7 @@ interface StoresInput { pathDetailsStore: PathDetailsStore mapFeatureStore: MapFeatureStore poisStore: POIsStore + currentLocationStore: CurrentLocationStore } export const setStores = function (stores: StoresInput) { @@ -40,6 +43,7 @@ export const setStores = function (stores: StoresInput) { pathDetailsStore = stores.pathDetailsStore mapFeatureStore = stores.mapFeatureStore poisStore = stores.poisStore + currentLocationStore = stores.currentLocationStore } export const getSettingsStore = () => settingsStore @@ -51,3 +55,4 @@ export const getMapOptionsStore = () => mapOptionsStore export const getPathDetailsStore = () => pathDetailsStore export const getMapFeatureStore = () => mapFeatureStore export const getPOIsStore = () => poisStore +export const getCurrentLocationStore = () => currentLocationStore From b9b8231ce68e8c721ac20b836dc2cd26f1d11f53 Mon Sep 17 00:00:00 2001 From: oblonski Date: Mon, 4 Aug 2025 22:54:39 +0200 Subject: [PATCH 02/14] show current location as blue dot - fix #430 --- src/layers/UseCurrentLocationLayer.tsx | 162 +++++++++++++++++++++++++ src/layers/UseQueryPointsLayer.tsx | 2 +- src/stores/CurrentLocationStore.ts | 67 ++++++++++ 3 files changed, 230 insertions(+), 1 deletion(-) create mode 100644 src/layers/UseCurrentLocationLayer.tsx create mode 100644 src/stores/CurrentLocationStore.ts diff --git a/src/layers/UseCurrentLocationLayer.tsx b/src/layers/UseCurrentLocationLayer.tsx new file mode 100644 index 00000000..680023de --- /dev/null +++ b/src/layers/UseCurrentLocationLayer.tsx @@ -0,0 +1,162 @@ +import { Feature, Map } from 'ol' +import { useEffect, useRef, useState } from 'react' +import VectorLayer from 'ol/layer/Vector' +import VectorSource from 'ol/source/Vector' +import { Point, Circle as CircleGeom } from 'ol/geom' +import { fromLonLat } from 'ol/proj' +import { Style, Fill, Stroke, Circle as CircleStyle } from 'ol/style' +import Geolocation from 'ol/Geolocation' + +interface CurrentLocationState { + enabled: boolean + tracking: boolean +} + +const LOCATION_LAYER_KEY = 'gh:current_location' + +export default function useCurrentLocationLayer(map: Map, locationState: CurrentLocationState) { + const geolocationRef = useRef(null) + const [hasPermission, setHasPermission] = useState(null) + + useEffect(() => { + if (!locationState.enabled) { + removeCurrentLocationLayer(map) + if (geolocationRef.current) { + geolocationRef.current.setTracking(false) + geolocationRef.current = null + } + return + } + + // Check for geolocation permission + if ('permissions' in navigator) { + navigator.permissions.query({ name: 'geolocation' }).then((result) => { + setHasPermission(result.state === 'granted') + result.addEventListener('change', () => { + setHasPermission(result.state === 'granted') + }) + }) + } + + // Create geolocation instance + const geolocation = new Geolocation({ + trackingOptions: { + enableHighAccuracy: true + }, + projection: map.getView().getProjection() + }) + + geolocationRef.current = geolocation + + // Create the location layer + const locationLayer = createLocationLayer() + map.addLayer(locationLayer) + + // Handle position updates + const positionFeature = new Feature() + const accuracyFeature = new Feature() + + geolocation.on('change:position', () => { + const coordinates = geolocation.getPosition() + if (coordinates) { + positionFeature.setGeometry(new Point(coordinates)) + + // Update view if tracking is enabled + if (locationState.tracking) { + map.getView().animate({ + center: coordinates, + duration: 500 + }) + } + } + }) + + geolocation.on('change:accuracyGeometry', () => { + const accuracy = geolocation.getAccuracyGeometry() + if (accuracy) { + accuracyFeature.setGeometry(accuracy) + } + }) + + geolocation.on('error', (error) => { + console.error('Geolocation error:', error) + setHasPermission(false) + }) + + // Add features to the layer + const source = locationLayer.getSource() + if (source) { + source.addFeature(accuracyFeature) + source.addFeature(positionFeature) + } + + // Start tracking + geolocation.setTracking(true) + + return () => { + geolocation.setTracking(false) + removeCurrentLocationLayer(map) + } + }, [map, locationState.enabled, locationState.tracking]) + + return hasPermission +} + +function removeCurrentLocationLayer(map: Map) { + map.getLayers() + .getArray() + .filter(l => l.get(LOCATION_LAYER_KEY)) + .forEach(l => map.removeLayer(l)) +} + +function createLocationLayer(): VectorLayer { + const layer = new VectorLayer({ + source: new VectorSource(), + style: (feature) => { + const geometry = feature.getGeometry() + if (geometry instanceof Point) { + // Blue dot style for position + return [ + new Style({ + image: new CircleStyle({ + radius: 8, + fill: new Fill({ + color: '#4285F4' + }), + stroke: new Stroke({ + color: '#FFFFFF', + width: 2 + }) + }) + }), + // Pulsing effect outer ring + new Style({ + image: new CircleStyle({ + radius: 16, + fill: new Fill({ + color: 'rgba(66, 133, 244, 0.2)' + }) + }) + }) + ] + } else if (geometry instanceof CircleGeom) { + // Accuracy circle style + return new Style({ + fill: new Fill({ + color: 'rgba(66, 133, 244, 0.1)' + }), + stroke: new Stroke({ + color: 'rgba(66, 133, 244, 0.3)', + width: 1 + }) + }) + } + return [] + } + }) + + layer.set(LOCATION_LAYER_KEY, true) + layer.setZIndex(4) // Above paths and query points + + return layer +} \ No newline at end of file diff --git a/src/layers/UseQueryPointsLayer.tsx b/src/layers/UseQueryPointsLayer.tsx index 48973ea8..d21e4f1a 100644 --- a/src/layers/UseQueryPointsLayer.tsx +++ b/src/layers/UseQueryPointsLayer.tsx @@ -85,7 +85,7 @@ function removeDragInteractions(map: Map) { .forEach(i => map.removeInteraction(i)) } -function addDragInteractions(map: Map, queryPointsLayer: VectorLayer>>) { +function addDragInteractions(map: Map, queryPointsLayer: VectorLayer) { let tmp = queryPointsLayer.getSource() if (tmp == null) throw new Error('source must not be null') // typescript requires this const modify = new Modify({ diff --git a/src/stores/CurrentLocationStore.ts b/src/stores/CurrentLocationStore.ts new file mode 100644 index 00000000..3dd75274 --- /dev/null +++ b/src/stores/CurrentLocationStore.ts @@ -0,0 +1,67 @@ +import Store from '@/stores/Store' +import { Action } from '@/stores/Dispatcher' + +export interface CurrentLocationStoreState { + enabled: boolean + tracking: boolean + hasPermission: boolean | null +} + +export class ToggleCurrentLocation implements Action { + readonly enabled: boolean + + constructor(enabled: boolean) { + this.enabled = enabled + } +} + +export class ToggleLocationTracking implements Action { + readonly tracking: boolean + + constructor(tracking: boolean) { + this.tracking = tracking + } +} + +export class SetLocationPermission implements Action { + readonly hasPermission: boolean | null + + constructor(hasPermission: boolean | null) { + this.hasPermission = hasPermission + } +} + +export default class CurrentLocationStore extends Store { + constructor() { + super(CurrentLocationStore.getInitialState()) + } + + private static getInitialState(): CurrentLocationStoreState { + return { + enabled: false, + tracking: false, + hasPermission: null + } + } + + reduce(state: CurrentLocationStoreState, action: Action): CurrentLocationStoreState { + if (action instanceof ToggleCurrentLocation) { + return { + ...state, + enabled: action.enabled, + tracking: action.enabled ? state.tracking : false + } + } else if (action instanceof ToggleLocationTracking) { + return { + ...state, + tracking: action.tracking + } + } else if (action instanceof SetLocationPermission) { + return { + ...state, + hasPermission: action.hasPermission + } + } + return state + } +} \ No newline at end of file From f22425501347f0c87ef27eb23874a3635021211c Mon Sep 17 00:00:00 2001 From: Peter Date: Thu, 7 Aug 2025 15:40:44 +0200 Subject: [PATCH 03/14] new location layer, new 'not in sync' state, todo: precision radius and real world testing --- src/App.tsx | 28 ++++- src/actions/Actions.ts | 30 ++++- src/layers/UseCurrentLocationLayer.tsx | 165 +++++++++---------------- src/map/ContextMenuContent.tsx | 4 +- src/map/LocationButton.tsx | 59 ++++----- src/map/MapComponent.tsx | 20 --- src/map/location_not_in_sync.svg | 3 + src/map/map.ts | 8 +- src/sidebar/search/Search.tsx | 12 +- src/stores/CurrentLocationStore.ts | 142 ++++++++++++++------- src/stores/MapActionReceiver.ts | 4 +- 11 files changed, 253 insertions(+), 222 deletions(-) create mode 100644 src/map/location_not_in_sync.svg diff --git a/src/App.tsx b/src/App.tsx index 544c8d4b..2f539c80 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -171,13 +171,22 @@ interface LayoutProps { route: RouteStoreState map: Map mapOptions: MapOptionsStoreState + currentLocation: CurrentLocationStoreState error: ErrorStoreState encodedValues: object[] drawAreas: boolean - currentLocation: CurrentLocationStoreState } -function LargeScreenLayout({ query, route, map, error, mapOptions, encodedValues, drawAreas, currentLocation }: LayoutProps) { +function LargeScreenLayout({ + query, + route, + map, + error, + mapOptions, + encodedValues, + drawAreas, + currentLocation, +}: LayoutProps) { const [showSidebar, setShowSidebar] = useState(true) const [showCustomModelBox, setShowCustomModelBox] = useState(false) return ( @@ -228,7 +237,7 @@ function LargeScreenLayout({ query, route, map, error, mapOptions, encodedValues
- +
@@ -241,7 +250,16 @@ function LargeScreenLayout({ query, route, map, error, mapOptions, encodedValues ) } -function SmallScreenLayout({ query, route, map, error, mapOptions, encodedValues, drawAreas, currentLocation }: LayoutProps) { +function SmallScreenLayout({ + query, + route, + map, + error, + mapOptions, + encodedValues, + drawAreas, + currentLocation, +}: LayoutProps) { return ( <>
@@ -260,7 +278,7 @@ function SmallScreenLayout({ query, route, map, error, mapOptions, encodedValues
- +
diff --git a/src/actions/Actions.ts b/src/actions/Actions.ts index 466b1282..5b053ce8 100644 --- a/src/actions/Actions.ts +++ b/src/actions/Actions.ts @@ -189,7 +189,7 @@ export class ToggleExternalMVTLayer implements Action { export class MapIsLoaded implements Action {} -export class ZoomMapToPoint implements Action { +export class MoveMapToPoint implements Action { readonly coordinate: Coordinate constructor(coordinate: Coordinate) { @@ -272,3 +272,31 @@ export class SetPOIs implements Action { this.pois = pois } } + +/** + * Start watching the location and synchronizing the view. + */ +export class StartWatchCurrentLocation implements Action {} +export class StopWatchCurrentLocation implements Action {} + +/** + * Start synchronizing the view again. + */ +export class StartSyncCurrentLocation implements Action {} +export class StopSyncCurrentLocation implements Action {} + +export class CurrentLocationError implements Action { + readonly error: string + + constructor(error: string) { + this.error = error + } +} + +export class CurrentLocation implements Action { + readonly coordinate: Coordinate + + constructor(coordinate: Coordinate) { + this.coordinate = coordinate + } +} diff --git a/src/layers/UseCurrentLocationLayer.tsx b/src/layers/UseCurrentLocationLayer.tsx index 680023de..70a2d017 100644 --- a/src/layers/UseCurrentLocationLayer.tsx +++ b/src/layers/UseCurrentLocationLayer.tsx @@ -1,105 +1,50 @@ import { Feature, Map } from 'ol' -import { useEffect, useRef, useState } from 'react' +import { useEffect, useRef } from 'react' import VectorLayer from 'ol/layer/Vector' import VectorSource from 'ol/source/Vector' -import { Point, Circle as CircleGeom } from 'ol/geom' +import { Circle as CircleGeom, Point } from 'ol/geom' +import { Circle as CircleStyle, Fill, Stroke, Style } from 'ol/style' +import { CurrentLocationStoreState } from '@/stores/CurrentLocationStore' import { fromLonLat } from 'ol/proj' -import { Style, Fill, Stroke, Circle as CircleStyle } from 'ol/style' -import Geolocation from 'ol/Geolocation' - -interface CurrentLocationState { - enabled: boolean - tracking: boolean -} +import { getMap } from '@/map/map' +import Dispatcher from '@/stores/Dispatcher' const LOCATION_LAYER_KEY = 'gh:current_location' -export default function useCurrentLocationLayer(map: Map, locationState: CurrentLocationState) { - const geolocationRef = useRef(null) - const [hasPermission, setHasPermission] = useState(null) - +export default function useCurrentLocationLayer(map: Map, locationState: CurrentLocationStoreState) { useEffect(() => { + console.log('NOW useEffect, syncView = ', locationState.syncView) + if (!locationState.enabled) { removeCurrentLocationLayer(map) - if (geolocationRef.current) { - geolocationRef.current.setTracking(false) - geolocationRef.current = null - } return } - // Check for geolocation permission - if ('permissions' in navigator) { - navigator.permissions.query({ name: 'geolocation' }).then((result) => { - setHasPermission(result.state === 'granted') - result.addEventListener('change', () => { - setHasPermission(result.state === 'granted') - }) - }) - } - - // Create geolocation instance - const geolocation = new Geolocation({ - trackingOptions: { - enableHighAccuracy: true - }, - projection: map.getView().getProjection() - }) - - geolocationRef.current = geolocation - - // Create the location layer - const locationLayer = createLocationLayer() - map.addLayer(locationLayer) - - // Handle position updates const positionFeature = new Feature() - const accuracyFeature = new Feature() - - geolocation.on('change:position', () => { - const coordinates = geolocation.getPosition() - if (coordinates) { - positionFeature.setGeometry(new Point(coordinates)) - - // Update view if tracking is enabled - if (locationState.tracking) { - map.getView().animate({ - center: coordinates, - duration: 500 - }) - } - } - }) - - geolocation.on('change:accuracyGeometry', () => { - const accuracy = geolocation.getAccuracyGeometry() - if (accuracy) { - accuracyFeature.setGeometry(accuracy) + if (locationState.coordinate) { + positionFeature.setGeometry( + new Point(fromLonLat([locationState.coordinate.lng, locationState.coordinate.lat])) + ) + + if (locationState.syncView) { + // TODO same code as for MoveMapToPoint action, but calling Dispatcher here is ugly + let zoom = map.getView().getZoom() + if (zoom == undefined || zoom < 8) zoom = 8 + map.getView().animate({ + zoom: zoom, + center: fromLonLat([locationState.coordinate.lng, locationState.coordinate.lat]), + duration: 400, + }) } - }) - - geolocation.on('error', (error) => { - console.error('Geolocation error:', error) - setHasPermission(false) - }) - - // Add features to the layer - const source = locationLayer.getSource() - if (source) { - source.addFeature(accuracyFeature) - source.addFeature(positionFeature) } - // Start tracking - geolocation.setTracking(true) + const layer = createLocationLayer(positionFeature) + map.addLayer(layer) return () => { - geolocation.setTracking(false) - removeCurrentLocationLayer(map) + map.removeLayer(layer) } - }, [map, locationState.enabled, locationState.tracking]) - - return hasPermission + }, [locationState.enabled, locationState.coordinate, locationState.syncView]) } function removeCurrentLocationLayer(map: Map) { @@ -109,10 +54,10 @@ function removeCurrentLocationLayer(map: Map) { .forEach(l => map.removeLayer(l)) } -function createLocationLayer(): VectorLayer { +function createLocationLayer(positionFeature: Feature): VectorLayer { const layer = new VectorLayer({ source: new VectorSource(), - style: (feature) => { + style: feature => { const geometry = feature.getGeometry() if (geometry instanceof Point) { // Blue dot style for position @@ -121,42 +66,42 @@ function createLocationLayer(): VectorLayer { image: new CircleStyle({ radius: 8, fill: new Fill({ - color: '#4285F4' + color: '#4285F4', }), stroke: new Stroke({ color: '#FFFFFF', - width: 2 - }) - }) + width: 2, + }), + }), }), // Pulsing effect outer ring - new Style({ - image: new CircleStyle({ - radius: 16, - fill: new Fill({ - color: 'rgba(66, 133, 244, 0.2)' - }) - }) - }) + // new Style({ + // image: new CircleStyle({ + // radius: 16, + // fill: new Fill({ + // color: 'rgba(66, 133, 244, 0.2)' + // }) + // }) + // }) ] } else if (geometry instanceof CircleGeom) { // Accuracy circle style - return new Style({ - fill: new Fill({ - color: 'rgba(66, 133, 244, 0.1)' - }), - stroke: new Stroke({ - color: 'rgba(66, 133, 244, 0.3)', - width: 1 - }) - }) + // return new Style({ + // fill: new Fill({ + // color: 'rgba(66, 133, 244, 0.1)' + // }), + // stroke: new Stroke({ + // color: 'rgba(66, 133, 244, 0.3)', + // width: 1 + // }) + // }) } return [] - } + }, }) - - layer.set(LOCATION_LAYER_KEY, true) + layer.setZIndex(4) // Above paths and query points - + + layer.getSource()?.addFeature(positionFeature) return layer -} \ No newline at end of file +} diff --git a/src/map/ContextMenuContent.tsx b/src/map/ContextMenuContent.tsx index 9c142725..4fdcc011 100644 --- a/src/map/ContextMenuContent.tsx +++ b/src/map/ContextMenuContent.tsx @@ -3,7 +3,7 @@ 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, ZoomMapToPoint } from '@/actions/Actions' +import { AddPoint, SetPoint, MoveMapToPoint } from '@/actions/Actions' import { RouteStoreState } from '@/stores/RouteStore' import { findNextWayPoint } from '@/map/findNextWayPoint' import { tr } from '@/translation/Translation' @@ -143,7 +143,7 @@ export function ContextMenuContent({ className={styles.entry} onClick={() => { onSelect() - Dispatcher.dispatch(new ZoomMapToPoint(coordinate)) + Dispatcher.dispatch(new MoveMapToPoint(coordinate)) }} > {tr('center_map')} diff --git a/src/map/LocationButton.tsx b/src/map/LocationButton.tsx index 783389ca..822010e5 100644 --- a/src/map/LocationButton.tsx +++ b/src/map/LocationButton.tsx @@ -1,55 +1,40 @@ import styles from './LocationButton.module.css' -import { onCurrentLocationButtonClicked } from '@/map/MapComponent' import Dispatcher from '@/stores/Dispatcher' -import { SetBBox, SetPoint, ZoomMapToPoint } from '@/actions/Actions' -import { QueryPoint, QueryPointType } from '@/stores/QueryStore' +import { StartSyncCurrentLocation, StartWatchCurrentLocation } from '@/actions/Actions' import LocationError from '@/map/location_error.svg' import LocationSearching from '@/map/location_searching.svg' import LocationOn from '@/map/location_on.svg' -import { useState } from 'react' -import { tr } from '@/translation/Translation' -import { getBBoxFromCoord } from '@/utils' -import { ToggleCurrentLocation } from '@/stores/CurrentLocationStore' +import LocationNotInSync from '@/map/location_not_in_sync.svg' +import { useEffect, useState } from 'react' import { CurrentLocationStoreState } from '@/stores/CurrentLocationStore' -export default function LocationButton(props: { queryPoints: QueryPoint[]; currentLocation: CurrentLocationStoreState }) { - const [locationSearch, setLocationSearch] = useState('synched_map_or_initial') - +export default function LocationButton(props: { currentLocation: CurrentLocationStoreState }) { + const [locationSearch, setLocationSearch] = useState('initial') + + useEffect(() => { + if (props.currentLocation.enabled) { + if (!props.currentLocation.syncView) setLocationSearch('on_but_not_in_sync') + else if (props.currentLocation.error) setLocationSearch('error') + else setLocationSearch('initial') + } + }, [props.currentLocation.syncView, props.currentLocation.error, props.currentLocation.enabled]) + return (
{ - // First toggle location display - Dispatcher.dispatch(new ToggleCurrentLocation(!props.currentLocation.enabled)) - - // Then handle the location button click for routing/centering - setLocationSearch('search') - onCurrentLocationButtonClicked(coordinate => { - if (coordinate) { - if (props.queryPoints[0] && !props.queryPoints[0].isInitialized) - Dispatcher.dispatch( - new SetPoint( - { - ...props.queryPoints[0], - coordinate, - queryText: tr('current_location'), - isInitialized: true, - type: QueryPointType.From, - }, - false - ) - ) - Dispatcher.dispatch(new ZoomMapToPoint(coordinate)) - // We do not reset state of this button when map is moved, so we do not know if - // the map is currently showing the location. - setLocationSearch('synched_map_or_initial') - } else setLocationSearch('error') - }) + if (props.currentLocation.enabled && !props.currentLocation.error) { + Dispatcher.dispatch(new StartSyncCurrentLocation()) + } else { + Dispatcher.dispatch(new StartWatchCurrentLocation()) + setLocationSearch('search') + } }} > {locationSearch == 'error' && } {locationSearch == 'search' && } - {locationSearch == 'synched_map_or_initial' && } + {locationSearch == 'initial' && } + {locationSearch == 'on_but_not_in_sync' && }
) } diff --git a/src/map/MapComponent.tsx b/src/map/MapComponent.tsx index ac2d39b3..18f5dab7 100644 --- a/src/map/MapComponent.tsx +++ b/src/map/MapComponent.tsx @@ -43,23 +43,3 @@ export function onCurrentLocationSelected( { timeout: 300_000, enableHighAccuracy: true } ) } - -export function onCurrentLocationButtonClicked(onSelect: (coordinate: Coordinate | undefined) => void) { - if (!navigator.geolocation) { - Dispatcher.dispatch(new ErrorAction('Geolocation is not supported in this browser')) - onSelect(undefined) - return - } - - navigator.geolocation.getCurrentPosition( - position => { - onSelect({ lat: position.coords.latitude, lng: position.coords.longitude }) - }, - error => { - Dispatcher.dispatch(new ErrorAction(tr('searching_location_failed') + ': ' + error.message)) - onSelect(undefined) - }, - // DO NOT use e.g. maximumAge: 5_000 -> getCurrentPosition will then never return on mobile firefox!? - { timeout: 300_000, enableHighAccuracy: true } - ) -} diff --git a/src/map/location_not_in_sync.svg b/src/map/location_not_in_sync.svg new file mode 100644 index 00000000..9679f21b --- /dev/null +++ b/src/map/location_not_in_sync.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/src/map/map.ts b/src/map/map.ts index b0aefc25..8cc35182 100644 --- a/src/map/map.ts +++ b/src/map/map.ts @@ -1,7 +1,7 @@ import Dispatcher from '@/stores/Dispatcher' import { Map, View } from 'ol' import { fromLonLat } from 'ol/proj' -import { MapIsLoaded } from '@/actions/Actions' +import { MapIsLoaded, StopSyncCurrentLocation } from '@/actions/Actions' import { defaults as defaultControls } from 'ol/control' import styles from '@/map/Map.module.css' @@ -31,12 +31,18 @@ export function createMap(): Map { map.once('postrender', () => { Dispatcher.dispatch(new MapIsLoaded()) }) + + map.on('pointerdrag', () => { + if (!getMap().getView().getAnimating()) Dispatcher.dispatch(new StopSyncCurrentLocation()) + }) + return map } export function setMap(m: Map) { map = m } + export function getMap(): Map { if (!map) throw Error('Map must be initialized before it can be used. Use "createMap" when starting the app') return map diff --git a/src/sidebar/search/Search.tsx b/src/sidebar/search/Search.tsx index fd4d8649..17faeb8f 100644 --- a/src/sidebar/search/Search.tsx +++ b/src/sidebar/search/Search.tsx @@ -2,7 +2,16 @@ import { useState } from 'react' import Dispatcher from '@/stores/Dispatcher' import styles from '@/sidebar/search/Search.module.css' import { QueryPoint } from '@/stores/QueryStore' -import { AddPoint, ClearRoute, InvalidatePoint, MovePoint, RemovePoint, SetBBox, SetPoint } from '@/actions/Actions' +import { + AddPoint, + ClearRoute, + InvalidatePoint, + MovePoint, + RemovePoint, + SetBBox, + SetPoint, + StopSyncCurrentLocation, +} from '@/actions/Actions' import RemoveIcon from './minus-circle-solid.svg' import AddIcon from './plus-circle-solid.svg' import TargetIcon from './send.svg' @@ -34,6 +43,7 @@ export default function Search({ points, profile, map }: { points: QueryPoint[]; onChange={() => { Dispatcher.dispatch(new ClearRoute()) Dispatcher.dispatch(new InvalidatePoint(point)) + Dispatcher.dispatch(new StopSyncCurrentLocation()) }} showTargetIcons={showTargetIcons} moveStartIndex={moveStartIndex} diff --git a/src/stores/CurrentLocationStore.ts b/src/stores/CurrentLocationStore.ts index 3dd75274..07e8cd54 100644 --- a/src/stores/CurrentLocationStore.ts +++ b/src/stores/CurrentLocationStore.ts @@ -1,67 +1,123 @@ import Store from '@/stores/Store' -import { Action } from '@/stores/Dispatcher' +import Dispatcher, { Action } from '@/stores/Dispatcher' +import { + CurrentLocation, + CurrentLocationError, + MoveMapToPoint, + StartSyncCurrentLocation, + StartWatchCurrentLocation, + StopSyncCurrentLocation, + StopWatchCurrentLocation, +} from '@/actions/Actions' +import { tr } from '@/translation/Translation' +import { Coordinate } from '@/utils' export interface CurrentLocationStoreState { + error: string | null enabled: boolean - tracking: boolean - hasPermission: boolean | null -} - -export class ToggleCurrentLocation implements Action { - readonly enabled: boolean - - constructor(enabled: boolean) { - this.enabled = enabled - } -} - -export class ToggleLocationTracking implements Action { - readonly tracking: boolean - - constructor(tracking: boolean) { - this.tracking = tracking - } -} - -export class SetLocationPermission implements Action { - readonly hasPermission: boolean | null - - constructor(hasPermission: boolean | null) { - this.hasPermission = hasPermission - } + syncView: boolean + coordinate: Coordinate | null } export default class CurrentLocationStore extends Store { - constructor() { - super(CurrentLocationStore.getInitialState()) - } + private watchId: number | null = null - private static getInitialState(): CurrentLocationStoreState { - return { + constructor() { + super({ + error: null, enabled: false, - tracking: false, - hasPermission: null - } + syncView: false, + coordinate: null, + }) } reduce(state: CurrentLocationStoreState, action: Action): CurrentLocationStoreState { - if (action instanceof ToggleCurrentLocation) { + if (action instanceof StartWatchCurrentLocation) { + if (state.enabled) { + console.log('NOW cannot start as already started. ID = ' + this.watchId) + return state + } + + console.log('NOW start ' + JSON.stringify(action, null, 2)) + this.start() return { ...state, - enabled: action.enabled, - tracking: action.enabled ? state.tracking : false + error: null, + enabled: true, + syncView: true, + coordinate: null, } - } else if (action instanceof ToggleLocationTracking) { + } else if (action instanceof StopWatchCurrentLocation) { + console.log('NOW stop ' + JSON.stringify(action, null, 2)) + this.stop() return { ...state, - tracking: action.tracking + error: null, + enabled: false, + syncView: false, } - } else if (action instanceof SetLocationPermission) { + } else if (action instanceof CurrentLocationError) { + console.log('NOW error ' + JSON.stringify(action, null, 2)) return { ...state, - hasPermission: action.hasPermission + enabled: false, + error: action.error, + coordinate: null, + } + } else if (action instanceof CurrentLocation) { + console.log('NOW current ' + JSON.stringify(action, null, 2)) + return { + ...state, + coordinate: action.coordinate, + } + } else if (action instanceof StartSyncCurrentLocation) { + if (!state.enabled) { + console.log('NOW cannot start sync as not enabled') + return state + } + + console.log('NOW start sync ' + JSON.stringify(action, null, 2)) + return { + ...state, + error: null, + enabled: true, + syncView: true, + } + } else if (action instanceof StopSyncCurrentLocation) { + if (!state.enabled) return state + + console.log('NOW stop sync ' + JSON.stringify(action, null, 2)) + return { + ...state, + error: null, + syncView: false, } } return state } -} \ No newline at end of file + + start() { + if (!navigator.geolocation) { + Dispatcher.dispatch(new CurrentLocationError('Geolocation is not supported in this browser')) + this.watchId = null + return + } + + this.watchId = navigator.geolocation.watchPosition( + position => { + Dispatcher.dispatch( + new CurrentLocation({ lng: position.coords.longitude, lat: position.coords.latitude }) + ) + }, + error => { + Dispatcher.dispatch(new CurrentLocationError(tr('searching_location_failed') + ': ' + error.message)) + }, + // DO NOT use e.g. maximumAge: 5_000 -> getCurrentPosition will then never return on mobile firefox!? + { timeout: 300_000, enableHighAccuracy: true } + ) + } + + stop() { + if (this.watchId) navigator.geolocation.clearWatch(this.watchId) + } +} diff --git a/src/stores/MapActionReceiver.ts b/src/stores/MapActionReceiver.ts index a2191be7..6361e3c9 100644 --- a/src/stores/MapActionReceiver.ts +++ b/src/stores/MapActionReceiver.ts @@ -7,7 +7,7 @@ import { RouteRequestSuccess, SetBBox, SetSelectedPath, - ZoomMapToPoint, + MoveMapToPoint, } from '@/actions/Actions' import RouteStore from '@/stores/RouteStore' import { Bbox } from '@/api/graphhopper' @@ -30,7 +30,7 @@ export default class MapActionReceiver implements ActionReceiver { // we estimate the map size to be equal to the window size. we don't know better at this point, because // the map has not been rendered for the first time yet fitBounds(this.map, action.bbox, isSmallScreen, [window.innerWidth, window.innerHeight]) - } else if (action instanceof ZoomMapToPoint) { + } else if (action instanceof MoveMapToPoint) { let zoom = this.map.getView().getZoom() if (zoom == undefined || zoom < 8) zoom = 8 this.map.getView().animate({ From 3be532f24ba905ec13720c29e31ac64f3c275fd6 Mon Sep 17 00:00:00 2001 From: Peter Date: Thu, 7 Aug 2025 16:14:46 +0200 Subject: [PATCH 04/14] show accurracy radius and show search animation --- src/actions/Actions.ts | 4 ++- src/layers/UseCurrentLocationLayer.tsx | 47 +++++++++++--------------- src/map/LocationButton.tsx | 10 ++++-- src/stores/CurrentLocationStore.ts | 18 +++++----- 4 files changed, 40 insertions(+), 39 deletions(-) diff --git a/src/actions/Actions.ts b/src/actions/Actions.ts index 5b053ce8..dbd854ab 100644 --- a/src/actions/Actions.ts +++ b/src/actions/Actions.ts @@ -295,8 +295,10 @@ export class CurrentLocationError implements Action { export class CurrentLocation implements Action { readonly coordinate: Coordinate + readonly accuracy: number - constructor(coordinate: Coordinate) { + constructor(coordinate: Coordinate, accuracy: number) { this.coordinate = coordinate + this.accuracy = accuracy } } diff --git a/src/layers/UseCurrentLocationLayer.tsx b/src/layers/UseCurrentLocationLayer.tsx index 70a2d017..a8692c97 100644 --- a/src/layers/UseCurrentLocationLayer.tsx +++ b/src/layers/UseCurrentLocationLayer.tsx @@ -1,44 +1,39 @@ import { Feature, Map } from 'ol' -import { useEffect, useRef } from 'react' +import { useEffect } from 'react' import VectorLayer from 'ol/layer/Vector' import VectorSource from 'ol/source/Vector' -import { Circle as CircleGeom, Point } from 'ol/geom' +import { Circle, Circle as CircleGeom, Point } from 'ol/geom' import { Circle as CircleStyle, Fill, Stroke, Style } from 'ol/style' import { CurrentLocationStoreState } from '@/stores/CurrentLocationStore' import { fromLonLat } from 'ol/proj' -import { getMap } from '@/map/map' -import Dispatcher from '@/stores/Dispatcher' const LOCATION_LAYER_KEY = 'gh:current_location' export default function useCurrentLocationLayer(map: Map, locationState: CurrentLocationStoreState) { useEffect(() => { - console.log('NOW useEffect, syncView = ', locationState.syncView) - if (!locationState.enabled) { removeCurrentLocationLayer(map) return } const positionFeature = new Feature() + const accuracyFeature = new Feature() if (locationState.coordinate) { - positionFeature.setGeometry( - new Point(fromLonLat([locationState.coordinate.lng, locationState.coordinate.lat])) - ) + const coord = fromLonLat([locationState.coordinate.lng, locationState.coordinate.lat]) + positionFeature.setGeometry(new Point(coord)) + accuracyFeature.setGeometry(new Circle(coord, locationState.accuracy)) if (locationState.syncView) { // TODO same code as for MoveMapToPoint action, but calling Dispatcher here is ugly let zoom = map.getView().getZoom() if (zoom == undefined || zoom < 8) zoom = 8 - map.getView().animate({ - zoom: zoom, - center: fromLonLat([locationState.coordinate.lng, locationState.coordinate.lat]), - duration: 400, - }) + map.getView().animate({ zoom: zoom, center: coord, duration: 400 }) } } - const layer = createLocationLayer(positionFeature) + const layer = createLocationLayer() + layer.getSource()?.addFeature(positionFeature) + layer.getSource()?.addFeature(accuracyFeature) map.addLayer(layer) return () => { @@ -54,7 +49,7 @@ function removeCurrentLocationLayer(map: Map) { .forEach(l => map.removeLayer(l)) } -function createLocationLayer(positionFeature: Feature): VectorLayer { +function createLocationLayer(): VectorLayer { const layer = new VectorLayer({ source: new VectorSource(), style: feature => { @@ -86,22 +81,20 @@ function createLocationLayer(positionFeature: Feature): VectorLayer { Dispatcher.dispatch( - new CurrentLocation({ lng: position.coords.longitude, lat: position.coords.latitude }) + new CurrentLocation( + { lng: position.coords.longitude, lat: position.coords.latitude }, + position.coords.accuracy + ) ) }, error => { From fbb0e05d9f58f91bbbb1fd58420f616b017635c7 Mon Sep 17 00:00:00 2001 From: Peter Date: Thu, 7 Aug 2025 16:19:19 +0200 Subject: [PATCH 05/14] show different state via button color --- src/map/LocationButton.tsx | 8 ++++++-- src/map/location.svg | 6 ++++++ src/map/location_on.svg | 2 +- 3 files changed, 13 insertions(+), 3 deletions(-) create mode 100644 src/map/location.svg diff --git a/src/map/LocationButton.tsx b/src/map/LocationButton.tsx index ed6374f5..73411eb9 100644 --- a/src/map/LocationButton.tsx +++ b/src/map/LocationButton.tsx @@ -4,6 +4,7 @@ import { StartSyncCurrentLocation, StartWatchCurrentLocation } from '@/actions/A import LocationError from '@/map/location_error.svg' import LocationSearching from '@/map/location_searching.svg' import LocationOn from '@/map/location_on.svg' +import Location from '@/map/location.svg' import LocationNotInSync from '@/map/location_not_in_sync.svg' import { useEffect, useState } from 'react' import { CurrentLocationStoreState } from '@/stores/CurrentLocationStore' @@ -15,8 +16,10 @@ export default function LocationButton(props: { currentLocation: CurrentLocation if (props.currentLocation.enabled) { if (!props.currentLocation.syncView) setLocationSearch('on_but_not_in_sync') else if (props.currentLocation.error) setLocationSearch('error') - else if (props.currentLocation.coordinate != null) setLocationSearch('initial') + else if (props.currentLocation.coordinate != null) setLocationSearch('on') else setLocationSearch('search') + } else { + setLocationSearch('initial') } }, [ props.currentLocation.syncView, @@ -37,9 +40,10 @@ export default function LocationButton(props: { currentLocation: CurrentLocation } }} > + {locationSearch == 'initial' && } {locationSearch == 'error' && } {locationSearch == 'search' && } - {locationSearch == 'initial' && } + {locationSearch == 'on' && } {locationSearch == 'on_but_not_in_sync' && }
) diff --git a/src/map/location.svg b/src/map/location.svg new file mode 100644 index 00000000..0a1871ad --- /dev/null +++ b/src/map/location.svg @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/src/map/location_on.svg b/src/map/location_on.svg index 0a1871ad..18711b65 100644 --- a/src/map/location_on.svg +++ b/src/map/location_on.svg @@ -1,6 +1,6 @@ - + \ No newline at end of file From 7b7d1cc318ebf59ea1a1b8a0e27aef2e5ea6d82b Mon Sep 17 00:00:00 2001 From: Peter Date: Thu, 7 Aug 2025 16:27:51 +0200 Subject: [PATCH 06/14] now stop pos watching possible via button (if in-synch state) --- src/map/LocationButton.tsx | 11 +++++++---- src/stores/CurrentLocationStore.ts | 1 - 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/src/map/LocationButton.tsx b/src/map/LocationButton.tsx index 73411eb9..65b80881 100644 --- a/src/map/LocationButton.tsx +++ b/src/map/LocationButton.tsx @@ -1,6 +1,6 @@ import styles from './LocationButton.module.css' import Dispatcher from '@/stores/Dispatcher' -import { StartSyncCurrentLocation, StartWatchCurrentLocation } from '@/actions/Actions' +import {StartSyncCurrentLocation, StartWatchCurrentLocation, StopWatchCurrentLocation} from '@/actions/Actions' import LocationError from '@/map/location_error.svg' import LocationSearching from '@/map/location_searching.svg' import LocationOn from '@/map/location_on.svg' @@ -32,11 +32,14 @@ export default function LocationButton(props: { currentLocation: CurrentLocation
{ - if (props.currentLocation.enabled && !props.currentLocation.error) { + if (props.currentLocation.enabled && !props.currentLocation.syncView && !props.currentLocation.error) { Dispatcher.dispatch(new StartSyncCurrentLocation()) } else { - Dispatcher.dispatch(new StartWatchCurrentLocation()) - setLocationSearch('search') + if (props.currentLocation.enabled) { + Dispatcher.dispatch(new StopWatchCurrentLocation()) + } else { + Dispatcher.dispatch(new StartWatchCurrentLocation()) + } } }} > diff --git a/src/stores/CurrentLocationStore.ts b/src/stores/CurrentLocationStore.ts index 991aa396..918084b1 100644 --- a/src/stores/CurrentLocationStore.ts +++ b/src/stores/CurrentLocationStore.ts @@ -48,7 +48,6 @@ export default class CurrentLocationStore extends Store Date: Thu, 7 Aug 2025 16:35:05 +0200 Subject: [PATCH 07/14] separate useEffect for layer creation and coordinate change to avoid flickering --- src/layers/UseCurrentLocationLayer.tsx | 72 ++++++++++++++++---------- 1 file changed, 44 insertions(+), 28 deletions(-) diff --git a/src/layers/UseCurrentLocationLayer.tsx b/src/layers/UseCurrentLocationLayer.tsx index a8692c97..10b75652 100644 --- a/src/layers/UseCurrentLocationLayer.tsx +++ b/src/layers/UseCurrentLocationLayer.tsx @@ -1,5 +1,5 @@ import { Feature, Map } from 'ol' -import { useEffect } from 'react' +import {useEffect, useRef} from 'react' import VectorLayer from 'ol/layer/Vector' import VectorSource from 'ol/source/Vector' import { Circle, Circle as CircleGeom, Point } from 'ol/geom' @@ -10,43 +10,59 @@ import { fromLonLat } from 'ol/proj' const LOCATION_LAYER_KEY = 'gh:current_location' export default function useCurrentLocationLayer(map: Map, locationState: CurrentLocationStoreState) { + const layerRef = useRef | null>(null) + const positionFeatureRef = useRef(null) + const accuracyFeatureRef = useRef(null) + + // Create layer once when enabled useEffect(() => { if (!locationState.enabled) { - removeCurrentLocationLayer(map) + if (layerRef.current) { + map.removeLayer(layerRef.current) + layerRef.current = null + positionFeatureRef.current = null + accuracyFeatureRef.current = null + } return - } + } else if (!layerRef.current) { + const layer = createLocationLayer() + const positionFeature = new Feature() + const accuracyFeature = new Feature() + layer.getSource()?.addFeature(positionFeature) + layer.getSource()?.addFeature(accuracyFeature) + map.addLayer(layer) - const positionFeature = new Feature() - const accuracyFeature = new Feature() - if (locationState.coordinate) { - const coord = fromLonLat([locationState.coordinate.lng, locationState.coordinate.lat]) - positionFeature.setGeometry(new Point(coord)) - accuracyFeature.setGeometry(new Circle(coord, locationState.accuracy)) + layerRef.current = layer + positionFeatureRef.current = positionFeature + accuracyFeatureRef.current = accuracyFeature + } - if (locationState.syncView) { - // TODO same code as for MoveMapToPoint action, but calling Dispatcher here is ugly - let zoom = map.getView().getZoom() - if (zoom == undefined || zoom < 8) zoom = 8 - map.getView().animate({ zoom: zoom, center: coord, duration: 400 }) + return () => { + if (layerRef.current) { + map.removeLayer(layerRef.current) + layerRef.current = null + positionFeatureRef.current = null + accuracyFeatureRef.current = null } } + }, [locationState.enabled]) - const layer = createLocationLayer() - layer.getSource()?.addFeature(positionFeature) - layer.getSource()?.addFeature(accuracyFeature) - map.addLayer(layer) - - return () => { - map.removeLayer(layer) + useEffect(() => { + if (!locationState.enabled || !locationState.coordinate || !positionFeatureRef.current || !accuracyFeatureRef.current) { + return } - }, [locationState.enabled, locationState.coordinate, locationState.syncView]) -} -function removeCurrentLocationLayer(map: Map) { - map.getLayers() - .getArray() - .filter(l => l.get(LOCATION_LAYER_KEY)) - .forEach(l => map.removeLayer(l)) + const coord = fromLonLat([locationState.coordinate.lng, locationState.coordinate.lat]) + positionFeatureRef.current.setGeometry(new Point(coord)) + accuracyFeatureRef.current.setGeometry(new Circle(coord, locationState.accuracy)) + + if (locationState.syncView) { + // TODO same code as for MoveMapToPoint action, but calling Dispatcher here is ugly + let zoom = map.getView().getZoom() + if (zoom == undefined || zoom < 8) zoom = 8 + map.getView().animate({ zoom: zoom, center: coord, duration: 400 }) + } + }, [locationState.coordinate, locationState.accuracy, locationState.syncView, locationState.enabled]) } function createLocationLayer(): VectorLayer { From 8edd35344c6e8768bb8d5a595bc295972e39d887 Mon Sep 17 00:00:00 2001 From: Peter Date: Thu, 7 Aug 2025 18:26:51 +0200 Subject: [PATCH 08/14] removed unused code --- src/layers/UseCurrentLocationLayer.tsx | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/src/layers/UseCurrentLocationLayer.tsx b/src/layers/UseCurrentLocationLayer.tsx index 10b75652..d28c87dc 100644 --- a/src/layers/UseCurrentLocationLayer.tsx +++ b/src/layers/UseCurrentLocationLayer.tsx @@ -7,8 +7,6 @@ import { Circle as CircleStyle, Fill, Stroke, Style } from 'ol/style' import { CurrentLocationStoreState } from '@/stores/CurrentLocationStore' import { fromLonLat } from 'ol/proj' -const LOCATION_LAYER_KEY = 'gh:current_location' - export default function useCurrentLocationLayer(map: Map, locationState: CurrentLocationStoreState) { const layerRef = useRef | null>(null) const positionFeatureRef = useRef(null) @@ -85,15 +83,6 @@ function createLocationLayer(): VectorLayer { }), }), }), - // Pulsing effect outer ring - // new Style({ - // image: new CircleStyle({ - // radius: 16, - // fill: new Fill({ - // color: 'rgba(66, 133, 244, 0.2)' - // }) - // }) - // }) ] } else if (geometry instanceof CircleGeom) { // Accuracy circle style From 22b34ea141ca455348958cb63a587f514d5dd41f Mon Sep 17 00:00:00 2001 From: Peter Date: Sat, 9 Aug 2025 00:49:51 +0200 Subject: [PATCH 09/14] simplify code; add heading; use 'our' blue --- src/actions/Actions.ts | 4 +- src/layers/UseCurrentLocationLayer.tsx | 106 ++++++++++++++++--------- src/map/LocationButton.tsx | 33 ++------ src/map/location_on.svg | 4 +- src/stores/CurrentLocationStore.ts | 14 +++- 5 files changed, 94 insertions(+), 67 deletions(-) diff --git a/src/actions/Actions.ts b/src/actions/Actions.ts index dbd854ab..bc20514a 100644 --- a/src/actions/Actions.ts +++ b/src/actions/Actions.ts @@ -296,9 +296,11 @@ export class CurrentLocationError implements Action { export class CurrentLocation implements Action { readonly coordinate: Coordinate readonly accuracy: number + readonly heading: number | null - constructor(coordinate: Coordinate, accuracy: number) { + constructor(coordinate: Coordinate, accuracy: number, heading: number | null) { this.coordinate = coordinate this.accuracy = accuracy + this.heading = heading } } diff --git a/src/layers/UseCurrentLocationLayer.tsx b/src/layers/UseCurrentLocationLayer.tsx index d28c87dc..c04cc4bd 100644 --- a/src/layers/UseCurrentLocationLayer.tsx +++ b/src/layers/UseCurrentLocationLayer.tsx @@ -1,16 +1,17 @@ import { Feature, Map } from 'ol' -import {useEffect, useRef} from 'react' +import { useEffect, useRef } from 'react' import VectorLayer from 'ol/layer/Vector' import VectorSource from 'ol/source/Vector' import { Circle, Circle as CircleGeom, Point } from 'ol/geom' -import { Circle as CircleStyle, Fill, Stroke, Style } from 'ol/style' +import { Circle as CircleStyle, Fill, RegularShape, Stroke, Style } from 'ol/style' import { CurrentLocationStoreState } from '@/stores/CurrentLocationStore' import { fromLonLat } from 'ol/proj' export default function useCurrentLocationLayer(map: Map, locationState: CurrentLocationStoreState) { const layerRef = useRef | null>(null) - const positionFeatureRef = useRef(null) - const accuracyFeatureRef = useRef(null) + const posFeatureRef = useRef(null) + const accFeatureRef = useRef(null) + const headingFeatureRef = useRef(null) // Create layer once when enabled useEffect(() => { @@ -18,41 +19,59 @@ export default function useCurrentLocationLayer(map: Map, locationState: Current if (layerRef.current) { map.removeLayer(layerRef.current) layerRef.current = null - positionFeatureRef.current = null - accuracyFeatureRef.current = null + posFeatureRef.current = null + accFeatureRef.current = null + headingFeatureRef.current = null } return } else if (!layerRef.current) { const layer = createLocationLayer() const positionFeature = new Feature() const accuracyFeature = new Feature() + const headingFeature = new Feature() layer.getSource()?.addFeature(positionFeature) layer.getSource()?.addFeature(accuracyFeature) + layer.getSource()?.addFeature(headingFeature) map.addLayer(layer) layerRef.current = layer - positionFeatureRef.current = positionFeature - accuracyFeatureRef.current = accuracyFeature + posFeatureRef.current = positionFeature + accFeatureRef.current = accuracyFeature + headingFeatureRef.current = headingFeature } return () => { if (layerRef.current) { map.removeLayer(layerRef.current) layerRef.current = null - positionFeatureRef.current = null - accuracyFeatureRef.current = null + posFeatureRef.current = null + accFeatureRef.current = null + headingFeatureRef.current = null } } }, [locationState.enabled]) useEffect(() => { - if (!locationState.enabled || !locationState.coordinate || !positionFeatureRef.current || !accuracyFeatureRef.current) { + if ( + !locationState.enabled || + !locationState.coordinate || + !posFeatureRef.current || + !accFeatureRef.current || + !headingFeatureRef.current + ) return - } const coord = fromLonLat([locationState.coordinate.lng, locationState.coordinate.lat]) - positionFeatureRef.current.setGeometry(new Point(coord)) - accuracyFeatureRef.current.setGeometry(new Circle(coord, locationState.accuracy)) + posFeatureRef.current.setGeometry(new Point(coord)) + accFeatureRef.current.setGeometry(new Circle(coord, locationState.accuracy)) + + // Set heading feature position (style will handle the triangle and rotation) + if (locationState.heading != null) { + headingFeatureRef.current.setGeometry(new Point(coord)) + headingFeatureRef.current.set('heading', locationState.heading) + } else { + headingFeatureRef.current.setGeometry(undefined) + } if (locationState.syncView) { // TODO same code as for MoveMapToPoint action, but calling Dispatcher here is ugly @@ -60,46 +79,57 @@ export default function useCurrentLocationLayer(map: Map, locationState: Current if (zoom == undefined || zoom < 8) zoom = 8 map.getView().animate({ zoom: zoom, center: coord, duration: 400 }) } - }, [locationState.coordinate, locationState.accuracy, locationState.syncView, locationState.enabled]) + }, [ + locationState.coordinate, + locationState.accuracy, + locationState.heading, + locationState.syncView, + locationState.enabled, + ]) } function createLocationLayer(): VectorLayer { - const layer = new VectorLayer({ + return new VectorLayer({ source: new VectorSource(), style: feature => { const geometry = feature.getGeometry() if (geometry instanceof Point) { - // Blue dot style for position - return [ - new Style({ + // Check if this is the heading feature + const heading = feature.get('heading') + if (heading !== undefined) { + // Triangle style for heading direction + return new Style({ + image: new RegularShape({ + points: 3, + radius: 8, + displacement: [0, 9], + rotation: (heading * Math.PI) / 180, // Convert degrees to radians + fill: new Fill({ color: '#368fe8' }), + stroke: new Stroke({ color: '#FFFFFF', width: 1 }), + }), + zIndex: 1, + }) + } else { + // Blue dot style for position + return new Style({ image: new CircleStyle({ radius: 8, - fill: new Fill({ - color: '#4285F4', - }), - stroke: new Stroke({ - color: '#FFFFFF', - width: 2, - }), + fill: new Fill({ color: '#368fe8' }), + stroke: new Stroke({ color: '#FFFFFF', width: 2 }), }), - }), - ] + zIndex: 2, // above the others + }) + } } else if (geometry instanceof CircleGeom) { // Accuracy circle style return new Style({ - fill: new Fill({ - color: 'rgba(66, 133, 244, 0.1)', - }), - stroke: new Stroke({ - color: 'rgba(66, 133, 244, 0.3)', - width: 1, - }), + fill: new Fill({ color: 'rgba(66, 133, 244, 0.1)' }), + stroke: new Stroke({ color: 'rgba(66, 133, 244, 0.3)', width: 1 }), + zIndex: 0, // behind the others }) } return [] }, + zIndex: 4, // Above paths and query points }) - - layer.setZIndex(4) // Above paths and query points - return layer } diff --git a/src/map/LocationButton.tsx b/src/map/LocationButton.tsx index 65b80881..fa9ddd72 100644 --- a/src/map/LocationButton.tsx +++ b/src/map/LocationButton.tsx @@ -1,33 +1,14 @@ import styles from './LocationButton.module.css' import Dispatcher from '@/stores/Dispatcher' -import {StartSyncCurrentLocation, StartWatchCurrentLocation, StopWatchCurrentLocation} from '@/actions/Actions' +import { StartSyncCurrentLocation, StartWatchCurrentLocation, StopWatchCurrentLocation } from '@/actions/Actions' import LocationError from '@/map/location_error.svg' import LocationSearching from '@/map/location_searching.svg' import LocationOn from '@/map/location_on.svg' import Location from '@/map/location.svg' import LocationNotInSync from '@/map/location_not_in_sync.svg' -import { useEffect, useState } from 'react' import { CurrentLocationStoreState } from '@/stores/CurrentLocationStore' export default function LocationButton(props: { currentLocation: CurrentLocationStoreState }) { - const [locationSearch, setLocationSearch] = useState('initial') - - useEffect(() => { - if (props.currentLocation.enabled) { - if (!props.currentLocation.syncView) setLocationSearch('on_but_not_in_sync') - else if (props.currentLocation.error) setLocationSearch('error') - else if (props.currentLocation.coordinate != null) setLocationSearch('on') - else setLocationSearch('search') - } else { - setLocationSearch('initial') - } - }, [ - props.currentLocation.syncView, - props.currentLocation.error, - props.currentLocation.enabled, - props.currentLocation.coordinate, - ]) - return (
- {locationSearch == 'initial' && } - {locationSearch == 'error' && } - {locationSearch == 'search' && } - {locationSearch == 'on' && } - {locationSearch == 'on_but_not_in_sync' && } + {(() => { + if (props.currentLocation.error) return + if (!props.currentLocation.enabled) return + if (!props.currentLocation.syncView) return + if (props.currentLocation.coordinate != null) return + return + })()}
) } diff --git a/src/map/location_on.svg b/src/map/location_on.svg index 18711b65..643c3be1 100644 --- a/src/map/location_on.svg +++ b/src/map/location_on.svg @@ -1,6 +1,6 @@ - - + \ No newline at end of file diff --git a/src/stores/CurrentLocationStore.ts b/src/stores/CurrentLocationStore.ts index 918084b1..a95b7639 100644 --- a/src/stores/CurrentLocationStore.ts +++ b/src/stores/CurrentLocationStore.ts @@ -16,6 +16,7 @@ export interface CurrentLocationStoreState { enabled: boolean syncView: boolean accuracy: number // meters + heading: number | null coordinate: Coordinate | null } @@ -28,11 +29,15 @@ export default class CurrentLocationStore extends Store 0.1) { + map.getView().animate({ zoom: targetZoom, center: coord, duration: 400 }) + } else { + // for smaller zoom changes set center without animation to avoid pulsing of map + map.getView().setCenter(coord) + } } }, [ locationState.coordinate, diff --git a/src/map/location_not_in_sync.svg b/src/map/location_not_in_sync.svg index 9679f21b..6534e601 100644 --- a/src/map/location_not_in_sync.svg +++ b/src/map/location_not_in_sync.svg @@ -1,3 +1,3 @@ - + \ No newline at end of file From 7198b67f0976e74a4d0e04f9bbdacefe1a11f084 Mon Sep 17 00:00:00 2001 From: Peter Date: Sat, 9 Aug 2025 01:15:29 +0200 Subject: [PATCH 11/14] closer zoom --- src/layers/UseCurrentLocationLayer.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/layers/UseCurrentLocationLayer.tsx b/src/layers/UseCurrentLocationLayer.tsx index c2ebcfbe..d190ab93 100644 --- a/src/layers/UseCurrentLocationLayer.tsx +++ b/src/layers/UseCurrentLocationLayer.tsx @@ -75,7 +75,7 @@ export default function useCurrentLocationLayer(map: Map, locationState: Current if (locationState.syncView) { const currentZoom = map.getView().getZoom() - const targetZoom = currentZoom == undefined || currentZoom < 8 ? 8 : currentZoom + const targetZoom = currentZoom == undefined || currentZoom < 16 ? 16 : currentZoom const zoomDifference = Math.abs(targetZoom - (currentZoom || 0)) if (zoomDifference > 0.1) { map.getView().animate({ zoom: targetZoom, center: coord, duration: 400 }) From 36c39468635243e53c0a0da505a9086a62196f67 Mon Sep 17 00:00:00 2001 From: Peter Date: Sat, 9 Aug 2025 01:16:49 +0200 Subject: [PATCH 12/14] more desriptive names --- src/layers/UseCurrentLocationLayer.tsx | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/src/layers/UseCurrentLocationLayer.tsx b/src/layers/UseCurrentLocationLayer.tsx index d190ab93..99c8737d 100644 --- a/src/layers/UseCurrentLocationLayer.tsx +++ b/src/layers/UseCurrentLocationLayer.tsx @@ -9,8 +9,8 @@ import { fromLonLat } from 'ol/proj' export default function useCurrentLocationLayer(map: Map, locationState: CurrentLocationStoreState) { const layerRef = useRef | null>(null) - const posFeatureRef = useRef(null) - const accFeatureRef = useRef(null) + const positionFeatureRef = useRef(null) + const accuracyFeatureRef = useRef(null) const headingFeatureRef = useRef(null) // Create layer once when enabled @@ -19,8 +19,8 @@ export default function useCurrentLocationLayer(map: Map, locationState: Current if (layerRef.current) { map.removeLayer(layerRef.current) layerRef.current = null - posFeatureRef.current = null - accFeatureRef.current = null + positionFeatureRef.current = null + accuracyFeatureRef.current = null headingFeatureRef.current = null } return @@ -35,8 +35,8 @@ export default function useCurrentLocationLayer(map: Map, locationState: Current map.addLayer(layer) layerRef.current = layer - posFeatureRef.current = positionFeature - accFeatureRef.current = accuracyFeature + positionFeatureRef.current = positionFeature + accuracyFeatureRef.current = accuracyFeature headingFeatureRef.current = headingFeature } @@ -44,8 +44,8 @@ export default function useCurrentLocationLayer(map: Map, locationState: Current if (layerRef.current) { map.removeLayer(layerRef.current) layerRef.current = null - posFeatureRef.current = null - accFeatureRef.current = null + positionFeatureRef.current = null + accuracyFeatureRef.current = null headingFeatureRef.current = null } } @@ -55,15 +55,15 @@ export default function useCurrentLocationLayer(map: Map, locationState: Current if ( !locationState.enabled || !locationState.coordinate || - !posFeatureRef.current || - !accFeatureRef.current || + !positionFeatureRef.current || + !accuracyFeatureRef.current || !headingFeatureRef.current ) return const coord = fromLonLat([locationState.coordinate.lng, locationState.coordinate.lat]) - posFeatureRef.current.setGeometry(new Point(coord)) - accFeatureRef.current.setGeometry(new Circle(coord, locationState.accuracy)) + positionFeatureRef.current.setGeometry(new Point(coord)) + accuracyFeatureRef.current.setGeometry(new Circle(coord, locationState.accuracy)) // Set heading feature position (style will handle the triangle and rotation) if (locationState.heading != null) { From c0db4fbc6c7bf710747f48ae13953addd09ef292 Mon Sep 17 00:00:00 2001 From: Peter Date: Sat, 9 Aug 2025 01:22:19 +0200 Subject: [PATCH 13/14] minor cleanup --- src/layers/UseCurrentLocationLayer.tsx | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/src/layers/UseCurrentLocationLayer.tsx b/src/layers/UseCurrentLocationLayer.tsx index 99c8737d..ba460415 100644 --- a/src/layers/UseCurrentLocationLayer.tsx +++ b/src/layers/UseCurrentLocationLayer.tsx @@ -13,7 +13,6 @@ export default function useCurrentLocationLayer(map: Map, locationState: Current const accuracyFeatureRef = useRef(null) const headingFeatureRef = useRef(null) - // Create layer once when enabled useEffect(() => { if (!locationState.enabled) { if (layerRef.current) { @@ -65,12 +64,13 @@ export default function useCurrentLocationLayer(map: Map, locationState: Current positionFeatureRef.current.setGeometry(new Point(coord)) accuracyFeatureRef.current.setGeometry(new Circle(coord, locationState.accuracy)) - // Set heading feature position (style will handle the triangle and rotation) + // set heading feature position (style will handle the triangle and rotation) if (locationState.heading != null) { headingFeatureRef.current.setGeometry(new Point(coord)) headingFeatureRef.current.set('heading', locationState.heading) } else { headingFeatureRef.current.setGeometry(undefined) + headingFeatureRef.current.unset('heading') // not strictly necessary } if (locationState.syncView) { @@ -99,23 +99,22 @@ function createLocationLayer(): VectorLayer { style: feature => { const geometry = feature.getGeometry() if (geometry instanceof Point) { - // Check if this is the heading feature const heading = feature.get('heading') if (heading !== undefined) { - // Triangle style for heading direction + // triangle style for heading direction return new Style({ image: new RegularShape({ points: 3, radius: 8, displacement: [0, 9], - rotation: (heading * Math.PI) / 180, // Convert degrees to radians + rotation: (heading * Math.PI) / 180, // convert degrees to radians fill: new Fill({ color: '#368fe8' }), stroke: new Stroke({ color: '#FFFFFF', width: 1 }), }), zIndex: 1, }) } else { - // Blue dot style for position + // blue dot style for position return new Style({ image: new CircleStyle({ radius: 8, @@ -126,7 +125,7 @@ function createLocationLayer(): VectorLayer { }) } } else if (geometry instanceof CircleGeom) { - // Accuracy circle style + // accuracy circle style return new Style({ fill: new Fill({ color: 'rgba(66, 133, 244, 0.1)' }), stroke: new Stroke({ color: 'rgba(66, 133, 244, 0.3)', width: 1 }), @@ -135,6 +134,6 @@ function createLocationLayer(): VectorLayer { } return [] }, - zIndex: 4, // Above paths and query points + zIndex: 4, // layer itself should be above paths and query points }) } From b4bdbcabcdac9608af8e849988d80da7afc10420 Mon Sep 17 00:00:00 2001 From: Peter Date: Sat, 9 Aug 2025 01:39:13 +0200 Subject: [PATCH 14/14] simplify --- src/layers/UseCurrentLocationLayer.tsx | 20 +++++--------------- 1 file changed, 5 insertions(+), 15 deletions(-) diff --git a/src/layers/UseCurrentLocationLayer.tsx b/src/layers/UseCurrentLocationLayer.tsx index ba460415..bd0d5f8c 100644 --- a/src/layers/UseCurrentLocationLayer.tsx +++ b/src/layers/UseCurrentLocationLayer.tsx @@ -18,34 +18,22 @@ export default function useCurrentLocationLayer(map: Map, locationState: Current if (layerRef.current) { map.removeLayer(layerRef.current) layerRef.current = null - positionFeatureRef.current = null - accuracyFeatureRef.current = null - headingFeatureRef.current = null } return } else if (!layerRef.current) { const layer = createLocationLayer() - const positionFeature = new Feature() - const accuracyFeature = new Feature() - const headingFeature = new Feature() - layer.getSource()?.addFeature(positionFeature) - layer.getSource()?.addFeature(accuracyFeature) - layer.getSource()?.addFeature(headingFeature) + layer.getSource()?.addFeature((positionFeatureRef.current = new Feature())) + layer.getSource()?.addFeature((accuracyFeatureRef.current = new Feature())) + layer.getSource()?.addFeature((headingFeatureRef.current = new Feature())) map.addLayer(layer) layerRef.current = layer - positionFeatureRef.current = positionFeature - accuracyFeatureRef.current = accuracyFeature - headingFeatureRef.current = headingFeature } return () => { if (layerRef.current) { map.removeLayer(layerRef.current) layerRef.current = null - positionFeatureRef.current = null - accuracyFeatureRef.current = null - headingFeatureRef.current = null } } }, [locationState.enabled]) @@ -54,6 +42,8 @@ export default function useCurrentLocationLayer(map: Map, locationState: Current if ( !locationState.enabled || !locationState.coordinate || + !layerRef.current || + // typescript complaints without the following !positionFeatureRef.current || !accuracyFeatureRef.current || !headingFeatureRef.current