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

94
shared/booking.ts Normal file
View file

@ -0,0 +1,94 @@
import type { Place } from './types'
export interface BookingLink {
url: string
label: string
provider: string
}
/** Normalise un nom de ville pour l'inclure dans une URL slug. */
function slugify(label: string): string {
return label
.normalize('NFD').replace(/\p{Diacritic}/gu, '')
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
}
/** Extrait le nom de ville avant la virgule ("Lille, France" -> "Lille"). */
function cityName(label: string): string {
return label.split(',')[0]?.trim() ?? label.trim()
}
/** Format de date que Kombo et CheckMyBus acceptent tous les deux. */
function dateISO(d: Date): string {
return d.toISOString().slice(0, 10)
}
/** Kombo comprend les noms de ville en clair : pas de mapping de gares a maintenir. */
export function buildKomboLink(from: Place, to: Place, date: Date): BookingLink {
const f = encodeURIComponent(cityName(from.label))
const t = encodeURIComponent(cityName(to.label))
const d = dateISO(date)
return {
url: `https://www.kombo.co/fr/app/outward/1/1/18/${d}/${f}/${t}/0/0/results`,
label: 'Reserver sur Kombo',
provider: 'Kombo',
}
}
/**
* CheckMyBus accepte un fallback avec UUID zeros quand on ne connait pas
* son identifiant interne : le nom + les coordonnees suffisent a lancer
* la recherche. Le fragment porte tout, y compris la date.
*/
export function buildCheckMyBusLink(from: Place, to: Place, date: Date): BookingLink {
const zeroId = '00000000-0000-0000-0000-000000000000'
const encodeStop = (p: Place) =>
encodeURIComponent(`${cityName(p.label)}, France`)
+ `%24${p.lat}%2C${p.lon}`
+ `%24${zeroId}%24${zeroId}%24true%24false`
const fromSlug = slugify(cityName(from.label))
const toSlug = slugify(cityName(to.label))
const d = dateISO(date)
const fragment = [
`departureDate=${d}`,
`origin=${encodeStop(from)}`,
`destination=${encodeStop(to)}`,
'sortValue=Relevance',
'sortOrder=ascending',
'adults=1',
].join('&')
return {
url: `https://www.checkmybus.fr/${fromSlug}/${toSlug}?mode=search#${fragment}`,
label: 'Chercher sur CheckMyBus',
provider: 'CheckMyBus',
}
}
/** Chargemap : on ouvre juste la page d'accueil du planificateur. */
export function buildChargemapLink(): BookingLink {
return {
url: 'https://chargemap.com/map',
label: 'Ouvrir Chargemap',
provider: 'Chargemap',
}
}
/** Retourne le lien approprie pour le mode donne, ou null si aucun. */
export function buildBookingLink(
mode: 'train' | 'bus' | 'electric_car' | 'bike',
from: Place,
to: Place,
date: Date,
): BookingLink | null {
switch (mode) {
case 'train': return buildKomboLink(from, to, date)
case 'bus': return buildCheckMyBusLink(from, to, date)
case 'electric_car': return buildChargemapLink()
case 'bike': return null
}
}

38
shared/emissions.ts Normal file
View file

@ -0,0 +1,38 @@
/**
* Grammes de CO équivalent par passager et par kilomètre.
* Source : ADEME, Base Carbone.
*/
export const EMISSION_FACTORS = {
electric_car: 103,
bike: 0,
train: 2.4,
bus: 113,
} as const
export type TransportMode = keyof typeof EMISSION_FACTORS
/**
* Facteurs par mode Transitous. Chaque tronçon est compté avec son propre
* facteur : un TGV et un TER n'ont pas du tout la même empreinte.
*/
export const LEG_FACTORS: Record<string, number> = {
HIGHSPEED_RAIL: 2.4,
LONG_DISTANCE: 2.4,
NIGHT_RAIL: 2.4,
REGIONAL_RAIL: 24.8,
REGIONAL_FAST_RAIL: 24.8,
RAIL: 24.8,
SUBWAY: 4.1,
METRO: 4.1,
TRAM: 3.6,
BUS: 113,
COACH: 35.4,
WALK: 0,
BIKE: 0,
}
/** Mode inconnu : on prend le bus, le plus émetteur, pour ne pas sous-estimer. */
export const DEFAULT_LEG_FACTOR = 113
/** Référence de comparaison : la voiture thermique. */
export const PETROL_CAR = 218

89
shared/polyline.ts Normal file
View file

