Fix map centering on mobile

This commit is contained in:
InfoX 2026-10-03 19:23:37 +02:00
parent 7b32a47fc0
commit 58950fd0d1
Signed by: infox
GPG key ID: 0C5A10019B6B50F6
2 changed files with 41 additions and 9 deletions

View file

@ -515,6 +515,7 @@ export default function App() {
onSelect={select}
route={view === 'route' && routeTab === 'walk' ? route : null}
journey={journey}
sheet={sheetMode && !(guiding && view === 'route') ? sheet : null}
routeEnds={view === 'route' ? routeEnds : null}
altRoutes={view === 'route' && routeTab === 'walk' ? altRoutes : []}
focus={focus}

View file

@ -87,6 +87,8 @@ interface Props {
reduceMotion: boolean;
/** Public-transport journey to draw: walking legs, rides and the stops where you board / get off. */
journey: JourneyOverlay | null;
/** Phone bottom sheet that covers the lower part of the map (null when the panel is beside the map or hidden). */
sheet: 'peek' | 'half' | 'full' | null;
}
export interface JourneyOverlay {
@ -95,7 +97,7 @@ export interface JourneyOverlay {
stops: { at: LatLon; label: string }[];
}
export function MapView({ places, showPlaces, selectedId, onSelect, route, routeEnds, altRoutes, focus, visible, bbox, online, basemap, onBasemap, userPos, follow, reach, hotspots, hotspotFocus, reduceMotion, journey }: Props) {
export function MapView({ places, showPlaces, selectedId, onSelect, route, routeEnds, altRoutes, focus, visible, bbox, online, basemap, onBasemap, userPos, follow, reach, hotspots, hotspotFocus, reduceMotion, journey, sheet }: Props) {
const { t, lang } = useI18n();
const ref = useRef<HTMLDivElement>(null);
const mapRef = useRef<MLMap | null>(null);
@ -112,6 +114,25 @@ export function MapView({ places, showPlaces, selectedId, onSelect, route, route
const reduced = () => rmRef.current || window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const [ready, setReady] = useState(false);
// The sheet hides the lower part of the map. Camera padding makes "centre" mean the middle of the part you can see, so a route fit
// or a focused place lands there. The inset comes from the sheet's target size (not from the DOM), so it is right while the sheet
// is still animating; every camera move syncs it first, and moving the sheet by hand re-centres smoothly.
const sheetRef = useRef(sheet);
sheetRef.current = sheet;
const insetPx = (): number => {
const h = ref.current?.clientHeight ?? 0;
const s = sheetRef.current;
if (!s) return 0;
if (s === 'peek') return Math.min(h * 0.5, 6.5 * parseFloat(getComputedStyle(document.documentElement).fontSize));
return h * (s === 'half' ? 0.55 : 0.85);
};
const padding = () => ({ top: 0, left: 0, right: 0, bottom: Math.round(insetPx()) });
const syncInset = (map: MLMap) => map.setPadding(padding());
const syncInsetRef = useRef(syncInset);
syncInsetRef.current = syncInset;
const ease = (map: MLMap, o: Parameters<MLMap['easeTo']>[0]) => { syncInset(map); map.easeTo(o); };
const fit = (map: MLMap, b: maplibregl.LngLatBounds, o: Parameters<MLMap['fitBounds']>[1]) => { syncInset(map); map.fitBounds(b, o); };
// ---- init ----
useEffect(() => {
if (!ref.current) return;
@ -187,10 +208,12 @@ export function MapView({ places, showPlaces, selectedId, onSelect, route, route
if (id) onSelectRef.current(id);
});
applyBasemap(map, basemapRef.current);
syncInsetRef.current(map);
setReady(true);
});
mapRef.current = map;
const ro = new ResizeObserver(() => map.resize());
const ro = new ResizeObserver(() => { map.resize(); if (!map.isMoving()) syncInsetRef.current(map); });
ro.observe(ref.current);
return () => { ro.disconnect(); markers.current.forEach((m) => m.remove()); map.remove(); mapRef.current = null; setReady(false); };
// eslint-disable-next-line react-hooks/exhaustive-deps
@ -198,6 +221,14 @@ export function MapView({ places, showPlaces, selectedId, onSelect, route, route
useEffect(() => { if (visible) mapRef.current?.resize(); }, [visible]);
// the sheet moved: keep what was centred centred in the visible part (skipped while a camera move is running, it already used the target inset)
useEffect(() => {
const map = mapRef.current;
if (!ready || !map || map.isMoving()) return;
map.easeTo({ padding: padding(), duration: reduced() ? 0 : 250 });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [ready, sheet]);
// ---- places ----
const fc = useMemo(
() => ({
@ -221,7 +252,7 @@ export function MapView({ places, showPlaces, selectedId, onSelect, route, route
if (!ready || !map) return;
map.setFilter('places-selected', ['==', ['get', 'id'], showPlaces ? selectedId ?? '' : '']);
const sel = selectedId ? places.find((p) => p.place.id === selectedId)?.place : undefined;
if (sel && showPlaces) map.easeTo({ center: [sel.lon, sel.lat], zoom: Math.max(map.getZoom(), 17), duration: reduced() ? 0 : 500 });
if (sel && showPlaces) ease(map, { center: [sel.lon, sel.lat], zoom: Math.max(map.getZoom(), 17), duration: reduced() ? 0 : 500 });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [ready, selectedId, showPlaces]);
@ -280,7 +311,7 @@ export function MapView({ places, showPlaces, selectedId, onSelect, route, route
}
const bounds = new maplibregl.LngLatBounds();
route.coords.forEach((c) => bounds.extend(lngLat(c)));
map.fitBounds(bounds, { padding: 70, duration: reduced() ? 0 : 600, maxZoom: 17 });
fit(map, bounds, { padding: 70, duration: reduced() ? 0 : 600, maxZoom: 17 });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [ready, route, routeEnds, lang]);
@ -327,14 +358,14 @@ export function MapView({ places, showPlaces, selectedId, onSelect, route, route
}
const bounds = new maplibregl.LngLatBounds();
[...journey.walks.flat(), ...journey.rides.flatMap((r) => r.path)].forEach((c) => bounds.extend(lngLat(c)));
if (!bounds.isEmpty()) map.fitBounds(bounds, { padding: 70, duration: reduced() ? 0 : 600, maxZoom: 16 });
if (!bounds.isEmpty()) fit(map, bounds, { padding: 70, duration: reduced() ? 0 : 600, maxZoom: 16 });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [ready, journey, routeEnds, lang]);
useEffect(() => {
const map = mapRef.current;
if (!ready || !map || !focus) return;
map.easeTo({ center: lngLat(focus.at), zoom: Math.max(map.getZoom(), 18), duration: reduced() ? 0 : 500 });
ease(map, { center: lngLat(focus.at), zoom: Math.max(map.getZoom(), 18), duration: reduced() ? 0 : 500 });
}, [ready, focus]);
// ---- basemap (street map / satellite) ----
@ -355,7 +386,7 @@ export function MapView({ places, showPlaces, selectedId, onSelect, route, route
el.setAttribute('aria-label', t('guide.you'));
meMarker.current = new maplibregl.Marker({ element: el }).setLngLat(lngLat(userPos)).addTo(map);
} else meMarker.current.setLngLat(lngLat(userPos));
if (follow) map.easeTo({ center: lngLat(userPos), zoom: Math.max(map.getZoom(), 17), duration: reduced() ? 0 : 600 });
if (follow) ease(map, { center: lngLat(userPos), zoom: Math.max(map.getZoom(), 17), duration: reduced() ? 0 : 600 });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [ready, userPos, follow]);
@ -380,7 +411,7 @@ export function MapView({ places, showPlaces, selectedId, onSelect, route, route
if (reach && reach.length) {
const bounds = new maplibregl.LngLatBounds();
reach.forEach(([a, b]) => { bounds.extend(lngLat(a)); bounds.extend(lngLat(b)); });
map.fitBounds(bounds, { padding: 60, duration: reduced() ? 0 : 500, maxZoom: 17 });
fit(map, bounds, { padding: 60, duration: reduced() ? 0 : 500, maxZoom: 17 });
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [ready, reach]);
@ -405,7 +436,7 @@ export function MapView({ places, showPlaces, selectedId, onSelect, route, route
useEffect(() => {
const map = mapRef.current;
if (!ready || !map || !hotspotFocus) return;
map.easeTo({ center: lngLat(hotspotFocus.at), zoom: 18, duration: reduced() ? 0 : 500 });
ease(map, { center: lngLat(hotspotFocus.at), zoom: 18, duration: reduced() ? 0 : 500 });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [ready, hotspotFocus]);