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

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>