feat: SVG circular progress ring on map tech markers

Replace faint linear bars with SVG ring around avatar:
- Outer arc (faded): planned load / 8h capacity, color-coded
  green→yellow→orange→red based on load percentage
- Inner arc (solid green): jobs completed / total jobs
- Ring uses stroke-dasharray for clean arcs with round caps
- Tooltip: "Name — 2/5 jobs (3.0h / 7.0h)"
- Crew badge (purple "2") positioned on avatar corner

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
louispaulb 2026-03-27 13:05:32 -04:00
parent f7fea2b8e5
commit 6f901f911c
2 changed files with 39 additions and 26 deletions

View File

@ -192,17 +192,51 @@ export function useMap (deps) {
const donePct = totalHours > 0 ? Math.min(doneHours / 8, 1) : 0 const donePct = totalHours > 0 ? Math.min(doneHours / 8, 1) : 0
const loadColor = loadPct < 0.5 ? '#10b981' : loadPct < 0.75 ? '#f59e0b' : loadPct < 0.9 ? '#f97316' : '#ef4444' const loadColor = loadPct < 0.5 ? '#10b981' : loadPct < 0.75 ? '#f59e0b' : loadPct < 0.9 ? '#f97316' : '#ef4444'
// SVG ring dimensions
const SIZE = 46, STROKE = 3.5, R = (SIZE - STROKE) / 2
const CIRC = 2 * Math.PI * R
const completedJobs = allToday.filter(j => (j.status || '').toLowerCase() === 'completed').length
const totalJobs = allToday.length
const completionPct = totalJobs > 0 ? completedJobs / totalJobs : 0
// Outer wrapper // Outer wrapper
const outer = document.createElement('div') const outer = document.createElement('div')
outer.style.cssText = 'cursor:pointer;display:flex;flex-direction:column;align-items:center;' outer.style.cssText = 'cursor:pointer;position:relative;'
outer.dataset.techId = tech.id outer.dataset.techId = tech.id
// Avatar circle // SVG ring (load arc + completion arc)
if (totalHours > 0) {
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
svg.setAttribute('width', SIZE); svg.setAttribute('height', SIZE)
svg.style.cssText = 'position:absolute;top:0;left:0;transform:rotate(-90deg);pointer-events:none;'
// Load arc (how full the day is — faded)
const loadArc = document.createElementNS('http://www.w3.org/2000/svg', 'circle')
loadArc.setAttribute('cx', SIZE/2); loadArc.setAttribute('cy', SIZE/2); loadArc.setAttribute('r', R)
loadArc.setAttribute('fill', 'none'); loadArc.setAttribute('stroke', loadColor)
loadArc.setAttribute('stroke-width', STROKE); loadArc.setAttribute('opacity', '0.3')
loadArc.setAttribute('stroke-dasharray', `${CIRC * loadPct} ${CIRC}`)
loadArc.setAttribute('stroke-linecap', 'round')
svg.appendChild(loadArc)
// Completion arc (jobs done — solid bright)
if (completionPct > 0) {
const doneArc = document.createElementNS('http://www.w3.org/2000/svg', 'circle')
doneArc.setAttribute('cx', SIZE/2); doneArc.setAttribute('cy', SIZE/2); doneArc.setAttribute('r', R)
doneArc.setAttribute('fill', 'none'); doneArc.setAttribute('stroke', '#10b981')
doneArc.setAttribute('stroke-width', STROKE); doneArc.setAttribute('opacity', '1')
doneArc.setAttribute('stroke-dasharray', `${CIRC * completionPct * loadPct} ${CIRC}`)
doneArc.setAttribute('stroke-linecap', 'round')
svg.appendChild(doneArc)
}
outer.appendChild(svg)
}
// Avatar circle (centered inside SVG ring)
const el = document.createElement('div') const el = document.createElement('div')
el.className = 'sb-map-tech-pin' el.className = 'sb-map-tech-pin'
el.style.cssText = `background:${color};border-color:${color};` const pad = totalHours > 0 ? STROKE + 1 : 0
el.style.cssText = `background:${color};border-color:${color};margin:${pad}px;`
el.textContent = initials el.textContent = initials
el.title = `${tech.fullName}${doneHours.toFixed(1)}h complété / ${totalHours.toFixed(1)}h planifié` el.title = `${tech.fullName}${completedJobs}/${totalJobs} jobs (${doneHours.toFixed(1)}h / ${totalHours.toFixed(1)}h)`
outer.appendChild(el) outer.appendChild(el)
// Group badge (crew size) // Group badge (crew size)
@ -215,25 +249,6 @@ export function useMap (deps) {
el.appendChild(badge) el.appendChild(badge)
} }
// Dual progress bar: load (background) + done (foreground)
if (totalHours > 0) {
const bar = document.createElement('div')
bar.className = 'sb-map-load-bar'
// Load bar (planned hours — full width = 8h)
const loadFill = document.createElement('div')
loadFill.className = 'sb-map-load-fill'
loadFill.style.cssText = `width:${Math.round(loadPct * 100)}%;background:${loadColor};opacity:0.35;`
bar.appendChild(loadFill)
// Done bar (completed hours — overlaid on top)
if (doneHours > 0) {
const doneFill = document.createElement('div')
doneFill.className = 'sb-map-done-fill'
doneFill.style.cssText = `width:${Math.round(donePct * 100)}%;background:#10b981;`
bar.appendChild(doneFill)
}
outer.appendChild(bar)
}
// Drag & drop handlers // Drag & drop handlers
outer.addEventListener('dragover', e => { e.preventDefault(); el.style.transform = 'scale(1.25)' }) outer.addEventListener('dragover', e => { e.preventDefault(); el.style.transform = 'scale(1.25)' })
outer.addEventListener('dragleave', () => { el.style.transform = '' }) outer.addEventListener('dragleave', () => { el.style.transform = '' })

