@@ -23,11 +23,13 @@ import {
2323 type ElementsQueryVariables ,
2424 useElementsQuery ,
2525} from '../graphql/__generated__/types' ;
26+ import { type Viewport , viewportStore } from './viewportStore' ;
2627
2728type ElementWithLocation = ElementsQuery [ 'elements' ] [ number ] ;
2829
2930const MAP_STYLE = 'https://tiles.openfreemap.org/styles/liberty' ;
3031const 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.
3335const OFF_CENTER_THRESHOLD = 0.2 ;
@@ -38,10 +40,30 @@ const BOTTOM_BAR_CLEARANCE = 64;
3840
3941export 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 ,
0 commit comments