260 lines
6.6 KiB
Vue
260 lines
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>
|