Import initial : code en production sur Hetzner
This commit is contained in:
commit
cf6ee64e31
43 changed files with 14404 additions and 0 deletions
48
server/utils/estimate.ts
Normal file
48
server/utils/estimate.ts
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
import { EMISSION_FACTORS } from '#shared/emissions'
|
||||
import type { Coord } from '#shared/types'
|
||||
|
||||
const TRAIN_AVG_KMH = 90
|
||||
|
||||
/**
|
||||
* Distance a vol d oiseau entre deux points geographiques en km.
|
||||
* Formule de Haversine, rayon terrestre moyen 6371 km.
|
||||
*/
|
||||
function haversineKm(a: { lat: number, lon: number }, b: { lat: number, lon: number }): number {
|
||||
const toRad = (d: number) => (d * Math.PI) / 180
|
||||
const R = 6371
|
||||
const dLat = toRad(b.lat - a.lat)
|
||||
const dLon = toRad(b.lon - a.lon)
|
||||
const s = Math.sin(dLat / 2) ** 2
|
||||
+ Math.cos(toRad(a.lat)) * Math.cos(toRad(b.lat)) * Math.sin(dLon / 2) ** 2
|
||||
return 2 * R * Math.asin(Math.sqrt(s))
|
||||
}
|
||||
|
||||
/**
|
||||
* Fabrique une estimation train quand Transitous ne couvre pas le trajet.
|
||||
* Distance vol d oiseau x 1.2 pour approximer le detour ferroviaire,
|
||||
* vitesse pratique moyenne 90 km/h (compromis TGV/TER), facteur ADEME train
|
||||
* generique. C est un ordre de grandeur, pas un horaire.
|
||||
*/
|
||||
export function estimateTrainTrip(
|
||||
from: { lat: number, lon: number },
|
||||
to: { lat: number, lon: number },
|
||||
) {
|
||||
const straightKm = haversineKm(from, to)
|
||||
// Facteur 1.2 : les voies ferrees suivent rarement une ligne droite.
|
||||
const distanceKm = Math.round(straightKm * 1.2 * 10) / 10
|
||||
const durationMin = Math.round((distanceKm / TRAIN_AVG_KMH) * 60)
|
||||
const co2Grams = Math.round(distanceKm * EMISSION_FACTORS.train)
|
||||
|
||||
// Geometrie ligne droite [depart, arrivee] pour que la carte affiche
|
||||
// quelque chose de coherent avec l estimation textuelle.
|
||||
const geometry: Coord[] = [[from.lon, from.lat], [to.lon, to.lat]]
|
||||
|
||||
return {
|
||||
mode: 'train' as const,
|
||||
distanceKm,
|
||||
durationMin,
|
||||
co2Grams,
|
||||
geometry,
|
||||
estimated: true,
|
||||
}
|
||||
}
|
||||
82
server/utils/geocoding.ts
Normal file
82
server/utils/geocoding.ts
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
import type { Place } from '#shared/types'
|
||||
|
||||
const UA = 'lecheminvert.tech (adam@belkacemi.me)'
|
||||
|
||||
interface BanResponse {
|
||||
features: Array<{
|
||||
geometry: { coordinates: [number, number] }
|
||||
properties: { label: string }
|
||||
}>
|
||||
}
|
||||
|
||||
interface PhotonResponse {
|
||||
features: Array<{
|
||||
geometry: { coordinates: [number, number] }
|
||||
properties: {
|
||||
name?: string
|
||||
city?: string
|
||||
country?: string
|
||||
street?: string
|
||||
housenumber?: string
|
||||
}
|
||||
}>
|
||||
}
|
||||
|
||||
/** La BAN est excellente en France, et aveugle partout ailleurs. */
|
||||
async function fromBan(q: string): Promise<Place[]> {
|
||||
const url = `https://api-adresse.data.gouv.fr/search/?q=${encodeURIComponent(q)}&limit=5&autocomplete=1`
|
||||
const data = await $fetch<BanResponse>(url, { headers: { 'User-Agent': UA } })
|
||||
return data.features.map(f => ({
|
||||
label: f.properties.label,
|
||||
lon: f.geometry.coordinates[0],
|
||||
lat: f.geometry.coordinates[1],
|
||||
}))
|
||||
}
|
||||
|
||||
/** Photon couvre le monde entier via OpenStreetMap, sans clé. */
|
||||
async function fromPhoton(q: string): Promise<Place[]> {
|
||||
const url = `https://photon.komoot.io/api/?q=${encodeURIComponent(q)}&limit=5&lang=fr`
|
||||
const data = await $fetch<PhotonResponse>(url, { headers: { 'User-Agent': UA } })
|
||||
return data.features.map((f) => {
|
||||
const p = f.properties
|
||||
const rue = [p.housenumber, p.street].filter(Boolean).join(' ')
|
||||
const parts = [rue || p.name, p.city !== p.name ? p.city : undefined, p.country]
|
||||
return {
|
||||
label: parts.filter(Boolean).join(', '),
|
||||
lon: f.geometry.coordinates[0],
|
||||
lat: f.geometry.coordinates[1],
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/** Deux entrees sont identiques si elles partagent le nom ou la position. */
|
||||
export function dedupe(places: Place[]): Place[] {
|
||||
const seenLabel = new Set<string>()
|
||||
const seenCoord = new Set<string>()
|
||||
return places.filter((p) => {
|
||||
const label = p.label.toLowerCase().trim()
|
||||
const coord = `${p.lat.toFixed(2)},${p.lon.toFixed(2)}`
|
||||
if (seenLabel.has(label) || seenCoord.has(coord)) return false
|
||||
seenLabel.add(label)
|
||||
seenCoord.add(coord)
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Les deux sources sont entrelacees plutot que concatenees : sans cela,
|
||||
* les cinq resultats francais de la BAN enterrent systematiquement la
|
||||
* vraie ville etrangere trouvee par Photon.
|
||||
*/
|
||||
export async function searchPlaces(q: string): Promise<Place[]> {
|
||||
const [ban, photon] = await Promise.allSettled([fromBan(q), fromPhoton(q)])
|
||||
const a = ban.status === 'fulfilled' ? ban.value : []
|
||||
const b = photon.status === 'fulfilled' ? photon.value : []
|
||||
|
||||
const merged: Place[] = []
|
||||
for (let i = 0; i < Math.max(a.length, b.length); i++) {
|
||||
if (a[i]) merged.push(a[i]!)
|
||||
if (b[i]) merged.push(b[i]!)
|
||||
}
|
||||
return dedupe(merged).slice(0, 8)
|
||||
}
|
||||
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