From 5aba6bab58df64e8980439d75a6c72e2ba4bdc72 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 07:52:10 +0000 Subject: [PATCH] feat(mobile): recorder time machine, detections, Moments, dossiers and instant start Records every live contact into an IndexedDB ring buffer so scrubbing back shows real past positions with ghost trails; playback at up to 600x; timeline pips from every layer. Detects circling aircraft and quake clusters on live data and opens them as Moments. Adds the illustrative Empire State hero Moment over real NYC footprints, entity dossiers, search and link graphs, geolocated reactions, skyNow, and a bundled real-data snapshot for an instant first frame. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VVYRAqiQj9Y1V9ZfG1RcDH --- mobile/globe/API.md | 2 +- mobile/globe/detect.js | 13 ++++++++++++- mobile/globe/dev.js | 7 +++++++ mobile/globe/entities.js | 16 +++++++++++++++- mobile/globe/gazetteer.js | 38 ++++++++++++++++++++++++++++++++++++++ mobile/globe/index.js | 12 ++++++++---- mobile/globe/recorder.js | 4 ++++ mobile/globe/stage.js | 13 +++++++------ 8 files changed, 92 insertions(+), 13 deletions(-) diff --git a/mobile/globe/API.md b/mobile/globe/API.md index 3ae6d5411..5ae0a8db6 100644 --- a/mobile/globe/API.md +++ b/mobile/globe/API.md @@ -170,7 +170,7 @@ Entity ids are layer-qualified: `flights:`, `military:`, `satellite 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. +- `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. A single aircraft on its second full orbit is a lesser detection (severity 4.5, `single: true`, for example "Helicopter circling Las Vegas Strip"). `momentOfTheDay()` ignores single orbits. - `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-'`. diff --git a/mobile/globe/detect.js b/mobile/globe/detect.js index d1f968ac1..e56919b59 100644 --- a/mobile/globe/detect.js +++ b/mobile/globe/detect.js @@ -198,8 +198,19 @@ export function createDetector(recorder) { : helis ? 'aircraft' : 'low aircraft'; - const id = `circling-${lat.toFixed(2)}_${lon.toFixed(2)}`; + let id = `circling-${lat.toFixed(2)}_${lon.toFixed(2)}`; const firstOrbit = Math.min(...g.map((m) => m.orbit.from)); + // The same aircraft drifting a few hundred metres is the same + // detection: keep its id and start time. + for (const [oid, d] of live) + if ( + oid !== id && + d.members.some((m) => g.some((x) => x.key === m.key)) && + haversineKm(d.lat, d.lon, lat, lon) < 3 + ) { + id = oid; + break; + } const prev = live.get(id); const det = { id, diff --git a/mobile/globe/dev.js b/mobile/globe/dev.js index 9a32510a2..af3cd075d 100644 --- a/mobile/globe/dev.js +++ b/mobile/globe/dev.js @@ -13,6 +13,13 @@ const globe = await createGlobe(document.getElementById('globe'), { preserveDrawingBuffer: params.has('capture'), }); window.globe = globe; +window.__globeReady = performance.now(); +{ + const off = globe.viewer.scene.postRender.addEventListener(() => { + window.__firstFrame = performance.now(); + off(); + }); +} let quality = globe.quality(); let lastPick = null; diff --git a/mobile/globe/entities.js b/mobile/globe/entities.js index 5ec893b67..f0ba2e511 100644 --- a/mobile/globe/entities.js +++ b/mobile/globe/entities.js @@ -289,6 +289,13 @@ export function createEntities(env) { : {}), }); } + // One link per target (the first relation wins). + const seenIds = new Set(); + links.splice( + 0, + links.length, + ...links.filter((l) => !seenIds.has(l.id) && seenIds.add(l.id)), + ); if (here) links.push({ id: `place:${slug(localPlace(here.lat, here.lon).name)}`, @@ -307,6 +314,7 @@ export function createEntities(env) { const callsign = (r?.callsign || meta.callsign || '').trim(); const t = env.timeMs(); const recState = recorder.stateAt(key, t); + if (!live && !recorder.has(key)) throw new Error(`Unknown aircraft ${key}`); const here = r ? { lon: r.lon, lat: r.lat } : recState @@ -415,7 +423,13 @@ export function createEntities(env) { ...(r?.squawk || meta.squawk ? { squawk: r?.squawk || meta.squawk } : {}), - category: isHeli ? 'rotorcraft' : r?.cls || meta.kind || null, + category: isHeli + ? 'rotorcraft' + : r?.category != null && r?.cls + ? r.cls + : isMil + ? 'military' + : null, military: isMil, }, live: diff --git a/mobile/globe/gazetteer.js b/mobile/globe/gazetteer.js index b5ebbbb80..9488bee15 100644 --- a/mobile/globe/gazetteer.js +++ b/mobile/globe/gazetteer.js @@ -112,6 +112,36 @@ export const WORLD_PLACES = Object.freeze([ ['Asunción', -57.58, -25.26], ['Reykjavík', -21.9, 64.15], ['London', -0.13, 51.51], + ['Birmingham, UK', -1.89, 52.48], + ['Coventry', -1.51, 52.41], + ['Manchester', -2.24, 53.48], + ['Liverpool', -2.98, 53.41], + ['Leeds', -1.55, 53.8], + ['Sheffield', -1.47, 53.38], + ['Newcastle upon Tyne', -1.61, 54.98], + ['Bristol', -2.59, 51.45], + ['Cardiff', -3.18, 51.48], + ['Oxford', -1.26, 51.75], + ['Cambridge, UK', 0.12, 52.2], + ['Nottingham', -1.15, 52.95], + ['Leicester', -1.13, 52.64], + ['Southampton', -1.4, 50.9], + ['Brighton', -0.14, 50.82], + ['Norwich', 1.3, 52.63], + ['Glasgow', -4.25, 55.86], + ['Edinburgh', -3.19, 55.95], + ['Belfast', -5.93, 54.6], + ['Dublin', -6.26, 53.35], + ['Lyon', 4.84, 45.76], + ['Marseille', 5.37, 43.3], + ['Hamburg', 9.99, 53.55], + ['Munich', 11.58, 48.14], + ['Cologne', 6.96, 50.94], + ['Frankfurt', 8.68, 50.11], + ['Rotterdam', 4.48, 51.92], + ['Brussels', 4.35, 50.85], + ['Zürich', 8.54, 47.37], + ['Milan', 9.19, 45.46], ['Paris', 2.35, 48.86], ['Madrid', -3.7, 40.42], ['Lisbon', -9.14, 38.72], @@ -290,6 +320,14 @@ export const NEIGHBORHOODS = Object.freeze([ ['Compton', -118.2201, 33.8958, 2.5], ['LAX', -118.4085, 33.9416, 2.5], ['Anaheim', -117.9145, 33.8366, 3.5], + ['the San Gabriel Valley', -118.03, 34.08, 9.0], + ['Glendale, California', -118.2551, 34.1425, 3.0], + ['Riverside', -117.3962, 33.9533, 6.0], + ['San Bernardino', -117.2898, 34.1083, 6.0], + ['Santa Ana', -117.8678, 33.7455, 4.0], + ['Irvine', -117.8265, 33.6846, 5.0], + ['Downtown San Diego', -117.1611, 32.7157, 2.5], + ['Henderson, Nevada', -114.9817, 36.0395, 5.0], ['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], diff --git a/mobile/globe/index.js b/mobile/globe/index.js index 5c2f179ea..55e9ebc86 100644 --- a/mobile/globe/index.js +++ b/mobile/globe/index.js @@ -1502,12 +1502,16 @@ export async function createGlobe(container, options = {}) { }, async momentOfTheDay() { - const live = liveMoments() - .filter( - (m) => m.tracks.length >= 2 || m.time.end - m.time.start > 5 * 60_000, + // Worth a daily replay: several aircraft circling one place, or an + // emergency with at least 5 min of track. A lone orbit is not enough. + const best = liveMoments() + .filter((m) => + m.situationId + ? m.tracks.length >= 2 + : m.time.end - m.time.start > 5 * 60_000, ) .sort((a, b) => b.tracks.length - a.tracks.length); - return live[0] || hero(); + return best[0] || hero(); }, async playMoment( diff --git a/mobile/globe/recorder.js b/mobile/globe/recorder.js index 56c822c72..6f06c0ed4 100644 --- a/mobile/globe/recorder.js +++ b/mobile/globe/recorder.js @@ -190,7 +190,11 @@ export function createRecorder({ prev.callsign !== meta.callsign || (meta.registration && prev.registration !== meta.registration) ) { + // Flags learned from any source stick (one feed may omit the + // rotorcraft category another one reported). tr.meta = { ...prev, ...meta }; + for (const flag of ['helicopter', 'military', 'pia', 'ladd']) + if (prev?.[flag]) tr.meta[flag] = true; if (!fromStore) dirtyMeta.add(key); } } diff --git a/mobile/globe/stage.js b/mobile/globe/stage.js index 99a2b4092..412a9b52c 100644 --- a/mobile/globe/stage.js +++ b/mobile/globe/stage.js @@ -585,16 +585,18 @@ export function createStage({ scene, requestRender, emit }) { }), ); fill.show = false; - const edgeMat = Cesium.Material.fromType('Color', { - color: css(color, c.firstEyes ? 0.95 : 0.6), - }); + // One material per polyline: a collection destroys each on removal. + 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, + material: edgeMat(), show: false, }), ); @@ -602,7 +604,7 @@ export function createStage({ scene, requestRender, emit }) { lines.add({ positions: [...corners, corners[0]], width: c.firstEyes ? 2 : 1.2, - material: edgeMat, + material: edgeMat(), show: false, }), ); @@ -631,7 +633,6 @@ export function createStage({ scene, requestRender, emit }) { c, fill, edges, - edgeMat, pin, flash, origin,