@@ -10,24 +10,20 @@ import {
1010} from '@maplibre/maplibre-react-native' ;
1111import { useCallback , useEffect , useMemo , useRef , useState } from 'react' ;
1212import 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' ;
2014import { useSafeAreaInsets } from 'react-native-safe-area-context' ;
2115import {
2216 type ElementsQuery ,
2317 type ElementsQueryVariables ,
2418 useElementsQuery ,
2519} from '../graphql/__generated__/types' ;
20+ import { type Viewport , viewportStore } from './viewportStore' ;
2621
2722type ElementWithLocation = ElementsQuery [ 'elements' ] [ number ] ;
2823
2924const MAP_STYLE = 'https://tiles.openfreemap.org/styles/liberty' ;
3025const 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.
3329const OFF_CENTER_THRESHOLD = 0.2 ;
@@ -38,10 +34,30 @@ const BOTTOM_BAR_CLEARANCE = 64;
3834
3935export 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 ,
0 commit comments