477 lines
17 KiB
Vue
477 lines
17 KiB
Vue
|
|
<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>
|