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

+63 -62
View File
@@ -4,10 +4,10 @@ _The window through which you see the world, and the eye through which the world
This folder holds two things:
| Path | What it is | Tethered to production? |
| --- | --- | --- |
| [`mobile/index.html`](index.html) → `/mobile/` | **Prototype.** A working phone/foldable app that sets the design language on top of the real engine: CesiumJS, Esri cloudless imagery, NASA Black Marble night lights, Google Photorealistic 3D Tiles (when keyed), and the app's own live `/api` feeds (ADS-B, military, AIS, TLEs, quakes, FIRMS fires, cyclones, launches, CCTV) plus the bundled Bhote Koshi event pack. Capture is real: camera, IMU, GPS and a WebCrypto signature. | Yes. Runs on the existing dev server (`npm run dev`, open `http://localhost:4173/mobile/`). |
| [`mobile/vision/index.html`](vision/index.html) | **Vision.** A single self-contained file with no server, no keys and no production code. It shows where the product goes: the full feed, 4D reconstructions with collaborative hypotheses, the provenance ledger, holograms left in places, a hand-written WebGL globe with a cinematic post chain. | No. Open the file straight from disk in any browser (the dev server's CSP blocks its inline script). Rebuild it with `node mobile/vision/build.mjs`. |
| Path | What it is | Tethered to production? |
| ----------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
| [`mobile/index.html`](index.html) → `/mobile/` | **Prototype.** A working phone/foldable app that sets the design language on top of the real engine: CesiumJS, Esri cloudless imagery, NASA Black Marble night lights, Google Photorealistic 3D Tiles (when keyed), and the app's own live `/api` feeds (ADS-B, military, AIS, TLEs, quakes, FIRMS fires, cyclones, launches, CCTV) plus the bundled Bhote Koshi event pack. Capture is real: camera, IMU, GPS and a WebCrypto signature. | Yes. Runs on the existing dev server (`npm run dev`, open `http://localhost:4173/mobile/`). |
| [`mobile/vision/index.html`](vision/index.html) | **Vision.** A single self-contained file with no server, no keys and no production code. It shows where the product goes: the full feed, 4D reconstructions with collaborative hypotheses, the provenance ledger, holograms left in places, a hand-written WebGL globe with a cinematic post chain. | No. Open the file straight from disk in any browser (the dev server's CSP blocks its inline script). Rebuild it with `node mobile/vision/build.mjs`. |
---
@@ -29,13 +29,13 @@ Five commitments fall out of that sentence:
Five tabs. Capture is a tab, not a floating action, because it is a _mode_ with its own full-screen surface (HIG: tab bars navigate between peer areas; capture is a peer area here, as it is in Camera-first apps).
| Tab | SF-style glyph | Job |
| --- | --- | --- |
| **Now** | `dot.radiowaves` | A two-column, RedNote-style masonry feed of live and recent situations. Each card is a living thumbnail: real imagery of the place, live counters, a pulse when it changes. Sections: _Happening now_, _Near you_, _Reconstructions_, _Always on_ (chopper schools, busy airports, ISS, port of Rotterdam…). |
| **Globe** | `globe.americas` | The window. Full-bleed globe, the lens dock, the time ribbon, search. This is the expert surface. |
| **Capture** | `viewfinder` | Two modes on one dial: **Look** (AR — the God's-eye view projected around you: aircraft, satellites, ships, quakes as overlays on the camera, aligned by compass + gyro) and **Witness** (your phone becomes an eye for the God's eye: signed photo/video with IMU, GPS, heading, depth when available). |
| **Threads** | `point.3.connected.trianglepath.dotted` | Reconstructions people are building together: observations, hypotheses, AI refinements, and the ledger. |
| **You** | `person.crop.circle` | Your captures and their seals, holograms you left in places, situations you follow, alert radius. |
| Tab | SF-style glyph | Job |
| ----------- | --------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| **Now** | `dot.radiowaves` | A two-column, RedNote-style masonry feed of live and recent situations. Each card is a living thumbnail: real imagery of the place, live counters, a pulse when it changes. Sections: _Happening now_, _Near you_, _Reconstructions_, _Always on_ (chopper schools, busy airports, ISS, port of Rotterdam…). |
| **Globe** | `globe.americas` | The window. Full-bleed globe, the lens dock, the time ribbon, search. This is the expert surface. |
| **Capture** | `viewfinder` | Two modes on one dial: **Look** (AR — the God's-eye view projected around you: aircraft, satellites, ships, quakes as overlays on the camera, aligned by compass + gyro) and **Witness** (your phone becomes an eye for the God's eye: signed photo/video with IMU, GPS, heading, depth when available). |
| **Threads** | `point.3.connected.trianglepath.dotted` | Reconstructions people are building together: observations, hypotheses, AI refinements, and the ledger. |
| **You** | `person.crop.circle` | Your captures and their seals, holograms you left in places, situations you follow, alert radius. |
The iOS 26-era shell: a floating glass tab bar that shrinks to a pill while you scroll the feed and expands when you stop; a **tab-bar accessory** shows the one situation you are following live ("Typhoon · 185 km/h · 2 min ago"), like a now-playing bar for the planet.
@@ -60,14 +60,14 @@ The iOS 26-era shell: a floating glass tab bar that shrinks to a pill while you
## 5. Rendering budget (phone)
| Item | Target |
| --- | --- |
| Frame | 60 fps while interacting, 0 fps (request-render mode) at rest |
| Resolution scale | `min(devicePixelRatio, 2)`, dropping to 1.5 / 1.25 when frame time > 20 ms for 2 s |
| Tiles | `maximumScreenSpaceError` 2 → 4 on compact; tile cache 256 MB → 128 MB |
| Post chain | atmosphere + night-lights blend (built into the globe shader) → FXAA → single-pass bloom (half res) → vignette + grade. Bloom and grain switch off under Low Power / thermal pressure |
| Labels | no Cesium labels; HTML/canvas overlay in screen space, capped at 40 visible |
| Data | each layer has an LOD rule (aggregate above 2,000 km altitude, glyphs below) and a visible-count cap |
| Item | Target |
| ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Frame | 60 fps while interacting, 0 fps (request-render mode) at rest |
| Resolution scale | `min(devicePixelRatio, 2)`, dropping to 1.5 / 1.25 when frame time > 20 ms for 2 s |
| Tiles | `maximumScreenSpaceError` 2 → 4 on compact; tile cache 256 MB → 128 MB |
| Post chain | atmosphere + night-lights blend (built into the globe shader) → FXAA → single-pass bloom (half res) → vignette + grade. Bloom and grain switch off under Low Power / thermal pressure |
| Labels | no Cesium labels; HTML/canvas overlay in screen space, capped at 40 visible |
| Data | each layer has an LOD rule (aggregate above 2,000 km altitude, glyphs below) and a visible-count cap |
## 6. Capture and provenance
@@ -98,16 +98,16 @@ Collaborative AI prompting happens on stratum 2: a participant asks the model to
## 8. Layer catalog (44)
| Family | Layers |
| --- | --- |
| Sky | Commercial flights · Military aircraft · Helicopters · Private/GA · Emergency squawks · Drones (Remote ID) · Airport boards · Holding patterns |
| Sea | AIS vessels · Ports · Tankers/cargo · Fishing effort · Dark vessels (AIS gaps) · Submarine cables |
| Orbit | Satellites (TLE) · ISS & crewed · Starlink trains · Launches & trajectories · Debris · Ground tracks & passes |
| Hazards | Earthquakes · Active fires (FIRMS) · Fire perimeters · Floods · Tropical cyclones · Volcanoes · Air quality |
| Eyes | Public CCTV · Signed captures · Street-level imagery · Holograms · Live streams |
| Ground | Transit vehicles · Traffic flow · Bike share · Rail · Events & venues · ALPR cameras |
| Weather | Radar · Clouds · Lightning · Wind · Forecast models |
| Infra | Datacenters · Dams · Power plants · Military installations · GNSS interference · Recent satellite imagery |
| Family | Layers |
| ------- | ---------------------------------------------------------------------------------------------------------------------------------------------- |
| Sky | Commercial flights · Military aircraft · Helicopters · Private/GA · Emergency squawks · Drones (Remote ID) · Airport boards · Holding patterns |
| Sea | AIS vessels · Ports · Tankers/cargo · Fishing effort · Dark vessels (AIS gaps) · Submarine cables |
| Orbit | Satellites (TLE) · ISS & crewed · Starlink trains · Launches & trajectories · Debris · Ground tracks & passes |
| Hazards | Earthquakes · Active fires (FIRMS) · Fire perimeters · Floods · Tropical cyclones · Volcanoes · Air quality |
| Eyes | Public CCTV · Signed captures · Street-level imagery · Holograms · Live streams |
| Ground | Transit vehicles · Traffic flow · Bike share · Rail · Events & venues · ALPR cameras |
| Weather | Radar · Clouds · Lightning · Wind · Forecast models |
| Infra | Datacenters · Dams · Power plants · Military installations · GNSS interference · Recent satellite imagery |
## 9. Situations worth monitoring (the feed's genres)
@@ -168,12 +168,12 @@ Review notes on the first build: the feed showed one kind of picture (open ocean
The app's subject is altitude. Every surface tells you **where the sensor is** and **how high it is**, from orbit down to street level and finally inside: a room, a cockpit, a person's own capture. That is the brand, and it replaces the weather-app palette of blue gradients and big temperature-style numbers.
| Altitude band | Sensor | Telemetry burned into the frame |
| --- | --- | --- |
| **Orbit** · 400–36,000 km | satellites, ISS, launches, weather satellite IR | `ORBIT · 412 KM · 7.66 KM/S · PASS 21:04` |
| **Sky** · 0–15 km | ADS-B aircraft, helicopters, military, radar returns, lightning | `SKY · FL350 · 487 KT · SQ 7700` |
| **Ground** · 0 m | CCTV, traffic, transit, fires, quakes, floods | `GROUND · CAM 0412 · I-5 S · 14:02:11Z` |
| **Inside** · you | signed captures, live broadcasts, holograms, conversations | `WITNESS · SEALED · ±4 M · 1.2 KM FROM EVENT` |
| Altitude band | Sensor | Telemetry burned into the frame |
| ------------------------- | --------------------------------------------------------------- | --------------------------------------------- |
| **Orbit** · 400–36,000 km | satellites, ISS, launches, weather satellite IR | `ORBIT · 412 KM · 7.66 KM/S · PASS 21:04` |
| **Sky** · 0–15 km | ADS-B aircraft, helicopters, military, radar returns, lightning | `SKY · FL350 · 487 KT · SQ 7700` |
| **Ground** · 0 m | CCTV, traffic, transit, fires, quakes, floods | `GROUND · CAM 0412 · I-5 S · 14:02:11Z` |
| **Inside** · you | signed captures, live broadcasts, holograms, conversations | `WITNESS · SEALED · ±4 M · 1.2 KM FROM EVENT` |
Visual rules:
@@ -193,7 +193,7 @@ Card archetypes:
3. **Track card**: one moving thing, such as an aircraft squawking 7700, a helicopter on its 14th circuit at Fort Novosel, or the ISS crossing your night sky. It shows a trail, a big readout, and a "ride along" call to action.
4. **Number card**: one striking fact with a sparkline. Examples: "4,812 lightning strikes in the last hour over the Gulf", "GNSS jamming over the Baltic, 340 aircraft affected".
5. **Witness card**: a signed capture or a live broadcast from a person. It shows the seal, the distance from the event, and the reply count.
6. **Room card**: a conversation that is heating up. It shows the top claim, its status (*Confirmed by 3 signed captures* / *Disputed*), and the participants.
6. **Room card**: a conversation that is heating up. It shows the top claim, its status (_Confirmed by 3 signed captures_ / _Disputed_), and the participants.
Situation genres to source from keyless layers. Each card must say its source:
@@ -222,12 +222,13 @@ People will talk about events anyway, on other platforms and without the evidenc
- **Structured posts.** Each post is one of: **Witness** (I saw or recorded this), **Context** (background, a link, a map), **Question**, **Correction**, or **Prediction**. The kind is chosen in the composer and drawn as an icon. Questions float to the top until answered.
- **Anchored.** A post can pin a place (a dot on the globe) and a time (a pip on the time ribbon). Tapping a post flies the globe there and scrubs the ribbon to that moment. Pinned posts appear in 3D as small speech glyphs at their location.
- **Earned badges, not follower counts.** Badges come from evidence:
- *Was there*: a signed capture within N km and M minutes of the event.
- *Sealed*: the post has a sealed attachment.
- *Local*: the author's home area is set.
- *Domain*: pilot, mariner, met, first responder, held as a verified credential.
- _Was there_: a signed capture within N km and M minutes of the event.
- _Sealed_: the post has a sealed attachment.
- _Local_: the author's home area is set.
- _Domain_: pilot, mariner, met, first responder, held as a verified credential.
Posts are ordered by evidence first, then recency, never by follower count.
- **Community notes with bridging.** Any claim can get a note. A note becomes visible only when raters who usually disagree both rate it helpful (bridging-based ranking, as Community Notes does), which resists brigading.
- **AI synthesis, kept honest.** A pinned card at the top of every room is labelled **Known / Disputed / Unknown**. AI writes it from the observations and the posts, and every line cites its sources. It is regenerated as the room changes, with its prompt and inputs recorded in the ledger. It never states anything that has no citation.
- **Live layer.** While you are in a room, presence shows as small avatars, reactions float up the side, and a "N people watching" count appears. Rooms for live situations sit in the feed as Room cards.
@@ -237,12 +238,12 @@ People will talk about events anyway, on other platforms and without the evidenc
Capture is how a person becomes a sensor. Four modes share one dial:
| Mode | What it makes | Trust tier |
| --- | --- | --- |
| **Look** | AR overlay of contacts around you. Nothing is recorded unless you tap. | none |
| **Witness** | A sealed photo or clip with the IMU, GPS and heading trace (§6). | **Sealed** |
| **Live** | A broadcast from your camera. It appears on the globe as an *eye*: a frustum cone at your position and heading, with live telemetry, watchable by the room. Every 10 s the stream is sealed as hash-chained segments. | **Sealed live** |
| **Upload** | A photo or video from your library. EXIF is read if present, then you place it on the map and set the time. | **Unverified**: labelled as such everywhere, and it can never become an observation, only cited by a hypothesis. |
| Mode | What it makes | Trust tier |
| ----------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- |
| **Look** | AR overlay of contacts around you. Nothing is recorded unless you tap. | none |
| **Witness** | A sealed photo or clip with the IMU, GPS and heading trace (§6). | **Sealed** |
| **Live** | A broadcast from your camera. It appears on the globe as an _eye_: a frustum cone at your position and heading, with live telemetry, watchable by the room. Every 10 s the stream is sealed as hash-chained segments. | **Sealed live** |
| **Upload** | A photo or video from your library. EXIF is read if present, then you place it on the map and set the time. | **Unverified**: labelled as such everywhere, and it can never become an observation, only cited by a hypothesis. |
After the shutter comes the **attach sheet**. It suggests the situation you are probably at, from distance and time ("Typhoon Champi · 4.2 km from track", "Times Square camera · 180 m"). It offers "new place" if none fits, a post kind (Witness by default), an optional note, and whether to drop it as a **hologram** that others can find at this spot later. Posting puts the capture in that situation's room, and a Witness card in the feed when it is notable.
@@ -260,14 +261,14 @@ Founder's brief for this round: take what made Palantir Gotham work, fix what Ci
Gotham's power is not the map. It is that **everything is an object, every object has a history and links, and every view (map, timeline, graph, dossier) shows the same objects**. Select something anywhere and every view follows. Analysts build an investigation, share it, and every fact keeps its source.
| Gotham idea | God's Eye version |
| --- | --- |
| Ontology of objects and links | **Entities**: aircraft, vessels, satellites, cameras, places, events, captures and contributors (pseudonymous). Each has a **dossier**: identity, live state, track history, events, links and receipts. |
| Map + timeline + graph, synchronized | Globe, **time machine** and **link graph** share one selection and one clock. |
| Search to dossier | Universal search ("N46567", "7700", "Piccadilly", "M5.2", "ISS") opens a dossier, not a list of pins. |
| Watchlists and alerts | **Watch**: follow an entity or a place, and write alert rules in plain chips ("any squawk 7700 within 200 km of Home"). |
| Investigations with provenance | **Moments**: shareable 4D reconstructions whose every track, capture and claim carries its source and hash. |
| Audit log | The room ledger: every post, note, AI synthesis and vote is hash-chained and exportable. |
| Gotham idea | God's Eye version |
| ------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Ontology of objects and links | **Entities**: aircraft, vessels, satellites, cameras, places, events, captures and contributors (pseudonymous). Each has a **dossier**: identity, live state, track history, events, links and receipts. |
| Map + timeline + graph, synchronized | Globe, **time machine** and **link graph** share one selection and one clock. |
| Search to dossier | Universal search ("N46567", "7700", "Piccadilly", "M5.2", "ISS") opens a dossier, not a list of pins. |
| Watchlists and alerts | **Watch**: follow an entity or a place, and write alert rules in plain chips ("any squawk 7700 within 200 km of Home"). |
| Investigations with provenance | **Moments**: shareable 4D reconstructions whose every track, capture and claim carries its source and hash. |
| Audit log | The room ledger: every post, note, AI synthesis and vote is hash-chained and exportable. |
**The line we don't cross.** Gotham tracks people; we don't. Trackable entities are things that broadcast publicly (transponders, AIS, orbital elements), public infrastructure, and events. Contributors are pseudonymous. There is no face recognition and no person search, and aircraft on owner-privacy lists (LADD/PIA) are never named. This is the product's moral spine and a talking point, not a footnote.
@@ -301,7 +302,7 @@ A **Moment** is a 4D reconstruction assembled around an event: tracks, sensor fr
The time ribbon becomes a time machine:
- **It remembers.** The app records every live contact's position each poll (a ring buffer persisted in IndexedDB, about 6 h). Scrubbing back shows where everything actually was, with ghost trails. A selected aircraft's full track comes from the server's track routes.
- **It remembers.** The app records every live contact's position each poll (a ring buffer persisted in IndexedDB, about 6 h). Scrubbing back shows where everything actually was, with ghost trails. A selected aircraft's full track comes from the server's track routes. The app also ships a **bundled real recording** (`mobile/demo/history/`, a few hours over New York, London and the Gulf of Mexico, captured with `capture-history.mjs`) so a first run already has ghosts to replay; it keeps its true timestamps and the time machine shows it where it happened (`RECORDING · <date>`).
- **Everything moves together.** Contacts, radar and lightning frames, quakes and fires appearing, cyclone positions, the sun, room posts, and captures all follow the clock.
- **Play.** Play/pause and 1×, 10×, 60× and 600× speeds; momentum scrubbing; detents at events; a **NOW** snap.
- **Event pips.** Pips from every layer are colour-coded by altitude band. Tapping one jumps to it.
@@ -311,13 +312,13 @@ The time ribbon becomes a time machine:
Two lanes in every room. This is the delineation that keeps the conversation both fun and true.
| | **Live** (Twitch) | **Record** (Reddit and X) |
| --- | --- | --- |
| Pace | Fast, ephemeral, scrolls away | Considered, persistent, threaded |
| Shape | One-line chat, emotes, @mentions, slow mode, viewer count | Posts with kinds (§11.3), replies, quote-posts, attachments |
| Reactions | Hearts, 🔥, 👀 and 😮 fly up the screen, **and rise from the reactor's location on the globe**: you see the world watching | Corroborate / Dispute votes weighted by Signal |
| Badges | Witness glow for people with a sealed capture nearby, plus First eyes | Was there, Sealed, Local, Domain |
| Becomes the record? | Only if it carries evidence or is promoted by corroboration | Yes, hash-chained in the ledger |
| | **Live** (Twitch) | **Record** (Reddit and X) |
| ------------------- | -------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------- |
| Pace | Fast, ephemeral, scrolls away | Considered, persistent, threaded |
| Shape | One-line chat, emotes, @mentions, slow mode, viewer count | Posts with kinds (§11.3), replies, quote-posts, attachments |
| Reactions | Hearts, 🔥, 👀 and 😮 fly up the screen, **and rise from the reactor's location on the globe**: you see the world watching | Corroborate / Dispute votes weighted by Signal |
| Badges | Witness glow for people with a sealed capture nearby, plus First eyes | Was there, Sealed, Local, Domain |
| Becomes the record? | Only if it carries evidence or is promoted by corroboration | Yes, hash-chained in the ledger |
- **The clerk.** Every few minutes, AI summarizes the Live lane into "What chat is saying", with citations to messages, and posts it into the Record as a clearly labelled synthesis.
- **Crews.** Crews are persistent groups (X communities): "NYC Scanner Nerds", "Fort Novosel Alumni", "Gulf Storm Chasers". A crew has its own feed, members and rooms, and can co-watch a Moment together.
+388
View File
@@ -0,0 +1,388 @@
#!/usr/bin/env node
/**
* Captures a bundled flight-history recording for the time machine
* (DESIGN.md §12.5). For N minutes it polls the real feeds the engine uses
* (adsb.lol point snapshots around each region's anchors, the dev server's
* /api/flights as a fallback and its /api/military), keeps the busiest
* regions, and writes one file per region to mobile/demo/history/<region>.json.
*
* npx vite --port 4191 --strictPort # a dev server, for the fallbacks
* node mobile/demo/capture-history.mjs [--base http://localhost:4191]
* [--minutes 150] [--poll 20] [--budget 4000000] [--out dir]
* (behind an HTTPS proxy: NODE_USE_ENV_PROXY=1 node …)
*
* The engine's normalisation (flightFromState, classifyAircraft) is used, and the
* same sampling as the recorder's persisted history: one fix per 30 s for
* low traffic, one per 120 s for cruise. The file keeps the real capture
* timestamps; the engine never shifts them.
*
* File format (quantised, delta-encoded):
* { format: 'gev-history/1', region, label, bbox, capturedAt: { start, end },
* source, t0 (epoch s), tracks: [{ k, m, p }] }
* k: recorder key (icao24 / hex) m: recorder meta (callsign, type…)
* p: comma-separated ints, 6 per sample, deltas from the previous sample:
* dt (s) · dlon (1e-4°) · dlat (1e-4°) · dalt (10 m) · heading (°, absolute)
* · speed (m/s, absolute)
* Written every 5 minutes, so an interrupted run still leaves a usable file.
*/
import { mkdir, writeFile } from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { flightFromState } from '../globe/feeds.js';
import { classifyAircraft } from '../../src/data/aircraftClass.js';
const HERE = path.dirname(fileURLToPath(import.meta.url));
const args = Object.fromEntries(
process.argv
.slice(2)
.join(' ')
.split('--')
.filter(Boolean)
.map((kv) => {
const [k, ...v] = kv.trim().split(/\s+/);
return [k, v.join(' ') || true];
}),
);
const BASE = args.base || 'http://localhost:4191';
const MINUTES = Number(args.minutes || 150);
const POLL_S = Number(args.poll || 20);
const BUDGET = Number(args.budget || 4_000_000);
const OUT = args.out ? path.resolve(args.out) : path.join(HERE, 'history');
const REGIONS = [
{
id: 'nyc',
label: 'New York area',
bbox: { west: -76.5, south: 39.4, east: -71.2, north: 42.2 },
anchors: [{ lat: 40.75, lon: -73.98 }],
},
{
id: 'london',
label: 'London and the North Sea approaches',
bbox: { west: -2.6, south: 50.2, east: 5.6, north: 53.6 },
anchors: [
{ lat: 51.51, lon: -0.12 },
{ lat: 52.2, lon: 4.2 },
],
},
{
id: 'gulf',
label: 'Gulf of Mexico',
bbox: { west: -98.5, south: 23.5, east: -81.5, north: 31.2 },
anchors: [
{ lat: 27.5, lon: -90 },
{ lat: 29.6, lon: -95.2 },
{ lat: 27.9, lon: -83.5 },
],
},
];
const CRUISE_ALT_M = 6000;
const LOW_GAP_S = 30;
const CRUISE_GAP_S = 120;
const inBox = (b, lon, lat) =>
lat >= b.south && lat <= b.north && lon >= b.west && lon <= b.east;
/** region id → Map(key → { meta, pts: [[t, lon, lat, alt, hdg, spd]] }) */
const store = new Map(REGIONS.map((r) => [r.id, new Map()]));
let firstAt = Infinity;
let lastAt = -Infinity;
const sources = new Set();
function add(region, key, meta, s) {
const tracks = store.get(region.id);
let tr = tracks.get(key);
if (!tr) {
tr = { meta, pts: [] };
tracks.set(key, tr);
} else tr.meta = { ...tr.meta, ...meta };
const last = tr.pts[tr.pts.length - 1];
const gap = s[3] > CRUISE_ALT_M ? CRUISE_GAP_S : LOW_GAP_S;
if (last && s[0] - last[0] < gap) return;
tr.pts.push(s);
if (s[0] < firstAt) firstAt = s[0];
if (s[0] > lastAt) lastAt = s[0];
}
function ingest(records) {
const nowS = Date.now() / 1000;
for (const r of records) {
if (!Number.isFinite(r.lon) || !Number.isFinite(r.lat)) continue;
if (r.onGround && (r.speed ?? 0) < 8) continue;
const t = Math.floor(
Math.min(r.seenAt ?? Date.now(), Date.now() + 5000) / 1000,
);
if (nowS - t > 15 * 60) continue;
const region = REGIONS.find((g) => inBox(g.bbox, r.lon, r.lat));
if (!region) continue;
const meta = {
kind: r.military ? 'military' : r.helicopter ? 'helicopter' : 'aircraft',
layer: r.military ? 'military' : 'flights',
callsign: r.callsign || undefined,
...(r.registration ? { registration: r.registration } : {}),
...(r.type ? { type: r.type } : {}),
...(r.country ? { country: r.country } : {}),
...(r.squawk ? { squawk: r.squawk } : {}),
...(r.helicopter ? { helicopter: true } : {}),
...(r.military ? { military: true } : {}),
...(r.ga ? { ga: true } : {}),
...(r.pia ? { pia: true } : {}),
...(r.ladd ? { ladd: true } : {}),
};
add(region, String(r.id), meta, [
t,
r.lon,
r.lat,
Math.round(r.altM ?? 0),
Math.round(r.heading ?? 0),
Math.round(r.speed ?? 0),
]);
}
}
async function getJson(url, headers = {}) {
const ctl = new AbortController();
const timer = setTimeout(() => ctl.abort(), 30_000);
try {
const res = await fetch(url, { signal: ctl.signal, headers });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return { json: await res.json(), headers: res.headers };
} finally {
clearTimeout(timer);
}
}
const ADSBLOL = 'https://api.adsb.lol/v2';
const UA = { 'User-Agent': 'gods-eye-view-history-capture/1.0' };
/** adsb.lol v2 aircraft → the engine's flight record shape. */
function fromAdsbLol(a, nowMs) {
if (!Number.isFinite(a.lon) || !Number.isFinite(a.lat)) return null;
const onGround = a.alt_baro === 'ground';
const altFt = Number.isFinite(a.alt_geom) ? a.alt_geom : a.alt_baro;
const cls = classifyAircraft({ typeCode: a.t, category: a.category });
const military = Boolean(a.dbFlags & 1);
return {
id: String(a.hex).replace(/^~/, ''),
callsign: String(a.flight || a.r || a.hex).trim(),
registration: a.r || '',
type: a.t || '',
lon: a.lon,
lat: a.lat,
altM: onGround
? 0
: Math.max(0, (Number.isFinite(altFt) ? altFt : 15000) * 0.3048),
onGround,
speed: Number.isFinite(a.gs) ? a.gs * 0.514444 : 0,
heading: a.track ?? a.true_heading ?? 0,
squawk: a.squawk || null,
helicopter: cls === 'helicopter',
military,
pia: Boolean(a.dbFlags & 4),
ladd: Boolean(a.dbFlags & 8),
seenAt: nowMs - (a.seen_pos ?? a.seen ?? 0) * 1000,
};
}
/**
* Each region's anchors straight from adsb.lol (250 nm point queries, the
* same source the dev server falls back to); an anchor that fails is asked
* through the dev server instead.
*/
async function pollFlights() {
let n = 0;
for (const g of REGIONS) {
for (const a of g.anchors) {
// Polite spacing: adsb.lol answers 429 to bursts.
await new Promise((r) => setTimeout(r, 1500));
try {
const { json } = await getJson(
`${ADSBLOL}/lat/${a.lat}/lon/${a.lon}/dist/250`,
UA,
);
const now = Number.isFinite(json.now) ? json.now : Date.now();
const recs = (json.ac || [])
.map((x) => fromAdsbLol(x, now))
.filter(Boolean);
ingest(recs);
n += recs.length;
sources.add('adsb.lol (ODbL)');
continue;
} catch (error) {
console.warn(`[adsb.lol ${g.id}]`, error.message);
}
try {
const { json } = await getJson(
`${BASE}/api/flights?lat=${a.lat}&lon=${a.lon}`,
);
const recs = (json.states || []).map(flightFromState).filter(Boolean);
ingest(recs);
n += recs.length;
sources.add('God’s Eye dev server (OpenSky / adsb.lol)');
} catch (error) {
console.warn(`[flights ${g.id}]`, error.message);
}
}
}
return n;
}
async function pollMilitary() {
try {
const { json } = await getJson(`${BASE}/api/military`);
const recs = [];
for (const a of json.ac || []) {
if (!Number.isFinite(a.lon) || !Number.isFinite(a.lat)) continue;
const altFt = Number.isFinite(a.alt_geom) ? a.alt_geom : a.alt_baro;
recs.push({
id: String(a.hex),
callsign: String(a.flight || a.r || a.hex).trim(),
registration: a.r || '',
type: a.t || '',
lon: a.lon,
lat: a.lat,
altM:
a.alt_baro === 'ground' ? 0 : Math.max(0, (altFt ?? 15000) * 0.3048),
onGround: a.alt_baro === 'ground',
speed: Number.isFinite(a.gs) ? a.gs * 0.514444 : 0,
heading: a.track ?? a.true_heading ?? 0,
squawk: a.squawk || null,
military: true,
pia: Boolean(a.dbFlags & 4),
ladd: Boolean(a.dbFlags & 8),
seenAt: Date.now() - (a.seen_pos ?? a.seen ?? 0) * 1000,
});
}
ingest(recs);
sources.add('adsb.lol military (ODbL)');
return recs.length;
} catch (error) {
console.warn('[military]', error.message);
return 0;
}
}
function encode(region, cruiseGap) {
const tracks = [];
const t0 = Number.isFinite(firstAt) ? firstAt : Math.floor(Date.now() / 1000);
for (const [k, tr] of store.get(region.id)) {
if (tr.pts.length < 2) continue;
const p = [];
let prev = [t0, 0, 0, 0];
let keptT = -Infinity;
tr.pts.forEach((s, i) => {
const isLast = i === tr.pts.length - 1;
if (
cruiseGap > CRUISE_GAP_S &&
s[3] > CRUISE_ALT_M &&
!isLast &&
s[0] - keptT < cruiseGap
)
return;
keptT = s[0];
const lon = Math.round(s[1] * 1e4);
const lat = Math.round(s[2] * 1e4);
const alt = Math.round(s[3] / 10);
p.push(
s[0] - prev[0],
lon - prev[1],
lat - prev[2],
alt - prev[3],
s[4],
s[5],
);
prev = [s[0], lon, lat, alt];
});
// A string, so the file stays compact under Prettier.
tracks.push({ k, m: tr.meta, p: p.join(',') });
}
return {
format: 'gev-history/1',
region: region.id,
label: region.label,
bbox: region.bbox,
capturedAt: {
start: new Date(firstAt * 1000).toISOString(),
end: new Date(lastAt * 1000).toISOString(),
},
source: [...sources].join(' · '),
note: 'Real recorded positions, captured through the God’s Eye dev server. Timestamps are the true capture times and are never shifted.',
t0,
tracks,
};
}
/** Prettier-clean JSON (the repo checks mobile/ with Prettier). */
async function pretty(text) {
try {
const prettier = await import('prettier');
return await prettier.format(text, { parser: 'json' });
} catch {
return text + '\n';
}
}
async function save() {
await mkdir(OUT, { recursive: true });
let gap = CRUISE_GAP_S;
for (;;) {
const files = REGIONS.map((g) => [g, JSON.stringify(encode(g, gap))]);
const bytes = files.reduce((n, [, s]) => n + s.length, 0);
if (bytes > BUDGET && gap < 300) {
gap += 60;
continue;
}
for (const [g, text] of files)
await writeFile(path.join(OUT, `${g.id}.json`), await pretty(text));
const index = {
format: 'gev-history-index/1',
capturedAt: {
start: new Date(firstAt * 1000).toISOString(),
end: new Date(lastAt * 1000).toISOString(),
},
regions: REGIONS.map((g, i) => ({
id: g.id,
label: g.label,
bbox: g.bbox,
file: `${g.id}.json`,
tracks: JSON.parse(files[i][1]).tracks.length,
})),
};
await writeFile(
path.join(OUT, 'index.json'),
await pretty(JSON.stringify(index)),
);
console.log(
`[save] ${new Date().toISOString()} span ${Math.round((lastAt - firstAt) / 60)} min, ${Math.round(bytes / 1024)} KB (cruise gap ${gap}s)`,
);
return;
}
}
const until = Date.now() + MINUTES * 60_000;
let polls = 0;
let lastSave = Date.now();
let lastMil = 0;
console.log(`capturing ${MINUTES} min from ${BASE} → ${OUT}`);
while (Date.now() < until) {
const started = Date.now();
const n = await pollFlights();
if (Date.now() - lastMil > 40_000) {
lastMil = Date.now();
await pollMilitary();
}
polls++;
if (polls % 15 === 1)
console.log(
`[poll ${polls}] ${n} aircraft; tracks ${REGIONS.map((g) => `${g.id}:${store.get(g.id).size}`).join(' ')}`,
);
if (Date.now() - lastSave > 5 * 60_000) {
lastSave = Date.now();
await save().catch((e) => console.warn('[save]', e.message));
}
const wait = POLL_S * 1000 - (Date.now() - started);
if (wait > 0) await new Promise((r) => setTimeout(r, wait));
}
await save();
console.log('done');
File diff suppressed because it is too large. Load diff
+30
View File
@@ -0,0 +1,30 @@
{
"format": "gev-history-index/1",
"capturedAt": {
"start": "2026-10-23T08:25:37.000Z",
"end": "2026-10-23T09:02:03.000Z"
},
"regions": [
{
"id": "nyc",
"label": "New York area",
"bbox": { "west": -76.5, "south": 39.4, "east": -71.2, "north": 42.2 },
"file": "nyc.json",
"tracks": 47
},
{
"id": "london",
"label": "London and the North Sea approaches",
"bbox": { "west": -2.6, "south": 50.2, "east": 5.6, "north": 53.6 },
"file": "london.json",
"tracks": 500
},
{
"id": "gulf",
"label": "Gulf of Mexico",
"bbox": { "west": -98.5, "south": 23.5, "east": -81.5, "north": 31.2 },
"file": "gulf.json",
"tracks": 78
}
]
}
File diff suppressed because it is too large. Load diff
+583
View File
@@ -0,0 +1,583 @@
{
"format": "gev-history/1",
"region": "nyc",
"label": "New York area",
"bbox": { "west": -76.5, "south": 39.4, "east": -71.2, "north": 42.2 },
"capturedAt": {
"start": "2026-10-23T08:25:37.000Z",
"end": "2026-10-23T09:02:03.000Z"
},
"source": "adsb.lol (ODbL) · adsb.lol military (ODbL) · God’s Eye dev server (OpenSky / adsb.lol)",
"note": "Real recorded positions (adsb.lol point snapshots around each anchor, OpenSky / adsb.lol via the dev server as a fallback). Timestamps are the true capture times and are never shifted.",
"t0": 1792743937,
"tracks": [
{
"k": "adbe4a",
"m": {
"kind": "aircraft",
"layer": "flights",
"callsign": "MTN8302",
"registration": "N985FX",
"type": "C208",
"squawk": "1761",
"country": "United States"
},
"p": "651,-762197,396788,118,242,93,40,-384,-158,0,242,93,40,-385,-158,0,242,93,40,-380,-156,0,242,93,40,-381,-157,0,242,93,40,-383,-159,0,242,93,31,-300,-124,0,242,94,41,-389,-161,0,242,93"
},
{
"k": "adc201",
"m": {
"kind": "aircraft",
"layer": "flights",
"callsign": "MTN8305",
"registration": "N986FX",
"type": "C208",
"squawk": "1554"
},
"p": "650,-762182,394631,114,230,46,39,-327,-199,0,231,46,42,-337,-213,0,231,47,39,-320,-205,0,231,47"
},
{
"k": "0d10bc",
"m": {
"kind": "aircraft",
"layer": "flights",
"callsign": "VIV687",
"registration": "XA-VDG",
"type": "A320",
"squawk": "2611",
"country": "Mexico"
},
"p": "651,-760314,396918,1018,231,235,120,-2495,-1595,43,230,232"
},
{
"k": "0d0e3a",
"m": {
"kind": "aircraft",
"layer": "flights",
"callsign": "VOI1902",
"registration": "XA-VSZ",
"type": "A20N",
"squawk": "2673"
},
"p": "651,-759607,394170,929,46,215,120,2061,1526,-127,46,196,132,2035,1553,-135,43,178,80,1088,901,-79,43,169,40,538,445,-38,43,169,40,535,442,-38,43,165,40,526,432,-36,43,161,40,499,411,-37,43,158,40,498,409,-34,43,155,52,652,534,-46,43,154,40,489,398,-24,43,148,40,467,383,-21,43,146,40,474,386,-22,43,147,40,464,378,-21,43,143,40,478,360,-22,47,141,40,552,269,-8,63,142,40,596,238,-4,63,143,40,590,235,-10,63,140,40,586,231,-9,63,139,39,570,225,-10,63,139,41,586,231,-26,63,137,40,572,225,-27,63,135,51,680,265,-22,63,115,40,451,176,-13,63,99,40,408,162,-30,63,96,31,285,110,-2,63,79,41,246,198,0,27,77,40,161,253,-8,26,76,40,153,244,-15,26,74,40,150,235,-15,26,70,40,123,195,-13,26,57,40,119,189,-13,26,57,40,119,188,-12,26,59"
},
{
"k": "a32bba",
"m": {
"kind": "aircraft",
"layer": "flights",
"callsign": "AAL28",
"registration": "N303NY",
"type": "A21N",
"squawk": "6743"
},
"p": "651,-758033,413053,952,111,252,120,3282,-1042,-161,114,241,132,3362,-1133,-75,114,234,120,3016,-952,-67,100,224,100,2592,-308,-53,99,219,40,1022,-122,-4,99,219,72,1847,-222,0,99,220,40,1035,-126,0,99,219,40,990,-242,0,124,229,40,660,-662,0,160,238,40,250,-829,-29,168,230,40,237,-797,-55,167,224,40,236,-774,-60,167,222,40,232,-767,-44,167,212,40,217,-705,-22,167,189,40,193,-621,-21,167,168,39,182,-567,-42,166,163,41,196,-566,-37,164,160,40,513,-362,-28,99,151,51,833,157,-38,71,141,40,629,166,-32,71,138,40,608,160,-25,70,134,31,457,135,-26,59,133,41,227,421,-26,4,129,40,28,453,-19,2,120,39,14,388,0,1,97,41,6,326,0,1,89,40,8,322,0,1,89,40,8,323,0,1,88,40,-145,289,0,303,100"
},
{
"k": "a24c77",
"m": {
"kind": "aircraft",
"layer": "flights",
"callsign": "UAL2186",
"registration": "N24706",
"type": "B737",
"squawk": "2455"
},
"p": "651,-756542,409975,485,106,187,40,844,-181,-40,106,183,41,824,-172,-38,105,178,39,804,-173,-38,106,177,40,789,-169,-37,106,174,40,783,-169,-21,106,168,31,578,-126,-8,106,155,41,705,-154,-29,106,152,40,663,-180,-3,110,146,40,655,-177,1,104,146,40,696,-25,-20,91,149,40,698,-12,-20,91,146,40,683,-10,-19,91,143,40,670,-14,-22,92,140,72,963,-309,-12,154,128,40,276,-409,-10,152,128,40,265,-401,-14,154,120,40,258,-381,-21,153,119,40,259,-389,-15,152,122,40,260,-388,-1,153,120,40,241,-368,1,153,110,40,234,-351,-5,153,108,40,241,-341,-19,141,106,40,405,-133,-7,89,90,39,378,72,-2,74,80,41,359,82,-2,73,79,40,293,141,-5,25,77,51,203,316,-11,25,75,40,143,229,-8,26,63,40,124,195,-8,26,58,31,91,144,-9,27,56,41,122,192,-12,26,61,40,126,201,-13,25,64,40,133,210,-13,25,65,56,131,214,-5,26,12"
},
{
"k": "ae07f4",
"m": {
"kind": "military",
"layer": "military",
"callsign": "RCH733",
"registration": "97-0042",
"type": "C17",
"squawk": "1032",
"military": true
},
"p": "649,-753780,405942,1067,46,121,120,2389,1746,0,46,120,132,2707,1940,0,47,122,120,2442,1754,0,46,123,121,2502,1764,0,47,122,129,2662,1863,0,47,121,122,2444,1737,0,46,113,121,2614,1742,0,48,118,138,2942,2029,0,46,126"
},
{
"k": "a32890",
"m": {
"kind": "aircraft",
"layer": "flights",
"callsign": "N302UP",
"registration": "N302UP",
"type": "B763",
"squawk": "6724",
"country": "United States"
},
"p": "651,-751671,400018,155,97,126,40,538,-51,-27,98,108,41,490,-73,-24,112,104,39,196,-334,-16,174,107,40,-115,-357,-19,221,111,40,-423,-181,-10,250,99,31,-334,-83,-18,254,89,41,-383,-86,-17,254,83,40,-364,-68,-19,255,79,136,-427,-115,-5,163,10"
},
{
"k": "aa6bc0",
"m": {
"kind": "aircraft",
"layer": "flights",
"callsign": "UAL2397",
"registration": "N77022",
"type": "B772",
"squawk": "2472",
"country": "United States"
},
"p": "651,-748784,407699,270,128,156,40,586,-345,0,128,155,40,562,-334,-17,128,150,40,553,-329,-21,128,145,40,531,-317,-23,128,143,40,529,-315,-23,128,141,31,411,-246,-18,128,139,41,500,-302,-13,128,126,40,446,-265,-23,128,115,40,413,-248,-35,128,112,40,443,-84,-12,76,88,40,382,79,-2,72,83,40,371,92,-1,72,83,40,331,141,-2,31,82,72,299,468,-9,26,72,40,131,207,-14,26,60,40,122,194,-12,26,58,40,121,192,-13,26,61,40,127,199,-12,26,61,40,130,206,-14,25,65,38,128,201,-6,26,52,38,39,69,0,26,12,84,-33,37,0,276,11,39,-47,4,0,276,9"
},
{
"k": "39d2a5",
"m": {
"kind": "aircraft",
"layer": "flights",
"callsign": "AFR173",
"registration": "F-HUVF",
"type": "A359",
"squawk": "3227"
},
"p": "652,-748311,399191,1212,50,253,139,3183,2064,0,50,254,132,3025,1941,0,50,257,120,2769,1763,-1,50,256,120,2817,1775,0,51,258,132,3100,1936,-1,51,257,120,2844,1757,0,51,258,120,2867,1756,-1,51,259,120,2884,1749,0,51,259,131,3178,1908,0,52,259,132,3214,1911,-1,52,260,120,2956,1739,0,52,261,120,2980,1735,-1,52,262"
},
{
"k": "a3f930",
"m": {
"kind": "aircraft",
"layer": "flights",
"callsign": "UPS1114",
"registration": "N355UP",
"type": "B763",
"squawk": "6734"
},
"p": "651,-747047,394015,793,61,228,120,2700,1131,-135,62,212,60,1279,533,-77,61,209,51,909,639,-54,41,206,41,621,564,-31,40,202,40,607,555,-30,40,200,40,595,543,-35,40,195,40,549,549,-34,32,192,40,435,595,-29,29,189,40,426,599,-29,29,187,40,398,563,-7,29,169,72,689,852,-23,32,146,40,351,432,-23,32,141,40,343,436,-29,30,140,40,326,429,-26,30,137,40,313,423,-26,30,135,40,308,417,-22,29,130,40,289,402,-20,28,125,40,273,390,-28,27,124,40,255,406,-24,24,122,40,218,376,-10,24,107,39,161,328,-10,360,92,41,-334,218,-4,303,99,40,-397,196,0,301,100,51,-510,233,-14,301,97,40,-372,169,-20,301,83,40,-309,140,-16,301,73,31,-224,103,-13,300,71,41,-292,132,-16,301,71,40,-282,128,-8,301,66,40,-109,46,0,301,10,40,-43,-18,0,242,12,39,-51,5,0,276,10,30,-33,-11,0,242,8"
},
{
"k": "a0566a",
"m": {
"kind": "aircraft",
"layer": "flights",
"callsign": "UAL91",
"registration": "N12021",
"type": "B78X",
"squawk": "0701"
},
"p": "651,-743550,414956,232,198,153,40,-226,-525,-15,198,152,40,-223,-523,-8,198,152,40,-223,-518,0,198,152,40,-223,-518,0,198,152,40,-221,-517,0,198,151,31,-173,-407,0,198,151,41,-225,-523,0,198,150,40,-220,-514,0,198,151,39,-91,-534,-2,172,152,41,130,-539,-24,169,151,40,99,-531,-14,184,143,40,-91,-503,-20,190,141,40,-118,-513,-1,190,145,72,68,-901,0,159,142,39,237,-476,0,159,142,41,244,-489,-9,159,144,40,237,-476,-20,159,140,40,234,-465,-18,159,135,40,271,-399,-13,133,114,39,456,-117,0,85,102,41,462,99,0,73,101,40,390,131,0,39,83,40,182,261,-1,25,83,39,165,264,-18,26,81,41,165,260,-18,26,78,40,141,225,-14,26,66,51,178,279,-18,26,70,40,148,235,-15,26,73,34,117,184,-4,37,48,33,31,55,0,37,9"
},
{
"k": "71c251",
"m": {
"kind": "aircraft",
"layer": "flights",
"callsign": "KAL251",
"registration": "HL8251",
"type": "B77L",
"squawk": "1434"
},
"p": "651,-742641,395863,559,33,184,40,533,512,-33,40,181,40,544,492,-28,41,175,40,516,466,-26,40,170,40,499,454,-23,41,164,40,502,454,-25,40,170,31,350,399,-19,29,170,41,378,531,-25,29,166,40,363,514,-24,29,160,40,348,498,-24,27,154,40,365,453,-22,34,138,40,338,398,-27,33,134,40,337,408,-26,32,135,40,328,410,-23,31,132,72,560,724,-39,31,129,40,303,399,-23,30,127,39,284,389,-19,29,123,41,281,399,-30,27,127,40,255,397,-12,25,117,40,215,356,-6,23,104,39,-64,347,-12,340,99,41,-148,324,-6,341,93,40,-145,317,0,341,92,40,-337,226,-3,301,100,39,-394,179,-21,301,95,41,-375,171,-21,301,84,40,-299,136,-15,301,71,51,-368,167,-20,301,71,40,-275,125,-7,301,47,39,-74,23,0,301,8"
},
{
"k": "a27567",
"m": {
"kind": "aircraft",
"layer": "flights",
"callsign": "MXY9028",
"registration": "N258BZ",
"type": "BCS3",
"squawk": "2557",
"country": "United States"
},
"p": "651,-742532,402792,841,54,204,120,2340,1278,-1,55,203,132,2580,1397,-38,55,201,120,2249,1209,-104,55,190,120,2127,1137,-98,54,180,40,601,455,-10,42,180,52,737,632,-2,41,180,40,567,486,-32,42,179,40,560,479,-32,42,177,40,555,476,-29,42,177,40,548,467,-27,42,173,40,540,460,-28,42,170,40,539,458,-28,42,169,40,533,453,-27,42,166,40,520,440,-26,42,162,40,414,463,-24,14,148,39,-150,475,-4,330,139,41,-353,430,0,328,140,40,-349,431,-31,334,138,51,-151,613,-39,352,134,40,-81,460,-28,352,121,40,-75,409,-23,352,107,31,-53,288,-17,352,101,41,-62,348,-19,352,94,40,-59,330,-20,352,92,40,-59,329,-19,353,95,40,-62,346,-21,352,96,40,-59,323,-17,352,88,40,127,275,-9,41,83,40,277,213,-7,45,83"
},
{
"k": "a4009e",
"m": {
"kind": "aircraft",
"layer": "flights",
"callsign": "UPS208",
"registration": "N357UP",
"type": "B763",
"squawk": "0712"
},
"p": "651,-740638,421622,453,198,210,40,-314,-710,-41,198,206,40,-310,-707,-26,198,205,40,-302,-692,-20,198,196,40,-283,-648,-20,198,185,40,-268,-611,-18,198,175,31,-197,-454,-12,198,167,41,-246,-564,-16,198,156,40,-227,-521,-17,198,149,40,-218,-501,-15,198,145,40,-216,-496,-15,198,146,40,-218,-504,-17,198,148,40,-216,-500,-16,198,145,40,-214,-501,-12,198,146,72,-387,-899,1,198,147,40,-216,-503,0,198,146,40,-213,-497,0,198,145,40,-213,-499,0,198,145,40,-210,-491,0,198,144,40,-213,-500,0,197,147,40,-100,-522,-3,184,147,40,-46,-525,-10,184,145,40,-46,-522,-9,184,144,40,-47,-507,-9,184,140,39,-36,-505,0,180,144,41,34,-523,-6,171,144,40,211,-479,-14,158,137,51,327,-580,-15,157,137,40,253,-453,-10,157,137,40,247,-445,-15,157,133,31,183,-330,-11,157,125,41,247,-396,-13,139,113,40,450,-87,-9,73,99,40,438,93,-3,73,94,40,409,109,0,59,90,39,194,269,0,27,80,41,161,252,-10,26,76,40,151,240,-16,26,74"
},
{
"k": "a79bdb",
"m": {
"kind": "aircraft",
"layer": "flights",
"callsign": "TFF959",
"registration": "N59JE",
"type": "GLF5",
"squawk": "4274",
"ladd": true,
"country": "United States"
},
"p": "651,-737233,408120,137,131,137,40,476,-305,-10,130,123,40,411,-264,-7,130,108,40,380,-245,-10,130,104,40,373,-240,-10,130,102,40,407,-144,-24,105,96,31,340,-75,-20,106,93,40,381,-116,-13,128,85,41,313,-200,-1,131,85,40,275,-17,-9,57,55,40,190,129,-4,47,55,40,22,183,-4,345,57,40,-102,193,-2,328,59,40,-200,154,-18,313,61"
},
{
"k": "acbe63",
"m": {
"kind": "aircraft",
"layer": "flights",
"callsign": "TAI568",
"registration": "N920CG",
"type": "A20N",
"squawk": "7451",
"country": "United States"
},
"p": "651,-735933,405533,56,10,95,40,-241,255,0,305,92,40,-371,173,0,301,87,40,-330,150,-18,301,77,40,-285,130,-14,301,70,40,-283,129,-16,301,71,31,-226,102,-8,301,70,40,-149,68,0,301,11,79,-6,-20,0,121,8,40,42,-19,0,121,12,42,55,-24,0,121,10,100,-3,-58,0,211,8"
},
{
"k": "274eb6",
"m": { "kind": "aircraft", "layer": "flights", "callsign": "~274eb6" },
"p": "649,-735033,407819,162,90,90,42,445,16,72,89,88,40,416,15,-96,88,89,38,402,14,84,88,89,42,434,-5,-76,99,88,40,386,-42,-72,98,83,51,462,-32,17,81,75,36,326,3,30,91,76"
},
{
"k": "a8d845",
"m": {
"kind": "aircraft",
"layer": "flights",
"callsign": "GTX323",
"registration": "N66919",
"type": "BE36",
"squawk": "2777"
},
"p": "651,-734188,407675,56,339,47,40,-91,158,17,336,48,40,-98,165,17,336,53,40,-148,154,17,283,64,40,-291,-72,19,247,65,40,-305,-101,5,247,78,31,-244,-145,0,228,87,41,-302,-201,13,229,78,40,-253,-171,24,228,66,40,-237,-157,15,230,69,40,-264,-168,9,230,76,40,-296,-186,0,231,87,40,-325,-204,0,230,89,40,-318,-203,1,230,89,72,-631,-324,-1,245,89,40,-376,-142,1,243,88,40,-369,-140,0,244,88,40,-375,-141,-1,244,90,40,-385,-144,1,243,90,40,-379,-146,0,243,90,40,-378,-140,0,244,89,40,-378,-141,0,244,89,40,-371,-140,0,244,88,40,-374,-141,0,244,89,39,-374,-143,0,243,89,41,-376,-143,0,244,89,40,-376,-143,0,243,89,51,-481,-183,0,243,90,40,-378,-143,0,244,88,40,-375,-144,0,243,88,32,-289,-111,0,243,88,40,-376,-146,0,243,88,40,-373,-144,0,244,90,40,-374,-143,0,243,89,40,-374,-144,0,243,89,40,-376,-145,0,244,89,40,-371,-143,0,243,88,40,-369,-144,0,243,88"
},
{
"k": "a544ee",
"m": {
"kind": "aircraft",
"layer": "flights",
"callsign": "DAL226",
"registration": "N439DX",
"type": "A339",
"squawk": "6077",
"country": "United States"
},
"p": "651,-733993,401695,226,348,135,40,-134,465,-29,347,131,40,-144,454,-52,346,130,40,-154,467,-31,345,133,40,-181,443,-20,337,131,40,-286,420,-8,332,130,31,-219,308,-4,331,118,41,-257,357,-10,331,106,40,-234,312,-12,330,94,40,-211,280,-3,330,88,40,-213,273,-1,321,90,40,-355,198,0,300,94,40,-359,163,0,300,84,40,-314,144,-16,301,71,72,-512,233,-28,301,70,39,-279,126,-12,301,70,40,-186,84,0,301,18,81,15,11,0,121,8,39,37,-22,0,124,11,40,49,-22,0,121,12,40,44,-20,0,121,12"
},
{
"k": "ad6ec5",
"m": {
"kind": "aircraft",
"layer": "flights",
"callsign": "AMX025",
"registration": "N965AM",
"type": "B788",
"squawk": "3220",
"country": "United States"
},
"p": "651,-733028,408777,1212,51,257,120,2841,1761,0,51,258,132,3142,1927,0,51,260,120,2879,1750,-1,51,258,120,2883,1735,0,51,258,132,3218,1917,-1,52,261,120,2961,1746,0,52,260,120,2970,1735,0,52,262"
},
{
"k": "7380ca",
"m": {
"kind": "aircraft",
"layer": "flights",
"callsign": "ELY003",
"registration": "4X-EDL",
"type": "B789",
"squawk": "0710",
"country": "Israel"
},
"p": "651,-730821,406750,277,254,140,40,-633,-138,-31,254,141,40,-650,-141,-33,254,143,40,-644,-139,-29,254,142,40,-643,-141,-29,254,142,40,-633,-137,-23,254,139,31,-496,-111,-15,254,135,41,-588,-129,-2,254,122,40,-532,-116,-17,254,113,40,-474,61,-16,301,105,40,-409,192,-25,301,95,40,-351,160,-19,301,77,40,-304,138,-17,301,74,40,-301,137,-15,301,75,69,-343,155,-6,312,8,183,50,-44,0,120,9"
},
{
"k": "ab3c0d",
"m": {
"kind": "aircraft",
"layer": "flights",
"callsign": "AAL293",
"registration": "N823AN",
"type": "B789",
"squawk": "0711",
"country": "United States"
},
"p": "651,-726840,410669,417,213,189,40,-479,-570,-34,213,186,40,-456,-547,-15,212,173,40,-443,-493,-6,216,160,40,-436,-453,0,216,158,40,-440,-460,0,216,158,31,-349,-365,-2,216,158,41,-444,-465,-26,216,157,40,-454,-427,-25,233,149,40,-637,-175,-26,254,140,40,-631,-136,-27,254,140,40,-643,-139,-31,254,143,40,-655,-141,-27,254,143,40,-646,-143,-25,254,141,72,-1141,-248,-37,254,138,40,-618,-138,-16,254,131,40,-573,-93,-25,280,118,40,-467,194,-28,302,105,40,-380,176,-21,301,84,40,-311,142,-16,301,74,40,-301,137,-17,301,74,40,-298,135,-13,301,74"
},
{
"k": "4d22a0",
"m": {
"kind": "aircraft",
"layer": "flights",
"callsign": "AIZ993",
"registration": "9H-HFA",
"type": "A333",
"squawk": "0715"
},
"p": "651,-717150,421744,1072,214,246,120,-1892,-2131,-115,213,232,132,-2081,-2364,-132,213,232,120,-1761,-2017,-113,213,221,120,-1719,-1984,-107,213,211,40,-530,-615,-25,213,202,52,-696,-810,-75,213,208,40,-538,-627,-40,213,205,39,-515,-603,-37,213,200,41,-514,-602,-36,213,193,40,-467,-548,-25,213,173,40,-476,-464,-3,222,158,40,-507,-423,-1,222,158,40,-503,-420,0,222,158,40,-498,-420,0,222,157,40,-501,-421,0,222,158,39,-499,-418,1,222,158,41,-504,-424,-1,222,158,40,-582,-317,-25,259,148,51,-785,196,-58,294,133,40,-582,195,-61,293,134,40,-560,154,-29,277,123,31,-384,-162,-32,215,130,41,-283,-412,-22,207,123,40,-438,-232,-24,251,115,40,-510,-128,-24,252,111,40,-477,-49,0,291,101,40,-404,178,-22,301,97,40,-372,168,-19,301,85,40,-313,143,-18,301,70"
},
{
"k": "a18284",
"m": {
"kind": "aircraft",
"layer": "flights",
"callsign": "DAL960",
"registration": "N197DN",
"type": "B763",
"squawk": "7722"
},
"p": "691,-764883,414975,1213,110,248,120,3329,-925,-51,110,250,132,3737,-1058,-208,111,252,120,3256,-1041,-172,114,237,120,3023,-1020,-66,114,233,132,3365,-1030,-8,101,231,120,3115,-367,-58,99,218,120,3009,-362,-49,99,213,40,983,-197,-11,114,217,39,815,-478,1,140,227,41,478,-747,-1,167,227,40,214,-789,0,168,227,51,285,-1024,-1,168,227,40,224,-792,-51,167,220,40,232,-762,-66,167,213,31,170,-561,-41,167,203,41,262,-675,-35,149,181,40,632,-324,-26,96,157,40,599,218,-49,34,141,40,347,431,-57,32,141,40,330,417,-33,31,126,40,73,403,-36,346,111,40,-115,360,-43,345,97"
},
{
"k": "a09fc4",
"m": {
"kind": "aircraft",
"layer": "flights",
"callsign": "UAL2222",
"registration": "N13954",
"type": "B789",
"squawk": "3412"
},
"p": "691,-712332,416130,989,269,238,120,-3384,-34,2,269,237,132,-3756,-52,0,269,238,120,-3438,-57,1,269,240,120,-3392,-66,-72,268,233,132,-3616,-81,-107,268,226,120,-3126,191,-132,285,209,100,-2366,430,-90,276,206,40,-1002,-50,0,259,213,40,-999,-138,0,260,213,40,-1007,-139,0,259,213,40,-1004,-140,-32,260,207,40,-937,-134,-92,259,196,31,-720,-101,-87,259,197,40,-902,-127,-63,259,178,40,-762,-112,-28,259,149,31,-504,-138,-21,232,149,41,-327,-485,-24,198,153,40,-225,-521,-24,198,150,40,-221,-517,-11,198,153,40,-226,-524,0,198,153,40,-225,-523,1,198,152,40,-223,-521,0,198,151,40,-221,-516,0,198,150"
},
{
"k": "a1ac91",
"m": {
"kind": "aircraft",
"layer": "flights",
"callsign": "AVA042",
"registration": "N207FR",
"type": "A320",
"squawk": "1411"
},
"p": "943,-743420,394233,639,19,216,40,336,748,-74,19,213,40,323,705,-48,24,207,40,568,606,-51,40,208,40,621,567,-50,40,201,40,598,543,-27,40,201,40,606,549,-21,41,201,52,709,748,-27,29,194,40,432,608,-6,29,185,39,385,542,1,29,161,41,345,485,-1,29,146,40,341,451,-1,33,146,40,367,449,1,32,146,40,362,444,-13,33,145,40,368,444,-38,32,145,40,349,435,-32,31,142,40,327,431,-33,30,138,39,314,425,-31,29,135,41,304,420,-31,28,130,40,286,424,-41,25,133,51,0,546,-21,341,114,40,-164,352,-7,340,95,40,-152,317,-1,340,92,32,-177,224,1,308,100,40,-392,182,-22,301,89,40,-323,148,-19,301,71,40,-272,124,-13,301,65,39,-262,119,-15,301,65,41,-265,120,-13,300,65,40,-252,114,-6,301,46"
},
{
"k": "a2a915",
"m": {
"kind": "aircraft",
"layer": "flights",
"callsign": "UAL148",
"registration": "N27015",
"type": "B772",
"squawk": "6065"
},
"p": "1083,-736746,394270,389,330,177,40,-405,547,-36,330,172,40,-395,534,-35,330,168,72,-657,887,-39,330,144,39,-321,432,-19,330,136,41,-317,426,-29,330,135,40,-312,421,-21,330,134,40,-317,414,0,324,135,40,-431,347,-18,315,135,40,-448,336,-13,313,132,40,-443,327,0,314,133,40,-451,336,0,314,134,40,-444,332,0,314,134,39,-449,334,0,314,134,41,-457,341,1,314,133,40,-449,332,-1,314,133,51,-567,436,1,326,135,40,-67,470,0,10,135,40,118,475,0,11,133,31,94,365,0,11,133,41,118,469,-22,11,132,40,114,465,-22,11,128,40,108,442,-19,11,122,40,108,429,-22,11,118,40,97,387,-6,11,102,40,89,357,-1,11,100,40,116,327,1,25,82"
},
{
"k": "a460d1",
"m": {
"kind": "aircraft",
"layer": "flights",
"callsign": "UPS1070",
"registration": "N3818P",
"type": "B763",
"squawk": "6606"
},
"p": "1103,-760391,394088,972,51,240,132,2840,1773,-206,51,229,120,2266,1568,-150,43,208,40,642,533,-48,43,203,40,646,540,-54,43,202,40,636,523,-48,43,198,40,629,518,-48,43,194,40,616,505,-45,43,191,40,589,483,-22,43,178,40,540,443,-11,43,161,40,493,404,-13,43,148,40,463,378,-22,43,142,51,567,460,-25,43,134,40,429,344,-20,46,130,40,459,329,-19,47,133,31,356,253,0,47,134,41,538,266,0,65,137,40,579,211,0,65,137,40,581,207,-18,65,133,40,558,199,-25,65,131,39,556,197,-24,65,130,41,562,198,-24,65,127,40,530,189,-16,65,122"
},
{
"k": "a11fc7",
"m": {
"kind": "aircraft",
"layer": "flights",
"callsign": "UAL427",
"registration": "N17139",
"type": "B752",
"squawk": "6752"
},
"p": "1143,-764911,412023,880,107,247,131,3526,-801,-155,107,225,120,2933,-678,-134,107,207,41,928,-215,-44,107,201,40,925,-216,-52,107,200,40,892,-210,-44,107,197,40,888,-210,-44,107,192,40,854,-201,-43,107,188,39,831,-198,-42,108,184,41,803,-192,-13,107,166,40,721,-172,-14,108,154,51,871,-218,-24,111,152,40,663,-246,0,124,156,40,586,-340,0,128,156,31,457,-270,-2,128,156,41,582,-344,-27,128,152,40,559,-333,-25,128,148,40,550,-328,-28,128,146,40,533,-318,-27,128,144,40,513,-308,-14,128,128,40,427,-255,1,128,104,40,396,-237,0,128,106"
},
{
"k": "76cce7",
"m": {
"kind": "aircraft",
"layer": "flights",
"callsign": "SIA22",
"registration": "9V-SGG",
"type": "A359",
"squawk": "7447"
},
"p": "1235,-755665,421794,674,122,215,80,1734,-805,-79,122,207,40,842,-386,-38,118,201,40,854,-319,-30,117,194,40,816,-335,-23,128,193,40,650,-500,-31,136,195,40,661,-500,-31,135,193,40,643,-487,-31,136,188,40,628,-482,-31,136,187,39,549,-512,-32,160,186,41,262,-640,-32,162,180,40,258,-592,-16,162,167,51,304,-702,-15,162,157,40,237,-546,-10,162,156,40,228,-531,-11,162,156,31,178,-414,-7,162,155,41,232,-541,-10,162,154,40,227,-529,-10,162,154,40,222,-521,-11,162,153,40,223,-522,-10,162,151,40,222,-519,-6,162,150,40,216,-508,-15,162,149,40,74,-524,-16,186,147"
},
{
"k": "a27f7f",
"m": {
"kind": "aircraft",
"layer": "flights",
"callsign": "FDX1073",
"registration": "N260FE",
"type": "B763",
"squawk": "3140"
},
"p": "1315,-758925,394251,1000,41,241,120,2125,1885,-173,41,215,120,1933,1642,-157,43,196,60,921,763,-80,43,190,40,615,507,-62,43,192,40,598,495,-55,43,186,40,581,477,-51,43,180,31,448,366,-41,43,178,40,567,463,-38,43,170,40,511,420,-10,43,151,31,370,305,-17,43,145,41,460,373,-22,43,138,40,439,357,-20,43,136,40,451,350,-20,47,135,40,458,326,-14,47,134,40,493,308,0,60,138,40,589,221,0,65,139,40,592,213,0,65,139"
},
{
"k": "a64ce6",
"m": {
"kind": "aircraft",
"layer": "flights",
"callsign": "DAL1114",
"registration": "N505DZ",
"type": "A21N",
"squawk": "1643"
},
"p": "1355,-764662,403465,660,115,203,80,1681,-613,-74,115,188,40,794,-291,-50,116,189,40,794,-290,-38,116,184,40,767,-282,-37,116,179,40,762,-225,-28,104,168,40,736,-148,-19,105,161,40,728,-146,-12,105,160,40,725,-147,-13,105,159,51,925,-188,-13,105,160,40,724,-147,-24,104,158,40,718,-148,-34,105,156,31,544,-110,-25,105,154,41,701,-144,-31,105,151,40,678,-142,-27,105,148,40,650,-135,-28,105,143,40,642,-106,-35,97,140,40,663,-54,-31,96,138,40,611,-50,-12,100,125,40,394,-276,-17,167,120"
},
{
"k": "ad7ab8",
"m": {
"kind": "aircraft",
"layer": "flights",
"callsign": "JBU16",
"registration": "N968JT",
"type": "A321",
"squawk": "3201"
},
"p": "1395,-764763,414944,998,110,244,120,3294,-916,-60,110,247,120,3315,-940,-35,111,247,131,3587,-1138,-50,114,246,132,3403,-1147,-124,114,229,120,2926,-900,-49,98,215,120,2982,-346,-38,99,209"
},
{
"k": "ace5bd",
"m": {
"kind": "aircraft",
"layer": "flights",
"callsign": "AVA153",
"registration": "N930AV",
"type": "A20N",
"squawk": "1654"
},
"p": "1415,-737745,406292,0,301,31,40,-273,123,11,299,83,40,-385,-22,39,228,101,40,-358,-312,29,214,123,40,32,-412,46,154,127,40,205,-469,37,174,148,40,152,-525,56,147,151,40,632,-194,49,84,151,40,722,84,48,81,159,31,578,69,42,81,163,40,819,105,7,80,180,40,816,-153,4,129,188,31,345,-452,35,166,193,41,-150,-686,44,204,196,40,-347,-651,51,202,197,40,-370,-660,47,205,201,40,-411,-655,43,206,204,120,-1288,-2114,91,205,227"
},
{
"k": "abe212",
"m": {
"kind": "aircraft",
"layer": "flights",
"callsign": "GTI8084",
"registration": "N865GT",
"type": "B748",
"squawk": "2422"
},
"p": "1475,-764846,409872,1028,64,258,120,3305,1221,-2,64,256,120,3285,1200,1,64,255,131,3601,1299,-1,65,255,132,3620,1291,0,65,255,120,3293,1196,-1,60,254"
},
{
"k": "0d1236",
"m": {
"kind": "aircraft",
"layer": "flights",
"callsign": "AMX007",
"squawk": "3427"
},
"p": "1475,-756414,394141,1154,48,252,120,2634,1836,0,48,254,120,2648,1830,0,48,254,131,2930,2010,-3,49,257,132,3029,1930,2,50,256,140,3245,2062,-1,50,257"
},
{
"k": "c038ac",
"m": {
"kind": "aircraft",
"layer": "flights",
"callsign": "ACA97",
"registration": "C-FVLZ",
"type": "B789",
"squawk": "6060"
},
"p": "1475,-720901,394267,1181,346,230,120,-803,2406,-1,345,229,120,-796,2373,1,345,226,131,-874,2580,0,345,227,132,-891,2613,-1,345,228,120,-821,2384,0,345,229"
},
{
"k": "0d07a8",
"m": {
"kind": "aircraft",
"layer": "flights",
"callsign": "VOI504",
"registration": "XA-VOY",
"type": "A320",
"squawk": "4261"
},
"p": "1594,-759491,394097,910,44,226,121,2116,1667,-133,45,204,131,2050,1640,-134,43,183,52,730,608,-50,43,178,40,567,468,-40,43,175,40,551,456,-39,43,173,40,549,452,-38,43,170,40,537,442,-39,43,166,40,526,432,-35,43,163,40,516,422,-38,43,161,40,511,417,-25,43,152"
},
{
"k": "78029e",
"m": {
"kind": "aircraft",
"layer": "flights",
"callsign": "CSS181",
"registration": "B-2425",
"type": "B744",
"squawk": "7254"
},
"p": "1595,-753366,421891,978,115,260,120,3364,-1181,-61,115,253,131,3586,-1277,-49,116,246,132,3053,-1697,-59,156,248,120,1418,-2378,-124,156,233"
},
{
"k": "a4f08d",
"m": {
"kind": "aircraft",
"layer": "flights",
"callsign": "N4170V",
"registration": "N4170V",
"type": "P32R",
"squawk": "2746"
},
"p": "1674,-762976,401207,0,239,10,40,2,-15,0,118,10,204,56,-11,0,298,13,40,-160,68,15,299,50,40,-222,83,19,293,53,40,-144,124,19,8,46,40,96,145,18,28,46,40,102,145,18,29,47,40,109,153,14,28,52"
},
{
"k": "7380c7",
"m": {
"kind": "aircraft",
"layer": "flights",
"callsign": "ELY017",
"registration": "4X-EDI",
"type": "B789",
"squawk": "0716"
},
"p": "1715,-732626,421904,1243,207,247,131,-1779,-2615,1,207,248,132,-1770,-2624,-1,207,250,120,-1608,-2407,1,207,250"
},
{
"k": "a25f6e",
"m": {
"kind": "aircraft",
"layer": "flights",
"callsign": "FDX1700",
"registration": "N252FE",
"type": "B763",
"squawk": "6672"
},
"p": "1898,-764470,407490,864,86,244,120,3311,190,-147,86,225,100,2579,141,-119,86,212,40,995,53,-49,86,208"
},
{
"k": "a6aaf0",
"m": {
"kind": "aircraft",
"layer": "flights",
"callsign": "KOW529",
"registration": "N529FX",
"type": "CL30",
"squawk": "4256"
},
"p": "1937,-741677,406984,0,191,8,61,-20,-46,0,188,9,59,-29,-51,0,211,12"
},
{
"k": "a12f6f",
"m": {
"kind": "aircraft",
"layer": "flights",
"callsign": "UAL1745",
"registration": "N17593",
"type": "B39M",
"squawk": "3250"
},
"p": "1978,-764825,412603,755,110,226,120,2925,-792,-134,110,211,40,939,-255,-44,110,207,40,912,-248,-16,110,202"
},
{
"k": "a599a8",
"m": {
"kind": "aircraft",
"layer": "flights",
"callsign": "AAL1188",
"registration": "N460AN",
"type": "A21N",
"squawk": "0747"
},
"p": "1998,-764704,401240,609,94,199,40,916,-50,-43,94,196,39,902,-51,-49,94,194,41,905,-51,-42,94,190,40,869,-49,-40,94,185"
},
{
"k": "aa92fc",
"m": {
"kind": "aircraft",
"layer": "flights",
"callsign": "UAL2482",
"registration": "N78005",
"type": "B772",
"squawk": "3542"
},
"p": "2058,-764949,412637,764,110,228,120,2997,-811,-56,110,217"
}
]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 142 KiB

+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,
+188 -74
View File
@@ -38,7 +38,7 @@ import { createMomentPlayer } from './ui/moment.js';
import { createDirector } from './ui/director.js';
import { social, callSocial } from './ui/socialBridge.js';
import { PLACES } from './ui/shim3.js';
import { distKm, llShort, fmtKm } from './ui/bands.js';
import { distKm, llShort } from './ui/bands.js';
const app = $('#app');
const stage = $('#stage');
@@ -309,7 +309,7 @@ const fetchJson = (url) =>
.catch(() => null);
// No snapshot on this device yet: the bundled one (real data, see capturedAt).
if (!snap)
fetchJson('/mobile/demo/snapshot.json').then((j) => {
fetchJson(new URL('./demo/snapshot.json', import.meta.url).href).then((j) => {
const list = Array.isArray(j) ? j : j?.situations;
if (!list?.length || situationsCache) return;
const at = j?.capturedAt || null;
@@ -325,9 +325,9 @@ if (!snap)
}
});
// Today's Moment paints from the bundle before the globe is up.
fetchJson('/mobile/demo/moments/empire-state.json').then(
(m) => m?.tracks && today.setMoment(m),
);
fetchJson(
new URL('./demo/moments/empire-state.json', import.meta.url).href,
).then((m) => m?.tracks && today.setMoment(m));
// Distances appear by themselves when location was already granted.
navigator.permissions
@@ -463,10 +463,21 @@ async function clipMoment({ start, end }) {
const g = await globeReady;
const c = lastCam || { lon: -73.98, lat: 40.75, height: 400e3 };
const radius = Math.max(15, Math.min(600, (c.height || 400e3) / 1000 / 1.5));
const near = g
.contactsNear(c.lon, c.lat, radius)
.filter((x) => /aircraft|helicopter|military|ship|vessel/.test(x.kind))
.slice(0, 6);
// What the recorder (or the bundled recording) holds for that range,
// else whatever is live around the camera.
const recorded = g
.historyNear((start + end) / 2, c.lon, c.lat, radius)
.slice(0, 12);
const fromRecord = recorded.length >= 2;
const near = fromRecord
? recorded
: g
.contactsNear(c.lon, c.lat, radius)
.filter((x) => /aircraft|helicopter|military|ship|vessel/.test(x.kind))
.slice(0, 6);
const archive = g.history()?.archive;
const inArchive =
archive && start >= archive.start - 60e3 && end <= archive.end + 60e3;
const tracks = (
await Promise.all(
near.map(async (x) => {
@@ -500,8 +511,21 @@ async function clipMoment({ start, end }) {
let place = PLACES.map((p) => ({ p, d: distKm(c, p) })).sort(
(a, b) => a.d - b.d,
)[0];
// Name the clip honestly: a landmark only when the view is that close,
// else the recording's region, else coordinates.
const region = inArchive
? archive.regions?.find(
(r) =>
c.lat >= r.bbox.south &&
c.lat <= r.bbox.north &&
c.lon >= r.bbox.west &&
c.lon <= r.bbox.east,
)
: null;
const placeName =
place && place.d < radius ? place.p.name : llShort(c.lat, c.lon, 2);
place && place.d < Math.min(radius, 25)
? place.p.name
: region?.label || llShort(c.lat, c.lon, 2);
const m = {
id: `clip-${Math.round(start / 1000).toString(36)}`,
title: `Clip · ${placeName}`,
@@ -533,7 +557,11 @@ async function clipMoment({ start, end }) {
{
name: g.isStub
? 'Simulated recorder (offline stub)'
: 'On-device recorder',
: inArchive
? 'Bundled recording (real positions, captured ' +
new Date(archive.start).toISOString().slice(0, 10) +
')'
: 'On-device recorder',
detail: `${tracks.length} tracks · ${evts.length} events · ${new Date(start).toISOString().slice(11, 16)}–${new Date(end).toISOString().slice(11, 16)}Z`,
},
],
@@ -677,25 +705,18 @@ globeReady
applyTab('live');
}
const subj = s.moment?.subject;
const c = subj
? { lon: subj.lon, lat: subj.lat, height: 4200 }
: s.camera || {};
const height = Math.max(2500, Math.min(c.height || 3000, 6000));
// On a phone the room sheet covers the lower half: lift the place
// into the visible top of the map so the hearts rise in view.
let lat = c.lat ?? s.lat;
if (!isTwoPane()) {
const r = stage.getBoundingClientRect();
const mPerPx = (height * 0.364) / (Math.min(r.width, r.height) / 2);
lat -= (mPerPx * r.height * 0.27) / 111320;
}
g.flyTo({
lon: c.lon ?? s.lon,
lat,
height,
pitch: -40,
duration: 1.8,
});
const c = subj ? { lon: subj.lon, lat: subj.lat } : s.camera || {};
const lon = c.lon ?? s.lon;
const lat = c.lat ?? s.lat;
if (!Number.isFinite(lon) || !Number.isFinite(lat)) return;
frameForSheet(
{ lon, lat },
{
distM: subj
? 3000
: Math.max(2500, Math.min((c.height || 3000) * 0.9, 6000)),
},
);
},
onCredits: () => chrome.openCredits(),
onLocate: () => locate(),
@@ -814,6 +835,10 @@ globeReady
today.setSituations(cached);
}
}
// The director is ready as soon as the shell is: a presenter must never
// wait on the first live situations() call (it can take a minute).
director = buildDirector(g);
director.bindTripleTap(feed.titleEl);
today.setGlobe(g);
await feed.load({ quiet: primed });
const sits = await getSituations();
@@ -837,8 +862,6 @@ globeReady
.evaluate({ quiet: true })
.then(() => setTimeout(() => watch.evaluate(), 4000));
applyTab(current);
director = buildDirector(g);
director.bindTripleTap(feed.titleEl);
// Deep links: #s=<situation id>, #m=<moment id>, #e=<entity id>
const hs = /#s=([^&]+)/.exec(location.hash);
@@ -941,8 +964,91 @@ async function openThreadFor(s) {
});
}
/**
* An oblique view of a place, framed into the part of the map a sheet leaves
* visible: on a phone the medium sheet covers the lower half, so the place
* sits in the upper third; on iPad (sheets in the leading pane) a little
* below the centre, leaving room above for rising reactions.
*/
function frameForSheet(
{ lon, lat },
{ distM = 3000, rayDeg = 30, heading = 32, duration = 1.6 } = {},
) {
if (!globe) return Promise.resolve();
const r = stage.getBoundingClientRect();
const W = r.width || 1;
const H = r.height || 1;
const fov = globe.raw?.viewer?.camera?.frustum?.fov ?? Math.PI / 3;
const tanHalfV = W > H ? (Math.tan(fov / 2) * H) / W : Math.tan(fov / 2);
const yFrac = isTwoPane() ? 0.56 : 0.27;
const above = Math.atan((0.5 - yFrac) * 2 * tanHalfV) * (180 / Math.PI);
const pitch = Math.max(-88, -rayDeg - above);
const ray = (rayDeg * Math.PI) / 180;
const back = distM * Math.cos(ray);
const hd = ((heading + 180) * Math.PI) / 180;
return globe.flyTo({
lon:
lon + (back * Math.sin(hd)) / (111320 * Math.cos((lat * Math.PI) / 180)),
lat: lat + (back * Math.cos(hd)) / 111320,
height: 12 + distM * Math.sin(ray),
heading,
pitch,
eye: true,
duration,
});
}
/**
* The busiest region of the bundled recording: a camera over it and the
* instant to start replaying (20 min before its busiest 5 minutes).
*/
async function busiestRecording(g) {
const arch = (await g.loadHistory()) || g.history()?.archive;
const regions = arch?.regions || [];
if (!regions.length) return null;
const r = regions
.slice()
.sort((a, b) => (b.busiest?.aircraft || 0) - (a.busiest?.aircraft || 0))[0];
const b = r.bbox;
const lat = (b.south + b.north) / 2;
const widthKm =
(b.east - b.west) * 111 * Math.cos((lat * Math.PI) / 180) * 0.62;
return {
label: r.label,
from: Math.max(r.start, (r.busiest?.t ?? r.start) - 20 * 60e3),
camera: {
lon: (b.west + b.east) / 2,
// A little south: the ribbon and tab bar cover the bottom of the map.
lat: lat - (b.north - b.south) * 0.12,
height: Math.max(160e3, Math.min(700e3, widthKm * 1000 * 0.9)),
},
};
}
/** Director: a steady stream of reactions rising from a place. */
let reactTimer = 0;
function reactionStream(g, { lon, lat }, ms = 15000) {
clearInterval(reactTimer);
const kinds = ['heart', 'heart', 'fire', 'heart', 'eyes', 'wow', 'heart'];
const until = Date.now() + ms;
let i = 0;
reactTimer = setInterval(() => {
if (Date.now() > until) return clearInterval(reactTimer);
g.react({
lon: lon + (Math.random() - 0.5) * 0.006,
lat: lat + (Math.random() - 0.5) * 0.005,
kind: kinds[i++ % kinds.length],
count: 2 + Math.round(Math.random() * 2),
});
}, 650);
}
/* ---------------- Director (demo mode) ---------------- */
function closeAll() {
clearInterval(reactTimer);
// Capture covers the globe (and on iPad the whole stage): leave it.
if (current === 'capture') applyTab(isTwoPane() ? 'today' : 'live');
watch.clearBanners();
player?.close();
dossier?.close();
sky?.close();
@@ -1012,16 +1118,15 @@ function buildDirector(g) {
run: async () => {
closeAll();
const m = await hero();
// Show the globe first, then the Live lane over it.
const sit =
(await getSituations()).find((x) => x.momentId === m.id) || m;
// The Live lane over the globe: the room frames Midtown above the
// sheet (onShowGlobe) and hearts rise from the city.
// Never wait on a cold live situations() call.
const list =
situationsCache ||
(await Promise.race([getSituations(), settle(1500)])) ||
[];
const sit = list.find((x) => x.momentId === m.id) || m;
if (!isTwoPane()) selectTab('live');
await g.flyTo({
lon: m.lon,
lat: m.lat - 0.004,
height: 4200,
duration: 1.2,
});
const ok = (
await callSocial('openRoom', sit, {
lane: 'live',
@@ -1029,17 +1134,10 @@ function buildDirector(g) {
})
).ok;
if (!ok) await openRoom(m, { lane: 'live' });
for (let i = 0; i < 6; i += 1)
setTimeout(
() =>
g.react({
lon: m.lon + (Math.random() - 0.5) * 0.02,
lat: m.lat + (Math.random() - 0.5) * 0.02,
kind: ['heart', 'fire', 'heart', 'eyes', 'heart', 'wow'][i],
count: 3,
}),
300 + i * 260,
);
const subj = m.subject || { lon: m.lon, lat: m.lat };
await settle(300);
await frameForSheet(subj, { distM: 3000, duration: 1.2 });
reactionStream(g, subj);
},
},
{
@@ -1109,18 +1207,33 @@ function buildDirector(g) {
},
},
{
title: 'Time machine: −3 h at 60×',
note: 'The recorder replays where everything actually was',
title: 'Time machine at 60×',
note: 'Replays a real recording: where everything actually was',
icon: 'clock',
run: async () => {
closeAll();
selectTab('live');
g.flyTo({ lon: -62, lat: 38, height: 4200e3, duration: 1.4 });
const r = await busiestRecording(g);
if (r) {
await g.flyTo({ ...r.camera, duration: 1.4 });
await settle(300);
chrome.ribbon.jump(r.from, { play: true, speed: 60 });
toast(
`Replaying a real recording · ${r.label} · ${new Date(r.from).toUTCString().slice(5, 11)}`,
{ icon: icon('clock') },
);
return;
}
// No bundled recording: the true start of this device's history.
const h = g.history();
g.flyTo({ lon: -73.98, lat: 40.2, height: 420e3, duration: 1.4 });
await settle(450);
chrome.ribbon.jump(Date.now() - 3 * 3600e3, {
play: true,
speed: 60,
});
chrome.ribbon.jump(
Number.isFinite(h?.start)
? Math.max(h.start, Date.now() - 3 * 3600e3)
: Date.now() - 15 * 60e3,
{ play: true, speed: 60 },
);
},
},
{
@@ -1130,14 +1243,19 @@ function buildDirector(g) {
run: async () => {
closeAll();
selectTab('live');
await g.flyTo({
const r = await busiestRecording(g);
const cam = r?.camera || {
lon: -73.95,
lat: 40.72,
height: 260e3,
};
await g.flyTo({
...cam,
height: Math.min(cam.height, 300e3),
duration: 1.2,
});
await settle(350);
const t0 = Date.now() - 50 * 60e3;
const t0 = r ? r.from + 6 * 60e3 : Date.now() - 50 * 60e3;
chrome.ribbon.jump(t0 + 9 * 60e3);
chrome.ribbon.selectRange(t0, t0 + 18 * 60e3);
},
@@ -1149,26 +1267,22 @@ function buildDirector(g) {
run: async () => {
closeAll();
applyTab('today');
const s = (await getSituations()).find((x) =>
/squawk/.test(x.kind || ''),
);
const home = watch.home();
const d = s ? distKm(home, s) : NaN;
watch.banner({
title: s?.title || 'Squawk 7700',
body: `${s?.place || 'Emergency squawk'}${Number.isFinite(d) ? ` · ${fmtKm(d)} from ${home.label || home.name}` : ''} · your rule: any emergency squawk`,
band: 'sky',
onTap: () => s && openSituation(s),
});
await settle(250);
await watch.demoAlert();
},
},
{
title: 'Search to dossier',
note: '“7700”, “ISS”, “Piccadilly”',
note: '“7700” when a squawk is live, else “ISS”; then any result',
icon: 'search',
run: async () => {
closeAll();
search.open('7700');
// “7700” only reads well while an aircraft is squawking it.
const hits = await g.search('7700').catch(() => []);
const squawk = hits.some((x) =>
/aircraft|helicopter|military/.test(x.kind),
);
search.open(squawk ? '7700' : 'ISS');
},
},
],
+184
View File
@@ -794,3 +794,187 @@
visibility: hidden;
}
}
/* ------------------------------------------------------------------ */
/* Tablet leading pane: header · on the record · chat (DESIGN §12.4) */
/* ------------------------------------------------------------------ */
.mp-feed,
.mp-ch {
display: none;
}
.mp-fh {
display: flex;
}
.mp-fh,
.mp-ch {
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 0 16px;
color: var(--ink-3);
font-size: 0.5625rem;
font-weight: 700;
letter-spacing: 0.1em;
}
.mp-fh > span:first-child,
.mp-ch > span:first-child {
display: inline-flex;
align-items: center;
gap: 6px;
color: var(--ink-2);
}
.mp-fh .i {
font-size: 0.85rem;
}
.mp-feed-list {
display: grid;
gap: 8px;
padding: 8px 14px 12px;
overflow-y: auto;
overscroll-behavior: contain;
scrollbar-width: thin;
}
.mp-fi.in {
animation: msg-in 420ms var(--spring) both;
}
.mp-fcap {
display: flex;
gap: 10px;
width: 100%;
padding: 6px;
border-radius: 14px;
text-align: left;
color: var(--ink);
background: rgba(235, 199, 119, 0.06);
box-shadow: inset 0 0 0 0.5px rgba(235, 199, 119, 0.35);
}
.mp-fcap.first {
background: rgba(235, 199, 119, 0.1);
box-shadow:
inset 0 0 0 1px rgba(235, 199, 119, 0.8),
0 0 22px rgba(235, 199, 119, 0.14);
}
.mp-fcap .cone-thumb {
flex: none;
width: 96px;
border-radius: 10px;
}
.mp-fbody {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
justify-content: center;
gap: 3px;
}
.mp-fbody .fe {
display: inline-flex;
align-items: center;
gap: 5px;
color: var(--b-inside);
font-size: 0.5625rem;
font-weight: 700;
letter-spacing: 0.1em;
--seal-ink: #2a1a00;
}
.mp-fbody b {
font-size: var(--t-foot);
font-weight: 650;
line-height: 1.25;
}
.mp-fbody small {
font-family: var(--mono);
font-size: 0.5625rem;
color: var(--ink-3);
}
@media (min-width: 600px) {
.mp-side-title {
flex: none;
}
.mp-feed {
display: flex;
flex-direction: column;
flex: 0 1 auto;
min-height: 0;
max-height: 46%;
margin-top: 6px;
border-top: 0.5px solid var(--hairline);
padding-top: 12px;
}
.mp-ch {
display: flex;
flex: none;
padding-top: 12px;
border-top: 0.5px solid var(--hairline);
}
/* Everything at once on a tablet: no segmented switch. */
.mp-side-head,
.mp-pane[data-pane='record'],
.mp-pane[data-pane='captures'] {
display: none !important;
}
.mp-pane[data-pane='chat'] {
display: flex !important;
flex-direction: column;
-webkit-mask-image: none;
mask-image: none;
padding-top: 6px;
}
.mp-pane[data-pane='chat'] .mp-chat,
.mp-pane[data-pane='chat'] .mp-ext {
flex: 1;
min-height: 0;
}
/* Chat reads from the top, newest at the bottom. */
.mp-chat {
justify-content: flex-start;
padding-top: 4px;
}
.mp-ext {
display: flex;
flex-direction: column;
}
.co.co-tall {
flex: 1;
min-height: 0;
justify-content: flex-start;
}
.co.co-tall .co-list {
flex: 1;
min-height: 0;
justify-content: flex-start;
overflow-y: auto;
scrollbar-width: none;
pointer-events: auto;
-webkit-mask-image: linear-gradient(to bottom, transparent 0, #000 18px);
mask-image: linear-gradient(to bottom, transparent 0, #000 18px);
}
.co.co-tall .cl.old {
opacity: 0.7;
}
}
+49
View File
@@ -1003,3 +1003,52 @@ input[type='range'].slider::-moz-range-thumb {
opacity: 0;
}
}
/* Content scrolls under the floating glass: a soft scrim behind the tab bar
(and its accessory) keeps the last rows legible as they pass beneath. */
.app:is([data-tab='today'], [data-tab='rooms'], [data-tab='you'])
.tabbar::before {
content: '';
position: absolute;
z-index: -1;
left: calc(-12px - var(--safe-l));
right: calc(-12px - var(--safe-r));
bottom: calc(-1 * var(--tabbar-gap));
height: calc(var(--tabbar-h) + var(--tabbar-gap) + 46px);
background: linear-gradient(
to top,
rgba(5, 7, 11, 0.92) 0,
rgba(5, 7, 11, 0.7) 45%,
rgba(5, 7, 11, 0) 100%
);
pointer-events: none;
}
.app:is([data-tab='today'], [data-tab='rooms'], [data-tab='you'])
.tabbar.has-accessory::before {
height: calc(var(--tabbar-h) + var(--tabbar-gap) + 104px);
}
/* On a tablet the Globe tab's leading pane is a scrolling list too. */
@media (min-width: 600px) {
.app[data-tab='live'] .tabbar::before {
content: '';
position: absolute;
z-index: -1;
left: calc(-12px - var(--safe-l));
right: calc(-12px - var(--safe-r));
bottom: calc(-1 * var(--tabbar-gap));
height: calc(var(--tabbar-h) + var(--tabbar-gap) + 46px);
background: linear-gradient(
to top,
rgba(5, 7, 11, 0.92) 0,
rgba(5, 7, 11, 0.7) 45%,
rgba(5, 7, 11, 0) 100%
);
pointer-events: none;
}
.app[data-tab='live'] .tabbar.has-accessory::before {
height: calc(var(--tabbar-h) + var(--tabbar-gap) + 104px);
}
}
+47 -1
View File
@@ -186,10 +186,12 @@
animation: none;
}
/* Floats above the whole time machine, so it never covers the readout. */
.tm-callout {
position: absolute;
z-index: 2;
left: 50%;
bottom: calc(100% + 6px);
bottom: calc(100% + 8px);
transform: translateX(-50%);
max-width: 82%;
padding: 5px 9px;
@@ -283,3 +285,47 @@
white-space: normal;
line-height: 1.35;
}
/* NOW snap: a ring pulses out of the playhead and the NOW pill. */
.ribbon.tm.snap .tm-head::after {
content: '';
position: absolute;
top: 50%;
left: 50%;
width: 10px;
height: 10px;
margin: -5px 0 0 -5px;
border-radius: 50%;
box-shadow: 0 0 0 2px rgba(255, 69, 58, 0.9);
animation: tm-snap 760ms var(--ease-out) both;
pointer-events: none;
}
.ribbon.tm.snap .tm-now {
animation: tm-now-pop 520ms var(--spring) both;
}
@keyframes tm-snap {
from {
opacity: 1;
transform: scale(0.6);
}
to {
opacity: 0;
transform: scale(6);
}
}
@keyframes tm-now-pop {
40% {
transform: scale(1.12);
box-shadow: 0 0 0 4px rgba(255, 69, 58, 0.28);
}
}
@media (prefers-reduced-motion: reduce) {
.ribbon.tm.snap .tm-head::after,
.ribbon.tm.snap .tm-now {
animation: none;
}
}
+1 -1
View File
@@ -247,7 +247,7 @@ export function createDossier({
<button class="ds-act" type="button" data-ds="room">${icon('rooms')}<span>Open room</span></button>
<button class="ds-act" type="button" data-ds="time">${icon('clock')}<span>Time machine</span></button>
</div>
${d.privacy ? `<div class="ds-privacy">${icon('lock')}<span><b>Owner privacy (${esc(String(d.privacy).toUpperCase())})</b> Identity is limited to what the transponder broadcasts. We never name aircraft on privacy lists.</span></div>` : ''}
${d.privacy ? `<div class="ds-privacy">${icon('lock')}<span><b>Owner privacy (${esc(String(d.privacy).toUpperCase())})</b> Identity is limited to what the transponder broadcasts. We never name aircraft on privacy lists.</span></div>` : d.registryMissing ? `<div class="ds-privacy ds-registry">${icon('info')}<span><b>No public registry record found</b> for this airframe.${d.identity?.operator ? ` Operator from the callsign’s airline prefix.` : ''}</span></div>` : ''}
${trackHtml(d)}
${ident.length ? `<section class="ds-block"><h3 class="ds-h">Identity</h3><dl class="ds-id">${ident.map(([k, v]) => `<dt>${esc(ID_LABEL[k] || cap(k))}</dt><dd class="${/icao|norad|mmsi|registration/.test(k) ? 'mono' : ''}">${esc(v)}</dd>`).join('')}</dl></section>` : ''}
${
+17 -3
View File
@@ -155,14 +155,28 @@ export function createFeed(
const hd = $('[data-utc]', view);
if (hd) hd.textContent = u;
const dt = $('[data-date]', view);
if (dt)
dt.textContent = now
if (dt) {
// The kicker greets the hour: what the planet is doing for you now.
const hr = now.getHours();
const greet =
hr < 5
? 'Up late'
: hr < 12
? 'Good morning'
: hr < 17
? 'Good afternoon'
: hr < 22
? 'Good evening'
: 'Tonight';
const txt = `${now
.toLocaleDateString('en-GB', {
weekday: 'long',
day: 'numeric',
month: 'long',
})
.toUpperCase();
.toUpperCase()} · ${greet.toUpperCase()}`;
if (dt.textContent !== txt) dt.textContent = txt;
}
const lt = $('[data-localtime]', view);
if (lt) {
const txt = now.toLocaleTimeString('en-US', {
+5
View File
@@ -198,6 +198,11 @@ function guard(engine, kind) {
},
cachedThumbnail: (s) => call('cachedThumbnail', null, s),
history: () => call('history', null),
loadHistory: () => asyncCall('loadHistory', null),
historyNear: (ms, lon, lat, km) => {
const v = call('historyNear', [], ms, lon, lat, km);
return Array.isArray(v) ? v : [];
},
trackOf: (id) => asyncCall('trackOf', [], id),
playback: {
play: (o) => {
+5 -2
View File
@@ -956,6 +956,8 @@ export function mountChatOverlay(core, el, ctx = {}) {
let seqN = 0;
let driven = false; // a player calls push(): ignore the engine's own clock
const full = ctx.moment || null;
const maxLines = ctx.maxLines || 9;
if (maxLines > 9) root.classList.add('co-tall');
function line(m, { quiet = false } = {}) {
const key = m.id || `${m.t}|${m.handle}`;
@@ -980,7 +982,8 @@ export function mountChatOverlay(core, el, ctx = {}) {
const node = h(lineHtml(msg, core.rt.me.handle));
if (!quiet) node.classList.add('in');
list.appendChild(node);
while (list.childElementCount > 9) list.firstElementChild.remove();
while (list.childElementCount > maxLines) list.firstElementChild.remove();
if (maxLines > 9) list.scrollTop = list.scrollHeight;
// Lines fade after a while, as on a stream overlay.
setTimeout(() => node.classList.add('old'), 9000);
// Chat and hearts go together.
@@ -1062,7 +1065,7 @@ export function mountChatOverlay(core, el, ctx = {}) {
// Lines already said: show the last few, quietly.
const said = Array.isArray(m?.chat) ? m.chat : [];
for (const c of said) shown.add(c.id || `${c.t}|${c.handle}`);
for (const c of said.slice(-6)) {
for (const c of said.slice(maxLines > 9 ? -24 : -6)) {
shown.delete(c.id || `${c.t}|${c.handle}`);
line(c, { quiet: true });
}
+85 -1
View File
@@ -158,6 +158,11 @@ export function createMomentPlayer({
</div>
<aside class="mp-side glass-strong">
<div class="mp-side-title"><div class="mp-st-k tele"></div><h3></h3><p class="mp-st-s"></p></div>
<section class="mp-feed" aria-label="On the record, as it happened">
<header class="mp-fh tele"><span>${icon('receipt')}On the record</span><span class="mp-fn"></span></header>
<div class="mp-feed-list"></div>
</section>
<header class="mp-ch tele"><span><span class="live-dot"></span>Chat replay</span><span class="mp-chn"></span></header>
<div class="mp-side-head">
<div class="segmented mp-seg" role="tablist"><button role="tab" type="button" data-seg="chat" aria-selected="true">Chat<span class="mp-n" data-n="chat"></span></button><button role="tab" type="button" data-seg="record" aria-selected="false">Record<span class="mp-n" data-n="record"></span></button><button role="tab" type="button" data-seg="captures" aria-selected="false">Captures<span class="mp-n" data-n="captures"></span></button></div>
</div>
@@ -176,6 +181,8 @@ export function createMomentPlayer({
const chatEl = $('.mp-chat', el);
const recEl = $('.mp-record', el);
const capsEl = $('.mp-caps', el);
const feedEl = $('.mp-feed-list', el);
let feedKeys = new Set();
let m = null;
let raf = 0;
@@ -230,6 +237,55 @@ export function createMomentPlayer({
${p.kind !== 'synthesis' ? `<footer class="tele"><span>${icon('agree')}${p.votes?.corroborate ?? 0} corroborate</span><span>${icon('dispute')}${p.votes?.dispute ?? 0} dispute</span>${p.captureId ? `<button type="button" data-cap="${esc(p.captureId)}">${icon('seal')}Sealed capture</button>` : ''}</footer>` : '<footer class="tele"><span>AI synthesis · cites 5 captures and 37 messages</span></footer>'}</article>`;
}
/** A capture as a compact row for the leading pane's feed. */
function capRow(c) {
const fe = c.firstEyes;
return `<button class="mp-fi mp-fcap ${fe ? 'first' : ''}" type="button" data-cap="${esc(c.id)}">
${coneThumb(c, m, { w: 96, hgt: 72 })}
<span class="mp-fbody"><span class="fe tele">${icon(fe ? 'sealFill' : c.live ? 'live' : 'seal')}${fe ? 'FIRST EYES' : c.live ? 'LIVE · SEALED' : 'SEALED CAPTURE'}</span>
<b>${esc(c.title)}</b><small>${esc(c.handle)} · ${esc(utc(c.t))} · ${esc(fmtKm((c.distanceM || 0) / 1000))}</small></span></button>`;
}
/**
* The leading pane's feed (tablet): captures and record posts stacked
* newest first as the clock passes them.
*/
function renderFeed() {
const items = [
...(m.captures || [])
.filter((c) => c.t <= t)
.map((c) => ({ key: `c:${c.id}`, t: c.t, html: capRow(c) })),
...(m.posts || [])
.filter((p) => p.t <= t)
.map((p) => ({
key: `p:${p.id || p.t}`,
t: p.t,
html: postHtml(p).replace('class="mp-post', 'class="mp-fi mp-post'),
})),
].sort((a, b) => b.t - a.t);
const keys = new Set(items.map((x) => x.key));
const same =
keys.size === feedKeys.size && [...keys].every((k) => feedKeys.has(k));
if (!same) {
const fresh = feedKeys.size > 0;
feedEl.innerHTML =
items
.slice(0, 12)
.map((x) =>
fresh && !feedKeys.has(x.key)
? x.html.replace('class="mp-fi', 'class="mp-fi in')
: x.html,
)
.join('') ||
'<p class="mp-empty tele">Sealed captures and record posts appear here at the moment they were made</p>';
feedKeys = keys;
feedEl.scrollTop = 0;
}
$('.mp-fn', el).textContent = items.length
? `${items.length} ITEM${items.length === 1 ? '' : 'S'}`
: '';
}
function drawScrub() {
const r = scrub.getBoundingClientRect();
const dpr = Math.min(2, window.devicePixelRatio || 1);
@@ -282,6 +338,24 @@ export function createMomentPlayer({
sctx.arc(x(p.t), mid + 9, 2, 0, Math.PI * 2);
sctx.fill();
}
// First eyes stamps its byline onto the timeline as the clock passes it.
for (const c of m.captures || []) {
if (!c.firstEyes || c.t > t) continue;
const k = clamp((t - c.t) / 6000, 0, 1);
const label = `FIRST EYES · ${c.handle || ''}`;
sctx.font = '700 8px "JetBrains Mono", ui-monospace, monospace';
const w = sctx.measureText(label).width + 12;
const bx = clamp(x(c.t) - w / 2, 2, W - w - 2);
sctx.globalAlpha = k;
sctx.fillStyle = 'rgba(235,199,119,0.95)';
sctx.beginPath();
sctx.roundRect(bx, 1 + (1 - k) * 4, w, 12, 6);
sctx.fill();
sctx.fillStyle = '#2a1a00';
sctx.textAlign = 'left';
sctx.fillText(label, bx + 6, 10 + (1 - k) * 4);
sctx.globalAlpha = 1;
}
for (const c of m.captures || []) {
sctx.fillStyle = c.t <= t ? '#ebc777' : 'rgba(235,199,119,0.4)';
sctx.beginPath();
@@ -377,6 +451,8 @@ export function createMomentPlayer({
$('[data-n="chat"]', el).textContent = nc ? ` ${nc}` : '';
$('[data-n="record"]', el).textContent = np ? ` ${np}` : '';
$('[data-n="captures"]', el).textContent = nk ? ` ${nk}` : '';
$('.mp-chn', el).textContent = nc ? `${nc} MESSAGES` : '';
if (isTwoPane()) renderFeed();
// Legend: live per-track readouts.
(m.tracks || []).forEach((tr) => {
const row = legend.querySelector(`[data-track="${CSS.escape(tr.id)}"]`);
@@ -483,6 +559,8 @@ export function createMomentPlayer({
.map((x) => `${x.name}${x.detail ? ` — ${x.detail}` : ''}`)
.join(' · ');
shown = { chat: 0, posts: 0, caps: 0 };
feedKeys = new Set();
feedEl.innerHTML = '';
chatEl.innerHTML = '';
recEl.innerHTML = emptyRec();
capsEl.innerHTML = '';
@@ -533,7 +611,13 @@ export function createMomentPlayer({
try {
const host = h('<div class="mp-ext"></div>');
slot.prepend(host);
ext = sm.chatOverlay(host, { moment: raw, globe, getTime: () => t });
ext = sm.chatOverlay(host, {
moment: raw,
globe,
getTime: () => t,
// A tablet's leading pane has the height for a real chat column.
maxLines: isTwoPane() ? 60 : 9,
});
ext?.setMoment?.({ ...raw, chat: [] });
slot.classList.add('has-ext');
el.classList.add('has-ext');
+3 -1
View File
@@ -259,7 +259,9 @@ export function installRound3(
async function loadHero() {
if (hero) return hero;
try {
const r = await fetch('/mobile/demo/moments/empire-state.json');
const r = await fetch(
new URL('../demo/moments/empire-state.json', import.meta.url).href,
);
if (r.ok && /json/.test(r.headers.get('content-type') || '')) {
const j = await r.json();
if (j?.tracks?.length) hero = j;
+1
View File
@@ -620,6 +620,7 @@ export function chatOverlay(el, ctx = {}) {
return mountChatOverlay(c, el, {
globe: ctx.globe || c.globe,
moment: ctx.moment,
maxLines: ctx.maxLines,
});
}
+128 -26
View File
@@ -59,8 +59,8 @@ export function createTimeRibbon(
<div class="rb-track tm-track" role="slider" tabindex="0" aria-label="Scrub time" aria-valuetext="Live">
<canvas></canvas>
<div class="rb-head tm-head" aria-hidden="true"><i></i></div>
<div class="tm-callout tele" hidden></div>
</div>
<div class="tm-callout tele" hidden></div>
<div class="tm-cliprow" hidden>
<span class="tm-cliplen tele"></span>
<button class="pill-btn plain tm-clipcancel" type="button">Cancel</button>
@@ -82,6 +82,7 @@ export function createTimeRibbon(
let win = getWindow();
let rec = null; // { start, end }
let arch = null; // the bundled recording: { start, end, label }
let custom = null;
let pips = [];
let t = Date.now();
@@ -103,7 +104,11 @@ export function createTimeRibbon(
const start = () =>
custom
? custom.start
: Math.min(win.start.getTime(), rec?.start ?? Infinity);
: Math.min(
win.start.getTime(),
rec?.start ?? Infinity,
arch ? arch.start - HOUR : Infinity,
);
const end = () => (custom ? custom.end : win.end.getTime());
const nowMs = () => (custom ? (custom.now ?? custom.start) : Date.now());
const x = (ms) => W / 2 + ((ms - t) / HOUR) * pxPerHour;
@@ -114,6 +119,21 @@ export function createTimeRibbon(
const hst = getHistory?.();
if (hst && Number.isFinite(+hst.start))
rec = { start: +hst.start, end: +hst.end || Date.now() };
const a = hst?.archive;
arch =
a && Number.isFinite(+a.start)
? {
start: +a.start,
end: +a.end,
label: `RECORDING · ${new Date(+a.start)
.toLocaleDateString('en-GB', {
day: 'numeric',
month: 'short',
timeZone: 'UTC',
})
.toUpperCase()}`,
}
: null;
} catch {
rec = null;
}
@@ -157,22 +177,64 @@ export function createTimeRibbon(
ctx.fillStyle = 'rgba(255,255,255,0.035)';
ctx.fillRect(xs, base - 12, xe - xs, 24);
if (!custom) {
// Recorded range: where the app remembers every contact.
// Range labels sit inside the band's lower edge. Each is placed only
// where it fits inside its own range and clear of the labels already
// placed, so they never run into each other ("RECORDEDCAST").
ctx.font = '600 8.5px "JetBrains Mono", ui-monospace, monospace';
const placed = [];
const label = (texts, from, to, color, align = 'left') => {
// The longest wording that fits inside its range.
const all = [].concat(texts);
const text =
all.find(
(tx) =>
ctx.measureText(tx).width + 8 <=
Math.min(to, W - 24) - Math.max(from, 24),
) || all[all.length - 1];
const w = ctx.measureText(text).width;
const lo = Math.max(from, 24);
const hi = Math.min(to, W - 24);
if (hi - lo < w + 8) return;
const x0 = align === 'right' ? hi - 4 - w : lo + 4;
if (placed.some(([a, b]) => x0 < b + 8 && x0 + w > a - 8)) return;
placed.push([x0, x0 + w]);
ctx.fillStyle = color;
ctx.textAlign = 'left';
ctx.fillText(text, x0, base + 9);
};
const band = (a, b, top, bottom, edge) => {
const g = ctx.createLinearGradient(0, base - 12, 0, base + 12);
g.addColorStop(0, top);
g.addColorStop(1, bottom);
ctx.fillStyle = g;
ctx.fillRect(a, base - 12, b - a, 24);
ctx.fillStyle = edge;
ctx.fillRect(a, base - 12, b - a, 1.2);
};
// The bundled recording: real positions at their real times.
if (arch) {
const a = clamp(x(arch.start), -2, W + 2);
const b = clamp(x(Math.min(arch.end, n)), -2, W + 2);
if (b > a) {
band(
a,
b,
'rgba(122,231,199,0.2)',
'rgba(122,231,199,0.05)',
'rgba(122,231,199,0.8)',
);
}
}
// Recorded range: where this device remembers every contact.
if (rec) {
const a = clamp(x(rec.start), -2, W + 2);
const g = ctx.createLinearGradient(0, base - 12, 0, base + 12);
g.addColorStop(0, 'rgba(163,216,255,0.22)');
g.addColorStop(1, 'rgba(163,216,255,0.06)');
ctx.fillStyle = g;
ctx.fillRect(a, base - 12, xn - a, 24);
ctx.fillStyle = 'rgba(163,216,255,0.85)';
ctx.fillRect(a, base - 12, xn - a, 1.2);
if (a > 4 && a < W - 60) {
ctx.font = '600 8.5px "JetBrains Mono", ui-monospace, monospace';
ctx.fillStyle = 'rgba(163,216,255,0.8)';
ctx.textAlign = 'left';
ctx.fillText('RECORDED', a + 4, base + 9);
}
band(
a,
xn,
'rgba(163,216,255,0.22)',
'rgba(163,216,255,0.06)',
'rgba(163,216,255,0.85)',
);
}
// Forecast
if (xn < W) {
@@ -181,13 +243,25 @@ export function createTimeRibbon(
g.addColorStop(1, 'rgba(179,157,255,0.02)');
ctx.fillStyle = g;
ctx.fillRect(xn, base - 12, Math.min(W, xe) - xn, 24);
if (xn > -40 && xn < W - 70) {
ctx.font = '600 8.5px "JetBrains Mono", ui-monospace, monospace';
ctx.fillStyle = 'rgba(199,180,255,0.75)';
ctx.textAlign = 'left';
ctx.fillText('FORECAST', xn + 6, base + 9);
}
}
// Labels: forecast first (anchored at now), then the recordings.
if (xn < W)
label('FORECAST', xn, Math.min(W, xe), 'rgba(199,180,255,0.75)');
if (rec)
label(
['RECORDED', 'REC'],
x(rec.start),
xn,
'rgba(163,216,255,0.8)',
'right',
);
if (arch)
label(
[arch.label, 'RECORDING', 'REC'],
x(arch.start),
x(Math.min(arch.end, n)),
'rgba(122,231,199,0.85)',
);
}
// Clip range
if (range) {
@@ -210,6 +284,7 @@ export function createTimeRibbon(
const t0 = Math.ceil(tAt(-10) / step) * step;
const t1 = tAt(W + 10);
ctx.textAlign = 'center';
let lastLabelEnd = -Infinity;
for (let m = t0; m <= t1; m += step) {
if (m < s - step || m > e + step) continue;
const px = x(m);
@@ -245,7 +320,17 @@ export function createTimeRibbon(
})
.toUpperCase()
: `${String(d.getUTCHours()).padStart(2, '0')}:${String(d.getUTCMinutes()).padStart(2, '0')}`;
ctx.fillText(label, px, base - 15);
const lw = ctx.measureText(label).width;
// Skip a label that would collide with the previous one or be
// cut by the edge fades.
if (
px - lw / 2 > lastLabelEnd + 6 &&
px - lw / 2 > 14 &&
px + lw / 2 < W - 14
) {
ctx.fillText(label, px, base - 15);
lastLabelEnd = px + lw / 2;
}
}
}
// NOW marker
@@ -300,7 +385,14 @@ export function createTimeRibbon(
);
return;
}
const abs = utc(new Date(live ? Date.now() : t));
const at = new Date(live ? Date.now() : t);
// Another UTC day: say which one, so a recording reads honestly.
const otherDay =
!live &&
at.toISOString().slice(0, 10) !== new Date().toISOString().slice(0, 10);
const abs = otherDay
? `${at.toLocaleDateString('en-GB', { day: 'numeric', month: 'short', timeZone: 'UTC' }).toUpperCase()} · ${utc(at)}`
: utc(at);
readD.textContent = live ? 'LIVE' : fmtDelta(t - Date.now());
readA.textContent = abs;
el.dataset.future = String(!live && t > Date.now());
@@ -526,7 +618,7 @@ export function createTimeRibbon(
}
if (!custom && Math.abs(target - Date.now()) < (8 / pxPerHour) * HOUR) {
setLive(true);
haptic('tick');
snapPulse();
return animateTo(Date.now(), () => emit(true));
}
const q = custom ? 1000 : stepFor() >= HOUR ? 5 * MIN : MIN / 4;
@@ -549,8 +641,18 @@ export function createTimeRibbon(
raf = requestAnimationFrame(step);
}
/** The NOW snap: a haptic tick and a pulse ring, like a detent landing. */
function snapPulse() {
haptic('medium');
el.classList.remove('snap');
void el.offsetWidth;
el.classList.add('snap');
clearTimeout(snapPulse.t);
snapPulse.t = setTimeout(() => el.classList.remove('snap'), 900);
}
function goLive() {
haptic('tick');
snapPulse();
stopPlay();
if (clipMode) setClipMode(false);
if (custom) {
+114 -12
View File
@@ -16,6 +16,66 @@ import { trackAt } from './stubMoments.js';
import { domeSvg } from './skySheet.js';
const LAST_KEY = 'gev.lastVisit';
const SKY_KEY = 'gev.sky.v1';
/**
* The hero Moment's card, so it paints in the first frame (the full Moment
* JSON is ~0.5 MB and arrives a moment later). Its picture is the same Esri
* span the live card draws on, bundled (mobile/demo/moments/empire-state.jpg).
*/
const HERO_CARD = {
id: 'empire-state',
title: 'Climbers on the Empire State Building',
place: 'Midtown Manhattan, New York',
minutes: 25,
tracks: 3,
captures: 8,
illustrative: true,
};
const HERO_IMG = new URL('../demo/moments/empire-state.jpg', import.meta.url)
.href;
/** The last sky snapshot (this device), so the card never says "Looking up…". */
function readSky() {
try {
const j = JSON.parse(localStorage.getItem(SKY_KEY) || 'null');
if (j?.sky && Date.now() - j.at < 12 * 3600e3)
return { ...j.sky, cachedAt: j.at };
} catch {
/* ignore */
}
return null;
}
function saveSky(sky) {
try {
const slim = {
at: sky.at,
observer: sky.observer,
aircraft: (sky.aircraft || []).slice(0, 16).map((a) => ({
id: a.id,
title: a.title,
kind: a.kind,
elevationDeg: a.elevationDeg,
azimuthDeg: a.azimuthDeg,
headingDeg: a.headingDeg,
})),
satellites: (sky.satellites || []).slice(0, 24).map((x) => ({
id: x.id,
title: x.title,
elevationDeg: x.elevationDeg,
azimuthDeg: x.azimuthDeg,
})),
iss: sky.iss
? { visibleNow: false, nextPass: sky.iss.nextPass || null }
: null,
};
localStorage.setItem(
SKY_KEY,
JSON.stringify({ at: Date.now(), sky: slim }),
);
} catch {
/* per-device convenience only */
}
}
const SEEN_KEY = 'gev.seenIds';
function readLast() {
@@ -85,6 +145,17 @@ export function createToday(
const mCanvas = $('.bf-mcanvas', slot);
const mctx = mCanvas.getContext('2d');
// First frame: the hero card from its bundled picture and a few facts.
{
const img = $('.bf-mimg', slot);
img.onload = () => img.classList.add('loaded');
img.src = HERO_IMG;
$('.bf-mtitle', slot).textContent = HERO_CARD.title;
$('.bf-msub', slot).textContent =
`${HERO_CARD.place} · ${HERO_CARD.minutes} min in 20 s · ${HERO_CARD.tracks} helicopters · ${HERO_CARD.captures} sealed captures`;
$('.bf-mtag', slot).innerHTML = '<span class="tag-ill">Illustrative</span>';
}
/* ---------- since you last looked ---------- */
function changes() {
const now = Date.now();
@@ -169,15 +240,19 @@ export function createToday(
const span = 0.02;
const img = $('.bf-mimg', slot);
const r = mBtn.getBoundingClientRect();
// The card is 16:11 whatever its width (today.css).
const w = Math.max(320, Math.round(r.width || 400));
const hgt = Math.max(200, Math.round(r.height || 260));
img.src = esriThumb(
m.lon,
m.lat,
span,
Math.min(1024, w * 2),
Math.min(1024, Math.round((hgt / w) * Math.min(1024, w * 2))),
);
const hgt = Math.round((w * 11) / 16);
if (m.id !== HERO_CARD.id) {
img.classList.remove('loaded');
img.src = esriThumb(
m.lon,
m.lat,
span,
Math.min(1024, w * 2),
Math.min(1024, Math.round((hgt / w) * Math.min(1024, w * 2))),
);
} else if (!img.src.includes('empire-state')) img.src = HERO_IMG;
img.onload = () => img.classList.add('loaded');
const dLon = span / Math.cos((m.lat * Math.PI) / 180);
const dLat = span * (hgt / w);
@@ -304,7 +379,9 @@ export function createToday(
}
function renderTonight() {
const rows = [];
const np = sky?.iss?.nextPass;
const pass = sky?.iss?.nextPass;
const np =
pass && (pass.end || pass.start + 600e3) > Date.now() ? pass : null;
if (sky?.iss?.visibleNow)
rows.push(
`<button class="bf-row" type="button" data-sky data-band="orbit"><i class="bd"></i><span class="bf-main"><b>ISS is overhead now</b><small>${Math.round(sky.iss.elevationDeg)}° up, ${esc(cardinal(sky.iss.azimuthDeg))}</small></span></button>`,
@@ -329,11 +406,28 @@ export function createToday(
}
$('[data-tonight]', slot).innerHTML =
rows.join('') ||
'<div class="bf-calm"><span>Clear skies, nothing scheduled.</span></div>';
(sky
? '<div class="bf-calm"><span>Clear skies, nothing scheduled.</span></div>'
: '<div class="bf-row bf-ph"><i></i><span></span></div><div class="bf-row bf-ph"><i></i><span></span></div>');
}
function renderSky() {
if (!sky) return;
const kicker = $('.bf-skyk', slot);
if (!sky) {
// Never looked from this device yet: an empty dome, counting.
const home = getHome();
$('[data-dome]', slot).innerHTML = domeSvg(
{ aircraft: [], satellites: [] },
{ size: 120, labels: false },
);
$('[data-skyh]', slot).textContent = 'Counting what’s above you';
$('[data-skyn]', slot).textContent =
`Aircraft within 50 km and satellites over ${home.label || home.name}`;
return;
}
kicker.innerHTML = sky.cachedAt
? `YOUR SKY · ${esc(ago(sky.cachedAt).toUpperCase())}`
: '<span class="live-dot"></span>YOUR SKY NOW';
$('[data-dome]', slot).innerHTML = domeSvg(sky, {
size: 120,
labels: false,
@@ -434,13 +528,21 @@ export function createToday(
async function refreshSky() {
if (!globe) return;
const home = getHome();
sky = await globe
const fresh = await globe
.skyNow({ lon: home.lon, lat: home.lat })
.catch(() => null);
if (fresh) {
sky = fresh;
saveSky(fresh);
}
renderSky();
renderTonight();
}
sky = readSky();
renderSky();
if (sky) renderTonight();
watch.onChange(() => renderWatch());
setInterval(() => {
if (!document.hidden && slot.offsetParent) {
+68
View File
@@ -471,8 +471,76 @@ export function createWatch({
if (!document.hidden) evaluate();
}, 30000);
/**
* Director: one deterministic banner. A real rule against live data when
* any rule matches now (fired before or not); else a real situation that
* a demo rule ("any emergency squawk anywhere") would catch; else a
* clearly labelled example. Sticky until tapped or the next beat.
*/
async function demoAlert() {
clearBanners();
// Never wait on a cold live fetch: after 1.2 s the last good snapshot
// stands in (its age is said in the banner).
let sits = [];
try {
const fresh = await Promise.race([
getSituations(),
new Promise((r) => setTimeout(() => r(null), 1200)),
]);
sits = fresh || (await globe.situationsCached?.()) || [];
sits = [...sits, ...(globe.detections?.() || [])];
} catch {
/* example below */
}
const live = sits.filter((s) => Number.isFinite(s.lat));
const age = (s) =>
s.cached && s.capturedAt ? ` · seen ${ago(+new Date(s.capturedAt))}` : '';
for (const r of state.rules) {
const w = what(r.what);
if (!r.on || !w.test) continue;
const pl = placeById(r.of);
const s = live.find(
(x) => w.test(x) && (r.within >= 20000 || distKm(pl, x) <= r.within),
);
if (s)
return banner({
title: s.title,
body: `${ruleText(r)} · ${fmtKm(distKm(pl, s))} from ${pl.name}${age(s)}`,
band: bandOf(s),
sticky: true,
onTap: () => onOpenSituation?.(s),
});
}
const home = placeById('home');
for (const id of ['emergency', 'circling', 'quake5', 'launch', 'fire']) {
const w = what(id);
const s = live
.filter((x) => w.test(x))
.sort((a, b) => distKm(home, a) - distKm(home, b))[0];
if (s)
return banner({
title: s.title,
body: `Demo rule: any ${w.label} anywhere · ${fmtKm(distKm(home, s))} from ${home.name}${age(s)}`,
band: bandOf(s),
sticky: true,
onTap: () => onOpenSituation?.(s),
});
}
return banner({
title: 'Squawk 7700 near Home',
body: 'Example alert · how a rule match looks when one fires',
band: 'sky',
sticky: true,
});
}
function clearBanners() {
stack.replaceChildren();
}
return {
banner,
demoAlert,
clearBanners,
evaluate,
open() {
render();