diff --git a/apps/ops/src/components/shared/RouteMap.vue b/apps/ops/src/components/shared/RouteMap.vue index 3b444f5..4a878cc 100644 --- a/apps/ops/src/components/shared/RouteMap.vue +++ b/apps/ops/src/components/shared/RouteMap.vue @@ -95,7 +95,10 @@ function renderLive (list) { liveMk.push({ mk, el: wrap }) } } -// Jobs NON assignés du jour : gouttes oranges (à situer/répartir) ; clic → la page ouvre le détail. +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() @@ -103,18 +106,31 @@ function renderPins (list) { for (const p of (list || [])) { if (!okLL(p.lon, p.lat)) continue const color = p.color || '#f97316' - // MÊME rendu que les arrêts assignés (pastille round-rect horizontale + icône material via ), - // coloré priorité (orange = non assigné), SANS numéro d'ordre. Icône = markerIcon (ligature material fiable). 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 ic = /^[a-z0-9_]+$/.test(String(p.icon || '')) ? p.icon : 'place' - disc.innerHTML = '' + ic + '' + const icons = (p.icons && p.icons.length) ? p.icons : [p.icon || 'place'] + let html = icons.map(ic => '' + (/^[a-z0-9_]+$/.test(String(ic)) ? ic : 'place') + '').join('') + if (p.count > 1) html += '' + p.count + '' + if (p.totalLabel) html += '' + esc(p.totalLabel) + '' + disc.innerHTML = html wrap.appendChild(disc) - wrap.title = (p.subject || 'Job') + (p.city ? ' · ' + p.city : '') + ' — non assigné' - wrap.addEventListener('click', () => emit('pin-click', p)) + 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) => `
${esc(j.subject)}${esc(j.est || '')}
`).join('') + const pop = new mapboxgl.Popup({ offset: 18, maxWidth: '280px' }).setLngLat([+p.lon, +p.lat]) + .setHTML(`
${jobs.length} jobs · ≈${esc(p.totalLabel || '')}${p.city ? ' · ' + esc(p.city) : ''}
${rows}
`).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) - pinMk.push({ mk, el: wrap }) + 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() @@ -146,29 +162,31 @@ function renderMarkers (routes) { } // 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) + // Arrêts assignés ET gouttes non assignées (par adresse) partagent l'éclatement en éventail au chevauchement. + const all = stopMk.concat(pinMk) + 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 < stopMk.length; i++) for (let j = i + 1; j < stopMk.length; j++) { + 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 < 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 (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 => stopMk[i]) + 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) - 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 + 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 }) } } @@ -274,6 +292,14 @@ watch(() => props.pins, () => { renderPins(props.pins) }, { deep: true }) // job } .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