feat(maps): zéro Mapbox (app terrain → MapLibre/OSM) + meilleur trajet trafic
- field-app.html (app tech) : carte Mapbox GL → MapLibre GL + tuiles raster OSM (aucun jeton). C'était le DERNIER Mapbox vivant.
- NOUVEAU « 🚀 Meilleur trajet du jour (trafic en direct) » : ordre optimal des arrêts via NOTRE OSRM /table + plus-proche-voisin (le plugin /trip n'est pas compilé), puis handoff à Google Maps multi-arrêts → trafic EN DIRECT gratuit (OSM seul n'a pas de trafic). Endpoint hub GET /field/route?t=&from=lat,lon.
- config/erpnext.js MAPBOX_TOKEN vidé + JobEditModal (module mort) : vignette statique api.mapbox.com → lien OSM. Bundle SPA désormais SANS jeton Mapbox ni api.mapbox.com (vérifié).
- serveFieldApp : retrait de l'injection du jeton Mapbox.
Vérifié live : field-app rendu = MapLibre (0 api.mapbox.com) ; OSRM /table Ok ; ordre A→C→B correct depuis origine ouest ; /field/route 200. Reste (co-édité, déployé non-commité) : tooltips PlanificationPage « Mapbox »→« OSRM/OSM » + retrait carte réglages Mapbox.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
218bef8c5a
commit
9cb9739d3f
|
|
@ -7,7 +7,9 @@ export const BASE_URL = viteBase === '/' ? '' : viteBase.replace(/\/$/, '')
|
|||
// Direct link to ERPNext desk (for admin actions like user management)
|
||||
export const ERP_DESK_URL = 'https://erp.gigafibre.ca'
|
||||
|
||||
export const MAPBOX_TOKEN = 'pk.eyJ1IjoidGFyZ29pbnRlcm5ldCIsImEiOiJjbW13Z3lwMXAwdGt1MnVvamsxNWkybzFkIn0.rdYB17XUdfn96czdnnJ6eg'
|
||||
// DÉPRÉCIÉ : les cartes OPS utilisent MapLibre + tuiles OSM auto-hébergées (config/basemap.js), plus aucun Mapbox.
|
||||
// Constante gardée vide pour compat des rares injections/destructurations résiduelles (aucune n'appelle Mapbox).
|
||||
export const MAPBOX_TOKEN = ''
|
||||
|
||||
export const TECH_COLORS = [
|
||||
'#6366f1', // Indigo
|
||||
|
|
|
|||
|
|
@ -71,8 +71,10 @@ function close () { emit('update:modelValue', null); emit('cancel') }
|
|||
</div>
|
||||
</div>
|
||||
<div v-if="modelValue.latitude" class="sb-wo-minimap">
|
||||
<img :src="'https://api.mapbox.com/styles/v1/mapbox/dark-v11/static/pin-s+6366f1('+modelValue.longitude+','+modelValue.latitude+')/'+modelValue.longitude+','+modelValue.latitude+',14,0/280x280@2x?access_token='+MAPBOX_TOKEN"
|
||||
alt="Carte" class="sb-minimap-img" />
|
||||
<!-- Plus de vignette Mapbox (aucun jeton) : coords + lien carte OSM/Google (le vrai rendu carte = MapLibre ailleurs). -->
|
||||
<a :href="'https://www.openstreetmap.org/?mlat='+modelValue.latitude+'&mlon='+modelValue.longitude+'#map=16/'+modelValue.latitude+'/'+modelValue.longitude" target="_blank" rel="noopener" class="sb-minimap-link">
|
||||
📍 {{ (+modelValue.latitude).toFixed(5) }}, {{ (+modelValue.longitude).toFixed(5) }} — voir sur la carte
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="sb-modal-ftr">
|
||||
|
|
|
|||
|
|
@ -941,12 +941,11 @@ function fieldSign (name) { const h = crypto.createHmac('sha256', FIELD_SECRET).
|
|||
function fieldVerify (token) { try { const [b, h] = String(token || '').split('.'); if (!b || !h) return null; const name = Buffer.from(b, 'base64url').toString('utf8'); const exp = crypto.createHmac('sha256', FIELD_SECRET).update(name).digest('hex').slice(0, 12); return crypto.timingSafeEqual(Buffer.from(h), Buffer.from(exp)) ? name : null } catch (e) { return null } }
|
||||
function haversineM (a1, o1, a2, o2) { const R = 6371000, t = Math.PI / 180; const dA = (a2 - a1) * t, dO = (o2 - o1) * t; const x = Math.sin(dA / 2) ** 2 + Math.cos(a1 * t) * Math.cos(a2 * t) * Math.sin(dO / 2) ** 2; return Math.round(R * 2 * Math.atan2(Math.sqrt(x), Math.sqrt(1 - x))) }
|
||||
function fmtDistM (m) { return m < 1000 ? (m + ' m') : ((m / 1000).toFixed(m < 10000 ? 1 : 0) + ' km') }
|
||||
// Token PAR TECH (préfixe 'T:') → l'app charge les jobs du jour du tech. Token Mapbox public (pk) pour la carte de l'app.
|
||||
const FIELD_MAPBOX = 'pk.eyJ1IjoidGFyZ29pbnRlcm5ldCIsImEiOiJjbW13Z3lwMXAwdGt1MnVvamsxNWkybzFkIn0.rdYB17XUdfn96czdnnJ6eg'
|
||||
// Token PAR TECH (préfixe 'T:') → l'app charge les jobs du jour du tech. Carte = MapLibre + tuiles OSM (aucun jeton Mapbox).
|
||||
function techFieldSign (name) { return fieldSign('T:' + name) }
|
||||
function techFieldVerify (t) { const v = fieldVerify(t); return (v && v.startsWith('T:')) ? v.slice(2) : null }
|
||||
function serveFieldApp (res) {
|
||||
try { let html = require('fs').readFileSync('/app/public/field-app.html', 'utf8'); html = html.split('__MAPBOX_TOKEN__').join(FIELD_MAPBOX).split('__HUB__').join(cfg.PUBLIC_BASE || 'https://msg.gigafibre.ca'); res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); return res.end(html) } catch (e) { res.writeHead(500); return res.end('app terrain indisponible') }
|
||||
try { let html = require('fs').readFileSync('/app/public/field-app.html', 'utf8'); html = html.split('__HUB__').join(cfg.PUBLIC_BASE || 'https://msg.gigafibre.ca'); res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); return res.end(html) } catch (e) { res.writeHead(500); return res.end('app terrain indisponible') }
|
||||
}
|
||||
function savePhoto (jobName, dataUrl) {
|
||||
const m = /^data:image\/(\w+);base64,(.+)$/.exec(String(dataUrl || '')); if (!m) return null
|
||||
|
|
@ -1027,6 +1026,42 @@ async function handleFieldTech (req, res, method, path, url) {
|
|||
const jobs = (rows || []).map(j => ({ name: j.name, subject: j.subject, customer: j.customer_name || '', address: j.address || '', lat: j.latitude, lon: j.longitude, date: j.scheduled_date, start: j.start_time ? String(j.start_time).slice(0, 5) : '', status: j.status, started: !!j.actual_start, ended: !!j.actual_end, minutes: minutesBetween(j.actual_start, j.actual_end), token: fieldSign(j.name), reply: j.legacy_ticket_id ? `https://store.targo.ca/targo/reply_ticket.php?ticket=${j.legacy_ticket_id}&staff=${staffId}` : '', activation: j.legacy_activation_url || '', notes: j.notes || '', skill: j.required_skill || '', dur: j.duration_h || null }))
|
||||
return json(res, 200, { ok: true, tech, jobs })
|
||||
}
|
||||
// MEILLEUR TRAJET du jour : ordre optimal des arrêts via NOTRE OSRM (OSM, sans trafic). La navigation réelle
|
||||
// (trafic en direct) est laissée au GPS du téléphone (Google Maps multi-arrêts) → zéro Mapbox. `from`=lat,lon = position courante.
|
||||
if (path === '/field/route' && method === 'GET') {
|
||||
const tech = techFieldVerify(token); if (!tech) return json(res, 403, { ok: false, error: 'lien invalide' })
|
||||
const today = todayET()
|
||||
const rows = await erp.list('Dispatch Job', { filters: [['assigned_tech', '=', tech], ['scheduled_date', '=', today]], fields: ['name', 'subject', 'address', 'latitude', 'longitude', 'start_time', 'actual_end', 'status'], orderBy: 'start_time asc', limit: 60 })
|
||||
let stops = (rows || [])
|
||||
.filter(j => j.latitude != null && Math.abs(+j.latitude) > 0.01 && !j.actual_end && j.status !== 'Completed' && j.status !== 'Cancelled')
|
||||
.map(j => ({ name: j.name, subject: j.subject || '', address: j.address || '', lat: +j.latitude, lon: +j.longitude }))
|
||||
let ordered = false
|
||||
const from = url.searchParams.get('from') // "lat,lon" position courante (optionnel)
|
||||
if (stops.length >= 2) {
|
||||
try {
|
||||
// Ordre optimal via la MATRICE OSRM /table (le plugin /trip n'est pas compilé sur notre OSRM) + plus-proche-voisin
|
||||
// depuis l'origine. Heuristique TSP suffisante pour une tournée de tech ; le trafic réel = géré par la nav du téléphone.
|
||||
const coords = []
|
||||
const hasFrom = from && /^-?\d+(\.\d+)?,-?\d+(\.\d+)?$/.test(from)
|
||||
if (hasFrom) { const [la, lo] = from.split(','); coords.push([+lo, +la]) } // origine (position courante) en index 0
|
||||
stops.forEach(s => coords.push([s.lon, s.lat]))
|
||||
const coordStr = coords.map(c => c[0].toFixed(6) + ',' + c[1].toFixed(6)).join(';')
|
||||
const j = await osrmGet('/table/v1/driving/' + coordStr + '?annotations=duration')
|
||||
if (j && j.code === 'Ok' && Array.isArray(j.durations) && j.durations.length === coords.length) {
|
||||
const D = j.durations; const n = coords.length; const visited = new Array(n).fill(false)
|
||||
visited[0] = true; let cur = 0; const tour = [0] // départ = origine si fournie, sinon 1er arrêt (ordre horaire)
|
||||
for (let step = 1; step < n; step++) {
|
||||
let best = -1; let bestD = Infinity
|
||||
for (let k = 0; k < n; k++) { if (visited[k]) continue; const dd = (D[cur] && D[cur][k] != null) ? D[cur][k] : Infinity; if (dd < bestD) { bestD = dd; best = k } }
|
||||
if (best < 0) break; visited[best] = true; tour.push(best); cur = best
|
||||
}
|
||||
const seq = tour.map(idx => hasFrom ? (idx >= 1 ? stops[idx - 1] : null) : stops[idx]).filter(Boolean)
|
||||
if (seq.length === stops.length) { stops = seq; ordered = true }
|
||||
}
|
||||
} catch (e) { /* OSRM indispo → ordre horaire conservé */ }
|
||||
}
|
||||
return json(res, 200, { ok: true, ordered, count: stops.length, stops })
|
||||
}
|
||||
if (path === '/field/photo' && method === 'POST') { // photo (base64) → /app/uploads/field + note sur le job (+ géoloc → confirme la présence au bon endroit)
|
||||
const b = await parseBody(req); const name = fieldVerify(b.token || token); if (!name) return json(res, 403, { ok: false, error: 'lien invalide' })
|
||||
const fn = savePhoto(name, b.image); if (!fn) return json(res, 400, { ok: false, error: 'image invalide' })
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
<!doctype html><html lang=fr><head><meta charset=utf-8>
|
||||
<meta name=viewport content="width=device-width,initial-scale=1,viewport-fit=cover,maximum-scale=1">
|
||||
<title>Targo Tech</title>
|
||||
<link href="https://api.mapbox.com/mapbox-gl-js/v3.6.0/mapbox-gl.css" rel=stylesheet>
|
||||
<script src="https://api.mapbox.com/mapbox-gl-js/v3.6.0/mapbox-gl.js"></script>
|
||||
<!-- Carte : MapLibre GL + tuiles OSM (aucun Mapbox, aucun jeton). -->
|
||||
<link href="https://unpkg.com/maplibre-gl@4.7.1/dist/maplibre-gl.css" rel=stylesheet>
|
||||
<script src="https://unpkg.com/maplibre-gl@4.7.1/dist/maplibre-gl.js"></script>
|
||||
<style>
|
||||
*{box-sizing:border-box}body{font-family:-apple-system,Segoe UI,Roboto,sans-serif;margin:0;background:#0f172a;color:#e2e8f0}
|
||||
.wrap{max-width:640px;margin:0 auto;padding:12px;padding-bottom:80px}
|
||||
|
|
@ -32,6 +33,7 @@
|
|||
<!-- LISTE -->
|
||||
<div id=list>
|
||||
<h1>Mes interventions <span class=sub id=techname></span></h1>
|
||||
<button id=bestroute onclick=bestRoute() style="display:none;width:100%;background:#0e7c7b;color:#fff;border:0;border-radius:12px;padding:13px;font-size:15px;font-weight:700;margin:6px 0 10px">🚀 Meilleur trajet du jour (trafic en direct)</button>
|
||||
<div id=jobs class=sub>Chargement…</div>
|
||||
</div>
|
||||
<!-- DÉTAIL -->
|
||||
|
|
@ -79,7 +81,9 @@
|
|||
</div>
|
||||
<div class=toast id=toast></div>
|
||||
<script>
|
||||
const HUB='__HUB__', MAPBOX='__MAPBOX_TOKEN__', T=new URLSearchParams(location.search).get('t')||'';
|
||||
const HUB='__HUB__', T=new URLSearchParams(location.search).get('t')||'';
|
||||
// Style MapLibre = tuiles raster OSM (libre, sans jeton). Attribution © OpenStreetMap requise (ODbL).
|
||||
const OSM_STYLE={version:8,sources:{osm:{type:'raster',tiles:['https://a.tile.openstreetmap.org/{z}/{x}/{y}.png','https://b.tile.openstreetmap.org/{z}/{x}/{y}.png','https://c.tile.openstreetmap.org/{z}/{x}/{y}.png'],tileSize:256,attribution:'© OpenStreetMap'}},layers:[{id:'osm',type:'raster',source:'osm'}]};
|
||||
const CAP=window.Capacitor, NATIVE=!!(CAP&&CAP.isNativePlatform&&CAP.isNativePlatform()), MLKIT=CAP&&CAP.Plugins&&CAP.Plugins.BarcodeScanning;
|
||||
const $=id=>document.getElementById(id); let JOBS=[],CUR=null,map=null,jobMk=null,meMk=null,watch=null,enteredZone=false;
|
||||
function toast(m){const t=$('toast');t.textContent=m;t.style.display='block';setTimeout(()=>t.style.display='none',2600)}
|
||||
|
|
@ -105,6 +109,27 @@ function renderList(){
|
|||
JOBS.forEach((j,i)=>{const d=document.createElement('div');d.className='card job';d.onclick=()=>openDetail(i);
|
||||
d.innerHTML='<div style=flex:1><div style=font-weight:600>'+esc(dec(j.subject)||'Job')+'</div><div class=sub>'+(j.start?j.start+' · ':'')+esc(dec(j.address))+'</div></div>'+badge(j);
|
||||
$('jobs').appendChild(d)});
|
||||
// Bouton « meilleur trajet » dès qu'il reste ≥2 arrêts géolocalisés non terminés (sinon 1 seul → « Itinéraire » du détail suffit).
|
||||
const stops=JOBS.filter(j=>!j.ended&&j.lat&&Math.abs(+j.lat)>0.01);
|
||||
$('bestroute').style.display=stops.length>=2?'block':'none';
|
||||
}
|
||||
// MEILLEUR TRAJET : l'ordre optimal des arrêts vient de NOTRE OSRM (OSM, sans trafic) ; la navigation réelle est
|
||||
// remise au GPS du téléphone (Google Maps) qui applique le TRAFIC EN DIRECT gratuitement. Zéro Mapbox.
|
||||
async function bestRoute(){
|
||||
const btn=$('bestroute');btn.disabled=true;const lbl=btn.textContent;btn.textContent='⏳ Calcul du trajet…';
|
||||
let origin=null;try{const p=await getPos();origin=p.coords.latitude.toFixed(6)+','+p.coords.longitude.toFixed(6)}catch(e){}
|
||||
let r=null;try{r=await api('/field/route?t='+encodeURIComponent(T)+(origin?'&from='+origin:''))}catch(e){}
|
||||
btn.disabled=false;btn.textContent=lbl;
|
||||
const stops=(r&&r.ok&&r.stops&&r.stops.length)?r.stops:JOBS.filter(j=>!j.ended&&j.lat&&Math.abs(+j.lat)>0.01).map(j=>({lat:j.lat,lon:j.lon}));
|
||||
if(!stops.length){toast('Aucun arrêt géolocalisé');return}
|
||||
const pts=stops.map(s=>s.lat+','+s.lon);
|
||||
const dest=pts[pts.length-1], way=pts.slice(0,-1);
|
||||
// Google Maps applique le trafic en direct en mode driving. origin omis = position courante ; sinon GPS mesuré.
|
||||
let url='https://www.google.com/maps/dir/?api=1&travelmode=driving&destination='+encodeURIComponent(dest);
|
||||
if(origin)url+='&origin='+encodeURIComponent(origin);
|
||||
if(way.length)url+='&waypoints='+encodeURIComponent(way.join('|'));
|
||||
window.open(url,'_blank');
|
||||
toast('🚀 '+pts.length+' arrêt(s)'+(r&&r.ordered?' · ordre optimisé':''));
|
||||
}
|
||||
function esc(s){return String(s||'').replace(/[<>&]/g,c=>({'<':'<','>':'>','&':'&'}[c]))}
|
||||
function dec(s){var v=String(s||'');if(v.indexOf('&')<0)return v;for(var i=0;i<2;i++){var t=document.createElement('textarea');t.innerHTML=v;if(t.value===v)break;v=t.value}return v} // décode les entités HTML (' é …) venues d'osTicket AVANT affichage
|
||||
|
|
@ -131,11 +156,12 @@ async function toggleChrono(){
|
|||
async function checkpoint(type,pos){const body={token:CUR.token,type};if(pos){body.lat=pos.coords.latitude;body.lon=pos.coords.longitude;body.acc=pos.coords.accuracy}
|
||||
return api('/field/checkpoint',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)}).catch(()=>({ok:false}))}
|
||||
function getPos(){return new Promise((res,rej)=>navigator.geolocation.getCurrentPosition(res,rej,{enableHighAccuracy:true,timeout:10000}))}
|
||||
function initMap(hasLL){mapboxgl.accessToken=MAPBOX;map=new mapboxgl.Map({container:'map',style:'mapbox://styles/mapbox/streets-v12',center:hasLL?[+CUR.lon,+CUR.lat]:[-73.9,45.18],zoom:hasLL?15:9});
|
||||
map.on('load',()=>{map.resize();if(hasLL){jobMk=new mapboxgl.Marker({color:'#ea580c'}).setLngLat([+CUR.lon,+CUR.lat]).addTo(map)}})}
|
||||
function initMap(hasLL){map=new maplibregl.Map({container:'map',style:OSM_STYLE,center:hasLL?[+CUR.lon,+CUR.lat]:[-73.9,45.18],zoom:hasLL?15:9});
|
||||
map.addControl(new maplibregl.NavigationControl({showCompass:false}),'top-right');
|
||||
map.on('load',()=>{map.resize();if(hasLL){jobMk=new maplibregl.Marker({color:'#ea580c'}).setLngLat([+CUR.lon,+CUR.lat]).addTo(map)}})}
|
||||
function startWatch(hasLL){if(!navigator.geolocation)return;
|
||||
watch=navigator.geolocation.watchPosition(p=>{const ll=[p.coords.longitude,p.coords.latitude];
|
||||
if(map){if(!meMk){meMk=new mapboxgl.Marker({color:'#38bdf8'}).setLngLat(ll).addTo(map)}else meMk.setLngLat(ll)}
|
||||
if(map){if(!meMk){meMk=new maplibregl.Marker({color:'#38bdf8'}).setLngLat(ll).addTo(map)}else meMk.setLngLat(ll)}
|
||||
if(hasLL){const d=dist(+CUR.lat,+CUR.lon,p.coords.latitude,p.coords.longitude);$('dgeo').textContent='à '+d+' m de l\'adresse (±'+Math.round(p.coords.accuracy)+' m)';
|
||||
if(d<=250&&!enteredZone&&!CUR.started){enteredZone=true;CUR.started=true;setChrono();$('dbadge').innerHTML=badge(CUR);checkpoint('geo_enter',p);toast('Arrivée détectée (auto)')}}
|
||||
},e=>{$('dgeo').innerHTML='<span class=warn>Active la localisation pour le suivi auto</span>'},{enableHighAccuracy:true,maximumAge:15000,timeout:20000})}
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user