fix(routemap): readable overlapping pins + hover brings a tech's route to front

Overlap fix: stop circles get circle-sort-key (k) and numbers get
symbol-sort-key (-k) with text-allow-overlap:false — the TOP circle and
the DISPLAYED number are now the same feature; the underneath pin's
number is culled instead of bleeding over its neighbor.

Hover-to-front: three "active" layers (line/stops/numbers, thicker line,
all numbers shown) sit above everything, filtered by rid; mousemove on a
route/stop (or hovering a tech chip / legend item) calls setActive(rid)
→ the whole tournée pops to the foreground; cleared 160ms after leave.
Exposed setActive() alongside fitTo/fitAll. Benefits both maps (review +
Tournées) since it's the shared RouteMap.

Verified: Houssam's magenta route renders on top on chip hover; no more
double numbers at overlapping stops.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
louispaulb 2026-07-02 19:31:45 -04:00
parent c3f1c77164
commit f33cfbc7ca
2 changed files with 35 additions and 14 deletions

View File

@ -41,12 +41,13 @@ function boundsOf (routes) {
return b return b
} }
function straightData (routes) { function straightData (routes) {
const lines = []; const pts = [] const lines = []; const pts = []; let k = 0
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 } }) } 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 } }) }
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 || '' } }) } // k = clé de superposition : cercle du DESSUS (circle-sort-key haut) == numéro AFFICHÉ (symbol-sort-key bas gagne le placement)
if (coords.length >= 2) lines.push({ type: 'Feature', geometry: { type: 'LineString', coordinates: coords }, properties: { color: r.color, id: r.id } }) 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 } })
} }
return { lines, pts } return { lines, pts }
} }
@ -79,9 +80,9 @@ async function draw () {
if (seq !== drawSeq || !map || !map.getSource('rm-line')) return if (seq !== drawSeq || !map || !map.getSource('rm-line')) return
const metrics = {} const metrics = {}
const finalLines = results.map(({ r, info }) => { 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 } } } 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]) 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 } } : null return coords.length >= 2 ? { type: 'Feature', geometry: { type: 'LineString', coordinates: coords }, properties: { color: r.color, id: r.id, rid: r.id } } : null
}).filter(Boolean) }).filter(Boolean)
map.getSource('rm-line').setData({ type: 'FeatureCollection', features: finalLines }) map.getSource('rm-line').setData({ type: 'FeatureCollection', features: finalLines })
if (Object.keys(metrics).length) emit('metrics', metrics) if (Object.keys(metrics).length) emit('metrics', metrics)
@ -91,7 +92,19 @@ 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) {} }
defineExpose({ fitTo, fitAll }) // 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
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) {}
}
defineExpose({ fitTo, fitAll, setActive })
onMounted(async () => { onMounted(async () => {
if (!MAPBOX_TOKEN || !el.value) return if (!MAPBOX_TOKEN || !el.value) return
@ -106,11 +119,19 @@ onMounted(async () => {
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.75 } })
map.addSource('rm-pt', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } }) 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 } }) 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 } })
map.addLayer({ id: 'rm-stop', type: 'circle', source: 'rm-pt', filter: ['==', ['get', 'kind'], 'stop'], paint: { 'circle-radius': 10, 'circle-color': ['get', 'color'], 'circle-stroke-color': '#fff', 'circle-stroke-width': 2 } }) // TRI de superposition : cercle du DESSUS (circle-sort-key haut) == numéro AFFICHÉ (symbol-sort-key bas gagne le placement,
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': true }, paint: { 'text-color': '#fff' } }) // allow-overlap:false ÉLIMINE le numéro du pin d'en dessous au lieu de le dessiner par-dessus le voisin).
map.on('click', 'rm-stop', (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.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.on('mouseenter', 'rm-stop', () => { map.getCanvas().style.cursor = 'pointer' }) 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' } })
map.on('mouseleave', 'rm-stop', () => { map.getCanvas().style.cursor = '' }) // 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-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(`<div style="font-size:12px"><b>${esc(p.tech)}</b> · arrêt ${esc(p.seq)}<br>${esc(p.subject)}</div>`).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)
ready = true ready = true
draw() draw()
}) })

View File

