feat(job-map): itinéraire routier OSRM tracé DANS la carte MapLibre

Le module carte du job (JobMapModule, réutilisé volet Tournées + détail ticket) avait
un repère + tracé GPS Traccar mais AUCUN itinéraire in-app (seulement un lien Google
externe). Ajout d'un toggle « Itinéraire routier » : trace le trajet routier RÉEL via
l'OSRM auto-hébergé (roster.osrmRoute → geometry) sur la carte MapLibre + affiche km/min,
et cadre la vue sur le trajet. Départ = position live du tech assigné (traccarLive),
sinon géoloc du navigateur ; destination = coords du SERVICE (GPS de l'adresse de service,
désormais fiables). Lien « Navigation » Google conservé pour la voix sur téléphone.

Réutilise l'infra existante (OSRM /roster/osrm-route, MapLibre/OSM auto-hébergé, ZÉRO
Mapbox). Vérifié : build spa OK, 0 erreur HMR. Rendu live (fiche/ticket) = SSO côté prod.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
louispaulb 2026-07-20 11:25:52 -04:00
parent 12b9da3e36
commit 08a68016ec

View File

@ -16,6 +16,14 @@
:label="show ? 'Masquer la carte' : 'Voir la carte / tracé GPS'" @click="show = !show" />
<div v-show="show">
<div ref="mapEl" class="jd-map"></div>
<!-- Itinéraire routier RÉEL (OSRM auto-hébergé) tracé DANS la carte MapLibre départ = position live du tech
assigné, sinon géoloc du navigateur. Le lien Google reste dispo pour la navigation vocale sur le téléphone. -->
<div v-if="hasCoords" class="jd-track-row">
<q-toggle v-model="showRoute" dense size="sm" color="primary" />
<span class="text-caption text-weight-medium">Itinéraire routier</span>
<span class="text-caption text-grey-6">{{ routeMsg }}</span>
<a :href="navUrl" target="_blank" rel="noopener" class="jd-geolink q-ml-sm"><q-icon name="navigation" size="13px" /> Navigation</a>
</div>
<div v-if="canTrack" class="jd-track-row">
<q-toggle v-model="showTrack" dense size="sm" color="warning" />
<span class="text-caption text-weight-medium">Tracé GPS réel (Traccar)</span>
@ -45,11 +53,13 @@ const props = defineProps({
const mapEl = ref(null); let _map = null
const show = ref(false)
const showTrack = ref(false); const trackMsg = ref('')
const showRoute = ref(false); const routeMsg = ref('')
const laNum = computed(() => +props.lat); const loNum = computed(() => +props.lon)
const hasCoords = computed(() => isFinite(laNum.value) && isFinite(loNum.value) && (laNum.value !== 0 || loNum.value !== 0))
const mapsUrl = computed(() => `https://www.google.com/maps/search/?api=1&query=${props.lat},${props.lon}`)
const streetViewUrl = computed(() => `https://www.google.com/maps/@?api=1&map_action=pano&viewpoint=${props.lat},${props.lon}`)
const navUrl = computed(() => `https://www.google.com/maps/dir/?api=1&destination=${props.lat},${props.lon}&travelmode=driving`) // navigation vocale (téléphone)
async function initMap () {
if (!mapEl.value) return
@ -67,9 +77,14 @@ async function initMap () {
_map.on('click', 'jd-near', (e) => { const p = e.features[0].properties; new mapboxgl.Popup({ offset: 10 }).setLngLat(e.features[0].geometry.coordinates).setHTML('<div style="font-size:11px;color:#475569">' + String(p.t || '').replace(/[<>&]/g, '') + '</div>').addTo(_map) })
}
if (hasLL) new mapboxgl.Marker({ color: '#1976d2' }).setLngLat([loNum.value, laNum.value]).addTo(_map)
// Itinéraire routier (OSRM) sous le tracé GPS réel
_map.addSource('jd-route', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } })
_map.addLayer({ id: 'jd-route-halo', type: 'line', source: 'jd-route', layout: { 'line-join': 'round', 'line-cap': 'round' }, paint: { 'line-color': '#2563eb', 'line-width': 9, 'line-opacity': 0.2 } })
_map.addLayer({ id: 'jd-route-l', type: 'line', source: 'jd-route', layout: { 'line-join': 'round', 'line-cap': 'round' }, paint: { 'line-color': '#2563eb', 'line-width': 4, 'line-opacity': 0.85 } })
_map.addSource('jd-track', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } })
_map.addLayer({ id: 'jd-track-halo', type: 'line', source: 'jd-track', layout: { 'line-join': 'round', 'line-cap': 'round' }, paint: { 'line-color': '#ff6f00', 'line-width': 8, 'line-opacity': 0.22 } })
_map.addLayer({ id: 'jd-track-l', type: 'line', source: 'jd-track', layout: { 'line-join': 'round', 'line-cap': 'round' }, paint: { 'line-color': '#ef6c00', 'line-width': 3, 'line-opacity': 0.78 } })
if (showRoute.value) loadRoute()
if (showTrack.value) loadTrack()
})
}
@ -86,9 +101,33 @@ async function loadTrack () {
} catch (e) { trackMsg.value = 'Tracé indisponible' }
}
// Géoloc navigateur (repli si pas de tech assigné / pas de GPS live).
function browserGeo () { return new Promise(res => { if (!navigator.geolocation) return res(null); navigator.geolocation.getCurrentPosition(p => res([p.coords.longitude, p.coords.latitude]), () => res(null), { enableHighAccuracy: true, timeout: 8000 }) }) }
// Itinéraire routier RÉEL via l'OSRM auto-hébergé (roster.osrmRoute) tracé DANS la carte MapLibre + km/min.
// Départ : position live du tech assigné (Traccar), sinon la position du navigateur. Destination = coords du service.
async function loadRoute () {
if (!_map || !hasCoords.value) { routeMsg.value = ''; return }
routeMsg.value = 'calcul…'
let start = null, from = ''
if (props.techId) {
try { const p = await roster.techLivePosition(props.techId); const la = +(p && (p.lat != null ? p.lat : p.latitude)); const lo = +(p && (p.lon != null ? p.lon : p.longitude)); if (isFinite(la) && isFinite(lo) && (la || lo)) { start = [lo, la]; from = '· du technicien' } } catch (e) {}
}
if (!start) { const g = await browserGeo(); if (g) { start = g; from = '· de ma position' } }
if (!start) { routeMsg.value = 'position de départ indisponible (autorise la géolocalisation)'; return }
try {
const r = await roster.osrmRoute([start, [loNum.value, laNum.value]])
const src = _map && _map.getSource('jd-route')
if (r && r.geometry && src) {
src.setData({ type: 'Feature', geometry: r.geometry, properties: {} })
routeMsg.value = `${r.km} km · ${r.mins} min ${from}`
try { const b = new window.mapboxgl.LngLatBounds(); (r.geometry.coordinates || []).forEach(c => b.extend(c)); if (!b.isEmpty()) _map.fitBounds(b, { padding: 40, maxZoom: 15 }) } catch (e) {}
} else routeMsg.value = 'itinéraire indisponible'
} catch (e) { routeMsg.value = 'itinéraire indisponible' }
}
function destroy () { if (_map) { try { _map.remove() } catch (e) {} _map = null } }
watch(show, (on) => { if (on) nextTick(() => setTimeout(initMap, 200)); else destroy() })
watch(showTrack, (on) => { if (!_map) return; if (on) loadTrack(); else { const s = _map.getSource('jd-track'); if (s) s.setData({ type: 'FeatureCollection', features: [] }); trackMsg.value = '' } })
watch(showRoute, (on) => { if (!_map) return; if (on) loadRoute(); else { const s = _map.getSource('jd-route'); if (s) s.setData({ type: 'FeatureCollection', features: [] }); routeMsg.value = ''; if (hasCoords.value) _map.flyTo({ center: [loNum.value, laNum.value], zoom: 14 }) } })
onBeforeUnmount(destroy)
</script>