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:
commit
63393c052f
9 changed files with 2222 additions and 0 deletions
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
node_modules
|
||||
.wrangler
|
||||
.dev.vars
|
||||
dist
|
||||
*.log
|
||||
.DS_Store
|
||||
55
README.md
Normal file
55
README.md
Normal 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
1595
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
19
package.json
Normal file
19
package.json
Normal 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
43
schema.sql
Normal 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
97
src/index.js
Normal 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
235
src/page.js
Normal 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 & 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> <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
152
src/simulate.js
Normal 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
20
wrangler.toml
Normal 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"
|
||||
Loading…
Add table
Reference in a new issue