From d966e1b7495aed98b8970de8d98c2e0a9e58f7c0 Mon Sep 17 00:00:00 2001 From: louispaulb Date: Fri, 3 Jul 2026 18:06:16 -0400 Subject: [PATCH] =?UTF-8?q?feat(planif):=20positions=20GPS=20live=20des=20?= =?UTF-8?q?techs=20(Traccar)=20sur=20la=20carte=20des=20tourn=C3=A9es?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Onglet Tournées : marqueurs « live » de chaque tech (appareil Traccar associé), rafraîchis ~25 s tant que l'onglet est ouvert. Marqueur = pastille ronde à initiales, couleur = celle de la tournée du tech, halo pulsé si position fraîche (grisé sans halo si > 10 min). Tooltip : nom · « il y a X min » · vitesse (km/h) ou « arrêté ». Bouton « GPS live (N) » pour afficher/masquer. Respecte les chips techs masqués. - hub roster.js : GET /roster/tech-positions → {positions:[{techId,techName,lat,lon,time,speed}]} (batch getPositions par appareil). Vérifié prod : 14 positions. - api/roster.js : techPositions(). - RouteMap.vue : prop `live` + marqueurs .rm-live (halo pulsé, staleness), watch dédié. - PlanificationPage : polling 25 s (démarré sur l'onglet Tournées, arrêté sinon + onUnmounted), couleur mappée depuis dayRoutes. Co-Authored-By: Claude Opus 4.8 --- apps/ops/src/api/roster.js | 2 ++ apps/ops/src/components/shared/RouteMap.vue | 37 +++++++++++++++++++-- apps/ops/src/pages/PlanificationPage.vue | 21 ++++++++++-- services/targo-hub/lib/roster.js | 17 ++++++++++ 4 files changed, 73 insertions(+), 4 deletions(-) diff --git a/apps/ops/src/api/roster.js b/apps/ops/src/api/roster.js index 4ba4392..da0b709 100644 --- a/apps/ops/src/api/roster.js +++ b/apps/ops/src/api/roster.js @@ -182,6 +182,8 @@ export const jobServiceStatus = (job) => jget('/roster/job-service-status?job=' export const onsiteCross = (days = 30) => jget('/traccar/onsite?days=' + days) // Position GPS LIVE (dernière connue) d'un tech → { ok, lat, lon, time, speed } ; pour fixer son point de départ (domicile). export const techLivePosition = (tech) => jget('/traccar/live?tech=' + encodeURIComponent(tech)) +// Positions GPS LIVE de TOUS les techs (appareil Traccar associé) → { positions:[{techId,techName,lat,lon,time,speed}] }. +export const techPositions = () => jget('/roster/tech-positions') // Liste des appareils Traccar (pour associer un tech à son GPS) — { id, name, uniqueId, status, lastUpdate }. export const listTraccarDevices = () => jget('/traccar/devices') // Associer / changer (device_id) ou dissocier ('') l'appareil Traccar d'un tech — INLINE depuis Planif. diff --git a/apps/ops/src/components/shared/RouteMap.vue b/apps/ops/src/components/shared/RouteMap.vue index efcaf3d..921d94b 100644 --- a/apps/ops/src/components/shared/RouteMap.vue +++ b/apps/ops/src/components/shared/RouteMap.vue @@ -15,12 +15,13 @@ import * as roster from 'src/api/roster' const props = defineProps({ routes: { type: Array, default: () => [] }, + live: { type: Array, default: () => [] }, // positions GPS LIVE : [{ techId, name|techName, color, lat, lon, time, speed(nœuds) }] height: { type: String, default: '440px' }, }) const emit = defineEmits(['metrics', 'stop-click']) let el = null; let map = null; let ro = null; let ready = false; let drawSeq = 0 -let stopMk = []; let homeMk = [] // marqueurs HTML (arrêts + domiciles) +let stopMk = []; let homeMk = []; let liveMk = [] // marqueurs HTML (arrêts + domiciles + positions live) const _rmCache = RouteMapCache() // cache module (partagé entre instances) : signature → { geometry, km, mins } function RouteMapCache () { const g = globalThis; if (!g.__opsRouteMapCache) g.__opsRouteMapCache = new Map(); return g.__opsRouteMapCache } function setEl (node) { el = node } @@ -68,6 +69,29 @@ async function realLine (r) { // géométrie routière réelle d'une tournée (O // ── Marqueurs HTML : le numéro est DANS la pastille (même élément, même niveau) ── function clearMarkers () { for (const m of stopMk) m.mk.remove(); for (const m of homeMk) m.mk.remove(); stopMk = []; homeMk = [] } + +// ── Positions GPS LIVE des techs (marqueur distinct des arrêts : pastille ronde à initiales + halo pulsé) ── +function initials (n) { return String(n || '?').trim().split(/\s+/).map(w => w[0]).filter(Boolean).slice(0, 2).join('').toUpperCase() } +function clearLive () { for (const m of liveMk) m.mk.remove(); liveMk = [] } +function renderLive (list) { + if (!map || !ready) return + clearLive() + const mapboxgl = window.mapboxgl; const now = Date.now() + for (const p of (list || [])) { + if (!okLL(p.lon, p.lat)) continue + const ageMin = p.time ? Math.round((now - Date.parse(p.time)) / 60000) : null + const stale = ageMin != null && ageMin > 10 // > 10 min sans fix = position vieillie (pas de halo, grisée) + const color = p.color || '#1e88e5' + const wrap = document.createElement('div'); wrap.className = 'rm-live' + (stale ? ' stale' : '') + wrap.innerHTML = '' + + '' + initials(p.name || p.techName) + '' + const spd = (p.speed != null && p.speed > 1) ? Math.round(p.speed * 1.852) + ' km/h' : 'arrêté' // Traccar speed = nœuds → km/h + const when = ageMin == null ? 'position' : (ageMin <= 1 ? "à l'instant" : 'il y a ' + ageMin + ' min') + wrap.title = (p.name || p.techName || '') + ' — ' + when + ' · ' + spd + const mk = new mapboxgl.Marker({ element: wrap, anchor: 'center' }).setLngLat([+p.lon, +p.lat]).addTo(map) + liveMk.push({ mk, el: wrap }) + } +} function renderMarkers (routes) { clearMarkers() const mapboxgl = window.mapboxgl @@ -149,6 +173,7 @@ async function draw () { const ls = map.getSource('rm-line'); if (!ls) return ls.setData({ type: 'FeatureCollection', features: lineFeatures(routes) }) renderMarkers(routes) + renderLive(props.live) const sig = routes.map(r => r.id + ':' + (r.stops || []).length).join('|') if (sig !== _lastSig) { _lastSig = sig; try { const b = boundsOf(routes); if (!b.isEmpty()) map.fitBounds(b, { padding: 60, maxZoom: 13, duration: 400 }) } catch (e) {} } // Routes RÉELLES en asynchrone : remplace les segments droits + émet km/min réels. Ignoré si un draw plus récent est parti. @@ -199,8 +224,9 @@ onMounted(async () => { draw() }) }) -onBeforeUnmount(() => { clearMarkers(); if (ro) { try { ro.disconnect() } catch (e) {} ro = null } if (map) { try { map.remove() } catch (e) {} map = null } ready = false }) +onBeforeUnmount(() => { clearMarkers(); clearLive(); if (ro) { try { ro.disconnect() } catch (e) {} ro = null } if (map) { try { map.remove() } catch (e) {} map = null } ready = false }) watch(() => props.routes, () => { draw() }, { deep: true }) +watch(() => props.live, () => { renderLive(props.live) }, { deep: true }) // rafraîchit les positions live sans redessiner les tournées