mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 19:04:02 +00:00
Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01VVYRAqiQj9Y1V9ZfG1RcDH
181 lines
5.2 KiB
JavaScript
181 lines
5.2 KiB
JavaScript
/**
|
||
* Moments (DESIGN.md §12.4): 4D reconstructions on one clock.
|
||
*
|
||
* bundled the hero, "Climbers on the Empire State Building"
|
||
* (mobile/demo/moments/empire-state.json, illustrative: true)
|
||
* live built on demand from the recorder for every detection
|
||
* (circling, emergency squawk): real tracks, illustrative: false
|
||
*/
|
||
import { localPlace } from './gazetteer.js';
|
||
|
||
const HERO_URL = new URL('../demo/moments/empire-state.json', import.meta.url)
|
||
.href;
|
||
const BUILDINGS_URL = new URL('../demo/midtown-buildings.json', import.meta.url)
|
||
.href;
|
||
const D2R = Math.PI / 180;
|
||
|
||
let heroPromise = null;
|
||
let buildingsPromise = null;
|
||
|
||
export function loadHero() {
|
||
if (!heroPromise)
|
||
heroPromise = fetch(HERO_URL)
|
||
.then((r) => (r.ok ? r.json() : Promise.reject(new Error(r.status))))
|
||
.catch((error) => {
|
||
heroPromise = null;
|
||
throw error;
|
||
});
|
||
return heroPromise;
|
||
}
|
||
|
||
export function loadBuildings(name) {
|
||
if (name !== 'midtown-buildings.json') return Promise.resolve(null);
|
||
if (!buildingsPromise)
|
||
buildingsPromise = fetch(BUILDINGS_URL)
|
||
.then((r) => (r.ok ? r.json() : null))
|
||
.catch(() => {
|
||
buildingsPromise = null;
|
||
return null;
|
||
});
|
||
return buildingsPromise;
|
||
}
|
||
|
||
/** A slow orbit of camera keys around a point (for live Moments). */
|
||
export function orbitCameraPath(
|
||
lon,
|
||
lat,
|
||
start,
|
||
end,
|
||
{ rangeM = 2600, heightM = 1100, lookAltM = 250, startBearing = 200 } = {},
|
||
) {
|
||
const keys = [];
|
||
const n = 9;
|
||
const kx = 111_320 * Math.cos(lat * D2R);
|
||
for (let i = 0; i < n; i++) {
|
||
const f = i / (n - 1);
|
||
const b = (startBearing + 140 * f) % 360;
|
||
const r = rangeM * (1.2 - 0.35 * Math.sin(Math.PI * f));
|
||
const h = heightM * (1.15 - 0.3 * Math.sin(Math.PI * f));
|
||
const x = Math.sin(b * D2R) * r;
|
||
const y = Math.cos(b * D2R) * r;
|
||
keys.push({
|
||
t: Math.round(start + (end - start) * f),
|
||
lon: lon + x / kx,
|
||
lat: lat + y / 110_574,
|
||
height: Math.round(h),
|
||
heading: (b + 180) % 360,
|
||
pitch: (Math.atan2(lookAltM - h, r) * 180) / Math.PI,
|
||
});
|
||
}
|
||
return keys;
|
||
}
|
||
|
||
/**
|
||
* A live Moment from a circling detection, from the recorder's samples.
|
||
* @param {object} det detector output
|
||
* @param {object} recorder
|
||
*/
|
||
export function momentFromCircling(det, recorder) {
|
||
const start = Math.max(det.since - 4 * 60_000, Date.now() - 45 * 60_000);
|
||
const end = Math.max(det.updatedAt, Date.now());
|
||
const tracks = [];
|
||
for (const m of det.members) {
|
||
const pts = recorder
|
||
.samples(m.key, start, end)
|
||
.map((p) => ({ t: p.t, lon: p.lon, lat: p.lat, altM: p.altM }));
|
||
if (pts.length < 2) continue;
|
||
const meta = recorder.meta(m.key) || {};
|
||
tracks.push({
|
||
id: m.key,
|
||
entityId: `${meta.layer || 'flights'}:${m.key}`,
|
||
kind: meta.helicopter ? 'helicopter' : 'aircraft',
|
||
label: meta.callsign || m.key.toUpperCase(),
|
||
callsign: meta.callsign || null,
|
||
registration: meta.registration || null,
|
||
type: meta.type || null,
|
||
operator: '',
|
||
points: pts,
|
||
});
|
||
}
|
||
let maxAlt = 0;
|
||
for (const tr of tracks)
|
||
for (const p of tr.points) maxAlt = Math.max(maxAlt, p.altM);
|
||
const place = localPlace(det.lat, det.lon).name;
|
||
return {
|
||
id: `live-${det.id}`,
|
||
title: det.title,
|
||
place,
|
||
lon: det.lon,
|
||
lat: det.lat,
|
||
time: { start, end },
|
||
illustrative: false,
|
||
live: true,
|
||
situationId: det.id,
|
||
recommendedSpeed: 30,
|
||
subject: { lon: det.lon, lat: det.lat, altM: 0, label: place },
|
||
cameraPath: orbitCameraPath(det.lon, det.lat, start, end, {
|
||
rangeM: 2000,
|
||
heightM: Math.max(1400, maxAlt * 3),
|
||
lookAltM: maxAlt * 0.4,
|
||
}),
|
||
beats: [{ t: det.since, title: 'First complete orbit in the record' }],
|
||
tracks,
|
||
captures: [],
|
||
chat: [],
|
||
posts: [],
|
||
sources: [
|
||
{
|
||
name: 'God’s Eye recorder',
|
||
detail:
|
||
'Positions recorded from live ADS-B polls (OpenSky Network / adsb.lol, ODbL) in this browser.',
|
||
},
|
||
{
|
||
name: 'Circling detector',
|
||
detail: `${det.members.length} aircraft each completed at least one orbit within 1.5 km of a common centre.`,
|
||
},
|
||
],
|
||
};
|
||
}
|
||
|
||
/** A live Moment for one aircraft (an emergency squawk) from its track. */
|
||
export function momentFromAircraft({ id, title, key, points, meta, place }) {
|
||
if (!points?.length) return null;
|
||
const start = points[0].t;
|
||
const end = Math.max(points.at(-1).t, start + 60_000);
|
||
const last = points.at(-1);
|
||
return {
|
||
id,
|
||
title,
|
||
place: place || localPlace(last.lat, last.lon).name,
|
||
lon: last.lon,
|
||
lat: last.lat,
|
||
time: { start, end },
|
||
illustrative: false,
|
||
live: true,
|
||
recommendedSpeed: 60,
|
||
subject: null,
|
||
follow: { trackId: key, rangeM: 22_000, heightM: 7_000 },
|
||
cameraPath: [],
|
||
beats: [],
|
||
tracks: [
|
||
{
|
||
id: key,
|
||
entityId: `${meta?.layer || 'flights'}:${key}`,
|
||
kind: meta?.helicopter ? 'helicopter' : 'aircraft',
|
||
label: meta?.callsign || key.toUpperCase(),
|
||
operator: '',
|
||
points,
|
||
},
|
||
],
|
||
captures: [],
|
||
chat: [],
|
||
posts: [],
|
||
sources: [
|
||
{
|
||
name: 'God’s Eye recorder',
|
||
detail: 'Positions recorded from live ADS-B polls in this browser.',
|
||
},
|
||
],
|
||
};
|
||
}
|