Planification : carte de journée INTERACTIVE (Mapbox GL) — itinéraire routier réel + zoom/déplacement

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) <noreply@anthropic.com>
This commit is contained in:
louispaulb 2026-06-07 17:44:22 -04:00
parent d56800805e
commit 307fb4cea5

View File

@ -679,10 +679,10 @@
<div v-for="(b, bi) in dayBlocks()" :key="'k' + bi" class="tl-blk" :style="blockStyle(b, dayOcc() && dayOcc().pct)"></div> <div v-for="(b, bi) in dayBlocks()" :key="'k' + bi" class="tl-blk" :style="blockStyle(b, dayOcc() && dayOcc().pct)"></div>
<span v-for="tk in axisTicks" :key="'t' + tk.h" class="tldlg-tick" :style="{ left: tk.left }">{{ tk.h }}</span> <span v-for="tk in axisTicks" :key="'t' + tk.h" class="tldlg-tick" :style="{ left: tk.left }">{{ tk.h }}</span>
</div> </div>
<!-- minimap : territoire des jobs (pins numérotés dans l'ordre de tournée + tracé) → vérifier les adresses d'un coup d'œil --> <!-- carte interactive : itinéraire ROUTIER réel + pins numérotés, navigable (zoom molette/boutons, déplacement) -->
<div v-if="dayMapUrl" class="de-map-wrap" @click="gotoDispatch(dayEditor.tech, dayEditor.day && dayEditor.day.iso)"> <div v-show="dayEditor.list.length" class="de-map-wrap">
<img :src="dayMapUrl" class="de-map" alt="Territoire du jour" loading="lazy" /> <div ref="dayMapEl" class="de-map-gl"></div>
<div class="de-map-cap">🗺 Pins = arrêts dans l'ordre · clic = carte interactive (Dispatch)<span v-if="dayNoCoord" class="text-deep-orange-7"> · {{ dayNoCoord }} sans coords (absent de la carte)</span></div> <div class="de-map-cap">🗺 Itinéraire routier · molette/boutons = zoom · glisser = déplacer<span v-if="dayNoCoord" class="text-deep-orange-7"> · {{ dayNoCoord }} sans coords (absent de la carte)</span></div>
</div> </div>
<div v-if="!dayEditor.list.length" class="text-grey-6 q-pa-md text-center">Aucun job ce jour.</div> <div v-if="!dayEditor.list.length" class="text-grey-6 q-pa-md text-center">Aucun job ce jour.</div>
<!-- liste éditable : flèches/glisser pour réordonner · durée en minutes · pour retirer --> <!-- liste éditable : flèches/glisser pour réordonner · durée en minutes · pour retirer -->
@ -741,7 +741,7 @@
* 10. Chargement & solveur ................. loadBase/loadWeek/loadStats · doGenerate/doPublish * 10. Chargement & solveur ................. loadBase/loadWeek/loadStats · doGenerate/doPublish
* 11. Helpers date/temps/couleur .......... iso/hToNum/numToTime · occColor/todColor/getTagColor * 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. // 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 { symOutlinedToolsLadder, symOutlinedHeadsetMic, symOutlinedHandyman } from '@quasar/extras/material-symbols-outlined'
import { onBeforeRouteLeave, useRouter } from 'vue-router' import { onBeforeRouteLeave, useRouter } from 'vue-router'
@ -1098,31 +1098,77 @@ const packedDay = computed(() => {
} }
return out 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 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) 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 const dayTotalH = () => Math.round(dayEditor.list.reduce((s, j) => s + (Number(j.dur) || 0), 0) * 10) / 10
async function removeFromDay (j) { 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) } 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-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; } .de-dot { width: 11px; height: 11px; border-radius: 3px; flex: 0 0 auto; }
/* minimap du jour (territoire des arrêts) */ /* 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 { margin: 8px 0 4px; border-radius: 8px; overflow: hidden; border: 1px solid #e0e0e0; }
.de-map-wrap:hover { border-color: #3b5bdb; } .de-map-gl { width: 100%; height: 240px; }
.de-map { display: block; width: 100%; height: auto; }
.de-map-cap { font-size: 10px; color: #777; padding: 3px 6px; background: #fafafa; border-top: 1px solid #eee; } .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-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; } .de-dur { display: flex; align-items: center; gap: 2px; font-size: 10px; color: #888; }