fix: mobile UX — GPS permission flow, traffic toggle, overflow

GPS permission:
- Remove silent mount-time getCurrentPosition calls that cause iOS Safari
  to cache a "denied" state without ever prompting the user
- LocateButton always retries getCurrentPosition on tap (user gesture)
- Only show "denied" toast on PERMISSION_DENIED (code 1), not timeout
- MapView watchPosition now starts only after confirmed grant, not unconditionally

Traffic toggle:
- Fix isStyleLoaded() race in LayerControl — if style not loaded when
  toggle fires, defer to map.once("style.load") instead of silently bailing
- Change outside-click handler from mousedown to pointerdown for mobile

Mobile UX (from prior session):
- Add LocateButton component (crosshair GPS locate/re-center)
- Reposition layer control + locate button to top-right on mobile
  (below MapLibre nav controls, above bottom sheet)
- ModeSelector: add min-w-0 to prevent flex overflow at 390px
- StopItem: remove button visible on touch (60% opacity vs hover-only)
- Panel: overflow-x-hidden + safe-area-inset-bottom on mobile sheet
- Body overflow-x guard on mobile viewports

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Matt 2026-04-22 03:27:21 +00:00
commit 03e9780834
8 changed files with 216 additions and 72 deletions

View file

@ -40,27 +40,49 @@ export default function LayerControl({ mapRef }) {
// Apply layers when prefs change
useEffect(() => {
const map = mapRef?.current?.getMap?.()
if (!map || !map.isStyleLoaded()) return
const mapView = mapRef?.current
if (!mapView) return
const map = mapView.getMap?.()
if (!map) return
if (hillshade && hasFeature('has_hillshade')) {
mapRef.current.addHillshadeLayer?.()
const apply = () => {
if (hillshade && hasFeature('has_hillshade')) {
mapView.addHillshadeLayer?.()
} else {
mapView.removeHillshadeLayer?.()
}
}
if (map.isStyleLoaded()) {
apply()
} else {
mapRef.current.removeHillshadeLayer?.()
map.once('style.load', apply)
}
savePrefs({ hillshade, traffic })
return () => map.off('style.load', apply)
}, [hillshade, mapRef])
useEffect(() => {
const map = mapRef?.current?.getMap?.()
if (!map || !map.isStyleLoaded()) return
const mapView = mapRef?.current
if (!mapView) return
const map = mapView.getMap?.()
if (!map) return
if (traffic && hasFeature('has_traffic_overlay')) {
mapRef.current.addTrafficLayer?.()
const apply = () => {
if (traffic && hasFeature('has_traffic_overlay')) {
mapView.addTrafficLayer?.()
} else {
mapView.removeTrafficLayer?.()
}
}
if (map.isStyleLoaded()) {
apply()
} else {
mapRef.current.removeTrafficLayer?.()
map.once('style.load', apply)
}
savePrefs({ hillshade, traffic })
return () => map.off('style.load', apply)
}, [traffic, mapRef])
// Close on outside click
@ -71,8 +93,8 @@ export default function LayerControl({ mapRef }) {
setOpen(false)
}
}
document.addEventListener('mousedown', handleClick)
return () => document.removeEventListener('mousedown', handleClick)
document.addEventListener('pointerdown', handleClick)
return () => document.removeEventListener('pointerdown', handleClick)
}, [open])
const showHillshade = hasFeature('has_hillshade')

View file

@ -0,0 +1,54 @@
import { Locate } from 'lucide-react'
import toast from 'react-hot-toast'
import { useStore } from '../store'
export default function LocateButton({ mapRef }) {
const handleClick = () => {
const { userLocation } = useStore.getState()
// If we have a cached location, fly immediately for instant feedback
if (userLocation) {
mapRef.current?.flyTo(userLocation.lat, userLocation.lon, 14)
}
// Always request fresh position never trust cached permission state.
// iOS Safari can "forget" a silent mount-time denial between requests,
// and a user-gesture-triggered call is more likely to prompt.
navigator.geolocation.getCurrentPosition(
(pos) => {
const loc = { lat: pos.coords.latitude, lon: pos.coords.longitude }
useStore.getState().setUserLocation(loc)
useStore.getState().setGeoPermission('granted')
// Fly to fresh position if we didn't have a cached one
if (!userLocation) {
mapRef.current?.flyTo(loc.lat, loc.lon, 14)
}
},
(err) => {
if (err.code === 1) {
// PERMISSION_DENIED user explicitly denied
useStore.getState().setGeoPermission('denied')
toast('Location access denied.\nEnable in browser settings.', { icon: '\u{1F4CD}' })
} else if (err.code === 3 && !userLocation) {
// TIMEOUT only toast if we have no cached location
toast('Location timed out. Try again.', { icon: '\u23F1\uFE0F' })
}
// POSITION_UNAVAILABLE (code 2): silent, likely temporary
},
{ enableHighAccuracy: true, timeout: 10000 }
)
}
if (!navigator.geolocation) return null
return (
<button
className="locate-btn"
onClick={handleClick}
title="My location"
aria-label="Center map on my location"
>
<Locate size={18} />
</button>
)
}

