Import initial : code en production sur Hetzner
This commit is contained in:
commit
cf6ee64e31
43 changed files with 14404 additions and 0 deletions
260
app/components/RouteMap.vue
Normal file
260
app/components/RouteMap.vue
Normal 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>
|
||||
Loading…
Add table
Add a link
Reference in a new issue