Skip to content

Commit ce24228

Browse files
karusselloblonski
andauthored
Current location tracking (#432)
* show current location as blue dot - fix #430 * show current location as blue dot - fix #430 * new location layer, new 'not in sync' state, todo: precision radius and real world testing * show accurracy radius and show search animation * show different state via button color * now stop pos watching possible via button (if in-synch state) * separate useEffect for layer creation and coordinate change to avoid flickering * removed unused code * simplify code; add heading; use 'our' blue * try to fix 'pulsing' * closer zoom * more desriptive names * minor cleanup * simplify --------- Co-authored-by: oblonski <[email protected]>
1 parent c062951 commit ce24228

16 files changed

+394
-67
lines changed

src/App.tsx

Lines changed: 34 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
getQueryStore,
1212
getRouteStore,
1313
getSettingsStore,
14+
getCurrentLocationStore,
1415
} from '@/stores/Stores'
1516
import MapComponent from '@/map/MapComponent'
1617
import MapOptions from '@/map/MapOptions'
@@ -22,6 +23,7 @@ import { QueryStoreState, RequestState } from '@/stores/QueryStore'
2223
import { RouteStoreState } from '@/stores/RouteStore'
2324
import { MapOptionsStoreState } from '@/stores/MapOptionsStore'
2425
import { ErrorStoreState } from '@/stores/ErrorStore'
26+
import { CurrentLocationStoreState } from '@/stores/CurrentLocationStore'
2527
import Search from '@/sidebar/search/Search'
2628
import ErrorMessage from '@/sidebar/ErrorMessage'
2729
import useBackgroundLayer from '@/layers/UseBackgroundLayer'
@@ -45,6 +47,7 @@ import useExternalMVTLayer from '@/layers/UseExternalMVTLayer'
4547
import LocationButton from '@/map/LocationButton'
4648
import { SettingsContext } from '@/contexts/SettingsContext'
4749
import usePOIsLayer from '@/layers/UsePOIsLayer'
50+
import useCurrentLocationLayer from '@/layers/UseCurrentLocationLayer'
4851

