Skip to content

Commit 46d6e26

Browse files
dfallingclaude
andcommitted
Show recenter button when the map drifts off the user
Tracks viewport center after each region change and compares it to the user's location. When the user has panned more than 20% of the visible span away in either axis, surface a floating button that flies the camera back to their location at the same zoom level we use initially. Positioned with safe-area insets plus a fixed clearance so it sits above the "signed in as…" bar at the bottom of App.tsx. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 59a10e1 commit 46d6e26

1 file changed

Lines changed: 73 additions & 6 deletions

File tree

src/map/MapScreen.tsx

Lines changed: 73 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,14 @@ import {
1010
} from '@maplibre/maplibre-react-native';
1111
import {useCallback, useEffect, useMemo, useRef, useState} from 'react';
1212
import 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';
1421
import {
1522
type ElementsQuery,
1623
type ElementsQueryVariables,
@@ -21,11 +28,19 @@ type ElementWithLocation = ElementsQuery['elements'][number];
2128

2229
const MAP_STYLE = 'https://tiles.openfreemap.org/styles/liberty';
2330
const 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

2539
export 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

Comments
 (0)