Skip to content

Commit 445334a

Browse files
dfallingclaude
andcommitted
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>
1 parent 0f738f9 commit 445334a

2 files changed

Lines changed: 140 additions & 21 deletions

File tree

src/map/MapScreen.tsx

Lines changed: 86 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -23,11 +23,13 @@ import {
2323
type ElementsQueryVariables,
2424
useElementsQuery,
2525
} from '../graphql/__generated__/types';
26+
import {type Viewport, viewportStore} from './viewportStore';
2627

2728
type ElementWithLocation = ElementsQuery['elements'][number];
2829

2930
const MAP_STYLE = 'https://tiles.openfreemap.org/styles/liberty';
3031
const USER_ZOOM = 14;
32+
const DEFAULT_INITIAL_VIEW = {center: [0, 20] as [number, number], zoom: 1};
3133
// Show the recenter button once the viewport center drifts more than this
3234
// fraction of the visible span away from the user in either axis.
3335
const OFF_CENTER_THRESHOLD = 0.2;
@@ -38,10 +40,30 @@ const BOTTOM_BAR_CLEARANCE = 64;
3840

3941
export function MapScreen() {
4042
const cameraRef = useRef<CameraRef>(null);
41-
const hasCenteredRef = useRef(false);
43+
// Gate the bounds fetch + viewport-save until we know the map is sitting on
44+
// a real location — either a restored viewport or a fly-to-user. Prevents
45+
// the initial zoom-1 world frame from triggering a fetch of every element.
46+
const hasSettledRef = useRef(false);
4247
const [permissionGranted, setPermissionGranted] = useState(false);
48+
const [savedViewport, setSavedViewport] = useState<
49+
Viewport | null | undefined
50+
>(undefined);
4351
const safeAreaInsets = useSafeAreaInsets();
4452

53+
useEffect(() => {
54+
let cancelled = false;
55+
viewportStore.load().then(v => {
56+
if (cancelled) return;
57+
// Open the gate synchronously so the first region-did-change after the
58+
// map mounts at the restored viewport is allowed through.
59+
if (v) hasSettledRef.current = true;
60+
setSavedViewport(v);
61+
});
62+
return () => {
63+
cancelled = true;
64+
};
65+
}, []);
66+
4567
useEffect(() => {
4668
let cancelled = false;
4769
(async () => {
@@ -69,37 +91,64 @@ export function MapScreen() {
6991
});
7092
}, []);
7193

94+
// First-launch fallback: with nothing to restore, fly to the user when their
95+
// position becomes available. Once a saved viewport exists this branch is
96+
// never taken — the recenter button is how the user goes to themselves.
7297
useEffect(() => {
73-
if (hasCenteredRef.current || !position) return;
74-
hasCenteredRef.current = true;
98+
if (
99+
hasSettledRef.current ||
100+
!position ||
101+
savedViewport === undefined ||
102+
savedViewport !== null
103+
) {
104+
return;
105+
}
106+
hasSettledRef.current = true;
75107
flyToUser();
76-
}, [position, flyToUser]);
108+
}, [position, savedViewport, flyToUser]);
77109

78110
const [bounds, setBounds] = useState<ElementsQueryVariables['bounds']>();
79-
const [isCenteredOnUser, setIsCenteredOnUser] = useState(true);
111+
const [viewportState, setViewportState] = useState<{
112+
centerLng: number;
113+
centerLat: number;
114+
spanLng: number;
115+
spanLat: number;
116+
} | null>(null);
80117

81118
const onRegionDidChange = useCallback(
82119
(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;
120+
if (!hasSettledRef.current) return;
86121
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;
91122
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-
);
123+
setBounds({left: west, bottom: south, right: east, top: north});
124+
setViewportState({
125+
centerLng,
126+
centerLat,
127+
spanLng: east - west,
128+
spanLat: north - south,
129+
});
130+
viewportStore.save({
131+
center: [centerLng, centerLat],
132+
zoom: event.nativeEvent.zoom,
133+
});
99134
},
100135
[],
101136
);
102137

138+
// Derived: is the user's location near the viewport center? When unknown
139+
// (no position yet, or map hasn't reported a region), treat as centered so
140+
// the recenter button stays hidden until we have real data to compare.
141+
const isCenteredOnUser = useMemo(() => {
142+
if (!position || !viewportState) return true;
143+
const offLng =
144+
Math.abs(viewportState.centerLng - position.coords.longitude) /
145+
viewportState.spanLng;
146+
const offLat =
147+
Math.abs(viewportState.centerLat - position.coords.latitude) /
148+
viewportState.spanLat;
149+
return offLng <= OFF_CENTER_THRESHOLD && offLat <= OFF_CENTER_THRESHOLD;
150+
}, [position, viewportState]);
151+
103152
const {data} = useElementsQuery({
104153
skip: !bounds,
105154
variables: bounds ? {bounds} : undefined,
@@ -133,13 +182,25 @@ export function MapScreen() {
133182
});
134183
}, [elementsById, bounds]);
135184

185+
if (savedViewport === undefined) {
186+
return (
187+
<View style={[styles.container, styles.hydrating]}>
188+
<ActivityIndicator size="large" color="#1d6fe0" />
189+
</View>
190+
);
191+
}
192+
193+
const initialViewState = savedViewport
194+
? {center: savedViewport.center, zoom: savedViewport.zoom}
195+
: DEFAULT_INITIAL_VIEW;
196+
136197
return (
137198
<View style={styles.container}>
138199
<MapLibreMap
139200
mapStyle={MAP_STYLE}
140201
style={styles.map}
141202
onRegionDidChange={onRegionDidChange}>
142-
<Camera ref={cameraRef} initialViewState={{center: [0, 20], zoom: 1}} />
203+
<Camera ref={cameraRef} initialViewState={initialViewState} />
143204
<UserLocation animated accuracy />
144205
{visibleElements.map(el =>
145206
el.location ? (
@@ -154,7 +215,7 @@ export function MapScreen() {
154215
) : null,
155216
)}
156217
</MapLibreMap>
157-
{!position ? (
218+
{!savedViewport && !position ? (
158219
<View pointerEvents="none" style={styles.loadingOverlay}>
159220
<ActivityIndicator size="large" color="#1d6fe0" />
160221
</View>
@@ -182,6 +243,10 @@ const styles = StyleSheet.create({
182243
map: {
183244
flex: 1,
184245
},
246+
hydrating: {
247+
alignItems: 'center',
248+
justifyContent: 'center',
249+
},
185250
pin: {
186251
width: 36,
187252
height: 36,

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)