@@ -10,7 +10,14 @@ import {
1010} from '@maplibre/maplibre-react-native' ;
1111import { useCallback , useEffect , useMemo , useRef , useState } from 'react' ;
1212import type { NativeSyntheticEvent } from 'react-native' ;
13- import { ActivityIndicator , StyleSheet , Text , View } from 'react-native' ;
13+ import {
14+ ActivityIndicator ,
15+ StyleSheet ,
16+ Text ,
17+ TouchableOpacity ,
18+ View ,
19+ } from 'react-native' ;
20+ import { useSafeAreaInsets } from 'react-native-safe-area-context' ;
1421import {
1522 type ElementsQuery ,
1623 type ElementsQueryVariables ,
@@ -21,11 +28,19 @@ type ElementWithLocation = ElementsQuery['elements'][number];
2128
2229const MAP_STYLE = 'https://tiles.openfreemap.org/styles/liberty' ;
2330const USER_ZOOM = 14 ;
31+ // Show the recenter button once the viewport center drifts more than this
32+ // fraction of the visible span away from the user in either axis.
33+ const OFF_CENTER_THRESHOLD = 0.2 ;
34+ // Vertical clearance above the device safe area so the recenter button sits
35+ // above the app's bottom "signed in as…" bar in App.tsx. Keep in sync if that
36+ // bar's height changes.
37+ const BOTTOM_BAR_CLEARANCE = 64 ;
2438
2539export function MapScreen ( ) {
2640 const cameraRef = useRef < CameraRef > ( null ) ;
2741 const hasCenteredRef = useRef ( false ) ;
2842 const [ permissionGranted , setPermissionGranted ] = useState ( false ) ;
43+ const safeAreaInsets = useSafeAreaInsets ( ) ;
2944
3045 useEffect ( ( ) => {
3146 let cancelled = false ;
@@ -41,18 +56,27 @@ export function MapScreen() {
4156 } , [ ] ) ;
4257
4358 const position = useCurrentPosition ( { enabled : permissionGranted } ) ;
59+ const positionRef = useRef ( position ) ;
60+ positionRef . current = position ;
4461
45- useEffect ( ( ) => {
46- if ( hasCenteredRef . current || ! position ) return ;
47- hasCenteredRef . current = true ;
62+ const flyToUser = useCallback ( ( ) => {
63+ const pos = positionRef . current ;
64+ if ( ! pos ) return ;
4865 cameraRef . current ?. flyTo ( {
49- center : [ position . coords . longitude , position . coords . latitude ] ,
66+ center : [ pos . coords . longitude , pos . coords . latitude ] ,
5067 zoom : USER_ZOOM ,
5168 duration : 1500 ,
5269 } ) ;
53- } , [ position ] ) ;
70+ } , [ ] ) ;
71+
72+ useEffect ( ( ) => {
73+ if ( hasCenteredRef . current || ! position ) return ;
74+ hasCenteredRef . current = true ;
75+ flyToUser ( ) ;
76+ } , [ position , flyToUser ] ) ;
5477
5578 const [ bounds , setBounds ] = useState < ElementsQueryVariables [ 'bounds' ] > ( ) ;
79+ const [ isCenteredOnUser , setIsCenteredOnUser ] = useState ( true ) ;
5680
5781 const onRegionDidChange = useCallback (
5882 ( event : NativeSyntheticEvent < ViewStateChangeEvent > ) => {
@@ -61,6 +85,17 @@ export function MapScreen() {
6185 if ( ! hasCenteredRef . current ) return ;
6286 const [ west , south , east , north ] = event . nativeEvent . bounds ;
6387 setBounds ( { left : west , bottom : south , right : east , top : north } ) ;
88+
89+ const pos = positionRef . current ;
90+ if ( ! pos ) return ;
91+ const [ centerLng , centerLat ] = event . nativeEvent . center ;
92+ const spanLng = east - west ;
93+ const spanLat = north - south ;
94+ const offLng = Math . abs ( centerLng - pos . coords . longitude ) / spanLng ;
95+ const offLat = Math . abs ( centerLat - pos . coords . latitude ) / spanLat ;
96+ setIsCenteredOnUser (
97+ offLng <= OFF_CENTER_THRESHOLD && offLat <= OFF_CENTER_THRESHOLD ,
98+ ) ;
6499 } ,
65100 [ ] ,
66101 ) ;
@@ -124,6 +159,18 @@ export function MapScreen() {
124159 < ActivityIndicator size = "large" color = "#1d6fe0" />
125160 </ View >
126161 ) : null }
162+ { position && ! isCenteredOnUser ? (
163+ < TouchableOpacity
164+ accessibilityLabel = "Recenter map on your location"
165+ accessibilityRole = "button"
166+ onPress = { flyToUser }
167+ style = { [
168+ styles . recenterButton ,
169+ { bottom : safeAreaInsets . bottom + BOTTOM_BAR_CLEARANCE } ,
170+ ] } >
171+ < Text style = { styles . recenterIcon } > ◎</ Text >
172+ </ TouchableOpacity >
173+ ) : null }
127174 </ View >
128175 ) ;
129176}
@@ -160,4 +207,24 @@ const styles = StyleSheet.create({
160207 justifyContent : 'center' ,
161208 backgroundColor : 'rgba(255,255,255,0.6)' ,
162209 } ,
210+ recenterButton : {
211+ position : 'absolute' ,
212+ right : 16 ,
213+ width : 48 ,
214+ height : 48 ,
215+ borderRadius : 24 ,
216+ backgroundColor : '#ffffff' ,
217+ alignItems : 'center' ,
218+ justifyContent : 'center' ,
219+ shadowColor : '#000' ,
220+ shadowOpacity : 0.2 ,
221+ shadowRadius : 4 ,
222+ shadowOffset : { width : 0 , height : 2 } ,
223+ elevation : 4 ,
224+ } ,
225+ recenterIcon : {
226+ fontSize : 24 ,
227+ lineHeight : 28 ,
228+ color : '#1d6fe0' ,
229+ } ,
163230} ) ;
0 commit comments