68 lines
5.9 KiB
HTML
68 lines
5.9 KiB
HTML
<!doctype html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||
<title>Macker · Work car</title>
|
||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css">
|
||
<style>
|
||
*{box-sizing:border-box}body{margin:0;background:#f3f5f2;color:#182d26;font:16px system-ui,sans-serif}header{padding:24px 5vw;display:flex;justify-content:space-between;align-items:center}h1{font-size:24px;margin:0}header p{margin:4px 0;color:#607269}main{margin:0 5vw 28px}.stats{display:flex;gap:16px;margin-bottom:20px}.card{background:white;border:1px solid #dce4dd;border-radius:12px;padding:20px;flex:1}.label{font-size:12px;text-transform:uppercase;letter-spacing:.1em;color:#607269}.value{display:block;font-size:32px;margin:8px 0}small{color:#607269}#address{font-size:20px;overflow-wrap:anywhere}.stats{flex-wrap:wrap}.card{min-width:220px}#map{height:65vh;min-height:320px;border-radius:14px;border:1px solid #cbd8cd}button{background:#1c593f;color:white;border:0;border-radius:8px;padding:12px;cursor:pointer}#status{margin:12px 0;min-height:24px}@media(max-width:540px){.stats{flex-direction:column;gap:10px}.card{padding:14px}.value{font-size:26px}header{gap:12px}#address{font-size:20px;overflow-wrap:anywhere}.stats{flex-wrap:wrap}.card{min-width:220px}#map{height:50vh}}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<header><div><h1>Macker</h1><p>Work car · Live location</p></div><button id="follow" type="button">Follow car: on</button></header>
|
||
<main><div class="stats"><section class="card"><span class="label">Current speed</span><strong class="value" id="speed">—</strong><small id="metric">Waiting for GPS</small></section><section class="card"><span class="label">Last update</span><strong class="value" id="age">—</strong><small id="time">No location received yet</small></section><section class="card"><span class="label">Nearby address</span><strong class="value" id="address">—</strong><small id="address-distance">Waiting for GPS</small><br><small id="address-source"></small></section></div><p id="status" role="status">Waiting for the car’s first GPS fix…</p><p id="uncertainty"><small>GPS uncertainty unavailable</small></p><div id="map" aria-label="Car location map with GPS uncertainty circle"></div></main>
|
||
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
||
<script>
|
||
const el=id=>document.getElementById(id);
|
||
let map, marker, uncertaintyCircle, latest, follow=true, failed=false;
|
||
if(window.L){map=L.map('map').setView([20,0],2);L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png',{maxZoom:19,attribution:'© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'}).addTo(map);}
|
||
el('follow').onclick=()=>{follow=!follow;el('follow').textContent=`Follow car: ${follow?'on':'off'}`;if(follow&&marker)map.panTo(marker.getLatLng());};
|
||
// gpsd axis errors are already in metres. Enclose their horizontal error box.
|
||
// Zero also represents an omitted estimate in the client's gpsd dependency.
|
||
function uncertaintyRadius(point){
|
||
const {LatErr,LongErr}=point;
|
||
if(!Number.isFinite(LatErr)||!Number.isFinite(LongErr)||LatErr<=0||LongErr<=0)return null;
|
||
const radius=Math.hypot(LatErr,LongErr);
|
||
return Number.isFinite(radius)?radius:null;
|
||
}
|
||
function updateUncertainty(point,pos){
|
||
const radius=uncertaintyRadius(point);
|
||
el('uncertainty').textContent=radius===null?'GPS uncertainty unavailable':`Estimated GPS uncertainty: ${radius.toFixed(1)} m radius · shaded circle`;
|
||
if(radius===null){
|
||
if(uncertaintyCircle){map.removeLayer(uncertaintyCircle);uncertaintyCircle=null;}
|
||
return;
|
||
}
|
||
if(!uncertaintyCircle){
|
||
uncertaintyCircle=L.circle(pos,{radius,color:'#287a57',weight:2,fillColor:'#409b73',fillOpacity:0.16,interactive:false}).addTo(map);
|
||
}else{uncertaintyCircle.setLatLng(pos).setRadius(radius);}
|
||
}
|
||
function renderAddress(nearby,stale){
|
||
const address=nearby?.address;
|
||
el('address').textContent=address?.label||'—';
|
||
el('address-source').textContent=address?.attribution||'';
|
||
if(address){
|
||
const m=address.distance_meters;
|
||
const distance=m<1000?`${Math.round(m)} m`:`${(m/1000).toFixed(1)} km`;
|
||
el('address-distance').textContent=`Approximately ${distance} away · straight-line${stale?' · last known fix':''}`;
|
||
}else{
|
||
const messages={disabled:'Address lookup is not configured',pending:'Looking up nearby address…',unavailable:'No nearby mapped address available'};
|
||
el('address-distance').textContent=messages[nearby?.status]||'Address unavailable';
|
||
}
|
||
}
|
||
function render(){
|
||
if(!latest){el('status').textContent=failed?'Cannot reach server. Retrying…':'Waiting for the car’s first GPS fix…';return;}
|
||
const seconds=Math.max(0,Math.floor((Date.now()-Date.parse(latest.updated_at))/1000));
|
||
const stale=seconds>30,p=latest.point;
|
||
renderAddress(latest.nearby,stale||failed);
|
||
el('age').textContent=seconds<60?`${seconds} s ago`:seconds<3600?`${Math.floor(seconds/60)} min ago`:`${Math.floor(seconds/3600)} h ago`;
|
||
el('time').textContent=new Date(latest.updated_at).toLocaleString();
|
||
el('speed').textContent=stale||p.Speed==null?'—':`${(p.Speed*2.236936).toFixed(1)} mph`;
|
||
el('metric').textContent=stale?'Last fix is stale':p.Speed==null?'Speed unavailable':`${(p.Speed*3.6).toFixed(1)} km/h`;
|
||
el('status').textContent=failed?'Connection lost · showing last known location':stale?'No recent GPS fix · showing last known location':`GPS active · ${p.Lat.toFixed(5)}, ${p.Long.toFixed(5)}`;
|
||
if(!map)el('status').textContent+=' · Map failed to load; reload to retry';
|
||
}
|
||
async function poll(){try{const res=await fetch('/api/location',{cache:'no-store',signal:AbortSignal.timeout(8000)});if(!res.ok)throw Error(res.status);latest=await res.json();failed=false;if(latest&&map){const pos=[latest.point.Lat,latest.point.Long];updateUncertainty(latest.point,pos);if(!marker){marker=L.marker(pos).addTo(map);map.setView(pos,15);}else{marker.setLatLng(pos);if(follow)map.panTo(pos);}}}catch(e){failed=true;}render();setTimeout(poll,2000);}
|
||
setInterval(render,1000);poll();
|
||
</script>
|
||
</body></html>
|