219 lines
7 KiB
TypeScript
219 lines
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,
|
||
|
|
}
|
||
|
|
}
|