Files
gods-eye-view/mobile/globe/moments.js
T

181 lines
5.2 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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.',
},
],
};
}