Add in-app routing (foot/bike/moto/car) via OSRM — no external maps handoff

- New src/lib/routing.ts: keyless OSRM client, travel modes, distance/skate-time formatters
- New RouteDialog: self-contained Leaflet map drawing the route from the user's
  location to a spot/shop/event, with a mode toggle and distance/time summary
- Wire the "Directions" buttons on Spot/Shop/Event detail pages to route in-app
  instead of opening Google/Apple Maps

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
This commit is contained in:
shakespeare.diy 2026-09-03 10:17:06 +07:00
parent f4bca7b736
commit 35915557bb
5 changed files with 505 additions and 17 deletions

View file

@ -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<HTMLDivElement>(null);
const mapRef = useRef<L.Map | null>(null);
const routeLayerRef = useRef<L.Polyline | null>(null);
const originMarkerRef = useRef<L.Marker | null>(null);
const destMarkerRef = useRef<L.Marker | null>(null);
const [origin, setOrigin] = useState<{ lat: number; lng: number } | null>(null);
const [mode, setMode] = useState<TravelMode>('foot');
const [route, setRoute] = useState<RouteResult | null>(null);
const [locating, setLocating] = useState(false);
const [routing, setRouting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [locError, setLocError] = useState<string | null>(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: `<div style="width:32px;height:32px;background:linear-gradient(135deg,#ff0040,#ff6600);border-radius:50% 50% 50% 0;transform:rotate(-45deg);box-shadow:0 4px 12px rgba(255,0,64,0.5);border:3px solid rgba(255,255,255,0.9);"></div>`,
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: `<div style="width:18px;height:18px;background:#00b3ff;border-radius:50%;box-shadow:0 0 0 6px rgba(0,179,255,0.25),0 2px 8px rgba(0,0,0,0.4);border:3px solid #fff;"></div>`,
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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-lg p-0 gap-0 overflow-hidden">
<DialogHeader className="p-4 pb-3">
<DialogTitle className="flex items-center gap-2 text-lg">
<Navigation className="h-5 w-5 text-[#ccff00]" />
Route to {destinationLabel}
</DialogTitle>
<DialogDescription className="text-xs">
Routed in-app · nothing leaves to Google or Apple Maps
</DialogDescription>
</DialogHeader>
{/* Mode toggle */}
<div className="px-4 pb-3 flex gap-1.5">
{TRAVEL_MODES.map((m) => (
<button
key={m.mode}
type="button"
onClick={() => setMode(m.mode)}
className={cn(
'flex-1 flex flex-col items-center gap-0.5 rounded-xl py-2 text-xs font-bold uppercase tracking-wide transition-all border-2',
mode === m.mode
? 'bg-[#ccff00]/15 border-[#ccff00] text-[#ccff00]'
: 'border-transparent bg-muted/50 text-muted-foreground hover:bg-muted',
)}
aria-pressed={mode === m.mode}
>
<span className="text-lg leading-none">{m.emoji}</span>
<span className="text-[10px]">{m.label}</span>
</button>
))}
</div>
{/* Map */}
<div className="relative">
<div
ref={mapDivRef}
className="h-64 w-full bg-muted"
aria-label="Route map"
/>
{/* Overlays */}
{(locating || (routing && !route)) && (
<div className="absolute inset-0 flex items-center justify-center bg-background/60 backdrop-blur-sm">
<div className="flex items-center gap-2 text-sm font-medium">
<Loader2 className="h-4 w-4 animate-spin text-[#ccff00]" />
{locating ? 'Finding your location…' : 'Plotting your route…'}
</div>
</div>
)}
{locError && !origin && (
<div className="absolute inset-0 flex items-center justify-center bg-background/80 p-6">
<div className="text-center space-y-3 max-w-xs">
<MapPin className="h-8 w-8 mx-auto text-muted-foreground" />
<p className="text-sm text-muted-foreground">{locError}</p>
<Button size="sm" variant="outline" onClick={locate} className="gap-2">
<LocateFixed className="h-4 w-4" />
Try again
</Button>
</div>
</div>
)}
</div>
{/* Summary bar */}
<div className="p-4 border-t bg-muted/30 min-h-[64px] flex items-center">
{error ? (
<div className="flex items-center gap-2 text-sm text-amber-500">
<AlertTriangle className="h-4 w-4 shrink-0" />
<span>{error}</span>
</div>
) : route ? (
<div className="flex items-center justify-between w-full">
<div className="flex items-center gap-3">
<div className="text-2xl leading-none">
{TRAVEL_MODES.find((m) => m.mode === mode)?.emoji}
</div>
<div>
<div className="text-xl font-black tabular-nums">
{formatDistance(route.distanceM / 1000)}
</div>
<div className="text-xs text-muted-foreground">
{formatDuration(route.durationS)}
{TRAVEL_MODES.find((m) => m.mode === mode)?.approximate && (
<span className="ml-1">· uses driving roads</span>
)}
</div>
</div>
</div>
{routing && (
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
)}
</div>
) : (
<div className="text-sm text-muted-foreground">
{origin ? 'Pick a mode to see your route.' : 'Waiting for your location…'}
</div>
)}
</div>
</DialogContent>
</Dialog>
);
}

156
src/lib/routing.ts Normal file
View file

@ -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<RouteResult> {
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;
}

View file

@ -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() {
</div>
</div>
</div>
{/* In-app route (no external maps) */}
<RouteDialog
open={showRouteDialog}
onOpenChange={setShowRouteDialog}
destination={coordinates}
destinationLabel={event?.title ?? 'Event'}
/>
</Layout>
);
}

View file

@ -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() {
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* In-app route (no external maps) */}
<RouteDialog
open={showRouteDialog}
onOpenChange={setShowRouteDialog}
destination={coordinates}
destinationLabel={shop?.name ?? 'Shop'}
/>
</Layout>
);
}

View file

@ -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) */}
<RouteDialog
open={showRouteDialog}
onOpenChange={setShowRouteDialog}
destination={coordinates}
destinationLabel={spot?.title ?? 'Spot'}
/>
</Layout>
);
}