lecheminvert.tech/server/utils/transitous.ts
Adam Belkacemi cf6ee64e31
Some checks are pending
CI / tests (push) Waiting to run
CI / deploiement (push) Blocked by required conditions
Import initial : code en production sur Hetzner
2026-09-07 18:21:38 +00:00

219 lines
No EOL
7 KiB
TypeScript

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<Family, number> = { 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<MotisItinerary[]> {
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,
}
}