diff --git a/src/components/RouteDialog.tsx b/src/components/RouteDialog.tsx new file mode 100644 index 0000000..2b2bf34 --- /dev/null +++ b/src/components/RouteDialog.tsx @@ -0,0 +1,311 @@ +import { useEffect, useRef, useState, useCallback } from 'react'; +import L from 'leaflet'; +import { Loader2, MapPin, Navigation, AlertTriangle, LocateFixed } from 'lucide-react'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, +} from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { cn } from '@/lib/utils'; +import { getCurrentPosition, GeolocationError } from '@/lib/geolocation'; +import { + fetchRoute, + formatDistance, + formatDuration, + TRAVEL_MODES, + type TravelMode, + type RouteResult, +} from '@/lib/routing'; + +interface RouteDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + /** Destination coordinates. */ + destination: { lat: number; lng: number } | null; + /** Destination label shown in the header. */ + destinationLabel?: string; +} + +/** + * In-app routing dialog. + * + * Shows a self-contained Leaflet map with a route drawn from the user's current + * location to a destination, with a foot/bike/moto/car mode toggle. No external + * maps app is opened — the route comes from the keyless public OSRM server and + * is drawn here. See src/lib/routing.ts for the honest scope notes. + */ +export function RouteDialog({ + open, + onOpenChange, + destination, + destinationLabel = 'Destination', +}: RouteDialogProps) { + const mapDivRef = useRef(null); + const mapRef = useRef(null); + const routeLayerRef = useRef(null); + const originMarkerRef = useRef(null); + const destMarkerRef = useRef(null); + + const [origin, setOrigin] = useState<{ lat: number; lng: number } | null>(null); + const [mode, setMode] = useState('foot'); + const [route, setRoute] = useState(null); + const [locating, setLocating] = useState(false); + const [routing, setRouting] = useState(false); + const [error, setError] = useState(null); + const [locError, setLocError] = useState(null); + + // ─── Get the user's location when the dialog opens ───────────────────────── + const locate = useCallback(async () => { + setLocating(true); + setLocError(null); + try { + const pos = await getCurrentPosition(); + setOrigin(pos); + } catch (err) { + const msg = + err instanceof GeolocationError + ? err.kind === 'denied' + ? 'Location permission denied. Enable location to route from where you are.' + : 'Could not get your location. Try again.' + : 'Could not get your location.'; + setLocError(msg); + } finally { + setLocating(false); + } + }, []); + + useEffect(() => { + if (open && !origin && !locating && !locError) { + locate(); + } + }, [open, origin, locating, locError, locate]); + + // Reset transient state when closed + useEffect(() => { + if (!open) { + setRoute(null); + setError(null); + } + }, [open]); + + // ─── Initialize the Leaflet map once the dialog content is mounted ───────── + useEffect(() => { + if (!open || !mapDivRef.current || mapRef.current || !destination) return; + + const map = L.map(mapDivRef.current, { + center: [destination.lat, destination.lng], + zoom: 14, + scrollWheelZoom: true, + attributionControl: false, + minZoom: 2, + }); + + L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { + attribution: '', + subdomains: 'abc', + maxZoom: 19, + className: 'skatespots-dark-tiles', + }).addTo(map); + + // Destination marker + const destIcon = L.divIcon({ + className: 'route-dest-icon', + html: `
`, + iconSize: [32, 32], + iconAnchor: [16, 30], + }); + destMarkerRef.current = L.marker([destination.lat, destination.lng], { + icon: destIcon, + }).addTo(map); + + mapRef.current = map; + + setTimeout(() => map.invalidateSize(), 120); + + return () => { + map.remove(); + mapRef.current = null; + routeLayerRef.current = null; + originMarkerRef.current = null; + destMarkerRef.current = null; + }; + }, [open, destination]); + + // ─── Draw / update the origin marker when we have the user's location ────── + useEffect(() => { + const map = mapRef.current; + if (!map || !origin) return; + + if (originMarkerRef.current) { + originMarkerRef.current.setLatLng([origin.lat, origin.lng]); + } else { + const userIcon = L.divIcon({ + className: 'route-origin-icon', + html: `
`, + iconSize: [18, 18], + iconAnchor: [9, 9], + }); + originMarkerRef.current = L.marker([origin.lat, origin.lng], { + icon: userIcon, + }).addTo(map); + } + }, [origin]); + + // ─── Fetch + draw the route whenever origin / mode changes ───────────────── + useEffect(() => { + if (!open || !origin || !destination) return; + + const controller = new AbortController(); + let cancelled = false; + + (async () => { + setRouting(true); + setError(null); + try { + const result = await fetchRoute(origin, destination, mode, controller.signal); + if (cancelled) return; + setRoute(result); + + const map = mapRef.current; + if (map) { + if (routeLayerRef.current) { + routeLayerRef.current.remove(); + routeLayerRef.current = null; + } + const line = L.polyline(result.coordinates, { + color: '#ccff00', + weight: 5, + opacity: 0.9, + lineJoin: 'round', + lineCap: 'round', + }).addTo(map); + routeLayerRef.current = line; + map.fitBounds(line.getBounds(), { padding: [48, 48], maxZoom: 16 }); + } + } catch (err) { + if (cancelled) return; + if (err instanceof DOMException && err.name === 'AbortError') return; + setError( + err instanceof Error && err.message === 'No route found' + ? 'No route found for this mode. Try another mode.' + : "Couldn't reach the routing service. Check your connection and try again.", + ); + } finally { + if (!cancelled) setRouting(false); + } + })(); + + return () => { + cancelled = true; + controller.abort(); + }; + }, [open, origin, destination, mode]); + + return ( + + + + + + Route to {destinationLabel} + + + Routed in-app · nothing leaves to Google or Apple Maps + + + + {/* Mode toggle */} +
+ {TRAVEL_MODES.map((m) => ( + + ))} +
+ + {/* Map */} +
+
+ + {/* Overlays */} + {(locating || (routing && !route)) && ( +
+
+ + {locating ? 'Finding your location…' : 'Plotting your route…'} +
+
+ )} + + {locError && !origin && ( +
+
+ +

{locError}

+ +
+
+ )} +
+ + {/* Summary bar */} +
+ {error ? ( +
+ + {error} +
+ ) : route ? ( +
+
+
+ {TRAVEL_MODES.find((m) => m.mode === mode)?.emoji} +
+
+
+ {formatDistance(route.distanceM / 1000)} +
+
+ {formatDuration(route.durationS)} + {TRAVEL_MODES.find((m) => m.mode === mode)?.approximate && ( + · uses driving roads + )} +
+
+
+ {routing && ( + + )} +
+ ) : ( +
+ {origin ? 'Pick a mode to see your route.' : 'Waiting for your location…'} +
+ )} +
+ +
+ ); +} diff --git a/src/lib/routing.ts b/src/lib/routing.ts new file mode 100644 index 0000000..d377034 --- /dev/null +++ b/src/lib/routing.ts @@ -0,0 +1,156 @@ +/** + * In-app routing — OSRM client + skate-friendly distance/time formatting. + * ───────────────────────────────────────────────────────────────────────────── + * We deliberately do NOT hand off to Google/Apple Maps. Instead we fetch a route + * from the keyless, account-free public OSRM server and draw it on our own + * Leaflet map. This keeps navigation in-app, private, and dependency-light. + * + * SCOPE (on purpose): + * • We return a static route line + distance + duration summary. + * • We do NOT do live turn-by-turn, voice, or rerouting — that's a huge native + * effort and out of scope for a lean PWA. + * + * MODES: + * foot / bike / car are real OSRM profiles. "moto" (motorbike) isn't a real + * OSRM profile, so we route it as `car` (driving roads) and label it honestly. + * + * TRANSIT (subway/public transport) is intentionally NOT here: there is no free, + * keyless, worldwide transit routing API. It needs per-city GTFS data and would + * be empty in most cities — a worse experience than omitting it. + */ + +/** Travel modes offered in the UI. */ +export type TravelMode = 'foot' | 'bike' | 'moto' | 'car'; + +export interface TravelModeMeta { + mode: TravelMode; + /** OSRM routing profile actually used. */ + profile: 'foot' | 'bike' | 'car'; + label: string; + emoji: string; + /** True when we're being honest that the profile is an approximation. */ + approximate?: boolean; +} + +export const TRAVEL_MODES: TravelModeMeta[] = [ + { mode: 'foot', profile: 'foot', label: 'Skate / Walk', emoji: '🛹' }, + { mode: 'bike', profile: 'bike', label: 'Bike', emoji: '🚲' }, + { mode: 'moto', profile: 'car', label: 'Moto', emoji: '🏍️', approximate: true }, + { mode: 'car', profile: 'car', label: 'Car', emoji: '🚗' }, +]; + +export function travelModeMeta(mode: TravelMode): TravelModeMeta { + return TRAVEL_MODES.find((m) => m.mode === mode) ?? TRAVEL_MODES[0]; +} + +export interface RouteResult { + /** Decoded [lat, lng] path suitable for a Leaflet polyline. */ + coordinates: Array<[number, number]>; + /** Straight-line-ish route distance in meters. */ + distanceM: number; + /** Estimated travel time in seconds (from OSRM for the profile). */ + durationS: number; + /** Which mode the route was requested for. */ + mode: TravelMode; +} + +const OSRM_BASE = 'https://router.project-osrm.org/route/v1'; +const CORS_PROXY = 'https://proxy.shakespeare.diy/?url='; + +interface OsrmResponse { + code: string; + routes?: Array<{ + distance: number; + duration: number; + geometry: { coordinates: Array<[number, number]> }; // [lng, lat] + }>; +} + +/** + * Fetch a route between two points for a given travel mode using OSRM. + * Throws on network error or when OSRM returns no route. + */ +export async function fetchRoute( + from: { lat: number; lng: number }, + to: { lat: number; lng: number }, + mode: TravelMode, + signal?: AbortSignal, +): Promise { + const { profile } = travelModeMeta(mode); + // OSRM wants lon,lat;lon,lat + const coords = `${from.lng},${from.lat};${to.lng},${to.lat}`; + const target = `${OSRM_BASE}/${profile}/${coords}?overview=full&geometries=geojson`; + const url = `${CORS_PROXY}${encodeURIComponent(target)}`; + + const res = await fetch(url, { signal }); + if (!res.ok) { + throw new Error(`Routing service returned ${res.status}`); + } + + const data = (await res.json()) as OsrmResponse; + if (data.code !== 'Ok' || !data.routes || data.routes.length === 0) { + throw new Error('No route found'); + } + + const route = data.routes[0]; + // GeoJSON is [lng, lat] → Leaflet wants [lat, lng] + const coordinates = route.geometry.coordinates.map( + ([lng, lat]) => [lat, lng] as [number, number], + ); + + return { + coordinates, + distanceM: route.distance, + durationS: route.duration, + mode, + }; +} + +// ─── Formatting helpers ────────────────────────────────────────────────────── + +/** + * Format a distance in kilometers, human-friendly. + * e.g. 0.34 → "340 m", 3.4 → "3.4 km", 12 → "12 km" + */ +export function formatDistance(km: number): string { + if (!isFinite(km) || km < 0) return '—'; + if (km < 1) return `${Math.round(km * 1000)} m`; + if (km < 10) return `${km.toFixed(1)} km`; + return `${Math.round(km)} km`; +} + +/** Format a duration in seconds → "12 min", "1 h 5 min". */ +export function formatDuration(seconds: number): string { + if (!isFinite(seconds) || seconds < 0) return '—'; + const mins = Math.round(seconds / 60); + if (mins < 1) return '<1 min'; + if (mins < 60) return `${mins} min`; + const h = Math.floor(mins / 60); + const m = mins % 60; + return m > 0 ? `${h} h ${m} min` : `${h} h`; +} + +/** + * Skaters think in time, not distance. Estimate a cruising time from a + * straight-line distance (km) at a relaxed ~10 km/h push, and phrase it in a + * skate-native way. No routing call needed — pure formula, works offline. + */ +export function skateTime(km: number): string { + if (!isFinite(km) || km < 0) return ''; + const CRUISE_KMH = 10; // relaxed cruising pace + const mins = Math.round((km / CRUISE_KMH) * 60); + if (mins < 1) return 'right here'; + if (mins < 60) return `~${mins} min cruise`; + const h = (mins / 60).toFixed(1); + return `~${h} h cruise`; +} + +/** + * Compact one-line readout for cards/popups given a distance in km. + * e.g. "1.2 km · ~7 min cruise" + */ +export function distanceReadout(km: number): string { + const d = formatDistance(km); + const t = skateTime(km); + return t ? `${d} · ${t}` : d; +} diff --git a/src/pages/EventDetailPage.tsx b/src/pages/EventDetailPage.tsx index ab906e7..595841d 100644 --- a/src/pages/EventDetailPage.tsx +++ b/src/pages/EventDetailPage.tsx @@ -1,9 +1,10 @@ -import { useMemo } from 'react'; +import { useMemo, useState } from 'react'; import { useSeoMeta } from '@unhead/react'; import { useParams, Link } from 'react-router-dom'; import ngeohash from 'ngeohash'; import { Layout } from '@/components/Layout'; import { SpotMap } from '@/components/SpotMap'; +import { RouteDialog } from '@/components/RouteDialog'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Badge } from '@/components/ui/badge'; import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; @@ -92,6 +93,8 @@ export default function EventDetailPage() { const metadata = author.data?.metadata; const displayName = metadata?.name ?? genUserName(event?.pubkey || ''); + const [showRouteDialog, setShowRouteDialog] = useState(false); + const coordinates = useMemo(() => { if (!event?.geohash) return null; const { latitude, longitude } = ngeohash.decode(event.geohash); @@ -145,10 +148,8 @@ export default function EventDetailPage() { const handleDirections = () => { if (coordinates) { - window.open( - `https://www.google.com/maps/dir/?api=1&destination=${coordinates.lat},${coordinates.lng}`, - '_blank' - ); + // In-app routing — nothing leaves to Google/Apple Maps. + setShowRouteDialog(true); } }; @@ -420,6 +421,14 @@ export default function EventDetailPage() { + + {/* In-app route (no external maps) */} + ); } diff --git a/src/pages/ShopDetailPage.tsx b/src/pages/ShopDetailPage.tsx index 52cdd02..cc67133 100644 --- a/src/pages/ShopDetailPage.tsx +++ b/src/pages/ShopDetailPage.tsx @@ -5,6 +5,7 @@ import { useQueryClient } from '@tanstack/react-query'; import ngeohash from 'ngeohash'; import { Layout } from '@/components/Layout'; import { SpotMap } from '@/components/SpotMap'; +import { RouteDialog } from '@/components/RouteDialog'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Badge } from '@/components/ui/badge'; import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; @@ -85,6 +86,7 @@ export default function ShopDetailPage() { const navigate = useNavigate(); const queryClient = useQueryClient(); const [showDeleteDialog, setShowDeleteDialog] = useState(false); + const [showRouteDialog, setShowRouteDialog] = useState(false); const isOwner = user && shop && user.pubkey === shop.pubkey; const isAdmin = isAdminPubkey(user?.pubkey); @@ -139,10 +141,8 @@ export default function ShopDetailPage() { const handleDirections = () => { if (coordinates) { - window.open( - `https://www.google.com/maps/dir/?api=1&destination=${coordinates.lat},${coordinates.lng}`, - '_blank' - ); + // In-app routing — nothing leaves to Google/Apple Maps. + setShowRouteDialog(true); } }; @@ -507,6 +507,14 @@ export default function ShopDetailPage() { + + {/* In-app route (no external maps) */} + ); } diff --git a/src/pages/SpotDetailPage.tsx b/src/pages/SpotDetailPage.tsx index 567c260..18f7514 100644 --- a/src/pages/SpotDetailPage.tsx +++ b/src/pages/SpotDetailPage.tsx @@ -14,6 +14,7 @@ import { ZapButton } from '@/components/ZapButton'; import { CommentsSection } from '@/components/comments/CommentsSection'; import { SpotChat } from '@/components/SpotChat'; import { CrewsForSpotCard } from '@/components/CrewsForSpotCard'; +import { RouteDialog } from '@/components/RouteDialog'; import { useSkateSpot } from '@/hooks/useSkateSpots'; import { useAuthor } from '@/hooks/useAuthor'; import { useDeleteEntity } from '@/hooks/useDeleteEntity'; @@ -131,6 +132,7 @@ export default function SpotDetailPage() { const [showCollectionDialog, setShowCollectionDialog] = useState(false); const [showReportDialog, setShowReportDialog] = useState(false); const [showStatusDialog, setShowStatusDialog] = useState(false); + const [showRouteDialog, setShowRouteDialog] = useState(false); const isOwner = user && spot && user.pubkey === spot.pubkey; const isAdmin = isAdminPubkey(user?.pubkey); @@ -184,14 +186,8 @@ export default function SpotDetailPage() { const handleDirections = () => { if (!coordinates) return; - const { lat, lng } = coordinates; - const label = encodeURIComponent(spot?.title ?? 'Skate Spot'); - // Use Apple Maps on iOS/macOS, Google Maps everywhere else - const isApple = /iPhone|iPad|iPod|Macintosh/.test(navigator.userAgent) && !/(Windows|Android)/.test(navigator.userAgent); - const url = isApple - ? `https://maps.apple.com/?ll=${lat},${lng}&q=${label}&dirflg=d` - : `https://www.google.com/maps/dir/?api=1&destination=${lat},${lng}`; - window.open(url, '_blank'); + // In-app routing — draws the route on our own map, nothing leaves to Google/Apple. + setShowRouteDialog(true); }; const handleDelete = async () => { @@ -1026,6 +1022,14 @@ export default function SpotDetailPage() { onOpenChange={setShowStatusDialog} preselectedSpot={spot} /> + + {/* In-app route (no external maps) */} + ); }