mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 19:04:02 +00:00
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:
29 files changed
+9600
-205
No files matched your search
@@ -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).
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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() {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user