wip(mobile): checkpoint engine and experience round three in progress

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01VVYRAqiQj9Y1V9ZfG1RcDH
This commit is contained in:
Claude committed 2026-10-23 07:29:27 +00:00
1 parent a6f98ce3c1
commit a720b10913
83 files changed
+50005 -416

No files matched your search

+165
View File
@@ -111,3 +111,168 @@ When NHC has published an advisory but its cone geometry is still pending, the c
- `?lat=&lon=` sets the viewer.
- `?only=cyclone,eye` filters cards by id prefix.
- `?w=330` sets the card width.
---
# Round three: Gotham for everyone (DESIGN.md §12)
Additive. Nothing above changes. Every call below exists on the object `createGlobe()` resolves to. Times are epoch ms unless a `Date` is shown.
## Instant first frame
```js
globe.situations({ cached: true }) → Promise<Situation[]>
// resolves at once from the last good IndexedDB snapshot (written by every live
// situations() call), else the bundled mobile/demo/snapshot.json (real data).
// Each cached Situation adds: cached: true, cacheOrigin: 'device' | 'bundled',
// capturedAt (epoch ms), live: false, thumbUrl (bundled /mobile/demo/thumbs/<id>.jpg
// or a Cache API blob URL) or null.
globe.situations() → Promise<Situation[]> // live, as before (also saves the cache)
globe.cachedThumbnail(situation) → string | null // sync; thumbUrl for a cached card
globe.thumbnail(cachedSituation) → resolves to the cached JPEG when the card has no live scene
```
The globe paints its first frame with imagery only. Live layers start right after it, and weather, transit, GNSS, Starlink and satellites start about a second later. Background warm-ups (jet, TCM, AQ, CCTV, rasters) start at 4 s.
## Recorder and time machine
```js
globe.history() → { start, end, entities, points, bytes } // recorded range; start/end null when empty
globe.setTime(t) // unchanged. Inside the recorded range, contacts are drawn at their interpolated
// recorded positions with 4-minute ghost trails (pickable: 'pick' kind is the
// contact's kind, meta.layer 'history', entityId). Beyond the record, dimmed as before.
globe.trackOf(entityId) → Promise<[{ t, lon, lat, altM, headingDeg, speed }]>
// recorder samples merged with /api/flights/track (OpenSky) or /api/military/track
// (adsb.lol traces); the array has .source
globe.playback.play({ speed = 60, from?, until? } = {}) → state
// speeds: 1, 10, 30, 60, 600. From live it replays the last 15 min. Reaching "now" goes live;
// inside a Moment it stops at the Moment's end.
globe.playback.stop() → state
globe.playback.state() → { playing, speed, t, live }
globe.playback.speeds // [1, 10, 30, 60, 600]
// event 'playback' → state, on start/stop and every 250 ms while playing.
// 'time' is also emitted every 250 ms while playing ({ live:false, time, playing:true }).
// Playing runs the render loop; at rest the globe is request-render again.
globe.timeline({ from, to } = {}) → [{ t, kind, band, title, severity, situationId?, entityId?, momentId?, lon?, lat? }]
// kind: 'quake' | 'quake-cluster' | 'launch' | 'squawk' | 'cyclone' | 'fire' | 'circling'
// | 'lightning' | 'moment' (a Moment beat) | 'capture' | 'post' | 'chat-burst'
// band: 'orbit' | 'sky' | 'ground' | 'inside'; severity 0..3; sorted by t.
// Defaults to timeWindow(). Moment items appear for Moments that were loaded and overlap the range.
```
The recorder keeps every poll for low traffic (helicopters, approaches, anything below about 6 km) and one fix every 50 s for cruise traffic. Samples older than 30 min are thinned. The buffer is capped at 1.2 M samples (about 24 MB) and persisted to IndexedDB about once a minute, so a reload keeps up to 6 h. When OpenSky is down, the recorder also polls adsb.lol snapshots around New York, London, Los Angeles and the viewer every 15 s, so the detector has real data.
Entity ids are layer-qualified: `flights:<icao24>`, `military:<hex>`, `satellites:<norad>`, `earthquakes:<id>`, `cctv:<id>`, `vessels:<mmsi>`, `situation:<id>`, `place:<slug>`, `moment:<momentId>`, `moment:<momentId>:<trackId>`, `capture:<momentId>:<captureId>`. Every `'pick'` payload can be passed to `entity()` as is.
## Detection
Detections run on recorder data every poll. They appear only when real data shows them.
- `circling`: two or more aircraft (rotorcraft and low-and-slow preferred, below about 8,000 ft and 155 kt), each turning through at least 360° within about 1.5 km of a common centre in the last 20 min. Card: `{ kind:'circling', band:'sky', archetype:'track', title:'3 helicopters circling Midtown Manhattan', momentId:'live-circling-…', members:[{ key, layer, callsign, helicopter }], entityIds, detected:true }`. Its thumbnail draws the recorded orbit tracks.
- `quake-cluster`: three or more M2.5+ earthquakes within 50 km in 24 h. Archetype `number`, with a magnitude series.
- `squawk` cards now carry `momentId: 'live-squawk-<icao24>'`.
```js
globe.detections() → Situation[] // current detections, sync
// event 'detections' → detector output, when the set of circling detections changes
```
## Moments
```js
globe.moments() → Promise<Moment[]> // live (circling, squawks) first, then the bundled hero
globe.moment(id) → Promise<Moment|null> // 'empire-state' | 'live-circling-…' | 'live-squawk-<hex>'
globe.momentOfTheDay() → Promise<Moment> // best live Moment if any, else the hero
globe.playMoment(idOrMoment, { speed, camera = true, autoplay = true, framing = 6 } = {}) → Promise<Moment>
// speed defaults to moment.recommendedSpeed (hero: 30 → 50 s). camera:false leaves the camera alone.
// framing: degrees the cinematic camera looks below the subject (puts it in the upper half).
// Hides every live layer, draws tracks (glyph, 90 s bright trail, faint flown path, drop line),
// capture view frustums that pop in at their t, the subject building and its beacon, and flies
// the camera path keyed to the clock. Touching the globe releases the camera.
globe.exitMoment({ restoreCamera = true } = {}) // back to live (or the time before) and the old camera
globe.activeMoment() → { id, title, illustrative, t, start, end, playing, speed, cinematic, moment } | null
globe.seekMoment(t) // epoch ms, or a 0..1 fraction of the Moment
globe.momentCamera(on = true) // re-attach (or release) the cinematic camera → boolean
// event 'moment' → { type, item, t, momentId }
// type: 'start' (item = Moment) | 'capture' | 'chat' | 'post' | 'beat' | 'end' | 'exit'
// | 'camera' (item = { cinematic:false } when the person takes the camera)
// Items fire as the clock passes them while playing, and on small seeks. A seek back, or a jump of
// more than 90 s while paused, repositions silently: render the Live/Record lanes by filtering
// activeMoment().moment.chat/posts/captures by t <= activeMoment().t.
```
```ts
Moment = {
id, title, place, lon, lat,
time: { start, end }, // epoch ms
illustrative: boolean, // true for the bundled hero; false for live detections
illustrativeNote?: string,
recommendedSpeed?: number,
subject?: { lon, lat, altM, label, roofM?, antennaM?, decks? },
cameraPath: [{ t, lon, lat, height /* m above ground */, heading, pitch }],
beats?: [{ t, title }],
tracks: [{ id, kind /* 'helicopter'|'aircraft' */, label, operator, callsign?, registration?, type?,
color?, entityId? /* live: 'flights:<hex>' */, points: [{ t, lon, lat, altM /* MSL */ }] }],
captures: [{ id, t, lon, lat, altM, headingDeg, pitchDeg, fovDeg, rangeM, title, handle, place,
sealed, firstEyes?, kind /* 'photo'|'clip' */, image? }],
chat: [{ t, handle, text, badge? /* 'first-eyes'|'witness'|'local'|'pilot'|'met' */ }],
posts: [{ id, t, handle, kind /* 'witness'|'context'|'question'|'correction' */, text, votes, captureId? }],
sources: [{ name, detail }],
}
```
The hero is `mobile/demo/moments/empire-state.json` (id `empire-state`): "Climbers on the Empire State Building", 25 min, 3 helicopters, 8 captures, 120 chat lines and 12 posts. Regenerate it with `node mobile/demo/build-empire-state.mjs`. Its Midtown massing is real NYC Open Data footprints and roof heights in `mobile/demo/midtown-buildings.json` (`node mobile/demo/build-midtown.mjs`).
## Entities and dossiers
```js
globe.entity(ref) → Promise<Dossier> // ref: entity id string, a 'pick' payload, or { kind, id }
Dossier = {
id, kind, title, subtitle,
identity: { icao24?, callsign?, registration?, type?, typeCode?, operator?, route?, country?, squawk?,
norad?, intlDesignator?, tle?, mmsi?, provider?, magnitude?, illustrative?, ... },
live: { lon, lat, altM, speed /* m/s */, heading, squawk?, updatedAt, speedKt?, elevationDeg? } | null,
track: [{ t, lon, lat, altM }],
events: [{ t, title }],
links: [{ id, kind, title, relation, distanceKm? }],
sources: [{ name, fetchedAt, url?, hash /* 'sha256:…' (or 'fnv1a:…' outside secure contexts) */, detail? }],
privacy?: 'pia' | 'ladd' | 'no-public-identity', // identity then holds only what the transponder broadcasts
trackPending?: true, // the server track was slow; call trackOf(id) later for the full one
nextPass?: { start, max, end, maxElevationDeg, visible }, // satellites, for the viewer
illustrative?: true, // hero Moment tracks and captures
}
globe.search(query) → Promise<[{ id, kind, title, subtitle }]> // ≤ 30; "N46567", "UAL", "7700",
// "M5.2", "ISS", "Midtown", "empire"… across live aircraft, the record, satellites, quakes,
// cameras, situations, places (neighbourhoods, airports, cities) and Moments
globe.graph(ref) → { center, nodes: [{ id, kind, title }], edges: [{ from, to, relation }] } // ≤ 40 nodes, sync
```
Kinds: `aircraft`, `helicopter`, `military`, `satellite`, `vessel`, `quake`, `camera`, `situation`, `place`, `moment`, `track`, `capture`. There are never person entities. Aircraft dossiers wait at most 2.5 s for the server track (aircraft identity comes from adsbdb, 3.5 s budget).
## Reactions and attention
```js
globe.react({ lon, lat, kind: 'heart'|'fire'|'eyes'|'wow', count = 1, altM = 0 }) → number
globe.attentionHeat(points | null) // points: [{ lon, lat, weight? }]; soft glow where viewers are
```
Reactions draw on a screen-space canvas that the globe owns (`.gev-reactions`, pointer-events none, above the globe and below your HTML). Each glyph stays anchored to its point as the globe moves. At most 300 are alive at once, and the canvas animates only while some are alive.
## Your sky now
```js
globe.skyNow({ lon, lat } = viewer) → Promise<{
at, observer, sunElevationDeg,
aircraft: [{ id, title, kind, lon, lat, altM, distanceKm, elevationDeg, azimuthDeg, headingDeg, speedMps, squawk }],
satellites: [{ id, title, norad, altKm, elevationDeg, azimuthDeg }],
iss: { visibleNow, aboveHorizon, elevationDeg, azimuthDeg,
nextPass: { start, max, end, maxElevationDeg, riseAzimuthDeg, visible } | null },
sources: [string],
}> // aircraft within 50 km, satellites above the horizon; each list sorted by elevation, highest first
```
## Dev harness
`mobile/globe/dev.html?moment=empire-state&speed=30` plays a Moment on load and prints its events in the HUD. `globe.debug` exposes `{ recorder, detector, stage, ghosts }` for tests. It is not part of the contract.
+147
View File
@@ -0,0 +1,147 @@
/**
* Instant first frame (DESIGN.md §12.8): the last good situations and their
* thumbnails, so the feed paints before any live feed answers.
*
* 1. IndexedDB: the last live situations() result in this browser
* 2. else the bundled snapshot, mobile/demo/snapshot.json (real data,
* captured by mobile/demo/capture-snapshot.mjs; see `capturedAt`)
*
* Thumbnails: the last rendered JPEG per situation id from the Cache API
* (secure contexts), else the bundled mobile/demo/thumbs/<id>.jpg.
*/
import { idbGet, idbPut } from './idb.js';
const SNAPSHOT_URL = new URL('../demo/snapshot.json', import.meta.url).href;
const BUNDLED_THUMBS = import.meta.glob('../demo/thumbs/*.jpg', {
eager: true,
query: '?url',
import: 'default',
});
const LAST_CACHE = 'gev-thumbs-last-v1';
const cacheApi = () =>
typeof caches !== 'undefined' && globalThis.isSecureContext;
const bundledThumb = new Map();
for (const [path, url] of Object.entries(BUNDLED_THUMBS)) {
const id = decodeURIComponent(
path
.split('/')
.pop()
.replace(/\.jpg$/, ''),
);
bundledThumb.set(id, url);
}
const cachedThumb = new Map(); // id → blob: URL from the Cache API
export function thumbFileName(id) {
return `${String(id).replace(/[^\w.-]+/g, '_')}.jpg`;
}
function bundledFor(id) {
return (
bundledThumb.get(thumbFileName(id).replace(/\.jpg$/, '')) ||
bundledThumb.get(id) ||
null
);
}
/** Sync: the best cached thumbnail URL for a situation, or null. */
export function cachedThumbnail(situation) {
const id = situation?.id;
if (!id) return null;
return cachedThumb.get(id) || bundledFor(id) || situation.thumbUrl || null;
}
/** Remember the last rendered thumbnail for an id (Cache API). */
export async function rememberThumbnail(id, blob) {
if (!cacheApi() || !blob) return;
try {
const c = await caches.open(LAST_CACHE);
await c.put(
`${location.origin}/__gev-last/${encodeURIComponent(id)}`,
new Response(blob, { headers: { 'Content-Type': 'image/jpeg' } }),
);
} catch {
/* quota or private mode */
}
}
async function hydrateThumbs(list) {
if (!cacheApi()) return;
try {
const c = await caches.open(LAST_CACHE);
await Promise.all(
list.map(async (s) => {
if (cachedThumb.has(s.id)) return;
const hit = await c.match(
`${location.origin}/__gev-last/${encodeURIComponent(s.id)}`,
);
if (hit) cachedThumb.set(s.id, URL.createObjectURL(await hit.blob()));
}),
);
} catch {
/* ignore */
}
}
let bundledPromise = null;
function loadBundled() {
if (!bundledPromise)
bundledPromise = fetch(SNAPSHOT_URL)
.then((r) => (r.ok ? r.json() : null))
.catch(() => null);
return bundledPromise;
}
// Start the bundled fetch at import so a cached call never waits on it.
loadBundled();
function decorate(list, capturedAt, origin) {
return list.map((s) => ({
...s,
cached: true,
cacheOrigin: origin,
capturedAt,
live: false,
thumbUrl: cachedThumb.get(s.id) || bundledFor(s.id) || s.thumbUrl || null,
}));
}
/**
* The last good situations, fast. Resolves [] only when there is nothing at
* all (no IndexedDB snapshot and no bundled file).
*/
export async function loadCachedSituations() {
const stored = await Promise.race([
idbGet('kv', 'situations'),
new Promise((r) => setTimeout(() => r(null), 60)),
]);
if (stored?.list?.length) {
hydrateThumbs(stored.list);
return decorate(stored.list, stored.capturedAt, 'device');
}
const bundled = await loadBundled();
if (bundled?.situations?.length)
return decorate(
bundled.situations,
Date.parse(bundled.capturedAt) || bundled.capturedAt,
'bundled',
);
return [];
}
/** Store a live situations() result as the next first frame. */
export function saveSituations(list) {
if (!list?.length) return;
const clean = list.map((s) => {
const { thumbUrl, cached, cacheOrigin, ...rest } = s;
void thumbUrl;
void cached;
void cacheOrigin;
return rest;
});
idbPut('kv', 'situations', { capturedAt: Date.now(), list: clean }).catch(
() => {},
);
}
export const hasBundledThumb = (id) => Boolean(bundledFor(id));
+54
View File
@@ -24,6 +24,7 @@ import { cctvFrameUrl } from './feeds.js';
import { aqiColor, sampleField } from './feedsWeather.js';
import { strikeLevel } from './layers/weather.js';
import { BLACK_MARBLE_URL } from './imagery.js';
import { cachedThumbnail, rememberThumbnail } from './cache.js';
const D2R = Math.PI / 180;
const ESRI_TILE = (z, x, y) =>
@@ -1469,6 +1470,53 @@ const SCENE_DRAW = {
vignette(g, W, H, 0.5);
},
/** Detected circling: each aircraft's real recorded orbit track. */
async circling(g, view, sc, dpr) {
await base(g, view, 'sky');
tint(g, view.W, view.H, 'sky', 0.38);
const colors = [
'rgba(94,200,255,1)',
'rgba(255,209,102,1)',
'rgba(255,159,107,1)',
'rgba(155,225,93,1)',
'rgba(201,167,255,1)',
];
const [cx, cy] = view.project(sc.lon, sc.lat);
for (const km of [0.5, 1, 1.5]) {
g.beginPath();
g.arc(cx, cy, km * view.pxPerKm, 0, Math.PI * 2);
g.strokeStyle = 'rgba(160,210,255,0.28)';
g.setLineDash([2 * dpr, 5 * dpr]);
g.lineWidth = 1 * dpr;
g.stroke();
}
g.setLineDash([]);
(sc.tracks || []).forEach((tr, i) => {
const css = colors[i % colors.length];
if (tr.points?.length > 1) {
pathLonLat(g, view, tr.points);
glowStroke(g, css.replace(',1)', ',0.85)'), 2.2 * dpr, 10 * dpr);
}
const last = tr.points?.at(-1);
if (last) {
const [x, y] = view.project(last[0], last[1]);
if (tr.helicopter) heliGlyph(g, x, y, 15 * dpr, css);
else planeGlyph(g, x, y, tr.heading || 0, 13 * dpr, css);
}
});
g.save();
g.globalCompositeOperation = 'lighter';
g.drawImage(
glowSprite('rgba(255,209,102,0.9)', 64),
cx - 14 * dpr,
cy - 14 * dpr,
28 * dpr,
28 * dpr,
);
g.restore();
vignette(g, view.W, view.H);
},
async plain(g, view, sc) {
await base(g, view, sc.band || 'ground');
tint(g, view.W, view.H, sc.band || 'ground');
@@ -1556,6 +1604,11 @@ export function createCompositor({ feeds }) {
const d = Math.max(1, Math.min(3, dpr));
const W = Math.round(width * d);
const H = Math.round(height * d);
// A cached first-frame card has no scene: hand back its stored JPEG.
if (situation.cached && !SCENES.has(situation.id)) {
const url = cachedThumbnail(situation);
if (url) return Promise.resolve(url);
}
const key = keyFor(situation, W, H, d);
let p = mem.get(key);
if (p) return p;
@@ -1571,6 +1624,7 @@ export function createCompositor({ feeds }) {
}
}
const blob = await render(situation, W, H, d);
rememberThumbnail(situation.id, blob);
if (cacheApi)
caches
.open(CACHE_NAME)
+307
View File
@@ -0,0 +1,307 @@
/**
* Detection on real data (DESIGN.md §12.4). Runs over the recorder, never
* over anything invented: when nothing in the record matches, nothing is
* raised.
*
* Circling: an aircraft "orbits" when, within the last ~20 min, its ground
* track turns through at least 360° in one direction while its positions stay
* within ~1.5 km of their centroid. Rotorcraft and low-and-slow contacts are
* preferred (airliners in a holding stack are excluded by altitude and
* speed). Two or more orbiters whose centres lie within ~1.5 km of a common
* centre make a detection: "3 helicopters circling Midtown Manhattan". A
* single aircraft on its second full orbit is a lesser detection
* ("Helicopter circling Lower Manhattan").
*
* Quake clusters: three or more M2.5+ events within 50 km of each other in
* 24 h, from the USGS feed.
*/
import { haversineKm } from './geo.js';
import { localPlace, describePlace } from './gazetteer.js';
const WINDOW_MS = 20 * 60_000;
const MAX_CENTER_KM = 1.5;
const MAX_ALT_M = 2400; // ~8,000 ft
const MAX_SPEED_MPS = 80; // ~155 kt
const D2R = Math.PI / 180;
function bearing(a, b) {
const dx = (b.lon - a.lon) * Math.cos(a.lat * D2R);
const dy = b.lat - a.lat;
return (Math.atan2(dx, dy) / D2R + 360) % 360;
}
const metres = (a, b) =>
Math.hypot(
(b.lon - a.lon) * 111_320 * Math.cos(a.lat * D2R),
(b.lat - a.lat) * 110_574,
);
/**
* Orbit analysis of one track (samples sorted by t). Returns the most recent
* full orbit: { center:{lon,lat}, radiusM, turns, dir, from, to, points } or null.
*/
export function analyseOrbit(samples) {
if (samples.length < 6) return null;
// Thin to points at least 60 m apart so jitter does not fake turning.
const pts = [samples[0]];
for (const p of samples) if (metres(pts.at(-1), p) >= 60) pts.push(p);
if (pts.length < 6) return null;
// Walk backwards from the newest point accumulating signed turn.
let turn = 0;
let prevB = bearing(pts.at(-2), pts.at(-1));
let startIdx = -1;
for (let i = pts.length - 2; i >= 1; i--) {
// A gap in the record breaks the orbit.
if (pts[i + 1].t - pts[i].t > 4 * 60_000) break;
const b = bearing(pts[i - 1], pts[i]);
let d = ((prevB - b + 540) % 360) - 180;
// A step that turns > 150° is a sampling artefact, not a turn.
if (Math.abs(d) > 150) d = 0;
turn += d;
prevB = b;
if (Math.abs(turn) >= 360) {
startIdx = i - 1;
break;
}
}
if (startIdx < 0) return null;
const orbit = pts.slice(startIdx);
let lon = 0;
let lat = 0;
for (const p of orbit) {
lon += p.lon;
lat += p.lat;
}
const center = { lon: lon / orbit.length, lat: lat / orbit.length };
let r = 0;
let rMax = 0;
for (const p of orbit) {
const d = metres(center, p);
r += d;
rMax = Math.max(rMax, d);
}
r /= orbit.length;
if (r > MAX_CENTER_KM * 1000 || rMax > MAX_CENTER_KM * 1600) return null;
if (r < 80) return null; // hovering jitter, not an orbit
// Count full turns over the whole window (for "on its 6th orbit").
let total = 0;
for (let i = 2; i < pts.length; i++) {
let d =
((bearing(pts[i - 1], pts[i]) - bearing(pts[i - 2], pts[i - 1]) + 540) %
360) -
180;
if (Math.abs(d) > 150) d = 0;
total += d;
}
return {
center,
radiusM: Math.round(r),
turns: Math.max(1, Math.floor(Math.abs(total) / 360)),
dir: turn > 0 ? 'cw' : 'ccw',
from: orbit[0].t,
to: orbit.at(-1).t,
};
}
/** "near Port-Olry, Vanuatu" from a USGS place, else from the gazetteer. */
function clusterPlace(q) {
const usgs = q.place ? q.place.replace(/^.*\bof\s+/, '').trim() : '';
if (usgs) return `near ${usgs}`;
const p = describePlace(q.lat, q.lon);
if (!p) return '';
return /\bkm\b/.test(p) ? `, ${p}` : `near ${p}`;
}
const isCandidate = (meta, s) =>
meta &&
meta.kind !== 'vessel' &&
!meta.onGround &&
s.altM < MAX_ALT_M &&
(meta.helicopter || s.speed < MAX_SPEED_MPS);
/**
* @param {ReturnType<import('./recorder.js').createRecorder>} recorder
*/
export function createDetector(recorder) {
/** Detections by id (kept while they last, plus a history for pips). */
const live = new Map();
const history = [];
let lastRun = 0;
let lastOrbiters = [];
function circling(now = Date.now()) {
const from = now - WINDOW_MS;
const orbiters = [];
for (const key of recorder.keys()) {
const meta = recorder.meta(key);
if (!meta || meta.kind === 'vessel') continue;
const samples = recorder.samples(key, from, now);
if (samples.length < 6) continue;
const lastS = samples.at(-1);
if (now - lastS.t > 5 * 60_000) continue; // gone quiet
if (!isCandidate(meta, lastS)) continue;
const orbit = analyseOrbit(samples);
if (orbit) orbiters.push({ key, meta, orbit, last: lastS });
}
lastOrbiters = orbiters;
// Greedy clustering on orbit centres.
const used = new Set();
const groups = [];
orbiters.sort(
(a, b) =>
Number(b.meta.helicopter) - Number(a.meta.helicopter) ||
b.orbit.turns - a.orbit.turns,
);
for (const a of orbiters) {
if (used.has(a.key)) continue;
const g = [a];
for (const b of orbiters) {
if (b === a || used.has(b.key)) continue;
const km = haversineKm(
a.orbit.center.lat,
a.orbit.center.lon,
b.orbit.center.lat,
b.orbit.center.lon,
);
if (km <= MAX_CENTER_KM) g.push(b);
}
if (g.length >= 2) {
for (const m of g) used.add(m.key);
groups.push(g);
}
}
// A single aircraft on its second full orbit is a detection too (a
// police or news helicopter holding over a scene), ranked below groups.
const singles = orbiters
.filter((a) => !used.has(a.key) && a.orbit.turns >= 2)
.sort((a, b) => b.orbit.turns - a.orbit.turns)
.slice(0, 4);
for (const a of singles) groups.push([a]);
const out = [];
for (const g of groups) {
let lon = 0;
let lat = 0;
for (const m of g) {
lon += m.orbit.center.lon;
lat += m.orbit.center.lat;
}
lon /= g.length;
lat /= g.length;
const helis = g.filter((m) => m.meta.helicopter).length;
const place = localPlace(lat, lon);
const noun =
g.length === 1
? helis
? 'Helicopter'
: 'Aircraft'
: helis === g.length
? 'helicopters'
: helis
? 'aircraft'
: 'low aircraft';
const id = `circling-${lat.toFixed(2)}_${lon.toFixed(2)}`;
const firstOrbit = Math.min(...g.map((m) => m.orbit.from));
const prev = live.get(id);
const det = {
id,
kind: 'circling',
title:
g.length === 1
? `${noun} circling ${place.name}`
: `${g.length} ${noun} circling ${place.name}`,
single: g.length === 1,
place: place.name,
lon,
lat,
since: prev?.since ?? firstOrbit,
updatedAt: now,
members: g.map((m) => ({
key: m.key,
layer: m.meta.layer,
callsign: m.meta.callsign || m.key,
helicopter: Boolean(m.meta.helicopter),
radiusM: m.orbit.radiusM,
turns: m.orbit.turns,
dir: m.orbit.dir,
altM: Math.round(m.last.altM),
speed: m.last.speed,
lon: m.last.lon,
lat: m.last.lat,
})),
};
live.set(id, det);
if (!prev)
history.push({
t: firstOrbit,
kind: 'circling',
title: det.title,
situationId: id,
momentId: `live-${id}`,
lon,
lat,
});
out.push(det);
}
// Detections that stopped circling linger 10 min, then go.
for (const [id, d] of live)
if (now - d.updatedAt > 10 * 60_000) live.delete(id);
if (history.length > 200) history.splice(0, history.length - 200);
return out;
}
function quakeClusters(records, now = Date.now()) {
const recent = (records || []).filter(
(q) => Number.isFinite(q.time) && now - q.time < 86_400_000,
);
const used = new Set();
const out = [];
const sorted = recent.slice().sort((a, b) => b.mag - a.mag);
for (const q of sorted) {
if (used.has(q.stableId)) continue;
const near = recent.filter(
(o) =>
!used.has(o.stableId) &&
haversineKm(q.lat, q.lon, o.lat, o.lon) <= 50,
);
if (near.length < 3) continue;
for (const o of near) used.add(o.stableId);
const times = near.map((o) => o.time).sort((a, b) => a - b);
out.push({
id: `quake-cluster-${q.stableId}`,
kind: 'quake-cluster',
title: `${near.length} earthquakes in 24 h ${clusterPlace(q)}`.trim(),
lon: q.lon,
lat: q.lat,
maxMag: q.mag,
count: near.length,
since: times[0],
updatedAt: times.at(-1),
members: near.map((o) => ({
id: o.stableId,
mag: o.mag,
time: o.time,
lon: o.lon,
lat: o.lat,
place: o.place,
})),
});
}
return out;
}
return {
/** Run the circling detector at most every `minGapMs`. */
run({ force = false, minGapMs = 20_000 } = {}) {
const now = Date.now();
if (!force && now - lastRun < minGapMs) return [...live.values()];
lastRun = now;
circling(now);
return [...live.values()];
},
current: () => [...live.values()],
get: (id) => live.get(id) || null,
history: () => history.slice(),
/** Single orbiters seen in the last run (diagnostics and dossiers). */
orbiters: () => lastOrbiters.slice(),
quakeClusters,
};
}
+158
View File
@@ -0,0 +1,158 @@
/**
* Situation cards for detections (circling, quake clusters). Same shape as
* situations.js cards, each with a compositor scene of its real data.
*/
import { SCENES } from './situations.js';
import { thumbnailAround } from './geo.js';
const M_TO_FT = 3.28084;
const MPS_TO_KT = 1 / 0.514444;
const hhmm = (ms) => new Date(ms).toISOString().slice(11, 16) + 'Z';
const fmtLL = (lat, lon) =>
`${Math.abs(lat).toFixed(2)}°${lat >= 0 ? 'N' : 'S'} ${Math.abs(lon).toFixed(2)}°${lon >= 0 ? 'E' : 'W'}`;
export function circlingCard(det, recorder) {
const helis = det.members.filter((m) => m.helicopter).length;
const alts = det.members.map((m) => m.altM).sort((a, b) => a - b);
const radii = det.members.map((m) => m.radiusM).sort((a, b) => a - b);
const turns = det.members.reduce((n, m) => n + m.turns, 0);
const lead = det.members[0];
const ft = (m) => Math.round((m * M_TO_FT) / 50) * 50;
const id = det.id;
const card = {
id,
kind: 'circling',
family: 'Sky',
section: 'now',
band: 'sky',
archetype: 'track',
telemetry: [
'SKY',
`${det.members.length} ${helis === det.members.length ? 'ROTOR' : 'AIRCRAFT'} ORBITING`,
`R ${radii[0]}–${radii.at(-1)} M`,
`${ft(alts[0]).toLocaleString('en-US')}–${ft(alts.at(-1)).toLocaleString('en-US')} FT`,
lead?.callsign ? String(lead.callsign).toUpperCase() : null,
]
.filter(Boolean)
.join(' · '),
title: det.title,
subtitle: `${det.members.map((m) => m.callsign).join(', ')} · ${turns} orbits since ${hhmm(det.since)} · detected from ADS-B`,
place: det.place,
lon: det.lon,
lat: det.lat,
camera: {
lon: det.lon,
lat: det.lat,
height: 2600,
heading: 25,
pitch: -32,
},
layers: ['helicopters', 'flights', 'ga', 'military'],
time: { start: det.since, end: det.updatedAt },
metrics: [
{ label: 'Aircraft', value: det.members.length, unit: '' },
{ label: 'Orbits', value: turns, unit: '' },
{
label: 'Radius',
value: radii[Math.floor(radii.length / 2)],
unit: 'm',
},
...(lead?.speed
? [
{
label: 'Speed',
value: Math.round(lead.speed * MPS_TO_KT),
unit: 'kt',
},
]
: []),
],
headline: det.single
? { value: turns, unit: '', label: 'orbits' }
: {
value: det.members.length,
unit: '',
label:
helis === det.members.length
? 'helicopters circling'
: 'aircraft circling',
},
thumbnail: thumbnailAround(det.lon, det.lat, 2),
live: true,
updatedAt: det.updatedAt,
source:
'Detected by God’s Eye from live ADS-B (OpenSky Network / adsb.lol, ODbL)',
severity: det.single ? 4.5 : 6.5,
momentId: `live-${det.id}`,
members: det.members.map(({ key, layer, callsign, helicopter }) => ({
key,
layer,
callsign,
helicopter,
})),
entityIds: det.members.map((m) => `${m.layer || 'flights'}:${m.key}`),
detected: true,
};
SCENES.set(id, {
type: 'circling',
lon: det.lon,
lat: det.lat,
spanKm: 3.2,
band: 'sky',
tracks: det.members.map((m) => {
const pts = recorder
.samples(m.key, det.updatedAt - 20 * 60_000, det.updatedAt)
.map((p) => [p.lon, p.lat]);
return { points: pts, helicopter: m.helicopter, heading: 0 };
}),
});
void fmtLL;
return card;
}
export function quakeClusterCard(c) {
const id = c.id;
const card = {
id,
kind: 'quake-cluster',
family: 'Hazards',
section: 'now',
band: 'ground',
archetype: 'number',
telemetry: `GROUND · ${c.count} EVENTS · 24 H · MAX M${c.maxMag.toFixed(1)} · 50 KM`,
title: c.title,
subtitle: `Largest M${c.maxMag.toFixed(1)} · first ${hhmm(c.since)} · USGS`,
place: c.members[0]?.place || fmtLL(c.lat, c.lon),
lon: c.lon,
lat: c.lat,
camera: { lon: c.lon, lat: c.lat, height: 260_000, heading: 0, pitch: -55 },
layers: ['earthquakes'],
time: { start: c.since, end: c.updatedAt },
metrics: [
{ label: 'Quakes', value: c.count, unit: '' },
{ label: 'Largest', value: c.maxMag.toFixed(1), unit: 'M' },
],
headline: { value: c.count, unit: '', label: 'quakes in 24 h' },
series: c.members
.slice()
.sort((a, b) => a.time - b.time)
.map((m) => ({ t: m.time, v: m.mag })),
seriesLabel: 'magnitude per event',
thumbnail: thumbnailAround(c.lon, c.lat, 60),
live: true,
updatedAt: c.updatedAt,
source: 'U.S. Geological Survey · cluster detected by God’s Eye',
severity: 3 + Math.max(0, c.maxMag - 4),
detected: true,
};
SCENES.set(id, {
type: 'quake',
lon: c.lon,
lat: c.lat,
spanKm: 160,
band: 'ground',
quake: { lon: c.lon, lat: c.lat, mag: c.maxMag },
nearby: c.members.map((m) => ({ lon: m.lon, lat: m.lat, mag: m.mag })),
});
return card;
}
+25
View File
@@ -59,3 +59,28 @@ if (params.get('labels') !== '0') {
globe.on('camera', drawLabels);
setInterval(drawLabels, 1000);
}
// ?moment=empire-state&speed=30 plays a Moment on load; its events print in the HUD.
const momentLines = [];
globe.on('moment', (e) => {
if (!showHud) return;
const it = e.item || {};
const line =
e.type === 'chat'
? `💬 ${it.handle}: ${it.text}`
: e.type === 'post'
? `▤ ${it.kind} · ${it.handle}: ${it.text}`
: e.type === 'capture'
? `◉ ${it.firstEyes ? 'FIRST EYES ' : ''}@${it.handle} · ${it.title}`
: e.type === 'beat'
? `● ${it.title}`
: `[${e.type}]`;
momentLines.push(line);
while (momentLines.length > 8) momentLines.shift();
hud.textContent = momentLines.join('\n');
});
if (params.get('moment'))
globe.playMoment(params.get('moment'), {
speed: Number(params.get('speed')) || undefined,
autoplay: params.get('autoplay') !== '0',
});
File diff suppressed because it is too large. Load diff
+5
View File
@@ -208,6 +208,11 @@ export function createMilitaryFeed() {
cls,
helicopter: cls === 'helicopter',
military: true,
// adsb.lol dbFlags: 4 = PIA, 8 = LADD (owner privacy programmes).
pia: Boolean(a.dbFlags & 4),
ladd: Boolean(a.dbFlags & 8),
seenAt:
Date.now() - (finite(a.seen_pos) ?? finite(a.seen) ?? 0) * 1000,
});
}
return {
+92
View File
@@ -245,3 +245,95 @@ export function describePlace(lat, lon, list) {
if (p.km < 15) return p.name;
return `${p.km.toLocaleString('en-US')} km ${p.dir} of ${p.name}`;
}
/**
* Neighbourhoods and landmarks in the cities where helicopters gather
* (news, police, tours) — [name, lon, lat, radiusKm]. Public centroids,
* rounded. Used to name a detection ("circling Midtown Manhattan").
*/
export const NEIGHBORHOODS = Object.freeze([
['Midtown Manhattan', -73.9851, 40.7549, 1.6],
['Lower Manhattan', -74.0091, 40.7075, 1.4],
['Upper East Side', -73.9566, 40.7736, 1.5],
['Upper West Side', -73.9754, 40.787, 1.5],
['Central Park', -73.9654, 40.7829, 1.2],
['Harlem', -73.9465, 40.8116, 1.6],
['Chelsea, Manhattan', -74.0014, 40.7465, 0.9],
['Greenwich Village', -74.0037, 40.7336, 0.9],
['the Lower East Side', -73.9861, 40.715, 1.0],
['Washington Heights', -73.9393, 40.8417, 1.4],
['the South Bronx', -73.9162, 40.8166, 2.0],
['the Bronx', -73.8648, 40.8448, 4.0],
['Downtown Brooklyn', -73.9857, 40.6928, 1.2],
['Williamsburg, Brooklyn', -73.9573, 40.7081, 1.4],
['Brooklyn', -73.9442, 40.6782, 5.0],
['Long Island City', -73.9485, 40.7447, 1.4],
['Queens', -73.7949, 40.7282, 6.0],
['Staten Island', -74.1502, 40.5795, 6.0],
['Jersey City', -74.0776, 40.7282, 2.5],
['Hoboken', -74.0324, 40.744, 1.0],
['Newark', -74.1724, 40.7357, 3.0],
['JFK Airport', -73.7781, 40.6413, 3.0],
['LaGuardia Airport', -73.874, 40.7769, 1.6],
['Newark Airport', -74.1745, 40.6895, 2.5],
['Downtown Los Angeles', -118.2468, 34.0407, 2.0],
['Hollywood', -118.3287, 34.0928, 2.0],
['Koreatown, Los Angeles', -118.3009, 34.0618, 1.5],
['South Los Angeles', -118.2827, 33.9897, 3.5],
['Santa Monica', -118.4912, 34.0195, 2.5],
['Venice, Los Angeles', -118.4695, 33.985, 1.6],
['Long Beach', -118.1937, 33.7701, 4.0],
['Pasadena', -118.1445, 34.1478, 3.0],
['Burbank', -118.309, 34.1808, 3.0],
['the San Fernando Valley', -118.45, 34.2, 8.0],
['Inglewood', -118.3531, 33.9617, 2.5],
['Compton', -118.2201, 33.8958, 2.5],
['LAX', -118.4085, 33.9416, 2.5],
['Anaheim', -117.9145, 33.8366, 3.5],
['Westminster, London', -0.1357, 51.4975, 1.4],
['the City of London', -0.0918, 51.5155, 1.2],
['Canary Wharf', -0.0235, 51.5054, 1.2],
['Camden, London', -0.1426, 51.539, 1.6],
['Hackney, London', -0.0553, 51.545, 2.0],
['Brixton, London', -0.1145, 51.4613, 1.6],
['Stratford, London', -0.0035, 51.5413, 1.6],
['Croydon', -0.0982, 51.3762, 3.0],
['Heathrow', -0.4543, 51.47, 3.0],
['the Loop, Chicago', -87.6298, 41.8837, 1.4],
['the South Side, Chicago', -87.62, 41.78, 5.0],
['the West Side, Chicago', -87.72, 41.88, 4.0],
['Downtown San Francisco', -122.4014, 37.7881, 1.5],
['the Mission, San Francisco', -122.4194, 37.7599, 1.4],
['Oakland', -122.2711, 37.8044, 4.0],
['Downtown Seattle', -122.3321, 47.6062, 1.5],
['Downtown Houston', -95.3698, 29.7604, 2.0],
['Downtown Dallas', -96.797, 32.7767, 2.0],
['Downtown Miami', -80.1918, 25.7743, 1.6],
['Miami Beach', -80.13, 25.7907, 2.5],
['Downtown Atlanta', -84.388, 33.749, 2.0],
['Downtown Phoenix', -112.074, 33.4484, 2.5],
['Las Vegas Strip', -115.1728, 36.1147, 2.5],
['Downtown Toronto', -79.3832, 43.6532, 2.0],
['Central Paris', 2.3488, 48.8534, 2.5],
['La Défense', 2.2385, 48.8918, 1.4],
['Mitte, Berlin', 13.4049, 52.52, 2.5],
['Central Sydney', 151.2093, -33.8688, 2.0],
['Shinjuku, Tokyo', 139.7036, 35.6938, 2.0],
['Chiyoda, Tokyo', 139.7536, 35.6938, 2.0],
]);
/**
* The most local name for a point: a neighbourhood when it is inside one,
* else "N km DIR of City". Returns { name, local:boolean }.
*/
export function localPlace(lat, lon) {
let best = null;
for (const [name, plon, plat, r] of NEIGHBORHOODS) {
if (Math.abs(plat - lat) > 0.2) continue;
const km = haversineKm(lat, lon, plat, plon);
if (km <= r * 1.35 && (!best || km / r < best.score))
best = { name, score: km / r };
}
if (best) return { name: best.name, local: true };
return { name: describePlace(lat, lon), local: false };
}
+172
View File
@@ -0,0 +1,172 @@
/**
* History view (DESIGN.md §12.5): when the clock is in the recorded past,
* every recorded contact is drawn where it actually was, interpolated
* between polls, with a ghost trail of the four minutes before.
*
* One BillboardCollection (glyphs move in place, keyed by entity) and one
* PolylineCollection for trails, rebuilt at most 4×/s and only for the
* contacts nearest the view centre.
*/
import * as Cesium from 'cesium';
import { glyph } from './glyphs.js';
const D2R = Math.PI / 180;
const GLYPH_CAP = 900;
const TRAIL_CAP = 160;
const TRAIL_MS = 4 * 60_000;
const NEAR_FAR = new Cesium.NearFarScalar(2e4, 1.2, 3e6, 0.6);
const COLORS = {
aircraft: Cesium.Color.fromCssColorString('#BFE6FF'),
helicopter: Cesium.Color.fromCssColorString('#9BE15D'),
military: Cesium.Color.fromCssColorString('#FFD166'),
vessel: Cesium.Color.fromCssColorString('#38E1C6'),
emergency: Cesium.Color.fromCssColorString('#FF3B30'),
};
export function createGhosts({ scene, recorder, requestRender }) {
const root = scene.primitives.add(new Cesium.PrimitiveCollection());
root.show = false;
const glyphs = root.add(new Cesium.BillboardCollection());
const trails = root.add(new Cesium.PolylineCollection());
const byKey = new Map(); // key → { bb, state }
let lastTrails = 0;
let shown = false;
let lastMs = 0;
const colorOf = (meta) =>
meta?.emergency ? COLORS.emergency : COLORS[meta?.kind] || COLORS.aircraft;
function hide() {
if (!shown) return;
shown = false;
root.show = false;
glyphs.removeAll();
trails.removeAll();
byKey.clear();
requestRender();
}
function update(ms, view, { force = false } = {}) {
lastMs = ms;
const rect = view?.rect
? {
west: view.rect.west - 0.5,
east: view.rect.east + 0.5,
south: view.rect.south - 0.5,
north: view.rect.north + 0.5,
}
: null;
const list = recorder.snapshotAt(ms, { rect });
const c = view?.center || { lon: 0, lat: 0 };
const cos = Math.cos(c.lat * D2R);
for (const s of list)
s.d = (s.lat - c.lat) ** 2 + ((s.lon - c.lon) * cos) ** 2;
list.sort((a, b) => a.d - b.d);
const keep = list.slice(0, GLYPH_CAP);
const seen = new Set();
for (const s of keep) {
seen.add(s.key);
let e = byKey.get(s.key);
const pos = Cesium.Cartesian3.fromDegrees(s.lon, s.lat, s.altM || 0);
if (!e) {
const g = glyph(
s.meta?.kind === 'vessel'
? 'ship'
: s.meta?.helicopter
? 'heli'
: 'plane',
);
e = {
bb: glyphs.add({
position: pos,
image: g.image,
imageId: g.imageId,
color: colorOf(s.meta).withAlpha(0.92),
scale: s.meta?.helicopter ? 0.55 : 0.46,
alignedAxis: Cesium.Cartesian3.UNIT_Z,
scaleByDistance: NEAR_FAR,
disableDepthTestDistance: 150_000,
id: { layer: 'history', key: s.key },
}),
};
byKey.set(s.key, e);
} else e.bb.position = pos;
e.bb.rotation = -(s.headingDeg || 0) * D2R;
e.state = s;
}
for (const [key, e] of byKey)
if (!seen.has(key)) {
glyphs.remove(e.bb);
byKey.delete(key);
}
const now = performance.now();
if (force || now - lastTrails > 250) {
lastTrails = now;
trails.removeAll();
for (const s of keep.slice(0, TRAIL_CAP)) {
const pts = recorder.trail(s.key, ms - TRAIL_MS, ms, 60);
if (pts.length < 2) continue;
trails.add({
positions: pts.map(([lo, la, h]) =>
Cesium.Cartesian3.fromDegrees(lo, la, h || 0),
),
width: 2.5,
material: Cesium.Material.fromType('PolylineGlow', {
color: colorOf(s.meta).withAlpha(0.7),
glowPower: 0.25,
taperPower: 0.4,
}),
});
}
}
shown = true;
root.show = true;
requestRender();
return keep.length;
}
return {
update,
hide,
get shown() {
return shown;
},
get glyphs() {
return glyphs;
},
pick(key) {
const e = byKey.get(key);
if (!e?.state) return null;
const s = e.state;
const m = s.meta || {};
const layer = m.layer || 'flights';
return {
kind: m.kind || 'aircraft',
id: key,
entityId: `${layer}:${key}`,
title: m.callsign || m.title || key.toUpperCase(),
subtitle: [
'Recorded',
new Date(lastMs).toISOString().slice(11, 19) + 'Z',
`${Math.round((s.altM * 3.28084) / 100) * 100} ft`,
`${Math.round(s.speed / 0.514444)} kt`,
].join(' · '),
lon: s.lon,
lat: s.lat,
meta: {
layer: 'history',
entityLayer: layer,
icao24: key,
altitudeM: s.altM,
speedMps: s.speed,
headingDeg: s.headingDeg,
recordedAt: lastMs,
},
};
},
destroy() {
scene.primitives.remove(root);
},
};
}
+124
View File
@@ -0,0 +1,124 @@
/**
* A minimal promise wrapper over IndexedDB for the engine's own stores
* (recorder segments, the last good situations snapshot). Every call fails
* soft: no IndexedDB (private mode, insecure frames) resolves to null.
*/
const DB_NAME = 'gev-globe';
const DB_VERSION = 1;
export const STORES = Object.freeze(['seg', 'meta', 'kv']);
let dbPromise = null;
function open() {
if (dbPromise) return dbPromise;
dbPromise = new Promise((resolve) => {
let req;
try {
req = globalThis.indexedDB?.open(DB_NAME, DB_VERSION);
} catch {
resolve(null);
return;
}
if (!req) {
resolve(null);
return;
}
req.onupgradeneeded = () => {
const db = req.result;
for (const name of STORES)
if (!db.objectStoreNames.contains(name)) db.createObjectStore(name);
};
req.onsuccess = () => resolve(req.result);
req.onerror = () => resolve(null);
req.onblocked = () => resolve(null);
});
return dbPromise;
}
function done(req) {
return new Promise((resolve) => {
req.onsuccess = () => resolve(req.result);
req.onerror = () => resolve(null);
});
}
export async function idbGet(store, key) {
const db = await open();
if (!db) return null;
try {
return await done(db.transaction(store).objectStore(store).get(key));
} catch {
return null;
}
}
export async function idbPut(store, key, value) {
const db = await open();
if (!db) return false;
try {
await done(
db.transaction(store, 'readwrite').objectStore(store).put(value, key),
);
return true;
} catch {
return false;
}
}
/** Put many [key, value] pairs in one transaction. */
export async function idbPutMany(store, entries) {
const db = await open();
if (!db || !entries.length) return false;
return new Promise((resolve) => {
try {
const tx = db.transaction(store, 'readwrite');
const os = tx.objectStore(store);
for (const [k, v] of entries) os.put(v, k);
tx.oncomplete = () => resolve(true);
tx.onerror = () => resolve(false);
tx.onabort = () => resolve(false);
} catch {
resolve(false);
}
});
}
/** All [key, value] pairs with lower <= key (numeric keys). */
export async function idbEntries(store, lower = null) {
const db = await open();
if (!db) return [];
return new Promise((resolve) => {
const out = [];
try {
const range =
lower == null ? null : globalThis.IDBKeyRange.lowerBound(lower);
const req = db.transaction(store).objectStore(store).openCursor(range);
req.onsuccess = () => {
const c = req.result;
if (!c) return resolve(out);
out.push([c.key, c.value]);
c.continue();
};
req.onerror = () => resolve(out);
} catch {
resolve(out);
}
});
}
/** Delete keys below `upper` (numeric keys). */
export async function idbDeleteBelow(store, upper) {
const db = await open();
if (!db) return false;
try {
await done(
db
.transaction(store, 'readwrite')
.objectStore(store)
.delete(globalThis.IDBKeyRange.upperBound(upper, true)),
);
return true;
} catch {
return false;
}
}
+918 -13
View File
File diff suppressed because it is too large. Load diff
+5 -1
View File
@@ -129,8 +129,12 @@ export class AircraftLayer extends Layer {
}
onTime(ms, live) {
// Aircraft are live-only: away from now they dim to show they are not
// Inside the recorded past the history layer draws where everything
// actually was, and inside a Moment its stage owns the sky: the live
// snapshot hides. Beyond the record, aircraft dim to show they are not
// a reconstruction of that moment.
const mode = this.ctx.timeMode?.(ms, live) || 'none';
this.root.show = this.on && mode === 'none';
this.renderer.setAlpha(live ? 1 : 0.3);
}
+180
View File
@@ -0,0 +1,180 @@
/**
* 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.',
},
],
};
}
+169
View File
@@ -0,0 +1,169 @@
/**
* Geolocated reactions (DESIGN.md §12.6): hearts, fire, eyes and wow rise
* from the reactor's place on the globe. A screen-space canvas over the
* viewer (pointer-events: none) draws pre-rendered emoji sprites; each
* particle is re-projected every frame so the burst stays anchored to its
* point while the globe moves. The loop runs only while particles live.
*
* attentionHeat(points) draws a soft additive glow where viewers are; it is
* redrawn after every globe render, so it costs nothing at rest.
*/
const KINDS = { heart: '❤️', fire: '🔥', eyes: '👀', wow: '😮' };
const CAP = 300;
const LIFE_S = 2.6;
export function createReactions({ host, project, onFrame }) {
const canvas = document.createElement('canvas');
canvas.className = 'gev-reactions';
canvas.style.cssText =
'position:absolute;inset:0;width:100%;height:100%;pointer-events:none;z-index:2;';
host.appendChild(canvas);
const g = canvas.getContext('2d');
const sprites = new Map();
const parts = [];
let heat = null;
let raf = 0;
let dpr = 1;
function sprite(kind) {
let s = sprites.get(kind);
if (s) return s;
const size = 64;
const c = document.createElement('canvas');
c.width = c.height = size;
const x = c.getContext('2d');
x.textAlign = 'center';
x.textBaseline = 'middle';
x.font = `${size * 0.78}px "Apple Color Emoji","Segoe UI Emoji","Noto Color Emoji",sans-serif`;
x.shadowColor = 'rgba(0,0,0,0.45)';
x.shadowBlur = 6;
x.fillText(KINDS[kind] || KINDS.heart, size / 2, size / 2 + 3);
s = c;
sprites.set(kind, s);
return s;
}
function resize() {
dpr = Math.min(2, globalThis.devicePixelRatio || 1);
const w = Math.round(host.clientWidth * dpr);
const h = Math.round(host.clientHeight * dpr);
if (canvas.width !== w || canvas.height !== h) {
canvas.width = w;
canvas.height = h;
}
}
function drawHeat() {
if (!heat?.length) return;
g.save();
g.globalCompositeOperation = 'lighter';
for (const p of heat) {
const s = project(p.lon, p.lat, 0);
if (!s) continue;
const r = (26 + 40 * Math.sqrt(p.weight ?? 1)) * dpr;
const grad = g.createRadialGradient(
s.x * dpr,
s.y * dpr,
0,
s.x * dpr,
s.y * dpr,
r,
);
grad.addColorStop(0, 'rgba(255,120,150,0.30)');
grad.addColorStop(0.45, 'rgba(255,90,140,0.12)');
grad.addColorStop(1, 'rgba(255,90,140,0)');
g.fillStyle = grad;
g.fillRect(s.x * dpr - r, s.y * dpr - r, r * 2, r * 2);
}
g.restore();
}
function draw(now) {
resize();
g.clearRect(0, 0, canvas.width, canvas.height);
drawHeat();
for (let i = parts.length - 1; i >= 0; i--) {
const p = parts[i];
const age = (now - p.born) / 1000;
if (age > LIFE_S) {
parts.splice(i, 1);
continue;
}
if (age < 0) continue;
const s = project(p.lon, p.lat, p.altM);
if (!s) continue;
const f = age / LIFE_S;
const rise = p.rise * (1 - (1 - f) ** 2.2);
const x = s.x + p.dx * f + Math.sin(age * p.wobble + p.phase) * 9;
const y = s.y - rise;
const size = p.size * (age < 0.18 ? age / 0.18 : 1) * (1 - 0.25 * f);
g.globalAlpha = f < 0.7 ? 1 : 1 - (f - 0.7) / 0.3;
g.drawImage(
sprite(p.kind),
(x - size / 2) * dpr,
(y - size / 2) * dpr,
size * dpr,
size * dpr,
);
}
g.globalAlpha = 1;
}
function loop(now) {
draw(now);
raf = parts.length ? requestAnimationFrame(loop) : 0;
if (!parts.length) onFrame?.();
}
return {
react({ lon, lat, kind = 'heart', count = 1, altM = 0 } = {}) {
if (!Number.isFinite(lon) || !Number.isFinite(lat)) return 0;
const k = KINDS[kind] ? kind : 'heart';
const n = Math.max(1, Math.min(40, Math.round(count)));
const now = performance.now();
for (let i = 0; i < n; i++) {
if (parts.length >= CAP) parts.shift();
parts.push({
lon,
lat,
altM,
kind: k,
born: now + i * (90 + Math.random() * 70),
rise: 120 + Math.random() * 110,
dx: (Math.random() - 0.5) * 70,
wobble: 2 + Math.random() * 3,
phase: Math.random() * 6.28,
size: 22 + Math.random() * 16,
});
}
if (!raf) raf = requestAnimationFrame(loop);
return n;
},
heat(points) {
heat = Array.isArray(points)
? points.filter(
(p) => Number.isFinite(p?.lon) && Number.isFinite(p?.lat),
)
: null;
if (!raf) {
resize();
g.clearRect(0, 0, canvas.width, canvas.height);
drawHeat();
}
},
/** Redraw the heat after a globe render (camera moved). */
afterRender() {
if (raf || !heat?.length) return;
resize();
g.clearRect(0, 0, canvas.width, canvas.height);
drawHeat();
},
get count() {
return parts.length;
},
destroy() {
cancelAnimationFrame(raf);
canvas.remove();
},
};
}
+548
View File
@@ -0,0 +1,548 @@
/**
* The recorder (DESIGN.md §12.5, "it remembers"): every live contact's
* position on every poll, kept for about six hours.
*
* Memory layout: one growable Int32Array per entity, five ints per sample:
* t (epoch s) · lon×1e6 · lat×1e6 · alt (m) · heading×10 << 12 | speed (dm/s)
* That is 20 bytes a sample. Low traffic (helicopters, approaches, anything
* below ~6 km) keeps every poll (≥ 6 s apart); cruise traffic keeps one fix
* every 50 s, since straight lines interpolate. Samples older than 30 min
* are thinned (1/min low, 1/3 min cruise), and the whole buffer is capped at
* 1.2 M samples (~24 MB; oldest minutes are evicted first).
*
* Persistence: once a minute the new samples go to IndexedDB as one segment
* (at most one sample per entity per 30 s, per 2 min for cruise), with each
* entity's metadata.
* Segments older than the window are deleted. On start the recorder replays
* the stored segments, so a reload keeps its history.
*/
import { idbDeleteBelow, idbEntries, idbPut, idbPutMany } from './idb.js';
const STRIDE = 5;
const MIN_GAP_S = 6;
// Cruise traffic (above ~6 km) flies straight lines: one fix a minute
// interpolates to within metres of the truth, so it is recorded sparsely.
// Low traffic (helicopters, approaches, circling) keeps every poll.
const CRUISE_ALT_M = 6000;
const CRUISE_GAP_S = 50;
const FINE_WINDOW_S = 30 * 60;
const COARSE_GAP_S = 60;
const CRUISE_COARSE_GAP_S = 180;
const PERSIST_GAP_S = 30;
const CRUISE_PERSIST_GAP_S = 120;
const MAX_INTERP_GAP_S = 6 * 60;
const D2R = Math.PI / 180;
function packHs(heading, speed) {
const h = Math.round(((((heading ?? 0) % 360) + 360) % 360) * 10) & 0xfff;
const s = Math.max(0, Math.min(4095, Math.round((speed ?? 0) * 10)));
return (h << 12) | s;
}
const unpackH = (v) => ((v >>> 12) & 0xfff) / 10;
const unpackS = (v) => (v & 0xfff) / 10;
class Track {
constructor(key, meta) {
this.key = key;
this.meta = meta;
this.buf = new Int32Array(STRIDE * 16);
this.n = 0;
this.persistedT = 0;
}
get firstT() {
return this.n ? this.buf[0] : 0;
}
get lastT() {
return this.n ? this.buf[(this.n - 1) * STRIDE] : 0;
}
push(t, lon, lat, alt, hs) {
if (this.n && t <= this.lastT) {
// Out-of-order (a merged backfill): insert in place.
return this.insert(t, lon, lat, alt, hs);
}
if ((this.n + 1) * STRIDE > this.buf.length) {
const next = new Int32Array(this.buf.length * 2);
next.set(this.buf);
this.buf = next;
}
const o = this.n * STRIDE;
this.buf[o] = t;
this.buf[o + 1] = lon;
this.buf[o + 2] = lat;
this.buf[o + 3] = alt;
this.buf[o + 4] = hs;
this.n++;
return true;
}
insert(t, lon, lat, alt, hs) {
const i = this.lowerBound(t);
if (i < this.n && Math.abs(this.buf[i * STRIDE] - t) < 2) return false;
if (i > 0 && Math.abs(this.buf[(i - 1) * STRIDE] - t) < 2) return false;
if ((this.n + 1) * STRIDE > this.buf.length) {
const next = new Int32Array(this.buf.length * 2);
next.set(this.buf);
this.buf = next;
}
this.buf.copyWithin((i + 1) * STRIDE, i * STRIDE, this.n * STRIDE);
const o = i * STRIDE;
this.buf[o] = t;
this.buf[o + 1] = lon;
this.buf[o + 2] = lat;
this.buf[o + 3] = alt;
this.buf[o + 4] = hs;
this.n++;
return true;
}
/** First index with t >= value. */
lowerBound(t) {
let lo = 0;
let hi = this.n;
while (lo < hi) {
const mid = (lo + hi) >> 1;
if (this.buf[mid * STRIDE] < t) lo = mid + 1;
else hi = mid;
}
return lo;
}
sample(i) {
const o = i * STRIDE;
const b = this.buf;
return {
t: b[o] * 1000,
lon: b[o + 1] / 1e6,
lat: b[o + 2] / 1e6,
altM: b[o + 3],
headingDeg: unpackH(b[o + 4]),
speed: unpackS(b[o + 4]),
};
}
/** Remove samples before t; thin samples between t and fineFrom. */
compact(dropBefore, fineFrom) {
const b = this.buf;
let w = 0;
let lastKept = -Infinity;
for (let i = 0; i < this.n; i++) {
const o = i * STRIDE;
const t = b[o];
if (t < dropBefore) continue;
const isLast = i === this.n - 1;
const gap = b[o + 3] > CRUISE_ALT_M ? CRUISE_COARSE_GAP_S : COARSE_GAP_S;
if (t < fineFrom && !isLast && t - lastKept < gap) continue;
if (w !== i) b.copyWithin(w * STRIDE, o, o + STRIDE);
lastKept = t;
w++;
}
const removed = this.n - w;
this.n = w;
if (this.buf.length > 64 && this.n * STRIDE * 4 < this.buf.length) {
this.buf = this.buf.slice(0, Math.max(STRIDE * 16, this.n * STRIDE * 2));
}
return removed;
}
}
const lerp = (a, b, f) => a + (b - a) * f;
function lerpAngle(a, b, f) {
const d = ((b - a + 540) % 360) - 180;
return (a + d * f + 360) % 360;
}
/**
* @param {{ windowMs?: number, maxSamples?: number, persist?: boolean }} [opts]
*/
export function createRecorder({
windowMs = 6 * 3600_000,
maxSamples = 1_200_000,
persist = true,
} = {}) {
const tracks = new Map();
let total = 0;
let pending = []; // [key, t, lon, lat, alt, hs]
let segSeq = 0;
const dirtyMeta = new Set();
const listeners = new Set();
const events = []; // { t, kind, key, title } (first squawk, first seen helicopters…)
let loaded = false;
let loading = null;
let minT = Infinity;
let maxT = -Infinity;
function bounds() {
minT = Infinity;
maxT = -Infinity;
for (const tr of tracks.values()) {
if (!tr.n) continue;
if (tr.firstT < minT) minT = tr.firstT;
if (tr.lastT > maxT) maxT = tr.lastT;
}
}
function append(key, meta, t, lon, lat, alt, hs, { fromStore = false } = {}) {
let tr = tracks.get(key);
if (!tr) {
tr = new Track(key, meta);
tracks.set(key, tr);
} else if (meta) {
const prev = tr.meta;
if (
!prev ||
prev.squawk !== meta.squawk ||
prev.callsign !== meta.callsign ||
(meta.registration && prev.registration !== meta.registration)
) {
tr.meta = { ...prev, ...meta };
if (!fromStore) dirtyMeta.add(key);
}
}
const cruise = alt > CRUISE_ALT_M;
if (
tr.n &&
t > tr.lastT &&
t - tr.lastT < (cruise && !fromStore ? CRUISE_GAP_S : MIN_GAP_S)
)
return false;
if (!tr.push(t, lon, lat, alt, hs)) return false;
total++;
if (t < minT) minT = t;
if (t > maxT) maxT = t;
if (
!fromStore &&
persist &&
t - tr.persistedT >= (cruise ? CRUISE_PERSIST_GAP_S : PERSIST_GAP_S)
) {
tr.persistedT = t;
pending.push([key, t, lon, lat, alt, hs]);
}
return true;
}
/**
* Record a feed's records. `kind` names the source family:
* 'aircraft' (flights / regional), 'military', 'vessel'.
*/
function ingest(records, { kind = 'aircraft', at = Date.now() } = {}) {
if (!records?.length) return 0;
let n = 0;
const nowS = Math.floor(at / 1000);
for (const r of records) {
if (!Number.isFinite(r.lon) || !Number.isFinite(r.lat)) continue;
// The transponder's own last-contact time is the truth: a stale
// upstream repeats old positions with old times, never "now".
const seen = Number.isFinite(r.seenAt)
? Math.floor(Math.min(r.seenAt, at + 5000) / 1000)
: nowS;
if (nowS - seen > 15 * 60) continue;
const key = kind === 'vessel' ? `v:${r.id}` : String(r.id);
const meta =
kind === 'vessel'
? { kind: 'vessel', layer: 'vessels', title: r.name, type: r.type }
: {
kind: r.military
? 'military'
: r.helicopter
? 'helicopter'
: 'aircraft',
layer: r.military ? 'military' : 'flights',
callsign: r.callsign,
registration: r.registration || undefined,
type: r.type || undefined,
country: r.country || undefined,
squawk: r.squawk || undefined,
helicopter: Boolean(r.helicopter),
military: Boolean(r.military),
ga: Boolean(r.ga),
category: r.category ?? undefined,
onGround: Boolean(r.onGround),
...(r.pia ? { pia: true } : {}),
...(r.ladd ? { ladd: true } : {}),
};
const before = tracks.get(key);
const hadEmergency = before?.meta?.emergency;
if (r.emergency) meta.emergency = true;
if (
append(
key,
meta,
seen,
Math.round(r.lon * 1e6),
Math.round(r.lat * 1e6),
Math.round(r.altM ?? 0),
packHs(r.heading, r.speed),
)
)
n++;
if (r.emergency && !hadEmergency)
events.push({
t: seen * 1000,
kind: 'squawk',
key,
title: `${r.callsign || r.id} squawking ${r.squawk}`,
lon: r.lon,
lat: r.lat,
});
}
if (events.length > 400) events.splice(0, events.length - 400);
if (n) for (const fn of listeners) fn({ added: n, kind });
return n;
}
/** Merge an externally fetched track ([{t, lon, lat, altM, headingDeg?, speed?}]). */
function merge(key, points, meta) {
let n = 0;
for (const p of points) {
if (!Number.isFinite(p.t) || !Number.isFinite(p.lon)) continue;
if (
append(
key,
meta,
Math.floor(p.t / 1000),
Math.round(p.lon * 1e6),
Math.round(p.lat * 1e6),
Math.round(p.altM ?? 0),
packHs(p.headingDeg, p.speed),
{ fromStore: true },
)
)
n++;
}
return n;
}
function compactAll() {
const nowS = Math.floor(Date.now() / 1000);
let dropBefore = nowS - Math.floor(windowMs / 1000);
const fineFrom = nowS - FINE_WINDOW_S;
let removed = 0;
for (const [key, tr] of tracks) {
removed += tr.compact(dropBefore, fineFrom);
if (!tr.n) tracks.delete(key);
}
total -= removed;
// Over budget: evict the oldest ten minutes at a time.
while (total > maxSamples && dropBefore < nowS - 600) {
dropBefore += 600;
for (const [key, tr] of tracks) {
total -= tr.compact(dropBefore, fineFrom);
if (!tr.n) tracks.delete(key);
}
}
bounds();
}
async function flush() {
if (!persist || !pending.length) return;
const rows = pending;
pending = [];
const keys = [];
const index = new Map();
const data = new Int32Array(rows.length * 6);
rows.forEach(([key, t, lon, lat, alt, hs], i) => {
let k = index.get(key);
if (k == null) {
k = keys.length;
keys.push(key);
index.set(key, k);
}
data.set([k, t, lon, lat, alt, hs], i * 6);
});
const segKey = rows[0][1] + (segSeq++ % 1000) / 1000;
await idbPut('seg', segKey, { keys, data: data.buffer });
if (dirtyMeta.size) {
const metas = [];
for (const key of dirtyMeta) {
const tr = tracks.get(key);
if (tr?.meta) metas.push([key, tr.meta]);
}
dirtyMeta.clear();
await idbPutMany('meta', metas);
}
idbDeleteBelow(
'seg',
Math.floor((Date.now() - windowMs) / 1000) - 60,
).catch(() => {});
}
async function load() {
if (loaded) return;
if (loading) return loading;
loading = (async () => {
if (!persist) return;
const since = Math.floor((Date.now() - windowMs) / 1000);
const [segs, metas] = await Promise.all([
idbEntries('seg', since),
idbEntries('meta'),
]);
const metaBy = new Map(metas);
for (const [, seg] of segs) {
if (!seg?.keys || !seg.data) continue;
const d = new Int32Array(seg.data);
for (let i = 0; i + 5 < d.length; i += 6) {
const key = seg.keys[d[i]];
append(
key,
metaBy.get(key) || { kind: 'aircraft' },
d[i + 1],
d[i + 2],
d[i + 3],
d[i + 4],
d[i + 5],
{ fromStore: true },
);
}
}
// Stored history is coarse (30 s); new polls continue each track.
for (const tr of tracks.values()) tr.persistedT = tr.lastT;
})().finally(() => {
loaded = true;
});
return loading;
}
const flushTimer = setInterval(() => flush().catch(() => {}), 60_000);
const compactTimer = setInterval(compactAll, 5 * 60_000);
const onHide = () => {
if (document.visibilityState === 'hidden') flush().catch(() => {});
};
document.addEventListener('visibilitychange', onHide);
/** Interpolated state of one track at `ms`, or null outside its record. */
function stateAt(tr, ms, { holdMs = 45_000 } = {}) {
if (!tr?.n) return null;
const s = ms / 1000;
const first = tr.firstT;
const last = tr.lastT;
if (s < first - 5 || s > last + holdMs / 1000) return null;
const i = tr.lowerBound(s);
if (i >= tr.n) {
// Past the last fix: dead-reckon briefly along the last heading.
const p = tr.sample(tr.n - 1);
const dt = s - last;
if (p.speed > 1 && dt > 0) {
const h = p.headingDeg * D2R;
const d = p.speed * Math.min(dt, 45);
p.lat += (d * Math.cos(h)) / 111_320;
p.lon += (d * Math.sin(h)) / (111_320 * Math.cos(p.lat * D2R));
}
p.t = ms;
return p;
}
if (i === 0) return { ...tr.sample(0), t: ms };
const a = tr.sample(i - 1);
const b = tr.sample(i);
const gap = (b.t - a.t) / 1000;
if (gap > MAX_INTERP_GAP_S) return null;
const f = (ms - a.t) / (b.t - a.t || 1);
return {
t: ms,
lon: lerp(a.lon, b.lon, f),
lat: lerp(a.lat, b.lat, f),
altM: lerp(a.altM, b.altM, f),
headingDeg: lerpAngle(a.headingDeg, b.headingDeg, f),
speed: lerp(a.speed, b.speed, f),
};
}
return {
ingest,
merge,
load,
flush,
compact: compactAll,
events: () => events.slice(),
subscribe(fn) {
listeners.add(fn);
return () => listeners.delete(fn);
},
/** { start, end, entities, points } in epoch ms; null start/end when empty. */
range() {
if (!Number.isFinite(minT)) bounds();
return {
start: Number.isFinite(minT) ? minT * 1000 : null,
end: Number.isFinite(maxT) ? maxT * 1000 : null,
entities: tracks.size,
points: total,
bytes: total * STRIDE * 4,
};
},
covers(ms, slackMs = 60_000) {
if (!Number.isFinite(minT)) return false;
return ms >= minT * 1000 - slackMs && ms <= maxT * 1000 + slackMs;
},
has: (key) => tracks.has(String(key)),
meta: (key) => tracks.get(String(key))?.meta || null,
keys: () => tracks.keys(),
/** All samples of one entity: [{ t, lon, lat, altM, headingDeg, speed }]. */
samples(key, fromMs = -Infinity, toMs = Infinity) {
const tr = tracks.get(String(key));
if (!tr) return [];
const out = [];
const i0 = Number.isFinite(fromMs) ? tr.lowerBound(fromMs / 1000) : 0;
for (let i = i0; i < tr.n; i++) {
const p = tr.sample(i);
if (p.t > toMs) break;
out.push(p);
}
return out;
},
stateAt(key, ms, opts) {
return stateAt(tracks.get(String(key)), ms, opts);
},
/**
* Every entity with a state at `ms` (optionally inside `rect`):
* [{ key, meta, lon, lat, altM, headingDeg, speed }].
*/
snapshotAt(ms, { rect = null, holdMs = 45_000 } = {}) {
const out = [];
for (const tr of tracks.values()) {
if (!tr.n) continue;
const s = ms / 1000;
if (s < tr.firstT - 5 || s > tr.lastT + holdMs / 1000) continue;
if (rect) {
// Cheap reject on the nearest raw fix before interpolating.
const i = Math.min(tr.n - 1, tr.lowerBound(s));
const lat = tr.buf[i * STRIDE + 2] / 1e6;
const lon = tr.buf[i * STRIDE + 1] / 1e6;
if (lat < rect.south - 1 || lat > rect.north + 1) continue;
if (
rect.west <= rect.east &&
(lon < rect.west - 1.5 || lon > rect.east + 1.5)
)
continue;
}
const p = stateAt(tr, ms, { holdMs });
if (!p) continue;
if (rect && !inRectLL(rect, p.lon, p.lat)) continue;
out.push({ key: tr.key, meta: tr.meta, ...p });
}
return out;
},
/** Positions of one entity between two instants (for ghost trails). */
trail(key, fromMs, toMs, maxPoints = 120) {
const tr = tracks.get(String(key));
if (!tr?.n) return [];
const i0 = tr.lowerBound(fromMs / 1000);
const i1 = tr.lowerBound(toMs / 1000);
const step = Math.max(1, Math.ceil((i1 - i0) / maxPoints));
const out = [];
for (let i = i0; i < i1; i += step) {
const p = tr.sample(i);
out.push([p.lon, p.lat, p.altM]);
}
const end = stateAt(tr, toMs);
if (end) out.push([end.lon, end.lat, end.altM]);
return out;
},
destroy() {
clearInterval(flushTimer);
clearInterval(compactTimer);
document.removeEventListener('visibilitychange', onHide);
flush().catch(() => {});
},
};
}
function inRectLL(rect, lon, lat) {
if (lat < rect.south || lat > rect.north) return false;
if (rect.west <= rect.east) return lon >= rect.west && lon <= rect.east;
return lon >= rect.west || lon <= rect.east;
}
+180
View File
@@ -0,0 +1,180 @@
/**
* "Your sky now" (DESIGN.md §12.3): what is above a person this minute.
* Aircraft within ~50 km from the live ADS-B feeds (fetching a regional
* snapshot around the person when the current one does not cover them),
* satellites above the horizon from CelesTrak elements (SGP4), and the ISS
* with its next pass.
*/
import { twoline2satrec } from 'satellite.js';
import { findNextIssPass, lookAnglesAt } from '../../src/data/issPass.js';
import { haversineKm, subsolarPoint } from './geo.js';
import { bearingDeg } from './gazetteer.js';
const R = 6_371_000;
const D2R = Math.PI / 180;
const ISS_NORAD = '25544';
function elevationDeg(distM, altM) {
// Flat-earth elevation corrected for the curvature drop over distM.
const drop = (distM * distM) / (2 * R);
return Math.atan2(altM - drop, distM) / D2R;
}
function sunElevation(lat, lon, ms) {
const s = subsolarPoint(ms);
const cosZ =
Math.sin(lat * D2R) * Math.sin(s.lat * D2R) +
Math.cos(lat * D2R) * Math.cos(s.lat * D2R) * Math.cos((lon - s.lon) * D2R);
return 90 - Math.acos(Math.max(-1, Math.min(1, cosZ))) / D2R;
}
const satCache = { stamp: '', sats: [] };
function satrecs(feeds) {
const groups = ['stations', 'visual'];
const stamp = groups.map((g) => feeds[g].updatedAt).join(':');
if (stamp === satCache.stamp) return satCache.sats;
const seen = new Set();
const sats = [];
for (const g of groups)
for (const e of feeds[g].data?.entries || []) {
const id = e.line1.slice(2, 7).trim();
if (seen.has(id)) continue;
seen.add(id);
try {
sats.push({
id,
name: e.name.trim(),
satrec: twoline2satrec(e.line1, e.line2),
});
} catch {
/* malformed */
}
}
satCache.stamp = stamp;
satCache.sats = sats;
return sats;
}
export async function skyNow(feeds, { lon, lat }, { km = 50 } = {}) {
if (!Number.isFinite(lon) || !Number.isFinite(lat))
throw new TypeError('skyNow needs lon and lat');
const now = Date.now();
// Aircraft: whatever the feeds hold near the person, plus a regional
// snapshot around them when nothing current covers the spot.
const lists = [];
const flights = feeds.flights.data;
if (flights?.records) lists.push(flights.records);
if (feeds.military.data?.records) lists.push(feeds.military.data.records);
for (const e of feeds.regionalFlights.entries?.() || [])
if (e.data?.records) lists.push(e.data.records);
const covered =
flights &&
(!flights.regional ||
(flights.anchor &&
haversineKm(lat, lon, flights.anchor.lat, flights.anchor.lon) < 300));
if (!covered) {
const key = `sky:${lat.toFixed(1)}:${lon.toFixed(1)}`;
const reg = await Promise.race([
feeds.regionalFlights.get(key, { lat, lon }).catch(() => null),
new Promise((r) => setTimeout(() => r(null), 9000)),
]);
if (reg?.records) lists.push(reg.records);
}
const seen = new Set();
const aircraft = [];
for (const list of lists)
for (const r of list) {
if (seen.has(r.id) || r.onGround) continue;
if (Math.abs(r.lat - lat) > km / 100 + 0.2) continue;
const d = haversineKm(lat, lon, r.lat, r.lon);
if (d > km) continue;
seen.add(r.id);
aircraft.push({
id: `${r.military ? 'military' : 'flights'}:${r.id}`,
title: r.callsign || r.id.toUpperCase(),
kind: r.military
? 'military'
: r.helicopter
? 'helicopter'
: 'aircraft',
lon: r.lon,
lat: r.lat,
altM: Math.round(r.altM ?? 0),
distanceKm: Math.round(d * 10) / 10,
elevationDeg: Math.round(elevationDeg(d * 1000, r.altM ?? 0) * 10) / 10,
azimuthDeg: Math.round(bearingDeg(lat, lon, r.lat, r.lon)),
headingDeg: r.heading ?? null,
speedMps: r.speed ?? null,
squawk: r.squawk || null,
});
}
aircraft.sort((a, b) => b.elevationDeg - a.elevationDeg);
// Satellites above the horizon.
// Cached elements answer at once; a cold catalog gets 4 s.
await Promise.race([
Promise.allSettled([feeds.stations.get(), feeds.visual.get()]),
new Promise((r) => setTimeout(r, 4000)),
]);
const satellites = [];
let iss = null;
for (const s of satrecs(feeds)) {
const look = lookAnglesAt(s.satrec, now, lat, lon);
if (!look) continue;
if (s.id === ISS_NORAD) iss = { s, look };
if (look.elevDeg <= 0) continue;
const p = look.satECI;
satellites.push({
id: `satellites:${s.id}`,
title: s.name,
norad: s.id,
altKm: p ? Math.round(Math.hypot(p.x, p.y, p.z) - 6371) : null,
elevationDeg: Math.round(look.elevDeg * 10) / 10,
azimuthDeg: Math.round(look.azDeg),
});
}
satellites.sort((a, b) => b.elevationDeg - a.elevationDeg);
const sunEl = sunElevation(lat, lon, now);
let issOut = {
visibleNow: false,
elevationDeg: null,
azimuthDeg: null,
nextPass: null,
};
if (iss) {
const pass = findNextIssPass({
satrec: iss.s.satrec,
latDeg: lat,
lonDeg: lon,
fromMs: now,
minElevDeg: 10,
horizonHours: 36,
});
issOut = {
visibleNow: iss.look.elevDeg > 10 && sunEl < -6,
aboveHorizon: iss.look.elevDeg > 0,
elevationDeg: Math.round(iss.look.elevDeg * 10) / 10,
azimuthDeg: Math.round(iss.look.azDeg),
nextPass: pass
? {
start: pass.riseMs,
max: pass.maxElevMs,
end: pass.setMs,
maxElevationDeg: Math.round(pass.maxElevDeg),
riseAzimuthDeg: Math.round(pass.riseAzDeg ?? 0),
visible: Boolean(pass.visible),
}
: null,
};
}
return {
at: now,
observer: { lon, lat },
sunElevationDeg: Math.round(sunEl * 10) / 10,
aircraft: aircraft.slice(0, 60),
satellites: satellites.slice(0, 40),
iss: issOut,
sources: ['OpenSky Network / adsb.lol (ODbL)', 'CelesTrak GP data, SGP4'],
};
}
+951
View File
@@ -0,0 +1,951 @@
/**
* The Moment stage (DESIGN.md §12.4): draws one Moment on the globe, keyed to
* the clock.
*
* tracks a glyph per aircraft at its interpolated position, a bright
* 90-second trail, the whole flown path faint behind it, and a
* hairline drop line to the ground
* captures a 3D view frustum per witness capture, from the phone to the
* subject, appearing (with a flash) when the clock passes it
* subject a highlighted building (real massing when the Moment bundles
* it), an antenna beacon, a pulsing ring at the subject height
* camera a cinematic path keyed to time (Catmull-Rom through the keys),
* released the moment the person touches the globe
*
* Everything is primitives, never Entities. Positions update in place each
* frame; the long "flown path" polylines rebuild at most 4×/s.
*/
import * as Cesium from 'cesium';
import { glyph } from './glyphs.js';
const D2R = Math.PI / 180;
const TRAIL_MS = 90_000;
const SCRATCH = new Cesium.Cartesian3();
const css = (c, a = 1) => Cesium.Color.fromCssColorString(c).withAlpha(a);
/** Index of the last point with t <= ms (binary search), -1 before the start. */
function indexAt(points, ms) {
let lo = 0;
let hi = points.length - 1;
if (!points.length || ms < points[0].t) return -1;
if (ms >= points[hi].t) return hi;
while (lo < hi) {
const mid = (lo + hi + 1) >> 1;
if (points[mid].t <= ms) lo = mid;
else hi = mid - 1;
}
return lo;
}
/** Interpolated { lon, lat, altM, heading, speed } on a track at ms, or null. */
export function trackStateAt(points, ms, { holdMs = 30_000 } = {}) {
const i = indexAt(points, ms);
if (i < 0) return null;
const a = points[i];
const b = points[i + 1];
if (!b) {
if (ms - a.t > holdMs) return null;
const p = points[i - 1] || a;
return { ...a, heading: headingOf(p, a), speed: speedOf(p, a) };
}
if (b.t - a.t > 6 * 60_000) return null;
const f = (ms - a.t) / (b.t - a.t || 1);
// Heading and speed over a ~6-second baseline smooth out ADS-B jitter
// (on sparse live data: just the two fixes either side).
let j = i;
let k = i + 1;
while (j > 0 && a.t - points[j - 1].t <= 3000) j--;
while (k < points.length - 1 && points[k + 1].t - b.t <= 3000) k++;
return {
t: ms,
lon: a.lon + (b.lon - a.lon) * f,
lat: a.lat + (b.lat - a.lat) * f,
altM: a.altM + (b.altM - a.altM) * f,
heading: headingOf(points[j], points[k]),
speed: speedOf(points[j], points[k]),
};
}
function headingOf(a, b) {
const dx = (b.lon - a.lon) * Math.cos(a.lat * D2R);
const dy = b.lat - a.lat;
if (!dx && !dy) return 0;
return (Math.atan2(dx, dy) / D2R + 360) % 360;
}
function speedOf(a, b) {
const dt = (b.t - a.t) / 1000;
if (dt <= 0) return 0;
const dx = (b.lon - a.lon) * 111_320 * Math.cos(a.lat * D2R);
const dy = (b.lat - a.lat) * 110_574;
return Math.hypot(dx, dy) / dt;
}
/* ── Camera path ──────────────────────────────────────────────── */
function catmull(p0, p1, p2, p3, f) {
const f2 = f * f;
const f3 = f2 * f;
return (
0.5 *
(2 * p1 +
(-p0 + p2) * f +
(2 * p0 - 5 * p1 + 4 * p2 - p3) * f2 +
(-p0 + 3 * p1 - 3 * p2 + p3) * f3)
);
}
/** Camera { lon, lat, height, heading, pitch } on a keyed path at ms. */
export function cameraAt(path, ms) {
if (!path?.length) return null;
if (ms <= path[0].t) return { ...path[0] };
if (ms >= path.at(-1).t) return { ...path.at(-1) };
let i = 0;
while (i < path.length - 2 && path[i + 1].t <= ms) i++;
const k0 = path[Math.max(0, i - 1)];
const k1 = path[i];
const k2 = path[i + 1];
const k3 = path[Math.min(path.length - 1, i + 2)];
const raw = (ms - k1.t) / (k2.t - k1.t || 1);
// Unwrap headings around k1 so a 350° → 10° key pans the short way.
const un = (h) => k1.heading + (((h - k1.heading + 540) % 360) - 180);
const h0 = un(k0.heading);
const h2 = un(k2.heading);
const h3 = h2 + (((k3.heading - k2.heading + 540) % 360) - 180);
return {
lon: catmull(k0.lon, k1.lon, k2.lon, k3.lon, raw),
lat: catmull(k0.lat, k1.lat, k2.lat, k3.lat, raw),
height: catmull(k0.height, k1.height, k2.height, k3.height, raw),
heading: (catmull(h0, k1.heading, h2, h3, raw) + 360) % 360,
pitch: catmull(k0.pitch, k1.pitch, k2.pitch, k3.pitch, raw),
};
}
/* ── Frustum geometry ─────────────────────────────────────────── */
/** World-space apex and four far corners of a capture's view cone. */
function frustumCorners(c, groundH, lengthM) {
const origin = Cesium.Cartesian3.fromDegrees(
c.lon,
c.lat,
groundH + (c.altM || 1.5),
);
const enu = Cesium.Transforms.eastNorthUpToFixedFrame(origin);
const h = (c.headingDeg || 0) * D2R;
const p = (c.pitchDeg || 0) * D2R;
const d = [Math.sin(h) * Math.cos(p), Math.cos(h) * Math.cos(p), Math.sin(p)];
const r = [Math.cos(h), -Math.sin(h), 0];
const u = [
r[1] * d[2] - r[2] * d[1],
r[2] * d[0] - r[0] * d[2],
r[0] * d[1] - r[1] * d[0],
];
// Portrait phone frame: fovDeg across the short side, 4:3.
const th = Math.tan(((c.fovDeg || 60) * D2R) / 2);
const tv = th * (4 / 3);
const corners = [
[-1, -1],
[1, -1],
[1, 1],
[-1, 1],
].map(([sx, sy]) => {
const local = new Cesium.Cartesian3(
lengthM * (d[0] + th * sx * r[0] + tv * sy * u[0]),
lengthM * (d[1] + th * sx * r[1] + tv * sy * u[1]),
lengthM * (d[2] + th * sx * r[2] + tv * sy * u[2]),
);
return Cesium.Matrix4.multiplyByPoint(enu, local, new Cesium.Cartesian3());
});
return { origin, corners };
}
function frustumGeometry(origin, corners) {
const pts = [origin, ...corners];
const values = new Float64Array(pts.length * 3);
pts.forEach((q, i) => {
values[i * 3] = q.x;
values[i * 3 + 1] = q.y;
values[i * 3 + 2] = q.z;
});
return new Cesium.Geometry({
attributes: {
position: new Cesium.GeometryAttribute({
componentDatatype: Cesium.ComponentDatatype.DOUBLE,
componentsPerAttribute: 3,
values,
}),
},
// Four sides; the far face is left open so the subject shows through.
indices: new Uint16Array([0, 1, 2, 0, 2, 3, 0, 3, 4, 0, 4, 1]),
primitiveType: Cesium.PrimitiveType.TRIANGLES,
boundingSphere: Cesium.BoundingSphere.fromPoints(pts),
});
}
/* ── The subject building: stepped Art Deco massing ───────────── */
// Tiers as (fraction of the lot's long side, of its short side, from, to)
// in metres above the street, after the Empire State Building's setbacks.
const SUBJECT_TIERS = [
[1.0, 1.0, 0, 25],
[0.8, 0.86, 25, 80],
[0.66, 0.78, 80, 122],
[0.5, 0.7, 122, 286],
[0.36, 0.52, 286, 320],
];
const SUBJECT_MAST = [
[10, 320, 352],
[7, 352, 373],
[4.5, 373, 381],
];
function subjectMassing(row, olon, olat, kx, ky, base) {
const xs = [];
const ys = [];
for (let i = 2; i + 1 < row.length; i += 2) {
xs.push(row[i]);
ys.push(row[i + 1]);
}
const n = xs.length;
const cx = xs.reduce((a, b) => a + b, 0) / n;
const cy = ys.reduce((a, b) => a + b, 0) / n;
let sxx = 0;
let syy = 0;
let sxy = 0;
for (let i = 0; i < n; i++) {
sxx += (xs[i] - cx) ** 2;
syy += (ys[i] - cy) ** 2;
sxy += (xs[i] - cx) * (ys[i] - cy);
}
const th = 0.5 * Math.atan2(2 * sxy, sxx - syy);
const ux = Math.cos(th);
const uy = Math.sin(th);
let umax = 0;
let vmax = 0;
for (let i = 0; i < n; i++) {
const dx = xs[i] - cx;
const dy = ys[i] - cy;
umax = Math.max(umax, Math.abs(dx * ux + dy * uy));
vmax = Math.max(vmax, Math.abs(-dx * uy + dy * ux));
}
const toDeg = (x, y) => [olon + x / kx, olat + y / ky];
const rect = (fu, fv) => {
const out = [];
for (const [a, b] of [
[-1, -1],
[1, -1],
[1, 1],
[-1, 1],
]) {
const u = a * umax * fu;
const v = b * vmax * fv;
out.push(...toDeg(cx + u * ux - v * uy, cy + u * uy + v * ux));
}
return out;
};
const octagon = (r) => {
const out = [];
for (let k = 0; k < 8; k++) {
const a = th + (k * Math.PI) / 4 + Math.PI / 8;
out.push(...toDeg(cx + r * Math.cos(a), cy + r * Math.sin(a)));
}
return out;
};
const tiers = [
...SUBJECT_TIERS.map(([fu, fv, a, b]) => [rect(fu, fv), a, b]),
...SUBJECT_MAST.map(([r, a, b]) => [octagon(r), a, b]),
];
return tiers.map(([ring, from, to], i) => {
// The podium stays a deep amber; the tower brightens toward the mast.
const k = i === 0 ? -0.35 : i / (tiers.length - 1);
return new Cesium.GeometryInstance({
id: { layer: 'moment', key: 'subject' },
geometry: new Cesium.PolygonGeometry({
polygonHierarchy: new Cesium.PolygonHierarchy(
Cesium.Cartesian3.fromDegreesArray(ring),
),
height: base + (from ? 3 + from : 0),
extrudedHeight: base + 3 + to,
vertexFormat: Cesium.PerInstanceColorAppearance.VERTEX_FORMAT,
}),
attributes: {
color: Cesium.ColorGeometryInstanceAttribute.fromColor(
new Cesium.Color(
0.86 + 0.14 * k,
0.72 + 0.22 * k,
0.46 + 0.36 * k,
1,
),
),
},
});
});
}
/* ── Stage ────────────────────────────────────────────────────── */
const TRACK_COLORS = ['#5EC8FF', '#FFD166', '#FF9F6B', '#9BE15D', '#C9A7FF'];
export function createStage({ scene, requestRender, emit }) {
const root = scene.primitives.add(new Cesium.PrimitiveCollection());
root.show = false;
let massing = null; // Primitive (buildings)
let massingKey = '';
const lines = root.add(new Cesium.PolylineCollection());
const glyphs = root.add(new Cesium.BillboardCollection());
let coneRoot = root.add(new Cesium.PrimitiveCollection());
let moment = null;
let groundH = 0;
let altOffset = 0;
let tracks = [];
let captures = [];
let subject = null;
let lastPathRebuild = 0;
let cinematic = true;
let cameraTarget = null;
let framing = 6;
let lastT = 0;
const toCart = (lon, lat, altM, out) =>
Cesium.Cartesian3.fromDegrees(lon, lat, altM + altOffset, undefined, out);
function clear() {
lines.removeAll();
glyphs.removeAll();
root.remove(coneRoot);
coneRoot = root.add(new Cesium.PrimitiveCollection());
tracks = [];
captures = [];
subject = null;
}
async function sampleGround(lon, lat) {
try {
const provider = scene.globe.terrainProvider;
if (!provider || provider instanceof Cesium.EllipsoidTerrainProvider)
return 0;
const [c] = await Promise.race([
Cesium.sampleTerrainMostDetailed(provider, [
Cesium.Cartographic.fromDegrees(lon, lat),
]),
new Promise((r) => setTimeout(() => r([null]), 2500)),
]);
return Number.isFinite(c?.height) ? c.height : 0;
} catch {
return 0;
}
}
/** Real building massing ({ origin, b:[[roofM, groundM, x0,y0,…]] }). */
function buildMassing(data, highlightBin) {
if (massing) {
root.remove(massing);
massing = null;
}
if (!data?.b?.length) return;
const [olon, olat] = data.origin;
const kx = 111_320 * Math.cos(olat * D2R);
const ky = 110_574;
const subjectGround = 15;
const instances = [];
const sub = moment?.subject;
for (const row of data.b) {
const [roof, gnd] = row;
const ring = [];
for (let i = 2; i + 1 < row.length; i += 2)
ring.push(olon + row[i] / kx, olat + row[i + 1] / ky);
if (ring.length < 6) continue;
// Is this the subject building? (closest footprint to the subject)
const cx = row[2] / kx + olon;
const cy = row[3] / ky + olat;
const isSubject =
sub &&
roof > 300 &&
Math.abs(cx - sub.lon) < 0.0012 &&
Math.abs(cy - sub.lat) < 0.0012;
const base = groundH + (gnd - subjectGround) - 3;
if (isSubject) {
instances.push(...subjectMassing(row, olon, olat, kx, ky, base));
continue;
}
// Height-graded massing: low blocks dark, towers catch the light.
const k = Math.min(1, roof / 300);
// A little per-building variation so the massing reads as a city.
const j =
((((row[2] * 73856093) ^ (row[3] * 19349663)) >>> 0) % 1000) / 1000;
// Focus: the blocks around the subject catch a little more light,
// the city falls off into the dark with distance.
const dist = Math.hypot(
row[2] - (sub ? (sub.lon - olon) * kx : 0),
row[3] - (sub ? (sub.lat - olat) * ky : 0),
);
const v = (0.9 + 0.2 * j) * (1.18 - 0.5 * Math.min(1, dist / 2200));
const color = isSubject
? css('#FFE3A3', 1)
: new Cesium.Color(
(0.1 + 0.2 * k) * v,
(0.13 + 0.24 * k) * v,
(0.19 + 0.3 * k) * v,
1,
);
try {
instances.push(
new Cesium.GeometryInstance({
id: isSubject ? { layer: 'moment', key: 'subject' } : undefined,
geometry: new Cesium.PolygonGeometry({
polygonHierarchy: new Cesium.PolygonHierarchy(
Cesium.Cartesian3.fromDegreesArray(ring),
),
height: base,
extrudedHeight: base + 3 + roof,
vertexFormat: Cesium.PerInstanceColorAppearance.VERTEX_FORMAT,
}),
attributes: {
color: Cesium.ColorGeometryInstanceAttribute.fromColor(color),
},
}),
);
} catch {
/* a degenerate footprint */
}
}
void highlightBin;
massing = root.add(
new Cesium.Primitive({
geometryInstances: instances,
appearance: new Cesium.PerInstanceColorAppearance({
flat: false,
translucent: false,
closed: true,
}),
asynchronous: true,
shadows: Cesium.ShadowMode.DISABLED,
}),
);
massing.readyPromise?.then?.(requestRender);
}
function addSubject(s) {
if (!s) return;
const roof = s.roofM ?? s.altM ?? 0;
const top = s.antennaM ?? roof;
const base = toCart(s.lon, s.lat, groundH - altOffset);
// Antenna / marker mast: a glowing hairline from roof to tip.
if (top > roof + 5)
lines.add({
positions: [
Cesium.Cartesian3.fromDegrees(s.lon, s.lat, groundH + roof),
Cesium.Cartesian3.fromDegrees(s.lon, s.lat, groundH + top),
],
width: 3,
material: Cesium.Material.fromType('Color', {
color: css('#FFE3A3', 0.95),
}),
});
// A tall light column so the subject reads from 4 km out.
const column = lines.add({
positions: [
Cesium.Cartesian3.fromDegrees(s.lon, s.lat, groundH + top),
Cesium.Cartesian3.fromDegrees(s.lon, s.lat, groundH + top + 900),
],
width: 8,
material: Cesium.Material.fromType('PolylineGlow', {
color: css('#FFD166', 0.5),
glowPower: 0.12,
taperPower: 0.25,
}),
});
void base;
const ring = glyph('ring');
const halo = glyph('dot');
const beacon = glyphs.add({
position: Cesium.Cartesian3.fromDegrees(s.lon, s.lat, groundH + top),
image: halo.image,
imageId: halo.imageId,
color: css('#FF4D4D'),
scale: 0.9,
disableDepthTestDistance: Number.POSITIVE_INFINITY,
});
const pulse = glyphs.add({
position: Cesium.Cartesian3.fromDegrees(
s.lon,
s.lat,
groundH + (s.altM ?? roof),
),
image: ring.image,
imageId: ring.imageId,
color: css('#FFD166', 0.9),
scale: 1.2,
id: { layer: 'moment', key: 'subject' },
disableDepthTestDistance: Number.POSITIVE_INFINITY,
});
subject = { ...s, beacon, pulse, column, base };
}
function addTracks(list) {
list.forEach((tr, i) => {
if (!tr.points?.length) return;
const color = tr.color || TRACK_COLORS[i % TRACK_COLORS.length];
const g = glyph(tr.kind === 'helicopter' ? 'heli' : 'plane');
const bb = glyphs.add({
position: Cesium.Cartesian3.ZERO,
image: g.image,
imageId: g.imageId,
color: css(color),
scale: tr.kind === 'helicopter' ? 0.62 : 0.55,
alignedAxis: Cesium.Cartesian3.UNIT_Z,
show: false,
id: { layer: 'moment', key: `track:${tr.id}` },
disableDepthTestDistance: Number.POSITIVE_INFINITY,
});
const halo = glyph('dot');
const glow = glyphs.add({
position: Cesium.Cartesian3.ZERO,
image: halo.image,
imageId: halo.imageId,
color: css(color, 0.55),
scale: 1.1,
show: false,
disableDepthTestDistance: Number.POSITIVE_INFINITY,
});
const cart = tr.points.map((p) =>
toCart(p.lon, p.lat, p.altM ?? 0, new Cesium.Cartesian3()),
);
const path = lines.add({
positions: [],
width: 1.6,
material: Cesium.Material.fromType('Color', {
color: css(color, 0.32),
}),
});
const trail = lines.add({
positions: [],
width: 5,
material: Cesium.Material.fromType('PolylineGlow', {
color: css(color, 0.95),
glowPower: 0.22,
taperPower: 0.55,
}),
});
const drop = lines.add({
positions: [],
width: 1,
material: Cesium.Material.fromType('Color', {
color: css(color, 0.35),
}),
});
tracks.push({
tr,
color,
bb,
glow,
cart,
path,
trail,
drop,
state: null,
});
});
}
function addCaptures(list) {
const target = moment?.subject;
for (const c of list || []) {
const lengthM = Math.max(
120,
(c.rangeM ??
(target
? Cesium.Cartesian3.distance(
Cesium.Cartesian3.fromDegrees(c.lon, c.lat),
Cesium.Cartesian3.fromDegrees(target.lon, target.lat),
)
: 300)) * 0.9,
);
const { origin, corners } = frustumCorners(c, groundH, lengthM);
const color = c.firstEyes ? '#FFD166' : '#FFC857';
const fill = coneRoot.add(
new Cesium.Primitive({
geometryInstances: new Cesium.GeometryInstance({
id: { layer: 'moment', key: `capture:${c.id}` },
geometry: frustumGeometry(origin, corners),
attributes: {
color: Cesium.ColorGeometryInstanceAttribute.fromColor(
css(color, c.firstEyes ? 0.14 : 0.07),
),
},
}),
appearance: new Cesium.PerInstanceColorAppearance({
flat: true,
translucent: true,
closed: false,
renderState: { cull: { enabled: false } },
}),
asynchronous: false,
}),
);
fill.show = false;
const edgeMat = Cesium.Material.fromType('Color', {
color: css(color, c.firstEyes ? 0.95 : 0.6),
});
const edges = [];
for (let k = 0; k < 4; k++)
edges.push(
lines.add({
positions: [origin, corners[k]],
width: c.firstEyes ? 1.8 : 1.1,
material: edgeMat,
show: false,
}),
);
edges.push(
lines.add({
positions: [...corners, corners[0]],
width: c.firstEyes ? 2 : 1.2,
material: edgeMat,
show: false,
}),
);
const g = glyph('witness');
const pin = glyphs.add({
position: origin,
image: g.image,
imageId: g.imageId,
color: css(color),
scale: c.firstEyes ? 0.7 : 0.56,
show: false,
id: { layer: 'moment', key: `capture:${c.id}` },
disableDepthTestDistance: Number.POSITIVE_INFINITY,
});
const fl = glyph('dot');
const flash = glyphs.add({
position: origin,
image: fl.image,
imageId: fl.imageId,
color: css('#FFFFFF', 0),
scale: 0.1,
show: false,
disableDepthTestDistance: Number.POSITIVE_INFINITY,
});
captures.push({
c,
fill,
edges,
edgeMat,
pin,
flash,
origin,
shownAt: 0,
});
}
}
function setCamera(t) {
if (!cinematic || !moment) return;
const follow =
moment.follow && tracks.find((x) => x.tr.id === moment.follow.trackId);
if (follow?.state) {
// Chase view: behind and above the aircraft, looking along its track.
const s = follow.state;
const range = moment.follow.rangeM ?? 18_000;
const up = moment.follow.heightM ?? 6_000;
const back = (s.heading + 180 + (moment.follow.offsetDeg ?? 25)) % 360;
const kx = 111_320 * Math.cos(s.lat * D2R);
const lon = s.lon + (Math.sin(back * D2R) * range) / kx;
const lat = s.lat + (Math.cos(back * D2R) * range) / 110_574;
scene.camera.setView({
destination: Cesium.Cartesian3.fromDegrees(lon, lat, s.altM + up),
orientation: {
heading: ((back + 180) % 360) * D2R,
pitch: Math.atan2(-up, range),
roll: 0,
},
});
return;
}
if (!moment.cameraPath?.length) return;
const k = cameraAt(moment.cameraPath, t);
if (!k) return;
// Keys are framed for a landscape-ish view; a portrait phone has a
// narrower horizontal field, so pull back along the line of sight.
const aspect = canvas.clientWidth / Math.max(1, canvas.clientHeight);
const fit = Math.max(1, Math.min(1.6, 0.75 / Math.max(0.2, aspect)));
const sub = moment.subject || { lon: moment.lon, lat: moment.lat };
const kxm = 111_320 * Math.cos(sub.lat * D2R);
const dx = (k.lon - sub.lon) * kxm;
const dy = (k.lat - sub.lat) * 110_574;
const horiz = Math.hypot(dx, dy);
const lookAlt = k.height + Math.tan(k.pitch * D2R) * horiz;
const lon = sub.lon + (dx * fit) / kxm;
const lat = sub.lat + (dy * fit) / 110_574;
const height = lookAlt + (k.height - lookAlt) * fit;
if (subject?.column) {
const far = Math.hypot(horiz * fit, height);
subject.column.material.uniforms.color.alpha = Math.max(
0,
Math.min(0.5, (far - 2500) / 4000),
);
}
scene.camera.setView({
destination: Cesium.Cartesian3.fromDegrees(lon, lat, groundH + height),
orientation: {
heading: k.heading * D2R,
// Look a little below the subject so it sits in the upper half,
// clear of the shell's bottom sheet.
pitch: (k.pitch - framing) * D2R,
roll: 0,
},
});
}
/** Move everything to time `t` (epoch ms). Returns true when drawn. */
function update(t, { realNow = performance.now() } = {}) {
if (!moment) return false;
const rebuildPath = realNow - lastPathRebuild > 250;
if (rebuildPath) lastPathRebuild = realNow;
for (const k of tracks) {
const s = trackStateAt(k.tr.points, t);
k.state = s;
if (!s) {
k.bb.show = k.glow.show = false;
k.trail.positions = [];
k.drop.positions = [];
if (rebuildPath) {
const i = indexAt(k.tr.points, t);
k.path.positions = i > 0 ? k.cart.slice(0, i + 1) : [];
}
continue;
}
const pos = toCart(s.lon, s.lat, s.altM, new Cesium.Cartesian3());
k.bb.position = pos;
k.glow.position = pos;
k.bb.rotation = -s.heading * D2R;
k.bb.show = k.glow.show = true;
const i = indexAt(k.tr.points, t);
// Bright trail: the last 90 s, ending exactly at the glyph.
let j = indexAt(k.tr.points, t - TRAIL_MS);
if (j < 0) j = 0;
const step = Math.max(1, Math.floor((i - j) / 90));
const trail = [];
for (let q = j; q <= i; q += step) trail.push(k.cart[q]);
trail.push(pos);
k.trail.positions = trail.length > 1 ? trail : [];
if (rebuildPath) {
const stride = Math.max(1, Math.floor(i / 600));
const path = [];
for (let q = 0; q <= i; q += stride) path.push(k.cart[q]);
path.push(pos);
k.path.positions = path.length > 1 ? path : [];
}
k.drop.positions = [
pos,
Cesium.Cartesian3.fromDegrees(
s.lon,
s.lat,
groundH,
undefined,
SCRATCH,
).clone(),
];
}
for (const k of captures) {
const on = t >= k.c.t;
if (on && !k.shownAt) k.shownAt = realNow;
if (!on) k.shownAt = 0;
k.fill.show = on;
for (const e of k.edges) e.show = on;
k.pin.show = on;
const age = on ? (realNow - k.shownAt) / 1000 : 9;
k.flash.show = on && age < 0.9;
if (k.flash.show) {
const f = age / 0.9;
k.flash.scale = 0.4 + 3.2 * f;
k.flash.color = css('#FFFFFF', 0.9 * (1 - f));
}
}
if (subject) {
const ph = (realNow / 1000) % 1.6;
subject.pulse.scale = 1.0 + 0.6 * (ph / 1.6);
subject.pulse.color = css('#FFD166', 0.95 * (1 - ph / 1.6));
subject.beacon.color = css(
'#FF4D4D',
(realNow / 1000) % 1.2 < 0.25 ? 1 : 0.25,
);
}
setCamera(t);
lastT = t;
requestRender();
return true;
}
// The person takes the camera: stop flying the path until asked again.
const canvas = scene.canvas;
const release = () => {
if (!moment || !cinematic) return;
cinematic = false;
emit?.('moment', {
type: 'camera',
item: { cinematic: false },
t: lastT,
momentId: moment.id,
});
};
canvas.addEventListener('pointerdown', release, { passive: true });
canvas.addEventListener('wheel', release, { passive: true });
return {
async load(m, { buildings = null } = {}) {
clear();
moment = m;
cinematic = true;
const s = m.subject || { lon: m.lon, lat: m.lat, altM: 0 };
groundH = await sampleGround(s.lon, s.lat);
// Tracks are MSL; draw them relative to the sampled ground.
altOffset = Number.isFinite(m.groundMslM) ? groundH - m.groundMslM : 0;
const key = m.stage?.buildings || '';
if (buildings && key !== massingKey) {
massingKey = key;
buildMassing(buildings, m.stage?.highlight);
} else if (!buildings && massing) {
root.remove(massing);
massing = null;
massingKey = '';
}
if (massing) massing.show = true;
addSubject(m.subject);
addTracks(m.tracks || []);
addCaptures(m.captures || []);
cameraTarget = s;
root.show = true;
update(m.time.start);
},
unload() {
clear();
moment = null;
root.show = false;
if (massing) massing.show = false;
requestRender();
},
update,
get active() {
return Boolean(moment);
},
get cinematic() {
return cinematic;
},
/** Degrees the cinematic camera looks below its subject (0 = centred). */
setFraming(deg) {
if (Number.isFinite(deg)) framing = deg;
setCamera(lastT);
},
setCinematic(on) {
cinematic = Boolean(on);
if (cinematic) setCamera(lastT);
},
groundHeight: () => groundH,
target: () => cameraTarget,
/** Label candidates: [{ id, title, detail?, lon, lat, altM, kind, score }]. */
labels() {
if (!moment) return [];
const out = [];
for (const k of tracks) {
if (!k.state) continue;
out.push({
id: `moment:${moment.id}:${k.tr.id}`,
kind: 'track',
title: k.tr.label,
detail: `${Math.round((k.state.altM * 3.28084) / 25) * 25} FT · ${Math.round(k.state.speed / 0.514444)} KT`,
lon: k.state.lon,
lat: k.state.lat,
altM: k.state.altM + altOffset,
score: -80,
});
}
for (const k of captures)
if (lastT >= k.c.t)
out.push({
id: `capture:${moment.id}:${k.c.id}`,
kind: 'capture',
title: `@${k.c.handle}`,
detail: k.c.firstEyes ? 'FIRST EYES · SEALED' : 'SEALED',
lon: k.c.lon,
lat: k.c.lat,
altM: groundH + (k.c.altM || 1.5),
score: k.c.firstEyes ? -70 : -40,
});
if (subject)
out.push({
id: `moment:${moment.id}:subject`,
kind: 'subject',
title: subject.label || moment.place,
lon: subject.lon,
lat: subject.lat,
altM: groundH + (subject.antennaM ?? subject.altM ?? 0),
score: -90,
});
return out;
},
/** Pick payload for a stage id key. */
pick(key) {
if (!moment) return null;
if (key === 'subject')
return {
kind: 'subject',
id: `moment:${moment.id}:subject`,
title: moment.subject?.label || moment.place,
subtitle: moment.title,
lon: moment.subject?.lon ?? moment.lon,
lat: moment.subject?.lat ?? moment.lat,
meta: { layer: 'moment', momentId: moment.id },
};
const [type, id] = key.split(':');
if (type === 'track') {
const k = tracks.find((x) => x.tr.id === id);
if (!k) return null;
const s = k.state || k.tr.points.at(-1);
return {
kind: 'track',
id: `moment:${moment.id}:${id}`,
title: k.tr.label,
subtitle: [k.tr.type, k.tr.operator].filter(Boolean).join(' · '),
lon: s.lon,
lat: s.lat,
meta: {
layer: 'moment',
momentId: moment.id,
trackId: id,
illustrative: Boolean(moment.illustrative),
altitudeM: s.altM,
},
};
}
if (type === 'capture') {
const k = captures.find((x) => x.c.id === id);
if (!k) return null;
return {
kind: 'capture',
id: `capture:${moment.id}:${id}`,
title: k.c.title,
subtitle: `@${k.c.handle} · ${k.c.place || ''}${k.c.sealed ? ' · Sealed' : ''}`,
lon: k.c.lon,
lat: k.c.lat,
meta: {
layer: 'moment',
momentId: moment.id,
captureId: id,
headingDeg: k.c.headingDeg,
fovDeg: k.c.fovDeg,
firstEyes: Boolean(k.c.firstEyes),
illustrative: Boolean(moment.illustrative),
},
};
}
return null;
},
destroy() {
canvas.removeEventListener('pointerdown', release);
canvas.removeEventListener('wheel', release);
scene.primitives.remove(root);
},
};
}