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:
parent
d56800805e
commit
307fb4cea5
|
|
@ -679,10 +679,10 @@
|
|||
<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>
|
||||
</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 -->
|
||||
<div v-if="dayMapUrl" class="de-map-wrap" @click="gotoDispatch(dayEditor.tech, dayEditor.day && dayEditor.day.iso)">
|
||||
<img :src="dayMapUrl" class="de-map" alt="Territoire du jour" loading="lazy" />
|
||||
<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>
|
||||
<!-- carte interactive : itinéraire ROUTIER réel + pins numérotés, navigable (zoom molette/boutons, déplacement) -->
|
||||
<div v-show="dayEditor.list.length" class="de-map-wrap">
|
||||
<div ref="dayMapEl" class="de-map-gl"></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 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 -->
|
||||
|
|
@ -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; }
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user