@ -482,7 +482,7 @@
</div> </div>
<!-- Chips techs : enlever/ajouter une tournée de la carte d'un clic pastille = nb de jobs, dans la couleur du tech --> <!-- Chips techs : enlever/ajouter une tournée de la carte d'un clic pastille = nb de jobs, dans la couleur du tech -->
<div class="row items-center q-mb-xs" style="gap:5px;flex-wrap:wrap"> <div class="row items-center q-mb-xs" style="gap:5px;flex-wrap:wrap">
<span v-for="r in allDayRoutes" :key="'chip' + r.id" class="rt-chip" :class="{ off: hiddenRouteTechs.has(r.id) }" :style="hiddenRouteTechs.has(r.id) ? {} : { borderColor: r.color, background: r.color + '14' }" @click="toggleRouteTech(r.id)"> <span v-for="r in allDayRoutes" :key="'chip' + r.id" class="rt-chip" :class="{ off: hiddenRouteTechs.has(r.id) }" :style="hiddenRouteTechs.has(r.id) ? {} : { borderColor: r.color, background: r.color + '14' }" @click="toggleRouteTech(r.id)" @mouseenter="routesMapRef && routesMapRef.setActive(r.id)" @mouseleave="routesMapRef && routesMapRef.setActive(null)">
<span class="rt-dot" :style="{ background: hiddenRouteTechs.has(r.id) ? '#cbd5e1' : r.color }"></span>{{ r.name }} <span class="rt-dot" :style="{ background: hiddenRouteTechs.has(r.id) ? '#cbd5e1' : r.color }"></span>{{ r.name }}
<span class="rt-n" :style="{ background: hiddenRouteTechs.has(r.id) ? '#94a3b8' : r.color }">{{ r.stops.length }}</span> <span class="rt-n" :style="{ background: hiddenRouteTechs.has(r.id) ? '#94a3b8' : r.color }">{{ r.stops.length }}</span>
<q-tooltip>{{ hiddenRouteTechs.has(r.id) ? 'Cliquer pour RÉAFFICHER cette tournée' : 'Cliquer pour retirer cette tournée de la carte' }}</q-tooltip> <q-tooltip>{{ hiddenRouteTechs.has(r.id) ? 'Cliquer pour RÉAFFICHER cette tournée' : 'Cliquer pour retirer cette tournée de la carte' }}</q-tooltip>
@ -490,7 +490,7 @@
</div> </div>
<RouteMap ref="routesMapRef" :routes="dayRoutes" height="62vh" @metrics="m => Object.assign(dayRouteMetrics, m)" /> <RouteMap ref="routesMapRef" :routes="dayRoutes" height="62vh" @metrics="m => Object.assign(dayRouteMetrics, m)" />
<div class="suggest-legend q-mt-xs"> <div class="suggest-legend q-mt-xs">
<span v-for="r in dayRoutes" :key="r.id" class="suggest-leg" style="cursor:pointer" @click="routesMapRef && routesMapRef.fitTo(r.id)"> <span v-for="r in dayRoutes" :key="r.id" class="suggest-leg" style="cursor:pointer" @click="routesMapRef && routesMapRef.fitTo(r.id)" @mouseenter="routesMapRef && routesMapRef.setActive(r.id)" @mouseleave="routesMapRef && routesMapRef.setActive(null)">
<span class="suggest-leg-dot" :style="{ background: r.color }"></span>{{ r.name }} <span class="suggest-leg-dot" :style="{ background: r.color }"></span>{{ r.name }}
<q-icon :name="techHomes[r.id] ? 'home' : 'business'" size="13px" :color="techHomes[r.id] ? 'teal-7' : 'blue-grey-4'" style="cursor:pointer" @click.stop="openHomePicker({ id: r.id, name: r.name })"><q-tooltip>Départ : {{ techHomes[r.id] ? 'domicile' : 'bureau TARGO (défaut)' }} cliquer pour définir/modifier</q-tooltip></q-icon> <q-icon :name="techHomes[r.id] ? 'home' : 'business'" size="13px" :color="techHomes[r.id] ? 'teal-7' : 'blue-grey-4'" style="cursor:pointer" @click.stop="openHomePicker({ id: r.id, name: r.name })"><q-tooltip>Départ : {{ techHomes[r.id] ? 'domicile' : 'bureau TARGO (défaut)' }} cliquer pour définir/modifier</q-tooltip></q-icon>
· {{ r.stops.length }} arrêt(s)<span v-if="dayRouteMetrics[r.id]"> · 🚗 {{ dayRouteMetrics[r.id].km }} km / {{ dayRouteMetrics[r.id].mins }} min</span> · {{ r.stops.length }} arrêt(s)<span v-if="dayRouteMetrics[r.id]"> · 🚗 {{ dayRouteMetrics[r.id].km }} km / {{ dayRouteMetrics[r.id].mins }} min</span>