// ── 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, } }