Skip to content

Commit 2a0303a

Browse files
dfallingclaude
andauthored
Restore last viewport on launch; skip auto-fly when restored (#7)
* Restore last viewport on launch and skip auto-fly when restored Persist the map center+zoom (debounced) on each region change via a small viewport store backed by the same EncryptedStorage we use for auth. On launch, hydrate the saved viewport and use it as the camera's initialViewState so elements fetch immediately for that region instead of going through a zoom-1 world view and the fly-to-user dance. When a saved viewport exists, the recenter button is the user's way to jump to their current location; we no longer auto-fly there on first position fix. First-launch users (no saved viewport) still get the fly to user behavior so they don't open the app to a blank world. The off-center check now also runs from a useMemo so the recenter button can appear as soon as position arrives, even if the user hasn't moved the map yet. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Drop the map loading indicators Remove both the GPS-wait overlay and the viewport-hydration spinner. The hydration read is fast enough that the brief blank frame is less distracting than a flashed spinner, and the GPS-wait overlay only ever appeared on first-ever launch. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 0f738f9 commit 2a0303a

2 files changed

Lines changed: 135 additions & 42 deletions

File tree

src/map/MapScreen.tsx

Lines changed: 81 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -10,24 +10,20 @@ 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 {
14-
ActivityIndicator,
15-
StyleSheet,
16-
Text,
17-
TouchableOpacity,
18-
View,
19-
} from 'react-native';
13+
import {StyleSheet, Text, TouchableOpacity, View} from 'react-native';
2014
import {useSafeAreaInsets} from 'react-native-safe-area-context';
2115
import {
2216
type ElementsQuery,
2317
type ElementsQueryVariables,
2418
useElementsQuery,
2519
} from '../graphql/__generated__/types';
20+
import {type Viewport, viewportStore} from './viewportStore';
2621

2722
type ElementWithLocation = ElementsQuery['elements'][number];
2823

2924
const MAP_STYLE = 'https://tiles.openfreemap.org/styles/liberty';
3025
const USER_ZOOM = 14;
26+
const DEFAULT_INITIAL_VIEW = {center: [0, 20] as [number, number], zoom: 1};
3127
// Show the recenter button once the viewport center drifts more than this
3228
// fraction of the visible span away from the user in either axis.
3329
const OFF_CENTER_THRESHOLD = 0.2;
@@ -38,10 +34,30 @@ const BOTTOM_BAR_CLEARANCE = 64;
3834

3935
export function MapScreen() {
4036
const cameraRef = useRef<CameraRef>(null);
41-
const hasCenteredRef = useRef(false);
37+
// Gate the bounds fetch + viewport-save until we know the map is sitting on
38+
// a real location — either a restored viewport or a fly-to-user. Prevents
39+
// the initial zoom-1 world frame from triggering a fetch of every element.
40+
const hasSettledRef = useRef(false);
4241
const [permissionGranted, setPermissionGranted] = useState(false);
42+
const [savedViewport, setSavedViewport] = useState<
43+
Viewport | null | undefined
44+
>(undefined);
4345
const safeAreaInsets = useSafeAreaInsets();
4446

47+
useEffect(() => {
48+
let cancelled = false;
49+
viewportStore.load().then(v => {
50+
if (cancelled) return;
51+
// Open the gate synchronously so the first region-did-change after the
52+
// map mounts at the restored viewport is allowed through.
53+
if (v) hasSettledRef.current = true;
54+
setSavedViewport(v);
55+
});
56+
return () => {
57+
cancelled = true;
58+
};
59+
}, []);
60+
4561
useEffect(() => {
4662
let cancelled = false;
4763
(async () => {
@@ -69,37 +85,64 @@ export function MapScreen() {
6985
});
7086
}, []);
7187

88+
// First-launch fallback: with nothing to restore, fly to the user when their
89+
// position becomes available. Once a saved viewport exists this branch is
90+
// never taken — the recenter button is how the user goes to themselves.
7291
useEffect(() => {
73-
if (hasCenteredRef.current || !position) return;
74-
hasCenteredRef.current = true;
92+
if (
93+
hasSettledRef.current ||
94+
!position ||
95+
savedViewport === undefined ||
96+
savedViewport !== null
97+
) {
98+
return;
99+
}
100+
hasSettledRef.current = true;
75101
flyToUser();
76-
}, [position, flyToUser]);
102+
}, [position, savedViewport, flyToUser]);
77103

78104
const [bounds, setBounds] = useState<ElementsQueryVariables['bounds']>();
79-
const [isCenteredOnUser, setIsCenteredOnUser] = useState(true);
105+
const [viewportState, setViewportState] = useState<{
106+
centerLng: number;
107+
centerLat: number;
108+
spanLng: number;
109+
spanLat: number;
110+
} | null>(null);
80111

81112
const onRegionDidChange = useCallback(
82113
(event: NativeSyntheticEvent<ViewStateChangeEvent>) => {
83-
// Skip viewport events until we've flown to the user's location, so
84-
// we don't fetch the entire world at the initial zoom-1 framing.
85-
if (!hasCenteredRef.current) return;
114+
if (!hasSettledRef.current) return;
86115
const [west, south, east, north] = event.nativeEvent.bounds;
87-
setBounds({left: west, bottom: south, right: east, top: north});
88-
89-
const pos = positionRef.current;
90-
if (!pos) return;
91116
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-
);
117+
setBounds({left: west, bottom: south, right: east, top: north});
118+
setViewportState({
119+
centerLng,
120+
centerLat,
121+
spanLng: east - west,
122+
spanLat: north - south,
123+
});
124+
viewportStore.save({
125+
center: [centerLng, centerLat],
126+
zoom: event.nativeEvent.zoom,
127+
});
99128
},
100129
[],
101130
);
102131

