Completes the jobs-to-assign pool unification whose PlanificationPage wiring
landed in 9c49054 (which referenced these files before they were committed):
- JobPool.vue: one shared component (floating/docked/mobile) replacing the 3
divergent pool implementations
- useJobPool.js: shared filter/sort/group/badge composable
- RouteMap.vue: live tech icons join the explode/fan; single-tech real Traccar
day-route layer
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
348 lines
24 KiB
Vue
348 lines
24 KiB
Vue
<script setup>
|
|
/**
|
|
* RouteMap — carte de TOURNÉES réutilisable (revue du dispatch auto, onglet « Tournées », etc.).
|
|
*
|
|
* props.routes = [{ id, name, color, home:{lat,lon}|null, stops:[{lat,lon,seq,subject}] }]
|
|
* - LIGNES = couches GL (tracé instantané droit → géométrie ROUTIÈRE réelle OSRM via hub, cache module partagé)
|
|
* - ARRÊTS = MARQUEURS HTML (le numéro vit DANS la pastille = même élément, même niveau → jamais de chiffre orphelin
|
|
* par-dessus un voisin ; occlusion correcte). Au survol : la pastille passe au 1er plan ET si des pastilles se
|
|
* chevauchent, le groupe s'ÉCLATE en éventail pour les rendre toutes lisibles/cliquables.
|
|
* - émet 'metrics' { id:{km,mins} } (routes réelles) ; expose fitTo(id) / fitAll() / setActive(id).
|
|
*/
|
|
import { watch, onMounted, onBeforeUnmount } from 'vue'
|
|
import { installMaplibre, basemapStyle, addSatelliteLayer, satellitePref, setSatellitePref, setSatelliteVisible } from 'src/config/basemap' // fond MapLibre/OSM auto-hébergé (plus de Mapbox) + couche satellite ESRI
|
|
import * as roster from 'src/api/roster'
|
|
import { initials } from 'src/composables/useFormatters' // initiales (source unique, ex-locale dé-dupliquée)
|
|
|
|
const props = defineProps({
|
|
routes: { type: Array, default: () => [] },
|
|
live: { type: Array, default: () => [] }, // positions GPS LIVE : [{ techId, name|techName, color, lat, lon, time, speed(nœuds) }]
|
|
pins: { type: Array, default: () => [] }, // jobs NON assignés à situer : [{ lat, lon, subject, name, color(priorité), city }]
|
|
track: { type: Object, default: null }, // tracé GPS RÉEL (Traccar) d'UN tech sur la journée : { coords:[[lon,lat]…] } — couche orange au 1er plan (vs tournées prévues)
|
|
height: { type: String, default: '440px' },
|
|
})
|
|
const emit = defineEmits(['metrics', 'stop-click', 'pin-click'])
|
|
|
|
let el = null; let map = null; let ro = null; let ready = false; let drawSeq = 0
|
|
let stopMk = []; let homeMk = []; let liveMk = []; let pinMk = [] // marqueurs HTML (arrêts + domiciles + positions live + jobs non assignés)
|
|
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 }
|
|
|
|
function ensureMapbox () { return Promise.resolve(installMaplibre()) } // MapLibre bundlé + protocole pmtiles:// (alias window.mapboxgl)
|
|
const okLL = (lon, lat) => isFinite(+lon) && isFinite(+lat) && Math.abs(+lat) > 0.01 // écarte (0,0) / invalides qui feraient dézoomer la carte
|
|
function boundsOf (routes) {
|
|
const b = new window.mapboxgl.LngLatBounds()
|
|
for (const r of routes) { if (r.home && okLL(r.home.lon, r.home.lat)) b.extend([+r.home.lon, +r.home.lat]); for (const s of (r.stops || [])) if (okLL(s.lon, s.lat)) b.extend([+s.lon, +s.lat]) }
|
|
return b
|
|
}
|
|
function lineFeatures (routes) {
|
|
const lines = []
|
|
for (const r of routes) {
|
|
const coords = []
|
|
if (r.home) coords.push([+r.home.lon, +r.home.lat])
|
|
for (const s of (r.stops || [])) coords.push([+s.lon, +s.lat])
|
|
if (r.home && coords.length >= 2) coords.push([+r.home.lon, +r.home.lat]) // RETOUR au départ : boucle domicile→arrêts→domicile
|
|
if (coords.length >= 2) lines.push({ type: 'Feature', geometry: { type: 'LineString', coordinates: coords }, properties: { color: r.color, id: r.id, rid: r.id } })
|
|
}
|
|
return lines
|
|
}
|
|
async function realLine (r) { // géométrie routière réelle d'une tournée (OSRM via hub), cache par signature — BOUCLE avec retour au départ
|
|
const pts = []; if (r.home) pts.push([+r.home.lon, +r.home.lat]); for (const s of (r.stops || [])) pts.push([+s.lon, +s.lat])
|
|
if (r.home) pts.push([+r.home.lon, +r.home.lat]) // le tech revient à son point de départ → compté dans km/min
|
|
if (pts.length < 2 || pts.length > 52) return null
|
|
const sig = pts.map(p => p[0].toFixed(5) + ',' + p[1].toFixed(5)).join(';')
|
|
if (_rmCache.has(sig)) return _rmCache.get(sig)
|
|
try {
|
|
const d = await roster.osrmRoute(pts)
|
|
if (!d || !Array.isArray(d.geometry) || !d.geometry.length) return null
|
|
const info = { geometry: { type: 'LineString', coordinates: d.geometry }, km: d.km, mins: d.mins }
|
|
_rmCache.set(sig, info); return info
|
|
} catch (e) { return null }
|
|
}
|
|
|
|
// ── 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é) ──
|
|
// initials → composables/useFormatters (source unique)
|
|
function clearLive () { for (const m of liveMk) m.mk.remove(); liveMk = [] }
|
|
function renderTrack (tk) { // tracé GPS réel (couche GL orange) — vidé si absent
|
|
if (!map || !ready) return
|
|
const src = map.getSource('rm-track'); if (!src) return
|
|
const coords = (tk && Array.isArray(tk.coords)) ? tk.coords : []
|
|
src.setData(coords.length >= 2 ? { type: 'Feature', geometry: { type: 'LineString', coordinates: coords }, properties: {} } : { type: 'FeatureCollection', features: [] })
|
|
}
|
|
function clearPins () { for (const m of pinMk) m.mk.remove(); pinMk = [] }
|
|
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' : '')
|
|
// Élément INTERNE fannable : Mapbox pose sa transform de positionnement sur `wrap` (racine du marqueur) → on translate `inner`,
|
|
// pas `wrap` (comme les arrêts translatent .rm-pill, enfant de .rm-mk), sinon on casserait le placement GPS.
|
|
const inner = document.createElement('div'); inner.className = 'rm-live-in'
|
|
inner.innerHTML = '<span class="rm-live-pulse" style="background:' + color + '"></span>' +
|
|
'<span class="rm-live-dot" style="background:' + color + '">' + initials(p.name || p.techName) + '</span>'
|
|
wrap.appendChild(inner)
|
|
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)
|
|
// Rejoint l'ÉCLATEMENT en éventail : quand une position GPS chevauche des arrêts/gouttes, le groupe se sépare au survol.
|
|
const rec = { mk, el: wrap, disc: inner, rid: p.techId, lngLat: [+p.lon, +p.lat], fan: null, members: null, live: true }
|
|
wrap.addEventListener('mouseenter', () => onEnter(rec))
|
|
wrap.addEventListener('mouseleave', () => onLeave(rec))
|
|
liveMk.push(rec)
|
|
}
|
|
recluster()
|
|
}
|
|
function esc (s) { return String(s == null ? '' : s).replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c])) }
|
|
// Jobs NON assignés du jour, GROUPÉS PAR ADRESSE : pastille round-rect avec les icônes des types de jobs + le temps
|
|
// TOTAL. Clic (tap mobile) → popup listant les jobs de l'adresse (chaque ligne ouvre le détail). Rejoint l'éclatement
|
|
// en éventail au chevauchement (recluster/fan) pour rester distinguable/atteignable sur mobile.
|
|
function renderPins (list) {
|
|
if (!map || !ready) return
|
|
clearPins()
|
|
const mapboxgl = window.mapboxgl
|
|
for (const p of (list || [])) {
|
|
if (!okLL(p.lon, p.lat)) continue
|
|
const color = p.color || '#f97316'
|
|
const wrap = document.createElement('div'); wrap.className = 'rm-mk rm-mk-pin'
|
|
const disc = document.createElement('div'); disc.className = 'rm-pill'; disc.style.background = color
|
|
const icons = (p.icons && p.icons.length) ? p.icons : [p.icon || 'place']
|
|
let html = icons.map(ic => '<span class="material-icons rm-ic">' + (/^[a-z0-9_]+$/.test(String(ic)) ? ic : 'place') + '</span>').join('')
|
|
if (p.count > 1) html += '<span class="rm-num">' + p.count + '</span>'
|
|
if (p.totalLabel) html += '<span class="rm-time">' + esc(p.totalLabel) + '</span>'
|
|
disc.innerHTML = html
|
|
wrap.appendChild(disc)
|
|
const jobs = p.jobs || []
|
|
wrap.title = (p.count > 1 ? p.count + ' jobs' : (jobs[0] ? jobs[0].subject : 'Job')) + (p.city ? ' · ' + p.city : '') + (p.totalLabel ? ' · ≈' + p.totalLabel : '') + ' — à répartir'
|
|
wrap.addEventListener('click', (ev) => {
|
|
ev.stopPropagation()
|
|
if (jobs.length <= 1) { emit('pin-click', { job: (jobs[0] && jobs[0].job) || p.job }); return }
|
|
const rows = jobs.map((j, i) => `<div class="rm-pop-row" data-i="${i}"><span class="rm-pop-s">${esc(j.subject)}</span><span class="rm-pop-est">${esc(j.est || '')}</span></div>`).join('')
|
|
const pop = new mapboxgl.Popup({ offset: 18, maxWidth: '280px' }).setLngLat([+p.lon, +p.lat])
|
|
.setHTML(`<div class="rm-pop"><div class="rm-pop-hd">${jobs.length} jobs · ≈${esc(p.totalLabel || '')}${p.city ? ' · ' + esc(p.city) : ''}</div>${rows}</div>`).addTo(map)
|
|
setTimeout(() => { const el = pop.getElement(); if (el) el.querySelectorAll('.rm-pop-row').forEach(r => r.addEventListener('click', () => { const j = jobs[+r.dataset.i]; emit('pin-click', { job: j && j.job }); pop.remove() })) }, 0)
|
|
})
|
|
const mk = new mapboxgl.Marker({ element: wrap, anchor: 'center' }).setLngLat([+p.lon, +p.lat]).addTo(map)
|
|
const rec = { mk, el: wrap, disc, rid: null, lngLat: [+p.lon, +p.lat], fan: null, members: null, pin: p }
|
|
wrap.addEventListener('mouseenter', () => onEnter(rec))
|
|
wrap.addEventListener('mouseleave', () => onLeave(rec))
|
|
pinMk.push(rec)
|
|
}
|
|
recluster()
|
|
}
|
|
function renderMarkers (routes) {
|
|
clearMarkers()
|
|
const mapboxgl = window.mapboxgl
|
|
for (const r of routes) {
|
|
if (r.home) {
|
|
const wrap = document.createElement('div'); wrap.className = 'rm-mk rm-mk-home'; wrap.dataset.rid = String(r.id)
|
|
const d = document.createElement('div'); d.className = 'rm-home'; d.style.borderColor = r.color; wrap.appendChild(d)
|
|
const mk = new mapboxgl.Marker({ element: wrap, anchor: 'center' }).setLngLat([+r.home.lon, +r.home.lat]).addTo(map)
|
|
wrap.title = r.name + ' — départ'
|
|
// Le point de DÉPART (domicile/dépôt) rejoint aussi l'éclatement : disc = pastille interne (mapbox transforme `wrap`) + survol → fan.
|
|
const rec = { mk, el: wrap, disc: d, rid: r.id, lngLat: [+r.home.lon, +r.home.lat], fan: null, members: null, home: true }
|
|
wrap.addEventListener('mouseenter', () => onEnter(rec))
|
|
wrap.addEventListener('mouseleave', () => onLeave(rec))
|
|
homeMk.push(rec)
|
|
}
|
|
for (const s of (r.stops || [])) {
|
|
// Pastille = ROUND-RECT (pill) : icône du type de job + numéro d'ordre (best practice quand 2 infos ; cf. outils de tournée).
|
|
const wrap = document.createElement('div'); wrap.className = 'rm-mk'; wrap.dataset.rid = String(r.id)
|
|
const disc = document.createElement('div'); disc.className = 'rm-pill'; disc.style.background = r.color
|
|
const ic = /^[a-z0-9_]+$/.test(String(s.icon || '')) ? s.icon : 'place'
|
|
disc.innerHTML = '<span class="material-icons rm-ic">' + ic + '</span><span class="rm-num">' + s.seq + '</span>'
|
|
wrap.appendChild(disc)
|
|
const mk = new mapboxgl.Marker({ element: wrap, anchor: 'center' }).setLngLat([+s.lon, +s.lat]).addTo(map)
|
|
const rec = { mk, el: wrap, disc, rid: r.id, lngLat: [+s.lon, +s.lat], seq: s.seq, tech: r.name, subject: s.subject || '', stop: s, fan: null, members: null }
|
|
wrap.addEventListener('mouseenter', () => onEnter(rec))
|
|
wrap.addEventListener('mouseleave', () => onLeave(rec))
|
|
wrap.addEventListener('click', () => emit('stop-click', rec.stop, rec.rid)) // → la page ouvre la fiche détail complète du ticket
|
|
stopMk.push(rec)
|
|
}
|
|
}
|
|
recluster()
|
|
}
|
|
// Regroupe les pastilles qui se CHEVAUCHENT à l'écran (distance pixel) → calcule pour chacune un décalage en éventail.
|
|
function recluster () {
|
|
// TOUS les types d'icônes partagent l'éclatement au chevauchement : arrêts, gouttes (non assignés), positions GPS live ET départs (domicile/dépôt).
|
|
const all = stopMk.concat(pinMk, liveMk, homeMk)
|
|
if (!map || !all.length) return
|
|
const px = all.map(s => map.project(s.lngLat))
|
|
const parent = all.map((_, i) => i)
|
|
const find = (i) => { while (parent[i] !== i) { parent[i] = parent[parent[i]]; i = parent[i] } return i }
|
|
const TH = 24 // pastille ø24 → chevauchement si centres < ~24 px
|
|
for (let i = 0; i < all.length; i++) for (let j = i + 1; j < all.length; j++) {
|
|
const dx = px[i].x - px[j].x, dy = px[i].y - px[j].y
|
|
if (dx * dx + dy * dy < TH * TH) parent[find(i)] = find(j)
|
|
}
|
|
const groups = {}
|
|
for (let i = 0; i < all.length; i++) { const r = find(i); (groups[r] = groups[r] || []).push(i) }
|
|
for (const s of all) { s.fan = null; s.members = null }
|
|
for (const key in groups) {
|
|
const ids = groups[key]; if (ids.length < 2) continue
|
|
const cx = ids.reduce((a, i) => a + px[i].x, 0) / ids.length
|
|
const cy = ids.reduce((a, i) => a + px[i].y, 0) / ids.length
|
|
const R = 18 + ids.length * 6 // rayon d'éclatement croît avec le nombre
|
|
const members = ids.map(i => all[i])
|
|
ids.forEach((i, k) => {
|
|
const ang = (k / ids.length) * 2 * Math.PI - Math.PI / 2
|
|
const tx = cx + R * Math.cos(ang), ty = cy + R * Math.sin(ang)
|
|
all[i].fan = [tx - px[i].x, ty - px[i].y] // décalage (px) de la pastille par rapport à son point
|
|
all[i].members = members
|
|
})
|
|
}
|
|
}
|
|
let openMembers = null; let clTimer = null
|
|
function fan (members, on) {
|
|
for (const m of members) {
|
|
if (on && m.fan) { m.disc.style.transform = `translate(${m.fan[0]}px, ${m.fan[1]}px)`; m.el.classList.add('fanned') }
|
|
else { m.disc.style.transform = ''; m.el.classList.remove('fanned') }
|
|
}
|
|
}
|
|
function onEnter (rec) {
|
|
if (clTimer) { clearTimeout(clTimer); clTimer = null }
|
|
setActive(rec.rid); rec.el.classList.add('hot')
|
|
if (rec.members && rec.members !== openMembers) { if (openMembers) fan(openMembers, false); fan(rec.members, true); openMembers = rec.members }
|
|
}
|
|
function onLeave (rec) {
|
|
rec.el.classList.remove('hot')
|
|
clTimer = setTimeout(() => { setActive(null); if (openMembers) { fan(openMembers, false); openMembers = null } }, 200)
|
|
}
|
|
|
|
let _lastSig = ''
|
|
async function draw () {
|
|
if (!map || !ready) return
|
|
const seq = ++drawSeq
|
|
const routes = props.routes || []
|
|
const ls = map.getSource('rm-line'); if (!ls) return
|
|
ls.setData({ type: 'FeatureCollection', features: lineFeatures(routes) })
|
|
renderMarkers(routes)
|
|
renderLive(props.live)
|
|
renderPins(props.pins)
|
|
renderTrack(props.track)
|
|
const sig = routes.map(r => r.id + ':' + (r.stops || []).length).join('|') + '#' + (props.pins || []).length
|
|
if (sig !== _lastSig) { _lastSig = sig; try { const b = boundsOf(routes); for (const p of (props.pins || [])) if (okLL(p.lon, p.lat)) b.extend([+p.lon, +p.lat]); 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.
|
|
const results = await Promise.all(routes.map(async r => ({ r, info: await realLine(r) })))
|
|
if (seq !== drawSeq || !map || !map.getSource('rm-line')) return
|
|
const metrics = {}
|
|
const finalLines = results.map(({ r, info }) => {
|
|
if (info) { metrics[r.id] = { km: info.km, mins: info.mins }; return { type: 'Feature', geometry: info.geometry, properties: { color: r.color, id: r.id, rid: r.id } } }
|
|
const coords = []; if (r.home) coords.push([+r.home.lon, +r.home.lat]); for (const s of (r.stops || [])) coords.push([+s.lon, +s.lat])
|
|
return coords.length >= 2 ? { type: 'Feature', geometry: { type: 'LineString', coordinates: coords }, properties: { color: r.color, id: r.id, rid: r.id } } : null
|
|
}).filter(Boolean)
|
|
map.getSource('rm-line').setData({ type: 'FeatureCollection', features: finalLines })
|
|
if (Object.keys(metrics).length) emit('metrics', metrics)
|
|
}
|
|
function fitTo (id) {
|
|
const r = (props.routes || []).find(x => x.id === id); if (!r || !map) return
|
|
try { const b = boundsOf([r]); if (!b.isEmpty()) map.fitBounds(b, { padding: 60, maxZoom: 13, duration: 400 }) } catch (e) {}
|
|
}
|
|
function fitAll () { try { const b = boundsOf(props.routes || []); if (!b.isEmpty()) map.fitBounds(b, { padding: 50, maxZoom: 13, duration: 400 }) } catch (e) {} }
|
|
// Tournée ACTIVE (survol carte ou chip) : ligne épaissie (couche GL filtrée) + ses marqueurs passent au 1er plan.
|
|
let _active = null
|
|
function setActive (rid) {
|
|
if (!map || !ready || _active === rid) return
|
|
_active = rid || null
|
|
try { map.setFilter('rm-line-a', ['==', ['get', 'rid'], _active == null ? '___aucun___' : _active]) } catch (e) {}
|
|
for (const m of stopMk) m.el.classList.toggle('route-hot', _active != null && m.rid === _active)
|
|
for (const m of homeMk) m.el.classList.toggle('route-hot', _active != null && m.rid === _active)
|
|
}
|
|
defineExpose({ fitTo, fitAll, setActive })
|
|
|
|
onMounted(async () => {
|
|
if (!el) return
|
|
const mapboxgl = await ensureMapbox(); if (!mapboxgl || !el) return
|
|
map = new mapboxgl.Map({ container: el, style: basemapStyle('light'), center: [-73.55, 45.2], zoom: 8 })
|
|
map.addControl(new mapboxgl.NavigationControl({ showCompass: false }), 'top-right')
|
|
ro = new ResizeObserver(() => { if (map) map.resize() }); ro.observe(el)
|
|
map.on('load', () => {
|
|
map.resize()
|
|
addSatelliteLayer(map, satellitePref.value) // couche satellite (sous les tracés + marqueurs), masquée par défaut
|
|
map.addSource('rm-line', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } })
|
|
map.addLayer({ id: 'rm-line', type: 'line', source: 'rm-line', layout: { 'line-join': 'round', 'line-cap': 'round' }, paint: { 'line-color': ['get', 'color'], 'line-width': 3, 'line-opacity': 0.7 } })
|
|
map.addLayer({ id: 'rm-line-a', type: 'line', source: 'rm-line', filter: ['==', ['get', 'rid'], '___aucun___'], layout: { 'line-join': 'round', 'line-cap': 'round' }, paint: { 'line-color': ['get', 'color'], 'line-width': 5.5, 'line-opacity': 0.95 } })
|
|
// Tracé GPS RÉEL (Traccar) d'un seul tech — orange, AU-DESSUS des tournées prévues (halo + trait) → « où il est réellement passé ».
|
|
map.addSource('rm-track', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } })
|
|
map.addLayer({ id: 'rm-track-halo', type: 'line', source: 'rm-track', layout: { 'line-join': 'round', 'line-cap': 'round' }, paint: { 'line-color': '#ff6f00', 'line-width': 9, 'line-opacity': 0.22 } })
|
|
map.addLayer({ id: 'rm-track-l', type: 'line', source: 'rm-track', layout: { 'line-join': 'round', 'line-cap': 'round' }, paint: { 'line-color': '#ef6c00', 'line-width': 3.5, 'line-opacity': 0.85 } })
|
|
map.on('mousemove', 'rm-line', (e) => { const f = e.features && e.features[0]; if (f) setActive(f.properties.rid) })
|
|
map.on('mouseleave', 'rm-line', () => setActive(null))
|
|
map.on('movestart', () => { if (openMembers) { fan(openMembers, false); openMembers = null } }) // décalages px périmés au déplacement
|
|
map.on('moveend', recluster)
|
|
ready = true
|
|
draw()
|
|
})
|
|
})
|
|
onBeforeUnmount(() => { clearMarkers(); clearLive(); clearPins(); 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 (+ recluster interne) sans redessiner les tournées
|
|
watch(() => props.pins, () => { renderPins(props.pins) }, { deep: true }) // jobs non assignés (secteur) sans redessiner les tournées
|
|
watch(() => props.track, () => { renderTrack(props.track) }, { deep: true }) // tracé GPS réel (1 tech) sans redessiner les tournées
|
|
// Satellite : bouton local + préférence PARTAGÉE (satellitePref) → toutes les cartes suivent.
|
|
function toggleSat () { setSatellitePref(!satellitePref.value); if (map) setSatelliteVisible(map, satellitePref.value) }
|
|
watch(satellitePref, (v) => { if (map) setSatelliteVisible(map, v) })
|
|
</script>
|
|
|
|
<template>
|
|
<div class="rm-wrap" :style="{ height }">
|
|
<div :ref="setEl" class="route-map" style="height:100%" />
|
|
<q-btn round dense size="sm" class="rm-sat-btn" :color="satellitePref ? 'primary' : 'grey-9'" text-color="white" :icon="satellitePref ? 'map' : 'satellite_alt'" @click="toggleSat"><q-tooltip>{{ satellitePref ? 'Vue carte' : 'Vue satellite' }}</q-tooltip></q-btn>
|
|
</div>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.rm-wrap { position: relative; }
|
|
.route-map { border-radius: 8px; overflow: hidden; border: 1px solid #cfd8dc; }
|
|
.rm-sat-btn { position: absolute; top: 8px; left: 8px; z-index: 3; }
|
|
</style>
|
|
|
|
<!-- NON scoped : les marqueurs sont créés en JS et injectés dans le conteneur Mapbox (hors de l'arbre scoped). Préfixe rm- = pas de collision. -->
|
|
<style>
|
|
.rm-mk { cursor: pointer; will-change: transform; }
|
|
.rm-mk .rm-pill {
|
|
display: inline-flex; align-items: center; gap: 3px; box-sizing: border-box;
|
|
height: 22px; padding: 0 7px 0 5px; border-radius: 11px; white-space: nowrap;
|
|
color: #fff; border: 2px solid #fff; box-shadow: 0 1px 3px rgba(0,0,0,.45);
|
|
transition: transform .18s cubic-bezier(.2,.8,.3,1), box-shadow .15s;
|
|
}
|
|
.rm-mk .rm-pill .rm-ic { font-size: 14px; line-height: 1; opacity: .95; }
|
|
.rm-mk .rm-pill .rm-num { font-size: 12px; font-weight: 800; line-height: 1; }
|
|
.rm-mk .rm-pill .rm-time { font-size: 10.5px; font-weight: 700; line-height: 1; padding-left: 2px; white-space: nowrap; }
|
|
/* Popup « jobs à cette adresse » (tap mobile) — bloc <style> non-scopé, s'applique au popup mapbox global */
|
|
.rm-pop { font-size: 12px; min-width: 170px; max-width: 240px; max-height: 44vh; overflow-y: auto; }
|
|
.rm-pop-hd { font-weight: 700; color: #1e293b; margin-bottom: 4px; }
|
|
.rm-pop-row { display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 4px 2px; border-top: 1px solid #eef1f5; cursor: pointer; }
|
|
.rm-pop-row:hover { background: #eef2ff; }
|
|
.rm-pop-s { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
.rm-pop-est { color: #64748b; font-variant-numeric: tabular-nums; flex-shrink: 0; }
|
|
.rm-mk .rm-home { width: 15px; height: 15px; border-radius: 50%; box-sizing: border-box; background: #fff; border: 3px solid #888; box-shadow: 0 1px 3px rgba(0,0,0,.4); transition: transform .18s cubic-bezier(.2,.8,.3,1); }
|
|
.rm-mk.hot { z-index: 7; }
|
|
.rm-mk.hot .rm-pill { transform: scale(1.15); box-shadow: 0 3px 9px rgba(0,0,0,.55); }
|
|
.rm-mk.route-hot { z-index: 5; }
|
|
.rm-mk.fanned { z-index: 6; }
|
|
.rm-mk.fanned .rm-pill { box-shadow: 0 3px 10px rgba(0,0,0,.55); }
|
|
/* Position GPS LIVE d'un tech : pastille ronde à initiales + halo pulsé (distincte des pastilles d'arrêt). */
|
|
.rm-live { position: relative; width: 0; height: 0; z-index: 8; pointer-events: auto; }
|
|
.rm-live.fanned { z-index: 9; } /* éclatée au chevauchement → passe devant arrêts/gouttes */
|
|
.rm-live-in { position: absolute; width: 0; height: 0; transition: transform .18s cubic-bezier(.2,.8,.3,1); } /* élément translaté par l'éventail */
|
|
.rm-live .rm-live-dot { position: absolute; transform: translate(-50%, -50%); width: 28px; height: 28px; border-radius: 50%; border: 2.5px solid #fff; box-shadow: 0 2px 6px rgba(0,0,0,.5); color: #fff; font-size: 10.5px; font-weight: 800; display: flex; align-items: center; justify-content: center; z-index: 2; cursor: default; }
|
|
.rm-live .rm-live-pulse { position: absolute; width: 28px; height: 28px; border-radius: 50%; opacity: .45; z-index: 1; animation: rm-live-pulse 1.8s ease-out infinite; }
|
|
@keyframes rm-live-pulse { 0% { transform: translate(-50%,-50%) scale(1); opacity: .45; } 100% { transform: translate(-50%,-50%) scale(2.8); opacity: 0; } }
|
|
.rm-live.stale .rm-live-pulse { display: none; }
|
|
.rm-live.stale .rm-live-dot { opacity: .55; filter: grayscale(.5); }
|
|
/* Jobs NON assignés (goutte, couleur = priorité) — à situer/répartir */
|
|
.rm-pin { position: relative; width: 0; height: 0; z-index: 7; cursor: pointer; pointer-events: auto; }
|
|
.rm-pin-drop { position: absolute; left: -9px; bottom: 0; width: 18px; height: 18px; border-radius: 50% 50% 50% 0; transform: rotate(-45deg); border: 2px solid #fff; box-shadow: 0 2px 5px rgba(0, 0, 0, .4); }
|
|
.rm-pin:hover .rm-pin-drop { filter: brightness(1.12); }
|
|
</style>
|