gigafibre-fsm/apps/ops/src/composables/useMap.js
louispaulb 9844e5ae54 fix(ops/dispatch): top bar polish — visible ⋯ menu, collapsed AI, fly-to tech, views dropdown
Four fixes around the dispatch header following dispatcher feedback:

1. **⋯ overflow menu was invisible**: .sb-header had `overflow:hidden`,
   which clipped the absolutely-positioned dropdown right at the
   header's bottom edge. Switched the header to `overflow:visible`
   (children all have flex-shrink:0 + a flex:1 center, so the layout
   doesn't actually overflow horizontally). Bumped z-index to 5000
   for safety on top of map/calendar layers.

2. **NLP/Assistant IA bar hidden by default**: was eagerly rendering
   on every page load, with the long French placeholder polluting
   the header below the toolbar. The user just wanted the icon. Now
   `nlpVisible` defaults to false, persisted in localStorage so power
   users who flip it on keep it open across sessions. Toggle still
   lives in the ⋯ menu.

3. **Click a tech in the resource list now flies the map to them**:
   selectTechOnBoard previously only opened the map panel. Now it
   also `map.flyTo({ center })` using `tech.gpsCoords ?? tech.coords`
   — live Traccar position wins when the tech is online; falls back
   to the saved home base. Animated, deferred a tick so map.resize()
   happens first, otherwise flyTo can land on garbage coords during
   the panel's open transition.

4. **Board view tabs collapsed into a "Vue principale ▾" dropdown**:
   was [Vue principale][Par région][+] inline. Now a single button
   showing the active view; click reveals the others + the future
   "+ Nouvelle vue" entry. Same dropdown component as the ⋯ menu
   (shared CSS, click-outside + ESC close).
2026-05-05 14:17:33 -04:00

476 lines
25 KiB
JavaScript

// ── Map composable: Mapbox GL map, markers, routes, geo-fix, map-drag ────────
import { ref, watch, nextTick } from 'vue'
import { localDateStr, jobSpansDate, jobSvcCode, SVC_COLORS } from './useHelpers'
export function useMap (deps) {
const {
store, MAPBOX_TOKEN, TECH_COLORS,
currentView, periodStart, filteredResources, mapVisible,
routeLegs, routeGeometry,
getJobDate, jobColor, pushUndo, smartAssign, invalidateRoutes,
dragJob, dragIsAssist, rightPanel, openCtxMenu, openTechCtx,
saveTechHome,
} = deps
let map = null
let mapResizeObs = null
const mapContainer = ref(null)
const selectedTechId = ref(null)
const mapMarkers = ref([])
const mapPanelW = ref(parseInt(localStorage.getItem('sbv2-mapW')) || 340)
const geoFixJob = ref(null)
const geoFixTech = ref(null) // ← analog of geoFixJob, but for tech home base
const mapDragJob = ref(null)
let _mapGhost = null
// ── Geo-fix ──────────────────────────────────────────────────────────────────
function startGeoFix (job) {
geoFixJob.value = job
if (!mapVisible.value) mapVisible.value = true
if (map) map.getCanvas().style.cursor = 'crosshair'
}
function cancelGeoFix () {
geoFixJob.value = null
if (map) map.getCanvas().style.cursor = ''
}
watch(geoFixJob, v => { if (map) map.getCanvas().style.cursor = v ? 'crosshair' : '' })
// Tech home-base "click on the map" picker. Same pattern as geoFixJob:
// user enters the mode, cursor turns to crosshair, next click on the
// map captures the lng/lat and persists it to ERPNext.
function startTechGeoFix (tech) {
geoFixTech.value = tech
if (!mapVisible.value) mapVisible.value = true
if (map) map.getCanvas().style.cursor = 'crosshair'
}
function cancelTechGeoFix () {
geoFixTech.value = null
if (map) map.getCanvas().style.cursor = ''
}
watch(geoFixTech, v => { if (map) map.getCanvas().style.cursor = v ? 'crosshair' : '' })
// ── Panel resize ─────────────────────────────────────────────────────────────
function startMapResize (e) {
e.preventDefault()
const startX = e.clientX, startW = mapPanelW.value
function onMove (ev) {
mapPanelW.value = Math.max(220, Math.min(window.innerWidth * 0.65, startW - (ev.clientX - startX)))
}
function onUp () {
document.removeEventListener('mousemove', onMove)
document.removeEventListener('mouseup', onUp)
localStorage.setItem('sbv2-mapW', String(mapPanelW.value))
if (map) map.resize()
}
document.addEventListener('mousemove', onMove)
document.addEventListener('mouseup', onUp)
}
// ── Init ─────────────────────────────────────────────────────────────────────
async function initMap () {
if (!mapContainer.value || map) return
if (!window.mapboxgl) {
if (!document.getElementById('mapbox-js')) {
await new Promise(resolve => {
const s = document.createElement('script'); s.id = 'mapbox-js'
s.src = 'https://api.mapbox.com/mapbox-gl-js/v2.15.0/mapbox-gl.js'
s.onload = resolve; document.head.appendChild(s)
})
} else { await new Promise(r => setTimeout(r, 200)) }
}
const mapboxgl = window.mapboxgl
mapboxgl.accessToken = MAPBOX_TOKEN
map = new mapboxgl.Map({
container: mapContainer.value,
style: 'mapbox://styles/mapbox/dark-v11',
// Default centered on Gigafibre HQ (1867 chemin de la Rivière,
// Sainte-Clotilde, QC). Zoom 10 shows Sainte-Clotilde + the
// surrounding service area (Châteauguay, Napierville, Hemmingford…).
center: [-73.6756177, 45.1599145], zoom: 10,
})
if (mapResizeObs) mapResizeObs.disconnect()
mapResizeObs = new ResizeObserver(() => { if (map) map.resize() })
mapResizeObs.observe(mapContainer.value)
map.on('load', () => {
map.resize()
// Route layers
map.addSource('sb-route', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } })
map.addLayer({ id: 'sb-route-halo', type: 'line', source: 'sb-route', layout: { 'line-join': 'round', 'line-cap': 'round' }, paint: { 'line-color': '#6366f1', 'line-width': 12, 'line-opacity': 0.18 } })
map.addLayer({ id: 'sb-route-line', type: 'line', source: 'sb-route', layout: { 'line-join': 'round', 'line-cap': 'round' }, paint: { 'line-color': '#6366f1', 'line-width': 3.5, 'line-opacity': 0.85 } })
// Job layers
map.addSource('sb-jobs', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } })
map.addLayer({ id: 'sb-jobs-halo', type: 'circle', source: 'sb-jobs', paint: { 'circle-radius': 22, 'circle-color': ['get', 'color'], 'circle-opacity': ['*', ['get', 'opacity'], 0.18], 'circle-blur': 0.7 } })
map.addLayer({ id: 'sb-jobs-circle', type: 'circle', source: 'sb-jobs', paint: { 'circle-radius': 15, 'circle-color': ['get', 'color'], 'circle-opacity': ['get', 'opacity'], 'circle-stroke-width': 2, 'circle-stroke-color': ['case', ['get', 'unassigned'], 'rgba(255,255,255,0.4)', 'rgba(255,255,255,0.85)'], 'circle-stroke-opacity': ['get', 'opacity'] } })
map.addLayer({ id: 'sb-jobs-label', type: 'symbol', source: 'sb-jobs', layout: { 'text-field': ['get', 'label'], 'text-font': ['DIN Offc Pro Bold', 'Arial Unicode MS Bold'], 'text-size': 9, 'text-allow-overlap': true, 'text-ignore-placement': true }, paint: { 'text-color': '#ffffff', 'text-opacity': ['get', 'opacity'] } })
// Event handlers
map.on('mouseenter', 'sb-jobs-circle', () => { if (!mapDragJob.value && !geoFixJob.value) map.getCanvas().style.cursor = 'grab' })
map.on('mouseleave', 'sb-jobs-circle', () => { if (!mapDragJob.value && !geoFixJob.value) map.getCanvas().style.cursor = '' })
map.on('mousedown', 'sb-jobs-circle', e => {
if (geoFixJob.value) return
e.preventDefault()
const job = store.jobs.find(j => j.id === e.features[0].properties.id)
if (job) startMapDrag(e.originalEvent, job)
})
map.on('click', 'sb-jobs-circle', e => {
if (geoFixJob.value) return
const job = store.jobs.find(j => j.id === e.features[0].properties.id)
if (job) {
const tech = job.assignedTech ? store.technicians.find(t => t.id === job.assignedTech) : null
rightPanel.value = { mode: 'details', data: { job, tech } }
}
})
map.on('contextmenu', 'sb-jobs-circle', e => {
const job = store.jobs.find(j => j.id === e.features[0].properties.id)
if (job) {
const tech = job.assignedTech ? store.technicians.find(t => t.id === job.assignedTech) : null
openCtxMenu(e.originalEvent, job, tech?.id || null)
}
})
map.on('mouseenter', 'sb-route-line', () => { if (mapDragJob.value) map.getCanvas().style.cursor = 'copy' })
map.on('mouseleave', 'sb-route-line', () => { if (!mapDragJob.value) map.getCanvas().style.cursor = '' })
// Geo-fix click — handles BOTH job geofix and tech home-base pick.
// Tech mode wins if both are somehow active simultaneously (defensive;
// shouldn't happen in practice).
map.on('click', async e => {
if (geoFixTech.value) {
const tech = geoFixTech.value
geoFixTech.value = null
map.getCanvas().style.cursor = ''
if (typeof saveTechHome === 'function') {
try { await saveTechHome(tech, e.lngLat.lng, e.lngLat.lat) } catch (_e) {}
}
nextTick(() => drawMapMarkers())
return
}
if (!geoFixJob.value) return
const job = geoFixJob.value
const saved = JSON.parse(localStorage.getItem('dispatch-job-coords') || '{}')
saved[job.id] = [e.lngLat.lng, e.lngLat.lat]
localStorage.setItem('dispatch-job-coords', JSON.stringify(saved))
store.updateJobCoords(job.id, e.lngLat.lng, e.lngLat.lat)
routeLegs.value = {}; routeGeometry.value = {}
geoFixJob.value = null
map.getCanvas().style.cursor = ''
nextTick(() => {
drawMapMarkers()
const dayStr = localDateStr(periodStart.value)
filteredResources.value.forEach(tech => computeDayRoute(tech, dayStr))
drawSelectedRoute()
})
})
drawMapMarkers()
drawSelectedRoute()
})
}
// ── Draw markers ─────────────────────────────────────────────────────────────
function drawMapMarkers () {
if (!map || !window.mapboxgl) return
const dayStr = localDateStr(periodStart.value)
const mbgl = window.mapboxgl
const jobFeatures = store.jobs
.filter(j => j.coords && !(j.coords[0] === 0 && j.coords[1] === 0))
.filter(j => {
if (!j.assignedTech) return (j.scheduledDate || null) === dayStr
const tech = store.technicians.find(t => t.id === j.assignedTech)
return jobSpansDate(j, dayStr, tech)
})
.map(job => {
const isUnassigned = !job.assignedTech
const isCompleted = (job.status || '').toLowerCase() === 'completed'
const isSelected = selectedTechId.value && job.assignedTech === selectedTechId.value
const opacity = isCompleted ? 0.4 : (isSelected || isUnassigned || !selectedTechId.value ? 0.92 : 0.4)
let label = jobSvcCode(job)
if (!isUnassigned) {
const tech = store.technicians.find(t => t.id === job.assignedTech)
if (tech) { const idx = tech.queue.filter(j2 => getJobDate(j2.id) === dayStr).indexOf(job); if (idx >= 0) label = String(idx + 1) }
}
return { type: 'Feature', geometry: { type: 'Point', coordinates: job.coords }, properties: { id: job.id, color: jobColor(job), label, title: job.subject, opacity, unassigned: isUnassigned, completed: isCompleted } }
})
if (map.getSource('sb-jobs')) map.getSource('sb-jobs').setData({ type: 'FeatureCollection', features: jobFeatures })
// Tech avatar markers
mapMarkers.value.forEach(m => m.remove())
mapMarkers.value = []
// Pre-compute: which techs are assistants on which lead tech's jobs today
const groupCounts = {} // leadTechId → total crew size (1 + assistants)
store.technicians.forEach(tech => {
const todayJobs = tech.queue.filter(j => jobSpansDate(j, dayStr, tech))
const assistIds = new Set()
todayJobs.forEach(j => (j.assistants || []).forEach(a => assistIds.add(a.techId)))
if (assistIds.size > 0) groupCounts[tech.id] = 1 + assistIds.size
})
filteredResources.value.forEach(tech => {
const pos = tech.gpsCoords || tech.coords
if (!pos || (pos[0] === 0 && pos[1] === 0)) return
const initials = tech.fullName.split(' ').map(n => n[0]).join('').toUpperCase().slice(0, 2)
const color = TECH_COLORS[tech.colorIdx]
// Calculate daily workload + completion
const todayJobs = tech.queue.filter(j => jobSpansDate(j, dayStr, tech))
const todayAssist = (tech.assistJobs || []).filter(j => jobSpansDate(j, dayStr, tech))
const allToday = [...todayJobs, ...todayAssist]
const totalHours = allToday.reduce((s, j) => s + (j.duration || 1), 0)
const doneHours = allToday.filter(j => (j.status || '').toLowerCase() === 'completed')
.reduce((s, j) => s + (j.duration || 1), 0)
const loadPct = Math.min(totalHours / 8, 1)
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'
// Ring + avatar in a fixed-size container so Mapbox anchor stays consistent
const PIN = 36, STROKE = 3.5, SIZE = PIN + STROKE * 2 + 2 // ~45px
const R = (SIZE - STROKE) / 2, 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
// Fixed-size outer wrapper — Mapbox anchors to this. DO NOT set
// `position:relative` inline here: Mapbox GL's `.mapboxgl-marker`
// class applies `position:absolute` and is what gets the
// `transform: translate(...)` at every zoom/pan frame. An inline
// `relative` overrides that and the pin appears to drift away from
// its lat/lng on zoom. The svg + avatar children are positioned
// absolutely; that still works against an absolutely-positioned
// parent (any positioned element is a containing block).
const outer = document.createElement('div')
outer.style.cssText = `cursor:pointer;width:${SIZE}px;height:${SIZE}px;`
outer.dataset.techId = tech.id
// SVG ring (load arc + completion arc) — fills entire container
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;'
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)
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 — absolutely centered in container
const el = document.createElement('div')
el.className = 'sb-map-tech-pin'
const offset = (SIZE - PIN) / 2
el.style.cssText = `background:${color};border-color:${color};position:absolute;top:${offset}px;left:${offset}px;width:${PIN}px;height:${PIN}px;`
el.textContent = initials
el.title = `${tech.fullName}${completedJobs}/${totalJobs} jobs (${doneHours.toFixed(1)}h / ${totalHours.toFixed(1)}h)`
outer.appendChild(el)
// Group badge (crew size)
const crew = groupCounts[tech.id]
if (crew && crew > 1) {
const badge = document.createElement('div')
badge.className = 'sb-map-crew-badge'
badge.textContent = String(crew)
badge.title = `Équipe de ${crew}`
el.appendChild(badge)
}
// Right-click → open tech context menu (DispatchPage handles the
// q-menu). We pre-position by passing the original click event;
// useContextMenus reads e.clientX/Y to anchor the menu.
outer.addEventListener('contextmenu', e => {
e.preventDefault()
e.stopPropagation()
if (typeof openTechCtx === 'function') openTechCtx(e, tech)
})
// Drag & drop handlers
outer.addEventListener('dragover', e => { e.preventDefault(); el.style.transform = 'scale(1.25)' })
outer.addEventListener('dragleave', () => { el.style.transform = '' })
outer.addEventListener('drop', e => {
e.preventDefault(); el.style.transform = ''
const job = dragJob.value
if (job) {
pushUndo({ type: 'unassignJob', jobId: job.id, techId: job.assignedTech, routeOrder: job.routeOrder, scheduledDate: job.scheduledDate, assistants: [...(job.assistants || [])] })
smartAssign(job, tech.id, dayStr)
dragJob.value = null
invalidateRoutes()
}
})
outer.addEventListener('mouseenter', () => { if (mapDragJob.value) el.style.transform = 'scale(1.3)' })
outer.addEventListener('mouseleave', () => { el.style.transform = '' })
if (tech.gpsCoords) {
el.classList.add('sb-map-gps-active')
el.title += ' (GPS)'
}
const m = new mbgl.Marker({ element: outer, anchor: 'center' }).setLngLat(pos).addTo(map)
mapMarkers.value.push(m)
})
}
// ── Map drag (job pin → tech) ────────────────────────────────────────────────
function startMapDrag (e, job) {
e.preventDefault()
mapDragJob.value = job
if (map) map.dragPan.disable()
_mapGhost = document.createElement('div')
_mapGhost.className = 'sb-map-drag-ghost'
_mapGhost.textContent = job.subject
_mapGhost.style.cssText = `position:fixed;pointer-events:none;z-index:9999;left:${e.clientX + 14}px;top:${e.clientY + 14}px`
document.body.appendChild(_mapGhost)
document.addEventListener('mousemove', _onMapDragMove)
document.addEventListener('mouseup', _onMapDragEnd)
}
function _onMapDragMove (e) { if (_mapGhost) { _mapGhost.style.left = (e.clientX + 14) + 'px'; _mapGhost.style.top = (e.clientY + 14) + 'px' } }
function _onMapDragEnd (e) {
document.removeEventListener('mousemove', _onMapDragMove)
document.removeEventListener('mouseup', _onMapDragEnd)
if (_mapGhost) { _mapGhost.remove(); _mapGhost = null }
if (map) { map.getCanvas().style.cursor = ''; map.dragPan.enable() }
const job = mapDragJob.value; mapDragJob.value = null
if (!job) return
const els = document.elementsFromPoint(e.clientX, e.clientY)
const dateStr = localDateStr(periodStart.value)
function assignFromMap (tech) {
if (dragIsAssist.value) { dragIsAssist.value = false; return }
pushUndo({ type: 'unassignJob', jobId: job.id, techId: job.assignedTech, routeOrder: job.routeOrder, scheduledDate: job.scheduledDate, assistants: [...(job.assistants || [])] })
smartAssign(job, tech.id, dateStr)
invalidateRoutes()
}
const domTarget = els.find(el => el.dataset?.techId)
if (domTarget) { const tech = store.technicians.find(t => t.id === domTarget.dataset.techId); if (tech) assignFromMap(tech); return }
if (map && selectedTechId.value) {
const canvas = map.getCanvas(), rect = canvas.getBoundingClientRect()
if (e.clientX >= rect.left && e.clientX <= rect.right && e.clientY >= rect.top && e.clientY <= rect.bottom) {
const tech = store.technicians.find(t => t.id === selectedTechId.value)
if (tech) assignFromMap(tech)
}
}
}
// ── Route computation ────────────────────────────────────────────────────────
async function computeDayRoute (tech, dateStr) {
const key = `${tech.id}||${dateStr}`
if (routeLegs.value[key] !== undefined) return
const points = []
if (tech.coords?.[0] && tech.coords?.[1]) points.push(`${tech.coords[0]},${tech.coords[1]}`)
const allJobs = [...tech.queue.filter(j => jobSpansDate(j, dateStr, tech)), ...(tech.assistJobs || []).filter(j => jobSpansDate(j, dateStr, tech))]
allJobs.forEach(j => { if (j.coords && (j.coords[0] !== 0 || j.coords[1] !== 0)) points.push(`${j.coords[0]},${j.coords[1]}`) })
function setCache (legs, geom) {
routeLegs.value = { ...routeLegs.value, [key]: legs }
routeGeometry.value = { ...routeGeometry.value, [key]: geom }
}
if (points.length < 2) { setCache([], null); return }
try {
const url = `https://api.mapbox.com/directions/v5/mapbox/driving-traffic/${points.join(';')}?overview=full&geometries=geojson&access_token=${MAPBOX_TOKEN}`
const r = await fetch(url)
if (!r.ok) throw new Error(`HTTP ${r.status}`)
const data = await r.json()
if (data.routes?.[0]) setCache(data.routes[0].legs.map(l => Math.round(l.duration / 60)), data.routes[0].geometry.coordinates)
else setCache([], null)
} catch (e) { console.warn('[route] fetch error', e); setCache([], null) }
}
// ── Draw route ───────────────────────────────────────────────────────────────
function drawSelectedRoute () {
if (!map || !mapVisible.value) return
const src = map.getSource('sb-route'); if (!src) return
const empty = { type: 'FeatureCollection', features: [] }
if (currentView.value !== 'day') { src.setData(empty); return }
const dayStr = localDateStr(periodStart.value)
const features = []
const techs = selectedTechId.value ? filteredResources.value.filter(t => t.id === selectedTechId.value) : filteredResources.value
techs.forEach(tech => {
const coords = routeGeometry.value[`${tech.id}||${dayStr}`]
if (coords?.length) features.push({ type: 'Feature', geometry: { type: 'LineString', coordinates: coords }, properties: { color: TECH_COLORS[tech.colorIdx] } })
})
src.setData({ type: 'FeatureCollection', features })
map.setPaintProperty('sb-route-halo', 'line-color', ['get', 'color'])
map.setPaintProperty('sb-route-line', 'line-color', ['get', 'color'])
}
// ── Select tech on board ─────────────────────────────────────────────────────
function selectTechOnBoard (tech) {
const wasSelected = selectedTechId.value === tech.id
selectedTechId.value = wasSelected ? null : tech.id
if (!wasSelected && currentView.value === 'day') {
if (!mapVisible.value) {
mapPanelW.value = Math.round(window.innerWidth * 0.5)
localStorage.setItem('sbv2-mapW', String(mapPanelW.value))
mapVisible.value = true
}
// Live GPS wins over the ERPNext-saved home base, so the rep
// sees where the tech actually IS right now if Traccar reports
// them online. Fall back to home coords when offline. We
// flyTo (animated pan+zoom) so the dispatcher gets a clear
// visual cue, instead of a hard jump.
const pos = tech.gpsCoords || tech.coords
if (pos && map && Number.isFinite(pos[0]) && Number.isFinite(pos[1])) {
// Defer one tick so the map panel has time to be visible
// and `map.resize()` has run before the camera animation.
nextTick(() => {
try { map.flyTo({ center: pos, zoom: Math.max(map.getZoom(), 12), speed: 1.2, essential: true }) } catch (_e) {}
})
}
}
if (map) { drawMapMarkers(); drawSelectedRoute() }
}
// ── Watchers ─────────────────────────────────────────────────────────────────
watch([selectedTechId, () => periodStart.value?.getTime(), currentView, routeGeometry], () => { if (map) { drawMapMarkers(); drawSelectedRoute() } })
watch(mapVisible, async v => {
if (v) {
if (map) { try { map.remove() } catch (_) {} map = null }
await nextTick(); await initMap()
if (map) {
const r = () => { if (!map) return; map.resize(); drawMapMarkers(); drawSelectedRoute() }
await nextTick(); r(); setTimeout(r, 100); setTimeout(r, 300); setTimeout(r, 600)
}
} else {
if (mapResizeObs) { mapResizeObs.disconnect(); mapResizeObs = null }
if (map) { try { map.remove() } catch (_) {} map = null }
}
})
watch([() => periodStart.value?.getTime(), filteredResources], () => {
if (currentView.value === 'day' && mapVisible.value && map) { drawMapMarkers(); drawSelectedRoute() }
})
watch(
() => store.technicians.map(t => t.gpsCoords),
() => { if (map) drawMapMarkers() },
{ deep: true }
)
// ── Lifecycle helpers ────────────────────────────────────────────────────────
function destroyMap () {
if (map) { map.remove(); map = null }
if (mapResizeObs) { mapResizeObs.disconnect(); mapResizeObs = null }
}
function loadMapboxCss () {
if (!document.getElementById('mapbox-css')) {
const l = document.createElement('link'); l.id = 'mapbox-css'; l.rel = 'stylesheet'
l.href = 'https://api.mapbox.com/mapbox-gl-js/v2.15.0/mapbox-gl.css'; document.head.appendChild(l)
}
}
function getMap () { return map }
return {
mapContainer, selectedTechId, mapMarkers, mapPanelW, geoFixJob, geoFixTech, mapDragJob,
startGeoFix, cancelGeoFix, startTechGeoFix, cancelTechGeoFix,
startMapResize, initMap,
drawMapMarkers, drawSelectedRoute, computeDayRoute,
selectTechOnBoard, destroyMap, loadMapboxCss, getMap,
}
}