feat(ops): map unassigned jobs grouped by address + overlap fan
#3 Regroupement par adresse : routeUnassignedPins groupe les jobs non assignés d'une même adresse en UNE pastille portant les icônes des types de jobs (distinctes, max 3) + le TEMPS TOTAL + un compteur ; couleur = priorité la plus haute. Clic (tap mobile) sur une adresse à >1 job → popup listant chaque job (sujet + durée), chaque ligne ouvre le détail → plus besoin de cliquer chaque ticket, info visible d'un coup d'œil. #4 Chevauchement : les gouttes non assignées rejoignent recluster()/fan() (déjà éprouvé sur les arrêts assignés) → les pastilles proches s'éclatent en éventail pour rester distinguables/atteignables. (Le regroupement par adresse réduit déjà le fouillis ; le fan gère les adresses proches ≠.) Vérifié par injection du markup (impossible de rendre la carte en dev, jeu sans coordonnées) : pastille groupée = horizontale 92×22, 2 icônes + compteur + « 45min » visibles ; popup = lignes cliquables. Compile OK, 0 nouvelle erreur. Rendu carte à confirmer en prod (jobs géocodés). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
c05a6272b7
commit
aa52a2c98c
|
|
@ -95,7 +95,10 @@ function renderLive (list) {
|
||||||
liveMk.push({ mk, el: wrap })
|
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) {
|
function renderPins (list) {
|
||||||
if (!map || !ready) return
|
if (!map || !ready) return
|
||||||
clearPins()
|
clearPins()
|
||||||
|
|
@ -103,18 +106,31 @@ function renderPins (list) {
|
||||||
for (const p of (list || [])) {
|
for (const p of (list || [])) {
|
||||||
if (!okLL(p.lon, p.lat)) continue
|
if (!okLL(p.lon, p.lat)) continue
|
||||||
const color = p.color || '#f97316'
|
const color = p.color || '#f97316'
|
||||||
// MÊME rendu que les arrêts assignés (pastille round-rect horizontale + icône material via <span material-icons>),
|
|
||||||
// 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 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 disc = document.createElement('div'); disc.className = 'rm-pill'; disc.style.background = color
|
||||||
const ic = /^[a-z0-9_]+$/.test(String(p.icon || '')) ? p.icon : 'place'
|
const icons = (p.icons && p.icons.length) ? p.icons : [p.icon || 'place']
|
||||||
disc.innerHTML = '<span class="material-icons rm-ic">' + ic + '</span>'
|
let html = icons.map(ic => '<span class="material-icons rm-ic">' + (/^[a-z0-9_]+$/.test(String(ic)) ? ic : 'place') + '</span>').join('')
|
||||||
|
if (p.count > 1) html += '<span class="rm-num">' + p.count + '</span>'
|
||||||
|
if (p.totalLabel) html += '<span class="rm-time">' + esc(p.totalLabel) + '</span>'
|
||||||
|
disc.innerHTML = html
|
||||||
wrap.appendChild(disc)
|
wrap.appendChild(disc)
|
||||||
wrap.title = (p.subject || 'Job') + (p.city ? ' · ' + p.city : '') + ' — non assigné'
|
const jobs = p.jobs || []
|
||||||
wrap.addEventListener('click', () => emit('pin-click', p))
|
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) => `<div class="rm-pop-row" data-i="${i}"><span class="rm-pop-s">${esc(j.subject)}</span><span class="rm-pop-est">${esc(j.est || '')}</span></div>`).join('')
|
||||||
|
const pop = new mapboxgl.Popup({ offset: 18, maxWidth: '280px' }).setLngLat([+p.lon, +p.lat])
|
||||||
|
.setHTML(`<div class="rm-pop"><div class="rm-pop-hd">${jobs.length} jobs · ≈${esc(p.totalLabel || '')}${p.city ? ' · ' + esc(p.city) : ''}</div>${rows}</div>`).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)
|
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) {
|
function renderMarkers (routes) {
|
||||||
clearMarkers()
|
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.
|
// Regroupe les pastilles qui se CHEVAUCHENT à l'écran (distance pixel) → calcule pour chacune un décalage en éventail.
|
||||||
function recluster () {
|
function recluster () {
|
||||||
if (!map || !stopMk.length) return
|
// Arrêts assignés ET gouttes non assignées (par adresse) partagent l'éclatement en éventail au chevauchement.
|
||||||
const px = stopMk.map(s => map.project(s.lngLat))
|
const all = stopMk.concat(pinMk)
|
||||||
const parent = stopMk.map((_, i) => i)
|
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 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
|
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
|
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)
|
if (dx * dx + dy * dy < TH * TH) parent[find(i)] = find(j)
|
||||||
}
|
}
|
||||||
const groups = {}
|
const groups = {}
|
||||||
for (let i = 0; i < stopMk.length; i++) { const r = find(i); (groups[r] = groups[r] || []).push(i) }
|
for (let i = 0; i < all.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 s of all) { s.fan = null; s.members = null }
|
||||||
for (const key in groups) {
|
for (const key in groups) {
|
||||||
const ids = groups[key]; if (ids.length < 2) continue
|
const ids = groups[key]; if (ids.length < 2) continue
|
||||||
const cx = ids.reduce((a, i) => a + px[i].x, 0) / ids.length
|
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 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 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) => {
|
ids.forEach((i, k) => {
|
||||||
const ang = (k / ids.length) * 2 * Math.PI - Math.PI / 2
|
const ang = (k / ids.length) * 2 * Math.PI - Math.PI / 2
|
||||||
const tx = cx + R * Math.cos(ang), ty = cy + R * Math.sin(ang)
|
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
|
all[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].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-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-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 <style> non-scopé, s'applique au popup mapbox global */
|
||||||
|
.rm-pop { font-size: 12px; min-width: 170px; }
|
||||||
|
.rm-pop-hd { font-weight: 700; color: #1e293b; margin-bottom: 4px; }
|
||||||
|
.rm-pop-row { display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 4px 2px; border-top: 1px solid #eef1f5; cursor: pointer; }
|
||||||
|
.rm-pop-row:hover { background: #eef2ff; }
|
||||||
|
.rm-pop-s { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.rm-pop-est { color: #64748b; font-variant-numeric: tabular-nums; flex-shrink: 0; }
|
||||||
.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 .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 { z-index: 7; }
|
||||||
.rm-mk.hot .rm-pill { transform: scale(1.15); box-shadow: 0 3px 9px rgba(0,0,0,.55); }
|
.rm-mk.hot .rm-pill { transform: scale(1.15); box-shadow: 0 3px 9px rgba(0,0,0,.55); }
|
||||||
|
|
|
||||||
|
|
@ -4825,7 +4825,30 @@ const routeDayUnassignedView = computed(() => {
|
||||||
const _jlat = j => (j.latitude != null ? +j.latitude : (j.lat != null ? +j.lat : null))
|
const _jlat = j => (j.latitude != null ? +j.latitude : (j.lat != null ? +j.lat : null))
|
||||||
const _jlon = j => (j.longitude != null ? +j.longitude : (j.lon != null ? +j.lon : null))
|
const _jlon = j => (j.longitude != null ? +j.longitude : (j.lon != null ? +j.lon : null))
|
||||||
// Gouttes carte = non-assignés du jour (filtrés secteur) qui ont des coordonnées ; couleur = priorité
|
// Gouttes carte = non-assignés du jour (filtrés secteur) qui ont des coordonnées ; couleur = priorité
|
||||||
const routeUnassignedPins = computed(() => routeDayUnassignedView.value.filter(j => { const la = _jlat(j); const lo = _jlon(j); return la != null && lo != null && isFinite(la) && isFinite(lo) && Math.abs(la) > 0.01 }).map(j => ({ lat: _jlat(j), lon: _jlon(j), subject: j.subject || j.service_type || j.name, name: j.name, color: prioColor(j.priority), city: jobCity(j), icon: markerIcon(j.required_skill || j.service_type), job: j })))
|
// Gouttes carte des non-assignés — GROUPÉES PAR ADRESSE : une pastille par adresse portant les icônes des types de
|
||||||
|
// jobs + le temps TOTAL (évite de cliquer chaque ticket ; détail au clic via popup). Couleur = priorité la plus haute.
|
||||||
|
const routeUnassignedPins = computed(() => {
|
||||||
|
const valid = routeDayUnassignedView.value.filter(j => { const la = _jlat(j); const lo = _jlon(j); return la != null && lo != null && isFinite(la) && isFinite(lo) && Math.abs(la) > 0.01 })
|
||||||
|
const prioRank = p => ({ urgent: 0, high: 1, 'élevée': 1, moyenne: 2, medium: 2, normal: 2, low: 3, basse: 3 }[String(p || '').toLowerCase()] ?? 2)
|
||||||
|
const groups = new Map()
|
||||||
|
for (const j of valid) {
|
||||||
|
const la = _jlat(j), lo = _jlon(j)
|
||||||
|
const key = String(j.address || '').trim().toLowerCase() || (la.toFixed(5) + ',' + lo.toFixed(5))
|
||||||
|
let g = groups.get(key); if (!g) { g = { lat: la, lon: lo, jobs: [], city: jobCity(j), address: j.address || '' }; groups.set(key, g) }
|
||||||
|
g.jobs.push(j)
|
||||||
|
}
|
||||||
|
return [...groups.values()].map(g => {
|
||||||
|
const icons = [...new Set(g.jobs.map(j => markerIcon(j.required_skill || j.service_type)))].slice(0, 3)
|
||||||
|
const totalMin = g.jobs.reduce((s, j) => s + Math.round(jobDur(j) * 60), 0)
|
||||||
|
const topPrio = g.jobs.map(j => j.priority).sort((a, b) => prioRank(a) - prioRank(b))[0]
|
||||||
|
return {
|
||||||
|
lat: g.lat, lon: g.lon, icons, count: g.jobs.length, totalLabel: fmtMin(totalMin),
|
||||||
|
color: prioColor(topPrio), city: g.city, address: g.address,
|
||||||
|
jobs: g.jobs.map(j => ({ subject: j.subject || j.service_type || j.name, est: fmtMin(Math.round(jobDur(j) * 60)), job: j })),
|
||||||
|
job: g.jobs[0], // clic simple (1 job) → détail direct
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
// ── Deep-link (?day=YYYY-MM-DD & ?skill=…) + action « planifier des quarts » du slot-finder ──
|
// ── Deep-link (?day=YYYY-MM-DD & ?skill=…) + action « planifier des quarts » du slot-finder ──
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user