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,