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:
parent
cef6507f5f
commit
80e09c87ba
|
|
@ -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); }
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user