lecheminvert.tech/app/components/RouteMap.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

260 lines
No EOL
6.6 KiB
Vue

<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>