fix(routemap): HTML markers (number inside disc) + fan-out overlapping pins on hover

Root cause of the bleeding numbers: Mapbox renders ALL circles then ALL
number-symbols in separate passes, so a number can never sit at its
disc's z-level — a hidden pin's number floats over a visible neighbor.
Sort keys can't fix cross-layer stacking.

Fix: stops + homes are now HTML mapboxgl.Marker elements — the number
is a text node INSIDE the colored disc (one element, same level →
correct occlusion, no orphan numbers). Routes stay GL line layers.

- Hover a pin → it scales up (.hot) and its whole route comes to front
  (GL rm-line-a filter + .route-hot on same-rid markers).
- Overlapping pins EXPLODE on hover: recluster() unions markers within
  24px (projected), computes a radial fan offset per member; entering
  any cluster member translates all members' discs outward (CSS
  transition) so all are readable/clickable; collapses 200ms after
  leave; clusters recompute on moveend, collapse on movestart.

Verified: 43 numbered discs + 16 home markers; an 8-pin stack near
Huntingdon fans out on hover into individually readable pins; distinct
golden-angle colors preserved.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
louispaulb 2026-07-02 20:05:40 -04:00
parent f33cfbc7ca
commit d5c6240c6e

View File

@ -3,12 +3,13 @@
* RouteMap carte de TOURNÉES réutilisable (revue du dispatch auto, onglet « Tournées », etc.). * 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}] }] * 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 * - LIGNES = couches GL (tracé instantané droit géométrie ROUTIÈRE réelle OSRM via hub, cache module partagé)
* (OSRM auto-hébergé via hub /roster/osrm-route, cache module partagé entre instances) * - ARRÊTS = MARQUEURS HTML (le numéro vit DANS la pastille = même élément, même niveau jamais de chiffre orphelin
* - émet 'metrics' { id: { km, mins } } quand les routes réelles arrivent (pour les légendes) * par-dessus un voisin ; occlusion correcte). Au survol : la pastille passe au 1er plan ET si des pastilles se
* - expose fitTo(id) (zoom sur une tournée) et fitAll() * 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 { MAPBOX_TOKEN } from 'src/config/erpnext'
import * as roster from 'src/api/roster' import * as roster from 'src/api/roster'
@ -18,10 +19,11 @@ const props = defineProps({
}) })
const emit = defineEmits(['metrics']) const emit = defineEmits(['metrics'])
const el = ref(null) let el = null; let map = null; let ro = null; let ready = false; let drawSeq = 0
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 } 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 RouteMapCache () { const g = globalThis; if (!g.__opsRouteMapCache) g.__opsRouteMapCache = new Map(); return g.__opsRouteMapCache }
function setEl (node) { el = node }
function ensureMapbox () { function ensureMapbox () {
return new Promise((resolve) => { 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]) } 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 return b
} }
function straightData (routes) { function lineFeatures (routes) {
const lines = []; const pts = []; let k = 0 const lines = []
for (const r of routes) { for (const r of routes) {
const coords = [] 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 } }) } if (r.home) coords.push([+r.home.lon, +r.home.lat])
// 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])
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 (coords.length >= 2) lines.push({ type: 'Feature', geometry: { type: 'LineString', coordinates: coords }, properties: { color: r.color, id: r.id, rid: r.id } }) 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 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]) 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 _rmCache.set(sig, info); return info
} catch (e) { return null } } 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(`<div style="font-size:12px"><b>${esc(rec.tech)}</b> · arrêt ${esc(rec.seq)}<br>${esc(rec.subject)}</div>`).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 = '' let _lastSig = ''
async function draw () { async function draw () {
if (!map || !ready) return if (!map || !ready) return
const seq = ++drawSeq const seq = ++drawSeq
const routes = props.routes || [] const routes = props.routes || []
const { lines, pts } = straightData(routes) const ls = map.getSource('rm-line'); if (!ls) return
const ls = map.getSource('rm-line'); const ps = map.getSource('rm-pt') ls.setData({ type: 'FeatureCollection', features: lineFeatures(routes) })
if (!ls || !ps) return renderMarkers(routes)
ls.setData({ type: 'FeatureCollection', features: lines })
ps.setData({ type: 'FeatureCollection', features: pts })
const sig = routes.map(r => r.id + ':' + (r.stops || []).length).join('|') 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. // 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) }))) const results = await Promise.all(routes.map(async r => ({ r, info: await realLine(r) })))
if (seq !== drawSeq || !map || !map.getSource('rm-line')) return 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) {} 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) {} } 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. // Tournée ACTIVE (survol carte ou chip) : ligne épaissie (couche GL filtrée) + ses marqueurs passent au 1er plan.
let _active = null; let _clearTimer = null let _active = null
function setActive (rid) { function setActive (rid) {
if (!map || !ready || _active === rid) return if (!map || !ready || _active === rid) return
_active = rid || null _active = rid || null
const v = _active == null ? '___aucun___' : _active try { map.setFilter('rm-line-a', ['==', ['get', 'rid'], _active == null ? '___aucun___' : _active]) } catch (e) {}
try { for (const m of stopMk) m.el.classList.toggle('route-hot', _active != null && m.rid === _active)
map.setFilter('rm-line-a', ['==', ['get', 'rid'], v]) for (const m of homeMk) m.el.classList.toggle('route-hot', _active != null && m.rid === _active)
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) {}
} }
defineExpose({ fitTo, fitAll, setActive }) defineExpose({ fitTo, fitAll, setActive })
onMounted(async () => { onMounted(async () => {
if (!MAPBOX_TOKEN || !el.value) return if (!MAPBOX_TOKEN || !el) return
const mapboxgl = await ensureMapbox(); if (!mapboxgl || !el.value) return const mapboxgl = await ensureMapbox(); if (!mapboxgl || !el) return
mapboxgl.accessToken = MAPBOX_TOKEN 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') 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.on('load', () => {
map.resize() map.resize()
map.addSource('rm-line', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } }) 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.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.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-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-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.on('mousemove', 'rm-line', (e) => { const f = e.features && e.features[0]; if (f) setActive(f.properties.rid) })
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' } }) map.on('mouseleave', 'rm-line', () => setActive(null))
const popup = (e) => { const p = e.features[0].properties; new window.mapboxgl.Popup({ offset: 12 }).setLngLat(e.features[0].geometry.coordinates).setHTML(`<div style="font-size:12px"><b>${esc(p.tech)}</b> · arrêt ${esc(p.seq)}<br>${esc(p.subject)}</div>`).addTo(map) } map.on('movestart', () => { if (openMembers) { fan(openMembers, false); openMembers = null } }) // décalages px périmés au déplacement
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' } } map.on('moveend', recluster)
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)
ready = true ready = true
draw() 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 }) watch(() => props.routes, () => { draw() }, { deep: true })
</script> </script>
<template> <template>
<div ref="el" class="route-map" :style="{ height }" /> <div :ref="setEl" class="route-map" :style="{ height }" />
</template> </template>
<style scoped> <style scoped>
.route-map { border-radius: 8px; overflow: hidden; border: 1px solid #cfd8dc; } .route-map { border-radius: 8px; overflow: hidden; border: 1px solid #cfd8dc; }
</style> </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-disc {
width: 24px; height: 24px; border-radius: 50%; box-sizing: border-box;
display: flex; align-items: center; justify-content: center;
color: #fff; font-size: 11px; font-weight: 800; line-height: 1;
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-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); }
.rm-mk.hot { z-index: 7; }
.rm-mk.hot .rm-disc { transform: scale(1.2); 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-disc { box-shadow: 0 3px 10px rgba(0,0,0,.55); }
</style>