diff --git a/AGENTS.md b/AGENTS.md index 518a013..45eaf86 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -116,6 +116,15 @@ session with **no other memory of this project**. So: owner Settings health card (stale after 45 min) stays green; `LOG_LEVEL` is now wired to the transport. Relays come from `NOSTR_RELAYS` in the gitignored `cvm-server/.env`. The tracked manifest is `package.cvm.json`. +- **Routing / directions**: `RouteDialog` (opened from the spot / shop / event / + food detail pages) draws an in-app route on Leaflet. Road modes + (skate-walk / bike / moto / car) come from keyless **OSRM** via the + `proxy.shakespeare.diy` CORS proxy (`src/lib/routing.ts`); moto≈car is + labelled honestly. **Transit** mode uses **Transitous** (`api.transitous.org`, + MOTIS 2, open CORS) — note origin/destination go to that service, unlike road + modes. An opt-in **"Open in"** handoff (`src/lib/directions.ts`) deep-links + Google / Apple / OSM (+ Organic Maps via `geo:` on Android). No scooter/moped + profile (deliberate). - **Mints directory**: `/mints` page lists Cashu mints from the community **MintRadar** project. MintRadar has **no CORS**, so it's fetched same-origin at `/api/mints/*` — prod proxies via `public/_redirects` (`/api/mints/* → diff --git a/src/components/RouteDialog.tsx b/src/components/RouteDialog.tsx index 2b2bf34..c801b7f 100644 --- a/src/components/RouteDialog.tsx +++ b/src/components/RouteDialog.tsx @@ -13,12 +13,58 @@ import { cn } from '@/lib/utils'; import { getCurrentPosition, GeolocationError } from '@/lib/geolocation'; import { fetchRoute, + fetchTransitRoute, + formatClock, formatDistance, formatDuration, TRAVEL_MODES, + TRANSIT_ATTRIBUTION, type TravelMode, type RouteResult, } from '@/lib/routing'; +import { buildDirectionsLinks, openExternal } from '@/lib/directions'; + +// ─── Transit presentation helpers ──────────────────────────────────────────── + +/** Line colour for a transit leg, keyed by the MOTIS mode. */ +function transitLegColor(mode: string): string { + switch (mode) { + case 'BUS': return '#22c55e'; + case 'TRAM': return '#eab308'; + case 'SUBWAY': + case 'METRO': return '#3b82f6'; + case 'RAIL': + case 'REGIONAL_RAIL': + case 'REGIONAL_FAST_RAIL': + case 'LONG_DISTANCE': + case 'HIGHSPEED_RAIL': + case 'NIGHT_RAIL': + case 'SUBURBAN': return '#ef4444'; + case 'FERRY': return '#06b6d4'; + default: return '#ccff00'; + } +} + +/** Emoji for a transit leg's mode. */ +function transitModeEmoji(mode: string): string { + switch (mode) { + case 'BUS': return '🚌'; + case 'TRAM': return '🚊'; + case 'SUBWAY': + case 'METRO': return '🚇'; + case 'RAIL': + case 'REGIONAL_RAIL': + case 'REGIONAL_FAST_RAIL': + case 'LONG_DISTANCE': + case 'HIGHSPEED_RAIL': + case 'NIGHT_RAIL': + case 'SUBURBAN': return '🚆'; + case 'FERRY': return '⛴️'; + case 'WALK': return '🚶'; + case 'BIKE': return '🚲'; + default: return '🚇'; + } +} interface RouteDialogProps { open: boolean; @@ -45,7 +91,7 @@ export function RouteDialog({ }: RouteDialogProps) { const mapDivRef = useRef(null); const mapRef = useRef(null); - const routeLayerRef = useRef(null); + const routeLayersRef = useRef(null); const originMarkerRef = useRef(null); const destMarkerRef = useRef(null); @@ -128,7 +174,7 @@ export function RouteDialog({ return () => { map.remove(); mapRef.current = null; - routeLayerRef.current = null; + routeLayersRef.current = null; originMarkerRef.current = null; destMarkerRef.current = null; }; @@ -165,33 +211,61 @@ export function RouteDialog({ setRouting(true); setError(null); try { - const result = await fetchRoute(origin, destination, mode, controller.signal); + const result = mode === 'transit' + ? await fetchTransitRoute(origin, destination, controller.signal) + : 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; + if (routeLayersRef.current) { + routeLayersRef.current.remove(); + routeLayersRef.current = null; + } + const group = L.layerGroup().addTo(map); + + if (result.transit) { + // Public transport: draw each leg — walking dashed grey, transit + // legs solid and coloured by mode. + for (const leg of result.transit.legs) { + if (leg.coordinates.length < 2) continue; + const isWalk = leg.mode === 'WALK'; + L.polyline(leg.coordinates, { + color: isWalk ? '#9ca3af' : transitLegColor(leg.mode), + weight: isWalk ? 3 : 5, + opacity: isWalk ? 0.7 : 0.9, + dashArray: isWalk ? '4 6' : undefined, + lineJoin: 'round', + lineCap: 'round', + }).addTo(group); + } + } else { + L.polyline(result.coordinates, { + color: '#ccff00', + weight: 5, + opacity: 0.9, + lineJoin: 'round', + lineCap: 'round', + }).addTo(group); + } + + routeLayersRef.current = group; + if (result.coordinates.length > 0) { + map.fitBounds(L.latLngBounds(result.coordinates), { padding: [48, 48], maxZoom: 16 }); } - 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.", + mode === 'transit' + ? err instanceof Error && err.message === 'No transit route found' + ? 'No public-transport route found here — this area may not publish open timetables.' + : "Couldn't reach the transit service. Check your connection and try again." + : 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); @@ -213,7 +287,9 @@ export function RouteDialog({ Route to {destinationLabel} - Routed in-app · nothing leaves to Google or Apple Maps + {mode === 'transit' + ? 'Public transport via Transitous (open timetables + OpenStreetMap)' + : 'Routed in-app · nothing leaves to Google or Apple Maps'} @@ -233,7 +309,7 @@ export function RouteDialog({ aria-pressed={mode === m.mode} > {m.emoji} - {m.label} + {m.label} ))} @@ -277,6 +353,40 @@ export function RouteDialog({ {error} + ) : route?.transit ? ( +
+
+
🚇
+
+
+ {formatClock(route.transit.departISO)} → {formatClock(route.transit.arriveISO)} +
+
+ {formatDuration(route.transit.durationS)} ·{' '} + {route.transit.transfers === 0 + ? 'direct' + : `${route.transit.transfers} transfer${route.transit.transfers > 1 ? 's' : ''}`} + {routing && } +
+
+
+ {route.transit.legs.some((l) => l.mode !== 'WALK') && ( +
+ {route.transit.legs + .filter((l) => l.mode !== 'WALK') + .map((l, i) => ( + + {transitModeEmoji(l.mode)} + {l.line ?? l.mode} + + ))} +
+ )} +

