Worker + subdomain now drones.theradicalparty.com; in-app title/brand, package names, docs updated. D1 database binding kept as-is. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
236 lines
12 KiB
JavaScript
236 lines
12 KiB
JavaScript
// The Drones dashboard, served as a single self-contained HTML document.
|
|
// MapLibre GL JS (free, no API key): satellite imagery + Terrarium DEM for real
|
|
// 3D terrain relief. Detections render as species markers draped on the terrain;
|
|
// a timeline scrubber replays the survey as the drone flew it.
|
|
|
|
export function page(cfg) {
|
|
const c = JSON.stringify(cfg);
|
|
return `<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8" />
|
|
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1" />
|
|
<title>Drones — drone wildlife survey</title>
|
|
<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>
|
|
:root{--bg:#0b0f14;--panel:#121821e6;--line:#223041;--txt:#e6edf3;--mut:#8aa0b4;--accent:#39d98a;}
|
|
*{box-sizing:border-box}
|
|
html,body{margin:0;height:100%;font-family:ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,sans-serif;background:var(--bg);color:var(--txt)}
|
|
#map{position:absolute;inset:0}
|
|
.panel{position:absolute;background:var(--panel);backdrop-filter:blur(8px);border:1px solid var(--line);border-radius:12px;box-shadow:0 8px 30px #0008}
|
|
#hud{top:12px;left:12px;width:290px;padding:14px 16px}
|
|
#hud h1{margin:0 0 2px;font-size:16px;letter-spacing:.3px}
|
|
#hud .sub{color:var(--mut);font-size:12px;margin-bottom:10px}
|
|
.badge{display:inline-block;font-size:10px;font-weight:700;padding:2px 7px;border-radius:20px;letter-spacing:.5px;vertical-align:middle}
|
|
.badge.sim{background:#3a2a00;color:#ffcf4d;border:1px solid #6b5200}
|
|
.badge.live{background:#06301f;color:var(--accent);border:1px solid #0a5c3b}
|
|
.stats{display:grid;grid-template-columns:1fr 1fr;gap:8px;margin:10px 0}
|
|
.stat{background:#0e141c;border:1px solid var(--line);border-radius:8px;padding:8px}
|
|
.stat b{display:block;font-size:20px}
|
|
.stat span{color:var(--mut);font-size:11px}
|
|
.legend{margin-top:8px;max-height:210px;overflow:auto}
|
|
.lg{display:flex;align-items:center;justify-content:space-between;gap:8px;padding:4px 6px;border-radius:7px;cursor:pointer;user-select:none}
|
|
.lg:hover{background:#0e141c}
|
|
.lg.off{opacity:.35}
|
|
.lg .l{display:flex;align-items:center;gap:8px;font-size:13px}
|
|
.dot{width:16px;height:16px;border-radius:50%;display:grid;place-items:center;font-size:10px}
|
|
.lg .n{color:var(--mut);font-size:12px}
|
|
#timeline{bottom:14px;left:50%;transform:translateX(-50%);width:min(720px,92vw);padding:10px 16px;display:flex;align-items:center;gap:12px}
|
|
#timeline button{background:#182231;color:var(--txt);border:1px solid var(--line);border-radius:8px;width:38px;height:34px;font-size:15px;cursor:pointer}
|
|
#timeline button:hover{border-color:var(--accent)}
|
|
#timeline input[type=range]{flex:1;accent-color:var(--accent)}
|
|
#clock{font-variant-numeric:tabular-nums;color:var(--mut);font-size:12px;min-width:150px;text-align:right}
|
|
.ctl{position:absolute;top:12px;right:12px;padding:8px}
|
|
.ctl label{display:flex;align-items:center;gap:8px;font-size:12px;color:var(--mut);padding:4px 6px}
|
|
.marker{cursor:pointer;transition:transform .1s;filter:drop-shadow(0 1px 2px #000a)}
|
|
.marker:hover{transform:scale(1.25)}
|
|
.maplibregl-popup-content{background:#0e141c;color:var(--txt);border:1px solid var(--line);border-radius:10px;font-size:12px;padding:10px 12px}
|
|
.maplibregl-popup-tip{border-top-color:#0e141c!important;border-bottom-color:#0e141c!important}
|
|
.pk{color:var(--mut)}
|
|
#loading{position:absolute;inset:0;display:grid;place-items:center;background:var(--bg);z-index:9;font-size:14px;color:var(--mut)}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div id="map"></div>
|
|
<div id="loading">Loading terrain & survey…</div>
|
|
|
|
<div id="hud" class="panel">
|
|
<h1>🚁 Drones <span id="mode" class="badge sim">SIM</span></h1>
|
|
<div class="sub" id="surveyName">—</div>
|
|
<div class="stats">
|
|
<div class="stat"><b id="sTotal">0</b><span>detections</span></div>
|
|
<div class="stat"><b id="sArea">0</b><span>km² surveyed</span></div>
|
|
<div class="stat"><b id="sDur">0</b><span>min flight</span></div>
|
|
<div class="stat"><b id="sConf">0%</b><span>avg confidence</span></div>
|
|
</div>
|
|
<div style="display:flex;align-items:center;gap:8px;margin:6px 0 4px">
|
|
<span style="font-size:12px;color:var(--mut)">min confidence</span>
|
|
<input id="conf" type="range" min="0" max="100" value="40" style="flex:1;accent-color:var(--accent)">
|
|
<span id="confVal" style="font-size:12px;min-width:34px;text-align:right">40%</span>
|
|
</div>
|
|
<div class="legend" id="legend"></div>
|
|
</div>
|
|
|
|
<div class="ctl panel">
|
|
<label><input type="checkbox" id="tgPath" checked> flight path</label>
|
|
<label><input type="checkbox" id="tgTerrain" checked> 3D terrain</label>
|
|
</div>
|
|
|
|
<div id="timeline" class="panel">
|
|
<button id="play">▶</button>
|
|
<input id="scrub" type="range" min="0" max="1000" value="1000">
|
|
<div id="clock">—</div>
|
|
</div>
|
|
|
|
<script>
|
|
const CFG = ${c};
|
|
const SPECIES_STYLE = {
|
|
kangaroo:{icon:'🦘',color:'#e8a33d'}, deer:{icon:'🦌',color:'#c67b4a'},
|
|
rabbit:{icon:'🐇',color:'#c9c9c9'}, fox:{icon:'🦊',color:'#e5622d'},
|
|
boar:{icon:'🐗',color:'#8a6f52'}, person:{icon:'🧍',color:'#4db8ff'},
|
|
vehicle:{icon:'🚜',color:'#b45cff'}, unknown:{icon:'❔',color:'#7d8b99'}
|
|
};
|
|
|
|
const map = new maplibregl.Map({
|
|
container:'map',
|
|
center: CFG.center, zoom:14.2, pitch:62, bearing:-18, maxPitch:80, antialias:true,
|
|
style:{
|
|
version:8,
|
|
glyphs:'https://demotiles.maplibre.org/font/{fontstack}/{range}.pbf',
|
|
sources:{
|
|
sat:{type:'raster',tiles:['https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}'],tileSize:256,attribution:'Esri World Imagery'},
|
|
dem:{type:'raster-dem',tiles:['https://s3.amazonaws.com/elevation-tiles-prod/terrarium/{z}/{x}/{y}.png'],encoding:'terrarium',tileSize:256,maxzoom:14}
|
|
},
|
|
layers:[
|
|
{id:'sat',type:'raster',source:'sat'},
|
|
{id:'hills',type:'hillshade',source:'dem',paint:{'hillshade-exaggeration':0.4}}
|
|
]
|
|
}
|
|
});
|
|
map.addControl(new maplibregl.NavigationControl({visualizePitch:true}),'bottom-right');
|
|
|
|
let SURVEY=null, markers=[], hidden=new Set(), minConf=0.4, tSpan=[0,1], playing=false, playTimer=null;
|
|
|
|
function fmtClock(ms){ const d=new Date(ms); return d.toISOString().slice(11,19)+' UTC'; }
|
|
|
|
map.on('load', async ()=>{
|
|
map.setTerrain({source:'dem',exaggeration:1.35});
|
|
map.setSky({'sky-color':'#183048','horizon-color':'#9fc0d8','fog-color':'#0b0f14','sky-horizon-blend':0.5,'horizon-fog-blend':0.6,'fog-ground-blend':0.4});
|
|
|
|
const qs = new URLSearchParams(location.search);
|
|
const url = '/api/survey' + (qs.toString()? '?'+qs.toString():'');
|
|
const res = await fetch(url); SURVEY = await res.json();
|
|
document.getElementById('loading').style.display='none';
|
|
renderSurvey();
|
|
});
|
|
|
|
function renderSurvey(){
|
|
const s=SURVEY;
|
|
document.getElementById('surveyName').textContent = s.survey.name + ' · ' + s.survey.drone;
|
|
const isSim = s.survey.simulated;
|
|
const modeEl=document.getElementById('mode');
|
|
modeEl.textContent = isSim?'SIM':'LIVE'; modeEl.className='badge '+(isSim?'sim':'live');
|
|
|
|
// flight path
|
|
const coords = s.flight.map(p=>[p.lon,p.lat]);
|
|
if(map.getSource('path')) map.getSource('path').setData(pathGeo(coords));
|
|
else{
|
|
map.addSource('path',{type:'geojson',data:pathGeo(coords)});
|
|
map.addLayer({id:'path-line',type:'line',source:'path',paint:{'line-color':'#39d98a','line-width':2.2,'line-opacity':0.85,'line-dasharray':[2,1.5]}});
|
|
}
|
|
// drone launch marker
|
|
const el=document.createElement('div'); el.textContent='🚁'; el.style.fontSize='20px';
|
|
new maplibregl.Marker({element:el}).setLngLat(coords[0]).addTo(map);
|
|
|
|
// stats
|
|
const stat=s.stats;
|
|
document.getElementById('sTotal').textContent=stat.total;
|
|
document.getElementById('sArea').textContent=stat.area_km2;
|
|
document.getElementById('sDur').textContent=stat.duration_min;
|
|
const avg=s.detections.reduce((a,d)=>a+d.confidence,0)/(s.detections.length||1);
|
|
document.getElementById('sConf').textContent=Math.round(avg*100)+'%';
|
|
|
|
buildLegend(stat.counts);
|
|
buildMarkers();
|
|
fitToSurvey(coords);
|
|
applyFilters();
|
|
}
|
|
|
|
function pathGeo(coords){return{type:'Feature',geometry:{type:'LineString',coordinates:coords}};}
|
|
|
|
function fitToSurvey(coords){
|
|
const b=new maplibregl.LngLatBounds();
|
|
coords.forEach(c=>b.extend(c));
|
|
map.fitBounds(b,{padding:80,pitch:62,bearing:-18,duration:1200,maxZoom:15.5});
|
|
}
|
|
|
|
function buildLegend(counts){
|
|
const wrap=document.getElementById('legend'); wrap.innerHTML='';
|
|
Object.keys(SPECIES_STYLE).forEach(type=>{
|
|
const n=counts[type]||0; if(!n) return;
|
|
const st=SPECIES_STYLE[type];
|
|
const row=document.createElement('div'); row.className='lg'; row.dataset.type=type;
|
|
row.innerHTML='<div class="l"><span class="dot" style="background:'+st.color+'22;border:1px solid '+st.color+'">'+st.icon+'</span>'+type+'</div><span class="n">'+n+'</span>';
|
|
row.onclick=()=>{ if(hidden.has(type)) hidden.delete(type); else hidden.add(type); row.classList.toggle('off'); applyFilters(); };
|
|
wrap.appendChild(row);
|
|
});
|
|
}
|
|
|
|
function buildMarkers(){
|
|
markers.forEach(m=>m.marker.remove()); markers=[];
|
|
SURVEY.detections.forEach(d=>{
|
|
const st=SPECIES_STYLE[d.type]||SPECIES_STYLE.unknown;
|
|
const el=document.createElement('div'); el.className='marker'; el.style.fontSize='19px'; el.textContent=st.icon;
|
|
const pop=new maplibregl.Popup({offset:14,closeButton:false}).setHTML(
|
|
'<b style="text-transform:capitalize">'+d.type+'</b> <span class="pk">'+Math.round(d.confidence*100)+'% conf</span><br>'+
|
|
(d.temp_c!=null?('<span class="pk">thermal</span> '+d.temp_c+'°C<br>'):'')+
|
|
'<span class="pk">'+d.lat.toFixed(5)+', '+d.lon.toFixed(5)+'</span><br>'+
|
|
'<span class="pk">'+d.t.slice(11,19)+' UTC · frame '+d.frame+'</span>'
|
|
);
|
|
const marker=new maplibregl.Marker({element:el}).setLngLat([d.lon,d.lat]).setPopup(pop).addTo(map);
|
|
markers.push({marker,d,el});
|
|
});
|
|
}
|
|
|
|
function applyFilters(){
|
|
const tEnd = tSpan[1];
|
|
markers.forEach(({marker,d,el})=>{
|
|
const tv=new Date(d.t).getTime();
|
|
const show = !hidden.has(d.type) && d.confidence>=minConf && tv<=tEnd;
|
|
el.style.display = show?'block':'none';
|
|
});
|
|
}
|
|
|
|
// confidence slider
|
|
const confEl=document.getElementById('conf');
|
|
confEl.oninput=()=>{ minConf=confEl.value/100; document.getElementById('confVal').textContent=confEl.value+'%'; applyFilters(); };
|
|
|
|
// toggles
|
|
document.getElementById('tgPath').onchange=e=>{ if(map.getLayer('path-line')) map.setLayoutProperty('path-line','visibility',e.target.checked?'visible':'none'); };
|
|
document.getElementById('tgTerrain').onchange=e=>{ map.setTerrain(e.target.checked?{source:'dem',exaggeration:1.35}:null); map.easeTo({pitch:e.target.checked?62:0}); };
|
|
|
|
// timeline
|
|
const scrub=document.getElementById('scrub'), clock=document.getElementById('clock'), playBtn=document.getElementById('play');
|
|
function tRange(){ const a=new Date(SURVEY.survey.started_at).getTime(), b=new Date(SURVEY.survey.ended_at).getTime(); return [a,b]; }
|
|
function onScrub(){
|
|
if(!SURVEY) return;
|
|
const [a,b]=tRange(); const f=scrub.value/1000; const t=a+(b-a)*f;
|
|
tSpan=[a,t]; clock.textContent=fmtClock(t); applyFilters();
|
|
}
|
|
scrub.oninput=()=>{ if(playing) stopPlay(); onScrub(); };
|
|
function stopPlay(){ playing=false; playBtn.textContent='▶'; clearInterval(playTimer); }
|
|
playBtn.onclick=()=>{
|
|
if(!SURVEY) return;
|
|
if(playing){ stopPlay(); return; }
|
|
playing=true; playBtn.textContent='⏸';
|
|
if(+scrub.value>=1000) scrub.value=0;
|
|
playTimer=setInterval(()=>{ scrub.value=Math.min(1000,+scrub.value+12); onScrub(); if(+scrub.value>=1000) stopPlay(); },90);
|
|
};
|
|
// initialise clock once loaded
|
|
const _origRender=renderSurvey;
|
|
</script>
|
|
<script src="https://theradicalparty.com/footer.js" data-mode="pill" defer></script>
|
|
</body>
|
|
</html>`;
|
|
}
|