import { DEFAULT_LEG_FACTOR, LEG_FACTORS } from '#shared/emissions' import { decodePolyline, thin } from '#shared/polyline' import type { Coord, Segment } from '#shared/types' interface MotisPlace { lat: number lon: number name?: string } interface MotisLeg { mode: string duration?: number distance?: number startTime: string endTime: string from: MotisPlace to: MotisPlace headsign?: string routeColor?: string routeTextColor?: string routeShortName?: string routeLongName?: string displayName?: string agencyName?: string agencyUrl?: string realTime?: boolean legGeometry?: { points: string, precision: number, length: number } } interface MotisItinerary { duration: number transfers: number legs: MotisLeg[] } type Family = 'rail' | 'urban' | 'walk' const RAIL = new Set(['HIGHSPEED_RAIL', 'LONG_DISTANCE', 'NIGHT_RAIL', 'REGIONAL_RAIL', 'REGIONAL_FAST_RAIL', 'RAIL']) const URBAN = new Set(['BUS', 'COACH', 'SUBWAY', 'METRO', 'TRAM', 'TROLLEYBUS']) export function family(mode: string): Family { if (RAIL.has(mode)) return 'rail' if (URBAN.has(mode)) return 'urban' return 'walk' } /** Distance orthodromique entre deux points, en kilometres. */ export function haversineKm(a: MotisPlace, b: MotisPlace): number { const R = 6371.0088 const rad = (d: number) => (d * Math.PI) / 180 const dLat = rad(b.lat - a.lat) const dLon = rad(b.lon - a.lon) const h = Math.sin(dLat / 2) ** 2 + Math.cos(rad(a.lat)) * Math.cos(rad(b.lat)) * Math.sin(dLon / 2) ** 2 return 2 * R * Math.asin(Math.sqrt(h)) } /** Transitous ne renseigne pas toujours distance et duration : on recalcule. */ export function legKm(leg: MotisLeg): number { return leg.distance !== undefined ? leg.distance / 1000 : haversineKm(leg.from, leg.to) } export function legMin(leg: MotisLeg): number { if (leg.duration !== undefined) return leg.duration / 60 return (Date.parse(leg.endTime) - Date.parse(leg.startTime)) / 60000 } /** Le mode qui pese le plus longtemps decide de la nature du trajet. */ export function dominant(itinerary: MotisItinerary): Family { const weight: Record = { rail: 0, urban: 0, walk: 0 } for (const leg of itinerary.legs) weight[family(leg.mode)] += legMin(leg) if (weight.rail > 0 && weight.rail >= weight.urban) return 'rail' return weight.urban > 0 ? 'urban' : 'walk' } /** Les couleurs GTFS sont fournies sans diese : « fdc41f » et non « #fdc41f ». */ function hexColor(raw?: string): string | undefined { if (!raw || !/^[0-9a-f]{6}$/i.test(raw)) return undefined return `#${raw}` } /** MOTIS annonce sa precision dans la reponse : on ne la devine jamais. */ export function legGeometry(leg: MotisLeg): Coord[] | undefined { const g = leg.legGeometry if (!g?.points) return undefined return thin(decodePolyline(g.points, g.precision ?? 5), 400) } /** * Transforme un troncon brut en information affichable. * Les marches de moins de deux minutes sont ignorees : elles polluent * l'affichage sans rien apprendre au voyageur. */ export function toSegment(leg: MotisLeg): Segment | null { const minutes = Math.round(legMin(leg)) if (family(leg.mode) === 'walk' && minutes < 2) return null // MOTIS nomme les extremites du trajet START et END : sans interet pour l'usager. const nom = (p: MotisPlace) => (p.name === 'START' || p.name === 'END' ? '' : p.name ?? '') return { mode: leg.mode, line: leg.displayName || leg.routeShortName || (leg.mode === 'WALK' ? 'Marche' : ''), lineLong: leg.routeLongName || undefined, operator: leg.agencyName || undefined, operatorUrl: leg.agencyUrl || undefined, color: hexColor(leg.routeColor), textColor: hexColor(leg.routeTextColor), headsign: leg.headsign || undefined, from: nom(leg.from), to: nom(leg.to), departure: leg.startTime, arrival: leg.endTime, // Un troncon dure au moins une minute : afficher « 0 min » n'aide personne. durationMin: Math.max(1, minutes), realTime: leg.realTime === true, geometry: legGeometry(leg), } } export function summarize(itinerary: MotisItinerary, mode: 'train' | 'bus') { let km = 0 let co2 = 0 const segments: Segment[] = [] const geometry: Coord[] = [] for (const leg of itinerary.legs) { const d = legKm(leg) km += d co2 += (LEG_FACTORS[leg.mode] ?? DEFAULT_LEG_FACTOR) * d const seg = toSegment(leg) if (seg) { segments.push(seg) if (seg.geometry) geometry.push(...seg.geometry) } } return { mode, distanceKm: Math.round(km * 10) / 10, durationMin: Math.round(itinerary.duration / 60), co2Grams: Math.round(co2), transfers: itinerary.transfers, segments, geometry: thin(geometry, 800), } } /** * Modes autorises pour chaque famille. * Ces listes sont volontairement courtes : mesure faite, ajouter SUBWAY et TRAM * a la famille routiere fait passer MOTIS de cinq itineraires a zero. Elargir * l'espace de recherche ne l'enrichit pas, il l'empeche d'aboutir. */ const TRANSIT_MODES = { rail: 'HIGHSPEED_RAIL,LONG_DISTANCE,NIGHT_RAIL,REGIONAL_RAIL,REGIONAL_FAST_RAIL,RAIL', urban: 'BUS,COACH', } as const /** * Une requete par famille. MOTIS optimise sur la duree : interroge sans * contrainte, il ne proposera jamais un car de 4 h la ou un TGV met 1 h 37. * C'est en restreignant les modes qu'on obtient une vraie alternative. */ async function plan( from: { lat: number, lon: number }, to: { lat: number, lon: number }, departure: Date, transitModes: string, ): Promise { const params = new URLSearchParams({ fromPlace: `${from.lat},${from.lon}`, toPlace: `${to.lat},${to.lon}`, time: departure.toISOString().replace(/\.\d{3}Z$/, 'Z'), transitModes, }) const data = await $fetch<{ itineraries?: MotisItinerary[] }>( // Les virgules encodees et l'absence d'identification valent un 403. `https://api.transitous.org/api/v1/plan?${params.toString().replace(/%2C/g, ',')}`, { headers: { 'User-Agent': 'lecheminvert.tech (adam@belkacemi.me)' } }, ) return data.itineraries ?? [] } /** * Le plus rapide des itineraires exploitables. * Les modes etant deja contraints cote requete, on ne rejette qu'un trajet * entierement pietonnier, que MOTIS renvoie parfois faute de mieux. */ function best(itineraries: MotisItinerary[]): MotisItinerary | undefined { return itineraries .filter(i => dominant(i) !== 'walk') .sort((a, b) => a.duration - b.duration)[0] } export async function publicTransport( from: { lat: number, lon: number }, to: { lat: number, lon: number }, departure: Date, ) { // Les deux familles sont interrogees en parallele : une panne d'un cote // ne doit pas priver l'utilisateur de l'autre. const [rail, urban] = await Promise.allSettled([ plan(from, to, departure, TRANSIT_MODES.rail), plan(from, to, departure, TRANSIT_MODES.urban), ]) const railIt = rail.status === 'fulfilled' ? best(rail.value) : undefined const urbanIt = urban.status === 'fulfilled' ? best(urban.value) : undefined return { train: railIt ? summarize(railIt, 'train') : null, bus: urbanIt ? summarize(urbanIt, 'bus') : null, } }