View File

@ -1498,9 +1498,7 @@ html, body { margin:0; padding:0; height:100%; overflow:hidden; }
.sb-map-tech-pin { width:36px; height:36px; border-radius:50%; display:flex; align-items:center; justify-content:center; font-size:0.65rem; font-weight:800; color:#fff; border:2.5px solid; box-shadow:0 2px 10px rgba(0,0,0,0.55); cursor:pointer; transition:transform 0.15s; } .sb-map-tech-pin { width:36px; height:36px; border-radius:50%; display:flex; align-items:center; justify-content:center; font-size:0.65rem; font-weight:800; color:#fff; border:2.5px solid; box-shadow:0 2px 10px rgba(0,0,0,0.55); cursor:pointer; transition:transform 0.15s; }
.sb-map-tech-pin:hover { transform:scale(1.2); } .sb-map-tech-pin:hover { transform:scale(1.2); }
.sb-map-tech-pin { position:relative; } .sb-map-tech-pin { position:relative; }
.sb-map-load-bar { width:32px; height:5px; background:rgba(255,255,255,0.12); border-radius:3px; margin-top:3px; overflow:hidden; position:relative; } /* ring markers — no linear bars needed, SVG handles it */
.sb-map-load-fill { position:absolute; top:0; left:0; height:100%; border-radius:3px; }
.sb-map-done-fill { position:absolute; top:0; left:0; height:100%; border-radius:3px; }
.sb-map-crew-badge { position:absolute; top:-4px; right:-6px; min-width:16px; height:16px; border-radius:8px; background:#6366f1; color:#fff; font-size:9px; font-weight:800; display:flex; align-items:center; justify-content:center; border:2px solid #0f172a; line-height:1; padding:0 3px; } .sb-map-crew-badge { position:absolute; top:-4px; right:-6px; min-width:16px; height:16px; border-radius:8px; background:#6366f1; color:#fff; font-size:9px; font-weight:800; display:flex; align-items:center; justify-content:center; border:2px solid #0f172a; line-height:1; padding:0 3px; }
.sb-map-gps-active { box-shadow:0 0 0 3px rgba(16,185,129,0.5), 0 0 12px rgba(16,185,129,0.4), 0 2px 10px rgba(0,0,0,0.55); animation:gps-glow 2s infinite; } .sb-map-gps-active { box-shadow:0 0 0 3px rgba(16,185,129,0.5), 0 0 12px rgba(16,185,129,0.4), 0 2px 10px rgba(0,0,0,0.55); animation:gps-glow 2s infinite; }
@keyframes gps-glow { 0%,100% { box-shadow:0 0 0 3px rgba(16,185,129,0.5), 0 0 12px rgba(16,185,129,0.4), 0 2px 10px rgba(0,0,0,0.55); } 50% { box-shadow:0 0 0 6px rgba(16,185,129,0.3), 0 0 20px rgba(16,185,129,0.3), 0 2px 10px rgba(0,0,0,0.55); } } @keyframes gps-glow { 0%,100% { box-shadow:0 0 0 3px rgba(16,185,129,0.5), 0 0 12px rgba(16,185,129,0.4), 0 2px 10px rgba(0,0,0,0.55); } 50% { box-shadow:0 0 0 6px rgba(16,185,129,0.3), 0 0 20px rgba(16,185,129,0.3), 0 2px 10px rgba(0,0,0,0.55); } }