feat(ops): unassigned map jobs reuse assigned-stop pill + overlap fan

Sur la carte Tournées, les jobs NON assignés (props.pins) étaient de
simples gouttes oranges sans icône ni gestion du chevauchement, alors que
les arrêts ASSIGNÉS sont des pastilles round-rect (icône compétence + n°)
qui s'éclatent en éventail au survol quand elles se chevauchent
(recluster/fan). Uniformisé :
- renderPins() rend la MÊME pastille .rm-pill (icône de compétence via
  pin.icon = skillSym), variante « non assigné » (.rm-pill-un : bordure
  pointillée + halo orange), avec hover onEnter/onLeave.
- recluster() opère désormais sur stopMk.concat(pinMk) → arrêts et jobs
  non assignés participent au même éclatement au chevauchement.
- routeUnassignedPins fournit `icon` (skillSym du required_skill/service).

⚠️ Visuel (pastille + éventail) NON vérifiable en préview locale : le jeu
de données dev n'a pas de coordonnées sur les jobs → 0 pin rendu. Code =
miroir fidèle du rendu d'arrêt éprouvé ; map s'initialise, 0 nouvelle
erreur console. À vérifier à l'œil en prod (adresses réelles géocodées).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
louispaulb 2026-07-04 23:24:19 -04:00
parent cef6507f5f
commit 80e09c87ba
2 changed files with 27 additions and 14 deletions

View File

@ -103,13 +103,22 @@ 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'
const wrap = document.createElement('div'); wrap.className = 'rm-pin' // MÊME pastille round-rect que les arrêts assignés (icône de compétence), variante « non assigné » (bordure pointillée) ;
wrap.innerHTML = '<span class="rm-pin-drop" style="background:' + color + '"></span>' // participe au même éclatement en éventail au chevauchement (recluster/fan) que les arrêts.
const wrap = document.createElement('div'); wrap.className = 'rm-mk rm-mk-un'
const disc = document.createElement('div'); disc.className = 'rm-pill rm-pill-un'; disc.style.background = color
const ic = /^[a-z0-9_]+$/.test(String(p.icon || '')) ? p.icon : 'place'
disc.innerHTML = '<span class="material-icons rm-ic">' + ic + '</span>'
wrap.appendChild(disc)
wrap.title = (p.subject || 'Job') + (p.city ? ' · ' + p.city : '') + ' — non assigné' wrap.title = (p.subject || 'Job') + (p.city ? ' · ' + p.city : '') + ' — non assigné'
const mk = new mapboxgl.Marker({ element: wrap, anchor: 'center' }).setLngLat([+p.lon, +p.lat]).addTo(map)
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))
wrap.addEventListener('click', () => emit('pin-click', p)) wrap.addEventListener('click', () => emit('pin-click', p))
const mk = new mapboxgl.Marker({ element: wrap, anchor: 'bottom' }).setLngLat([+p.lon, +p.lat]).addTo(map) pinMk.push(rec)
pinMk.push({ mk, el: wrap })
} }
recluster()
} }
function renderMarkers (routes) { function renderMarkers (routes) {
clearMarkers() clearMarkers()
@ -141,29 +150,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 jobs non assignés partagent le même é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
}) })
} }
} }
@ -269,6 +280,8 @@ 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; }
/* Non assigné : même pastille, bordure pointillée + halo pour se distinguer des arrêts planifiés. */
.rm-mk-un .rm-pill { border-style: dashed; box-shadow: 0 0 0 2px rgba(249,115,22,.25), 0 1px 3px rgba(0,0,0,.45); }
.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); }

View File

@ -4760,7 +4760,7 @@ 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), job: j }))) 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: skillSym(j.required_skill || j.service_type), job: j })))
// 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()