132+
// Derived: is the user's location near the viewport center? When unknown
133+
// (no position yet, or map hasn't reported a region), treat as centered so
134+
// the recenter button stays hidden until we have real data to compare.
135+
const isCenteredOnUser = useMemo(() => {
136+
if (!position || !viewportState) return true;
137+
const offLng =
138+
Math.abs(viewportState.centerLng - position.coords.longitude) /
139+
viewportState.spanLng;
140+
const offLat =
141+
Math.abs(viewportState.centerLat - position.coords.latitude) /
142+
viewportState.spanLat;
143+
return offLng <= OFF_CENTER_THRESHOLD && offLat <= OFF_CENTER_THRESHOLD;
144+
}, [position, viewportState]);
145+
103146
const {data} = useElementsQuery({
104147
skip: !bounds,
105148
variables: bounds ? {bounds} : undefined,
@@ -133,13 +176,24 @@ export function MapScreen() {
133176
});
134177
}, [elementsById, bounds]);
135178

179+
// Brief blank frame while the saved viewport hydrates from storage; the
180+
// camera's initialViewState is set once, so we wait for the resolved value
181+
// rather than rendering the map at the default world view first.
182+
if (savedViewport === undefined) {
183+
return <View style={styles.container} />;
184+
}
185+
186+
const initialViewState = savedViewport
187+
? {center: savedViewport.center, zoom: savedViewport.zoom}
188+
: DEFAULT_INITIAL_VIEW;
189+
136190
return (
137191
<View style={styles.container}>
138192
<MapLibreMap
139193
mapStyle={MAP_STYLE}
140194
style={styles.map}
141195
onRegionDidChange={onRegionDidChange}>
142-
<Camera ref={cameraRef} initialViewState={{center: [0, 20], zoom: 1}} />
196+
<Camera ref={cameraRef} initialViewState={initialViewState} />
143197
<UserLocation animated accuracy />
144198
{visibleElements.map(el =>
145199
el.location ? (
@@ -154,11 +208,6 @@ export function MapScreen() {
154208
) : null,
155209
)}
156210
</MapLibreMap>
157-
{!position ? (
158-
<View pointerEvents="none" style={styles.loadingOverlay}>
159-
<ActivityIndicator size="large" color="#1d6fe0" />
160-
</View>
161-
) : null}
162211
{position && !isCenteredOnUser ? (
163212
<TouchableOpacity
164213
accessibilityLabel="Recenter map on your location"
@@ -197,16 +246,6 @@ const styles = StyleSheet.create({
197246
lineHeight: 22,
198247
textAlign: 'center',
199248
},
200-
loadingOverlay: {
201-
position: 'absolute',
202-
top: 0,
203-
left: 0,
204-
right: 0,
205-
bottom: 0,
206-
alignItems: 'center',
207-
justifyContent: 'center',
208-
backgroundColor: 'rgba(255,255,255,0.6)',
209-
},
210249
recenterButton: {
211250
position: 'absolute',
212251
right: 16,

src/map/viewportStore.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import EncryptedStorage from 'react-native-encrypted-storage';
2+
3+
const STORAGE_KEY = 'culpeos.viewport';
4+
// Coalesce rapid region changes (pinch-zoom, fling pans) before writing.
5+
const SAVE_DEBOUNCE_MS = 500;
6+
7+
export type Viewport = {
8+
center: [number, number];
9+
zoom: number;
10+
};
11+
12+
let saveTimer: ReturnType<typeof setTimeout> | null = null;
13+
let pendingViewport: Viewport | null = null;
14+
15+
function flushSave(): void {
16+
if (!pendingViewport) return;
17+
const toWrite = pendingViewport;
18+
pendingViewport = null;
19+
saveTimer = null;
20+
EncryptedStorage.setItem(STORAGE_KEY, JSON.stringify(toWrite)).catch(err => {
21+
console.warn('[viewport] failed to persist', err);
22+
});
23+
}
24+
25+
export const viewportStore = {
26+
async load(): Promise<Viewport | null> {
27+
try {
28+
const raw = await EncryptedStorage.getItem(STORAGE_KEY);
29+
if (!raw) return null;
30+
const parsed = JSON.parse(raw) as Partial<Viewport>;
31+
if (
32+
Array.isArray(parsed.center) &&
33+
parsed.center.length === 2 &&
34+
typeof parsed.center[0] === 'number' &&
35+
typeof parsed.center[1] === 'number' &&
36+
typeof parsed.zoom === 'number'
37+
) {
38+
return {
39+
center: [parsed.center[0], parsed.center[1]],
40+
zoom: parsed.zoom,
41+
};
42+
}
43+
return null;
44+
} catch (err) {
45+
console.warn('[viewport] failed to hydrate', err);
46+
return null;
47+
}
48+
},
49+
save(viewport: Viewport): void {
50+
pendingViewport = viewport;
51+
if (saveTimer) return;
52+
saveTimer = setTimeout(flushSave, SAVE_DEBOUNCE_MS);
53+
},
54+
};

0 commit comments

Comments
 (0)