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

476
app/app.vue Normal file
View file

@ -0,0 +1,476 @@
<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>

31
app/assets/css/main.css Normal file
View file

@ -0,0 +1,31 @@
@import "tailwindcss";
@theme {
--color-paper: #f2f4ef;
--color-paper-2: #e5e9df;
--color-rule: #c9d1c4;
--color-ink: #10231c;
--color-ink-soft: #4a5a52;
--color-car: #6b3fa0;
--color-bus: #b8621b;
--color-train: #1f4fa8;
--color-bike: #0e7c5a;
}
/* La carte occupe le fond : sans hauteur explicite, elle ne s'affiche pas. */
html, body, #__nuxt {
height: 100%;
}
.tnum {
font-variant-numeric: tabular-nums;
}
/* Barre de defilement discrete dans le panneau lateral. */
.scroll-soft::-webkit-scrollbar {
width: 8px;
}
.scroll-soft::-webkit-scrollbar-thumb {
background: var(--color-rule);
border-radius: 4px;
}

View file

@ -0,0 +1,133 @@
<script setup lang="ts">
import type { Place } from '#shared/types'
defineProps<{ label: string, id: string }>()
const model = defineModel<Place | null>({ default: null })
const query = ref('')
const results = ref<Place[]>([])
const open = ref(false)
const active = ref(-1)
let timer: ReturnType<typeof setTimeout> | undefined
/** Empeche une ecriture programmatique de relancer une recherche. */
let skipNext = false
/**
* Le champ doit refleter le modele meme quand il change de l'exterieur :
* inversion depart/arrivee, trajet d'exemple, restauration d'etat.
* Sans cela le texte affiche ment sur ce qui est reellement selectionne.
*/
watch(model, (m) => {
const label = m?.label ?? ''
if (label === query.value) return
skipNext = true
query.value = label
}, { immediate: true })
watch(query, (q) => {
if (skipNext) {
skipNext = false
return
}
// Toute frappe invalide la selection : on ne calcule jamais un trajet
// vers un lieu que l'utilisateur n'a pas explicitement confirme.
if (model.value && q !== model.value.label) model.value = null
clearTimeout(timer)
if (q.trim().length < 3) {
results.value = []
open.value = false
return
}
timer = setTimeout(async () => {
results.value = await $fetch<Place[]>('/api/geocode', { query: { q } })
open.value = results.value.length > 0
active.value = -1
}, 250)
})
function select(place: Place) {
model.value = place
skipNext = place.label !== query.value
query.value = place.label
open.value = false
active.value = -1
}
function onKeydown(e: KeyboardEvent) {
if (!open.value) return
if (e.key === 'ArrowDown') {
e.preventDefault()
active.value = (active.value + 1) % results.value.length
}
else if (e.key === 'ArrowUp') {
e.preventDefault()
active.value = active.value <= 0 ? results.value.length - 1 : active.value - 1
}
else if (e.key === 'Enter' && active.value >= 0) {
e.preventDefault()
select(results.value[active.value]!)
}
else if (e.key === 'Escape') {
open.value = false
}
}
/** Le delai laisse le mousedown de la liste s'executer avant la fermeture. */
function onBlur() {
setTimeout(() => { open.value = false }, 150)
}
</script>
<template>
<div class="relative">
<label :for="id" class="text-xs font-medium uppercase tracking-widest text-ink-soft">
{{ label }}
</label>
<input
:id="id"
v-model="query"
type="text"
role="combobox"
autocomplete="off"
:aria-expanded="open"
:aria-controls="`${id}-list`"
:aria-activedescendant="active >= 0 ? `${id}-opt-${active}` : undefined"
placeholder="Ville, gare ou adresse"
class="mt-1 w-full border-b-2 bg-transparent py-2 text-lg outline-none"
:class="model ? 'border-bike' : 'border-ink'"
@keydown="onKeydown"
@focus="open = results.length > 0"
@blur="onBlur"
>
<ul
v-if="open"
:id="`${id}-list`"
role="listbox"
class="absolute z-20 mt-1 w-full border-2 border-ink bg-paper shadow-[4px_4px_0_0_var(--color-ink)]"
>
<li
v-for="(place, i) in results"
:id="`${id}-opt-${i}`"
:key="place.label"
role="option"
:aria-selected="i === active"
class="cursor-pointer px-3 py-2 text-sm"
:class="i === active ? 'bg-ink text-paper' : 'hover:bg-paper-2'"
@mousedown.prevent="select(place)"
>
{{ place.label }}
</li>
</ul>
<p class="sr-only" aria-live="polite">
{{ open ? `${results.length} lieux proposés` : '' }}
</p>
</div>
</template>

