Files

107 lines
7.8 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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><p id="imagery-info" role="status"><small>Street map · OpenStreetMap</small></p><div id="map" aria-label="Street or aerial 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;
// USGS exports Web Mercator images; its ImageServer is not an XYZ tile cache.
// Match each Leaflet tile's projected bounds explicitly to keep fixes aligned.
function aerialTileURL(coords){
const halfWorld=20037508.342789244;
const span=2*halfWorld/2**coords.z;
const west=-halfWorld+coords.x*span,north=halfWorld-coords.y*span;
const query=new URLSearchParams({
f:'image',bbox:[west,north-span,west+span,north].join(','),
bboxSR:'3857',imageSR:'3857',size:'256,256',format:'jpg',
interpolation:'RSP_BilinearInterpolation',
renderingRule:JSON.stringify({rasterFunction:'NaturalColor'})
});
return `https://imagery.nationalmap.gov/arcgis/rest/services/USGSNAIPImagery/ImageServer/exportImage?${query}`;
}
if(window.L){
map=L.map('map').setView([31.4,-89.7],7);
const street=L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png',{
maxZoom:19,attribution:'&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
}).addTo(map);
const AerialLayer=L.TileLayer.extend({getTileUrl:aerialTileURL});
const aerial=new AerialLayer('',{
maxZoom:19,bounds:[[30.1,-91.7],[35.1,-88.0]],
updateWhenIdle:true,keepBuffer:1,
attribution:'Aerial imagery: USDA NAIP / <a href="https://www.usgs.gov/programs/national-geospatial-program/national-map">USGS, The National Map</a>'
});
L.control.layers({Street:street,Aerial:aerial},null,{collapsed:false}).addTo(map);
map.on('layeradd',event=>{
if(event.layer===aerial)el('imagery-info').textContent='Loading aerial imagery · USDA NAIP / USGS · Photography dates vary; imagery is not live';
else if(event.layer===street)el('imagery-info').textContent='Street map · OpenStreetMap';
});
aerial.on('load',()=>{
if(map.hasLayer(aerial))el('imagery-info').textContent='Aerial imagery · USDA NAIP / USGS · Photography dates vary; imagery is not live';
});
aerial.on('tileerror',()=>{
if(!map.hasLayer(aerial))return;
map.removeLayer(aerial);
street.addTo(map);
el('imagery-info').textContent='Aerial imagery could not load. Showing street map; select Aerial to retry.';
});
}
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>