diff --git a/apps/ops/src/components/shared/RouteMap.vue b/apps/ops/src/components/shared/RouteMap.vue index 5b2df42..0717665 100644 --- a/apps/ops/src/components/shared/RouteMap.vue +++ b/apps/ops/src/components/shared/RouteMap.vue @@ -3,12 +3,13 @@ * 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}] }] - * - trace INSTANTANÉMENT des segments droits, puis remplace par la géométrie ROUTIÈRE réelle - * (OSRM auto-hébergé via hub /roster/osrm-route, cache module partagé entre instances) - * - émet 'metrics' { id: { km, mins } } quand les routes réelles arrivent (pour les légendes) - * - expose fitTo(id) (zoom sur une tournée) et fitAll() + * - 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 { ref, watch, onMounted, onBeforeUnmount } from 'vue' +import { watch, onMounted, onBeforeUnmount } from 'vue' import { MAPBOX_TOKEN } from 'src/config/erpnext' import * as roster from 'src/api/roster' @@ -18,10 +19,11 @@ const props = defineProps({ }) const emit = defineEmits(['metrics']) -const el = ref(null) -let map = null; let ro = null; let ready = false; let drawSeq = 0 +let el = null; let map = null; let ro = null; let ready = false; let drawSeq = 0 +let stopMk = []; let homeMk = [] // marqueurs HTML (arrêts + domiciles) 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 new Promise((resolve) => { @@ -40,16 +42,15 @@ function boundsOf (routes) { for (const r of routes) { if (r.home) b.extend([+r.home.lon, +r.home.lat]); for (const s of (r.stops || [])) b.extend([+s.lon, +s.lat]) } return b } -function straightData (routes) { - const lines = []; const pts = []; let k = 0 +function lineFeatures (routes) { + const lines = [] for (const r of routes) { const coords = [] - if (r.home) { coords.push([+r.home.lon, +r.home.lat]); pts.push({ type: 'Feature', geometry: { type: 'Point', coordinates: [+r.home.lon, +r.home.lat] }, properties: { kind: 'home', color: r.color, rid: r.id } }) } - // k = clé de superposition : cercle du DESSUS (circle-sort-key haut) == numéro AFFICHÉ (symbol-sort-key bas gagne le placement) - for (const s of (r.stops || [])) { coords.push([+s.lon, +s.lat]); pts.push({ type: 'Feature', geometry: { type: 'Point', coordinates: [+s.lon, +s.lat] }, properties: { kind: 'stop', color: r.color, seq: String(s.seq), tech: r.name, subject: s.subject || '', rid: r.id, k: k++ } }) } + if (r.home) coords.push([+r.home.lon, +r.home.lat]) + for (const s of (r.stops || [])) coords.push([+s.lon, +s.lat]) 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, pts } + return lines } async function realLine (r) { // géométrie routière réelle d'une tournée (OSRM via hub), cache par signature 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]) @@ -63,18 +64,93 @@ async function realLine (r) { // géométrie routière réelle d'une tournée (O _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 = [] } +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' + homeMk.push({ mk, el: wrap, rid: r.id }) + } + for (const s of (r.stops || [])) { + const wrap = document.createElement('div'); wrap.className = 'rm-mk'; wrap.dataset.rid = String(r.id) + const disc = document.createElement('div'); disc.className = 'rm-disc'; disc.style.background = r.color; disc.textContent = String(s.seq) + 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 || '', fan: null, members: null } + wrap.addEventListener('mouseenter', () => onEnter(rec)) + wrap.addEventListener('mouseleave', () => onLeave(rec)) + wrap.addEventListener('click', () => openPopup(rec)) + stopMk.push(rec) + } + } + recluster() +} +function openPopup (rec) { + new window.mapboxgl.Popup({ offset: 14 }).setLngLat(rec.lngLat) + .setHTML(`
${esc(rec.tech)} · arrêt ${esc(rec.seq)}
${esc(rec.subject)}
`).addTo(map) +} +// Regroupe les pastilles qui se CHEVAUCHENT à l'écran (distance pixel) → calcule pour chacune un décalage en éventail. +function recluster () { + if (!map || !stopMk.length) return + const px = stopMk.map(s => map.project(s.lngLat)) + const parent = stopMk.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 < stopMk.length; i++) for (let j = i + 1; j < stopMk.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 < stopMk.length; i++) { const r = find(i); (groups[r] = groups[r] || []).push(i) } + for (const s of stopMk) { 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 => stopMk[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) + stopMk[i].fan = [tx - px[i].x, ty - px[i].y] // décalage (px) de la pastille par rapport à son point + stopMk[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 { lines, pts } = straightData(routes) - const ls = map.getSource('rm-line'); const ps = map.getSource('rm-pt') - if (!ls || !ps) return - ls.setData({ type: 'FeatureCollection', features: lines }) - ps.setData({ type: 'FeatureCollection', features: pts }) + const ls = map.getSource('rm-line'); if (!ls) return + ls.setData({ type: 'FeatureCollection', features: lineFeatures(routes) }) + renderMarkers(routes) 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: 50, maxZoom: 13, duration: 400 }) } catch (e) {} } + 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. const results = await Promise.all(routes.map(async r => ({ r, info: await realLine(r) }))) if (seq !== drawSeq || !map || !map.getSource('rm-line')) return @@ -92,58 +168,63 @@ function fitTo (id) { 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) : ses ligne/arrêts/numéros passent AU PREMIER PLAN via des couches filtrées au-dessus. -let _active = null; let _clearTimer = null +// 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 - const v = _active == null ? '___aucun___' : _active - try { - map.setFilter('rm-line-a', ['==', ['get', 'rid'], v]) - map.setFilter('rm-stop-a', ['all', ['==', ['get', 'kind'], 'stop'], ['==', ['get', 'rid'], v]]) - map.setFilter('rm-seq-a', ['all', ['==', ['get', 'kind'], 'stop'], ['==', ['get', 'rid'], v]]) - } catch (e) {} + 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 (!MAPBOX_TOKEN || !el.value) return - const mapboxgl = await ensureMapbox(); if (!mapboxgl || !el.value) return + if (!MAPBOX_TOKEN || !el) return + const mapboxgl = await ensureMapbox(); if (!mapboxgl || !el) return mapboxgl.accessToken = MAPBOX_TOKEN - map = new mapboxgl.Map({ container: el.value, style: 'mapbox://styles/mapbox/streets-v12', center: [-73.55, 45.2], zoom: 8 }) + map = new mapboxgl.Map({ container: el, style: 'mapbox://styles/mapbox/streets-v12', 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.value) + ro = new ResizeObserver(() => { if (map) map.resize() }); ro.observe(el) map.on('load', () => { map.resize() 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.75 } }) - map.addSource('rm-pt', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } }) - map.addLayer({ id: 'rm-home', type: 'circle', source: 'rm-pt', filter: ['==', ['get', 'kind'], 'home'], paint: { 'circle-radius': 7, 'circle-color': '#fff', 'circle-stroke-color': ['get', 'color'], 'circle-stroke-width': 3 } }) - // TRI de superposition : cercle du DESSUS (circle-sort-key haut) == numéro AFFICHÉ (symbol-sort-key bas gagne le placement, - // allow-overlap:false ÉLIMINE le numéro du pin d'en dessous au lieu de le dessiner par-dessus le voisin). - map.addLayer({ id: 'rm-stop', type: 'circle', source: 'rm-pt', filter: ['==', ['get', 'kind'], 'stop'], layout: { 'circle-sort-key': ['get', 'k'] }, paint: { 'circle-radius': 10, 'circle-color': ['get', 'color'], 'circle-stroke-color': '#fff', 'circle-stroke-width': 2 } }) - map.addLayer({ id: 'rm-seq', type: 'symbol', source: 'rm-pt', filter: ['==', ['get', 'kind'], 'stop'], layout: { 'text-field': ['get', 'seq'], 'text-size': 11, 'text-font': ['DIN Offc Pro Bold', 'Arial Unicode MS Bold'], 'text-allow-overlap': false, 'text-padding': 1, 'symbol-sort-key': ['*', -1, ['get', 'k']] }, paint: { 'text-color': '#fff' } }) - // Couches ACTIVES (survol) : la tournée survolée passe AU PREMIER PLAN (ligne épaissie + arrêts + TOUS ses numéros). + 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 } }) - map.addLayer({ id: 'rm-stop-a', type: 'circle', source: 'rm-pt', filter: ['all', ['==', ['get', 'kind'], 'stop'], ['==', ['get', 'rid'], '___aucun___']], layout: { 'circle-sort-key': ['get', 'k'] }, paint: { 'circle-radius': 11, 'circle-color': ['get', 'color'], 'circle-stroke-color': '#fff', 'circle-stroke-width': 2.5 } }) - map.addLayer({ id: 'rm-seq-a', type: 'symbol', source: 'rm-pt', filter: ['all', ['==', ['get', 'kind'], 'stop'], ['==', ['get', 'rid'], '___aucun___']], layout: { 'text-field': ['get', 'seq'], 'text-size': 11.5, 'text-font': ['DIN Offc Pro Bold', 'Arial Unicode MS Bold'], 'text-allow-overlap': true }, paint: { 'text-color': '#fff' } }) - const popup = (e) => { const p = e.features[0].properties; new window.mapboxgl.Popup({ offset: 12 }).setLngLat(e.features[0].geometry.coordinates).setHTML(`
${esc(p.tech)} · arrêt ${esc(p.seq)}
${esc(p.subject)}
`).addTo(map) } - const hoverOn = (e) => { const f = e.features && e.features[0]; if (f) { if (_clearTimer) { clearTimeout(_clearTimer); _clearTimer = null } setActive(f.properties.rid); map.getCanvas().style.cursor = 'pointer' } } - const hoverOff = () => { map.getCanvas().style.cursor = ''; if (_clearTimer) clearTimeout(_clearTimer); _clearTimer = setTimeout(() => setActive(null), 160) } - for (const layer of ['rm-stop', 'rm-line', 'rm-stop-a', 'rm-line-a']) { map.on('mousemove', layer, hoverOn); map.on('mouseleave', layer, hoverOff) } - map.on('click', 'rm-stop', popup); map.on('click', 'rm-stop-a', popup) + 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(() => { if (ro) { try { ro.disconnect() } catch (e) {} ro = null } if (map) { try { map.remove() } catch (e) {} map = null } ready = false }) +onBeforeUnmount(() => { clearMarkers(); 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 }) + + +