260
app/components/RouteMap.vue Normal file
View file

@ -0,0 +1,260 @@
<script setup lang="ts">
import { LngLatBounds, Map as MapLibreMap, Marker, NavigationControl, Popup, setWorkerUrl } from 'maplibre-gl'
import 'maplibre-gl/dist/maplibre-gl.css'
import type { Coord } from '#shared/types'
import { midpointAt } from '#shared/polyline'
setWorkerUrl('/maplibre-gl-worker.mjs')
interface Trace {
mode: string
color: string
geometry: Coord[]
}
interface TraceMeta {
mode: string
color: string
labelTop: string
}
const props = withDefaults(defineProps<{
traces?: Trace[]
metas?: TraceMeta[]
from?: Coord | null
to?: Coord | null
highlight?: string | null
}>(), {
traces: () => [],
metas: () => [],
from: null,
to: null,
highlight: null,
})
const container = ref<HTMLDivElement | null>(null)
let map: MapLibreMap | null = null
let markers: Marker[] = []
let bubbles: Map<string, Marker> = new Map()
let drawn: string[] = []
/**
* Fond raster de l'IGN Geoplateforme : service public, gratuit, sans clef.
* Le raster n'a pas besoin de Web Worker, contrairement aux tuiles vectorielles
* que le contexte de production empechait silencieusement de decoder.
*/
const STYLE = {
version: 8 as const,
sources: {
ign: {
type: 'raster' as const,
tiles: [
'https://data.geopf.fr/wmts?SERVICE=WMTS&VERSION=1.0.0&REQUEST=GetTile'
+ '&LAYER=GEOGRAPHICALGRIDSYSTEMS.PLANIGNV2&STYLE=normal&FORMAT=image/png'
+ '&TILEMATRIXSET=PM&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}',
],
tileSize: 256,
maxzoom: 19,
attribution: '© <a href="https://www.ign.fr/">IGN</a>',
},
},
layers: [{ id: 'ign', type: 'raster' as const, source: 'ign' }],
}
const FRANCE: Coord = [2.5, 46.6]
function clear() {
if (!map) return
for (const m of markers) m.remove()
markers = []
for (const b of bubbles.values()) b.remove()
bubbles.clear()
// On retire ce qui a reellement ete dessine, pas ce que les props contiennent
// maintenant : les deux listes different des que le trajet change.
for (const id of drawn) {
if (map.getLayer(id)) map.removeLayer(id)
if (map.getSource(id)) map.removeSource(id)
}
drawn = []
}
function draw() {
if (!map || !props.traces.length) return
const b = new LngLatBounds()
for (const t of props.traces) {
if (t.geometry.length < 2) continue
const id = `trace-${t.mode}`
if (map.getLayer(id)) map.removeLayer(id)
if (map.getSource(id)) map.removeSource(id)
map.addSource(id, {
type: 'geojson',
data: {
type: 'Feature',
properties: {},
geometry: { type: 'LineString', coordinates: t.geometry },
},
})
map.addLayer({
id,
type: 'line',
source: id,
layout: { 'line-join': 'round', 'line-cap': 'round' },
paint: { 'line-color': t.color, 'line-width': 4, 'line-opacity': 0.85 },
})
drawn.push(id)
for (const c of t.geometry) b.extend(c)
}
for (const [coord, label] of [[props.from, 'Départ'], [props.to, 'Arrivée']] as const) {
if (!coord) continue
markers.push(
new Marker({ color: '#10231c' })
.setLngLat(coord)
.setPopup(new Popup({ offset: 24 }).setText(label))
.addTo(map),
)
b.extend(coord)
}
if (!b.isEmpty()) map.fitBounds(b, { padding: 48, duration: 600 })
drawBubbles()
applyHighlight()
}
function drawBubbles() {
if (!map) return
// Offset vertical par mode pour que les 4 bulles ne se superposent pas
// quand les traces sont quasi confondues (typique des trajets moyennes distances).
const offsets: Record<string, number> = {
train: -46,
bike: -16,
bus: 14,
electric_car: 44,
}
for (const t of props.traces) {
if (t.geometry.length < 2) continue
const meta = props.metas.find(m => m.mode === t.mode)
if (!meta) continue
const el = document.createElement('div')
el.className = 'lcv-bubble'
el.style.borderLeftColor = t.color
el.innerHTML = `<span class="lcv-bubble-dot" style="background:${t.color}"></span><span>${meta.labelTop}</span>`
const bubble = new Marker({
element: el,
offset: [0, offsets[t.mode] ?? 0],
})
.setLngLat(midpointAt(t.geometry, 0.5))
.addTo(map)
bubbles.set(t.mode, bubble)
}
}
function applyHighlight() {
if (!map) return
for (const t of props.traces) {
const id = `trace-${t.mode}`
if (map.getLayer(id)) {
const actif = !props.highlight || props.highlight === t.mode
map.setPaintProperty(id, 'line-opacity', actif ? 0.9 : 0.15)
map.setPaintProperty(id, 'line-width', props.highlight === t.mode ? 6 : 4)
}
const bubble = bubbles.get(t.mode)
if (bubble) {
const el = bubble.getElement()
// Sur mobile, on n affiche qu une seule bulle max : celle du mode
// selectionne, sinon aucune. Les 4 bulles superposees etaient illisibles.
const isMobile = window.matchMedia('(max-width: 767px)').matches
const cachee = isMobile
? !props.highlight || props.highlight !== t.mode
: Boolean(props.highlight && props.highlight !== t.mode)
el.style.display = cachee ? 'none' : ''
}
}
}
/** Le style peut ne pas etre pret quand les traces arrivent : on attend l'evenement. */
function redraw() {
if (!map) return
const run = () => {
try {
clear()
draw()
}
catch (e) {
console.error('[carte] echec du trace', e)
}
}
if (map.isStyleLoaded()) run()
else map.once('idle', run)
}
onMounted(() => {
if (!container.value) return
map = new MapLibreMap({
container: container.value,
style: STYLE,
center: props.from ?? FRANCE,
zoom: props.from ? 8 : 5,
})
map.addControl(new NavigationControl({ showCompass: false }), 'bottom-right')
map.on('error', e => console.error('[carte]', e.error ?? e))
map.on('load', redraw)
window.addEventListener('resize', applyHighlight)
})
onBeforeUnmount(() => {
window.removeEventListener('resize', applyHighlight)
map?.remove()
map = null
})
watch(() => props.traces, redraw, { deep: true })
watch(() => props.highlight, applyHighlight)
</script>
<template>
<div
ref="container"
class="h-full w-full"
role="img"
aria-label="Carte des itinéraires comparés"
/>
</template>
<style>
.lcv-bubble {
display: inline-flex;
align-items: center;
gap: 6px;
background: white;
padding: 4px 10px;
font-size: 12px;
font-weight: 600;
color: #10231c;
border: 1px solid #ccc;
border-left-width: 4px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.12);
white-space: nowrap;
transition: opacity 0.2s;
pointer-events: none;
}
.lcv-bubble-dot {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
}
.lcv-bubble.dimmed {
opacity: 0.45;
}
</style>