CritterScope: 3D drone wildlife survey dashboard

- Hono Worker + MapLibre GL 3D terrain map (satellite + Terrarium DEM, no API key)
- Deterministic survey simulator: lawnmower flight + georeferenced detections
- Species markers, flight path, timeline scrubber, confidence + type filters
- D1 schema + POST /api/ingest for real drone data feeding the same dashboard

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
maverick 2026-07-21 21:10:21 +10:00
commit 63393c052f
9 changed files with 2222 additions and 0 deletions

6
.gitignore vendored Normal file
View file

@ -0,0 +1,6 @@
node_modules
.wrangler
.dev.vars
dist
*.log
.DS_Store

55
README.md Normal file
View file

@ -0,0 +1,55 @@
# 🛰️ CritterScope
Turn a drone survey into **georeferenced animal/object detections on a 3D map.**
A drone flies a survey → thermal+RGB frames + telemetry → an AI detector emits
warm-blob detections → each is projected onto terrain as `(lat, lon, type,
confidence, temp, time)` → this app plots them on a 3D terrain globe with a
timeline you can replay.
The dashboard runs **today** off a built-in simulator, so the whole UI is done and
testable before any drone arrives. Real footage feeds the *same* dashboard via
`POST /api/ingest`.
## Stack
- **Cloudflare Worker + Hono** (`src/index.js`)
- **MapLibre GL JS** — satellite imagery + Terrarium DEM for real 3D terrain (no API key)
- **D1** (optional) — stores real surveys/detections; schema in `schema.sql`
- Deploys to `critterscope.theradicalparty.com`
## Run locally
```bash
npm install
npm run dev # http://localhost:8787
```
Open `/` for the dashboard. Point it anywhere:
```
/?lat=-33.73&lon=150.31&r=1000 # center, radius (m)
/?seed=42 # different simulated survey
```
## Data contract (what the drone pipeline must produce)
`GET /api/survey` returns:
```jsonc
{
"survey": { "id","name","center_lon","center_lat","radius_m","sensor","drone","started_at","ended_at","simulated" },
"flight": [ { "seq","lon","lat","agl_m","t" } ],
"detections": [ { "id","type","lon","lat","confidence","temp_c","t","frame" } ],
"stats": { "total","counts","area_km2","duration_min" }
}
```
`type``deer | fox | rabbit | boar | kangaroo | person | vehicle | unknown`.
## Feeding real drone data
1. `wrangler d1 create critterscope`, paste the id into `wrangler.toml`, uncomment the binding.
2. `npm run db:init`
3. `POST /api/ingest` with `{ survey, flight, detections }`.
4. View it at `/?survey=<id>`.
The georeferencing step (frame + drone GPS + gimbal angle + terrain → lat/lon) lives
in the drone pipeline, not here — this app just needs the resulting detections.
## Roadmap
- [ ] Drone ingest pipeline (RTMP/SRT frames → YOLO thermal detector → georeferencer)
- [ ] Live mode (stream detections during flight, not just post-survey)
- [ ] Track clustering (merge repeat sightings of the same animal across passes)

1595
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

19
package.json Normal file
View file

@ -0,0 +1,19 @@
{
"name": "critterscope",
"version": "0.1.0",
"description": "Thermal/RGB drone survey → georeferenced animal detections on a 3D map",
"private": true,
"type": "module",
"scripts": {
"dev": "wrangler dev",
"deploy": "wrangler deploy",
"db:init": "wrangler d1 execute critterscope --file=schema.sql",
"db:init:local": "wrangler d1 execute critterscope --local --file=schema.sql"
},
"dependencies": {
"hono": "^4.6.3"
},
"devDependencies": {
"wrangler": "^3.80.0"
}
}

43
schema.sql Normal file
View file

