wip(mobile): checkpoint polish round 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 09:59:48 +00:00
1 parent 4e1e838771
commit 90b962745e
29 files changed
+9600 -205

No files matched your search

+37
View File
@@ -280,3 +280,40 @@ globe.skyNow({ lon, lat } = viewer) → Promise<{
## 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.
---
# Polish round
Additive. Nothing above changes.
## Bundled recording (the time machine always has ghosts)
`mobile/demo/history/{nyc,london,gulf}.json` hold a real recording of every aircraft over the New York area, London and the North Sea approaches, and the Gulf of Mexico, captured by `node mobile/demo/capture-history.mjs` (adsb.lol point snapshots around each region's anchors, the dev server's `/api/flights` and `/api/military` as fallbacks; one fix per 30 s below 6 km, per 120 s in cruise). Positions are quantised to 1e-4° and 10 m and delta-encoded (`format: 'gev-history/1'`). Timestamps are the true capture times and are **never shifted**: the time machine shows the recording where it happened.
The engine loads it about 5 s after the first frame into a second recorder (no expiry) and reads both through one view: ghosts, `setTime` history mode, `entity()`, `trackOf()` and `search()` see the recording; on a key present in both, the device's own recorder wins.
```js
globe.history().archive → { start, end, regions: [{ id, label, bbox, source, start, end, tracks,
busiest: { t, aircraft } }] } | null
globe.loadHistory() → Promise<history().archive> // resolves once it is loaded
globe.historyNear(t, lon, lat, km = 100)
→ [{ id /* entity id */, key, kind, title, lon, lat, altM }] // recorded contacts at t, nearest first
// event 'history' → history(), once the recording is loaded
```
The time ribbon shades the recording in teal (`RECORDING · 23 OCT`) next to the device's own `RECORDED` range, and its readout names the day when the playhead is on another UTC day.
## Camera
```js
globe.flyTo({ lon, lat, height, heading, pitch, eye: true }); // eye: lon/lat/height place the camera itself
```
## Night close-ups
Below ~400 km, where Cesium's lighting is faded out, a night-side view grades the Esri mosaic toward night (darker, desaturated, slightly cooler) with a soft glow of the real Black Marble lights above ~40 km; the strength follows the sun's elevation under the camera at the **scene clock**, so a Moment keeps the lighting of its own time (the hero is daylight). A terrain watchdog falls back to the flat ellipsoid when the keyless terrain host stalls (the globe would otherwise show only the star field).
## Dossiers
`privacy` is set only for real privacy programmes (`'pia'`, `'ladd'` flags). An airframe with no public registry record gets `registryMissing: true` instead, and `identity.operator` falls back to the callsign's ICAO airline prefix (`mobile/globe/airlines.js`, about 150 operators).
+169
View File
@@ -0,0 +1,169 @@
/**
* ICAO airline designators (the three letters that start an airline
* callsign, e.g. AMX021 → Aeroméxico) for about 150 of the world's busiest
* operators. Used to name the operator of a flight when the community
* registry has no record for its airframe. Source: ICAO Doc 8585 designators
* as published by the airlines; names are the common trading names.
*/
const AIRLINES = {
AAL: 'American Airlines',
AAR: 'Asiana Airlines',
ABX: 'ABX Air',
ACA: 'Air Canada',
ADR: 'Air Serbia',
AEA: 'Air Europa',
AEE: 'Aegean Airlines',
AFL: 'Aeroflot',
AFR: 'Air France',
AIC: 'Air India',
AJX: 'Air Japan',
ALK: 'SriLankan Airlines',
AMX: 'Aeroméxico',
ANA: 'All Nippon Airways',
ANZ: 'Air New Zealand',
ASA: 'Alaska Airlines',
ASH: 'Mesa Airlines',
AUA: 'Austrian Airlines',
AVA: 'Avianca',
AWE: 'American Airlines (US Airways)',
AXM: 'AirAsia',
AZA: 'ITA Airways',
AZU: 'Azul',
BAW: 'British Airways',
BCS: 'European Air Transport (DHL)',
BEL: 'Brussels Airlines',
BTI: 'airBaltic',
BOX: 'AeroLogic',
CAL: 'China Airlines',
CCA: 'Air China',
CES: 'China Eastern Airlines',
CFG: 'Condor',
CHH: 'Hainan Airlines',
CKS: 'Kalitta Air',
CLX: 'Cargolux',
CMP: 'Copa Airlines',
CPA: 'Cathay Pacific',
CPZ: 'Compass Airlines',
CSN: 'China Southern Airlines',
CSZ: 'Shenzhen Airlines',
CTN: 'Croatia Airlines',
CXA: 'Xiamen Airlines',
DAL: 'Delta Air Lines',
DHK: 'DHL Air',
DLH: 'Lufthansa',
EDV: 'Endeavor Air',
EIN: 'Aer Lingus',
EJA: 'NetJets',
EJU: 'easyJet Europe',
ELY: 'El Al',
ENY: 'Envoy Air',
ETD: 'Etihad Airways',
ETH: 'Ethiopian Airlines',
EVA: 'EVA Air',
EWG: 'Eurowings',
EXS: 'Jet2',
EZS: 'easyJet Switzerland',
EZY: 'easyJet',
FDX: 'FedEx Express',
FFT: 'Frontier Airlines',
FIN: 'Finnair',
FJI: 'Fiji Airways',
FLE: 'Flair Airlines',
GEC: 'Lufthansa Cargo',
GFA: 'Gulf Air',
GJS: 'GoJet Airlines',
GLO: 'Gol',
GTI: 'Atlas Air',
HAL: 'Hawaiian Airlines',
HVN: 'Vietnam Airlines',
IBE: 'Iberia',
IBS: 'Iberia Express',
ICE: 'Icelandair',
IGO: 'IndiGo',
JAL: 'Japan Airlines',
JBU: 'JetBlue',
JIA: 'PSA Airlines',
JJP: 'Jetstar Japan',
JST: 'Jetstar',
JZA: 'Jazz Aviation (Air Canada Express)',
KAL: 'Korean Air',
KLM: 'KLM Royal Dutch Airlines',
KQA: 'Kenya Airways',
LAN: 'LATAM Airlines',
LDM: 'Lauda Europe',
LOG: 'Loganair',
LOT: 'LOT Polish Airlines',
LXJ: 'Flexjet',
MAS: 'Malaysia Airlines',
MEA: 'Middle East Airlines',
MSR: 'EgyptAir',
NAX: 'Norwegian',
NJE: 'NetJets Europe',
NKS: 'Spirit Airlines',
NOZ: 'Norwegian',
NWG: 'Airwork',
OAW: 'Helvetic Airways',
OMA: 'Oman Air',
PAL: 'Philippine Airlines',
PAC: 'Polar Air Cargo',
PDT: 'Piedmont Airlines',
PGT: 'Pegasus Airlines',
POE: 'Porter Airlines',
QFA: 'Qantas',
QTR: 'Qatar Airways',
QXE: 'Horizon Air',
RAM: 'Royal Air Maroc',
RJA: 'Royal Jordanian',
ROU: 'Air Canada Rouge',
RPA: 'Republic Airways',
RYR: 'Ryanair',
RZO: 'Azores Airlines',
SAS: 'Scandinavian Airlines',
SAA: 'South African Airways',
SBI: 'S7 Airlines',
SCX: 'Sun Country Airlines',
SIA: 'Singapore Airlines',
SKW: 'SkyWest Airlines',
SLK: 'SilkAir',
SVA: 'Saudia',
SWA: 'Southwest Airlines',
SWR: 'Swiss',
SXS: 'SunExpress',
TAM: 'LATAM Brasil',
TAP: 'TAP Air Portugal',
TAR: 'Tunisair',
TCX: 'Thomas Cook Airlines',
THA: 'Thai Airways',
THY: 'Turkish Airlines',
TOM: 'TUI Airways',
TRA: 'Transavia',
TSC: 'Air Transat',
TVF: 'Transavia France',
TVS: 'Smartwings',
UAE: 'Emirates',
UAL: 'United Airlines',
UPS: 'UPS Airlines',
UZB: 'Uzbekistan Airways',
VIR: 'Virgin Atlantic',
VJT: 'VistaJet',
VLG: 'Vueling',
VOI: 'Volaris',
VOZ: 'Virgin Australia',
VTI: 'Vistara',
WJA: 'WestJet',
WIF: 'Widerøe',
WMT: 'Wizz Air Malta',
WUK: 'Wizz Air UK',
WZZ: 'Wizz Air',
XLF: 'XL Airways',
};
/** Operator name from a callsign's ICAO airline prefix, or null. */
export function airlineFromCallsign(callsign) {
const m = /^([A-Z]{3})\d/.exec(
String(callsign || '')
.trim()
.toUpperCase(),
);
return m ? AIRLINES[m[1]] || null : null;
}
+171
View File
@@ -0,0 +1,171 @@
/**
* The bundled recording (DESIGN.md §12.5): a few hours of real recorded
* positions over the busiest regions, captured with
* `node mobile/demo/capture-history.mjs` into mobile/demo/history/*.json.
*
* It ships so the time machine always has real ghosts to replay, even on a
* first run when the device's own recorder holds only minutes. Timestamps
* are the true capture times and are never shifted: the time machine shows
* the recording where it really happened (the director navigates there).
*
* The archive is a second recorder (no expiry, no persistence). `history`
* is a read-only view over both that ghosts, the dossier and search use; on
* a key present in both, the device's own recorder wins.
*/
import { createRecorder } from './recorder.js';
// Literal URLs so Vite copies the files into the production build.
const FILES = [
new URL('../demo/history/nyc.json', import.meta.url).href,
new URL('../demo/history/london.json', import.meta.url).href,
new URL('../demo/history/gulf.json', import.meta.url).href,
];
/** Decode one `gev-history/1` file into [{ key, meta, points }]. */
export function decodeHistory(j) {
if (j?.format !== 'gev-history/1' || !Array.isArray(j.tracks)) return [];
const out = [];
for (const tr of j.tracks) {
const p = typeof tr.p === 'string' ? tr.p.split(',').map(Number) : tr.p;
if (!Array.isArray(p) || p.length < 12) continue;
let t = j.t0;
let lon = 0;
let lat = 0;
let alt = 0;
const points = [];
for (let i = 0; i + 5 < p.length; i += 6) {
t += p[i];
lon += p[i + 1];
lat += p[i + 2];
alt += p[i + 3];
points.push({
t: t * 1000,
lon: lon / 1e4,
lat: lat / 1e4,
altM: alt * 10,
headingDeg: p[i + 4],
speed: p[i + 5],
});
}
out.push({ key: String(tr.k), meta: tr.m || { kind: 'aircraft' }, points });
}
return out;
}
export function createArchive() {
const rec = createRecorder({
persist: false,
archive: true,
windowMs: Infinity,
maxInterpGapS: 11 * 60,
});
const regions = [];
let loading = null;
function load() {
if (loading) return loading;
loading = Promise.all(
FILES.map((url) =>
fetch(url)
.then((r) => (r.ok ? r.json() : null))
.catch(() => null),
),
).then((files) => {
for (const j of files) {
if (!j) continue;
const tracks = decodeHistory(j);
let start = Infinity;
let end = -Infinity;
for (const tr of tracks) {
rec.merge(tr.key, tr.points, tr.meta);
start = Math.min(start, tr.points[0].t);
end = Math.max(end, tr.points[tr.points.length - 1].t);
}
if (!tracks.length) continue;
regions.push({
id: j.region,
label: j.label,
bbox: j.bbox,
source: j.source,
start,
end,
tracks: tracks.length,
busiest: busiestInstant(tracks, start, end),
});
}
return regions;
});
return loading;
}
return {
recorder: rec,
load,
get loaded() {
return regions.length > 0;
},
regions: () => regions.slice(),
range() {
if (!regions.length) return null;
return {
start: Math.min(...regions.map((r) => r.start)),
end: Math.max(...regions.map((r) => r.end)),
regions: regions.map((r) => ({ ...r })),
};
},
};
}
/** The 5-minute bucket with the most aircraft in the air. */
function busiestInstant(tracks, start, end) {
const B = 5 * 60_000;
const n = Math.max(1, Math.ceil((end - start) / B));
const counts = new Array(n).fill(0);
for (const tr of tracks) {
const a = Math.floor((tr.points[0].t - start) / B);
const b = Math.floor((tr.points[tr.points.length - 1].t - start) / B);
for (let i = Math.max(0, a); i <= Math.min(n - 1, b); i++) counts[i]++;
}
let best = 0;
for (let i = 1; i < n; i++) if (counts[i] > counts[best]) best = i;
return { t: start + best * B + B / 2, aircraft: counts[best] };
}
/**
* A read-only view over the device's recorder and the archive, with the
* recorder's query surface (covers, snapshotAt, trail, samples, stateAt…).
*/
export function historyView(live, archive) {
const a = archive.recorder;
const pick = (key) => (live.has(key) ? live : a.has(key) ? a : null);
return {
covers: (ms, slack) => live.covers(ms, slack) || a.covers(ms, slack),
has: (key) => live.has(key) || a.has(key),
meta: (key) => live.meta(key) || a.meta(key),
*keys() {
yield* live.keys();
for (const k of a.keys()) if (!live.has(k)) yield k;
},
samples(key, from, to) {
const own = live.samples(key, from, to);
return own.length ? own : a.samples(key, from, to);
},
stateAt(key, ms, opts) {
return live.stateAt(key, ms, opts) || a.stateAt(key, ms, opts);
},
snapshotAt(ms, opts) {
const own = live.snapshotAt(ms, opts);
if (!a.covers(ms, 60_000)) return own;
const seen = new Set(own.map((s) => s.key));
for (const s of a.snapshotAt(ms, opts)) if (!seen.has(s.key)) own.push(s);
return own;
},
trail(key, from, to, max) {
const r = live.trail(key, from, to, max);
return r.length >= 2 ? r : a.trail(key, from, to, max);
},
events: () => live.events(),
range: () => live.range(),
source: (key) => (pick(key) === a ? 'archive' : 'device'),
};
}
+41 -10
View File
@@ -12,6 +12,7 @@
* owners opted out (PIA / LADD) or that have no public registration are not
* named beyond what their transponder broadcasts.
*/
import { airlineFromCallsign } from './airlines.js';
import { twoline2satrec } from 'satellite.js';
import { findNextIssPass, lookAnglesAt } from '../../src/data/issPass.js';
import { satPosition } from './layers/orbit.js';
@@ -324,13 +325,13 @@ export function createEntities(env) {
const ladd = Boolean(r?.ladd || meta.ladd);
const identity = pia || ladd ? null : await aircraftIdentity(key, callsign);
const hasPublic = Boolean(identity?.type?.registration || r?.registration);
const privacy = pia
? 'pia'
: ladd
? 'ladd'
: identity?.typeFetched && !hasPublic
? 'no-public-identity'
: null;
// Only real privacy programmes (PIA / LADD flags) withhold identity. A
// missing registry record is just that, and is said neutrally.
const privacy = pia ? 'pia' : ladd ? 'ladd' : null;
const registryMissing = Boolean(
!privacy && identity?.typeFetched && !hasPublic,
);
const prefixOperator = privacy ? null : airlineFromCallsign(callsign);
// The server's full track can be slow (or down): the dossier waits at
// most 2.5 s for it, then uses the recorder's own samples.
const recorded = recorder.samples(key);
@@ -396,10 +397,12 @@ export function createEntities(env) {
kind,
title: callsign || key.toUpperCase(),
subtitle: [
privacy ? null : identity?.route?.airline,
privacy ? null : identity?.route?.airline || prefixOperator,
typeName,
reg,
altM != null ? `${Math.round((altM * M_TO_FT) / 100) * 100} ft` : null,
altM != null
? `${Math.round((Math.max(0, altM) * M_TO_FT) / 100) * 100} ft`
: null,
]
.filter(Boolean)
.join(' · '),
@@ -413,7 +416,9 @@ export function createEntities(env) {
: {}),
...(identity?.route?.airline && !privacy
? { operator: identity.route.airline }
: {}),
: prefixOperator
? { operator: prefixOperator }
: {}),
...(identity?.route && !privacy
? {
route: `${identity.route.origin.code} → ${identity.route.destination.code}`,
@@ -457,6 +462,7 @@ export function createEntities(env) {
sources,
...(trackPending ? { trackPending: true } : {}),
...(privacy ? { privacy } : {}),
...(registryMissing ? { registryMissing: true } : {}),
};
}
@@ -954,6 +960,31 @@ export function createEntities(env) {
relation: 'in moment',
},
...others,
// The sealed captures that caught it, and the place it flew over.
...(m.captures || [])
.filter((c) =>
new RegExp(
`${tr.label.split(/[\s(-]/)[0]}|${tr.kind === 'helicopter' ? 'helicopter|helo' : 'aircraft'}`,
'i',
).test(`${c.title} ${c.place || ''}`),
)
.slice(0, 3)
.map((c) => ({
id: `capture:${m.id}:${c.id}`,
kind: 'capture',
title: c.title,
relation: 'seen in sealed capture',
})),
...(Number.isFinite(cur?.lat)
? [
{
id: `place:${slug(localPlace(cur.lat, cur.lon).name)}`,
kind: 'place',
title: localPlace(cur.lat, cur.lon).name,
relation: 'over',
},
]
: []),
],
sources: [
{
+137 -1
View File
@@ -9,6 +9,7 @@ import * as Cesium from 'cesium';
import { createEsriImagery } from '../../src/maps/imagery.js';
import { createKeylessTerrain } from '../../src/maps/terrain.js';
import { loadPhotorealisticTileset } from '../../src/maps/google3d.js';
import { subsolarPoint } from './geo.js';
/** NASA GIBS VIIRS Black Marble (2016), Web Mercator, keyless, CORS-enabled. */
export const BLACK_MARBLE_URL =
@@ -20,6 +21,28 @@ const LIGHT_FULL_ABOVE_M = 2_500_000;
/** Cesium's night side keeps 0.3 of the lit colour (GlobeFS diffuse floor). */
const NIGHT_DIFFUSE_FLOOR = 0.3;
const NIGHT_LIGHTS_GAIN = 1.3;
/**
* Close-in night grade. Below ~400 km Cesium's lighting is faded out and the
* 500 m Black Marble mosaic is only a blur, so a night-side close-up shows the
* Esri day mosaic graded toward night (darker, desaturated, cooler) with a
* soft glow of the real city lights over it: the city stays readable at any
* hour. The sun is taken from the scene clock, so a Moment keeps the lighting
* of its own time.
*/
const GRADE = Object.freeze({
brightness: 0.5,
contrast: 1.18,
saturation: 0.38,
gamma: 0.9,
hue: 0.04,
glow: 0.3,
});
/**
* Terrain watchdog: when the globe has had nothing to draw for this long
* while it is on screen (the keyless terrain host throttles under load, and
* Cesium then shows only the star field), fall back to the flat ellipsoid.
*/
const TERRAIN_STALL_MS = 5000;
export const BASE_CREDITS = Object.freeze([
{
@@ -104,15 +127,45 @@ export function createImagery(viewer, { terrain = true, google = {} } = {}) {
),
};
let stallTimer = 0;
if (terrain) {
createKeylessTerrain().then(({ provider, fallback }) => {
if (viewer.isDestroyed()) return;
viewer.terrainProvider = provider;
state.terrainCredit = !fallback;
scene.requestRender();
if (!fallback) watchTerrain(provider);
});
}
function watchTerrain(provider) {
let emptySince = 0;
stallTimer = setInterval(() => {
if (viewer.isDestroyed() || viewer.terrainProvider !== provider)
return clearInterval(stallTimer);
// Only while the globe is on screen (a paused globe proves nothing).
if (document.hidden || !globe.show || !viewer.useDefaultRenderLoop) {
emptySince = 0;
return;
}
// Private, read defensively: how many tiles the globe could draw.
const n = globe._surface?._tilesToRender?.length;
if (n == null || n > 0) {
emptySince = 0;
return;
}
scene.requestRender();
if (!emptySince) emptySince = performance.now();
else if (performance.now() - emptySince > TERRAIN_STALL_MS) {
clearInterval(stallTimer);
console.warn('[globe] terrain stalled; using the flat ellipsoid');
viewer.terrainProvider = new Cesium.EllipsoidTerrainProvider();
state.terrainCredit = false;
scene.requestRender();
}
}, 1500);
}
async function ensureGoogle() {
if (!state.googleWanted || state.google || state.googleLoading) return;
state.googleLoading = loadPhotorealisticTileset(Cesium, {
@@ -162,10 +215,74 @@ export function createImagery(viewer, { terrain = true, google = {} } = {}) {
night.nightAlpha = Math.min(1, Math.max(0, (h - 300_000) / 700_000));
}
/**
* Night grade for close-in views (see GRADE): strength follows the sun's
* elevation under the camera and fades out as Cesium's own lighting fades
* in with altitude.
*/
let gradeK = -1;
let gradeStrength = 0;
function grade() {
if (state.nightMode) return;
// The camera's own height, every frame (the view handler is throttled).
const h = scene.camera.positionCartographic.height;
const close =
1 -
Math.min(
1,
Math.max(
0,
(h - LIGHT_FULL_BELOW_M) / (LIGHT_FULL_ABOVE_M - LIGHT_FULL_BELOW_M),
),
);
let k = 0;
if (close > 0) {
const c = scene.camera.positionCartographic;
const ms = Cesium.JulianDate.toDate(viewer.clock.currentTime).getTime();
const sun = subsolarPoint(ms);
const D = Math.PI / 180;
const lat = c.latitude;
const dLon = c.longitude - sun.lon * D;
const sinEl =
Math.sin(lat) * Math.sin(sun.lat * D) +
Math.cos(lat) * Math.cos(sun.lat * D) * Math.cos(dLon);
const el = Math.asin(Math.max(-1, Math.min(1, sinEl))) / D;
// Civil twilight: full grade once the sun is 8° down, none at +4°.
const night = Math.min(1, Math.max(0, (4 - el) / 12));
k = Math.round(night * close * 50) / 50;
}
const glow = GRADE.glow * k * Math.min(1, Math.max(0, (h - 40e3) / 160e3));
const key = k * 1000 + Math.round(glow * 50);
if (key === gradeK) return;
gradeK = key;
gradeStrength = k;
const mix = (a, b) => a + (b - a) * k;
day.brightness = mix(1, GRADE.brightness);
day.contrast = mix(1, GRADE.contrast);
day.saturation = mix(1, GRADE.saturation);
day.gamma = mix(1, GRADE.gamma);
day.hue = mix(0, GRADE.hue);
if (k > 0) {
// Lighting is faded out this close: one glow alpha for both sides.
// Below ~150 km a whole lit city is one 500 m blur, so the glow fades
// out and the graded day mosaic carries the night alone.
night.dayAlpha = glow;
night.nightAlpha = glow;
night.brightness = 1.6;
} else {
night.dayAlpha = 0;
tuneNight();
}
scene.requestRender();
}
const removeGradeListener = scene.preRender.addEventListener(grade);
/** Swap globe ↔ photorealistic tiles by camera height. */
function onHeight(heightM) {
state.heightM = heightM;
gradeK = -1;
tuneNight();
grade();
if (!state.googleWanted) return;
const close = heightM < GOOGLE_3D_MAX_HEIGHT_M;
if (close) ensureGoogle();
@@ -180,11 +297,22 @@ export function createImagery(viewer, { terrain = true, google = {} } = {}) {
/** Night mode: Black Marble everywhere, unshaded (lighting off). */
function setNightMode(on) {
state.nightMode = Boolean(on);
if (on) {
day.brightness = 1;
day.contrast = 1;
day.saturation = 1;
day.gamma = 1;
day.hue = 0;
}
gradeK = -1;
globe.enableLighting = !on;
night.dayAlpha = on ? 1 : 0;
night.nightAlpha = 1;
night.brightness = on ? 1.25 : night.brightness;
if (!on) tuneNight();
if (!on) {
tuneNight();
grade();
}
scene.requestRender();
}
@@ -208,6 +336,14 @@ export function createImagery(viewer, { terrain = true, google = {} } = {}) {
onHeight,
setNightMode,
credits,
/** Night-grade strength 0..1 (0 by day or from orbit). */
get nightGrade() {
return gradeStrength;
},
destroy() {
removeGradeListener();
clearInterval(stallTimer);
},
get googleActive() {
return Boolean(state.google?.show);
},
+66 -6
View File
@@ -85,6 +85,7 @@ import { createCompositor } from './compositor.js';
import { haversineKm } from './geo.js';
import { applyModelAtmosphereWorkaround } from '../../src/app/atmosphereCompat.js';
import { createRecorder } from './recorder.js';
import { createArchive, historyView } from './archive.js';
import { createDetector } from './detect.js';
import { circlingCard, quakeClusterCard } from './detectCards.js';
import { createGhosts } from './ghosts.js';
@@ -255,7 +256,7 @@ function tuneScene(viewer) {
globe.depthTestAgainstTerrain = false;
globe.preloadSiblings = false;
globe.loadingDescendantLimit = 12;
globe.tileCacheSize = 120;
globe.tileCacheSize = 320;
globe.showSkirts = true;
const ssc = scene.screenSpaceCameraController;
@@ -392,12 +393,15 @@ export async function createGlobe(container, options = {}) {
};
// ── Recorder, detector, history, Moments ─────────────────────────
const recorder = createRecorder({ persist: opts.record !== false });
// The bundled real recording, so the time machine always has ghosts.
const archive = createArchive();
const history = historyView(recorder, archive);
const detector = createDetector(recorder);
let momentState = null; // { moment, idx, prevT, restore }
let momentToken = 0;
function timeMode(ms, isLive) {
if (momentState) return 'moment';
if (!isLive && recorder.covers(ms, 90_000)) return 'history';
if (!isLive && history.covers(ms, 90_000)) return 'history';
return 'none';
}
const ingestFeed = (feed, kind) =>
@@ -465,7 +469,7 @@ export async function createGlobe(container, options = {}) {
const layers = buildLayers(ctx, feeds);
const pins = createPins({ scene, requestRender });
const compositor = createCompositor({ feeds });
const ghosts = createGhosts({ scene, recorder, requestRender });
const ghosts = createGhosts({ scene, recorder: history, requestRender });
const stage = createStage({ scene, requestRender, emit });
const reactions = createReactions({
host,
@@ -487,6 +491,13 @@ export async function createGlobe(container, options = {}) {
])
f.get().catch(() => {});
}, 4000);
// The bundled recording loads after the first frame (≈ 3 MB of JSON).
setTimeout(() => {
archive
.load()
.then(() => emit('history', api.history()))
.catch(() => {});
}, 5000);
let viewerPos = opts.observer
? { lon: opts.observer.lon, lat: opts.observer.lat }
: null;
@@ -725,7 +736,9 @@ export async function createGlobe(container, options = {}) {
pitch = -90,
duration,
targetHeight,
eye = false,
} = {}) {
// eye: lon/lat/height place the camera itself (not the point it looks at).
if (!Number.isFinite(lon) || !Number.isFinite(lat))
return Promise.reject(new TypeError('flyTo needs lon and lat'));
const reduceMotion = globalThis.matchMedia?.(
@@ -739,7 +752,7 @@ export async function createGlobe(container, options = {}) {
updateView();
resolve();
};
if (pitch <= -89.5) {
if (pitch <= -89.5 || eye) {
camera.flyTo({
destination: Cesium.Cartesian3.fromDegrees(lon, lat, height),
orientation: { heading: h, pitch: p, roll: 0 },
@@ -1152,6 +1165,13 @@ export async function createGlobe(container, options = {}) {
const trackCache = new Map();
async function trackOf(key, layer) {
key = String(key).toLowerCase();
// A contact known only from the bundled recording: its recorded track
// (a server track would describe today's flight, not that one).
if (!recorder.has(key) && archive.recorder.has(key)) {
const pts = archive.recorder.samples(key);
pts.source = 'Bundled recording (real positions, see capture time)';
return pts;
}
const cached = trackCache.get(key);
// A failed fetch is remembered for 5 min so a dead route costs nothing.
const ttl = cached?.points?.length ? 60_000 : 5 * 60_000;
@@ -1216,7 +1236,7 @@ export async function createGlobe(container, options = {}) {
}
const entities = createEntities({
feeds,
recorder,
recorder: history,
detector,
situations: () => {
const m = new Map(lastSituations);
@@ -1464,9 +1484,49 @@ export async function createGlobe(container, options = {}) {
entities: r.entities,
points: r.points,
bytes: r.bytes,
// The bundled real recording: { start, end, regions: [{ id, label,
// bbox, start, end, tracks, busiest: { t, aircraft } }] } or null.
archive: archive.range(),
};
},
/**
* Recorded contacts near a point at an instant (device recorder and the
* bundled recording): [{ id, key, kind, title, lon, lat, altM }].
*/
historyNear(ms, lon, lat, km = 100) {
const dLat = km / 111;
const dLon = km / (111 * Math.max(0.2, Math.cos((lat * Math.PI) / 180)));
const rect = {
west: lon - dLon,
east: lon + dLon,
south: lat - dLat,
north: lat + dLat,
};
return history
.snapshotAt(ms, { rect })
.map((s) => ({
id: `${s.meta?.layer || 'flights'}:${s.key}`,
key: s.key,
kind: s.meta?.kind || 'aircraft',
title: s.meta?.callsign || s.meta?.title || s.key.toUpperCase(),
lon: s.lon,
lat: s.lat,
altM: s.altM,
d: Math.hypot(
s.lat - lat,
(s.lon - lon) * Math.cos((lat * Math.PI) / 180),
),
}))
.sort((a, b) => a.d - b.d);
},
/** Resolves once the bundled recording is loaded → history().archive. */
async loadHistory() {
await archive.load();
return archive.range();
},
trackOf(entityId) {
const ref = entities.parseRef(entityId);
return trackOf(ref?.key ?? entityId, ref?.layer);
@@ -1941,7 +2001,7 @@ export async function createGlobe(container, options = {}) {
/** Internals for dev harnesses and tests (not part of the contract). */
get debug() {
return { recorder, detector, stage, ghosts };
return { recorder, archive, detector, stage, ghosts };
},
get viewer() {
+1 -1
View File
@@ -48,7 +48,7 @@ export function createGovernor(
Math.min(viewer.canvas.clientWidth, viewer.canvas.clientHeight) < 600;
const base = mode === 'high' ? 1.5 : compact ? 2 : 1.5;
globe.maximumScreenSpaceError = base * Math.max(1, scale);
globe.tileCacheSize = mode === 'low' || lowPower ? 60 : 120;
globe.tileCacheSize = mode === 'low' || lowPower ? 100 : 320;
// The first step down keeps bloom; the second (or being slow at the
// floor) drops it.
post?.setBloomAllowed(
+10 -4
View File
@@ -154,6 +154,8 @@ export function createRecorder({
windowMs = 6 * 3600_000,
maxSamples = 1_200_000,
persist = true,
archive = false,
maxInterpGapS = MAX_INTERP_GAP_S,
} = {}) {
const tracks = new Map();
let total = 0;
@@ -313,6 +315,8 @@ export function createRecorder({
}
function compactAll() {
// An archive (a bundled recording) is already sparse and never expires.
if (archive) return;
const nowS = Math.floor(Date.now() / 1000);
let dropBefore = nowS - Math.floor(windowMs / 1000);
const fineFrom = nowS - FINE_WINDOW_S;
@@ -402,12 +406,14 @@ export function createRecorder({
return loading;
}
const flushTimer = setInterval(() => flush().catch(() => {}), 60_000);
const compactTimer = setInterval(compactAll, 5 * 60_000);
const flushTimer = archive
? 0
: setInterval(() => flush().catch(() => {}), 60_000);
const compactTimer = archive ? 0 : setInterval(compactAll, 5 * 60_000);
const onHide = () => {
if (document.visibilityState === 'hidden') flush().catch(() => {});
};
document.addEventListener('visibilitychange', onHide);
if (!archive) document.addEventListener('visibilitychange', onHide);
/** Interpolated state of one track at `ms`, or null outside its record. */
function stateAt(tr, ms, { holdMs = 45_000 } = {}) {
@@ -434,7 +440,7 @@ export function createRecorder({
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;
if (gap > maxInterpGapS) return null;
const f = (ms - a.t) / (b.t - a.t || 1);
return {
t: ms,