View file

@ -197,36 +197,6 @@ const MapView = forwardRef(function MapView(_, ref) {
map.addControl(new maplibregl.NavigationControl(), 'top-right')
// GPS tracking creates chevron or dot marker
if (navigator.geolocation) {
navigator.geolocation.getCurrentPosition(
(pos) => {
const { latitude, longitude } = pos.coords
if (useStore.getState().stops.length === 0) {
map.flyTo({ center: [longitude, latitude], zoom: 12, duration: 1500 })
}
useStore.getState().setUserLocation({ lat: latitude, lon: longitude })
useStore.getState().setGeoPermission('granted')
createOrUpdateGpsMarker(map, latitude, longitude, null)
},
() => {
useStore.getState().setGeoPermission('denied')
},
{ enableHighAccuracy: false, timeout: 8000, maximumAge: 300000 }
)
// Watch for heading changes
watchIdRef.current = navigator.geolocation.watchPosition(
(pos) => {
const { latitude, longitude, heading } = pos.coords
useStore.getState().setUserLocation({ lat: latitude, lon: longitude })
createOrUpdateGpsMarker(map, latitude, longitude, heading)
},
() => {},
{ enableHighAccuracy: true, maximumAge: 5000 }
)
}
// Map click drop pin and reverse geocode
map.on('click', (e) => {
// If a stop pin was just clicked, skip the pin-drop
@ -335,7 +305,34 @@ const MapView = forwardRef(function MapView(_, ref) {
}
}
// Swap map theme when store.theme changes
// React to permission changes from LocateButton (when user grants after initial denial)
useEffect(() => {
const map = mapInstance.current
if (!map || geoPermission !== 'granted') return
// If marker already exists, watchPosition is already running nothing to do
if (gpsMarkerRef.current) return
// Permission was just granted (likely from LocateButton) create marker + start tracking
const loc = useStore.getState().userLocation
if (loc) {
createOrUpdateGpsMarker(map, loc.lat, loc.lon, null)
}
if (!watchIdRef.current) {
watchIdRef.current = navigator.geolocation.watchPosition(
(pos) => {
const { latitude, longitude, heading } = pos.coords
useStore.getState().setUserLocation({ lat: latitude, lon: longitude })
createOrUpdateGpsMarker(map, latitude, longitude, heading)
},
() => {},
{ enableHighAccuracy: true, maximumAge: 5000 }
)
}
}, [geoPermission])
// Swap map theme when store.theme changes
useEffect(() => {
const map = mapInstance.current
if (!map || currentThemeRef.current === theme) return

View file

@ -26,7 +26,7 @@ export default function ModeSelector() {
role="radio"
aria-checked={active}
onClick={() => setMode(m.id)}
className="flex-1 flex items-center justify-center gap-1.5 py-2 px-2 text-xs font-medium transition-colors duration-100"
className="flex-1 min-w-0 flex items-center justify-center gap-1.5 py-2 px-2 text-xs font-medium transition-colors duration-100"
style={{
background: active ? 'var(--accent-muted)' : 'transparent',
color: active ? 'var(--accent)' : 'var(--text-secondary)',

View file

@ -215,7 +215,7 @@ export default function Panel({ onManeuverClick }) {
</div>
{sheetState !== 'collapsed' && (
<div className="px-4 pb-4 overflow-y-auto h-[calc(100%-2rem)]">
<div className="px-4 pb-4 overflow-y-auto overflow-x-hidden h-[calc(100%-2rem)]" style={{ paddingBottom: 'max(1rem, env(safe-area-inset-bottom))' }}>
{header}
{content}
</div>

View file

@ -66,7 +66,7 @@ export default function StopItem({ stop, index, total, indexOffset = 0 }) {
{/* Remove button */}
<button
onClick={() => removeStop(stop.id)}
className="opacity-0 group-hover:opacity-100 transition-opacity p-0.5"
className="stop-remove-btn p-0.5"
style={{ color: 'var(--text-tertiary)' }}
aria-label={`Remove stop ${stop.name}`}
>