mirror of
https://github.com/zvx-echo6/navi.git
synced 2026-05-20 22:54:42 +02:00
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:
parent
4020d5ae0a
commit
03e9780834
8 changed files with 216 additions and 72 deletions
20
src/App.jsx
20
src/App.jsx
|
|
@ -7,6 +7,7 @@ import MapView from './components/MapView'
|
||||||
import Panel from './components/Panel'
|
import Panel from './components/Panel'
|
||||||
import PlaceDetail from './components/PlaceDetail'
|
import PlaceDetail from './components/PlaceDetail'
|
||||||
import LayerControl from './components/LayerControl'
|
import LayerControl from './components/LayerControl'
|
||||||
|
import LocateButton from './components/LocateButton'
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const mapViewRef = useRef(null)
|
const mapViewRef = useRef(null)
|
||||||
|
|
@ -24,24 +25,6 @@ export default function App() {
|
||||||
const setRouteLoading = useStore((s) => s.setRouteLoading)
|
const setRouteLoading = useStore((s) => s.setRouteLoading)
|
||||||
const setRouteError = useStore((s) => s.setRouteError)
|
const setRouteError = useStore((s) => s.setRouteError)
|
||||||
const clearRoute = useStore((s) => s.clearRoute)
|
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)
|
// 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
|
// 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() {
|
||||||
<Panel onManeuverClick={handleManeuverClick} />
|
<Panel onManeuverClick={handleManeuverClick} />
|
||||||
<PlaceDetail />
|
<PlaceDetail />
|
||||||
<LayerControl mapRef={mapViewRef} />
|
<LayerControl mapRef={mapViewRef} />
|
||||||
|
<LocateButton mapRef={mapViewRef} />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -40,27 +40,49 @@ export default function LayerControl({ mapRef }) {
|
||||||
|
|
||||||
// Apply layers when prefs change
|
// Apply layers when prefs change
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const map = mapRef?.current?.getMap?.()
|
const mapView = mapRef?.current
|
||||||
if (!map || !map.isStyleLoaded()) return
|
if (!mapView) return
|
||||||
|
const map = mapView.getMap?.()
|
||||||
|
if (!map) return
|
||||||
|
|
||||||
if (hillshade && hasFeature('has_hillshade')) {
|
const apply = () => {
|
||||||
mapRef.current.addHillshadeLayer?.()
|
if (hillshade && hasFeature('has_hillshade')) {
|
||||||
|
mapView.addHillshadeLayer?.()
|
||||||
|
} else {
|
||||||
|
mapView.removeHillshadeLayer?.()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (map.isStyleLoaded()) {
|
||||||
|
apply()
|
||||||
} else {
|
} else {
|
||||||
mapRef.current.removeHillshadeLayer?.()
|
map.once('style.load', apply)
|
||||||
}
|
}
|
||||||
savePrefs({ hillshade, traffic })
|
savePrefs({ hillshade, traffic })
|
||||||
|
return () => map.off('style.load', apply)
|
||||||
}, [hillshade, mapRef])
|
}, [hillshade, mapRef])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const map = mapRef?.current?.getMap?.()
|
const mapView = mapRef?.current
|
||||||
if (!map || !map.isStyleLoaded()) return
|
if (!mapView) return
|
||||||
|
const map = mapView.getMap?.()
|
||||||
|
if (!map) return
|
||||||
|
|
||||||
if (traffic && hasFeature('has_traffic_overlay')) {
|
const apply = () => {
|
||||||
mapRef.current.addTrafficLayer?.()
|
if (traffic && hasFeature('has_traffic_overlay')) {
|
||||||
|
mapView.addTrafficLayer?.()
|
||||||
|
} else {
|
||||||
|
mapView.removeTrafficLayer?.()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (map.isStyleLoaded()) {
|
||||||
|
apply()
|
||||||
} else {
|
} else {
|
||||||
mapRef.current.removeTrafficLayer?.()
|
map.once('style.load', apply)
|
||||||
}
|
}
|
||||||
savePrefs({ hillshade, traffic })
|
savePrefs({ hillshade, traffic })
|
||||||
|
return () => map.off('style.load', apply)
|
||||||
}, [traffic, mapRef])
|
}, [traffic, mapRef])
|
||||||
|
|
||||||
// Close on outside click
|
// Close on outside click
|
||||||
|
|
@ -71,8 +93,8 @@ export default function LayerControl({ mapRef }) {
|
||||||
setOpen(false)
|
setOpen(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
document.addEventListener('mousedown', handleClick)
|
document.addEventListener('pointerdown', handleClick)
|
||||||
return () => document.removeEventListener('mousedown', handleClick)
|
return () => document.removeEventListener('pointerdown', handleClick)
|
||||||
}, [open])
|
}, [open])
|
||||||
|
|
||||||
const showHillshade = hasFeature('has_hillshade')
|
const showHillshade = hasFeature('has_hillshade')
|
||||||
|
|
|
||||||
54
src/components/LocateButton.jsx
Normal file
54
src/components/LocateButton.jsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -197,36 +197,6 @@ const MapView = forwardRef(function MapView(_, ref) {
|
||||||
|
|
||||||
map.addControl(new maplibregl.NavigationControl(), 'top-right')
|
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 click — drop pin and reverse geocode
|
||||||
map.on('click', (e) => {
|
map.on('click', (e) => {
|
||||||
// If a stop pin was just clicked, skip the pin-drop
|
// 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(() => {
|
useEffect(() => {
|
||||||
const map = mapInstance.current
|
const map = mapInstance.current
|
||||||
if (!map || currentThemeRef.current === theme) return
|
if (!map || currentThemeRef.current === theme) return
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,7 @@ export default function ModeSelector() {
|
||||||
role="radio"
|
role="radio"
|
||||||
aria-checked={active}
|
aria-checked={active}
|
||||||
onClick={() => setMode(m.id)}
|
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={{
|
style={{
|
||||||
background: active ? 'var(--accent-muted)' : 'transparent',
|
background: active ? 'var(--accent-muted)' : 'transparent',
|
||||||
color: active ? 'var(--accent)' : 'var(--text-secondary)',
|
color: active ? 'var(--accent)' : 'var(--text-secondary)',
|
||||||
|
|
|
||||||
|
|
@ -215,7 +215,7 @@ export default function Panel({ onManeuverClick }) {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{sheetState !== 'collapsed' && (
|
{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}
|
{header}
|
||||||
{content}
|
{content}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -66,7 +66,7 @@ export default function StopItem({ stop, index, total, indexOffset = 0 }) {
|
||||||
{/* Remove button */}
|
{/* Remove button */}
|
||||||
<button
|
<button
|
||||||
onClick={() => removeStop(stop.id)}
|
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)' }}
|
style={{ color: 'var(--text-tertiary)' }}
|
||||||
aria-label={`Remove stop ${stop.name}`}
|
aria-label={`Remove stop ${stop.name}`}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
103
src/index.css
103
src/index.css
|
|
@ -58,18 +58,18 @@
|
||||||
|
|
||||||
/* ═══ LIGHT MODE ═══ */
|
/* ═══ LIGHT MODE ═══ */
|
||||||
[data-theme="light"] {
|
[data-theme="light"] {
|
||||||
--bg-base: #ece8e1; /* warm tan-gray (was #f5f2ed) */
|
--bg-base: #ddd2b9; /* warm khaki-tan (was #ece8e1) */
|
||||||
--bg-raised: #f5f2ec; /* raised surface (was #ffffff) */
|
--bg-raised: #e8dec8; /* raised surface (was #f5f2ec) */
|
||||||
--bg-overlay: #f0ece5; /* overlay/dropdown (was #faf8f5) */
|
--bg-overlay: #e3d9c1; /* overlay/dropdown (was #f0ece5) */
|
||||||
--bg-input: #f5f2ec; /* input fields (was #ffffff) */
|
--bg-input: #e8dec8; /* input fields (was #f5f2ec) */
|
||||||
|
|
||||||
--text-primary: #1a1d1a;
|
--text-primary: #1a1d1a;
|
||||||
--text-secondary: #5c6558;
|
--text-secondary: #4f5a49; /* darkened for WCAG AA on new base (was #5c6558) */
|
||||||
--text-tertiary: #8a9486;
|
--text-tertiary: #7a8674; /* darkened proportionally (was #8a9486) */
|
||||||
--text-inverse: #f5f2ed;
|
--text-inverse: #f5f2ed;
|
||||||
|
|
||||||
--border: #d4cfc5;
|
--border: #c4b89e; /* warmer border (was #d4cfc5) */
|
||||||
--border-subtle: #e8e3db;
|
--border-subtle: #d5cab2; /* warmer subtle border (was #e8e3db) */
|
||||||
|
|
||||||
--accent: #4a7040;
|
--accent: #4a7040;
|
||||||
--accent-hover: #3d5e35;
|
--accent-hover: #3d5e35;
|
||||||
|
|
@ -382,3 +382,90 @@ body {
|
||||||
.layer-control-toggle:checked::after {
|
.layer-control-toggle:checked::after {
|
||||||
transform: translateX(14px);
|
transform: translateX(14px);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* ═══ PLACE DETAIL ENRICHMENT ═══ */
|
||||||
|
.place-detail-section {
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.place-detail-section-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding-bottom: 6px;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.category-badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 2px 8px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--accent);
|
||||||
|
background: var(--accent-muted);
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* ═══ LOCATE BUTTON ═══ */
|
||||||
|
.locate-btn {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 80px;
|
||||||
|
right: 10px;
|
||||||
|
z-index: 10;
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: var(--bg-raised);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
transition: color 0.1s, border-color 0.1s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.locate-btn:hover {
|
||||||
|
color: var(--text-primary);
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ═══ STOP REMOVE BUTTON (touch-friendly) ═══ */
|
||||||
|
.stop-remove-btn {
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group:hover .stop-remove-btn {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ═══ MOBILE OVERRIDES ═══ */
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
body {
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layer-control {
|
||||||
|
bottom: auto;
|
||||||
|
top: 120px;
|
||||||
|
right: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.locate-btn {
|
||||||
|
bottom: auto;
|
||||||
|
top: 166px;
|
||||||
|
right: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stop-remove-btn {
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue