feat(routemap): pill markers — job-type icon + sequence number (best practice)

Route-stop markers changed from a plain number disc to a rounded-
rectangle "pill" carrying BOTH the job-type icon (material-icons) and
the sequence number, in the tech's route color with a white border —
the standard for markers that show two facts (logistics/route tools).
Number stays bold (primary scan key), icon is context.

- markerIcon(skill): map-safe resolver returning ONLY material-icons
  ligatures (skillSym's custom SVGs don't render in a plain <span>);
  install→construction, repair→build, tv→live_tv, phone→call, etc.
- stops in dayRoutes + suggestRoutes now carry icon; RouteMap renders
  <span.material-icons> + number in .rm-pill. Hover fan-out, hover-to-
  front, and cluster logic unchanged (operate on the pill element).

Verified: 43 pills render with glyph+number in tech colors; 8-pin
cluster still fans out on hover.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
louispaulb 2026-07-02 21:02:27 -04:00
parent 2177212a67
commit 7b7980a12b
2 changed files with 24 additions and 10 deletions

View File

@ -80,8 +80,11 @@ function renderMarkers (routes) {
homeMk.push({ mk, el: wrap, rid: r.id })
}
for (const s of (r.stops || [])) {
// Pastille = ROUND-RECT (pill) : icône du type de job + numéro d'ordre (best practice quand 2 infos ; cf. outils de tournée).
const wrap = document.createElement('div'); wrap.className = 'rm-mk'; wrap.dataset.rid = String(r.id)
const disc = document.createElement('div'); disc.className = 'rm-disc'; disc.style.background = r.color; disc.textContent = String(s.seq)
const disc = document.createElement('div'); disc.className = 'rm-pill'; disc.style.background = r.color
const ic = /^[a-z0-9_]+$/.test(String(s.icon || '')) ? s.icon : 'place'
disc.innerHTML = '<span class="material-icons rm-ic">' + ic + '</span><span class="rm-num">' + s.seq + '</span>'
wrap.appendChild(disc)
const mk = new mapboxgl.Marker({ element: wrap, anchor: 'center' }).setLngLat([+s.lon, +s.lat]).addTo(map)
const rec = { mk, el: wrap, disc, rid: r.id, lngLat: [+s.lon, +s.lat], seq: s.seq, tech: r.name, subject: s.subject || '', stop: s, fan: null, members: null }
@ -211,17 +214,18 @@ watch(() => props.routes, () => { draw() }, { deep: true })
<!-- NON scoped : les marqueurs sont créés en JS et injectés dans le conteneur Mapbox (hors de l'arbre scoped). Préfixe rm- = pas de collision. -->
<style>
.rm-mk { cursor: pointer; will-change: transform; }
.rm-mk .rm-disc {
width: 24px; height: 24px; border-radius: 50%; box-sizing: border-box;
display: flex; align-items: center; justify-content: center;
color: #fff; font-size: 11px; font-weight: 800; line-height: 1;
border: 2px solid #fff; box-shadow: 0 1px 3px rgba(0,0,0,.45);
.rm-mk .rm-pill {
display: inline-flex; align-items: center; gap: 3px; box-sizing: border-box;
height: 22px; padding: 0 7px 0 5px; border-radius: 11px; white-space: nowrap;
color: #fff; border: 2px solid #fff; box-shadow: 0 1px 3px rgba(0,0,0,.45);
transition: transform .18s cubic-bezier(.2,.8,.3,1), box-shadow .15s;
}
.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-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 .rm-disc { transform: scale(1.2); 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); }
.rm-mk.route-hot { z-index: 5; }
.rm-mk.fanned { z-index: 6; }
.rm-mk.fanned .rm-disc { box-shadow: 0 3px 10px rgba(0,0,0,.55); }
.rm-mk.fanned .rm-pill { box-shadow: 0 3px 10px rgba(0,0,0,.55); }
</style>

View File

@ -2362,6 +2362,16 @@ function skillIcon (sk) {
if (/factur|paiement|compta/.test(s)) return 'receipt_long'
return 'bolt'
}
// Icône de compétence pour un MARQUEUR carte (DOM, police material-icons) : UNIQUEMENT des ligatures material (pas les SVG
// custom de skillSym) sinon le glyphe ne s'affiche pas dans un <span class="material-icons"> hors q-icon.
function markerIcon (skill) {
const s = String(skill || '').toLowerCase()
if (/t[ée]l[ée]vis|\btv\b|iptv/.test(s)) return 'live_tv'
if (/install/.test(s)) return 'construction'
if (/monteur|poteau|hauteur|nacelle|a[ée]rien|grimp/.test(s)) return 'local_shipping'
const ic = skillIcon(skill)
return (typeof ic === 'string' && /^[a-z0-9_]+$/.test(ic)) ? ic : 'build'
}
function onTagsChange (t, items) {
const newLabels = (items || []).map(x => typeof x === 'string' ? x : x.tag).filter(Boolean)
const removed = (t.skills || []).filter(s => !newLabels.includes(s)) // compétences retirées vérifier l'impact sur les jobs assignés
@ -4476,7 +4486,7 @@ const suggestRoutes = computed(() => {
if (e.lat == null || e.lon == null || !isFinite(+e.lat) || !isFinite(+e.lon) || Math.abs(+e.lat) < 0.01) continue // 0,0 / invalides exclus (sinon la carte dézoome jusqu'au golfe de Guinée)
const k = (+e.lat).toFixed(4) + ',' + (+e.lon).toFixed(4)
const ex = seen.get(k)
if (ex) { ex.n++; ex.subject = ex.n + ' jobs — ' + ex.first } else { const s = { lat: +e.lat, lon: +e.lon, seq: stops.length + 1, subject: e.subject, first: e.subject, n: 1, name: e.jobName, entry: e }; seen.set(k, s); stops.push(s) }
if (ex) { ex.n++; ex.subject = ex.n + ' jobs — ' + ex.first } else { const s = { lat: +e.lat, lon: +e.lon, seq: stops.length + 1, subject: e.subject, first: e.subject, n: 1, name: e.jobName, entry: e, icon: markerIcon(e.skill) }; seen.set(k, s); stops.push(s) }
}
if (!stops.length) continue
const home = techOrigin(g.techId) // domicile, sinon bureau TARGO
@ -4502,7 +4512,7 @@ const allDayRoutes = computed(() => { // TOUTES les tournées du jour (couleurs
if (!jobs.length) continue
jobs.sort((a, b) => (a.route_order || 9999) - (b.route_order || 9999) || ((a.start_h ?? 99) - (b.start_h ?? 99))) // ordre de tournée réel
const h = techOrigin(t.id) // domicile, sinon bureau TARGO
out.push({ id: t.id, name: t.name, color: routeColor(ci++), home: h ? { lat: h.lat, lon: h.lon } : null, stops: jobs.map((j, i) => ({ lat: +j.lat, lon: +j.lon, seq: i + 1, subject: j.subject, name: j.name, job: j })) })
out.push({ id: t.id, name: t.name, color: routeColor(ci++), home: h ? { lat: h.lat, lon: h.lon } : null, stops: jobs.map((j, i) => ({ lat: +j.lat, lon: +j.lon, seq: i + 1, subject: j.subject, name: j.name, job: j, icon: markerIcon(j.skill) })) })
}
return out
})