From 08a68016ec19390d5f82808e41e8a7e9accf38d9 Mon Sep 17 00:00:00 2001 From: louispaulb Date: Mon, 20 Jul 2026 11:25:52 -0400 Subject: [PATCH] =?UTF-8?q?feat(job-map):=20itin=C3=A9raire=20routier=20OS?= =?UTF-8?q?RM=20trac=C3=A9=20DANS=20la=20carte=20MapLibre?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../detail-sections/modules/JobMapModule.vue | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/apps/ops/src/components/shared/detail-sections/modules/JobMapModule.vue b/apps/ops/src/components/shared/detail-sections/modules/JobMapModule.vue index 1e7dffd..bfa1ad2 100644 --- a/apps/ops/src/components/shared/detail-sections/modules/JobMapModule.vue +++ b/apps/ops/src/components/shared/detail-sections/modules/JobMapModule.vue @@ -16,6 +16,14 @@ :label="show ? 'Masquer la carte' : 'Voir la carte / tracé GPS'" @click="show = !show" />
+ +
+ + Itinéraire routier + {{ routeMsg }} + Navigation +
Tracé GPS réel (Traccar) @@ -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('
' + String(p.t || '').replace(/[<>&]/g, '') + '
').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)