From f33cfbc7ca7caf090fe0a1f2419d1b135d65fac3 Mon Sep 17 00:00:00 2001 From: louispaulb Date: Thu, 2 Jul 2026 19:31:45 -0400 Subject: [PATCH] fix(routemap): readable overlapping pins + hover brings a tech's route to front MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- apps/ops/src/components/shared/RouteMap.vue | 45 +++++++++++++++------ apps/ops/src/pages/PlanificationPage.vue | 4 +- 2 files changed, 35 insertions(+), 14 deletions(-) diff --git a/apps/ops/src/components/shared/RouteMap.vue b/apps/ops/src/components/shared/RouteMap.vue index c58e0b5..5b2df42 100644 --- a/apps/ops/src/components/shared/RouteMap.vue +++ b/apps/ops/src/components/shared/RouteMap.vue @@ -41,12 +41,13 @@ function boundsOf (routes) { return b } function straightData (routes) { - const lines = []; const pts = [] + const lines = []; const pts = []; let k = 0 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 } }) } - 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 || '' } }) } - if (coords.length >= 2) lines.push({ type: 'Feature', geometry: { type: 'LineString', coordinates: coords }, properties: { color: r.color, id: r.id } }) + 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 (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 } } @@ -79,9 +80,9 @@ async function draw () { 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 } } } + 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 } } : 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) map.getSource('rm-line').setData({ type: 'FeatureCollection', features: finalLines }) 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) {} } 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 () => { 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.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-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 } }) - 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' } }) - 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(`
${esc(p.tech)} · arrêt ${esc(p.seq)}
${esc(p.subject)}
`).addTo(map) }) - map.on('mouseenter', 'rm-stop', () => { map.getCanvas().style.cursor = 'pointer' }) - map.on('mouseleave', 'rm-stop', () => { map.getCanvas().style.cursor = '' }) + // 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-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) ready = true draw() }) diff --git a/apps/ops/src/pages/PlanificationPage.vue b/apps/ops/src/pages/PlanificationPage.vue index 65bf6e2..caa002c 100644 --- a/apps/ops/src/pages/PlanificationPage.vue +++ b/apps/ops/src/pages/PlanificationPage.vue @@ -482,7 +482,7 @@
- + {{ r.name }} {{ r.stops.length }} {{ hiddenRouteTechs.has(r.id) ? 'Cliquer pour RÉAFFICHER cette tournée' : 'Cliquer pour retirer cette tournée de la carte' }} @@ -490,7 +490,7 @@
- + {{ r.name }} Départ : {{ techHomes[r.id] ? 'domicile' : 'bureau TARGO (défaut)' }} — cliquer pour définir/modifier · {{ r.stops.length }} arrêt(s) · 🚗 {{ dayRouteMetrics[r.id].km }} km / {{ dayRouteMetrics[r.id].mins }} min