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:
louispaulb 2026-07-19 08:01:00 -04:00
parent 218bef8c5a
commit 9cb9739d3f
4 changed files with 77 additions and 12 deletions

View File

@ -7,7 +7,9 @@ export const BASE_URL = viteBase === '/' ? '' : viteBase.replace(/\/$/, '')
// Direct link to ERPNext desk (for admin actions like user management) // Direct link to ERPNext desk (for admin actions like user management)
export const ERP_DESK_URL = 'https://erp.gigafibre.ca' 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 = [ export const TECH_COLORS = [
'#6366f1', // Indigo '#6366f1', // Indigo

View File

@ -71,8 +71,10 @@ function close () { emit('update:modelValue', null); emit('cancel') }
</div> </div>
</div> </div>
<div v-if="modelValue.latitude" class="sb-wo-minimap"> <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" <!-- Plus de vignette Mapbox (aucun jeton) : coords + lien carte OSM/Google (le vrai rendu carte = MapLibre ailleurs). -->
alt="Carte" class="sb-minimap-img" /> <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> </div>
<div class="sb-modal-ftr"> <div class="sb-modal-ftr">

View File

@ -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 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 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') } 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. // Token PAR TECH (préfixe 'T:') → l'app charge les jobs du jour du tech. Carte = MapLibre + tuiles OSM (aucun jeton Mapbox).
const FIELD_MAPBOX = 'pk.eyJ1IjoidGFyZ29pbnRlcm5ldCIsImEiOiJjbW13Z3lwMXAwdGt1MnVvamsxNWkybzFkIn0.rdYB17XUdfn96czdnnJ6eg'
function techFieldSign (name) { return fieldSign('T:' + name) } function techFieldSign (name) { return fieldSign('T:' + name) }
function techFieldVerify (t) { const v = fieldVerify(t); return (v && v.startsWith('T:')) ? v.slice(2) : null } function techFieldVerify (t) { const v = fieldVerify(t); return (v && v.startsWith('T:')) ? v.slice(2) : null }
function serveFieldApp (res) { 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) { function savePhoto (jobName, dataUrl) {
const m = /^data:image\/(\w+);base64,(.+)$/.exec(String(dataUrl || '')); if (!m) return null 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 })) 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 }) 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) 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 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' }) const fn = savePhoto(name, b.image); if (!fn) return json(res, 400, { ok: false, error: 'image invalide' })

View File

@ -1,8 +1,9 @@
<!doctype html><html lang=fr><head><meta charset=utf-8> <!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"> <meta name=viewport content="width=device-width,initial-scale=1,viewport-fit=cover,maximum-scale=1">
<title>Targo Tech</title> <title>Targo Tech</title>
<link href="https://api.mapbox.com/mapbox-gl-js/v3.6.0/mapbox-gl.css" rel=stylesheet> <!-- Carte : MapLibre GL + tuiles OSM (aucun Mapbox, aucun jeton). -->
<script src="https://api.mapbox.com/mapbox-gl-js/v3.6.0/mapbox-gl.js"></script> <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> <style>
*{box-sizing:border-box}body{font-family:-apple-system,Segoe UI,Roboto,sans-serif;margin:0;background:#0f172a;color:#e2e8f0} *{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} .wrap{max-width:640px;margin:0 auto;padding:12px;padding-bottom:80px}
@ -32,6 +33,7 @@
<!-- LISTE --> <!-- LISTE -->
<div id=list> <div id=list>
<h1>Mes interventions <span class=sub id=techname></span></h1> <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 id=jobs class=sub>Chargement…</div>
</div> </div>
<!-- DÉTAIL --> <!-- DÉTAIL -->
@ -79,7 +81,9 @@
</div> </div>
<div class=toast id=toast></div> <div class=toast id=toast></div>
<script> <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 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; 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)} 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); 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); 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)}); $('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=>({'<':'&lt;','>':'&gt;','&':'&amp;'}[c]))} function esc(s){return String(s||'').replace(/[<>&]/g,c=>({'<':'&lt;','>':'&gt;','&':'&amp;'}[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 (&#039; &eacute; ) venues d'osTicket AVANT affichage 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 (&#039; &eacute; ) 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} 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}))} 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 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}); 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.on('load',()=>{map.resize();if(hasLL){jobMk=new mapboxgl.Marker({color:'#ea580c'}).setLngLat([+CUR.lon,+CUR.lat]).addTo(map)}})} 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; function startWatch(hasLL){if(!navigator.geolocation)return;
watch=navigator.geolocation.watchPosition(p=>{const ll=[p.coords.longitude,p.coords.latitude]; 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(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)')}} 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})} },e=>{$('dgeo').innerHTML='<span class=warn>Active la localisation pour le suivi auto</span>'},{enableHighAccuracy:true,maximumAge:15000,timeout:20000})}