Import initial : code en production sur Hetzner
This commit is contained in:
commit
cf6ee64e31
43 changed files with 14404 additions and 0 deletions
219
server/utils/transitous.ts
Normal file
219
server/utils/transitous.ts
Normal file
|
|
@ -0,0 +1,219 @@
|
|||
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,
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue