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:
parent
2bc903d128
commit
fdf33d2c45
4 changed files with 419 additions and 26 deletions
|
|
@ -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/* →
|
||||
|
|
|
|||
|
|
@ -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
101
src/lib/directions.ts
Normal 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');
|
||||
}
|
||||
|
|
@ -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 ──────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
|
|
|
|||
Loading…
Reference in a new issue