4952
export const POPUP_CONTAINER_ID = 'popup-container'
5053
export const SIDEBAR_CONTENT_ID = 'sidebar-content'
@@ -59,6 +62,7 @@ export default function App() {
5962
const [pathDetails, setPathDetails] = useState(getPathDetailsStore().state)
6063
const [mapFeatures, setMapFeatures] = useState(getMapFeatureStore().state)
6164
const [pois, setPOIs] = useState(getPOIsStore().state)
65+
const [currentLocation, setCurrentLocation] = useState(getCurrentLocationStore().state)
6266

6367
const map = getMap()
6468

@@ -72,6 +76,7 @@ export default function App() {
7276
const onPathDetailsChanged = () => setPathDetails(getPathDetailsStore().state)
7377
const onMapFeaturesChanged = () => setMapFeatures(getMapFeatureStore().state)
7478
const onPOIsChanged = () => setPOIs(getPOIsStore().state)
79+
const onCurrentLocationChanged = () => setCurrentLocation(getCurrentLocationStore().state)
7580

7681
getSettingsStore().register(onSettingsChanged)
7782
getQueryStore().register(onQueryChanged)
@@ -82,6 +87,7 @@ export default function App() {
8287
getPathDetailsStore().register(onPathDetailsChanged)
8388
getMapFeatureStore().register(onMapFeaturesChanged)
8489
getPOIsStore().register(onPOIsChanged)
90+
getCurrentLocationStore().register(onCurrentLocationChanged)
8591

8692
onQueryChanged()
8793
onInfoChanged()
@@ -91,6 +97,7 @@ export default function App() {
9197
onPathDetailsChanged()
9298
onMapFeaturesChanged()
9399
onPOIsChanged()
100+
onCurrentLocationChanged()
94101

95102
return () => {
96103
getSettingsStore().deregister(onSettingsChanged)
@@ -102,6 +109,7 @@ export default function App() {
102109
getPathDetailsStore().deregister(onPathDetailsChanged)
103110
getMapFeatureStore().deregister(onMapFeaturesChanged)
104111
getPOIsStore().deregister(onPOIsChanged)
112+
getCurrentLocationStore().deregister(onCurrentLocationChanged)
105113
}
106114
}, [])
107115

@@ -116,6 +124,7 @@ export default function App() {
116124
useQueryPointsLayer(map, query.queryPoints)
117125
usePathDetailsLayer(map, pathDetails)
118126
usePOIsLayer(map, pois)
127+
useCurrentLocationLayer(map, currentLocation)
119128

120129
const isSmallScreen = useMediaQuery({ query: '(max-width: 44rem)' })
121130
return (
@@ -138,6 +147,7 @@ export default function App() {
138147
error={error}
139148
encodedValues={info.encoded_values}
140149
drawAreas={settings.drawAreasEnabled}
150+
currentLocation={currentLocation}
141151
/>
142152
) : (
143153
<LargeScreenLayout
@@ -148,6 +158,7 @@ export default function App() {
148158
error={error}
149159
encodedValues={info.encoded_values}
150160
drawAreas={settings.drawAreasEnabled}
161+
currentLocation={currentLocation}
151162
/>
152163
)}
153164
</div>
@@ -160,12 +171,22 @@ interface LayoutProps {
160171
route: RouteStoreState
161172
map: Map
162173
mapOptions: MapOptionsStoreState
174+
currentLocation: CurrentLocationStoreState
163175
error: ErrorStoreState
164176
encodedValues: object[]
165177
drawAreas: boolean
166178
}
167179

168-
function LargeScreenLayout({ query, route, map, error, mapOptions, encodedValues, drawAreas }: LayoutProps) {
180+
function LargeScreenLayout({
181+
query,
182+
route,
183+
map,
184+
error,
185+
mapOptions,
186+
encodedValues,
187+
drawAreas,
188+
currentLocation,
189+
}: LayoutProps) {
169190
const [showSidebar, setShowSidebar] = useState(true)
170191
const [showCustomModelBox, setShowCustomModelBox] = useState(false)
171192
return (
@@ -216,7 +237,7 @@ function LargeScreenLayout({ query, route, map, error, mapOptions, encodedValues
216237
<div className={styles.popupContainer} id={POPUP_CONTAINER_ID} />
217238
<div className={styles.onMapRightSide}>
218239
<MapOptions {...mapOptions} />
219-
<LocationButton queryPoints={query.queryPoints} />
240+
<LocationButton currentLocation={currentLocation} />
220241
</div>
221242
<div className={styles.map}>
222243
<MapComponent map={map} />
@@ -229,7 +250,16 @@ function LargeScreenLayout({ query, route, map, error, mapOptions, encodedValues
229250
)
230251
}
231252

232-
function SmallScreenLayout({ query, route, map, error, mapOptions, encodedValues, drawAreas }: LayoutProps) {
253+
function SmallScreenLayout({
254+
query,
255+
route,
256+
map,
257+
error,
258+
mapOptions,
259+
encodedValues,
260+
drawAreas,
261+
currentLocation,
262+
}: LayoutProps) {
233263
return (
234264
<>
235265
<div className={styles.smallScreenSidebar}>
@@ -248,7 +278,7 @@ function SmallScreenLayout({ query, route, map, error, mapOptions, encodedValues
248278
<div className={styles.smallScreenMapOptions}>
249279
<div className={styles.onMapRightSide}>
250280
<MapOptions {...mapOptions} />
251-
<LocationButton queryPoints={query.queryPoints} />
281+
<LocationButton currentLocation={currentLocation} />
252282
</div>
253283
</div>
254284

src/actions/Actions.ts

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -189,7 +189,7 @@ export class ToggleExternalMVTLayer implements Action {
189189

190190
export class MapIsLoaded implements Action {}
191191

192-
export class ZoomMapToPoint implements Action {
192+
export class MoveMapToPoint implements Action {
193193
readonly coordinate: Coordinate
194194

195195
constructor(coordinate: Coordinate) {
@@ -272,3 +272,35 @@ export class SetPOIs implements Action {
272272
this.pois = pois
273273
}
274274
}
275+
276+
/**
277+
* Start watching the location and synchronizing the view.
278+
*/
279+
export class StartWatchCurrentLocation implements Action {}
280+
export class StopWatchCurrentLocation implements Action {}
281+
282+
/**
283+
* Start synchronizing the view again.
284+
*/
285+
export class StartSyncCurrentLocation implements Action {}
286+
export class StopSyncCurrentLocation implements Action {}
287+
288+
export class CurrentLocationError implements Action {
289+
readonly error: string
290+
291+
constructor(error: string) {
292+
this.error = error
293+
}
294+
}
295+
296+
export class CurrentLocation implements Action {
297+
readonly coordinate: Coordinate
298+
readonly accuracy: number
299+
readonly heading: number | null
300+
301+
constructor(coordinate: Coordinate, accuracy: number, heading: number | null) {
302+
this.coordinate = coordinate
303+
this.accuracy = accuracy
304+
this.heading = heading
305+
}
306+
}

src/index.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
getQueryStore,
1414
getRouteStore,
1515
getSettingsStore,
16+
getCurrentLocationStore,
1617
setStores,
1718
} from '@/stores/Stores'
1819
import Dispatcher from '@/stores/Dispatcher'
@@ -31,6 +32,7 @@ import MapFeatureStore from '@/stores/MapFeatureStore'
3132
import SettingsStore from '@/stores/SettingsStore'
3233
import { ErrorAction, InfoReceived } from '@/actions/Actions'
3334
import POIsStore from '@/stores/POIsStore'
35+
import CurrentLocationStore from '@/stores/CurrentLocationStore'
3436
import { setDistanceFormat } from '@/Converters'
3537
import { AddressParseResult } from '@/pois/AddressParseResult'
3638

@@ -61,6 +63,7 @@ setStores({
6163
pathDetailsStore: new PathDetailsStore(),
6264
mapFeatureStore: new MapFeatureStore(),
6365
poisStore: new POIsStore(),
66+
currentLocationStore: new CurrentLocationStore(),
6467
})
6568

6669
setMap(createMap())
@@ -75,6 +78,7 @@ Dispatcher.register(getMapOptionsStore())
7578
Dispatcher.register(getPathDetailsStore())
7679
Dispatcher.register(getMapFeatureStore())
7780
Dispatcher.register(getPOIsStore())
81+
Dispatcher.register(getCurrentLocationStore())
7882

7983
// register map action receiver
8084
const smallScreenMediaQuery = window.matchMedia('(max-width: 44rem)')
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
import { Feature, Map } from 'ol'
2+
import { useEffect, useRef } from 'react'
3+
import VectorLayer from 'ol/layer/Vector'
4+
import VectorSource from 'ol/source/Vector'
5+
import { Circle, Circle as CircleGeom, Point } from 'ol/geom'
6+
import { Circle as CircleStyle, Fill, RegularShape, Stroke, Style } from 'ol/style'
7+
import { CurrentLocationStoreState } from '@/stores/CurrentLocationStore'
8+
import { fromLonLat } from 'ol/proj'
9+
10+
export default function useCurrentLocationLayer(map: Map, locationState: CurrentLocationStoreState) {
11+
const layerRef = useRef<VectorLayer<VectorSource> | null>(null)
12+
const positionFeatureRef = useRef<Feature | null>(null)
13+
const accuracyFeatureRef = useRef<Feature | null>(null)
14+
const headingFeatureRef = useRef<Feature | null>(null)
15+
16+
useEffect(() => {
17+
if (!locationState.enabled) {
18+
if (layerRef.current) {
19+
map.removeLayer(layerRef.current)
20+
layerRef.current = null
21+
}
22+
return
23+
} else if (!layerRef.current) {
24+
const layer = createLocationLayer()
25+
layer.getSource()?.addFeature((positionFeatureRef.current = new Feature()))
26+
layer.getSource()?.addFeature((accuracyFeatureRef.current = new Feature()))
27+
layer.getSource()?.addFeature((headingFeatureRef.current = new Feature()))
28+
map.addLayer(layer)
29+
30+
layerRef.current = layer
31+
}
32+
33+
return () => {
34+
if (layerRef.current) {
35+
map.removeLayer(layerRef.current)
36+
layerRef.current = null
37+
}
38+
}
39+
}, [locationState.enabled])
40+
41+
useEffect(() => {
42+
if (
43+
!locationState.enabled ||
44+
!locationState.coordinate ||
45+
!layerRef.current ||
46+
// typescript complaints without the following
47+
!positionFeatureRef.current ||
48+
!accuracyFeatureRef.current ||
49+
!headingFeatureRef.current
50+
)
51+
return
52+
53+
const coord = fromLonLat([locationState.coordinate.lng, locationState.coordinate.lat])
54+
positionFeatureRef.current.setGeometry(new Point(coord))
55+
accuracyFeatureRef.current.setGeometry(new Circle(coord, locationState.accuracy))
56+
57+
// set heading feature position (style will handle the triangle and rotation)
58+
if (locationState.heading != null) {
59+
headingFeatureRef.current.setGeometry(new Point(coord))
60+
headingFeatureRef.current.set('heading', locationState.heading)
61+
} else {
62+
headingFeatureRef.current.setGeometry(undefined)
63+
headingFeatureRef.current.unset('heading') // not strictly necessary
64+
}
65+
66+
if (locationState.syncView) {
67+
const currentZoom = map.getView().getZoom()
68+
const targetZoom = currentZoom == undefined || currentZoom < 16 ? 16 : currentZoom
69+
const zoomDifference = Math.abs(targetZoom - (currentZoom || 0))
70+
if (zoomDifference > 0.1) {
71+
map.getView().animate({ zoom: targetZoom, center: coord, duration: 400 })
72+
} else {
73+
// for smaller zoom changes set center without animation to avoid pulsing of map
74+
map.getView().setCenter(coord)
75+
}
76+
}
77+
}, [
78+
locationState.coordinate,
79+
locationState.accuracy,
80+
locationState.heading,
81+
locationState.syncView,
82+
locationState.enabled,
83+
])
84+
}
85+
86+
function createLocationLayer(): VectorLayer<VectorSource> {
87+
return new VectorLayer({
88+
source: new VectorSource(),
89+
style: feature => {
90+
const geometry = feature.getGeometry()
91+
if (geometry instanceof Point) {
92+
const heading = feature.get('heading')
93+
if (heading !== undefined) {
94+
// triangle style for heading direction
95+
return new Style({
96+
image: new RegularShape({
97+
points: 3,
98+
radius: 8,
99+
displacement: [0, 9],
100+
rotation: (heading * Math.PI) / 180, // convert degrees to radians
101+
fill: new Fill({ color: '#368fe8' }),
102+
stroke: new Stroke({ color: '#FFFFFF', width: 1 }),
103+
}),
104+
zIndex: 1,
105+
})
106+
} else {
107+
// blue dot style for position
108+
return new Style({
109+
image: new CircleStyle({
110+
radius: 8,
111+
fill: new Fill({ color: '#368fe8' }),
112+
stroke: new Stroke({ color: '#FFFFFF', width: 2 }),
113+
}),
114+
zIndex: 2, // above the others
115+
})
116+
}
117+
} else if (geometry instanceof CircleGeom) {
118+
// accuracy circle style
119+
return new Style({
120+
fill: new Fill({ color: 'rgba(66, 133, 244, 0.1)' }),
121+
stroke: new Stroke({ color: 'rgba(66, 133, 244, 0.3)', width: 1 }),
122+
zIndex: 0, // behind the others
123+
})
124+
}
125+
return []
126+
},
127+
zIndex: 4, // layer itself should be above paths and query points
128+
})
129+
}

src/layers/UseQueryPointsLayer.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,7 @@ function removeDragInteractions(map: Map) {
8585
.forEach(i => map.removeInteraction(i))
8686
}
8787

88-
function addDragInteractions(map: Map, queryPointsLayer: VectorLayer<VectorSource<Feature<Geometry>>>) {
88+
function addDragInteractions(map: Map, queryPointsLayer: VectorLayer<VectorSource>) {
8989
let tmp = queryPointsLayer.getSource()
9090
if (tmp == null) throw new Error('source must not be null') // typescript requires this
9191
const modify = new Modify({

src/map/ContextMenuContent.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { coordinateToText } from '@/Converters'
33
import styles from './ContextMenuContent.module.css'
44
import QueryStore, { QueryPoint, QueryPointType } from '@/stores/QueryStore'
55
import Dispatcher from '@/stores/Dispatcher'
6-
import { AddPoint, SetPoint, ZoomMapToPoint } from '@/actions/Actions'
6+
import { AddPoint, SetPoint, MoveMapToPoint } from '@/actions/Actions'
77
import { RouteStoreState } from '@/stores/RouteStore'
88
import { findNextWayPoint } from '@/map/findNextWayPoint'
99
import { tr } from '@/translation/Translation'
@@ -143,7 +143,7 @@ export function ContextMenuContent({
143143
className={styles.entry}
144144
onClick={() => {
145145
onSelect()
146-
Dispatcher.dispatch(new ZoomMapToPoint(coordinate))
146+
Dispatcher.dispatch(new MoveMapToPoint(coordinate))
147147
}}
148148
>
149149
{tr('center_map')}

0 commit comments

Comments
 (0)