From 307fb4cea5ca66533494925a83c8c7fde847d99e Mon Sep 17 00:00:00 2001 From: louispaulb Date: Sun, 7 Jun 2026 17:44:22 -0400 Subject: [PATCH] =?UTF-8?q?Planification=20:=20carte=20de=20journ=C3=A9e?= =?UTF-8?q?=20INTERACTIVE=20(Mapbox=20GL)=20=E2=80=94=20itin=C3=A9raire=20?= =?UTF-8?q?routier=20r=C3=A9el=20+=20zoom/d=C3=A9placement?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remplace la mini-image statique (segments à vol d'oiseau) par une carte Mapbox GL : - Itinéraire ROUTIER réel via l'API Directions (geometries=geojson) tracé sur la carte (halo + ligne). - Pins numérotés dans l'ordre de tournée (cercle coloré par compétence + numéro). - Navigable : zoom molette + boutons NavigationControl (+/-), déplacement (pan), ajustée au territoire (fitBounds). - Lifecycle : init à l'ouverture du dialogue (après anim + resize), refresh débouncé au réordonnancement (re-trace l'itinéraire), destruction à la fermeture (pas de fuite). mapbox-gl chargé en CDN (comme le Dispatch). - Avertissement « N sans coords » conservé. Validé : Directions OK (géométrie 392 pts). Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/ops/src/pages/PlanificationPage.vue | 107 ++++++++++++++++------- 1 file changed, 76 insertions(+), 31 deletions(-) diff --git a/apps/ops/src/pages/PlanificationPage.vue b/apps/ops/src/pages/PlanificationPage.vue index c7d8033..07b3e9b 100644 --- a/apps/ops/src/pages/PlanificationPage.vue +++ b/apps/ops/src/pages/PlanificationPage.vue @@ -679,10 +679,10 @@
{{ tk.h }} - -
- Territoire du jour -
🗺 Pins = arrêts dans l'ordre · clic = carte interactive (Dispatch) · ⚠ {{ dayNoCoord }} sans coords (absent de la carte)
+ +
+
+
🗺 Itinéraire routier · molette/boutons = zoom · glisser = déplacer · ⚠ {{ dayNoCoord }} sans coords (absent de la carte)
Aucun job ce jour.
@@ -741,7 +741,7 @@ * 10. Chargement & solveur ................. loadBase/loadWeek/loadStats · doGenerate/doPublish * 11. Helpers date/temps/couleur .......... iso/hToNum/numToTime · occColor/todColor/getTagColor */ -import { ref, computed, reactive, onMounted, onUnmounted } from 'vue' +import { ref, computed, reactive, onMounted, onUnmounted, watch, nextTick } from 'vue' // Icônes de rôle monochromes outline (Material Symbols, style « une couleur » demandé) : échelle = installation. import { symOutlinedToolsLadder, symOutlinedHeadsetMic, symOutlinedHandyman } from '@quasar/extras/material-symbols-outlined' import { onBeforeRouteLeave, useRouter } from 'vue-router' @@ -1098,31 +1098,77 @@ const packedDay = computed(() => { } return out }) -// Encodage polyline (precision 5, deltas entiers) pour le tracé de la minimap Mapbox Static. -function encodePolyline (coords) { - let prevLat = 0, prevLon = 0, out = '' - const enc = (cur, prev) => { let v = cur - prev; v = v < 0 ? ~(v << 1) : (v << 1); let s = ''; while (v >= 0x20) { s += String.fromCharCode((0x20 | (v & 0x1f)) + 63); v >>= 5 } s += String.fromCharCode(v + 63); return s } - for (const [lat, lon] of coords) { const la = Math.round(lat * 1e5); const lo = Math.round(lon * 1e5); out += enc(la, prevLat) + enc(lo, prevLon); prevLat = la; prevLon = lo } - return out -} -// Minimap du jour (Mapbox Static) : pins numérotés dans l'ordre de tournée + tracé, ajustée au territoire (auto). -// → permet de VÉRIFIER d'un coup d'œil que chaque arrêt tombe à la bonne adresse (un pin mal placé = coord à corriger). const hasLL = (j) => j && j.lat != null && j.lon != null && isFinite(+j.lat) && isFinite(+j.lon) -const dayMapUrl = computed(() => { - if (!MAPBOX_TOKEN) return null - const pts = packedDay.value.filter(hasLL).slice(0, 24) - if (!pts.length) return null - const markers = pts.map((j, idx) => { - const hex = (j.skill ? getTagColor(j.skill) : '#1976d2').replace('#', '') - const lbl = idx < 9 ? '-' + (idx + 1) : '' // pin-s : label = 1 caractère (1..9) ; au-delà = pin sans numéro - return `pin-s${lbl}+${hex}(${(+j.lon).toFixed(5)},${(+j.lat).toFixed(5)})` - }) - const overlays = [] - if (pts.length > 1) overlays.push(`path-2+3b5bdb-0.55(${encodeURIComponent(encodePolyline(pts.map(j => [+j.lat, +j.lon])))})`) // tracé (ordre) - overlays.push(...markers) // marqueurs au-dessus du tracé - return `https://api.mapbox.com/styles/v1/mapbox/streets-v12/static/${overlays.join(',')}/auto/520x200@2x?padding=35&access_token=${MAPBOX_TOKEN}` -}) const dayNoCoord = computed(() => dayEditor.list.filter(j => !hasLL(j)).length) + +// ── Carte INTERACTIVE de la journée (Mapbox GL) : itinéraire ROUTIER réel (API Directions) + pins +// numérotés dans l'ordre de tournée. Navigable : zoom molette + boutons (NavigationControl), déplacement. ── +const dayMapEl = ref(null) +let _dayMap = null; let _dayMapRO = null; let _dirTimer = null +function ensureMapbox () { + return new Promise((resolve) => { + if (!document.getElementById('mapbox-css')) { const l = document.createElement('link'); l.id = 'mapbox-css'; l.rel = 'stylesheet'; l.href = 'https://api.mapbox.com/mapbox-gl-js/v2.15.0/mapbox-gl.css'; document.head.appendChild(l) } + if (window.mapboxgl) return resolve(window.mapboxgl) + let s = document.getElementById('mapbox-js') + if (!s) { s = document.createElement('script'); s.id = 'mapbox-js'; s.src = 'https://api.mapbox.com/mapbox-gl-js/v2.15.0/mapbox-gl.js'; document.head.appendChild(s) } + s.addEventListener('load', () => resolve(window.mapboxgl)) + const iv = setInterval(() => { if (window.mapboxgl) { clearInterval(iv); resolve(window.mapboxgl) } }, 150) + }) +} +function dayStops () { // arrêts géolocalisés, dans l'ordre de tournée (packedDay) + return packedDay.value.filter(hasLL).map((j, i) => ({ lon: +j.lon, lat: +j.lat, label: String(i + 1), color: j.skill ? getTagColor(j.skill) : '#1976d2' })) +} +async function initDayMap () { + if (!MAPBOX_TOKEN || !dayMapEl.value || _dayMap) return + const mapboxgl = await ensureMapbox(); if (!mapboxgl || !dayMapEl.value) return + mapboxgl.accessToken = MAPBOX_TOKEN + _dayMap = new mapboxgl.Map({ container: dayMapEl.value, style: 'mapbox://styles/mapbox/streets-v12', center: [-73.6756, 45.1599], zoom: 9 }) + _dayMap.addControl(new mapboxgl.NavigationControl({ showCompass: false }), 'top-right') // boutons zoom +/- + _dayMapRO = new ResizeObserver(() => { if (_dayMap) _dayMap.resize() }); _dayMapRO.observe(dayMapEl.value) + _dayMap.on('load', () => { + _dayMap.resize() + _dayMap.addSource('day-route', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } }) + _dayMap.addLayer({ id: 'day-route-halo', type: 'line', source: 'day-route', layout: { 'line-join': 'round', 'line-cap': 'round' }, paint: { 'line-color': '#3b5bdb', 'line-width': 9, 'line-opacity': 0.2 } }) + _dayMap.addLayer({ id: 'day-route', type: 'line', source: 'day-route', layout: { 'line-join': 'round', 'line-cap': 'round' }, paint: { 'line-color': '#3b5bdb', 'line-width': 4, 'line-opacity': 0.85 } }) + _dayMap.addSource('day-stops', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } }) + _dayMap.addLayer({ id: 'day-stops-c', type: 'circle', source: 'day-stops', paint: { 'circle-radius': 13, 'circle-color': ['get', 'color'], 'circle-stroke-width': 2, 'circle-stroke-color': '#fff' } }) + _dayMap.addLayer({ id: 'day-stops-l', type: 'symbol', source: 'day-stops', layout: { 'text-field': ['get', 'label'], 'text-font': ['DIN Offc Pro Bold', 'Arial Unicode MS Bold'], 'text-size': 12, 'text-allow-overlap': true }, paint: { 'text-color': '#fff' } }) + refreshDayMap() + }) +} +function refreshDayMap () { + if (!_dayMap || !_dayMap.isStyleLoaded()) { setTimeout(refreshDayMap, 200); return } + const stops = dayStops() + const sSrc = _dayMap.getSource('day-stops') + if (sSrc) sSrc.setData({ type: 'FeatureCollection', features: stops.map(s => ({ type: 'Feature', geometry: { type: 'Point', coordinates: [s.lon, s.lat] }, properties: { label: s.label, color: s.color } })) }) + if (stops.length === 1) _dayMap.easeTo({ center: [stops[0].lon, stops[0].lat], zoom: 13, duration: 400 }) + else if (stops.length > 1) { + const b = new window.mapboxgl.LngLatBounds([stops[0].lon, stops[0].lat], [stops[0].lon, stops[0].lat]) + stops.forEach(s => b.extend([s.lon, s.lat])) + _dayMap.fitBounds(b, { padding: 45, maxZoom: 14, duration: 400 }) + } + fetchDayRouteGeom(stops) +} +async function fetchDayRouteGeom (stops) { // itinéraire ROUTIER réel (Directions) → tracé sur la carte + const rSrc = _dayMap && _dayMap.getSource('day-route'); if (!rSrc) return + if (!stops || stops.length < 2) { rSrc.setData({ type: 'FeatureCollection', features: [] }); return } + try { + const pts = stops.slice(0, 25).map(s => `${s.lon.toFixed(6)},${s.lat.toFixed(6)}`).join(';') + const url = `https://api.mapbox.com/directions/v5/mapbox/driving-traffic/${pts}?overview=full&geometries=geojson&access_token=${MAPBOX_TOKEN}` + const r = await fetch(url); if (!r.ok) throw new Error('dir ' + r.status) + const d = await r.json(); const geom = d.routes && d.routes[0] && d.routes[0].geometry + const src = _dayMap && _dayMap.getSource('day-route') + if (geom && src) src.setData({ type: 'Feature', geometry: geom, properties: {} }) + } catch (e) { /* repli : pas de tracé routier (les pins restent visibles) */ } +} +function destroyDayMap () { + if (_dirTimer) { clearTimeout(_dirTimer); _dirTimer = null } + if (_dayMapRO) { _dayMapRO.disconnect(); _dayMapRO = null } + if (_dayMap) { try { _dayMap.remove() } catch (e) {} _dayMap = null } +} +// (ré)init à l'ouverture du dialogue (après l'anim) ; refresh débouncé au réordonnancement ; destruction à la fermeture. +watch(() => dayEditor.open, (open) => { if (open) nextTick(() => setTimeout(initDayMap, 250)); else destroyDayMap() }) +watch(() => dayEditor.list.map(j => j.name).join(','), () => { if (_dayMap) { clearTimeout(_dirTimer); _dirTimer = setTimeout(refreshDayMap, 500) } }) const dayTotalH = () => Math.round(dayEditor.list.reduce((s, j) => s + (Number(j.dur) || 0), 0) * 10) / 10 async function removeFromDay (j) { try { await roster.unassignJobRoster(j.name); dayEditor.list = dayEditor.list.filter(x => x.name !== j.name); await loadWeek(); $q.notify({ type: 'info', message: 'Retiré du tech (retour au pool « à assigner »)', timeout: 2200 }) } catch (e) { err(e) } @@ -1867,9 +1913,8 @@ tr.res-hidden .hide-eye { opacity: 1; } .de-ord { font-size: 12px; font-weight: 700; color: #607d8b; min-width: 16px; text-align: center; } .de-dot { width: 11px; height: 11px; border-radius: 3px; flex: 0 0 auto; } /* minimap du jour (territoire des arrêts) */ -.de-map-wrap { margin: 8px 0 4px; cursor: pointer; border-radius: 8px; overflow: hidden; border: 1px solid #e0e0e0; } -.de-map-wrap:hover { border-color: #3b5bdb; } -.de-map { display: block; width: 100%; height: auto; } +.de-map-wrap { margin: 8px 0 4px; border-radius: 8px; overflow: hidden; border: 1px solid #e0e0e0; } +.de-map-gl { width: 100%; height: 240px; } .de-map-cap { font-size: 10px; color: #777; padding: 3px 6px; background: #fafafa; border-top: 1px solid #eee; } .de-prio { font-size: 11px; border: 1px solid #ccc; border-left-width: 4px; border-radius: 4px; padding: 2px 4px; background: #fff; } .de-dur { display: flex; align-items: center; gap: 2px; font-size: 10px; color: #888; }