Import initial : code en production sur Hetzner
Some checks are pending
CI / tests (push) Waiting to run
CI / deploiement (push) Blocked by required conditions

This commit is contained in:
Adam Belkacemi 2026-09-07 18:21:38 +00:00
commit cf6ee64e31
43 changed files with 14404 additions and 0 deletions

View file

@ -0,0 +1,97 @@
import { z } from 'zod'
import { EMISSION_FACTORS } from '#shared/emissions'
import { thin } from '#shared/polyline'
import { estimateTrainTrip } from '../utils/estimate'
import type { Coord } from '#shared/types'
const BodySchema = z.object({
from: z.object({ lat: z.number(), lon: z.number() }),
to: z.object({ lat: z.number(), lon: z.number() }),
})
interface OrsResponse {
features: Array<{
properties: { summary: { distance: number, duration: number } }
geometry: { coordinates: Coord[] }
}>
}
/** Interroge OpenRouteService pour un profil donne. */
async function route(profile: string, body: unknown, key: string) {
const data = await $fetch<OrsResponse>(
`https://api.openrouteservice.org/v2/directions/${profile}/geojson`,
{
method: 'POST',
headers: {
Authorization: key,
// ORS refuse de repondre si on n'annonce pas accepter ce format precis.
Accept: 'application/geo+json',
},
body,
},
)
const feature = data.features[0]
if (!feature) throw new Error('Itinéraire introuvable')
return {
km: feature.properties.summary.distance / 1000,
min: feature.properties.summary.duration / 60,
// ORS renvoie deja du GeoJSON [lon, lat] : rien a decoder, juste a alleger.
geometry: thin(feature.geometry.coordinates, 800),
}
}
const unavailable = (mode: string, reason: string) => ({ mode, unavailable: true, reason })
export default defineEventHandler(async (event) => {
const parsed = BodySchema.safeParse(await readBody(event))
if (!parsed.success) {
throw createError({ statusCode: 400, statusMessage: 'Départ ou arrivée invalide.' })
}
const { from, to } = parsed.data
const { orsApiKey } = useRuntimeConfig(event)
const body = { coordinates: [[from.lon, from.lat], [to.lon, to.lat]] }
// allSettled et non all : un fournisseur en panne ne doit pas vider la page.
const [car, bike, pt] = await Promise.allSettled([
route('driving-car', body, orsApiKey),
route('cycling-regular', body, orsApiKey),
publicTransport(from, to, new Date()),
])
const options = []
options.push(car.status === 'fulfilled'
? {
mode: 'electric_car',
distanceKm: Math.round(car.value.km * 10) / 10,
durationMin: Math.round(car.value.min),
co2Grams: Math.round(car.value.km * EMISSION_FACTORS.electric_car),
geometry: car.value.geometry,
}
: unavailable('electric_car', 'Aucune route carrossable trouvée.'))
options.push(bike.status === 'fulfilled'
? {
mode: 'bike',
distanceKm: Math.round(bike.value.km * 10) / 10,
durationMin: Math.round(bike.value.min),
co2Grams: 0,
geometry: bike.value.geometry,
}
: unavailable('bike', 'Aucun itinéraire cyclable trouvé.'))
if (pt.status === 'fulfilled') {
options.push(pt.value.train ?? estimateTrainTrip(from, to))
options.push(pt.value.bus ?? unavailable('bus', 'Pas de ligne régulière.'))
}
else {
options.push(unavailable('train', 'Horaires indisponibles.'))
options.push(unavailable('bus', 'Horaires indisponibles.'))
}
return options.sort((a, b) =>
('co2Grams' in a ? a.co2Grams : Infinity) - ('co2Grams' in b ? b.co2Grams : Infinity))
})

10
server/api/geocode.get.ts Normal file
View file

@ -0,0 +1,10 @@
import { z } from 'zod'
import type { Place } from '#shared/types'
const QuerySchema = z.object({ q: z.string().min(3).max(200) })
export default defineEventHandler(async (event): Promise<Place[]> => {
const parsed = QuerySchema.safeParse(getQuery(event))
if (!parsed.success) return []
return searchPlaces(parsed.data.q)
})

13
server/api/health.get.ts Normal file
View file

@ -0,0 +1,13 @@
/**
* Cible du health check de l'ALB.
* Volontairement sans dependance externe : si OpenRouteService tombe,
* la tache reste saine et continue de servir ce qu'elle peut.
*/
export default defineEventHandler((event) => {
setHeader(event, 'Cache-Control', 'no-store')
return {
status: 'ok',
uptimeSeconds: Math.round(process.uptime()),
version: process.env.APP_VERSION ?? 'dev',
}
})

48
server/utils/estimate.ts Normal file
View 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
View 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
View 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,
}
}