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 */}