fix(mobile): polish round: night close-ups, bundled real recording, demo runbook

- Close-ups stay readable at any hour: night-side imagery is graded
  instead of black, and a watchdog drops to flat terrain when the
  keyless terrain host stalls.
- A bundled real ADS-B recording (23 Oct 2026, 08:25-11:06Z; NYC,
  London, Gulf) gives the time machine ghosts to replay on first open.
- Director beats frame their subjects (hearts rise over Midtown), fire
  a deterministic alert, and run in any order.
- Dossiers name operators from airline prefixes and show the privacy
  notice only for real privacy flags.
- The iPad Moment pane carries the record and chat; time machine
  labels no longer collide; demo data ships in the production build.
- mobile/DEMO.md explains how to run and present the demo.

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 11:45:31 +00:00
1 parent 90b962745e
commit 65fec98c94
8 files changed
+25828 -912

No files matched your search

+50
View File
@@ -0,0 +1,50 @@
# Running the mobile demo
## Start
```sh
npm install
npm run dev
```
Open `http://localhost:4173/mobile/` in Chrome. Open DevTools, turn on the device toolbar (Cmd+Shift+M), and pick a phone (iPhone 14 Pro Max is close to the 17 Pro Max at 430×932) or an iPad Pro (1024×1366). The two-pane tablet layout starts at 600 px wide.
No keys are needed. Load the page once before presenting: the first load compiles and caches, and later loads paint Today in under a second from the last good snapshot.
## Presenter keys
Press `.` to open the director. Press a key to jump to a beat. Each beat sets up its own state, so they can be run in any order.
| Key | Beat | What to say |
| --- | --- | --- |
| 1 | Today | The daily brief: since you last looked, today's Moment, your sky, your watch. |
| 2 | Your sky now | Everything above you this minute, from live ADS-B and orbital elements. |
| 3 | The Empire State Moment | A 4D reconstruction: helicopter tracks, sealed captures with view cones, chat and the record on one clock. Illustrative. |
| 4 | The Live lane, with hearts | Twitch-style chat over the city; reactions rise from where people are. Simulated chat. |
| 5 | Need eyes | A room asks for coverage; the first sealed answer earns the byline and Signal. |
| 6 | Capture | Look, Witness, Live and Upload, signed on the device. |
| 7 | Dossier of a live aircraft | Gotham-style: identity, live state, track, links, receipts. Real data. |
| 8 | Link graph | Everything that thing touches, synchronized with the globe. |
| 9 | Time machine at 60× | Replays a real bundled recording of London, NYC and the Gulf. |
| 0 | Clip this Moment | Select a range and clip it into a shareable Moment. |
| - | An alert, calmly | Severity-gated alert rules on live data. |
| = | Search to dossier | Search "7700" (or "ISS" if nothing is squawking) and open the result. |
`Esc` closes the top sheet. The director has a **Reset demo** action.
## What's real
| Real | Illustrative or simulated |
| --- | --- |
| Live aircraft, military, satellites, quakes, cyclones, fires, radar, lightning, wind, air quality, GNSS interference, transit, public cameras | The Empire State Moment (synthetic tracks over real NYC footprints) |
| Detections (circling, squawks, quake clusters) on live data | Chat in the Live lane, example posts, crews, and other contributors |
| The time machine's recording (this session's polling plus a bundled real recording from 23 Oct 2026, 08:25–11:06Z) | Need eyes requests from other people |
| Capture signing, hash chains and verification (WebCrypto on this device) | The room clerk (rules, not a model) |
Every illustrative or simulated surface carries a label in the app.
## Rehearse
Run the beats once on the presenting Mac before the meeting. On first load the keyless terrain host can be slow; if a close-up stays black for more than a few seconds, the engine falls back to flat terrain on its own.
Refresh the bundled data while `npm run dev` is running: `node mobile/demo/capture-snapshot.mjs --base http://localhost:4173` for the snapshot, and `node mobile/demo/capture-history.mjs --base http://localhost:4173 --minutes 150` for the recording (it writes every 5 minutes; behind a proxy, prefix it with `NODE_USE_ENV_PROXY=1`).
File diff suppressed because it is too large. Load diff
+4 -4
View File
@@ -2,7 +2,7 @@
"format": "gev-history-index/1", "format": "gev-history-index/1",
"capturedAt": { "capturedAt": {
"start": "2026-10-23T08:25:37.000Z", "start": "2026-10-23T08:25:37.000Z",
"end": "2026-10-23T09:02:03.000Z" "end": "2026-10-23T11:06:30.000Z"
}, },
"regions": [ "regions": [
{ {
@@ -10,21 +10,21 @@
"label": "New York area", "label": "New York area",
"bbox": { "west": -76.5, "south": 39.4, "east": -71.2, "north": 42.2 }, "bbox": { "west": -76.5, "south": 39.4, "east": -71.2, "north": 42.2 },
"file": "nyc.json", "file": "nyc.json",
"tracks": 47 "tracks": 525
}, },
{ {
"id": "london", "id": "london",
"label": "London and the North Sea approaches", "label": "London and the North Sea approaches",
"bbox": { "west": -2.6, "south": 50.2, "east": 5.6, "north": 53.6 }, "bbox": { "west": -2.6, "south": 50.2, "east": 5.6, "north": 53.6 },
"file": "london.json", "file": "london.json",
"tracks": 500 "tracks": 1559
}, },
{ {
"id": "gulf", "id": "gulf",
"label": "Gulf of Mexico", "label": "Gulf of Mexico",
"bbox": { "west": -98.5, "south": 23.5, "east": -81.5, "north": 31.2 }, "bbox": { "west": -98.5, "south": 23.5, "east": -81.5, "north": 31.2 },
"file": "gulf.json", "file": "gulf.json",
"tracks": 78 "tracks": 493
} }
] ]
} }
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+1 -1
View File
@@ -42,7 +42,7 @@ const GRADE = Object.freeze({
* while it is on screen (the keyless terrain host throttles under load, and * 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. * Cesium then shows only the star field), fall back to the flat ellipsoid.
*/ */
const TERRAIN_STALL_MS = 5000; const TERRAIN_STALL_MS = 3000;
export const BASE_CREDITS = Object.freeze([ export const BASE_CREDITS = Object.freeze([
{ {
+12 -5
View File
@@ -171,6 +171,9 @@ function applyTab(tab) {
if (tab === 'live' && two) chrome?.renderLeadView(); if (tab === 'live' && two) chrome?.renderLeadView();
if (tab !== 'live') chrome?.hidePick(); if (tab !== 'live') chrome?.hidePick();
} }
// Paint the right panes in the first frame (on a tablet the Globe pane
// would otherwise cover Today until the engine is up).
applyTab(current);
function selectTab(tab) { function selectTab(tab) {
if (tab === current) return; if (tab === current) return;
@@ -967,12 +970,14 @@ async function openThreadFor(s) {
/** /**
* An oblique view of a place, framed into the part of the map a sheet leaves * 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 * 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 * sits in the upper third; on iPad (sheets in the leading pane) at the
* below the centre, leaving room above for rising reactions. * centre, seen more steeply, leaving room above for rising reactions. The default
* heading looks north, up Midtown toward Central Park, which keeps
* the airports' dense live traffic (LaGuardia, JFK, Newark) out of frame.
*/ */
function frameForSheet( function frameForSheet(
{ lon, lat }, { lon, lat },
{ distM = 3000, rayDeg = 30, heading = 32, duration = 1.6 } = {}, { distM = 3000, rayDeg = 30, heading = 12, duration = 1.6 } = {},
) { ) {
if (!globe) return Promise.resolve(); if (!globe) return Promise.resolve();
const r = stage.getBoundingClientRect(); const r = stage.getBoundingClientRect();
@@ -980,7 +985,9 @@ function frameForSheet(
const H = r.height || 1; const H = r.height || 1;
const fov = globe.raw?.viewer?.camera?.frustum?.fov ?? Math.PI / 3; 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 tanHalfV = W > H ? (Math.tan(fov / 2) * H) / W : Math.tan(fov / 2);
const yFrac = isTwoPane() ? 0.56 : 0.27; // On a tablet the whole stage is free: look steeper, place at centre.
if (isTwoPane()) rayDeg = Math.max(rayDeg, 42);
const yFrac = isTwoPane() ? 0.5 : 0.27;
const above = Math.atan((0.5 - yFrac) * 2 * tanHalfV) * (180 / Math.PI); const above = Math.atan((0.5 - yFrac) * 2 * tanHalfV) * (180 / Math.PI);
const pitch = Math.max(-88, -rayDeg - above); const pitch = Math.max(-88, -rayDeg - above);
const ray = (rayDeg * Math.PI) / 180; const ray = (rayDeg * Math.PI) / 180;
@@ -1027,7 +1034,7 @@ async function busiestRecording(g) {
/** Director: a steady stream of reactions rising from a place. */ /** Director: a steady stream of reactions rising from a place. */
let reactTimer = 0; let reactTimer = 0;
function reactionStream(g, { lon, lat }, ms = 15000) { function reactionStream(g, { lon, lat }, ms = 45000) {
clearInterval(reactTimer); clearInterval(reactTimer);
const kinds = ['heart', 'heart', 'fire', 'heart', 'eyes', 'wow', 'heart']; const kinds = ['heart', 'heart', 'fire', 'heart', 'eyes', 'wow', 'heart'];
const until = Date.now() + ms; const until = Date.now() + ms;
+3 -1
View File
@@ -172,7 +172,9 @@ const PATTERNS = {
export function haptic(kind = 'tick') { export function haptic(kind = 'tick') {
if (!settings.haptics) return; if (!settings.haptics) return;
try { try {
if (navigator.vibrate) navigator.vibrate(PATTERNS[kind] ?? 5); // Chrome logs an intervention for vibrate() before any user gesture.
if (navigator.vibrate && navigator.userActivation?.hasBeenActive !== false)
navigator.vibrate(PATTERNS[kind] ?? 5);
} catch { } catch {
/* ignore */ /* ignore */
} }