Files
macker/server/index.html
T

107 lines
7.8 KiB
HTML
Raw Normal View History

2026-10-28 10:54:15 -05:00
<!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>
2026-10-28 14:04:39 -05:00
*{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}}
2026-10-28 10:54:15 -05:00
</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>
2026-10-28 10:54:15 -05:00
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script>
const el=id=>document.getElementById(id);
2026-10-28 13:43:27 -05:00
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.';
});
}
2026-10-28 10:54:15 -05:00
el('follow').onclick=()=>{follow=!follow;el('follow').textContent=`Follow car: ${follow?'on':'off'}`;if(follow&&marker)map.panTo(marker.getLatLng());};
2026-10-28 13:43:27 -05:00
// 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);}
}
2026-10-28 14:04:39 -05:00
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';
}
}
2026-10-28 10:54:15 -05:00
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;
2026-10-28 14:04:39 -05:00
renderAddress(latest.nearby,stale||failed);
2026-10-28 10:54:15 -05:00
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';
}
2026-10-28 13:43:27 -05:00
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);}
2026-10-28 10:54:15 -05:00
setInterval(render,1000);poll();
</script>
</body></html>