Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
123 changes: 81 additions & 42 deletions src/map/MapScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,24 +10,20 @@ import {
} from '@maplibre/maplibre-react-native';
import {useCallback, useEffect, useMemo, useRef, useState} from 'react';
import type {NativeSyntheticEvent} from 'react-native';
import {
ActivityIndicator,
StyleSheet,
Text,
TouchableOpacity,
View,
} from 'react-native';
import {StyleSheet, Text, TouchableOpacity, View} from 'react-native';
import {useSafeAreaInsets} from 'react-native-safe-area-context';
import {
type ElementsQuery,
type ElementsQueryVariables,
useElementsQuery,
} from '../graphql/__generated__/types';
import {type Viewport, viewportStore} from './viewportStore';

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

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

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

useEffect(() => {
let cancelled = false;
viewportStore.load().then(v => {
if (cancelled) return;
// Open the gate synchronously so the first region-did-change after the
// map mounts at the restored viewport is allowed through.
if (v) hasSettledRef.current = true;
setSavedViewport(v);
});
return () => {
cancelled = true;
};
}, []);

useEffect(() => {
let cancelled = false;
(async () => {
Expand Down Expand Up @@ -69,37 +85,64 @@ export function MapScreen() {
});
}, []);

// First-launch fallback: with nothing to restore, fly to the user when their
// position becomes available. Once a saved viewport exists this branch is
// never taken — the recenter button is how the user goes to themselves.
useEffect(() => {
if (hasCenteredRef.current || !position) return;
hasCenteredRef.current = true;
if (
hasSettledRef.current ||
!position ||
savedViewport === undefined ||
savedViewport !== null
) {
return;
}
hasSettledRef.current = true;
flyToUser();
}, [position, flyToUser]);
}, [position, savedViewport, flyToUser]);

const [bounds, setBounds] = useState<ElementsQueryVariables['bounds']>();
const [isCenteredOnUser, setIsCenteredOnUser] = useState(true);
const [viewportState, setViewportState] = useState<{
centerLng: number;
centerLat: number;
spanLng: number;
spanLat: number;
} | null>(null);

const onRegionDidChange = useCallback(
(event: NativeSyntheticEvent<ViewStateChangeEvent>) => {
// Skip viewport events until we've flown to the user's location, so
// we don't fetch the entire world at the initial zoom-1 framing.
if (!hasCenteredRef.current) return;
if (!hasSettledRef.current) return;
const [west, south, east, north] = event.nativeEvent.bounds;
setBounds({left: west, bottom: south, right: east, top: north});

const pos = positionRef.current;
if (!pos) return;
const [centerLng, centerLat] = event.nativeEvent.center;
const spanLng = east - west;
const spanLat = north - south;
const offLng = Math.abs(centerLng - pos.coords.longitude) / spanLng;
const offLat = Math.abs(centerLat - pos.coords.latitude) / spanLat;
setIsCenteredOnUser(
offLng <= OFF_CENTER_THRESHOLD && offLat <= OFF_CENTER_THRESHOLD,
);
setBounds({left: west, bottom: south, right: east, top: north});
setViewportState({
centerLng,
centerLat,
spanLng: east - west,
spanLat: north - south,
});
viewportStore.save({
center: [centerLng, centerLat],
zoom: event.nativeEvent.zoom,
});
},
[],
);

// Derived: is the user's location near the viewport center? When unknown
// (no position yet, or map hasn't reported a region), treat as centered so
// the recenter button stays hidden until we have real data to compare.
const isCenteredOnUser = useMemo(() => {
if (!position || !viewportState) return true;
const offLng =
Math.abs(viewportState.centerLng - position.coords.longitude) /
viewportState.spanLng;
const offLat =
Math.abs(viewportState.centerLat - position.coords.latitude) /
viewportState.spanLat;
return offLng <= OFF_CENTER_THRESHOLD && offLat <= OFF_CENTER_THRESHOLD;
}, [position, viewportState]);

const {data} = useElementsQuery({
skip: !bounds,
variables: bounds ? {bounds} : undefined,
Expand Down Expand Up @@ -133,13 +176,24 @@ export function MapScreen() {
});
}, [elementsById, bounds]);

// Brief blank frame while the saved viewport hydrates from storage; the
// camera's initialViewState is set once, so we wait for the resolved value
// rather than rendering the map at the default world view first.
if (savedViewport === undefined) {
return <View style={styles.container} />;
}

const initialViewState = savedViewport
? {center: savedViewport.center, zoom: savedViewport.zoom}
: DEFAULT_INITIAL_VIEW;

return (
<View style={styles.container}>
<MapLibreMap
mapStyle={MAP_STYLE}
style={styles.map}
onRegionDidChange={onRegionDidChange}>
<Camera ref={cameraRef} initialViewState={{center: [0, 20], zoom: 1}} />
<Camera ref={cameraRef} initialViewState={initialViewState} />
<UserLocation animated accuracy />
{visibleElements.map(el =>
el.location ? (
Expand All @@ -154,11 +208,6 @@ export function MapScreen() {
) : null,
)}
</MapLibreMap>
{!position ? (
<View pointerEvents="none" style={styles.loadingOverlay}>
<ActivityIndicator size="large" color="#1d6fe0" />
</View>
) : null}
{position && !isCenteredOnUser ? (
<TouchableOpacity
accessibilityLabel="Recenter map on your location"
Expand Down Expand Up @@ -197,16 +246,6 @@ const styles = StyleSheet.create({
lineHeight: 22,
textAlign: 'center',
},
loadingOverlay: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: 'rgba(255,255,255,0.6)',
},
recenterButton: {
position: 'absolute',
right: 16,
Expand Down
54 changes: 54 additions & 0 deletions src/map/viewportStore.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import EncryptedStorage from 'react-native-encrypted-storage';

const STORAGE_KEY = 'culpeos.viewport';
// Coalesce rapid region changes (pinch-zoom, fling pans) before writing.
const SAVE_DEBOUNCE_MS = 500;

export type Viewport = {
center: [number, number];
zoom: number;
};

let saveTimer: ReturnType<typeof setTimeout> | null = null;
let pendingViewport: Viewport | null = null;

function flushSave(): void {
if (!pendingViewport) return;
const toWrite = pendingViewport;
pendingViewport = null;
saveTimer = null;
EncryptedStorage.setItem(STORAGE_KEY, JSON.stringify(toWrite)).catch(err => {
console.warn('[viewport] failed to persist', err);
});
}

export const viewportStore = {
async load(): Promise<Viewport | null> {
try {
const raw = await EncryptedStorage.getItem(STORAGE_KEY);
if (!raw) return null;
const parsed = JSON.parse(raw) as Partial<Viewport>;
if (
Array.isArray(parsed.center) &&
parsed.center.length === 2 &&
typeof parsed.center[0] === 'number' &&
typeof parsed.center[1] === 'number' &&
typeof parsed.zoom === 'number'
) {
return {
center: [parsed.center[0], parsed.center[1]],
zoom: parsed.zoom,
};
}
return null;
} catch (err) {
console.warn('[viewport] failed to hydrate', err);
return null;
}
},
save(viewport: Viewport): void {
pendingViewport = viewport;
if (saveTimer) return;
saveTimer = setTimeout(flushSave, SAVE_DEBOUNCE_MS);
},
};
Loading