{TRANSIT_ATTRIBUTION}

+
) : route ? (
@@ -305,6 +415,27 @@ export function RouteDialog({
)}
+ + {/* Open in maps — opt-in handoff to an external app (this shares the trip) */} + {origin && destination && ( +
+ + Open in + + {buildDirectionsLinks(origin, destination, mode, destinationLabel).map((link) => ( + + ))} +
+ )} ); diff --git a/src/lib/directions.ts b/src/lib/directions.ts new file mode 100644 index 0000000..5403f16 --- /dev/null +++ b/src/lib/directions.ts @@ -0,0 +1,101 @@ +/** + * "Open in maps" handoff — deep links to external navigation apps. + * ───────────────────────────────────────────────────────────────────────────── + * The in-app RouteDialog deliberately keeps routing on our own map (OSRM + + * Transitous) so nothing goes to Google by default. This module is the *opt-in* + * escape hatch: build links so a user who wants real turn-by-turn / offline + * navigation can hand the trip to another app. + * + * Providers are chosen for reliable, keyless web URLs that also deep-link into + * their native apps: + * • Google Maps — supports driving / walking / bicycling / transit + * • Apple Maps — iOS/macOS (opens the web version elsewhere) + * • OpenStreetMap — car / bike / foot (no transit) + * • Organic Maps — Android `geo:` intent (privacy-first, offline) + * + * Handing off *does* share the trip with that provider — hence opt-in. + */ +import { Capacitor } from '@capacitor/core'; +import type { TravelMode } from '@/lib/routing'; + +export interface DirectionsLink { + id: string; + label: string; + url: string; +} + +interface Point { + lat: number; + lng: number; +} + +const GOOGLE_MODE: Record = { + foot: 'walking', + bike: 'bicycling', + moto: 'driving', + car: 'driving', + transit: 'transit', +}; + +function googleUrl(from: Point, to: Point, mode: TravelMode): string { + return `https://www.google.com/maps/dir/?api=1&origin=${from.lat},${from.lng}&destination=${to.lat},${to.lng}&travelmode=${GOOGLE_MODE[mode]}`; +} + +/** Apple Maps "dirflg": w = walking, r = public transit, d = driving. */ +function appleFlag(mode: TravelMode): string { + if (mode === 'foot') return 'w'; + if (mode === 'transit') return 'r'; + return 'd'; // car / moto / bike +} + +function appleUrl(from: Point, to: Point, mode: TravelMode): string { + return `https://maps.apple.com/?saddr=${from.lat},${from.lng}&daddr=${to.lat},${to.lng}&dirflg=${appleFlag(mode)}`; +} + +/** OSM has no public-transport routing; returns null for transit. */ +function osmEngine(mode: TravelMode): string | null { + if (mode === 'foot') return 'fossgis_osrm_foot'; + if (mode === 'bike') return 'fossgis_osrm_bike'; + if (mode === 'transit') return null; + return 'fossgis_osrm_car'; // car / moto +} + +function osmUrl(from: Point, to: Point, mode: TravelMode): string | null { + const engine = osmEngine(mode); + if (!engine) return null; + return `https://www.openstreetmap.org/directions?engine=${engine}&route=${from.lat},${from.lng};${to.lat},${to.lng}`; +} + +/** `geo:` intent — Android hands it to Organic Maps (or the default map app). */ +function organicUrl(to: Point, label: string): string { + return `geo:${to.lat},${to.lng}?q=${to.lat},${to.lng}(${encodeURIComponent(label)})`; +} + +export function buildDirectionsLinks( + from: Point, + to: Point, + mode: TravelMode, + destinationLabel: string, +): DirectionsLink[] { + const links: DirectionsLink[] = [ + { id: 'google', label: 'Google Maps', url: googleUrl(from, to, mode) }, + { id: 'apple', label: 'Apple Maps', url: appleUrl(from, to, mode) }, + ]; + + const osm = osmUrl(from, to, mode); + if (osm) links.push({ id: 'osm', label: 'OpenStreetMap', url: osm }); + + if (Capacitor.getPlatform() === 'android') { + links.push({ id: 'organic', label: 'Organic Maps', url: organicUrl(to, destinationLabel) }); + } + + return links; +} + +/** + * Open an external URL. Capacitor intercepts the `_system` target and hands the + * URL to the OS (system browser / registered app); on the web it's a new tab. + */ +export function openExternal(url: string): void { + window.open(url, '_system'); +} diff --git a/src/lib/routing.ts b/src/lib/routing.ts index d377034..34bc9ca 100644 --- a/src/lib/routing.ts +++ b/src/lib/routing.ts @@ -14,18 +14,21 @@ * 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. + * TRANSIT (public transport) is routed separately against **Transitous** — a + * free, keyless, community-run worldwide transit router (MOTIS 2 API). It has + * open CORS, so the browser calls it directly. It sends the origin/destination + * to api.transitous.org (privacy note: unlike OSRM-mode routing, this one call + * is not in-app only). Coverage depends on which operators publish open data; + * where none is published we report that honestly instead of faking a route. */ /** Travel modes offered in the UI. */ -export type TravelMode = 'foot' | 'bike' | 'moto' | 'car'; +export type TravelMode = 'foot' | 'bike' | 'moto' | 'car' | 'transit'; export interface TravelModeMeta { mode: TravelMode; - /** OSRM routing profile actually used. */ - profile: 'foot' | 'bike' | 'car'; + /** OSRM routing profile actually used (undefined for transit). */ + profile?: 'foot' | 'bike' | 'car'; label: string; emoji: string; /** True when we're being honest that the profile is an approximation. */ @@ -37,6 +40,7 @@ export const TRAVEL_MODES: TravelModeMeta[] = [ { mode: 'bike', profile: 'bike', label: 'Bike', emoji: '🚲' }, { mode: 'moto', profile: 'car', label: 'Moto', emoji: '🏍️', approximate: true }, { mode: 'car', profile: 'car', label: 'Car', emoji: '🚗' }, + { mode: 'transit', label: 'Transit', emoji: '🚇' }, ]; export function travelModeMeta(mode: TravelMode): TravelModeMeta { @@ -52,8 +56,36 @@ export interface RouteResult { durationS: number; /** Which mode the route was requested for. */ mode: TravelMode; + /** Present only for transit routes — the chosen public-transport itinerary. */ + transit?: TransitItinerary; } +/** One leg of a public-transport itinerary (walk, bus, subway, rail, …). */ +export interface TransitLeg { + /** MOTIS mode, uppercased: WALK, BUS, TRAM, SUBWAY, METRO, RAIL, … */ + mode: string; + /** Short line name when the leg is on a vehicle (e.g. "U9", "S7"). */ + line?: string; + fromName?: string; + toName?: string; + startISO?: string; + endISO?: string; + /** Decoded [lat, lng] path for this leg. */ + coordinates: Array<[number, number]>; +} + +/** A public-transport itinerary summary returned by the transit router. */ +export interface TransitItinerary { + departISO: string; + arriveISO: string; + durationS: number; + transfers: number; + legs: TransitLeg[]; +} + +/** Attribution required by Transitous / OpenStreetMap. */ +export const TRANSIT_ATTRIBUTION = 'Transit: Transitous · OpenStreetMap contributors'; + const OSRM_BASE = 'https://router.project-osrm.org/route/v1'; const CORS_PROXY = 'https://proxy.shakespeare.diy/?url='; @@ -77,6 +109,7 @@ export async function fetchRoute( signal?: AbortSignal, ): Promise { const { profile } = travelModeMeta(mode); + if (!profile) throw new Error('Not a road routing mode'); // transit uses fetchTransitRoute // 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`; @@ -106,6 +139,125 @@ export async function fetchRoute( }; } +// ─── Public transport (Transitous / MOTIS) ─────────────────────────────────── + +const TRANSITOUS_PLAN = 'https://api.transitous.org/api/v1/plan'; + +/** + * Decode an encoded polyline (Google/OSRM format) into [lat, lng] pairs. + * MOTIS emits these with `precision: 7` (1e7). Standard decoder — kept local + * so we don't add a dependency for one function. + */ +function decodePolyline(encoded: string, precision = 7): Array<[number, number]> { + const factor = 10 ** precision; + const coordinates: Array<[number, number]> = []; + let index = 0; + let lat = 0; + let lng = 0; + while (index < encoded.length) { + let shift = 0; + let result = 0; + let byte: number; + do { + byte = encoded.charCodeAt(index++) - 63; + result |= (byte & 0x1f) << shift; + shift += 5; + } while (byte >= 0x20); + lat += result & 1 ? ~(result >> 1) : result >> 1; + + shift = 0; + result = 0; + do { + byte = encoded.charCodeAt(index++) - 63; + result |= (byte & 0x1f) << shift; + shift += 5; + } while (byte >= 0x20); + lng += result & 1 ? ~(result >> 1) : result >> 1; + + coordinates.push([lat / factor, lng / factor]); + } + return coordinates; +} + +interface MotisLeg { + mode?: string; + routeShortName?: string; + routeLongName?: string; + distance?: number; + startTime?: string; + endTime?: string; + from?: { name?: string }; + to?: { name?: string }; + legGeometry?: { points?: string; precision?: number }; +} +interface MotisItinerary { + startTime?: string; + endTime?: string; + duration?: number; + transfers?: number; + legs?: MotisLeg[]; +} + +/** + * Fetch a public-transport itinerary from Transitous (MOTIS 2 API). Keyless and + * free; throws when no itinerary exists (e.g. no open timetable for the area) + * so the caller can say so plainly rather than inventing a route. + */ +export async function fetchTransitRoute( + from: { lat: number; lng: number }, + to: { lat: number; lng: number }, + signal?: AbortSignal, +): Promise { + const params = new URLSearchParams({ + fromPlace: `${from.lat},${from.lng}`, + toPlace: `${to.lat},${to.lng}`, + numItineraries: '4', + // ISO in UTC (JS always emits the Gregorian calendar — unlike the shell). + time: new Date().toISOString(), + }); + const res = await fetch(`${TRANSITOUS_PLAN}?${params.toString()}`, { signal }); + if (!res.ok) throw new Error(`Transit service returned ${res.status}`); + + const data = (await res.json()) as { itineraries?: MotisItinerary[] }; + const itineraries = Array.isArray(data.itineraries) ? data.itineraries : []; + const it = itineraries[0]; + if (!it) throw new Error('No transit route found'); + + const legs: TransitLeg[] = (it.legs ?? []).map((leg) => ({ + mode: (leg.mode ?? '').toUpperCase(), + line: leg.routeShortName || leg.routeLongName, + fromName: leg.from?.name, + toName: leg.to?.name, + startISO: leg.startTime, + endISO: leg.endTime, + coordinates: + leg.legGeometry?.points + ? decodePolyline(leg.legGeometry.points, leg.legGeometry.precision ?? 7) + : [], + })); + + return { + coordinates: legs.flatMap((l) => l.coordinates), + distanceM: (it.legs ?? []).reduce((sum, l) => sum + (l.distance ?? 0), 0), + durationS: it.duration ?? 0, + mode: 'transit', + transit: { + departISO: it.startTime ?? '', + arriveISO: it.endTime ?? '', + durationS: it.duration ?? 0, + transfers: it.transfers ?? 0, + legs, + }, + }; +} + +/** Format an ISO timestamp as a local clock time, e.g. "08:39". */ +export function formatClock(iso: string): string { + const d = new Date(iso); + if (Number.isNaN(d.getTime())) return ''; + return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); +} + // ─── Formatting helpers ────────────────────────────────────────────────────── /**