lecheminvert.tech/app/app.vue
Adam Belkacemi cf6ee64e31
Some checks are pending
CI / tests (push) Waiting to run
CI / deploiement (push) Blocked by required conditions
Import initial : code en production sur Hetzner
2026-09-07 18:21:38 +00:00

476 lines
17 KiB
Vue
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<script setup lang="ts">
import type { Coord, Place, Segment } from '#shared/types'
import { buildBookingLink } from '#shared/booking'
interface Option {
mode: 'electric_car' | 'bike' | 'train' | 'bus'
distanceKm?: number
durationMin?: number
co2Grams?: number
transfers?: number
segments?: Segment[]
geometry?: Coord[]
unavailable?: boolean
reason?: string
/** true si l estimation a ete generee par nos soins (Transitous ne couvrait pas). */
estimated?: boolean
}
const from = ref<Place | null>(null)
const to = ref<Place | null>(null)
const canCompare = computed(() => Boolean(from.value && to.value))
const options = ref<Option[] | null>(null)
const pending = ref(false)
const error = ref<string | null>(null)
const searched = ref<{ from: Place, to: Place } | null>(null)
const selected = ref<Option['mode'] | null>(null)
/** Formulaire de saisie visible ou non. Se referme apres un compare reussi
* sur mobile pour liberer de l espace ; toujours ouvert en desktop via CSS. */
const formOpen = ref(true)
const EXAMPLES: Array<{ label: string, from: Place, to: Place }> = [
{
label: 'Lille → Paris',
from: { label: 'Lille, France', lat: 50.6292, lon: 3.0573 },
to: { label: 'Paris, France', lat: 48.8566, lon: 2.3522 },
},
{
label: 'Grenoble → Marseille',
from: { label: 'Grenoble, France', lat: 45.1885, lon: 5.7245 },
to: { label: 'Marseille, France', lat: 43.2965, lon: 5.3698 },
},
{
label: 'Bordeaux → Toulouse',
from: { label: 'Bordeaux, France', lat: 44.8378, lon: -0.5792 },
to: { label: 'Toulouse, France', lat: 43.6047, lon: 1.4442 },
},
]
const LABELS: Record<Option['mode'], string> = {
electric_car: 'Voiture électrique',
bike: 'Vélo',
train: 'Train',
bus: 'Bus & car',
}
const SHORT: Record<Option['mode'], string> = {
electric_car: 'Voiture',
bike: 'Vélo',
train: 'Train',
bus: 'Bus',
}
const COLORS: Record<Option['mode'], string> = {
electric_car: '#6b3fa0',
bike: '#0e7c5a',
train: '#1f4fa8',
bus: '#b8621b',
}
const SEGMENT_LABELS: Record<string, string> = {
HIGHSPEED_RAIL: 'TGV',
LONG_DISTANCE: 'Train',
NIGHT_RAIL: 'Train de nuit',
REGIONAL_RAIL: 'TER',
REGIONAL_FAST_RAIL: 'TER',
RAIL: 'Train',
SUBWAY: 'Métro',
METRO: 'Métro',
TRAM: 'Tram',
BUS: 'Bus',
COACH: 'Car',
TROLLEYBUS: 'Trolleybus',
WALK: 'Marche',
}
const maxCo2 = computed(() => Math.max(1, ...(options.value ?? []).map(o => o.co2Grams ?? 0)))
const traces = computed(() =>
(options.value ?? [])
.filter(o => o.geometry?.length)
.map(o => ({ mode: o.mode, color: COLORS[o.mode], geometry: o.geometry! })),
)
/** Metadonnees affichees dans les bulles ancrees sur chaque trace. */
const metas = computed(() =>
(options.value ?? [])
.filter(o => o.geometry?.length && !o.unavailable && o.durationMin !== undefined && o.co2Grams !== undefined)
.map(o => ({
mode: o.mode,
color: COLORS[o.mode],
labelTop: `${duration(o.durationMin!)} · ${co2(o.co2Grams!)}`,
})),
)
const detail = computed(() =>
(options.value ?? []).find(o => o.mode === selected.value) ?? null,
)
const bookingLink = computed(() => {
if (!detail.value || !searched.value) return null
return buildBookingLink(detail.value.mode, searched.value.from, searched.value.to, new Date())
})
const saving = computed(() => {
const usable = (options.value ?? []).filter(o => !o.unavailable && o.co2Grams !== undefined)
if (usable.length < 2) return null
const min = Math.min(...usable.map(o => o.co2Grams!))
const max = Math.max(...usable.map(o => o.co2Grams!))
if (max - min < 500) return null
return { kg: (max - min) / 1000, ratio: min > 0 ? Math.round(max / min) : null }
})
function barWidth(o: Option) {
if (o.unavailable || o.co2Grams === undefined) return 0
return Math.max(1.5, (o.co2Grams / maxCo2.value) * 100)
}
const duration = (min: number) => {
const h = Math.floor(min / 60)
return h > 0 ? `${h} h ${String(min % 60).padStart(2, '0')}` : `${min} min`
}
const co2 = (g: number) => (g >= 1000 ? `${(g / 1000).toFixed(1)} kg` : `${g} g`)
const heure = (iso: string) =>
new Date(iso).toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' })
function swap() {
const tmp = from.value
from.value = to.value
to.value = tmp
}
async function compare() {
if (!canCompare.value) return
pending.value = true
error.value = null
selected.value = null
try {
options.value = await $fetch<Option[]>('/api/compare', {
method: 'POST',
body: { from: from.value, to: to.value },
})
searched.value = { from: from.value!, to: to.value! }
formOpen.value = false
}
catch {
error.value = 'Impossible de calculer ce trajet.'
}
finally {
pending.value = false
}
}
async function runExample(e: typeof EXAMPLES[number]) {
from.value = e.from
to.value = e.to
await nextTick()
await compare()
}
function reset() {
options.value = null
searched.value = null
selected.value = null
formOpen.value = true
}
</script>
<template>
<div class="flex h-dvh flex-col bg-paper text-ink md:flex-row">
<div
class="shrink-0 border-b-2 border-ink md:h-auto md:flex-1 md:border-b-0 md:border-l-2"
:class="options ? 'h-[30dvh]' : 'hidden md:block'"
>
<ClientOnly>
<RouteMap
:traces="traces"
:metas="metas"
:from="searched ? [searched.from.lon, searched.from.lat] : null"
:to="searched ? [searched.to.lon, searched.to.lat] : null"
:highlight="selected"
/>
</ClientOnly>
</div>
<aside class="flex min-h-0 flex-1 flex-col md:order-first md:h-full md:w-[380px] md:flex-none lg:w-[440px]">
<header class="shrink-0 border-b border-rule bg-paper-2">
<div v-if="!options" class="px-4 pb-3 pt-5">
<h1>
<img src="/logo.png" alt="Le Chemin Vert" class="mx-auto w-48 max-w-full">
</h1>
<p class="mt-1 text-center font-semibold">
Quatre chemins, un seul demain.
</p>
</div>
<div v-else class="flex items-center gap-2 px-4 py-2">
<img src="/logo.png" alt="Le Chemin Vert" class="h-8 w-8 shrink-0 object-contain">
<span class="text-sm font-semibold">Le Chemin Vert</span>
<button type="button" class="ml-auto text-xs underline underline-offset-4" @click="reset">
Nouveau trajet
</button>
</div>
<div class="space-y-2 px-4 pb-3" :class="{ 'hidden md:block': !formOpen && options }">
<AddressField id="from" v-model="from" label="Départ" />
<AddressField id="to" v-model="to" label="Arrivée" />
<div class="flex flex-wrap items-center gap-3 pt-1">
<button
type="button"
class="bg-ink px-4 py-2 text-paper disabled:cursor-not-allowed disabled:opacity-40"
:disabled="!canCompare || pending"
@click="compare"
>
{{ pending ? 'Calcul…' : 'Comparer' }}
</button>
<button
type="button"
class="text-sm underline underline-offset-4 disabled:opacity-40"
:disabled="!from && !to"
@click="swap"
>
Inverser
</button>
<span v-if="!canCompare" class="text-xs text-ink-soft">
Choisissez dans la liste.
</span>
</div>
</div>
</header>
<button
v-if="!formOpen && options && searched"
type="button"
class="flex w-full items-center gap-2 border-b border-rule bg-paper-2 px-4 py-3 text-left text-sm md:hidden"
@click="formOpen = true"
>
<span class="min-w-0 flex-1 truncate">
<span class="text-ink-soft">Trajet :</span>
<span class="font-medium">{{ searched.from.label }} {{ searched.to.label }}</span>
</span>
<span aria-hidden="true" class="shrink-0 text-ink-soft">Modifier </span>
</button>
<p v-if="error" role="alert" class="m-4 border-l-4 border-ink bg-paper-2 p-3 text-sm">
{{ error }}
</p>
<div v-else-if="!options && !pending" class="min-h-0 flex-1 overflow-y-auto px-4 py-3">
<p class="text-xs uppercase tracking-widest text-ink-soft">
Essayez un trajet
</p>
<ul class="mt-2 space-y-2">
<li v-for="e in EXAMPLES" :key="e.label">
<button
type="button"
class="w-full border border-rule px-3 py-2 text-left text-sm transition-colors hover:border-ink hover:bg-paper-2"
@click="runExample(e)"
>
{{ e.label }}
</button>
</li>
</ul>
<p class="mt-4 text-xs leading-relaxed text-ink-soft">
Chaque mode est calculé avec les données réelles des exploitants,
et son empreinte avec les facteurs d'émission de l'ADEME.
</p>
</div>
<p v-else-if="pending" class="p-4 text-sm text-ink-soft">
Interrogation des réseaux
</p>
<div
v-else-if="options && searched && !selected"
class="scroll-soft min-h-0 flex-1 overflow-y-auto px-4 py-3"
>
<h2 class="text-xs text-ink-soft">
{{ searched.from.label }} {{ searched.to.label }}
</h2>
<p v-if="saving" class="mt-2 border-l-4 border-bike bg-paper-2 p-2 text-xs leading-relaxed">
Jusqu'à <strong class="tnum">{{ saving.kg.toFixed(1) }} kg</strong> de CO₂e
d'écart entre le mode le plus sobre et le plus émetteur<span v-if="saving.ratio">,
soit un facteur {{ saving.ratio }}</span>.
</p>
<ul class="mt-3 border border-rule">
<li v-for="o in options" :key="o.mode" class="border-b border-rule last:border-b-0">
<h3>
<button
type="button"
class="relative flex w-full items-center gap-3 px-3 py-3 text-left transition-colors hover:bg-paper-2 disabled:cursor-not-allowed disabled:hover:bg-transparent"
:disabled="o.unavailable"
@click="selected = o.mode"
>
<span
class="absolute inset-y-0 left-0 transition-all duration-500"
:style="{ width: `${barWidth(o)}%`, background: COLORS[o.mode], opacity: 0.16 }"
aria-hidden="true"
/>
<span
class="relative h-4 w-4 shrink-0"
:style="{ background: COLORS[o.mode] }"
aria-hidden="true"
/>
<span class="relative min-w-0 flex-1">
<span class="block truncate font-semibold">{{ LABELS[o.mode] }}</span>
<span v-if="o.unavailable" class="block truncate text-xs font-normal text-ink-soft">
{{ o.reason }}
</span>
<span v-else class="block text-xs font-normal text-ink-soft">
{{ o.distanceKm }} km<span v-if="o.transfers"> · {{ o.transfers }} corresp.</span>
</span>
</span>
<span v-if="!o.unavailable" class="tnum relative shrink-0 text-right">
<span class="block">{{ duration(o.durationMin!) }}</span>
<span class="block text-xs font-normal text-ink-soft">{{ co2(o.co2Grams!) }} CO₂e</span>
</span>
<span
v-if="!o.unavailable"
class="relative shrink-0 text-3xl font-light leading-none text-ink-soft"
aria-hidden="true"
></span>
</button>
</h3>
</li>
</ul>
<p class="mt-3 text-center text-xs text-ink-soft">
Touchez un mode pour voir son trajet détaillé
</p>
</div>
<template v-else-if="options && searched && detail">
<div class="shrink-0 border-b border-rule px-3 py-2">
<button
type="button"
class="flex items-center gap-1 text-sm underline underline-offset-4"
@click="selected = null"
>
<span class="text-2xl font-light leading-none" aria-hidden="true"></span>
Comparer les quatre modes
</button>
<ul class="mt-2 flex gap-1">
<li v-for="o in options" :key="o.mode" class="min-w-0 flex-1">
<button
type="button"
class="w-full border-t-4 px-1 py-1.5 text-center transition-colors disabled:cursor-not-allowed disabled:opacity-40"
:style="{ borderColor: COLORS[o.mode] }"
:class="selected === o.mode ? 'bg-paper-2 font-semibold' : 'hover:bg-paper-2'"
:disabled="o.unavailable"
:aria-current="selected === o.mode ? 'true' : undefined"
@click="selected = o.mode"
>
<span class="block truncate text-xs">{{ SHORT[o.mode] }}</span>
<span v-if="!o.unavailable" class="tnum block text-[11px] text-ink-soft">
{{ duration(o.durationMin!) }}
</span>
<span v-else class="block text-[11px] text-ink-soft">—</span>
</button>
</li>
</ul>
</div>
<div class="scroll-soft min-h-0 flex-1 overflow-y-auto px-4 py-3">
<h2 class="text-lg font-semibold">{{ LABELS[detail.mode] }}</h2>
<p class="tnum mt-0.5 text-sm text-ink-soft">
{{ duration(detail.durationMin!) }} · {{ detail.distanceKm }} km ·
{{ co2(detail.co2Grams!) }} CO₂e
</p>
<aside
v-if="detail.estimated"
class="mt-3 border-l-4 border-ink bg-paper-2 p-3 text-xs leading-relaxed"
role="note"
>
<strong>Estimation indicative.</strong>
Notre référentiel ferroviaire ouvert (Transitous) ne couvre pas ce trajet.
Ordre de grandeur calculé à partir de la distance à vol d'oiseau et d'une
vitesse pratique de 90 km/h. Les horaires et le prix réels sont à vérifier
chez le transporteur.
</aside>
<a
v-if="bookingLink"
:href="bookingLink!.url"
target="_blank"
rel="noopener noreferrer"
class="mt-3 inline-block bg-ink px-4 py-2 text-sm text-paper hover:opacity-90"
:aria-label="`${bookingLink!.label} pour ${LABELS[detail.mode]} de ${searched.from.label} à ${searched.to.label}`"
>
{{ bookingLink!.label }} →
</a>
<p v-if="bookingLink" class="mt-2 text-[11px] text-ink-soft">
Vous serez redirigé vers {{ bookingLink!.provider }}.
</p>
<ol v-if="detail.segments?.length" class="mt-4 space-y-3">
<li
v-for="(s, i) in detail.segments"
:key="i"
class="flex gap-3 border-l-4 pl-3"
:style="{ borderColor: s.color || 'var(--color-rule)' }"
>
<div class="min-w-0 flex-1">
<div class="flex flex-wrap items-baseline gap-1.5">
<span
v-if="s.mode !== 'WALK'"
class="px-1.5 py-0.5 text-xs font-semibold"
:style="{
background: s.color || 'var(--color-ink)',
color: s.textColor || 'var(--color-paper)',
}"
>{{ s.line }}</span>
<span class="text-sm font-medium">
{{ SEGMENT_LABELS[s.mode] ?? s.mode }}
</span>
<span v-if="s.operator" class="text-xs text-ink-soft">
{{ s.operator }}
</span>
</div>
<p v-if="s.from || s.to" class="mt-0.5 text-sm text-ink-soft">
{{ s.from }}<span v-if="s.from && s.to"> → </span>{{ s.to }}
</p>
<p v-if="s.headsign" class="text-xs text-ink-soft">
direction {{ s.headsign }}
</p>
</div>
<div class="tnum shrink-0 text-right text-xs text-ink-soft">
<div>{{ heure(s.departure) }}</div>
<div>{{ s.durationMin }} min</div>
</div>
</li>
</ol>
<p v-else class="mt-4 text-sm text-ink-soft">
Trajet direct, sans correspondance. Son tracé est isolé sur la carte.
</p>
<footer class="mt-6 border-t border-rule pt-3 text-[11px] leading-relaxed text-ink-soft">
OpenRouteService, Transitous et ADEME Base Carbone. Les données GTFS
ouvertes peuvent comporter des imprécisions de classification.
</footer>
</div>
</template>
</aside>
</div>
</template>