diff --git a/src/App.jsx b/src/App.jsx index b1ea55d..61214cc 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -7,6 +7,7 @@ import MapView from './components/MapView' import Panel from './components/Panel' import PlaceDetail from './components/PlaceDetail' import LayerControl from './components/LayerControl' +import LocateButton from './components/LocateButton' export default function App() { const mapViewRef = useRef(null) @@ -24,24 +25,6 @@ export default function App() { const setRouteLoading = useStore((s) => s.setRouteLoading) const setRouteError = useStore((s) => s.setRouteError) const clearRoute = useStore((s) => s.clearRoute) - const setUserLocation = useStore((s) => s.setUserLocation) - const setGeoPermission = useStore((s) => s.setGeoPermission) - - // Proactive geolocation request on mount - useEffect(() => { - if (!navigator.geolocation) { - setGeoPermission('denied') - return - } - navigator.geolocation.getCurrentPosition( - (pos) => { - setUserLocation({ lat: pos.coords.latitude, lon: pos.coords.longitude }) - setGeoPermission('granted') - }, - () => setGeoPermission('denied'), - { enableHighAccuracy: false, timeout: 8000, maximumAge: 300000 } - ) - }, [setUserLocation, setGeoPermission]) // Fetch route when stops, mode, gpsOrigin, or geoPermission change (debounced 500ms) // NOTE: userLocation is NOT a dep — read from store inside the callback to avoid re-routing on every GPS update @@ -108,6 +91,7 @@ export default function App() { + ) } diff --git a/src/components/LayerControl.jsx b/src/components/LayerControl.jsx index 52f8dec..c929888 100644 --- a/src/components/LayerControl.jsx +++ b/src/components/LayerControl.jsx @@ -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') diff --git a/src/components/LocateButton.jsx b/src/components/LocateButton.jsx new file mode 100644 index 0000000..3ec445f --- /dev/null +++ b/src/components/LocateButton.jsx @@ -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 ( + + ) +} diff --git a/src/components/MapView.jsx b/src/components/MapView.jsx index 3c086b2..5818a1e 100644 --- a/src/components/MapView.jsx +++ b/src/components/MapView.jsx @@ -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 diff --git a/src/components/ModeSelector.jsx b/src/components/ModeSelector.jsx index 0502f99..9c10ead 100644 --- a/src/components/ModeSelector.jsx +++ b/src/components/ModeSelector.jsx @@ -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)', diff --git a/src/components/Panel.jsx b/src/components/Panel.jsx index 9b8f311..5bca363 100644 --- a/src/components/Panel.jsx +++ b/src/components/Panel.jsx @@ -215,7 +215,7 @@ export default function Panel({ onManeuverClick }) { {sheetState !== 'collapsed' && ( -
+
{header} {content}
diff --git a/src/components/StopItem.jsx b/src/components/StopItem.jsx index f59c93d..0f7484a 100644 --- a/src/components/StopItem.jsx +++ b/src/components/StopItem.jsx @@ -66,7 +66,7 @@ export default function StopItem({ stop, index, total, indexOffset = 0 }) { {/* Remove button */}