From 58950fd0d1c41a909d473959165cf82a25db1ba9 Mon Sep 17 00:00:00 2001 From: InfoX Date: Sat, 3 Oct 2026 19:23:37 +0200 Subject: [PATCH] Fix map centering on mobile --- src/App.tsx | 1 + src/components/MapView.tsx | 49 +++++++++++++++++++++++++++++++------- 2 files changed, 41 insertions(+), 9 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index ec4e3d9..6425faf 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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} diff --git a/src/components/MapView.tsx b/src/components/MapView.tsx index 9d23a76..29e7bbf 100644 --- a/src/components/MapView.tsx +++ b/src/components/MapView.tsx @@ -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(null); const mapRef = useRef(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[0]) => { syncInset(map); map.easeTo(o); }; + const fit = (map: MLMap, b: maplibregl.LngLatBounds, o: Parameters[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]);