@ -0,0 +1,43 @@
-- CritterScope data model.
-- A "survey" is one drone flight over an area; "detections" are georeferenced
-- animals/objects found in that survey. The demo runs off the live simulator,
-- but real drone footage lands here via POST /api/ingest.
CREATE TABLE IF NOT EXISTS surveys (
id TEXT PRIMARY KEY,
name TEXT,
center_lon REAL NOT NULL,
center_lat REAL NOT NULL,
radius_m REAL NOT NULL,
sensor TEXT, -- 'thermal' | 'rgb' | 'thermal+rgb'
drone TEXT, -- e.g. 'DJI Mavic 2 Enterprise Advanced'
started_at TEXT, -- ISO8601
ended_at TEXT,
created_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS flight_points (
survey_id TEXT NOT NULL,
seq INTEGER NOT NULL,
lon REAL NOT NULL,
lat REAL NOT NULL,
agl_m REAL, -- altitude above ground
t TEXT, -- ISO8601 timestamp at this point
PRIMARY KEY (survey_id, seq)
);
CREATE TABLE IF NOT EXISTS detections (
id TEXT PRIMARY KEY,
survey_id TEXT NOT NULL,
type TEXT NOT NULL, -- deer, fox, rabbit, boar, kangaroo, person, vehicle, unknown
lon REAL NOT NULL,
lat REAL NOT NULL,
confidence REAL NOT NULL, -- 0..1
temp_c REAL, -- thermal apparent temperature (null for rgb-only)
t TEXT, -- ISO8601 time of detection
frame INTEGER, -- source video frame index
created_at TEXT DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_detections_survey ON detections(survey_id);
CREATE INDEX IF NOT EXISTS idx_flight_survey ON flight_points(survey_id);

97
src/index.js Normal file
View file

@ -0,0 +1,97 @@
import { Hono } from "hono";
import { generateSurvey } from "./simulate.js";
import { page } from "./page.js";
const app = new Hono();
function centerFromReq(c) {
const url = new URL(c.req.url);
const env = c.env || {};
const lon = parseFloat(url.searchParams.get("lon") ?? env.DEFAULT_LON ?? "150.3119");
const lat = parseFloat(url.searchParams.get("lat") ?? env.DEFAULT_LAT ?? "-33.73");
const r = parseFloat(url.searchParams.get("r") ?? env.DEFAULT_RADIUS_M ?? "1000");
const seed = parseInt(url.searchParams.get("seed") ?? "1337", 10);
return { center: [lon, lat], radiusM: r, seed };
}
// Dashboard
app.get("/", (c) => {
const { center, radiusM } = centerFromReq(c);
return c.html(page({ center, radiusM }));
});
// Survey feed. Uses D1 if a survey exists there, otherwise the live simulator.
app.get("/api/survey", async (c) => {
const { center, radiusM, seed } = centerFromReq(c);
const url = new URL(c.req.url);
const surveyId = url.searchParams.get("survey");
if (c.env?.DB && surveyId) {
const stored = await loadSurvey(c.env.DB, surveyId);
if (stored) return c.json(stored);
}
return c.json(generateSurvey({ center, radiusM, seed }));
});
// Ingest real drone detections. Body: { survey:{...}, flight:[...], detections:[...] }
app.post("/api/ingest", async (c) => {
if (!c.env?.DB) return c.json({ error: "D1 not configured" }, 501);
const body = await c.req.json();
const s = body.survey || {};
const id = s.id || `sv-${Date.now()}`;
const db = c.env.DB;
await db
.prepare(
`INSERT OR REPLACE INTO surveys (id,name,center_lon,center_lat,radius_m,sensor,drone,started_at,ended_at)
VALUES (?,?,?,?,?,?,?,?,?)`
)
.bind(
id, s.name ?? null, s.center_lon, s.center_lat, s.radius_m ?? 1000,
s.sensor ?? "thermal+rgb", s.drone ?? null, s.started_at ?? null, s.ended_at ?? null
)
.run();
const stmts = [];
(body.flight || []).forEach((p, i) =>
stmts.push(
db.prepare(`INSERT OR REPLACE INTO flight_points (survey_id,seq,lon,lat,agl_m,t) VALUES (?,?,?,?,?,?)`)
.bind(id, p.seq ?? i, p.lon, p.lat, p.agl_m ?? null, p.t ?? null)
)
);
(body.detections || []).forEach((d, i) =>
stmts.push(
db.prepare(`INSERT OR REPLACE INTO detections (id,survey_id,type,lon,lat,confidence,temp_c,t,frame) VALUES (?,?,?,?,?,?,?,?,?)`)
.bind(d.id ?? `${id}-${i}`, id, d.type, d.lon, d.lat, d.confidence ?? 0.5, d.temp_c ?? null, d.t ?? null, d.frame ?? null)
)
);
if (stmts.length) await db.batch(stmts);
return c.json({ ok: true, survey: id, detections: (body.detections || []).length });
});
app.get("/health", (c) => c.json({ ok: true, service: "critterscope" }));
async function loadSurvey(db, id) {
const survey = await db.prepare(`SELECT * FROM surveys WHERE id=?`).bind(id).first();
if (!survey) return null;
const flight = (await db.prepare(`SELECT seq,lon,lat,agl_m,t FROM flight_points WHERE survey_id=? ORDER BY seq`).bind(id).all()).results || [];
const detections = (await db.prepare(`SELECT id,type,lon,lat,confidence,temp_c,t,frame FROM detections WHERE survey_id=? ORDER BY t`).bind(id).all()).results || [];
const counts = {};
for (const d of detections) counts[d.type] = (counts[d.type] || 0) + 1;
return {
survey: { ...survey, simulated: false },
flight,
detections,
stats: {
total: detections.length,
counts,
area_km2: Number(((Math.PI * survey.radius_m * survey.radius_m) / 1e6).toFixed(2)),
duration_min:
flight.length > 1
? Math.round((new Date(flight[flight.length - 1].t) - new Date(flight[0].t)) / 60000)
: 0,
},
};
}
export default app;

235
src/page.js Normal file
View file

@ -0,0 +1,235 @@
// The CritterScope 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>CritterScope 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 &amp; survey</div>
<div id="hud" class="panel">
<h1>🛰 CritterScope <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> &nbsp;<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>
</body>
</html>`;
}

152
src/simulate.js Normal file
View file

@ -0,0 +1,152 @@
// Deterministic survey simulator.
// Produces a lawnmower flight path over a square box around a center point, plus
// georeferenced animal/object detections scattered near the path — the same shape
// of data a real thermal+RGB drone survey would emit after AI detection.
function mulberry32(seed) {
let a = seed >>> 0;
return function () {
a = (a + 0x6d2b79f5) | 0;
let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
// Species profiles: relative frequency + a plausible thermal apparent temp range (°C).
const SPECIES = [
{ type: "kangaroo", weight: 26, temp: [32, 37] },
{ type: "deer", weight: 20, temp: [31, 36] },
{ type: "rabbit", weight: 16, temp: [30, 35] },
{ type: "fox", weight: 12, temp: [33, 38] },
{ type: "boar", weight: 9, temp: [32, 37] },
{ type: "person", weight: 5, temp: [30, 34] },
{ type: "vehicle", weight: 5, temp: [18, 55] }, // engine hot-spots
{ type: "unknown", weight: 7, temp: [22, 34] },
];
function pickSpecies(rng) {
const total = SPECIES.reduce((s, x) => s + x.weight, 0);
let r = rng() * total;
for (const s of SPECIES) {
if ((r -= s.weight) <= 0) return s;
}
return SPECIES[SPECIES.length - 1];
}
const metersToDeg = (m, lat) => ({
dLat: m / 111320,
dLon: m / (111320 * Math.cos((lat * Math.PI) / 180)),
});
// Build a lawnmower (boustrophedon) grid flight over the box.
function buildFlight(center, radiusM, startISO, rng) {
const [lon, lat] = center;
const { dLat, dLon } = metersToDeg(radiusM, lat);
const legs = 8; // number of N-S passes
const aglM = 120; // altitude above ground
const groundSpeed = 12; // m/s
const legLenM = radiusM * 2;
const points = [];
const start = new Date(startISO).getTime();
let t = 0; // seconds into flight
const dt = 4; // sample every 4s
for (let i = 0; i < legs; i++) {
const x = -dLon + (2 * dLon * i) / (legs - 1); // west→east step
const goingNorth = i % 2 === 0;
const samples = Math.max(2, Math.round(legLenM / (groundSpeed * dt)));
for (let j = 0; j <= samples; j++) {
const frac = j / samples;
const y = goingNorth ? -dLat + 2 * dLat * frac : dLat - 2 * dLat * frac;
// small jitter so the path reads like real GPS
const jx = (rng() - 0.5) * dLon * 0.01;
const jy = (rng() - 0.5) * dLat * 0.01;
points.push({
seq: points.length,
lon: lon + x + jx,
lat: lat + y + jy,
agl_m: aglM + (rng() - 0.5) * 6,
t: new Date(start + t * 1000).toISOString(),
});
t += dt;
}
}
return points;
}
export function generateSurvey({ center, radiusM = 1000, seed = 1337, count = 44 } = {}) {
const rng = mulberry32(seed);
const [lon, lat] = center;
const { dLat, dLon } = metersToDeg(radiusM, lat);
const startISO = "2026-07-21T06:20:00.000Z"; // fixed for deterministic demo (dawn survey)
const flight = buildFlight(center, radiusM, startISO, rng);
const detections = [];
for (let i = 0; i < count; i++) {
// uniform-ish scatter within the circle
const ang = rng() * Math.PI * 2;
const rad = Math.sqrt(rng()) * radiusM;
const dx = (rad * Math.cos(ang)) / (111320 * Math.cos((lat * Math.PI) / 180));
const dy = (rad * Math.sin(ang)) / 111320;
const dLonP = lon + dx;
const dLatP = lat + dy;
// attribute the detection to the nearest flight point (its timestamp/frame)
let best = flight[0];
let bestD = Infinity;
for (const p of flight) {
const d = (p.lon - dLonP) ** 2 + (p.lat - dLatP) ** 2;
if (d < bestD) { bestD = d; best = p; }
}
const sp = pickSpecies(rng);
const temp = sp.temp[0] + rng() * (sp.temp[1] - sp.temp[0]);
// confidence: warmer contrast + closer to nadir → higher; add noise
const base = sp.type === "unknown" ? 0.45 : 0.6;
const confidence = Math.min(0.99, Math.max(0.35, base + rng() * 0.4 - 0.1));
detections.push({
id: `d${seed}-${i}`,
type: sp.type,
lon: dLonP,
lat: dLatP,
confidence: Number(confidence.toFixed(2)),
temp_c: Number(temp.toFixed(1)),
t: best.t,
frame: best.seq * 30,
});
}
// sort detections by time so the timeline plays naturally
detections.sort((a, b) => a.t.localeCompare(b.t));
const counts = {};
for (const d of detections) counts[d.type] = (counts[d.type] || 0) + 1;
return {
survey: {
id: `sim-${seed}`,
name: "Simulated dawn survey",
center_lon: lon,
center_lat: lat,
radius_m: radiusM,
sensor: "thermal+rgb",
drone: "SIMULATED",
started_at: flight[0].t,
ended_at: flight[flight.length - 1].t,
simulated: true,
},
flight,
detections,
stats: {
total: detections.length,
counts,
area_km2: Number(((Math.PI * radiusM * radiusM) / 1e6).toFixed(2)),
duration_min: Math.round(
(new Date(flight[flight.length - 1].t) - new Date(flight[0].t)) / 60000
),
},
};
}

20
wrangler.toml Normal file
View file

@ -0,0 +1,20 @@
name = "critterscope"
main = "src/index.js"
compatibility_date = "2024-11-01"
compatibility_flags = ["nodejs_compat"]
routes = [{ pattern = "critterscope.theradicalparty.com", custom_domain = true }]
[vars]
BASE_URL = "https://critterscope.theradicalparty.com"
# Default map center [lon, lat] and survey radius (metres). Override per-survey via ?lat=&lon=&r=
DEFAULT_LON = "150.3119"
DEFAULT_LAT = "-33.7300"
DEFAULT_RADIUS_M = "1000"
# D1 is optional for the demo (falls back to the live simulator when empty).
# Uncomment + set database_id after: wrangler d1 create critterscope
# [[d1_databases]]
# binding = "DB"
# database_name = "critterscope"
# database_id = "REPLACE_ME"