@ -0,0 +1,89 @@
import type { Coord } from './types'
/**
* Decode le format « encoded polyline » de Google, utilise par MOTIS.
* La precision n'est pas universelle : Google encode a 1e5, MOTIS a 1e7.
* Se tromper d'un facteur 100 place le trace a des milliers de kilometres.
*/
export function decodePolyline(encoded: string, precision = 5): Coord[] {
const factor = 10 ** precision
const coords: Coord[] = []
let index = 0
let lat = 0
let lon = 0
while (index < encoded.length) {
let result = 0
let shift = 0
let byte: number
do {
byte = encoded.charCodeAt(index++) - 63
result |= (byte & 0x1F) << shift
shift += 5
} while (byte >= 0x20)
lat += result & 1 ? ~(result >> 1) : result >> 1
result = 0
shift = 0
do {
byte = encoded.charCodeAt(index++) - 63
result |= (byte & 0x1F) << shift
shift += 5
} while (byte >= 0x20)
lon += result & 1 ? ~(result >> 1) : result >> 1
// Ordre GeoJSON : longitude d'abord, comme l'attend MapLibre.
coords.push([lon / factor, lat / factor])
}
return coords
}
/**
* Reduit le nombre de points d'un trace.
* Un aller Lille-Paris peut compter plusieurs milliers de points ; au-dela
* d'un millier, l'oeil ne voit plus la difference mais le reseau, si.
*/
export function thin(coords: Coord[], max = 800): Coord[] {
if (coords.length <= max) return coords
const step = Math.ceil(coords.length / max)
const out = coords.filter((_, i) => i % step === 0)
// Le dernier point est le terminus : il ne doit jamais sauter.
if (out[out.length - 1] !== coords[coords.length - 1]) out.push(coords[coords.length - 1]!)
return out
}
/**
* Renvoie le point situe a un ratio donne de la distance cumulee le long
* du trace. midpointAt(coords, 0.5) donne le vrai milieu geometrique, pas
* le point d'index milieu qui atterrit dans le vide sur un trajet inegal.
* Utilise Haversine simplifie en distance euclidienne : suffisant pour
* placer une bulle a l'echelle d'un trajet inter-villes.
*/
export function midpointAt(coords: Coord[], ratio = 0.5): Coord {
if (coords.length === 0) throw new Error('trace vide')
if (coords.length === 1) return coords[0]!
if (ratio <= 0) return coords[0]!
if (ratio >= 1) return coords[coords.length - 1]!
const distances: number[] = [0]
let total = 0
for (let i = 1; i < coords.length; i++) {
const [x1, y1] = coords[i - 1]!
const [x2, y2] = coords[i]!
total += Math.hypot(x2 - x1, y2 - y1)
distances.push(total)
}
const target = total * ratio
for (let i = 1; i < distances.length; i++) {
if (distances[i]! >= target) {
const [x1, y1] = coords[i - 1]!
const [x2, y2] = coords[i]!
const segLength = distances[i]! - distances[i - 1]!
const segRatio = segLength === 0 ? 0 : (target - distances[i - 1]!) / segLength
return [x1 + (x2 - x1) * segRatio, y1 + (y2 - y1) * segRatio]
}
}
return coords[coords.length - 1]!
}

36
shared/types.ts Normal file
View file

@ -0,0 +1,36 @@
/** Un lieu résolu en coordonnées, tel que renvoyé par le géocodeur. */
export interface Place {
label: string
lat: number
lon: number
}
/** Coordonnée au format GeoJSON : [longitude, latitude]. */
export type Coord = [number, number]
/** Un tronçon d'un trajet en transport en commun, ou un rabattement à pied. */
export interface Segment {
/** Mode Transitous brut : SUBWAY, HIGHSPEED_RAIL, BUS, WALK... */
mode: string
/** Nom court affiché sur le véhicule : « M1 », « TGV 7204 », « 12 ». */
line: string
/** Libellé long, quand il apporte quelque chose : « METRO LIGNE 1 ». */
lineLong?: string
/** Exploitant commercial : ILEVIA, SNCF, FlixBus. */
operator?: string
operatorUrl?: string
/** Couleur officielle de la ligne, telle que publiée dans le GTFS. */
color?: string
textColor?: string
/** Direction affichée par le véhicule. */
headsign?: string
from: string
to: string
departure: string
arrival: string
durationMin: number
/** true si l'horaire provient du temps réel et non du théorique. */
realTime: boolean
/** Tracé du tronçon, décodé et allégé. */
geometry?: Coord[]
}