feat(directions): public-transport mode (Transitous) + open-in-maps handoff

- routing.ts: add a 'transit' travel mode via Transitous (MOTIS 2 API, keyless,
  open CORS), with a local precision-7 polyline decoder and per-leg parsing.
  Road modes stay on keyless OSRM; moto remains ≈car and honestly labelled.
- RouteDialog: transit draws each leg (walk dashed grey, transit coloured by
  mode) and summarises depart→arrive, duration, transfers and line chips, with
  Transitous/OpenStreetMap attribution.
- directions.ts: opt-in 'Open in' handoff to Google / Apple / OpenStreetMap
  (+ Organic Maps via geo: on Android), mode-aware. In-app routing stays the
  default; the description notes transit shares the trip with Transitous.
- No scooter/moped profile (deliberate).
This commit is contained in:
SkateSpots 2026-09-27 10:52:26 +07:00
parent 2bc903d128
commit fdf33d2c45
4 changed files with 419 additions and 26 deletions

View file

@ -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/* →

View file

@ -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<HTMLDivElement>(null);
const mapRef = useRef<L.Map | null>(null);
const routeLayerRef = useRef<L.Polyline | null>(null);
const routeLayersRef = useRef<L.LayerGroup | null>(null);
const originMarkerRef = useRef<L.Marker | null>(null);
const destMarkerRef = useRef<L.Marker | null>(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}
</DialogTitle>
<DialogDescription className="text-xs">
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'}
</DialogDescription>
</DialogHeader>
@ -233,7 +309,7 @@ export function RouteDialog({
aria-pressed={mode === m.mode}
>
<span className="text-lg leading-none">{m.emoji}</span>
<span className="text-[10px]">{m.label}</span>
<span className="text-[10px] leading-tight text-center">{m.label}</span>
</button>
))}
</div>
@ -277,6 +353,40 @@ export function RouteDialog({
<AlertTriangle className="h-4 w-4 shrink-0" />
<span>{error}</span>
</div>
) : route?.transit ? (
<div className="w-full space-y-2">
<div className="flex items-center gap-3">
<div className="text-2xl leading-none">🚇</div>
<div className="min-w-0">
<div className="text-lg font-black tabular-nums">
{formatClock(route.transit.departISO)} → {formatClock(route.transit.arriveISO)}
</div>
<div className="text-xs text-muted-foreground">
{formatDuration(route.transit.durationS)} ·{' '}
{route.transit.transfers === 0
? 'direct'
: `${route.transit.transfers} transfer${route.transit.transfers > 1 ? 's' : ''}`}
{routing && <Loader2 className="ml-1 inline h-3 w-3 animate-spin" />}
</div>
</div>
</div>
{route.transit.legs.some((l) => l.mode !== 'WALK') && (
<div className="flex flex-wrap gap-1">
{route.transit.legs
.filter((l) => l.mode !== 'WALK')
.map((l, i) => (
<span
key={`${l.mode}-${l.line}-${i}`}
className="inline-flex items-center gap-1 rounded-full border border-white/10 bg-white/5 px-2 py-0.5 text-[10px] font-bold text-gray-300"
>
<span aria-hidden>{transitModeEmoji(l.mode)}</span>
{l.line ?? l.mode}
</span>
))}
</div>
)}
<p className="text-[10px] text-muted-foreground/70">{TRANSIT_ATTRIBUTION}</p>
</div>
) : route ? (
<div className="flex items-center justify-between w-full">
<div className="flex items-center gap-3">
@ -305,6 +415,27 @@ export function RouteDialog({
</div>
)}
</div>
{/* Open in maps — opt-in handoff to an external app (this shares the trip) */}
{origin && destination && (
<div className="px-4 pb-4 flex flex-wrap items-center gap-2">
<span className="w-full text-[10px] uppercase tracking-widest text-muted-foreground">
Open in
</span>
{buildDirectionsLinks(origin, destination, mode, destinationLabel).map((link) => (
<Button
key={link.id}
type="button"
variant="outline"
size="sm"
className="gap-1.5"
onClick={() => openExternal(link.url)}
>
{link.label}
</Button>
))}
</div>
)}
</DialogContent>
</Dialog>
);

101
src/lib/directions.ts Normal file
View file

@ -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<TravelMode, string> = {
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');
}

View file

@ -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<RouteResult> {
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<RouteResult> {
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 ──────────────────────────────────────────────────────
/**