Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cad15b73cb | ||
|
|
65fec98c94 | ||
|
|
90b962745e | ||
|
|
4e1e838771 | ||
|
|
070b549990 | ||
|
|
e0fd7acead | ||
|
|
5aba6bab58 | ||
|
|
a720b10913 | ||
|
|
a6f98ce3c1 | ||
|
|
f16a9fd365 | ||
|
|
971ff13a61 | ||
|
|
2ef9dd7448 | ||
|
|
a644e797a7 | ||
|
|
8b7fcb09df | ||
|
|
ba1673a6db | ||
|
|
3218b7d427 | ||
|
|
c2a0c2e23e | ||
|
|
52de186005 | ||
|
|
e532c5efb6 | ||
|
|
a8b7680c7a | ||
|
|
35ffcedcad |
No files matched your search
@@ -26,6 +26,8 @@ export function panelBuildConfig(config) {
|
||||
cssCodeSplit: false,
|
||||
rollupOptions: {
|
||||
...config.build?.rollupOptions,
|
||||
// The panel is the app alone: one page, so one inlined script.
|
||||
input: undefined,
|
||||
output: { inlineDynamicImports: true },
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { applicationHtmlPlugin } from './application-html.js';
|
||||
import cesium from 'vite-plugin-cesium';
|
||||
import { DEFAULT_ALLOWED_HOSTS, hostCheckPlugin } from './allowedHosts.js';
|
||||
import { embedFramingPlugin } from './embed-framing.js';
|
||||
import { panelBuildPlugin } from './panel.js';
|
||||
|
||||
/** HTML documents a production build emits: the app and its mobile shell. */
|
||||
export const BROWSER_PAGES = Object.freeze({
|
||||
main: fileURLToPath(new URL('../index.html', import.meta.url)),
|
||||
mobile: fileURLToPath(new URL('../mobile/index.html', import.meta.url)),
|
||||
});
|
||||
|
||||
/**
|
||||
* Content-Security-Policy for every document the dev/preview server serves.
|
||||
* No inline script is permitted, and the only foreign script origins are the
|
||||
@@ -113,6 +120,9 @@ export function createBrowserViteConfig({
|
||||
mapillaryToken ?? '',
|
||||
),
|
||||
},
|
||||
build: { chunkSizeWarningLimit: 1500 },
|
||||
build: {
|
||||
chunkSizeWarningLimit: 1500,
|
||||
rollupOptions: { input: { ...BROWSER_PAGES } },
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -59,6 +59,24 @@ provider registration rejects compositions that cannot fit rather than truncatin
|
||||
identities. A malformed or duplicate entry invalidates the complete custom-switch
|
||||
field. Existing whole-link size limits still apply.
|
||||
|
||||
## Mobile app prototype — October 18, 2026
|
||||
|
||||
`/mobile/` is a phone and foldable shell over the same engine, specified in
|
||||
[`mobile/DESIGN.md`](../mobile/DESIGN.md). It has five tabs: a situation feed,
|
||||
the globe, capture, reconstruction threads and the user's own captures.
|
||||
`mobile/globe/` is a phone-tuned Cesium engine. It blends Esri day imagery
|
||||
with NASA Black Marble night lights across the terminator, uses Google 3D
|
||||
tiles below 25 km when keyed, and builds its layers on the existing `/api`
|
||||
routes and source parsers. Capture signs each bundle on the device with a
|
||||
non-extractable ECDSA P-256 WebCrypto key held in IndexedDB. A bundle carries
|
||||
a SHA-256 media hash, a per-device hash chain, and the IMU and GPS trace.
|
||||
These signatures are browser equivalents of hardware attestation; they are
|
||||
not hardware attestation. Hypotheses and authors in the reconstruction
|
||||
threads are labelled example content. `npm run build` now emits
|
||||
`dist/mobile/index.html` beside the app, while `npm run build:panel` still
|
||||
builds the app page alone. `mobile/vision/index.html` is a self-contained
|
||||
concept mockup with illustrative data and no production code.
|
||||
|
||||
## God's Eye View in conversations — October 2, 2026
|
||||
|
||||
Tool answers that can be shown in God's Eye View include a view: camera, layers,
|
||||
|
||||
@@ -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`).
|
||||
@@ -0,0 +1,346 @@
|
||||
# God's Eye — Mobile
|
||||
|
||||
_The window through which you see the world, and the eye through which the world sees itself._
|
||||
|
||||
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`. |
|
||||
|
||||
---
|
||||
|
||||
## 1. The goal, rewritten
|
||||
|
||||
> Build the app people open **the moment something happens in the real world**, and the app they open **when nothing is happening** just to look.
|
||||
>
|
||||
> It must make 40+ live layers feel like one calm surface on a 6.9" phone, unfold into a two-pane instrument on a foldable, scrub the planet through time like a video, and let anyone with a phone add **signed, sensor-rich observations** that make reconstructions of real events harder to fake.
|
||||
|
||||
Five commitments fall out of that sentence:
|
||||
|
||||
1. **Situations, not layers.** The public does not think in feeds. They think in _things worth watching_: a cyclone, a launch, a flood, a chopper school, a stadium, an airport at rush hour. The home screen is a feed of situations. Layers are the ingredients a situation turns on for you.
|
||||
2. **Click _into_ the world, not to the next card.** Opening a card is a spatial transition: the card's thumbnail is the globe, and it expands into the live globe at that place, at that time, with that situation's lenses on. Closing it folds the world back into the card. No page loads, no white flashes.
|
||||
3. **Time is a first-class axis.** Every view has a scrubbable timeline (past ← now → forecast). Situations carry their own time window. Reconstructions are 4D: space × time × evidence.
|
||||
4. **Truth needs walls.** Captures from this app are cryptographically signed at the moment of capture and carry the full session: IMU trace, GPS, heading, timestamps, device and sensor metadata, a hash chain. Foundation **observations** are kept separate from the **hypotheses** people (and AI) layer on top, and every change is in an inspectable audit trail.
|
||||
5. **Cinematic by default, performant always.** Day and night blend across the terminator. High orbit gets the planetary treatment (cloudless day mosaic + night lights + atmosphere + bloom); close in, it becomes photorealistic 3D. The post chain is budgeted for a phone GPU, adapts to thermal state and battery, and drops to a still frame when nothing moves.
|
||||
|
||||
## 2. Information architecture
|
||||
|
||||
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. |
|
||||
|
||||
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.
|
||||
|
||||
## 3. Interaction model
|
||||
|
||||
- **Card → world (hero transition).** The tapped card's frame animates to full-screen while its static thumbnail cross-fades into the live globe already flying to the situation camera. The sheet rises to the medium detent with the situation brief. Swipe the sheet down or tap the back chevron to fold back to the card. Reduced Motion: cross-fade only.
|
||||
- **Sheets with detents** (peek ≈ 96 pt / medium ≈ 45% / large ≈ 92%). Peek shows the headline numbers; medium shows the brief and the evidence strip; large shows the full thread. Dragging the globe while the sheet is at medium collapses it to peek, so the world always wins.
|
||||
- **Lens dock.** 40+ layers live in eight families. The dock shows _family_ chips (Sky, Sea, Orbit, Hazards, Eyes, Ground, Weather, Infra); tapping a family opens a Control-Center-style grid of its layers with live counts. Long-press a family to solo it. The globe also curates by **semantic zoom**: at planetary altitude only aggregates render (density glows, cyclone cones, fire clusters); individual glyphs appear as you descend.
|
||||
- **Time ribbon.** A thin, haptic-detented scrubber above the tab bar: −72 h ← NOW → +120 h (forecast). Event pips sit on the ribbon (the quake, the launch, the flood crest). Dragging fast moves in hours; slow, in minutes. Tapping NOW snaps back to live with a single tick haptic. In Live the ribbon breathes.
|
||||
- **Pick anything.** Tap a contact on the globe: it's highlighted, a trail draws, and a compact card offers **Follow**, **Ride** (cockpit/bridge camera), **Nearest eye** (closest live camera), **Thread** (start or join a reconstruction).
|
||||
- **Foldables & large screens.** Width ≥ 600 pt switches to a two-pane layout: feed/thread list on the leading pane, globe on the trailing pane; selection in one drives the other. With a vertical hinge (`horizontal-viewport-segments: 2`) the panes split exactly at `env(viewport-segment-right 0 0)` so nothing renders under the fold. Half-folded (tabletop, `vertical-viewport-segments: 2`): globe on top, controls and timeline on the bottom half, like a laptop.
|
||||
- **One-handed reach.** All primary controls sit in the bottom 40% of the screen. Search lives in the tab bar row (iOS 26 pattern), not at the top.
|
||||
- **Haptics & sound.** `navigator.vibrate` where available for ribbon detents and capture seal; never decorative.
|
||||
|
||||
## 4. Visual language
|
||||
|
||||
- **Ground:** near-black with a blue bias (`#05070B`), never pure grey. The globe is the only light source; UI is glass over it.
|
||||
- **Glass:** `backdrop-filter: blur(24px) saturate(1.6)` panels with a 0.5 px inner highlight, 22–28 pt continuous corners, no drop shadows on glass.
|
||||
- **Signal colors** carry meaning and are reserved: Sky `#7CC4FF`, Sea `#38E1C6`, Orbit `#C9A7FF`, Hazards `#FF6A3D`, Eyes `#FFD166`, Ground `#9BE15D`, Weather `#8AB4F8`, Infra `#B8C0CC`. Live = a soft pulse; Stale = desaturated with a dotted outline; Signed = a seal glyph in warm gold.
|
||||
- **Type:** the system UI face (SF Pro / Roboto Flex via `system-ui`) for UI; tabular numerals for every live readout; a monospace for coordinates and hashes only. Large titles (34 pt) on Now and Threads, collapsing on scroll.
|
||||
- **Tufte rule for reconstructions:** maximize data-ink. Small multiples for before/after, sparklines in-line with text, direct labels instead of legends, uncertainty drawn (cones, bands, dotted where inferred), and every number carries its source and age.
|
||||
|
||||
## 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 |
|
||||
|
||||
## 6. Capture and provenance
|
||||
|
||||
A Witness capture produces a **sealed bundle**:
|
||||
|
||||
```
|
||||
bundle = {
|
||||
media: JPEG/WebM bytes → sha256
|
||||
session: { start, end, imu: [{t, α, β, γ, ax, ay, az}], gps: [{t, lat, lon, acc, alt}], heading }
|
||||
context: { device, ua, screen, timezone, network-time-offset }
|
||||
prev: sha256 of this device's previous bundle (hash chain)
|
||||
hash: sha256(canonical(media hash + session + context + prev))
|
||||
signature: ECDSA P-256 over hash, key generated on device, non-extractable, stored in IndexedDB
|
||||
}
|
||||
```
|
||||
|
||||
In a native build the key lives in the Secure Enclave / StrongBox and is bound to App Attest / Play Integrity, and the bundle is written as a C2PA manifest. The prototype does the browser-equivalent with WebCrypto so the flow, the data and the verification UI are real. Verification recomputes the hash, checks the signature against the device public key, and checks IMU/GPS plausibility (the motion trace must agree with the image's horizon and the compass heading; the GPS track must agree with the time). None of this proves truth. It raises the cost of lying.
|
||||
|
||||
## 7. Reconstructions (Threads)
|
||||
|
||||
A reconstruction is a document with three strata, always drawn in this order:
|
||||
|
||||
1. **Observations** — machine-sourced or signed: ADS-B/AIS tracks, satellite imagery, seismograph readings, signed captures. Immutable; each has source, time, hash.
|
||||
2. **Hypotheses** — claims that connect observations ("this clip was filmed from the north bank at 14:02", "the aircraft entered a hold at 2,100 ft"). Each has an author (person or AI), confidence, the observations it cites, and agreement/disagreement counts.
|
||||
3. **Narrative** — the story view: a camera path through space and time that cites hypotheses.
|
||||
|
||||
Collaborative AI prompting happens on stratum 2: a participant asks the model to refine ("align the witness clip to the corridor using the bridge as a landmark"). The model's output is a _proposed hypothesis_ with its prompt, model, inputs and diff recorded in the ledger. Nobody edits an observation; you can only cite, dispute, or supersede a hypothesis. The ledger is a hash chain anyone can export and verify.
|
||||
|
||||
## 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 |
|
||||
|
||||
## 9. Situations worth monitoring (the feed's genres)
|
||||
|
||||
Live today with the repo's feeds: tropical cyclone tracks · largest quake in 24 h · fire clusters in Europe/California · military air activity over a region · ISS pass over you · busiest airport now · helicopter school circuits (Fort Novosel) · launch countdowns with trajectory · Bhote Koshi flood reconstruction · ships queuing at Suez/Panama/Singapore · GNSS jamming hot spots · transit at rush hour.
|
||||
|
||||
Ten more to build: stadium/concert 4D reconstructions from crowd captures · storm chasers with signed dashcams · emergency squawk 7700 replays · air-show displays · marathon/route races with runner trackers · eclipse path watch parties · aurora nowcasts with sky captures · ferry crossings and harbour pilots · wildfire smoke vs. air quality vs. hospital load · building-climb or rooftop incidents reconstructed from ADS-B news helicopters + witness video · protest/march route documentation with signed captures · volcano webcams vs. ash advisories · delivery-drone corridors (Prime Air style corridor replays).
|
||||
|
||||
## 10. Prototype architecture
|
||||
|
||||
```
|
||||
mobile/
|
||||
index.html entry served at /mobile/ (CSP: no inline script)
|
||||
main.js boots the shell, lazy-loads the globe
|
||||
styles/ tokens.css, shell.css, feed.css, globe-ui.css, capture.css, threads.css
|
||||
globe/ Cesium engine for phones (owned by the globe agent)
|
||||
index.js createGlobe(container, options) → Globe API (below)
|
||||
imagery.js Esri day + Black Marble night blend, Google 3D tiles toggle
|
||||
post.js mobile post chain + adaptive quality governor
|
||||
layers/*.js live layers on top of the repo's /api routes
|
||||
situations.js builds situation cards from live feeds (+ bundled Nepal)
|
||||
ui/ shell, feed, sheet, lens dock, time ribbon, capture, threads, you
|
||||
capture/ camera, IMU, GPS, provenance (WebCrypto signing + verify)
|
||||
```
|
||||
|
||||
### Globe API contract
|
||||
|
||||
```js
|
||||
const globe = await createGlobe(containerEl, { quality: 'auto' });
|
||||
globe.flyTo({ lon, lat, height, heading?, pitch?, duration? }) → Promise
|
||||
globe.home() // planetary view
|
||||
globe.layers() → [{ id, family, name, on, count, status: 'live'|'stale'|'off'|'error' }]
|
||||
globe.setLayer(id, on)
|
||||
globe.setFamily(family, on)
|
||||
globe.setTime(date | null) // null = live
|
||||
globe.timeWindow() → { start, end, now }
|
||||
globe.setLens(id) // 'cinematic' | 'clear' | 'night' | 'thermal' | 'nvg'
|
||||
globe.focus(situation) // flies + applies situation.layers + time
|
||||
globe.select(id | null)
|
||||
globe.on(event, fn) / off // 'pick' {kind,id,title,subtitle,lon,lat,meta}
|
||||
// 'layers' (counts changed), 'camera' {lon,lat,height}
|
||||
// 'quality' {scale,fps}
|
||||
globe.pause() / resume() // tab hidden / capture open
|
||||
globe.contactsNear(lon, lat, km) → [{ kind, id, title, lon, lat, altM, headingDeg }] // for AR
|
||||
globe.situations() → Promise<Situation[]> // live feed cards
|
||||
```
|
||||
|
||||
`Situation = { id, kind, family, title, subtitle, place, lon, lat, camera:{lon,lat,height,heading,pitch}, layers:[ids], time?:{start,end}, metrics:[{label,value,unit}], thumbnail: url, live:boolean, updatedAt, source }`.
|
||||
|
||||
Thumbnails use the keyless Esri World Imagery `export` endpoint for the situation's bounding box, so every card shows the real place.
|
||||
|
||||
---
|
||||
|
||||
## 11. Round two: from weather app to God's Eye
|
||||
|
||||
Review notes on the first build: the feed showed one kind of picture (open ocean under a cyclone), the app read as a weather app, there was nowhere for people to talk, and capture stopped at a sealed photo. This section is the response. Where it disagrees with §1–§10, it wins.
|
||||
|
||||
### 11.1 Identity: space → sky → ground → inside
|
||||
|
||||
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` |
|
||||
|
||||
Visual rules:
|
||||
|
||||
- **Sensor framing.** Feed cards and the globe chrome use corner brackets, a hairline reticle, and a monospace telemetry strip burned into the image (coordinates, MGRS, UTC, sensor id, altitude). It is restrained: one strip per card, never a full military HUD.
|
||||
- **Type.** Display: the system face, tight and heavy. Telemetry: a monospace face (JetBrains Mono, which the desktop app already loads) in small caps with tabular numerals. The monospace face is the voice of the instruments, not of the UI.
|
||||
- **Color.** Ground stays near-black. Each altitude band has one signal hue: Orbit violet, Sky ice-blue, Ground amber, Inside warm gold for sealed. Hazard red is reserved for things that hurt people. No blue-gradient cards.
|
||||
- **Imagery is evidence.** Thumbnails are composited from the layers that actually describe the situation (§11.2). A picture of empty ocean is never acceptable.
|
||||
|
||||
### 11.2 The feed: compelling, diverse, real
|
||||
|
||||
The Now feed is the global equivalent of a neighborhood safety feed: it tells you what is happening, where, how close, and who saw it. Diversity is a requirement. The feed mixes **card archetypes** and **altitude bands**, and never puts two cards of the same archetype or band side by side when it can avoid it.
|
||||
|
||||
Card archetypes:
|
||||
|
||||
1. **Sensor card**: a composited frame. The base is the Esri image of the place or GIBS satellite IR. On top go the real data layers (radar, lightning strikes, cyclone cone and track, flight trails, quake rings, fire hotspots and smoke direction from wind), with sensor framing and telemetry burned in.
|
||||
2. **Eye card**: a real frame from the nearest public camera (`/api/cctv`), with a LIVE timestamp, camera id, and direction of view. This is the most Citizen-like card: you see the street.
|
||||
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.
|
||||
|
||||
Situation genres to source from keyless layers. Each card must say its source:
|
||||
|
||||
- Cyclones (NHC cone, track and wind radii, GIBS IR)
|
||||
- Severe-storm cells (radar plus lightning density)
|
||||
- Heavy rain over a major city (radar)
|
||||
- Jet-stream wind anomalies (GFS wind)
|
||||
- Quakes (USGS, with intensity rings)
|
||||
- Wildfires (FIRMS or EONET, with smoke direction from wind)
|
||||
- Emergency squawks (7500, 7600, 7700)
|
||||
- Military activity
|
||||
- Helicopter schools
|
||||
- Launches (pad and trajectory)
|
||||
- ISS and Starlink passes over the viewer
|
||||
- GNSS interference (`/api/gnss-integrity`)
|
||||
- Transit at rush hour (`/api/transit`)
|
||||
- Busiest airport arrivals
|
||||
- Live public cameras at famous places (Times Square, Shibuya, Tower Bridge where available, otherwise any busy feed)
|
||||
- Ship queues at chokepoints
|
||||
- The Bhote Koshi reconstruction
|
||||
|
||||
### 11.3 Discourse: every situation has a room
|
||||
|
||||
People will talk about events anyway, on other platforms and without the evidence. The room puts the conversation **on the evidence**: anchored in space and time, and graded by how it was observed.
|
||||
|
||||
- **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.
|
||||
|
||||
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.
|
||||
- **Reply with evidence.** "Reply" offers **Capture** first and text second. The best answer to "is the bridge still standing?" is a sealed photo from 200 m away.
|
||||
|
||||
### 11.4 Capture: contribute a place, an event, or a live stream
|
||||
|
||||
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. |
|
||||
|
||||
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.
|
||||
|
||||
Privacy: faces and plates are blurred before upload by on-device detection (a native-build commitment, and a disclosed limitation in the web prototype). Your exact location is shared at the precision you choose (exact, 100 m, 1 km), and the seal still proves the exact value to verifiers without publishing it.
|
||||
|
||||
---
|
||||
|
||||
## 12. Round three: Gotham for everyone
|
||||
|
||||
Founder's brief for this round: take what made Palantir Gotham work, fix what Citizen got wrong, and build the window people open every day and the first app they open when something happens. The time slider does far more. Communication feels like Twitch chat meets an X community meets Reddit. Real data wherever possible, more realistic everywhere else. It is demoed on a Mac in Chrome's device mode, as a phone and as a tablet.
|
||||
|
||||
> No longer should the audit trail for physical reality be the dominion of nation states. We have the right to know, with the receipts attached.
|
||||
|
||||
### 12.1 What we take from Gotham
|
||||
|
||||
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. |
|
||||
|
||||
**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.
|
||||
|
||||
### 12.2 What Citizen got wrong, and our incentives
|
||||
|
||||
Citizen grew on fear: an alert stream about crime near you, ranked by alarm, with little verification and a paid "Protect" upsell. Anxiety is the retention loop, rumor travels as fast as truth, and nobody is rewarded for being right.
|
||||
|
||||
Ours rewards **being right, early, with receipts**:
|
||||
|
||||
- **Signal, not followers.** A contributor's reputation (Signal) rises when their sealed captures are corroborated (cited by others, matched by sensor data such as ADS-B, radar or quakes) and falls when their claims are corrected. Signal weighs their votes and notes. Follower counts are not shown.
|
||||
- **First eyes.** The first sealed capture of an event gets a permanent byline on the record ("First eyes: @harbor_cam_kid, 14:02:11Z, 180 m"). This is the reason to open the app and capture when something happens.
|
||||
- **Need eyes.** Any room can request coverage at a place ("Is the 7th St bridge underwater?"). People nearby get one quiet ask; fulfilling it with a sealed capture earns Signal and the byline. This turns curiosity into coverage, which is the incentive structure Citizen never had.
|
||||
- **Calm by default.** Notifications are severity-gated and local, "Nothing unusual near you" is a valid state, and half the feed is wonder (the ISS overhead, a launch, aurora, a helicopter school) so the daily habit is curiosity, not dread.
|
||||
- **Ranking by evidence.** Sealed, corroborated, and close beats loud.
|
||||
|
||||
### 12.3 The daily loop
|
||||
|
||||
- **Today** (first tab) opens with a brief: "Your world · 7:42 AM": since you last looked (the three biggest changes), what's overhead tonight (ISS pass, launch window), your watched places and things, and **Today's Moment**, one 20-second 4D replay a day worth watching (a ritual, like a daily puzzle). Below the brief comes the situation feed (§11.2).
|
||||
- **Your sky now.** One tap shows what is above you this minute: aircraft, satellites, the ISS. This is the most delightful hook in the app.
|
||||
- **Watch.** Follow places (home, commute, a family member's city) and things (a tail number, a ship, the ISS). A followed thing's status rides in the tab-bar accessory.
|
||||
|
||||
### 12.4 When something happens: Moments
|
||||
|
||||
A **Moment** is a 4D reconstruction assembled around an event: tracks, sensor frames, sealed captures with their view cones, the chat, and the record, all on one clock.
|
||||
|
||||
- **Detected from real data.** When two or more aircraft circle the same point (news and police helicopters orbiting a scene), the engine raises a situation before any headline: "3 helicopters circling Midtown Manhattan". It also flags an emergency squawk, a cluster of quakes or a sudden lightning cell. These are real detections on live ADS-B and sensor feeds.
|
||||
- **Replayable.** Open a Moment and the time machine replays it: helicopter tracks with trails, captures popping in at their timestamps with view cones pointing at the subject, chat replaying at its pace, and posts appearing as they were made.
|
||||
- **Hero demo Moment: "Climbers on the Empire State Building".** An illustrative reconstruction with realistic synthetic tracks: two news helicopters and one police helicopter orbiting at 1,000–1,500 ft and radii of 400–700 m, with realistic speeds and turn rates. Witness captures from 34th St, 5th Ave and an office at 1 Penn are shown with their view cones, along with the chat and the record. It is clearly labelled illustrative. It shows exactly what the product does when this happens for real.
|
||||
|
||||
### 12.5 The time machine
|
||||
|
||||
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. 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.
|
||||
- **Clip.** Select a range, then "Clip this Moment", which creates a shareable Moment with its receipts.
|
||||
|
||||
### 12.6 Talking: Live and Record
|
||||
|
||||
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 |
|
||||
|
||||
- **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.
|
||||
- **Watch parties.** Opening a live situation drops you into its Live lane, with presence and the heart stream. That is the "hearts flying" feeling, everywhere.
|
||||
|
||||
### 12.7 Tabs
|
||||
|
||||
**Today · Live · Capture · Rooms · You**
|
||||
|
||||
- **Today**: the brief plus the feed.
|
||||
- **Live**: the globe, time machine and lenses.
|
||||
- **Capture**: Look · Witness · Live · Upload, plus Need eyes requests near you.
|
||||
- **Rooms**: live rooms, crews and Moments.
|
||||
- **You**: Signal, receipts, captures, watchlist and alert rules.
|
||||
|
||||
Search (the tab-bar search button) leads to dossiers.
|
||||
|
||||
### 12.8 Demo mode
|
||||
|
||||
The demo is presented in Chrome's device toolbar on a Mac:
|
||||
|
||||
- **Instant first frame.** The last good situations and thumbnails are cached (IndexedDB) and a bundled snapshot of real data ships with the app (`mobile/demo/`, with its capture time shown). The app paints in under a second, then hydrates live.
|
||||
- **Mouse and touch** work everywhere (drag sheets, scrub, pinch with trackpad).
|
||||
- **Director.** Press `.` (or triple-tap the logo) to open a presenter menu that jumps to the beats of the story: Today → Your sky now → the Empire State Moment → the Live lane with hearts → Need eyes → Capture → Dossier and link graph → the time machine → tablet layout. Each beat sets up its state so the demo never depends on luck.
|
||||
- **Honest labels.** Real data shows its source and age. Illustrative content (the hero Moment, simulated chat, example posts and crews) carries a small "Illustrative" or "Simulated" tag. A VC should see that the real parts are real.
|
||||
@@ -0,0 +1,109 @@
|
||||
/** Rear camera: preview, still photo (JPEG) and short video (MediaRecorder). */
|
||||
|
||||
export function createCamera(video) {
|
||||
let stream = null;
|
||||
let recorder = null;
|
||||
let chunks = [];
|
||||
|
||||
async function start() {
|
||||
if (stream) return stream;
|
||||
if (!navigator.mediaDevices?.getUserMedia)
|
||||
throw new Error('Camera API unavailable (needs https)');
|
||||
stream = await navigator.mediaDevices.getUserMedia({
|
||||
audio: false,
|
||||
video: {
|
||||
facingMode: { ideal: 'environment' },
|
||||
width: { ideal: 1920 },
|
||||
height: { ideal: 1440 },
|
||||
},
|
||||
});
|
||||
video.srcObject = stream;
|
||||
video.muted = true;
|
||||
video.playsInline = true;
|
||||
await video.play().catch(() => {});
|
||||
return stream;
|
||||
}
|
||||
|
||||
function stop() {
|
||||
if (recorder && recorder.state !== 'inactive') recorder.stop();
|
||||
stream?.getTracks().forEach((t) => t.stop());
|
||||
stream = null;
|
||||
video.srcObject = null;
|
||||
}
|
||||
|
||||
function info() {
|
||||
const t = stream?.getVideoTracks()[0];
|
||||
if (!t) return null;
|
||||
const s = t.getSettings?.() || {};
|
||||
return {
|
||||
label: t.label || 'camera',
|
||||
width: s.width,
|
||||
height: s.height,
|
||||
facingMode: s.facingMode || null,
|
||||
frameRate: s.frameRate ? Math.round(s.frameRate) : null,
|
||||
};
|
||||
}
|
||||
|
||||
/** Grab the current frame as a JPEG blob at the sensor's resolution. */
|
||||
async function photo() {
|
||||
const w = video.videoWidth;
|
||||
const h = video.videoHeight;
|
||||
if (!w || !h) throw new Error('Camera not ready');
|
||||
const c = document.createElement('canvas');
|
||||
c.width = w;
|
||||
c.height = h;
|
||||
c.getContext('2d').drawImage(video, 0, 0, w, h);
|
||||
return new Promise((resolve, reject) =>
|
||||
c.toBlob(
|
||||
(b) => (b ? resolve(b) : reject(new Error('Encode failed'))),
|
||||
'image/jpeg',
|
||||
0.92,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function canRecord() {
|
||||
return !!stream && typeof MediaRecorder !== 'undefined';
|
||||
}
|
||||
|
||||
function startVideo() {
|
||||
if (!canRecord()) throw new Error('Video recording unavailable');
|
||||
const types = [
|
||||
'video/mp4;codecs=avc1',
|
||||
'video/webm;codecs=vp9',
|
||||
'video/webm;codecs=vp8',
|
||||
'video/webm',
|
||||
'video/mp4',
|
||||
];
|
||||
const mimeType = types.find((t) => MediaRecorder.isTypeSupported?.(t));
|
||||
chunks = [];
|
||||
recorder = new MediaRecorder(
|
||||
stream,
|
||||
mimeType ? { mimeType, videoBitsPerSecond: 4e6 } : undefined,
|
||||
);
|
||||
recorder.ondataavailable = (e) => e.data.size && chunks.push(e.data);
|
||||
recorder.start(250);
|
||||
}
|
||||
|
||||
function stopVideo() {
|
||||
return new Promise((resolve) => {
|
||||
if (!recorder || recorder.state === 'inactive') return resolve(null);
|
||||
recorder.onstop = () =>
|
||||
resolve(new Blob(chunks, { type: recorder.mimeType || 'video/webm' }));
|
||||
recorder.stop();
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
start,
|
||||
stop,
|
||||
info,
|
||||
photo,
|
||||
startVideo,
|
||||
stopVideo,
|
||||
canRecord,
|
||||
get active() {
|
||||
return !!stream;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* Minimal promise wrapper over the device's IndexedDB: signing keys, sealed
|
||||
* captures (+ media), and round two's room posts, uploads and small state.
|
||||
*/
|
||||
|
||||
const DB_NAME = 'gev-mobile';
|
||||
const DB_VERSION = 2;
|
||||
let dbp = null;
|
||||
|
||||
export function openDb() {
|
||||
if (dbp) return dbp;
|
||||
dbp = new Promise((resolve, reject) => {
|
||||
if (!('indexedDB' in globalThis)) {
|
||||
reject(new Error('IndexedDB is unavailable'));
|
||||
return;
|
||||
}
|
||||
const req = indexedDB.open(DB_NAME, DB_VERSION);
|
||||
req.onupgradeneeded = () => {
|
||||
const db = req.result;
|
||||
if (!db.objectStoreNames.contains('keys'))
|
||||
db.createObjectStore('keys', { keyPath: 'id' });
|
||||
if (!db.objectStoreNames.contains('captures')) {
|
||||
const s = db.createObjectStore('captures', { keyPath: 'id' });
|
||||
s.createIndex('seq', 'seq');
|
||||
}
|
||||
if (!db.objectStoreNames.contains('media'))
|
||||
db.createObjectStore('media', { keyPath: 'id' });
|
||||
// v2: rooms and uploads.
|
||||
if (!db.objectStoreNames.contains('posts')) {
|
||||
const p = db.createObjectStore('posts', { keyPath: 'id' });
|
||||
p.createIndex('room', 'room');
|
||||
}
|
||||
if (!db.objectStoreNames.contains('uploads'))
|
||||
db.createObjectStore('uploads', { keyPath: 'id' });
|
||||
if (!db.objectStoreNames.contains('kv'))
|
||||
db.createObjectStore('kv', { keyPath: 'id' });
|
||||
};
|
||||
req.onsuccess = () => {
|
||||
const db = req.result;
|
||||
// Another tab upgraded the schema: let it, and reopen next time.
|
||||
db.onversionchange = () => {
|
||||
db.close();
|
||||
dbp = null;
|
||||
};
|
||||
resolve(db);
|
||||
};
|
||||
req.onerror = () => reject(req.error);
|
||||
req.onblocked = () => reject(new Error('IndexedDB upgrade blocked'));
|
||||
});
|
||||
dbp.catch(() => (dbp = null));
|
||||
return dbp;
|
||||
}
|
||||
|
||||
function wrap(req) {
|
||||
return new Promise((resolve, reject) => {
|
||||
req.onsuccess = () => resolve(req.result);
|
||||
req.onerror = () => reject(req.error);
|
||||
});
|
||||
}
|
||||
|
||||
export async function get(store, key) {
|
||||
const db = await openDb();
|
||||
return wrap(db.transaction(store).objectStore(store).get(key));
|
||||
}
|
||||
|
||||
export async function put(store, value) {
|
||||
const db = await openDb();
|
||||
return wrap(db.transaction(store, 'readwrite').objectStore(store).put(value));
|
||||
}
|
||||
|
||||
export async function del(store, key) {
|
||||
const db = await openDb();
|
||||
return wrap(
|
||||
db.transaction(store, 'readwrite').objectStore(store).delete(key),
|
||||
);
|
||||
}
|
||||
|
||||
export async function all(store) {
|
||||
const db = await openDb();
|
||||
return wrap(db.transaction(store).objectStore(store).getAll());
|
||||
}
|
||||
|
||||
/** Write a capture bundle and its media atomically. */
|
||||
export async function putCapture(bundle, mediaBlob) {
|
||||
const db = await openDb();
|
||||
return new Promise((resolve, reject) => {
|
||||
const tx = db.transaction(['captures', 'media'], 'readwrite');
|
||||
tx.objectStore('captures').put(bundle);
|
||||
tx.objectStore('media').put({ id: bundle.id, blob: mediaBlob });
|
||||
tx.oncomplete = () => resolve(bundle);
|
||||
tx.onerror = () => reject(tx.error);
|
||||
tx.onabort = () => reject(tx.error);
|
||||
});
|
||||
}
|
||||
|
||||
export async function lastCapture() {
|
||||
const db = await openDb();
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = db
|
||||
.transaction('captures')
|
||||
.objectStore('captures')
|
||||
.index('seq')
|
||||
.openCursor(null, 'prev');
|
||||
req.onsuccess = () => resolve(req.result?.value || null);
|
||||
req.onerror = () => reject(req.error);
|
||||
});
|
||||
}
|
||||
|
||||
export async function captureBySeq(seq) {
|
||||
const db = await openDb();
|
||||
return wrap(
|
||||
db.transaction('captures').objectStore('captures').index('seq').get(seq),
|
||||
);
|
||||
}
|
||||
|
||||
export async function byIndex(store, index, key) {
|
||||
const db = await openDb();
|
||||
return wrap(
|
||||
db.transaction(store).objectStore(store).index(index).getAll(key),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
/**
|
||||
* Tiny, dependency-free EXIF reader for JPEG: GPS position, altitude and
|
||||
* image direction, DateTimeOriginal (+ offset), orientation, make/model.
|
||||
* Returns null for anything that is not a JPEG with an Exif APP1 segment.
|
||||
*/
|
||||
|
||||
const TYPE_SIZE = { 1: 1, 2: 1, 3: 2, 4: 4, 5: 8, 7: 1, 9: 4, 10: 8 };
|
||||
|
||||
function readIfd(view, tiff, offset, le) {
|
||||
const out = {};
|
||||
if (offset + 2 > view.byteLength) return out;
|
||||
const n = view.getUint16(offset, le);
|
||||
for (let i = 0; i < n; i += 1) {
|
||||
const e = offset + 2 + i * 12;
|
||||
if (e + 12 > view.byteLength) break;
|
||||
const tag = view.getUint16(e, le);
|
||||
const type = view.getUint16(e + 2, le);
|
||||
const count = view.getUint32(e + 4, le);
|
||||
const size = (TYPE_SIZE[type] || 1) * count;
|
||||
const at = size > 4 ? tiff + view.getUint32(e + 8, le) : e + 8;
|
||||
if (at + size > view.byteLength) continue;
|
||||
out[tag] = readValue(view, at, type, count, le);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function readValue(view, at, type, count, le) {
|
||||
const one = (k) => {
|
||||
const p = at + k * (TYPE_SIZE[type] || 1);
|
||||
switch (type) {
|
||||
case 1:
|
||||
case 7:
|
||||
return view.getUint8(p);
|
||||
case 3:
|
||||
return view.getUint16(p, le);
|
||||
case 4:
|
||||
return view.getUint32(p, le);
|
||||
case 9:
|
||||
return view.getInt32(p, le);
|
||||
case 5: {
|
||||
const d = view.getUint32(p + 4, le);
|
||||
return d ? view.getUint32(p, le) / d : 0;
|
||||
}
|
||||
case 10: {
|
||||
const d = view.getInt32(p + 4, le);
|
||||
return d ? view.getInt32(p, le) / d : 0;
|
||||
}
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
if (type === 2) {
|
||||
let s = '';
|
||||
for (let k = 0; k < count; k += 1) {
|
||||
const c = view.getUint8(at + k);
|
||||
if (!c) break;
|
||||
s += String.fromCharCode(c);
|
||||
}
|
||||
return s.trim();
|
||||
}
|
||||
if (count === 1) return one(0);
|
||||
return Array.from({ length: count }, (_, k) => one(k));
|
||||
}
|
||||
|
||||
const dms = (v) =>
|
||||
Array.isArray(v) ? v[0] + (v[1] || 0) / 60 + (v[2] || 0) / 3600 : Number(v);
|
||||
|
||||
/** "2026:10:18 20:53:37" (+ "-05:00") → ms epoch; local time if no offset. */
|
||||
function exifDate(s, offset) {
|
||||
const m = /^(\d{4}):(\d{2}):(\d{2})[ T](\d{2}):(\d{2}):(\d{2})/.exec(s || '');
|
||||
if (!m) return null;
|
||||
const iso = `${m[1]}-${m[2]}-${m[3]}T${m[4]}:${m[5]}:${m[6]}${offset && /^[+-]\d{2}:\d{2}$/.test(offset) ? offset : ''}`;
|
||||
const t = Date.parse(iso);
|
||||
return Number.isFinite(t) ? t : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Blob|ArrayBuffer} input
|
||||
* @returns {Promise<null | { lat?, lon?, altM?, headingDeg?, takenAt?, takenAtHasZone?, orientation?, make?, model?, raw }>}
|
||||
*/
|
||||
export async function readExif(input) {
|
||||
const buf =
|
||||
input instanceof Blob
|
||||
? await input.slice(0, 256 * 1024).arrayBuffer()
|
||||
: input;
|
||||
const view = new DataView(buf);
|
||||
if (view.byteLength < 4 || view.getUint16(0) !== 0xffd8) return null;
|
||||
let p = 2;
|
||||
while (p + 4 <= view.byteLength) {
|
||||
if (view.getUint8(p) !== 0xff) return null;
|
||||
const marker = view.getUint8(p + 1);
|
||||
const len = view.getUint16(p + 2);
|
||||
if (marker === 0xda || marker === 0xd9) return null; // start of scan: no EXIF before it
|
||||
if (
|
||||
marker === 0xe1 &&
|
||||
p + 10 <= view.byteLength &&
|
||||
view.getUint32(p + 4) === 0x45786966
|
||||
) {
|
||||
const tiff = p + 10;
|
||||
const le = view.getUint16(tiff) === 0x4949;
|
||||
if (view.getUint16(tiff + 2, le) !== 42) return null;
|
||||
const ifd0 = readIfd(view, tiff, tiff + view.getUint32(tiff + 4, le), le);
|
||||
const exif = ifd0[0x8769]
|
||||
? readIfd(view, tiff, tiff + ifd0[0x8769], le)
|
||||
: {};
|
||||
const gps = ifd0[0x8825]
|
||||
? readIfd(view, tiff, tiff + ifd0[0x8825], le)
|
||||
: {};
|
||||
const out = { raw: { ifd0, exif, gps } };
|
||||
if (gps[2] != null && gps[4] != null) {
|
||||
let lat = dms(gps[2]);
|
||||
let lon = dms(gps[4]);
|
||||
if (String(gps[1]).toUpperCase() === 'S') lat = -lat;
|
||||
if (String(gps[3]).toUpperCase() === 'W') lon = -lon;
|
||||
if (
|
||||
Number.isFinite(lat) &&
|
||||
Number.isFinite(lon) &&
|
||||
Math.abs(lat) <= 90 &&
|
||||
Math.abs(lon) <= 180 &&
|
||||
(lat || lon)
|
||||
) {
|
||||
out.lat = lat;
|
||||
out.lon = lon;
|
||||
}
|
||||
}
|
||||
if (gps[6] != null) out.altM = (gps[5] === 1 ? -1 : 1) * Number(gps[6]);
|
||||
if (gps[0x11] != null) out.headingDeg = Number(gps[0x11]);
|
||||
const offset = exif[0x9011] || exif[0x9010];
|
||||
const taken = exifDate(
|
||||
exif[0x9003] || exif[0x9004] || ifd0[0x0132],
|
||||
offset,
|
||||
);
|
||||
if (taken) {
|
||||
out.takenAt = taken;
|
||||
out.takenAtHasZone = !!offset;
|
||||
}
|
||||
if (ifd0[0x0112]) out.orientation = ifd0[0x0112];
|
||||
if (ifd0[0x010f]) out.make = ifd0[0x010f];
|
||||
if (ifd0[0x0110]) out.model = ifd0[0x0110];
|
||||
return out;
|
||||
}
|
||||
p += 2 + len;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
/**
|
||||
* Live broadcast session (DESIGN.md §11.4 "Live"): MediaRecorder timeslice
|
||||
* chunks are hashed and sealed every 10 s into a hash chain of segments,
|
||||
* each carrying the pose and position at that moment. Stopping returns the
|
||||
* whole recording, the chain and the sensor session, ready for `seal()`.
|
||||
*
|
||||
* Segment i covers bytes [offset, offset + bytes) of the final file, so a
|
||||
* verifier can re-split the file and recompute every link.
|
||||
*/
|
||||
|
||||
import { sha256Hex, canonical } from './provenance.js';
|
||||
|
||||
const TYPES = [
|
||||
'video/webm;codecs=vp9',
|
||||
'video/webm;codecs=vp8',
|
||||
'video/webm',
|
||||
'video/mp4;codecs=avc1',
|
||||
'video/mp4',
|
||||
];
|
||||
|
||||
export function canBroadcast(stream) {
|
||||
return !!stream && typeof MediaRecorder !== 'undefined';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{ stream: MediaStream, sensors: object, pose: () => object,
|
||||
* fix: () => object|null, segmentMs?: number, onSegment?: (seg) => void }} o
|
||||
*/
|
||||
export function createLiveSession(o) {
|
||||
const segmentMs = o.segmentMs || 10000;
|
||||
const mimeType = TYPES.find((t) => MediaRecorder.isTypeSupported?.(t));
|
||||
const chunks = [];
|
||||
let pending = [];
|
||||
const segments = [];
|
||||
let recorder = null;
|
||||
let timer = 0;
|
||||
let prev = null;
|
||||
let offset = 0;
|
||||
let t0 = 0;
|
||||
let segT0 = 0;
|
||||
let sealing = Promise.resolve();
|
||||
|
||||
async function sealSegment() {
|
||||
const parts = pending;
|
||||
pending = [];
|
||||
if (!parts.length) return null;
|
||||
const blob = new Blob(parts, { type: mimeType || 'video/webm' });
|
||||
const t1 = Date.now();
|
||||
const p = o.pose?.() || {};
|
||||
const f = o.fix?.() || null;
|
||||
const seg = {
|
||||
i: segments.length,
|
||||
t0: segT0,
|
||||
t1,
|
||||
offset,
|
||||
bytes: blob.size,
|
||||
media: await sha256Hex(blob),
|
||||
pose: {
|
||||
headingDeg: Number.isFinite(p.az) ? Math.round(p.az * 10) / 10 : null,
|
||||
pitchDeg: Number.isFinite(p.el) ? Math.round(p.el * 10) / 10 : null,
|
||||
},
|
||||
gps: f
|
||||
? {
|
||||
lat: +f.lat.toFixed(6),
|
||||
lon: +f.lon.toFixed(6),
|
||||
acc: Math.round(f.acc || 0),
|
||||
}
|
||||
: null,
|
||||
prev,
|
||||
};
|
||||
seg.hash = await sha256Hex(canonical(seg));
|
||||
prev = seg.hash;
|
||||
offset += blob.size;
|
||||
segT0 = t1;
|
||||
segments.push(seg);
|
||||
o.onSegment?.(seg);
|
||||
return seg;
|
||||
}
|
||||
|
||||
function start() {
|
||||
recorder = new MediaRecorder(
|
||||
o.stream,
|
||||
mimeType ? { mimeType, videoBitsPerSecond: 2.5e6 } : undefined,
|
||||
);
|
||||
recorder.ondataavailable = (e) => {
|
||||
if (!e.data?.size) return;
|
||||
chunks.push(e.data);
|
||||
pending.push(e.data);
|
||||
};
|
||||
recorder.start(1000);
|
||||
t0 = Date.now();
|
||||
segT0 = t0;
|
||||
o.sensors?.beginSession?.();
|
||||
timer = setInterval(() => {
|
||||
sealing = sealing
|
||||
.then(sealSegment)
|
||||
.catch((err) => console.warn('segment', err));
|
||||
}, segmentMs);
|
||||
}
|
||||
|
||||
async function stop() {
|
||||
clearInterval(timer);
|
||||
if (recorder && recorder.state !== 'inactive')
|
||||
await new Promise((resolve) => {
|
||||
recorder.onstop = resolve;
|
||||
recorder.stop();
|
||||
});
|
||||
await sealing;
|
||||
await sealSegment();
|
||||
const session = o.sensors?.endSession?.() || {
|
||||
start: new Date(t0).toISOString(),
|
||||
end: new Date().toISOString(),
|
||||
imu: [],
|
||||
gps: [],
|
||||
heading: null,
|
||||
};
|
||||
return {
|
||||
blob: new Blob(chunks, { type: mimeType || 'video/webm' }),
|
||||
segments,
|
||||
session,
|
||||
startedAt: t0,
|
||||
endedAt: Date.now(),
|
||||
mimeType: mimeType || 'video/webm',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
start,
|
||||
stop,
|
||||
get segments() {
|
||||
return segments;
|
||||
},
|
||||
get startedAt() {
|
||||
return t0;
|
||||
},
|
||||
get active() {
|
||||
return !!recorder && recorder.state === 'recording';
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Re-split a live file at the sealed offsets and recompute the chain. */
|
||||
export async function verifySegments(blob, segments) {
|
||||
let prev = null;
|
||||
for (const s of segments) {
|
||||
const part = blob.slice(s.offset, s.offset + s.bytes);
|
||||
const media = await sha256Hex(part);
|
||||
const { hash, ...core } = s;
|
||||
const h = await sha256Hex(canonical(core));
|
||||
if (media !== s.media || h !== hash || s.prev !== prev)
|
||||
return { ok: false, at: s.i };
|
||||
prev = hash;
|
||||
}
|
||||
return { ok: true, n: segments.length, head: prev };
|
||||
}
|
||||
@@ -0,0 +1,384 @@
|
||||
/**
|
||||
* Witness provenance (DESIGN.md §6), the browser equivalent of a Secure
|
||||
* Enclave + C2PA flow:
|
||||
*
|
||||
* media → sha256
|
||||
* session { start, end, imu:[{t,α,β,γ,ax,ay,az}], gps:[{t,lat,lon,acc,alt}], heading }
|
||||
* context { device, ua, screen, timezone, network-time-offset, … }
|
||||
* prev hash of this device's previous bundle (hash chain)
|
||||
* hash sha256(canonical(core)) core = everything above + seq, key id, public key, sealedAt
|
||||
* signature ECDSA P-256 / SHA-256 over the hash bytes, with a device key that
|
||||
* is generated non-extractable and kept in IndexedDB.
|
||||
*
|
||||
* None of this proves truth. It raises the cost of lying.
|
||||
*/
|
||||
|
||||
import { get, put, putCapture, lastCapture, captureBySeq } from './db.js';
|
||||
|
||||
const subtle = globalThis.crypto?.subtle;
|
||||
const enc = new TextEncoder();
|
||||
const ALG = { name: 'ECDSA', namedCurve: 'P-256' };
|
||||
const SIGN = { name: 'ECDSA', hash: 'SHA-256' };
|
||||
|
||||
/** Deterministic JSON: sorted keys, no whitespace, JSON number semantics. */
|
||||
export function canonical(value) {
|
||||
if (value === null || typeof value !== 'object') {
|
||||
if (typeof value === 'number' && !Number.isFinite(value)) return 'null';
|
||||
return JSON.stringify(value ?? null);
|
||||
}
|
||||
if (Array.isArray(value))
|
||||
return `[${value.map((v) => (v === undefined ? 'null' : canonical(v))).join(',')}]`;
|
||||
const keys = Object.keys(value)
|
||||
.filter((k) => value[k] !== undefined && typeof value[k] !== 'function')
|
||||
.sort();
|
||||
return `{${keys.map((k) => `${JSON.stringify(k)}:${canonical(value[k])}`).join(',')}}`;
|
||||
}
|
||||
|
||||
const normalize = (v) => JSON.parse(canonical(v));
|
||||
|
||||
export const toHex = (buf) =>
|
||||
[...new Uint8Array(buf)].map((b) => b.toString(16).padStart(2, '0')).join('');
|
||||
const fromHex = (hex) =>
|
||||
new Uint8Array(hex.match(/../g).map((b) => parseInt(b, 16)));
|
||||
const b64url = (buf) =>
|
||||
btoa(String.fromCharCode(...new Uint8Array(buf)))
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_')
|
||||
.replace(/=+$/, '');
|
||||
const fromB64url = (s) => {
|
||||
const b = atob(
|
||||
s.replace(/-/g, '+').replace(/_/g, '/') + '==='.slice((s.length + 3) % 4),
|
||||
);
|
||||
return Uint8Array.from(b, (c) => c.charCodeAt(0));
|
||||
};
|
||||
|
||||
export async function sha256Hex(data) {
|
||||
const bytes =
|
||||
typeof data === 'string'
|
||||
? enc.encode(data)
|
||||
: data instanceof Blob
|
||||
? await data.arrayBuffer()
|
||||
: data;
|
||||
return toHex(await subtle.digest('SHA-256', bytes));
|
||||
}
|
||||
|
||||
function assertCrypto() {
|
||||
if (!subtle)
|
||||
throw new Error('WebCrypto needs a secure context (https or localhost)');
|
||||
}
|
||||
|
||||
/** The device signing key: generated once, private half non-extractable. */
|
||||
export async function deviceKey() {
|
||||
assertCrypto();
|
||||
const stored = await get('keys', 'device').catch(() => null);
|
||||
if (stored?.privateKey && stored?.publicJwk) return stored;
|
||||
const pair = await subtle.generateKey(ALG, false, ['sign', 'verify']);
|
||||
const jwk = await subtle.exportKey('jwk', pair.publicKey);
|
||||
const publicJwk = { crv: jwk.crv, kty: jwk.kty, x: jwk.x, y: jwk.y };
|
||||
const keyId = (await sha256Hex(canonical(publicJwk))).slice(0, 16);
|
||||
const rec = {
|
||||
id: 'device',
|
||||
privateKey: pair.privateKey,
|
||||
publicKey: pair.publicKey,
|
||||
publicJwk,
|
||||
keyId,
|
||||
createdAt: new Date().toISOString(),
|
||||
extractable: pair.privateKey.extractable,
|
||||
};
|
||||
await put('keys', rec);
|
||||
return rec;
|
||||
}
|
||||
|
||||
/** Estimate the device clock's offset from the server's Date header (±1 s). */
|
||||
export async function networkTimeOffset() {
|
||||
try {
|
||||
const t0 = Date.now();
|
||||
const r = await fetch(location.pathname, {
|
||||
method: 'HEAD',
|
||||
cache: 'no-store',
|
||||
});
|
||||
const t1 = Date.now();
|
||||
const d = Date.parse(r.headers.get('date') || '');
|
||||
if (!Number.isFinite(d)) return null;
|
||||
return Math.round(d + 500 - (t0 + t1) / 2);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Seal a capture.
|
||||
* @param {{ media: Blob, session: object, context: object }} input
|
||||
* @returns {Promise<object>} the stored bundle
|
||||
*/
|
||||
export async function seal({ media, session, context }) {
|
||||
assertCrypto();
|
||||
const key = await deviceKey();
|
||||
const mediaHash = await sha256Hex(media);
|
||||
const last = await lastCapture().catch(() => null);
|
||||
const seq = (last?.seq || 0) + 1;
|
||||
const core = normalize({
|
||||
v: 1,
|
||||
seq,
|
||||
media: {
|
||||
sha256: mediaHash,
|
||||
type: media.type || 'application/octet-stream',
|
||||
bytes: media.size,
|
||||
},
|
||||
session,
|
||||
context,
|
||||
prev: last?.hash || null,
|
||||
keyId: key.keyId,
|
||||
publicKey: key.publicJwk,
|
||||
sealedAt: new Date().toISOString(),
|
||||
});
|
||||
const hash = await sha256Hex(canonical(core));
|
||||
const sig = await subtle.sign(SIGN, key.privateKey, fromHex(hash));
|
||||
const bundle = {
|
||||
...core,
|
||||
id: `cap-${String(seq).padStart(4, '0')}-${hash.slice(0, 8)}`,
|
||||
hash,
|
||||
alg: 'ES256',
|
||||
signature: b64url(sig),
|
||||
};
|
||||
await putCapture(bundle, media);
|
||||
return bundle;
|
||||
}
|
||||
|
||||
const median = (a) => {
|
||||
if (!a.length) return NaN;
|
||||
const s = [...a].sort((x, y) => x - y);
|
||||
return s[Math.floor(s.length / 2)];
|
||||
};
|
||||
|
||||
/** Core fields covered by the hash (everything but the outputs). */
|
||||
function coreOf(bundle) {
|
||||
const { id, hash, alg, signature, ...core } = bundle;
|
||||
void id;
|
||||
void hash;
|
||||
void alg;
|
||||
void signature;
|
||||
return core;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify a bundle: hash, signature, chain, sensor plausibility.
|
||||
* @returns {Promise<{ ok: boolean, checks: Array<{id,label,status,detail}> }>}
|
||||
*/
|
||||
export async function verify(bundle, mediaBlob) {
|
||||
assertCrypto();
|
||||
const checks = [];
|
||||
const add = (id, label, status, detail) =>
|
||||
checks.push({ id, label, status, detail });
|
||||
|
||||
// 1. Media hash
|
||||
if (mediaBlob) {
|
||||
const h = await sha256Hex(mediaBlob);
|
||||
add(
|
||||
'media',
|
||||
'Media hash',
|
||||
h === bundle.media?.sha256 ? 'pass' : 'fail',
|
||||
h === bundle.media?.sha256
|
||||
? `sha256 ${h.slice(0, 16)}… matches ${bundle.media.bytes.toLocaleString()} bytes`
|
||||
: `recomputed ${h.slice(0, 16)}… ≠ sealed ${String(bundle.media?.sha256).slice(0, 16)}…`,
|
||||
);
|
||||
} else
|
||||
add('media', 'Media hash', 'warn', 'Media not available on this device');
|
||||
|
||||
// 2. Bundle hash
|
||||
const recomputed = await sha256Hex(canonical(coreOf(bundle)));
|
||||
const hashOk = recomputed === bundle.hash;
|
||||
add(
|
||||
'hash',
|
||||
'Bundle hash',
|
||||
hashOk ? 'pass' : 'fail',
|
||||
hashOk
|
||||
? `sha256(canonical(bundle)) = ${recomputed.slice(0, 16)}…`
|
||||
: 'Bundle was modified after sealing',
|
||||
);
|
||||
|
||||
// 3. Signature
|
||||
try {
|
||||
const pub = await subtle.importKey(
|
||||
'jwk',
|
||||
{ ...bundle.publicKey, ext: true },
|
||||
ALG,
|
||||
true,
|
||||
['verify'],
|
||||
);
|
||||
const ok = await subtle.verify(
|
||||
SIGN,
|
||||
pub,
|
||||
fromB64url(bundle.signature),
|
||||
fromHex(bundle.hash),
|
||||
);
|
||||
const mine = await deviceKey().catch(() => null);
|
||||
const same = mine?.keyId === bundle.keyId;
|
||||
add(
|
||||
'signature',
|
||||
'Signature',
|
||||
ok ? (same ? 'pass' : 'warn') : 'fail',
|
||||
ok
|
||||
? `ECDSA P-256 valid · key ${bundle.keyId}${same ? ' (this device)' : ' (another device)'}`
|
||||
: 'Signature does not verify against the embedded public key',
|
||||
);
|
||||
} catch (err) {
|
||||
add('signature', 'Signature', 'fail', `Could not verify: ${err.message}`);
|
||||
}
|
||||
|
||||
// 4. Chain
|
||||
if (bundle.seq === 1)
|
||||
add(
|
||||
'chain',
|
||||
'Hash chain',
|
||||
bundle.prev === null ? 'pass' : 'fail',
|
||||
bundle.prev === null
|
||||
? 'Genesis capture for this device key'
|
||||
: 'First capture claims a predecessor',
|
||||
);
|
||||
else {
|
||||
const prev = await captureBySeq(bundle.seq - 1).catch(() => null);
|
||||
if (!prev)
|
||||
add(
|
||||
'chain',
|
||||
'Hash chain',
|
||||
'warn',
|
||||
`Capture #${bundle.seq - 1} is not on this device`,
|
||||
);
|
||||
else if (prev.hash === bundle.prev && prev.keyId === bundle.keyId)
|
||||
add(
|
||||
'chain',
|
||||
'Hash chain',
|
||||
'pass',
|
||||
`#${bundle.seq} ← #${prev.seq} ${prev.hash.slice(0, 12)}…`,
|
||||
);
|
||||
else add('chain', 'Hash chain', 'fail', `Link to #${prev.seq} is broken`);
|
||||
}
|
||||
|
||||
// 5. IMU plausibility
|
||||
const imu = bundle.session?.imu || [];
|
||||
if (imu.length < 5)
|
||||
add(
|
||||
'imu',
|
||||
'Motion trace',
|
||||
'warn',
|
||||
imu.length
|
||||
? `Only ${imu.length} samples`
|
||||
: 'No motion sensors were available',
|
||||
);
|
||||
else {
|
||||
const dur = (imu[imu.length - 1].t - imu[0].t) / 1000;
|
||||
const rate = imu.length / Math.max(0.001, dur);
|
||||
let mono = true;
|
||||
for (let i = 1; i < imu.length; i += 1)
|
||||
if (imu[i].t < imu[i - 1].t) mono = false;
|
||||
const mags = imu
|
||||
.filter((s) => Number.isFinite(s.ax))
|
||||
.map((s) => Math.hypot(s.ax, s.ay, s.az));
|
||||
const g = median(mags);
|
||||
const mean = mags.reduce((a, b) => a + b, 0) / Math.max(1, mags.length);
|
||||
const sd = Math.sqrt(
|
||||
mags.reduce((a, b) => a + (b - mean) ** 2, 0) / Math.max(1, mags.length),
|
||||
);
|
||||
const gravityOk = Number.isFinite(g) ? Math.abs(g - 9.81) < 1.6 : null;
|
||||
const alive = sd > 0.004;
|
||||
// Pitch from gravity should agree with the orientation β.
|
||||
let tiltAgree = null;
|
||||
const withBoth = imu.filter(
|
||||
(s) =>
|
||||
Number.isFinite(s.β) && Number.isFinite(s.ay) && Number.isFinite(s.az),
|
||||
);
|
||||
if (withBoth.length > 4) {
|
||||
// iOS reports gravity with the opposite sign to the W3C spec; accept either.
|
||||
const d = (a, b) => Math.abs(((a - b + 540) % 360) - 180);
|
||||
const diffs = withBoth.map((s) => {
|
||||
const pitchG = (Math.atan2(s.ay, s.az) * 180) / Math.PI;
|
||||
const flipped = (Math.atan2(-s.ay, -s.az) * 180) / Math.PI;
|
||||
return Math.min(d(pitchG, s.β), d(flipped, s.β));
|
||||
});
|
||||
tiltAgree = median(diffs) < 20;
|
||||
}
|
||||
const status =
|
||||
!mono || gravityOk === false
|
||||
? 'fail'
|
||||
: alive && tiltAgree !== false
|
||||
? 'pass'
|
||||
: 'warn';
|
||||
add(
|
||||
'imu',
|
||||
'Motion trace',
|
||||
status,
|
||||
`${imu.length} samples · ${rate.toFixed(0)} Hz · ${dur.toFixed(1)} s · |g| ${Number.isFinite(g) ? g.toFixed(2) : '—'} m/s²${alive ? ' · hand tremor present' : ' · suspiciously still'}${tiltAgree === null ? '' : tiltAgree ? ' · tilt agrees with gravity' : ' · tilt disagrees with gravity'}`,
|
||||
);
|
||||
}
|
||||
|
||||
// 6. GPS plausibility
|
||||
const gps = bundle.session?.gps || [];
|
||||
if (!gps.length) add('gps', 'Location', 'warn', 'No GPS fix during capture');
|
||||
else {
|
||||
const s0 = Date.parse(bundle.session.start);
|
||||
const s1 = Date.parse(bundle.session.end);
|
||||
const inWindow = gps.every((f) => f.t >= s0 - 120e3 && f.t <= s1 + 120e3);
|
||||
let maxSpeed = 0;
|
||||
for (let i = 1; i < gps.length; i += 1) {
|
||||
const a = gps[i - 1];
|
||||
const b = gps[i];
|
||||
const dt = (b.t - a.t) / 1000;
|
||||
if (dt > 0.5) {
|
||||
const R = 6371e3;
|
||||
const dLat = ((b.lat - a.lat) * Math.PI) / 180;
|
||||
const dLon = ((b.lon - a.lon) * Math.PI) / 180;
|
||||
const x =
|
||||
Math.sin(dLat / 2) ** 2 +
|
||||
Math.cos((a.lat * Math.PI) / 180) *
|
||||
Math.cos((b.lat * Math.PI) / 180) *
|
||||
Math.sin(dLon / 2) ** 2;
|
||||
maxSpeed = Math.max(maxSpeed, (2 * R * Math.asin(Math.sqrt(x))) / dt);
|
||||
}
|
||||
}
|
||||
const acc = median(gps.map((f) => f.acc).filter(Number.isFinite));
|
||||
const last = gps[gps.length - 1];
|
||||
// The clock's timezone should be roughly where the fix says we are.
|
||||
const tzH = -(bundle.context?.tzOffsetMin ?? 0) / 60;
|
||||
const solarH = last.lon / 15;
|
||||
const tzOk = Math.abs(((tzH - solarH + 36) % 24) - 12) < 3.5;
|
||||
const speedOk = maxSpeed < 300;
|
||||
const status =
|
||||
!inWindow || !speedOk ? 'fail' : tzOk && acc < 500 ? 'pass' : 'warn';
|
||||
add(
|
||||
'gps',
|
||||
'Location',
|
||||
status,
|
||||
`${gps.length} fix${gps.length > 1 ? 'es' : ''} · ±${Number.isFinite(acc) ? Math.round(acc) : '—'} m · max ${maxSpeed.toFixed(1)} m/s${inWindow ? ' · within session' : ' · outside session window'}${tzOk ? ' · clock zone agrees with longitude' : ' · clock zone disagrees with longitude'}`,
|
||||
);
|
||||
}
|
||||
|
||||
// 7. Heading
|
||||
const hd = bundle.session?.heading;
|
||||
if (hd && Number.isFinite(hd.deg))
|
||||
add(
|
||||
'heading',
|
||||
'Heading',
|
||||
hd.source === 'compass' || hd.source === 'absolute' ? 'pass' : 'warn',
|
||||
`${Math.round(hd.deg)}° (${hd.source})${Number.isFinite(hd.pitch) ? ` · camera pitch ${hd.pitch.toFixed(0)}°` : ''}`,
|
||||
);
|
||||
else add('heading', 'Heading', 'warn', 'No compass heading recorded');
|
||||
|
||||
// 8. Clock
|
||||
const off = bundle.context?.networkTimeOffsetMs;
|
||||
const sealedVsEnd =
|
||||
Date.parse(bundle.sealedAt) - Date.parse(bundle.session?.end);
|
||||
const clockOk = Number.isFinite(off) ? Math.abs(off) < 5000 : null;
|
||||
add(
|
||||
'clock',
|
||||
'Clock',
|
||||
clockOk === false || sealedVsEnd > 60e3 || sealedVsEnd < -2000
|
||||
? 'warn'
|
||||
: clockOk
|
||||
? 'pass'
|
||||
: 'warn',
|
||||
`${Number.isFinite(off) ? `device vs network ${off >= 0 ? '+' : ''}${(off / 1000).toFixed(1)} s` : 'network time unavailable'} · sealed ${(sealedVsEnd / 1000).toFixed(1)} s after capture`,
|
||||
);
|
||||
|
||||
return { ok: !checks.some((c) => c.status === 'fail'), checks };
|
||||
}
|
||||
@@ -0,0 +1,371 @@
|
||||
/**
|
||||
* Device sensors for Look and Witness: motion/orientation (with iOS
|
||||
* permission on a user gesture), compass heading, camera pose, GPS watch,
|
||||
* and a session recorder for the sealed IMU trace.
|
||||
*/
|
||||
|
||||
const D2R = Math.PI / 180;
|
||||
const R2D = 180 / Math.PI;
|
||||
|
||||
/** iOS 13+: must be called from a user gesture. */
|
||||
export async function requestMotionPermission() {
|
||||
const asks = [];
|
||||
if (
|
||||
typeof DeviceOrientationEvent !== 'undefined' &&
|
||||
typeof DeviceOrientationEvent.requestPermission === 'function'
|
||||
)
|
||||
asks.push(DeviceOrientationEvent.requestPermission());
|
||||
if (
|
||||
typeof DeviceMotionEvent !== 'undefined' &&
|
||||
typeof DeviceMotionEvent.requestPermission === 'function'
|
||||
)
|
||||
asks.push(DeviceMotionEvent.requestPermission());
|
||||
if (!asks.length)
|
||||
return typeof DeviceOrientationEvent === 'undefined'
|
||||
? 'unsupported'
|
||||
: 'granted';
|
||||
try {
|
||||
const res = await Promise.all(asks);
|
||||
return res.every((r) => r === 'granted') ? 'granted' : 'denied';
|
||||
} catch {
|
||||
return 'denied';
|
||||
}
|
||||
}
|
||||
|
||||
/** W3C Z-X'-Y'' rotation matrix (device → East-North-Up). */
|
||||
export function rotation(alpha, beta, gamma) {
|
||||
const a = alpha * D2R;
|
||||
const b = beta * D2R;
|
||||
const g = gamma * D2R;
|
||||
const cA = Math.cos(a);
|
||||
const sA = Math.sin(a);
|
||||
const cB = Math.cos(b);
|
||||
const sB = Math.sin(b);
|
||||
const cG = Math.cos(g);
|
||||
const sG = Math.sin(g);
|
||||
return [
|
||||
[cA * cG - sA * sB * sG, -cB * sA, cA * sG + cG * sA * sB],
|
||||
[cG * sA + cA * sB * sG, cA * cB, sA * sG - cA * cG * sB],
|
||||
[-cB * sG, sB, cB * cG],
|
||||
];
|
||||
}
|
||||
|
||||
/** Back-camera pose in ENU: forward / right / up unit vectors + azimuth/elevation. */
|
||||
export function cameraPose(alpha, beta, gamma, screenAngle = 0) {
|
||||
const R = rotation(alpha, beta, gamma);
|
||||
const col = (i) => [R[0][i], R[1][i], R[2][i]];
|
||||
const fwd = col(2).map((v) => -v);
|
||||
let right = col(0);
|
||||
let up = col(1);
|
||||
if (screenAngle) {
|
||||
// Landscape: rotate the screen basis about the forward axis.
|
||||
const s = screenAngle * D2R;
|
||||
const r2 = right.map((v, i) => v * Math.cos(s) + up[i] * Math.sin(s));
|
||||
const u2 = up.map((v, i) => v * Math.cos(s) - right[i] * Math.sin(s));
|
||||
right = r2;
|
||||
up = u2;
|
||||
}
|
||||
const az = (Math.atan2(fwd[0], fwd[1]) * R2D + 360) % 360;
|
||||
const el = Math.asin(Math.max(-1, Math.min(1, fwd[2]))) * R2D;
|
||||
const roll = Math.atan2(right[2], up[2]) * R2D;
|
||||
return { fwd, right, up, az, el, roll };
|
||||
}
|
||||
|
||||
/** Pose from a simulated yaw/pitch (desktop drag-to-look). */
|
||||
export function poseFromAzEl(az, el) {
|
||||
const a = az * D2R;
|
||||
const e = el * D2R;
|
||||
const fwd = [
|
||||
Math.cos(e) * Math.sin(a),
|
||||
Math.cos(e) * Math.cos(a),
|
||||
Math.sin(e),
|
||||
];
|
||||
const right = [Math.cos(a), -Math.sin(a), 0];
|
||||
const up = [
|
||||
right[1] * fwd[2] - right[2] * fwd[1],
|
||||
right[2] * fwd[0] - right[0] * fwd[2],
|
||||
right[0] * fwd[1] - right[1] * fwd[0],
|
||||
];
|
||||
return { fwd, right, up, az: ((az % 360) + 360) % 360, el, roll: 0 };
|
||||
}
|
||||
|
||||
export function createSensors() {
|
||||
const state = {
|
||||
orientation: null, // {alpha,beta,gamma,absolute}
|
||||
motion: null, // {ax,ay,az,lx,ly,lz,rr}
|
||||
compass: null, // degrees, iOS webkitCompassHeading
|
||||
compassAccuracy: null,
|
||||
fix: null, // {t,lat,lon,acc,alt,heading,speed}
|
||||
geoError: null,
|
||||
hasMotion: false,
|
||||
hasOrientation: false,
|
||||
absolute: false,
|
||||
};
|
||||
const trace = []; // live |a| for the HUD
|
||||
let rec = null;
|
||||
let geoWatch = null;
|
||||
let alphaOffset = 0;
|
||||
let listeners = [];
|
||||
|
||||
function onOrient(e) {
|
||||
if (e.alpha == null && e.beta == null) return;
|
||||
state.hasOrientation = true;
|
||||
const absolute =
|
||||
e.absolute === true || e.type === 'deviceorientationabsolute';
|
||||
if (absolute) state.absolute = true;
|
||||
if (
|
||||
!absolute &&
|
||||
state.absolute &&
|
||||
e.type === 'deviceorientation' &&
|
||||
e.webkitCompassHeading == null
|
||||
)
|
||||
return;
|
||||
state.orientation = {
|
||||
alpha: e.alpha ?? 0,
|
||||
beta: e.beta ?? 0,
|
||||
gamma: e.gamma ?? 0,
|
||||
absolute,
|
||||
};
|
||||
if (e.webkitCompassHeading != null) {
|
||||
state.compass = e.webkitCompassHeading;
|
||||
state.compassAccuracy = e.webkitCompassAccuracy;
|
||||
// Align the arbitrary iOS alpha with magnetic north, smoothly.
|
||||
// az = −α for an upright camera, so α + (raw − compass) points at north.
|
||||
const raw = cameraPose(e.alpha, e.beta, e.gamma).az;
|
||||
const target = ((raw - e.webkitCompassHeading + 540) % 360) - 180;
|
||||
alphaOffset += (((target - alphaOffset + 540) % 360) - 180) * 0.15;
|
||||
}
|
||||
if (rec) sample();
|
||||
}
|
||||
function onMotion(e) {
|
||||
const g = e.accelerationIncludingGravity;
|
||||
const l = e.acceleration;
|
||||
if (!g || g.x == null) return;
|
||||
state.hasMotion = true;
|
||||
state.motion = {
|
||||
ax: g.x,
|
||||
ay: g.y,
|
||||
az: g.z,
|
||||
lx: l?.x,
|
||||
ly: l?.y,
|
||||
lz: l?.z,
|
||||
rr: e.rotationRate
|
||||
? [e.rotationRate.alpha, e.rotationRate.beta, e.rotationRate.gamma]
|
||||
: null,
|
||||
};
|
||||
const mag = Math.hypot(g.x, g.y, g.z);
|
||||
trace.push({ t: performance.now(), v: mag });
|
||||
while (trace.length && performance.now() - trace[0].t > 5000) trace.shift();
|
||||
if (rec) sample();
|
||||
}
|
||||
|
||||
let lastSampleT = 0;
|
||||
function sample() {
|
||||
const now = performance.now();
|
||||
if (now - lastSampleT < 15) return; // ≤ ~66 Hz
|
||||
lastSampleT = now;
|
||||
const o = state.orientation || {};
|
||||
const m = state.motion || {};
|
||||
rec.imu.push({
|
||||
t: Date.now() - rec.t0Wall,
|
||||
α: round(o.alpha),
|
||||
β: round(o.beta),
|
||||
γ: round(o.gamma),
|
||||
ax: round(m.ax, 3),
|
||||
ay: round(m.ay, 3),
|
||||
az: round(m.az, 3),
|
||||
});
|
||||
if (rec.imu.length > 4000) rec.imu.shift();
|
||||
}
|
||||
|
||||
function round(v, d = 2) {
|
||||
return Number.isFinite(v) ? Math.round(v * 10 ** d) / 10 ** d : null;
|
||||
}
|
||||
|
||||
function on(target, type, fn) {
|
||||
target.addEventListener(type, fn, { passive: true });
|
||||
listeners.push(() => target.removeEventListener(type, fn));
|
||||
}
|
||||
|
||||
function start() {
|
||||
stop();
|
||||
if ('ondeviceorientationabsolute' in window)
|
||||
on(window, 'deviceorientationabsolute', onOrient);
|
||||
on(window, 'deviceorientation', onOrient);
|
||||
on(window, 'devicemotion', onMotion);
|
||||
if ('geolocation' in navigator) {
|
||||
geoWatch = navigator.geolocation.watchPosition(
|
||||
(p) => {
|
||||
state.geoError = null;
|
||||
state.fix = {
|
||||
t: p.timestamp || Date.now(),
|
||||
lat: p.coords.latitude,
|
||||
lon: p.coords.longitude,
|
||||
acc: p.coords.accuracy,
|
||||
alt: p.coords.altitude,
|
||||
heading: p.coords.heading,
|
||||
speed: p.coords.speed,
|
||||
};
|
||||
if (rec)
|
||||
rec.gps.push({
|
||||
t: state.fix.t,
|
||||
lat: round(state.fix.lat, 6),
|
||||
lon: round(state.fix.lon, 6),
|
||||
acc: round(state.fix.acc, 1),
|
||||
alt: round(state.fix.alt, 1),
|
||||
});
|
||||
},
|
||||
(err) => (state.geoError = err.message || 'Location unavailable'),
|
||||
{ enableHighAccuracy: true, maximumAge: 2000, timeout: 20000 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function stop() {
|
||||
listeners.forEach((f) => f());
|
||||
listeners = [];
|
||||
if (geoWatch != null) navigator.geolocation.clearWatch(geoWatch);
|
||||
geoWatch = null;
|
||||
}
|
||||
|
||||
/** Current back-camera pose, or null without orientation sensors. */
|
||||
function pose(screenAngle = screenOrientationAngle()) {
|
||||
const o = state.orientation;
|
||||
if (!o) return null;
|
||||
const alpha = state.compass != null ? o.alpha + alphaOffset : o.alpha;
|
||||
return cameraPose(alpha, o.beta, o.gamma, screenAngle);
|
||||
}
|
||||
|
||||
function headingInfo() {
|
||||
const p = pose();
|
||||
if (!p) return null;
|
||||
return {
|
||||
deg: Math.round(p.az * 10) / 10,
|
||||
pitch: Math.round(p.el * 10) / 10,
|
||||
roll: Math.round(p.roll * 10) / 10,
|
||||
source:
|
||||
state.compass != null
|
||||
? 'compass'
|
||||
: state.absolute
|
||||
? 'absolute'
|
||||
: 'relative',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
state,
|
||||
trace,
|
||||
start,
|
||||
stop,
|
||||
pose,
|
||||
headingInfo,
|
||||
/** Begin recording a session (IMU + GPS). Keeps a pre-roll of what's already live. */
|
||||
beginSession() {
|
||||
rec = {
|
||||
t0Wall: Date.now(),
|
||||
start: new Date().toISOString(),
|
||||
imu: [],
|
||||
gps: [],
|
||||
};
|
||||
if (state.fix)
|
||||
rec.gps.push({
|
||||
t: state.fix.t,
|
||||
lat: round(state.fix.lat, 6),
|
||||
lon: round(state.fix.lon, 6),
|
||||
acc: round(state.fix.acc, 1),
|
||||
alt: round(state.fix.alt, 1),
|
||||
});
|
||||
return rec;
|
||||
},
|
||||
endSession() {
|
||||
if (!rec) return null;
|
||||
const r = rec;
|
||||
rec = null;
|
||||
// Keep the last 20 s of motion before the shutter.
|
||||
const cutoff = Date.now() - r.t0Wall - 20000;
|
||||
const imu = r.imu.filter((s) => s.t >= cutoff);
|
||||
if (!r.gps.length && state.fix)
|
||||
r.gps.push({
|
||||
t: state.fix.t,
|
||||
lat: round(state.fix.lat, 6),
|
||||
lon: round(state.fix.lon, 6),
|
||||
acc: round(state.fix.acc, 1),
|
||||
alt: round(state.fix.alt, 1),
|
||||
});
|
||||
return {
|
||||
start: r.start,
|
||||
end: new Date().toISOString(),
|
||||
imu,
|
||||
gps: r.gps,
|
||||
heading: headingInfo(),
|
||||
};
|
||||
},
|
||||
get recording() {
|
||||
return !!rec;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function screenOrientationAngle() {
|
||||
const a = screen.orientation?.angle ?? window.orientation ?? 0;
|
||||
return Number(a) || 0;
|
||||
}
|
||||
|
||||
/* ---------- AR geometry ---------- */
|
||||
|
||||
const A = 6378137;
|
||||
const E2 = 6.69437999014e-3;
|
||||
|
||||
function ecef(lat, lon, h) {
|
||||
const la = lat * D2R;
|
||||
const lo = lon * D2R;
|
||||
const N = A / Math.sqrt(1 - E2 * Math.sin(la) ** 2);
|
||||
return [
|
||||
(N + h) * Math.cos(la) * Math.cos(lo),
|
||||
(N + h) * Math.cos(la) * Math.sin(lo),
|
||||
(N * (1 - E2) + h) * Math.sin(la),
|
||||
];
|
||||
}
|
||||
|
||||
/** Azimuth, elevation (deg) and slant range (m) from an observer to a target. */
|
||||
export function lookAngles(obs, tgt) {
|
||||
const o = ecef(obs.lat, obs.lon, obs.alt || 0);
|
||||
const t = ecef(tgt.lat, tgt.lon, tgt.altM || 0);
|
||||
const d = [t[0] - o[0], t[1] - o[1], t[2] - o[2]];
|
||||
const la = obs.lat * D2R;
|
||||
const lo = obs.lon * D2R;
|
||||
const e = -Math.sin(lo) * d[0] + Math.cos(lo) * d[1];
|
||||
const n =
|
||||
-Math.sin(la) * Math.cos(lo) * d[0] -
|
||||
Math.sin(la) * Math.sin(lo) * d[1] +
|
||||
Math.cos(la) * d[2];
|
||||
const u =
|
||||
Math.cos(la) * Math.cos(lo) * d[0] +
|
||||
Math.cos(la) * Math.sin(lo) * d[1] +
|
||||
Math.sin(la) * d[2];
|
||||
const range = Math.hypot(e, n, u);
|
||||
return {
|
||||
az: (Math.atan2(e, n) * R2D + 360) % 360,
|
||||
el: Math.asin(u / range) * R2D,
|
||||
range,
|
||||
enu: [e / range, n / range, u / range],
|
||||
};
|
||||
}
|
||||
|
||||
/** Project an ENU unit direction into screen pixels for a pinhole camera. */
|
||||
export function projectDir(dir, pose, W, H, vfovDeg) {
|
||||
const x =
|
||||
dir[0] * pose.right[0] + dir[1] * pose.right[1] + dir[2] * pose.right[2];
|
||||
const y = dir[0] * pose.up[0] + dir[1] * pose.up[1] + dir[2] * pose.up[2];
|
||||
const z = dir[0] * pose.fwd[0] + dir[1] * pose.fwd[1] + dir[2] * pose.fwd[2];
|
||||
const fy = H / 2 / Math.tan((vfovDeg / 2) * D2R);
|
||||
if (z <= 0.01)
|
||||
return { behind: true, x: W / 2 + x * 1e4, y: H / 2 - y * 1e4 };
|
||||
return { behind: false, x: W / 2 + (x / z) * fy, y: H / 2 - (y / z) * fy, z };
|
||||
}
|
||||
|
||||
export function dirFromAzEl(az, el) {
|
||||
const a = az * D2R;
|
||||
const e = el * D2R;
|
||||
return [Math.cos(e) * Math.sin(a), Math.cos(e) * Math.cos(a), Math.sin(e)];
|
||||
}
|
||||
@@ -0,0 +1,935 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Builds the hero Moment, mobile/demo/moments/empire-state.json:
|
||||
* "Climbers on the Empire State Building" (DESIGN.md §12.4).
|
||||
*
|
||||
* ILLUSTRATIVE. Nothing here happened. The event, the helicopters, their
|
||||
* callsigns and tail numbers, the witnesses, the chat and the posts are all
|
||||
* fictional, and the file says so (`illustrative: true`). What is real is the
|
||||
* place: the building's position and heights, the street corners, the
|
||||
* heliports, and the neighbouring building massing (midtown-buildings.json).
|
||||
*
|
||||
* The helicopter tracks come from a small flight model, not from hand-drawn
|
||||
* circles: each aircraft flies a constant airspeed in a steady wind, steers
|
||||
* to hold an orbit radius with a rate-limited heading (about standard rate),
|
||||
* and drifts in altitude. The ground track that results is what ADS-B shows
|
||||
* for a real news orbit: stretched downwind, faster on the downwind side,
|
||||
* with a few metres of position noise. Output is 1 Hz.
|
||||
*
|
||||
* node mobile/demo/build-empire-state.mjs
|
||||
*
|
||||
* Deterministic: the same seed always writes the same file.
|
||||
*/
|
||||
import { writeFile, mkdir } from 'node:fs/promises';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import path from 'node:path';
|
||||
|
||||
/** JSON as the repo's Prettier would write it (so `prettier --check` passes). */
|
||||
async function prettyJson(value) {
|
||||
const text = JSON.stringify(value);
|
||||
try {
|
||||
const prettier = await import('prettier');
|
||||
return await prettier.format(text, { parser: 'json' });
|
||||
} catch {
|
||||
return `${text}\n`;
|
||||
}
|
||||
}
|
||||
|
||||
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
||||
const SEED = 0x35_0f1f7a;
|
||||
|
||||
/* ── Place (real) ───────────────────────────────────────────────── */
|
||||
const ESB = { lon: -73.98566, lat: 40.74844 };
|
||||
const GROUND_MSL = 15; // m, 5th Ave at 34th St
|
||||
const ROOF = 381; // m above street (102nd floor roof)
|
||||
const ANTENNA = 443; // m, antenna tip
|
||||
const DECK_86 = 320; // m, 86th floor open-air deck
|
||||
const DECK_102 = 373; // m, 102nd floor deck
|
||||
const DMH = {
|
||||
lon: -74.00905,
|
||||
lat: 40.70137,
|
||||
name: 'Downtown Manhattan Heliport',
|
||||
};
|
||||
const E34 = {
|
||||
lon: -73.97175,
|
||||
lat: 40.74245,
|
||||
name: 'East 34th Street Heliport',
|
||||
};
|
||||
const NJ = { lon: -74.0742, lat: 40.7727, name: 'over Secaucus, NJ' };
|
||||
|
||||
/* ── Clock (fictional date) ─────────────────────────────────────── */
|
||||
const T0 = Date.UTC(2026, 8, 18, 21, 18, 0); // 5:18 PM EDT
|
||||
const DURATION_S = 25 * 60;
|
||||
const at = (mmss) => {
|
||||
const [m, s] = String(mmss).split(':').map(Number);
|
||||
return T0 + (m * 60 + (s || 0)) * 1000;
|
||||
};
|
||||
|
||||
/* ── Deterministic randomness ───────────────────────────────────── */
|
||||
function mulberry32(a) {
|
||||
return () => {
|
||||
a |= 0;
|
||||
a = (a + 0x6d2b79f5) | 0;
|
||||
let t = Math.imul(a ^ (a >>> 15), 1 | a);
|
||||
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||
};
|
||||
}
|
||||
const rand = mulberry32(SEED);
|
||||
const gauss = () => {
|
||||
let u = 0;
|
||||
let v = 0;
|
||||
while (!u) u = rand();
|
||||
while (!v) v = rand();
|
||||
return Math.sqrt(-2 * Math.log(u)) * Math.cos(2 * Math.PI * v);
|
||||
};
|
||||
|
||||
/* ── Local metric frame ─────────────────────────────────────────── */
|
||||
const D2R = Math.PI / 180;
|
||||
const KX = 111_320 * Math.cos(ESB.lat * D2R);
|
||||
const KY = 110_574;
|
||||
const toXY = (p) => [(p.lon - ESB.lon) * KX, (p.lat - ESB.lat) * KY];
|
||||
const toLL = (x, y) => ({ lon: ESB.lon + x / KX, lat: ESB.lat + y / KY });
|
||||
const KT = 0.514444;
|
||||
const FT = 0.3048;
|
||||
const wrap = (d) => (((d % 360) + 540) % 360) - 180;
|
||||
const bearingXY = (dx, dy) => (Math.atan2(dx, dy) / D2R + 360) % 360;
|
||||
|
||||
/* ── Flight model ───────────────────────────────────────────────── */
|
||||
// Wind at 1,000–1,500 ft over Midtown: 14 kt from 290° (blowing toward 110°).
|
||||
const WIND = { fromDeg: 290, kt: 14 };
|
||||
const windV = () => {
|
||||
const to = (WIND.fromDeg + 180) * D2R;
|
||||
return [Math.sin(to) * WIND.kt * KT, Math.cos(to) * WIND.kt * KT];
|
||||
};
|
||||
|
||||
/**
|
||||
* Fly a helicopter through `plan` (a list of legs) and return 1 Hz points.
|
||||
* Legs: { until, mode:'ground'|'goto'|'orbit', ... }.
|
||||
*/
|
||||
function fly({ start, startAltFt, plan, tStart, tEnd, noiseM = 4 }) {
|
||||
const dt = 0.2;
|
||||
let [x, y] = toXY(start);
|
||||
let alt = startAltFt * FT;
|
||||
let hdg = plan[0].initialHeading ?? 0;
|
||||
let air = 0; // airspeed m/s
|
||||
const [wx, wy] = windV();
|
||||
const out = [];
|
||||
let leg = 0;
|
||||
let nextOut = tStart;
|
||||
let altPhase = rand() * Math.PI * 2;
|
||||
for (let t = tStart; t <= tEnd; t += dt * 1000) {
|
||||
while (leg < plan.length - 1 && t >= plan[leg].until) leg++;
|
||||
const L = plan[leg];
|
||||
let targetAir = (L.kt ?? 0) * KT;
|
||||
let targetAlt = (L.altFt ?? startAltFt) * FT;
|
||||
let desired = hdg;
|
||||
if (L.mode === 'ground') {
|
||||
targetAir = 0;
|
||||
targetAlt = (L.altFt ?? 0) * FT;
|
||||
} else if (L.mode === 'goto') {
|
||||
const [tx, ty] = toXY(L.to);
|
||||
desired = bearingXY(tx - x, ty - y);
|
||||
// Arrive gently: slow down inside 600 m of the end of a goto leg.
|
||||
const d = Math.hypot(tx - x, ty - y);
|
||||
if (L.slowNear && d < 600)
|
||||
targetAir = Math.max(L.slowNear * KT, targetAir * (d / 600));
|
||||
} else if (L.mode === 'orbit') {
|
||||
const [cx, cy] = toXY(L.center);
|
||||
const dx = x - cx;
|
||||
const dy = y - cy;
|
||||
const d = Math.hypot(dx, dy);
|
||||
const beta = bearingXY(dx, dy);
|
||||
// Radius breathes a little: pilots never fly a perfect circle.
|
||||
const r =
|
||||
L.radius * (1 + 0.06 * Math.sin((t - tStart) / 1000 / 47 + L.radius));
|
||||
const err = Math.atan((d - r) / 110) / D2R;
|
||||
desired = L.dir === 'cw' ? beta + 90 + err : beta - 90 - err;
|
||||
// The heading that holds that ground track in the wind (crab angle).
|
||||
const gx = Math.sin(desired * D2R);
|
||||
const gy = Math.cos(desired * D2R);
|
||||
const cross = wx * gy - wy * gx; // wind across the track
|
||||
const crab = Math.asin(
|
||||
Math.max(-0.9, Math.min(0.9, cross / Math.max(8, targetAir))),
|
||||
);
|
||||
desired = desired + crab / D2R;
|
||||
}
|
||||
// Heading: rate-limited, about standard rate (3°/s), up to 5.5°/s.
|
||||
const maxRate = (L.maxRate ?? 5.5) * dt;
|
||||
const dh = wrap(desired - hdg);
|
||||
hdg = (hdg + Math.max(-maxRate, Math.min(maxRate, dh)) + 360) % 360;
|
||||
// Speed and altitude respond with first-order lags.
|
||||
air += (targetAir - air) * Math.min(1, dt / 6);
|
||||
altPhase += dt / 37;
|
||||
const drift =
|
||||
L.mode === 'orbit' ? (28 * Math.sin(altPhase) + 9 * gauss()) * FT : 0;
|
||||
alt += (targetAlt + drift - alt) * Math.min(1, dt / 9);
|
||||
if (L.mode === 'ground') {
|
||||
air = 0;
|
||||
alt += (0 - alt) * Math.min(1, dt / 4);
|
||||
} else {
|
||||
x += (Math.sin(hdg * D2R) * air + wx * Math.min(1, air / 10)) * dt;
|
||||
y += (Math.cos(hdg * D2R) * air + wy * Math.min(1, air / 10)) * dt;
|
||||
}
|
||||
if (t >= nextOut) {
|
||||
nextOut += 1000;
|
||||
if (L.mode === 'hidden') continue;
|
||||
const p = toLL(x + gauss() * noiseM, y + gauss() * noiseM);
|
||||
// ADS-B altitude: 25 ft steps, MSL.
|
||||
const altFt = Math.round((alt / FT + GROUND_MSL / FT) / 25) * 25;
|
||||
out.push({
|
||||
t: Math.round(t),
|
||||
lon: Math.round(p.lon * 1e6) / 1e6,
|
||||
lat: Math.round(p.lat * 1e6) / 1e6,
|
||||
altM: Math.round(altFt * FT),
|
||||
});
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const near = (p, m, deg) => {
|
||||
const [x, y] = toXY(p);
|
||||
return toLL(x + Math.sin(deg * D2R) * m, y + Math.cos(deg * D2R) * m);
|
||||
};
|
||||
const orbitCenter = (m, deg) => near(ESB, m, deg);
|
||||
|
||||
/* ── The three helicopters (fictional) ──────────────────────────── */
|
||||
const police = fly({
|
||||
start: DMH,
|
||||
startAltFt: 0,
|
||||
tStart: T0,
|
||||
tEnd: T0 + DURATION_S * 1000,
|
||||
plan: [
|
||||
{ mode: 'ground', until: at('0:20'), initialHeading: 20 },
|
||||
// Up the west side, over the Hudson shoreline, then across to 34th St.
|
||||
{
|
||||
mode: 'goto',
|
||||
to: { lon: -74.0135, lat: 40.7205 },
|
||||
kt: 95,
|
||||
altFt: 900,
|
||||
until: at('0:55'),
|
||||
},
|
||||
{
|
||||
mode: 'goto',
|
||||
to: { lon: -74.0055, lat: 40.739 },
|
||||
kt: 110,
|
||||
altFt: 1100,
|
||||
until: at('1:40'),
|
||||
},
|
||||
{
|
||||
mode: 'goto',
|
||||
to: near(ESB, 520, 240),
|
||||
kt: 90,
|
||||
altFt: 1100,
|
||||
slowNear: 70,
|
||||
until: at('2:10'),
|
||||
},
|
||||
{
|
||||
mode: 'orbit',
|
||||
center: orbitCenter(60, 200),
|
||||
radius: 470,
|
||||
kt: 64,
|
||||
altFt: 1100,
|
||||
dir: 'ccw',
|
||||
until: at('14:00'),
|
||||
},
|
||||
// Tightens and steps down once officers reach the 102nd floor deck.
|
||||
{
|
||||
mode: 'orbit',
|
||||
center: orbitCenter(30, 120),
|
||||
radius: 410,
|
||||
kt: 58,
|
||||
altFt: 1000,
|
||||
dir: 'ccw',
|
||||
until: at('25:01'),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const news7 = fly({
|
||||
start: NJ,
|
||||
startAltFt: 900,
|
||||
tStart: T0 + 85_000,
|
||||
tEnd: T0 + DURATION_S * 1000,
|
||||
plan: [
|
||||
{
|
||||
mode: 'goto',
|
||||
to: { lon: -74.024, lat: 40.7585 },
|
||||
kt: 120,
|
||||
altFt: 1400,
|
||||
until: at('3:20'),
|
||||
initialHeading: 120,
|
||||
},
|
||||
{
|
||||
mode: 'goto',
|
||||
to: near(ESB, 700, 300),
|
||||
kt: 105,
|
||||
altFt: 1450,
|
||||
slowNear: 80,
|
||||
until: at('4:30'),
|
||||
},
|
||||
{
|
||||
mode: 'orbit',
|
||||
center: orbitCenter(120, 30),
|
||||
radius: 660,
|
||||
kt: 76,
|
||||
altFt: 1450,
|
||||
dir: 'ccw',
|
||||
until: at('25:01'),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const sky4 = fly({
|
||||
start: E34,
|
||||
startAltFt: 0,
|
||||
tStart: T0 + 330_000,
|
||||
tEnd: T0 + DURATION_S * 1000,
|
||||
plan: [
|
||||
{ mode: 'ground', until: at('6:00'), initialHeading: 300 },
|
||||
{
|
||||
mode: 'goto',
|
||||
to: near(ESB, 600, 140),
|
||||
kt: 70,
|
||||
altFt: 1250,
|
||||
slowNear: 62,
|
||||
until: at('7:25'),
|
||||
},
|
||||
{
|
||||
mode: 'orbit',
|
||||
center: orbitCenter(90, 160),
|
||||
radius: 560,
|
||||
kt: 68,
|
||||
altFt: 1250,
|
||||
dir: 'ccw',
|
||||
until: at('21:00'),
|
||||
},
|
||||
// Leaves for the next story: back out over the East River, south.
|
||||
{
|
||||
mode: 'goto',
|
||||
to: { lon: -73.962, lat: 40.728 },
|
||||
kt: 110,
|
||||
altFt: 1300,
|
||||
until: at('22:30'),
|
||||
},
|
||||
{
|
||||
mode: 'goto',
|
||||
to: { lon: -73.99, lat: 40.69 },
|
||||
kt: 120,
|
||||
altFt: 1300,
|
||||
until: at('25:01'),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const tracks = [
|
||||
{
|
||||
id: 'pd-14',
|
||||
kind: 'helicopter',
|
||||
label: 'PD-14 (illustrative)',
|
||||
callsign: 'PD14',
|
||||
registration: 'ILLUS-14',
|
||||
type: 'Bell 429',
|
||||
operator: 'City police aviation (fictional)',
|
||||
role: 'police',
|
||||
color: '#5EC8FF',
|
||||
points: police,
|
||||
},
|
||||
{
|
||||
id: 'news-7',
|
||||
kind: 'helicopter',
|
||||
label: 'NEWS-7 (illustrative)',
|
||||
callsign: 'NEWS7',
|
||||
registration: 'ILLUS-07',
|
||||
type: 'Airbus H125',
|
||||
operator: 'Channel 7 News (fictional)',
|
||||
role: 'news',
|
||||
color: '#FFD166',
|
||||
points: news7,
|
||||
},
|
||||
{
|
||||
id: 'sky-4',
|
||||
kind: 'helicopter',
|
||||
label: 'SKY-4 (illustrative)',
|
||||
callsign: 'SKY4',
|
||||
registration: 'ILLUS-04',
|
||||
type: 'Airbus H130',
|
||||
operator: 'Sky 4 News (fictional)',
|
||||
role: 'news',
|
||||
color: '#FF9F6B',
|
||||
points: sky4,
|
||||
},
|
||||
];
|
||||
|
||||
/* ── Witness captures (fictional people, real corners) ──────────── */
|
||||
function aim(p, altM, targetAltM) {
|
||||
const [x, y] = toXY(p);
|
||||
const d = Math.hypot(x, y);
|
||||
return {
|
||||
headingDeg: Math.round(bearingXY(-x, -y) * 10) / 10,
|
||||
pitchDeg: Math.round((Math.atan2(targetAltM - altM, d) / D2R) * 10) / 10,
|
||||
rangeM: Math.round(d),
|
||||
};
|
||||
}
|
||||
const capture = (id, mmss, where, altM, targetAltM, fovDeg, rest) => ({
|
||||
id,
|
||||
t: at(mmss),
|
||||
lon: where.lon,
|
||||
lat: where.lat,
|
||||
altM,
|
||||
...aim(where, altM, targetAltM),
|
||||
fovDeg,
|
||||
sealed: true,
|
||||
...rest,
|
||||
});
|
||||
const captures = [
|
||||
capture(
|
||||
'c1',
|
||||
'0:52',
|
||||
{ lon: -73.98795, lat: 40.74968 },
|
||||
1.5,
|
||||
DECK_86 + 10,
|
||||
22,
|
||||
{
|
||||
title: 'Two figures outside the 86th floor fence',
|
||||
handle: 'kvn_on_34th',
|
||||
place: 'W 34th St & 6th Ave',
|
||||
firstEyes: true,
|
||||
kind: 'photo',
|
||||
},
|
||||
),
|
||||
capture(
|
||||
'c2',
|
||||
'3:05',
|
||||
{ lon: -73.98752, lat: 40.74482 },
|
||||
1.5,
|
||||
DECK_86 + 30,
|
||||
30,
|
||||
{
|
||||
title: 'North face, police helicopter overhead',
|
||||
handle: 'jules_flatiron',
|
||||
place: '5th Ave & 29th St',
|
||||
kind: 'clip',
|
||||
},
|
||||
),
|
||||
capture('c3', '6:10', { lon: -73.99325, lat: 40.75195 }, 142, ROOF + 20, 18, {
|
||||
title: 'On the mast, east side, from the 40th floor',
|
||||
handle: 'penn_plaza_40th',
|
||||
place: '1 Penn Plaza, 40th floor',
|
||||
kind: 'clip',
|
||||
}),
|
||||
capture('c4', '8:40', { lon: -74.00355, lat: 40.75255 }, 9, ROOF, 14, {
|
||||
title: 'Three helicopters around the spire',
|
||||
handle: 'hellsk1tchen',
|
||||
place: 'The High Line at W 30th St',
|
||||
kind: 'photo',
|
||||
}),
|
||||
capture('c5', '12:20', { lon: -73.98805, lat: 40.74745 }, 55, DECK_102, 40, {
|
||||
title: 'Officers on the 102nd floor deck',
|
||||
handle: 'lena.ktown',
|
||||
place: 'Rooftop, W 32nd St (Koreatown)',
|
||||
kind: 'clip',
|
||||
}),
|
||||
capture('c6', '15:30', { lon: -73.98205, lat: 40.75112 }, 1.5, ROOF, 28, {
|
||||
title: 'Crowd on 5th Ave looking up',
|
||||
handle: 'midtown_mara',
|
||||
place: '5th Ave & 38th St',
|
||||
kind: 'photo',
|
||||
}),
|
||||
capture('c7', '19:45', { lon: -73.97765, lat: 40.74662 }, 48, DECK_102, 20, {
|
||||
title: 'Both back over the rail at 102',
|
||||
handle: 'rooftop_rafa',
|
||||
place: 'Rooftop, Murray Hill',
|
||||
kind: 'clip',
|
||||
}),
|
||||
capture(
|
||||
'c8',
|
||||
'21:10',
|
||||
{ lon: -73.98875, lat: 40.74905 },
|
||||
1.5,
|
||||
ROOF - 40,
|
||||
34,
|
||||
{
|
||||
title: 'SKY-4 peeling away east',
|
||||
handle: 'deli_guy_dan',
|
||||
place: 'Broadway & 33rd St (Herald Sq)',
|
||||
kind: 'photo',
|
||||
},
|
||||
),
|
||||
];
|
||||
|
||||
/* ── Live lane: chat (fictional handles) ────────────────────────── */
|
||||
// [m:ss, handle, text, badge?]. Bursts follow the beats: first sighting,
|
||||
// police arrival, news arrival, the mast climb, the top, officers on deck,
|
||||
// coming down.
|
||||
const CHAT = [
|
||||
['0:12', 'deli_guy_dan', 'why is everyone on 34th looking up'],
|
||||
[
|
||||
'0:31',
|
||||
'kvn_on_34th',
|
||||
'theres PEOPLE on the empire state. outside the fence on 86',
|
||||
'local',
|
||||
],
|
||||
['0:38', 'midtown_mara', 'wait what'],
|
||||
['0:44', 'brklyn_bea', 'link??'],
|
||||
[
|
||||
'0:52',
|
||||
'kvn_on_34th',
|
||||
'sealed photo up. north side, two of them',
|
||||
'first-eyes',
|
||||
],
|
||||
['0:58', 'throwaway_tom', 'thats a window washer rig'],
|
||||
['1:04', 'kvn_on_34th', 'no rig. no ropes that I can see', 'first-eyes'],
|
||||
['1:10', 'jules_flatiron', 'omg I see them from 29th'],
|
||||
['1:19', 'nyc_scanner_nerd', 'scanner: units responding to 350 5th', 'local'],
|
||||
['1:33', 'ess_ay_dee', '👀👀👀'],
|
||||
['1:41', 'midtown_mara', 'this is insane'],
|
||||
[
|
||||
'1:52',
|
||||
'avgeek_jfk',
|
||||
'police helo just lifted out of the downtown heliport',
|
||||
'pilot',
|
||||
],
|
||||
['2:04', 'brklyn_bea', 'I hear it'],
|
||||
['2:10', 'nyc_scanner_nerd', 'PD-14 overhead now, orbiting', 'local'],
|
||||
['2:12', 'deli_guy_dan', 'LOUD'],
|
||||
['2:14', 'ess_ay_dee', '🔥🔥'],
|
||||
['2:16', 'hellsk1tchen', 'can see the heli circling from the west side'],
|
||||
['2:21', 'throwaway_tom', 'ok not window washers lol'],
|
||||
['2:25', 'murrayhill_mo', 'is this a protest?'],
|
||||
[
|
||||
'2:31',
|
||||
'kvn_on_34th',
|
||||
'no banner, they just look like climbers. helmets',
|
||||
'first-eyes',
|
||||
],
|
||||
['2:44', 'lena.ktown', 'going up to our roof'],
|
||||
['2:58', 'jules_flatiron', 'clip sealed from 5th & 29th', 'witness'],
|
||||
['3:12', 'brklyn_bea', 'how did they even get out there'],
|
||||
[
|
||||
'3:26',
|
||||
'avgeek_jfk',
|
||||
'orbit looks ~1,100 ft, tight, maybe 500 m radius',
|
||||
'pilot',
|
||||
],
|
||||
['3:39', 'midtown_mara', 'they are not moving'],
|
||||
['3:55', 'ess_ay_dee', 'pray for them fr'],
|
||||
['4:08', 'nyc_scanner_nerd', 'second helo inbound from jersey', 'local'],
|
||||
['4:22', 'deli_guy_dan', 'news is here'],
|
||||
['4:30', 'avgeek_jfk', 'NEWS-7 up high, ~1,450', 'pilot'],
|
||||
['4:33', 'murrayhill_mo', 'its on tv now'],
|
||||
['4:35', 'brklyn_bea', 'channel 7 live rn'],
|
||||
['4:37', 'ess_ay_dee', '😮'],
|
||||
['4:41', 'throwaway_tom', 'tv is 40s behind this chat lmao'],
|
||||
['4:52', 'hellsk1tchen', 'two helis circling opposite sides'],
|
||||
['5:10', 'kvn_on_34th', 'THEY ARE CLIMBING. the mast', 'first-eyes'],
|
||||
['5:12', 'midtown_mara', 'NO'],
|
||||
['5:13', 'jules_flatiron', 'oh my god'],
|
||||
['5:15', 'deli_guy_dan', 'nope nope nope'],
|
||||
['5:17', 'ess_ay_dee', '😮😮😮'],
|
||||
['5:20', 'brklyn_bea', 'im shaking'],
|
||||
[
|
||||
'5:24',
|
||||
'lena.ktown',
|
||||
'on the roof, I can see them on the east side',
|
||||
'local',
|
||||
],
|
||||
['5:31', 'murrayhill_mo', 'how high is that'],
|
||||
['5:36', 'avgeek_jfk', 'mast starts ~1,250 ft. antenna tip 1,454', 'pilot'],
|
||||
['5:52', 'throwaway_tom', 'who does this'],
|
||||
[
|
||||
'6:02',
|
||||
'nyc_scanner_nerd',
|
||||
'third helo lifting from E 34th St heliport',
|
||||
'local',
|
||||
],
|
||||
[
|
||||
'6:10',
|
||||
'penn_plaza_40th',
|
||||
'from 1 Penn 40th fl. sealed. they are halfway up the mast',
|
||||
'witness',
|
||||
],
|
||||
['6:14', 'deli_guy_dan', 'thats so close'],
|
||||
['6:25', 'midtown_mara', 'is that a drone?'],
|
||||
['6:40', 'hellsk1tchen', 'drone over by the river too'],
|
||||
[
|
||||
'6:58',
|
||||
'avgeek_jfk',
|
||||
'not a drone, thats SKY-4 coming off the E 34th pad',
|
||||
'pilot',
|
||||
],
|
||||
['7:20', 'brklyn_bea', 'three helicopters now'],
|
||||
['7:26', 'ess_ay_dee', '🔥🔥🔥'],
|
||||
[
|
||||
'7:41',
|
||||
'metwx_paul',
|
||||
'winds ~15 kt from WNW at 1,000+ ft. thats why the orbits look stretched east',
|
||||
'met',
|
||||
],
|
||||
['7:58', 'jules_flatiron', 'the gusts up there must be brutal'],
|
||||
['8:12', 'murrayhill_mo', 'they stopped'],
|
||||
['8:30', 'kvn_on_34th', 'one is waving??', 'first-eyes'],
|
||||
[
|
||||
'8:40',
|
||||
'hellsk1tchen',
|
||||
'sealed from the high line, all three helis in frame',
|
||||
'witness',
|
||||
],
|
||||
['8:51', 'deli_guy_dan', 'crowd on 5th is massive now'],
|
||||
['9:05', 'brklyn_bea', 'please come down'],
|
||||
['9:22', 'lena.ktown', 'they are going higher', 'witness'],
|
||||
['9:34', 'throwaway_tom', 'HOW'],
|
||||
['9:40', 'kvn_on_34th', 'ONE IS AT THE TOP OF THE MAST', 'first-eyes'],
|
||||
['9:41', 'midtown_mara', 'NOOOO'],
|
||||
['9:42', 'ess_ay_dee', '😮😮😮😮'],
|
||||
['9:43', 'jules_flatiron', 'oh my god oh my god'],
|
||||
['9:44', 'deli_guy_dan', 'cant watch'],
|
||||
['9:46', 'murrayhill_mo', 'insane'],
|
||||
['9:48', 'brklyn_bea', '😭'],
|
||||
['9:51', 'hellsk1tchen', 'the whole avenue just gasped'],
|
||||
['9:55', 'penn_plaza_40th', 'whole office is at the window', 'witness'],
|
||||
[
|
||||
'10:08',
|
||||
'heli_dispatch_ret',
|
||||
'three ships stacked 1,000/1,250/1,450, all left orbits. textbook',
|
||||
'pilot',
|
||||
],
|
||||
['10:22', 'throwaway_tom', 'helmet cam. this is for a video 100%'],
|
||||
[
|
||||
'10:40',
|
||||
'avgeek_jfk',
|
||||
'NEWS-7 widening out, prob for the wide shot',
|
||||
'pilot',
|
||||
],
|
||||
['11:05', 'midtown_mara', 'is the deck closed?'],
|
||||
[
|
||||
'11:20',
|
||||
'nyc_scanner_nerd',
|
||||
'scanner says deck cleared, ESU going up',
|
||||
'local',
|
||||
],
|
||||
['11:42', 'brklyn_bea', 'come on come on'],
|
||||
['12:05', 'murrayhill_mo', 'whats ESU'],
|
||||
[
|
||||
'12:11',
|
||||
'nyc_scanner_nerd',
|
||||
'emergency service unit. rescue specialists',
|
||||
'local',
|
||||
],
|
||||
[
|
||||
'12:20',
|
||||
'lena.ktown',
|
||||
'sealed: officers on the 102 deck right under them',
|
||||
'witness',
|
||||
],
|
||||
['12:24', 'ess_ay_dee', '🙏'],
|
||||
['12:35', 'deli_guy_dan', 'they are talking to them i think'],
|
||||
['13:02', 'jules_flatiron', 'PD-14 dropping lower'],
|
||||
[
|
||||
'13:40',
|
||||
'heli_dispatch_ret',
|
||||
'police ship tightening the orbit, stepping down to ~1,000',
|
||||
'pilot',
|
||||
],
|
||||
['14:10', 'throwaway_tom', 'third climber on the setback??'],
|
||||
['14:14', 'midtown_mara', 'wait three??'],
|
||||
['14:20', 'brklyn_bea', 'I see a third one'],
|
||||
[
|
||||
'14:48',
|
||||
'jules_flatiron',
|
||||
'thats the mast shadow lol. compare the 5th ave and high line shots',
|
||||
'witness',
|
||||
],
|
||||
['15:02', 'hellsk1tchen', 'yeah shadow, sun is behind us', 'witness'],
|
||||
[
|
||||
'15:30',
|
||||
'midtown_mara',
|
||||
'sealed from 38th, the crowd is all phones',
|
||||
'witness',
|
||||
],
|
||||
['15:51', 'deli_guy_dan', 'sold out of water lol'],
|
||||
['16:20', 'murrayhill_mo', 'how long has it been'],
|
||||
['16:24', 'kvn_on_34th', '16 min since I saw them', 'first-eyes'],
|
||||
['16:50', 'ess_ay_dee', '❤️❤️'],
|
||||
['17:15', 'brklyn_bea', 'they are moving'],
|
||||
['17:30', 'lena.ktown', 'coming DOWN', 'witness'],
|
||||
['17:32', 'midtown_mara', 'YES'],
|
||||
['17:34', 'deli_guy_dan', 'thank god'],
|
||||
['17:36', 'ess_ay_dee', '❤️❤️❤️'],
|
||||
['17:55', 'throwaway_tom', 'slowly. slowly'],
|
||||
['18:30', 'jules_flatiron', 'first one at the rail'],
|
||||
['18:58', 'penn_plaza_40th', 'officers reaching over the rail', 'witness'],
|
||||
['19:20', 'kvn_on_34th', 'ONE IS IN', 'first-eyes'],
|
||||
['19:22', 'brklyn_bea', '😭😭'],
|
||||
['19:24', 'midtown_mara', 'ok im crying'],
|
||||
['19:40', 'lena.ktown', 'SECOND ONE IN', 'witness'],
|
||||
['19:42', 'deli_guy_dan', 'LETS GOOO'],
|
||||
['19:43', 'ess_ay_dee', '❤️🔥❤️🔥'],
|
||||
['19:44', 'murrayhill_mo', 'the street is clapping'],
|
||||
[
|
||||
'19:45',
|
||||
'rooftop_rafa',
|
||||
'sealed from murray hill, both over the rail',
|
||||
'witness',
|
||||
],
|
||||
['19:50', 'hellsk1tchen', 'cheering from the high line too'],
|
||||
['20:10', 'throwaway_tom', 'they are so arrested'],
|
||||
[
|
||||
'20:40',
|
||||
'nyc_scanner_nerd',
|
||||
'scanner: two in custody, no injuries reported',
|
||||
'local',
|
||||
],
|
||||
['21:00', 'avgeek_jfk', 'SKY-4 departing east, next story', 'pilot'],
|
||||
['21:10', 'deli_guy_dan', 'sealed one of SKY-4 leaving', 'witness'],
|
||||
['21:35', 'jules_flatiron', 'what a day for midtown'],
|
||||
['22:10', 'midtown_mara', 'the replay of this is gonna be wild'],
|
||||
['22:40', 'brklyn_bea', 'thank you everyone for the updates'],
|
||||
[
|
||||
'23:15',
|
||||
'heli_dispatch_ret',
|
||||
'NEWS-7 and PD-14 still up. will hold till the street clears',
|
||||
'pilot',
|
||||
],
|
||||
['24:05', 'murrayhill_mo', 'traffic on 5th still stopped'],
|
||||
[
|
||||
'24:40',
|
||||
'kvn_on_34th',
|
||||
'cant believe I just looked up at the right time',
|
||||
'first-eyes',
|
||||
],
|
||||
];
|
||||
|
||||
/* ── Record lane: posts ─────────────────────────────────────────── */
|
||||
const POSTS = [
|
||||
[
|
||||
'0:58',
|
||||
'kvn_on_34th',
|
||||
'witness',
|
||||
'Two people outside the safety fence on the 86th floor setback, north face. Sealed photo from W 34th St & 6th Ave, 22° zoom.',
|
||||
412,
|
||||
],
|
||||
[
|
||||
'2:40',
|
||||
'midtown_mara',
|
||||
'question',
|
||||
'Is the observation deck being cleared? Is anyone still up there?',
|
||||
88,
|
||||
],
|
||||
[
|
||||
'3:30',
|
||||
'nyc_scanner_nerd',
|
||||
'context',
|
||||
'Police aviation is on scene, orbiting at about 1,100 ft. News helicopters usually follow within minutes.',
|
||||
230,
|
||||
],
|
||||
[
|
||||
'6:15',
|
||||
'penn_plaza_40th',
|
||||
'witness',
|
||||
'From 1 Penn Plaza, 40th floor: both are on the mooring mast, east side, about halfway up. Sealed clip.',
|
||||
377,
|
||||
],
|
||||
[
|
||||
'7:05',
|
||||
'avgeek_jfk',
|
||||
'correction',
|
||||
'The "drone" in clips around 6:30 is SKY-4 lifting out of the East 34th St heliport. Its track matches.',
|
||||
156,
|
||||
],
|
||||
[
|
||||
'7:50',
|
||||
'metwx_paul',
|
||||
'context',
|
||||
'Wind at 1,000–1,500 ft is about 15 kt from the WNW. That is why every orbit looks stretched toward the East River.',
|
||||
141,
|
||||
],
|
||||
[
|
||||
'10:15',
|
||||
'heli_dispatch_ret',
|
||||
'context',
|
||||
'Three helicopters holding 1,000, 1,250 and 1,450 ft in left-hand orbits. Staggered altitudes are normal deconfliction.',
|
||||
264,
|
||||
],
|
||||
[
|
||||
'12:25',
|
||||
'lena.ktown',
|
||||
'witness',
|
||||
'Rooftop in Koreatown: officers visible on the 102nd floor deck directly below the climbers. Sealed.',
|
||||
198,
|
||||
],
|
||||
[
|
||||
'14:55',
|
||||
'jules_flatiron',
|
||||
'correction',
|
||||
"There is no third climber. The figure on the setback is the mast's shadow: compare the 5th Ave and High Line captures, taken 6 minutes apart.",
|
||||
301,
|
||||
],
|
||||
['16:10', 'brklyn_bea', 'question', 'Has anyone been hurt?', 64],
|
||||
[
|
||||
'19:50',
|
||||
'rooftop_rafa',
|
||||
'witness',
|
||||
'Both are back over the rail at the 102nd floor and officers have them. Sealed clip from Murray Hill.',
|
||||
520,
|
||||
],
|
||||
[
|
||||
'20:45',
|
||||
'nyc_scanner_nerd',
|
||||
'context',
|
||||
'Scanner traffic: two in custody, no injuries reported. Unconfirmed until an official statement.',
|
||||
187,
|
||||
],
|
||||
];
|
||||
|
||||
/* ── Beats (for timeline pips) ──────────────────────────────────── */
|
||||
const BEATS = [
|
||||
['0:52', 'First sealed capture: two figures outside the 86th floor fence'],
|
||||
['2:10', 'Police helicopter on scene'],
|
||||
['4:30', 'NEWS-7 arrives'],
|
||||
['5:10', 'Climbers start up the mast'],
|
||||
['7:25', 'SKY-4 joins: three helicopters orbiting'],
|
||||
['9:40', 'One climber at the top of the mast'],
|
||||
['12:20', 'Officers on the 102nd floor deck'],
|
||||
['17:30', 'Climbers start down'],
|
||||
['19:40', 'Both back over the rail'],
|
||||
['21:00', 'SKY-4 departs'],
|
||||
];
|
||||
|
||||
/* ── Cinematic camera path, keyed to time ───────────────────────── */
|
||||
// Each key: bearing from the building, ground range, the camera's pitch
|
||||
// down to the look point, and the look point's height (default the deck).
|
||||
function camKey(
|
||||
mmss,
|
||||
bearingFromEsb,
|
||||
rangeM,
|
||||
pitchDownDeg,
|
||||
lookAltM = DECK_86,
|
||||
) {
|
||||
const heightM = Math.round(lookAltM + Math.tan(pitchDownDeg * D2R) * rangeM);
|
||||
const p = near(ESB, rangeM, bearingFromEsb);
|
||||
const [x, y] = toXY(p);
|
||||
const heading = bearingXY(-x, -y);
|
||||
const pitch = Math.atan2(lookAltM - heightM, Math.hypot(x, y)) / D2R;
|
||||
return {
|
||||
t: at(mmss),
|
||||
lon: Math.round(p.lon * 1e6) / 1e6,
|
||||
lat: Math.round(p.lat * 1e6) / 1e6,
|
||||
height: heightM,
|
||||
heading: Math.round(heading * 10) / 10,
|
||||
pitch: Math.round(pitch * 10) / 10,
|
||||
};
|
||||
}
|
||||
const cameraPath = [
|
||||
camKey('0:00', 212, 4200, 30, 150),
|
||||
camKey('1:00', 218, 3200, 30, 200),
|
||||
camKey('2:00', 228, 2300, 31, 260),
|
||||
camKey('3:30', 248, 1800, 32),
|
||||
camKey('5:00', 278, 1550, 31, ROOF),
|
||||
camKey('6:30', 308, 1450, 30, ROOF),
|
||||
camKey('8:00', 338, 1350, 29, ROOF),
|
||||
camKey('9:40', 6, 1150, 26, ROOF + 20),
|
||||
camKey('11:30', 38, 1350, 29, ROOF),
|
||||
camKey('13:30', 72, 1650, 32),
|
||||
camKey('15:30', 108, 1900, 34),
|
||||
camKey('17:30', 142, 1600, 31, ROOF),
|
||||
camKey('19:40', 172, 1300, 27, DECK_102),
|
||||
camKey('21:30', 198, 2000, 31),
|
||||
camKey('23:30', 216, 3000, 31, 220),
|
||||
camKey('25:00', 224, 4000, 30, 150),
|
||||
];
|
||||
|
||||
/* ── Assemble ───────────────────────────────────────────────────── */
|
||||
const jitter = (t, s) => t + Math.round(gauss() * s * 1000);
|
||||
const chat = CHAT.map(([mmss, handle, text, badge]) => ({
|
||||
t: jitter(at(mmss), 1.2),
|
||||
handle,
|
||||
text,
|
||||
...(badge ? { badge } : {}),
|
||||
})).sort((a, b) => a.t - b.t);
|
||||
const posts = POSTS.map(([mmss, handle, kind, text, votes], i) => ({
|
||||
id: `p${i + 1}`,
|
||||
t: at(mmss),
|
||||
handle,
|
||||
kind,
|
||||
text,
|
||||
votes,
|
||||
...(captures.find(
|
||||
(c) => c.handle === handle && Math.abs(c.t - at(mmss)) < 60_000,
|
||||
)
|
||||
? {
|
||||
captureId: captures.find(
|
||||
(c) => c.handle === handle && Math.abs(c.t - at(mmss)) < 60_000,
|
||||
).id,
|
||||
}
|
||||
: {}),
|
||||
}));
|
||||
|
||||
const moment = {
|
||||
id: 'empire-state',
|
||||
title: 'Climbers on the Empire State Building',
|
||||
place: 'Midtown Manhattan, New York',
|
||||
lon: ESB.lon,
|
||||
lat: ESB.lat,
|
||||
time: { start: T0, end: T0 + DURATION_S * 1000 },
|
||||
illustrative: true,
|
||||
illustrativeNote:
|
||||
'Illustrative reconstruction. The event, aircraft, callsigns, people, chat and posts are fictional; the place, building heights and heliports are real.',
|
||||
recommendedSpeed: 30,
|
||||
groundMslM: GROUND_MSL,
|
||||
subject: {
|
||||
lon: ESB.lon,
|
||||
lat: ESB.lat,
|
||||
altM: DECK_86,
|
||||
label: 'Empire State Building',
|
||||
roofM: ROOF,
|
||||
antennaM: ANTENNA,
|
||||
decks: [DECK_86, DECK_102],
|
||||
},
|
||||
stage: {
|
||||
buildings: 'midtown-buildings.json',
|
||||
water: 'midtown-water.json',
|
||||
highlight: '1015862',
|
||||
},
|
||||
wind: WIND,
|
||||
cameraPath,
|
||||
beats: BEATS.map(([mmss, title]) => ({ t: at(mmss), title })),
|
||||
tracks,
|
||||
captures,
|
||||
chat,
|
||||
posts,
|
||||
sources: [
|
||||
{
|
||||
name: 'Illustrative reconstruction',
|
||||
detail:
|
||||
'Helicopter tracks are synthetic, from a flight model (constant airspeed, 14 kt wind, rate-limited turns) shaped like real news and police orbits. Callsigns and tail numbers are fictional and are not real registrations.',
|
||||
},
|
||||
{
|
||||
name: 'NYC Open Data · Building Footprints',
|
||||
detail:
|
||||
'Real footprints and roof heights for Midtown (NYC OTI, dataset 5zhs-2jue).',
|
||||
},
|
||||
{
|
||||
name: 'OpenStreetMap water polygons',
|
||||
detail: 'Hudson and East River surfaces (ODbL, via OpenFreeMap).',
|
||||
},
|
||||
{
|
||||
name: 'Esri World Imagery',
|
||||
detail: 'Base imagery of the real place.',
|
||||
},
|
||||
{
|
||||
name: 'Simulated Live and Record lanes',
|
||||
detail:
|
||||
'Chat, posts, handles and witness captures are simulated to show how a room forms around an event.',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
await mkdir(path.join(HERE, 'moments'), { recursive: true });
|
||||
const file = path.join(HERE, 'moments', 'empire-state.json');
|
||||
await writeFile(file, await prettyJson(moment));
|
||||
console.log(
|
||||
`wrote ${file}: ${tracks.map((t) => `${t.id} ${t.points.length} pts`).join(', ')}, ` +
|
||||
`${captures.length} captures, ${chat.length} chat, ${posts.length} posts`,
|
||||
);
|
||||
@@ -0,0 +1,116 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Builds mobile/demo/midtown-water.json: the Hudson and East River around
|
||||
* the Empire State Moment, from OpenStreetMap water polygons (OpenFreeMap
|
||||
* vector tiles, OpenMapTiles schema, ODbL).
|
||||
*
|
||||
* Why: Esri World Imagery stitches two different water sources across the
|
||||
* Hudson, and from a low oblique camera the seam reads as a black-and-olive
|
||||
* staircase. The stage paints these real water polygons over the imagery so
|
||||
* the rivers read as one calm dark surface.
|
||||
*
|
||||
* node mobile/demo/build-midtown-water.mjs
|
||||
*
|
||||
* Output: { origin:[lon,lat], source, fetchedAt, polys:[[[x0,y0,…], hole…]] }
|
||||
* with integer metres east/north of `origin`.
|
||||
*/
|
||||
import { writeFile } from 'node:fs/promises';
|
||||
import { createRequire } from 'node:module';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import path from 'node:path';
|
||||
|
||||
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
||||
const require = createRequire(path.join(HERE, '..', '..', 'package.json'));
|
||||
const vt = await import(require.resolve('@mapbox/vector-tile'));
|
||||
const VectorTile = vt.VectorTile || vt.default?.VectorTile;
|
||||
const pbfMod = await import(require.resolve('pbf'));
|
||||
const Pbf = pbfMod.default?.default || pbfMod.default || pbfMod.PbfReader;
|
||||
|
||||
const ORIGIN = [-73.9857, 40.7484];
|
||||
const BBOX = { west: -74.08, east: -73.88, south: 40.68, north: 40.82 };
|
||||
const Z = 13;
|
||||
const kx = 111_320 * Math.cos((ORIGIN[1] * Math.PI) / 180);
|
||||
const ky = 110_574;
|
||||
|
||||
async function prettyJson(value) {
|
||||
const text = JSON.stringify(value);
|
||||
try {
|
||||
const prettier = await import('prettier');
|
||||
return await prettier.format(text, { parser: 'json' });
|
||||
} catch {
|
||||
return `${text}\n`;
|
||||
}
|
||||
}
|
||||
|
||||
const tileX = (lon) => Math.floor(((lon + 180) / 360) * 2 ** Z);
|
||||
const tileY = (lat) => {
|
||||
const r = (lat * Math.PI) / 180;
|
||||
return Math.floor(
|
||||
((1 - Math.log(Math.tan(r) + 1 / Math.cos(r)) / Math.PI) / 2) * 2 ** Z,
|
||||
);
|
||||
};
|
||||
|
||||
const meta = await (await fetch('https://tiles.openfreemap.org/planet')).json();
|
||||
const template = meta.tiles[0];
|
||||
const polys = [];
|
||||
for (let x = tileX(BBOX.west); x <= tileX(BBOX.east); x++)
|
||||
for (let y = tileY(BBOX.north); y <= tileY(BBOX.south); y++) {
|
||||
const url = template.replace('{z}', Z).replace('{x}', x).replace('{y}', y);
|
||||
const r = await fetch(url);
|
||||
if (!r.ok) throw new Error(`tile ${x}/${y} HTTP ${r.status}`);
|
||||
const tile = new VectorTile(new Pbf(new Uint8Array(await r.arrayBuffer())));
|
||||
const water = tile.layers.water;
|
||||
if (!water) continue;
|
||||
for (let i = 0; i < water.length; i++) {
|
||||
const gj = water.feature(i).toGeoJSON(x, y, Z);
|
||||
const list =
|
||||
gj.geometry.type === 'Polygon'
|
||||
? [gj.geometry.coordinates]
|
||||
: gj.geometry.type === 'MultiPolygon'
|
||||
? gj.geometry.coordinates
|
||||
: [];
|
||||
for (const rings of list) {
|
||||
const out = rings.map((ring) => {
|
||||
const flat = [];
|
||||
let px = null;
|
||||
let py = null;
|
||||
for (const [lon, lat] of ring) {
|
||||
const mx = Math.round((lon - ORIGIN[0]) * kx);
|
||||
const my = Math.round((lat - ORIGIN[1]) * ky);
|
||||
if (px !== null && Math.abs(mx - px) < 8 && Math.abs(my - py) < 8)
|
||||
continue;
|
||||
flat.push(mx, my);
|
||||
px = mx;
|
||||
py = my;
|
||||
}
|
||||
return flat;
|
||||
});
|
||||
// Drop ponds: only rivers, bays and harbour (big outer rings).
|
||||
const o = out[0];
|
||||
let minX = Infinity;
|
||||
let maxX = -Infinity;
|
||||
let minY = Infinity;
|
||||
let maxY = -Infinity;
|
||||
for (let k = 0; k < o.length; k += 2) {
|
||||
minX = Math.min(minX, o[k]);
|
||||
maxX = Math.max(maxX, o[k]);
|
||||
minY = Math.min(minY, o[k + 1]);
|
||||
maxY = Math.max(maxY, o[k + 1]);
|
||||
}
|
||||
if (o.length >= 6 && maxX - minX + (maxY - minY) > 600)
|
||||
polys.push(out.filter((ring) => ring.length >= 6));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const doc = {
|
||||
origin: ORIGIN,
|
||||
source:
|
||||
'OpenStreetMap contributors (ODbL) via OpenFreeMap vector tiles, water layer',
|
||||
fetchedAt: new Date().toISOString(),
|
||||
count: polys.length,
|
||||
polys,
|
||||
};
|
||||
const file = path.join(HERE, 'midtown-water.json');
|
||||
await writeFile(file, await prettyJson(doc));
|
||||
console.log(`wrote ${polys.length} water polygons → ${file}`);
|
||||
@@ -0,0 +1,139 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Builds mobile/demo/midtown-buildings.json: real building massing for the
|
||||
* Empire State Moment, from NYC Open Data "Building Footprints" (DoITT,
|
||||
* dataset 5zhs-2jue, roof heights from photogrammetry). Every building
|
||||
* within 1.6 km of the Empire State Building whose roof is over 60 ft, and
|
||||
* towers over 200 ft out to 2.6 km (so the massing thins out instead of
|
||||
* ending at a hard circle).
|
||||
*
|
||||
* node mobile/demo/build-midtown.mjs [path/to/cached.json]
|
||||
*
|
||||
* Output: { origin:[lon,lat], source, fetchedAt, b:[[roofM, groundM, x0,y0,x1,y1,…]] }
|
||||
* where x/y are integer metres east/north of `origin` (outer ring only,
|
||||
* Douglas-Peucker simplified to 1 m).
|
||||
*/
|
||||
import { readFile, writeFile } from 'node:fs/promises';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import path from 'node:path';
|
||||
|
||||
/** JSON as the repo's Prettier would write it (so `prettier --check` passes). */
|
||||
async function prettyJson(value) {
|
||||
const text = JSON.stringify(value);
|
||||
try {
|
||||
const prettier = await import('prettier');
|
||||
return await prettier.format(text, { parser: 'json' });
|
||||
} catch {
|
||||
return `${text}\n`;
|
||||
}
|
||||
}
|
||||
|
||||
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
||||
const ORIGIN = [-73.9857, 40.7484];
|
||||
const FT = 0.3048;
|
||||
|
||||
async function load() {
|
||||
const cached = process.argv[2];
|
||||
if (cached) return JSON.parse(await readFile(cached, 'utf8'));
|
||||
const url = new URL('https://data.cityofnewyork.us/resource/5zhs-2jue.json');
|
||||
url.searchParams.set(
|
||||
'$select',
|
||||
'the_geom,height_roof,ground_elevation,bin,construction_year',
|
||||
);
|
||||
url.searchParams.set(
|
||||
'$where',
|
||||
`within_circle(the_geom,${ORIGIN[1]},${ORIGIN[0]},2600) AND height_roof > 60`,
|
||||
);
|
||||
url.searchParams.set('$limit', '20000');
|
||||
const r = await fetch(url);
|
||||
if (!r.ok) throw new Error(`NYC Open Data HTTP ${r.status}`);
|
||||
return r.json();
|
||||
}
|
||||
|
||||
const kx = 111_320 * Math.cos((ORIGIN[1] * Math.PI) / 180);
|
||||
const ky = 110_574;
|
||||
const toXY = ([lon, lat]) => [(lon - ORIGIN[0]) * kx, (lat - ORIGIN[1]) * ky];
|
||||
|
||||
function simplify(pts, tol) {
|
||||
if (pts.length < 4) return pts;
|
||||
const keep = new Uint8Array(pts.length);
|
||||
keep[0] = keep[pts.length - 1] = 1;
|
||||
const stack = [[0, pts.length - 1]];
|
||||
while (stack.length) {
|
||||
const [a, b] = stack.pop();
|
||||
const [ax, ay] = pts[a];
|
||||
const [bx, by] = pts[b];
|
||||
const dx = bx - ax;
|
||||
const dy = by - ay;
|
||||
const len = Math.hypot(dx, dy) || 1e-9;
|
||||
let best = -1;
|
||||
let bestD = tol;
|
||||
for (let i = a + 1; i < b; i++) {
|
||||
const d = Math.abs((pts[i][0] - ax) * dy - (pts[i][1] - ay) * dx) / len;
|
||||
if (d > bestD) {
|
||||
bestD = d;
|
||||
best = i;
|
||||
}
|
||||
}
|
||||
if (best > 0) {
|
||||
keep[best] = 1;
|
||||
stack.push([a, best], [best, b]);
|
||||
}
|
||||
}
|
||||
return pts.filter((_, i) => keep[i]);
|
||||
}
|
||||
|
||||
const rows = await load();
|
||||
const out = [];
|
||||
for (const row of rows) {
|
||||
const roof = Number(row.height_roof) * FT;
|
||||
if (!Number.isFinite(roof) || roof < 18) continue;
|
||||
const ground = Number(row.ground_elevation) * FT;
|
||||
const polys = row.the_geom?.coordinates || [];
|
||||
// The largest outer ring stands for the building.
|
||||
let ring = null;
|
||||
for (const poly of polys) {
|
||||
const r = poly[0];
|
||||
if (r && (!ring || r.length > ring.length)) ring = r;
|
||||
}
|
||||
if (!ring || ring.length < 4) continue;
|
||||
let pts = ring.map(toXY);
|
||||
const [fx, fy] = pts[0];
|
||||
const dist = Math.hypot(fx, fy);
|
||||
if (dist > 2600) continue;
|
||||
// Beyond 1.6 km only towers, thinning out with distance.
|
||||
if (dist > 1600 && roof < 61 + ((dist - 1600) / 1000) * 60) continue;
|
||||
// Open ring: split at the farthest vertex so both halves simplify cleanly.
|
||||
pts = pts.slice(0, -1);
|
||||
let far = 1;
|
||||
for (let i = 1; i < pts.length; i++)
|
||||
if (
|
||||
Math.hypot(pts[i][0] - pts[0][0], pts[i][1] - pts[0][1]) >
|
||||
Math.hypot(pts[far][0] - pts[0][0], pts[far][1] - pts[0][1])
|
||||
)
|
||||
far = i;
|
||||
pts = [
|
||||
...simplify(pts.slice(0, far + 1), 1.0),
|
||||
...simplify([...pts.slice(far), pts[0]], 1.0).slice(1, -1),
|
||||
];
|
||||
if (pts.length < 3) continue;
|
||||
const flat = [];
|
||||
for (const [x, y] of pts) flat.push(Math.round(x), Math.round(y));
|
||||
out.push([
|
||||
Math.round(roof * 10) / 10,
|
||||
Number.isFinite(ground) ? Math.round(ground * 10) / 10 : 15,
|
||||
...flat,
|
||||
]);
|
||||
}
|
||||
out.sort((a, b) => a[0] - b[0]);
|
||||
const doc = {
|
||||
origin: ORIGIN,
|
||||
source:
|
||||
'NYC Open Data · Building Footprints (NYC OTI/DoITT, 5zhs-2jue), roof heights from photogrammetry',
|
||||
fetchedAt: new Date().toISOString(),
|
||||
count: out.length,
|
||||
b: out,
|
||||
};
|
||||
const file = path.join(HERE, 'midtown-buildings.json');
|
||||
await writeFile(file, await prettyJson(doc));
|
||||
console.log(`wrote ${out.length} buildings → ${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');
|
||||
@@ -0,0 +1,144 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Regenerates the bundled first-frame snapshot (DESIGN.md §12.8):
|
||||
*
|
||||
* mobile/demo/snapshot.json the live situations() result, with capturedAt
|
||||
* mobile/demo/thumbs/<id>.jpg each card's composited thumbnail
|
||||
*
|
||||
* It drives the real engine in headless Chromium against a running dev
|
||||
* server, so every card is real data at capture time.
|
||||
*
|
||||
* npm run dev # or: npx vite --port 4173
|
||||
* node mobile/demo/capture-snapshot.mjs [--base http://localhost:4173]
|
||||
* [--chrome /path/to/chrome] [--max 28] [--quality 0.8]
|
||||
* [--lat 40.75 --lon -73.98] # a viewer position for distances
|
||||
*
|
||||
* Budget: thumbnails are 360×480 CSS px at 1.25× and JPEG-recompressed so
|
||||
* the whole folder stays under ~3 MB.
|
||||
*/
|
||||
import { mkdir, readdir, rm, writeFile } from 'node:fs/promises';
|
||||
import { createRequire } from 'node:module';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
||||
const require = createRequire(path.join(HERE, '..', '..', 'package.json'));
|
||||
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:4173';
|
||||
const MAX = Number(args.max || 28);
|
||||
const QUALITY = Number(args.quality || 0.8);
|
||||
const BUDGET = 3 * 1024 * 1024;
|
||||
|
||||
const puppeteer = (await import(require.resolve('puppeteer'))).default;
|
||||
const browser = await puppeteer.launch({
|
||||
executablePath: args.chrome || process.env.CHROME_PATH || undefined,
|
||||
args: [
|
||||
'--use-gl=angle',
|
||||
'--use-angle=swiftshader',
|
||||
'--enable-unsafe-swiftshader',
|
||||
'--ignore-gpu-blocklist',
|
||||
'--no-sandbox',
|
||||
],
|
||||
protocolTimeout: 600_000,
|
||||
});
|
||||
try {
|
||||
const page = await browser.newPage();
|
||||
await page.setViewport({ width: 430, height: 932, deviceScaleFactor: 1 });
|
||||
page.on('pageerror', (e) => console.warn('[page]', e.message));
|
||||
await page.goto(`${BASE}/mobile/globe/dev.html?hud=0&labels=0`, {
|
||||
waitUntil: 'domcontentloaded',
|
||||
timeout: 120_000,
|
||||
});
|
||||
await page.waitForFunction('window.globe', { timeout: 120_000 });
|
||||
console.log('engine up; building live situations (this takes ~30–60 s)…');
|
||||
const viewer =
|
||||
args.lat && args.lon
|
||||
? { lat: Number(args.lat), lon: Number(args.lon) }
|
||||
: null;
|
||||
const result = await page.evaluate(
|
||||
async ({ max, quality, viewer }) => {
|
||||
if (viewer) globe.setViewer(viewer);
|
||||
// Warm the recorder a little so detections have a chance to show.
|
||||
const list = (await globe.situations()).slice(0, max);
|
||||
const out = [];
|
||||
for (const s of list) {
|
||||
let thumb = null;
|
||||
try {
|
||||
const url = await globe.thumbnail(s, {
|
||||
width: 360,
|
||||
height: 480,
|
||||
dpr: 1.25,
|
||||
});
|
||||
const img = await createImageBitmap(await (await fetch(url)).blob());
|
||||
const c = document.createElement('canvas');
|
||||
c.width = img.width;
|
||||
c.height = img.height;
|
||||
c.getContext('2d').drawImage(img, 0, 0);
|
||||
const blob = await new Promise((r) =>
|
||||
c.toBlob(r, 'image/jpeg', quality),
|
||||
);
|
||||
const buf = new Uint8Array(await blob.arrayBuffer());
|
||||
let bin = '';
|
||||
for (let i = 0; i < buf.length; i += 0x8000)
|
||||
bin += String.fromCharCode(...buf.subarray(i, i + 0x8000));
|
||||
thumb = btoa(bin);
|
||||
} catch (error) {
|
||||
console.warn('thumbnail failed', s.id, error?.message);
|
||||
}
|
||||
out.push({ situation: JSON.parse(JSON.stringify(s)), thumb });
|
||||
}
|
||||
return out;
|
||||
},
|
||||
{ max: MAX, quality: QUALITY, viewer },
|
||||
);
|
||||
const thumbsDir = path.join(HERE, 'thumbs');
|
||||
await mkdir(thumbsDir, { recursive: true });
|
||||
for (const f of await readdir(thumbsDir))
|
||||
if (f.endsWith('.jpg')) await rm(path.join(thumbsDir, f));
|
||||
let bytes = 0;
|
||||
const situations = [];
|
||||
for (const { situation, thumb } of result) {
|
||||
const file = `${String(situation.id).replace(/[^\w.-]+/g, '_')}.jpg`;
|
||||
if (thumb) {
|
||||
const buf = Buffer.from(thumb, 'base64');
|
||||
if (bytes + buf.length > BUDGET) {
|
||||
console.warn(`budget reached; ${situation.id} ships without a thumb`);
|
||||
} else {
|
||||
bytes += buf.length;
|
||||
await writeFile(path.join(thumbsDir, file), buf);
|
||||
situation.thumbFile = `thumbs/${file}`;
|
||||
}
|
||||
}
|
||||
situations.push(situation);
|
||||
}
|
||||
const snapshot = {
|
||||
capturedAt: new Date().toISOString(),
|
||||
source:
|
||||
'Live God’s Eye feeds through the dev server (see each card’s source)',
|
||||
note: 'Bundled first frame for demos. The shell shows capturedAt and replaces these with live cards as soon as situations() resolves.',
|
||||
situations,
|
||||
};
|
||||
let text = JSON.stringify(snapshot);
|
||||
try {
|
||||
const prettier = await import('prettier');
|
||||
text = await prettier.format(text, { parser: 'json' });
|
||||
} catch {
|
||||
text += '\n';
|
||||
}
|
||||
await writeFile(path.join(HERE, 'snapshot.json'), text);
|
||||
console.log(
|
||||
`wrote ${situations.length} situations, ${Math.round(bytes / 1024)} KB of thumbnails`,
|
||||
);
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"format": "gev-history-index/1",
|
||||
"capturedAt": {
|
||||
"start": "2026-10-23T08:25:37.000Z",
|
||||
"end": "2026-10-23T11:06:30.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": 525
|
||||
},
|
||||
{
|
||||
"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": 1559
|
||||
},
|
||||
{
|
||||
"id": "gulf",
|
||||
"label": "Gulf of Mexico",
|
||||
"bbox": { "west": -98.5, "south": 23.5, "east": -81.5, "north": 31.2 },
|
||||
"file": "gulf.json",
|
||||
"tracks": 493
|
||||
}
|
||||
]
|
||||
}
|
||||
|
After Width: | Height: | Size: 142 KiB |
|
After Width: | Height: | Size: 66 KiB |
|
After Width: | Height: | Size: 60 KiB |
|
After Width: | Height: | Size: 59 KiB |
|
After Width: | Height: | Size: 59 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 56 KiB |
|
After Width: | Height: | Size: 58 KiB |
|
After Width: | Height: | Size: 52 KiB |
|
After Width: | Height: | Size: 67 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 49 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 69 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 54 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 60 KiB |
|
After Width: | Height: | Size: 63 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 57 KiB |
|
After Width: | Height: | Size: 52 KiB |
|
After Width: | Height: | Size: 75 KiB |
|
After Width: | Height: | Size: 65 KiB |
|
After Width: | Height: | Size: 60 KiB |
@@ -0,0 +1,319 @@
|
||||
# Globe API: round-two additions
|
||||
|
||||
These additions extend DESIGN.md §10 and change no existing signature. Import them from `mobile/globe/index.js`. The doc comment at the top of that file repeats this contract.
|
||||
|
||||
## Viewer position
|
||||
|
||||
```js
|
||||
globe.setViewer({ lon, lat } | null) // where the person is: sets distanceKm and the ISS/Starlink "over you" cards
|
||||
globe.viewer() → { lon, lat } | null
|
||||
```
|
||||
|
||||
## Situations (extended)
|
||||
|
||||
```js
|
||||
globe.situations({ observer? }) → Promise<Situation[]>
|
||||
globe.omitted() → [{ genre, reason }] // genres the last call left out, and why
|
||||
globe.timings() → { [genre]: ms } // per-genre build time of the last call
|
||||
```
|
||||
|
||||
`Situation` keeps every field from round one (`id, kind, family, section, title, subtitle, place, lon, lat, camera, layers, time?, metrics, thumbnail, live, updatedAt, source, severity`) and adds these:
|
||||
|
||||
| Field | Type | Notes |
|
||||
| -------------- | --------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `band` | `'orbit' \| 'sky' \| 'ground' \| 'inside'` | The altitude band of the sensor. The engine never emits `inside`, because only the shell's own witness and room cards are inside. |
|
||||
| `archetype` | `'sensor' \| 'eye' \| 'track' \| 'number'` | The card layout. The shell adds `witness` and `room` cards itself. |
|
||||
| `telemetry` | `string` | A short uppercase strip, for example `SKY · FL206 · 332 KT · SQ 7700 · EZY58XM`, `GROUND · CAM 07450 · PICCADILLY CIRCUS · 02:16:14Z` or `ORBIT · ISS · 433 KM · 7.65 KM/S`. Draw it in HTML in a monospace face. |
|
||||
| `media?` | `{ kind:'image', url, refreshMs, cameraId, headingDeg?, provider?, city? }` | Eye cards only. `url` is a same-origin frame from `/api/cctv/frame/…` that was checked to be a real upstream image, not the proxy's synthetic fallback. For a fresh frame, call `globe.eyeFrameUrl(cameraId)` every `refreshMs`. |
|
||||
| `series?` | `[{ t, v }]` | Real history for number cards, and for some sensor cards. `t` is epoch ms. |
|
||||
| `seriesLabel?` | `string` | The quantity `v` measures, for example `'lightning-active area (km²) per 15 min'`. |
|
||||
| `headline?` | `{ value, unit, label }` | The one big number on a track or number card. |
|
||||
| `distanceKm?` | `number` | The great-circle distance from the viewer. It is present only once `setViewer` has been called, or when `observer` is passed. |
|
||||
|
||||
The `thumbnail` field is still a plain Esri still, so older code keeps working. New code calls `globe.thumbnail(situation)` instead.
|
||||
|
||||
The engine orders the cards by severity, then interleaves them so neighbouring cards differ in band and archetype where possible.
|
||||
|
||||
## Card compositor
|
||||
|
||||
```js
|
||||
globe.thumbnail(situation, { width = 360, height = 480, dpr = 2 } = {}) → Promise<string /* blob: object URL */>
|
||||
```
|
||||
|
||||
The compositor builds each thumbnail as a JPEG in an offscreen 2D canvas, never in the Cesium viewer. It works in three layers:
|
||||
|
||||
1. **Base imagery.** Esri World Imagery tiles, which are CORS-clean, or NASA GIBS Black Marble for orbit cards.
|
||||
2. **Real overlays.** The data that describes the situation. Examples include NOAA IR with a cold-top enhancement, MRMS radar, lightning density dots by age, the NHC cone with the forecast track and 34/50/64-kt wind radii, aircraft with 2-minute velocity leaders, quake rings, fires with a smoke plume along the observed wind, and SGP4 ground tracks.
|
||||
3. **Grade.** A tint for the altitude band (orbit violet night, sky cool, ground amber) and a vignette.
|
||||
|
||||
The compositor draws no text and no brackets.
|
||||
|
||||
Eye cards return the camera frame itself, lightly graded. Results are cached in memory and, when the page is a secure context, in the Cache API, keyed by id, size and an update bucket. Eye cards refresh every 30 s; other cards follow `updatedAt` in 5-minute buckets. Tiles are shared across cards, and at most 3 cards render at once.
|
||||
|
||||
If an overlay fails, the card still gets its base picture. The promise rejects only when the base imagery or the camera frame fails. In that case, fall back to `situation.thumbnail` or to the shell's own placeholder.
|
||||
|
||||
## Pins (discourse and capture)
|
||||
|
||||
```js
|
||||
globe.addPin({ id, lon, lat, kind, headingDeg?, fovDeg = 60, rangeM = 350, color?, label?, altM? }) → id
|
||||
// kind: 'post' | 'witness' | 'eye' | 'live' | 'hologram'
|
||||
// 'live' and 'eye' pins draw a view-frustum wedge on the ground along headingDeg
|
||||
globe.removePin(id) → boolean
|
||||
globe.pins() → [{ id, lon, lat, kind, headingDeg, fovDeg, rangeM, color, label, altM }]
|
||||
```
|
||||
|
||||
- Picking a pin emits `'pick'` with `{ kind: 'pin', pinKind, id, title, subtitle, lon, lat, meta: { layer: 'pins', headingDeg, fovDeg, label } }`.
|
||||
- Pins appear in `globe.labels()` with `layer: 'pins'` and `pinKind`.
|
||||
- Live pins breathe at 1 Hz.
|
||||
|
||||
## Labels
|
||||
|
||||
Each entry from `globe.labels(max)` can now carry an optional `detail`, a short intensity tag:
|
||||
|
||||
- Cyclones: `CAT 2 · 85 KT · 974 HPA`
|
||||
- Emergency squawks: `SQ 7700`
|
||||
- Air quality: `AQI 152`
|
||||
|
||||
## Nearest public camera
|
||||
|
||||
```js
|
||||
globe.nearestEye(lon, lat, km = 50)
|
||||
→ Promise<{ id, title, lon, lat, distanceKm, headingDeg, fovDeg, imageUrl, city, provider } | null>
|
||||
globe.eyeFrameUrl(cameraId) → string | null // a fresh same-origin frame URL
|
||||
```
|
||||
|
||||
`nearestEye` reads the CCTV catalog at `/api/cctv/sources`, which serves about 4,000 cameras across London, Toronto, Sydney, Seattle, the Bay Area, Los Angeles, Helsinki/Finland, Tallinn, Barcelona, Calgary, Norway and other places. It does not check the frame. A camera can still answer with the proxy's synthetic image when its upstream is down.
|
||||
|
||||
## Weather and storm layers (now available in `globe.layers()`)
|
||||
|
||||
| id | Family | Source | Time |
|
||||
| ------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------- |
|
||||
| `radar` | Weather | NOAA MRMS base reflectivity through nowCOAST (`/api/weather`), CONUS | Scrubbing replays about 50 min of 4-minute frames |
|
||||
| `clouds` | Weather | NOAA global longwave IR mosaic through nowCOAST, drawn as an unlit cloud layer (the alpha-keyed IR) | 6 h of hourly frames |
|
||||
| `lightning` | Weather | NOAA nowCOAST lightning density, 15 min (Americas and Pacific), with a flickering glow per active 0.5° cell | 3 h of 15-minute frames |
|
||||
| `wind` | Weather | 10 m wind, drawn with the desktop app's GPU streamline renderer. Uses `/api/wind` GFS when the server can decode GRIB, otherwise a 12° Open-Meteo grid | Live |
|
||||
| `jet` | Weather | Jet stream at 250 hPa from Open-Meteo, same renderer | Live |
|
||||
| `cyclones` | Hazards | Now also draws the filled NHC cone and 34/50/64-kt wind radii, parsed from the NHC TCM text at api.weather.gov | Forecast scrubbing, as before |
|
||||
| `air-quality` | Hazards | Open-Meteo Air Quality (CAMS model), PM2.5 AQI for 40 major cities | Live |
|
||||
| `gnss` | Infra | `/api/gnss-integrity` (adsb.lol NIC/NACp), shown as 0.5° cells in gpsjam.org bands. From orbit it samples the Baltic, Black Sea, Levant, Gulf and Gulf of Finland; close in, it samples the view | Live |
|
||||
| `transit` | Ground | `/api/transit` GTFS-realtime vehicles from the open feed nearest the view | Live, 20 s |
|
||||
|
||||
Rasters are drawn a few kilometres above the ground and unlit, so storms stay visible on the night side. When an upstream is down, the layer reports `status: 'error'` with a `message`.
|
||||
|
||||
When a weather layer is on, `layers()` entries can also carry `frames` (the number of frames available) and a `message` that gives the product title and the frame age.
|
||||
|
||||
When NHC has published an advisory but its cone geometry is still pending, the cyclone layer and cards take the forecast positions from the advisory text, so a track is still drawn but there is no cone.
|
||||
|
||||
## Dev harnesses
|
||||
|
||||
- `mobile/globe/dev.html` shows the globe full-screen and exposes `window.globe`. Its labels show `detail`.
|
||||
- `mobile/globe/contact.html` is the contact sheet: it calls `situations()`, then `thumbnail()` for every card.
|
||||
- `?lat=&lon=` sets the viewer.
|
||||
- `?only=cyclone,eye` filters cards by id prefix.
|
||||
- `?w=330` sets the card width.
|
||||
|
||||
---
|
||||
|
||||
# Round three: Gotham for everyone (DESIGN.md §12)
|
||||
|
||||
Additive. Nothing above changes. Every call below exists on the object `createGlobe()` resolves to. Times are epoch ms unless a `Date` is shown.
|
||||
|
||||
## Instant first frame
|
||||
|
||||
```js
|
||||
globe.situations({ cached: true }) → Promise<Situation[]>
|
||||
// resolves at once from the last good IndexedDB snapshot (written by every live
|
||||
// situations() call), else the bundled mobile/demo/snapshot.json (real data).
|
||||
// Each cached Situation adds: cached: true, cacheOrigin: 'device' | 'bundled',
|
||||
// capturedAt (epoch ms), live: false, thumbUrl (bundled /mobile/demo/thumbs/<id>.jpg
|
||||
// or a Cache API blob URL) or null.
|
||||
globe.situations() → Promise<Situation[]> // live, as before (also saves the cache)
|
||||
globe.cachedThumbnail(situation) → string | null // sync; thumbUrl for a cached card
|
||||
globe.thumbnail(cachedSituation) → resolves to the cached JPEG when the card has no live scene
|
||||
```
|
||||
|
||||
The globe paints its first frame with imagery only. Live layers start right after it, and weather, transit, GNSS, Starlink and satellites start about a second later. Background warm-ups (jet, TCM, AQ, CCTV, rasters) start at 4 s.
|
||||
|
||||
## Recorder and time machine
|
||||
|
||||
```js
|
||||
globe.history() → { start, end, entities, points, bytes } // recorded range; start/end null when empty
|
||||
globe.setTime(t) // unchanged. Inside the recorded range, contacts are drawn at their interpolated
|
||||
// recorded positions with 4-minute ghost trails (pickable: 'pick' kind is the
|
||||
// contact's kind, meta.layer 'history', entityId). Beyond the record, dimmed as before.
|
||||
globe.trackOf(entityId) → Promise<[{ t, lon, lat, altM, headingDeg, speed }]>
|
||||
// recorder samples merged with /api/flights/track (OpenSky) or /api/military/track
|
||||
// (adsb.lol traces); the array has .source
|
||||
|
||||
globe.playback.play({ speed = 60, from?, until? } = {}) → state
|
||||
// speeds: 1, 10, 30, 60, 600. From live it replays the last 15 min. Reaching "now" goes live;
|
||||
// inside a Moment it stops at the Moment's end.
|
||||
globe.playback.stop() → state
|
||||
globe.playback.state() → { playing, speed, t, live }
|
||||
globe.playback.speeds // [1, 10, 30, 60, 600]
|
||||
// event 'playback' → state, on start/stop and every 250 ms while playing.
|
||||
// 'time' is also emitted every 250 ms while playing ({ live:false, time, playing:true }).
|
||||
// Playing runs the render loop; at rest the globe is request-render again.
|
||||
|
||||
globe.timeline({ from, to } = {}) → [{ t, kind, band, title, severity, situationId?, entityId?, momentId?, lon?, lat? }]
|
||||
// kind: 'quake' | 'quake-cluster' | 'launch' | 'squawk' | 'cyclone' | 'fire' | 'circling'
|
||||
// | 'lightning' | 'moment' (a Moment beat) | 'capture' | 'post' | 'chat-burst'
|
||||
// band: 'orbit' | 'sky' | 'ground' | 'inside'; severity 0..3; sorted by t.
|
||||
// Defaults to timeWindow(). Moment items appear for Moments that were loaded and overlap the range.
|
||||
```
|
||||
|
||||
The recorder keeps every poll for low traffic (helicopters, approaches, anything below about 6 km) and one fix every 50 s for cruise traffic. Samples older than 30 min are thinned. The buffer is capped at 1.2 M samples (about 24 MB) and persisted to IndexedDB about once a minute, so a reload keeps up to 6 h. When OpenSky is down, the recorder also polls adsb.lol snapshots around New York, London, Los Angeles and the viewer every 15 s, so the detector has real data.
|
||||
|
||||
Entity ids are layer-qualified: `flights:<icao24>`, `military:<hex>`, `satellites:<norad>`, `earthquakes:<id>`, `cctv:<id>`, `vessels:<mmsi>`, `situation:<id>`, `place:<slug>`, `moment:<momentId>`, `moment:<momentId>:<trackId>`, `capture:<momentId>:<captureId>`. Every `'pick'` payload can be passed to `entity()` as is.
|
||||
|
||||
## Detection
|
||||
|
||||
Detections run on recorder data every poll. They appear only when real data shows them.
|
||||
|
||||
- `circling`: two or more aircraft (rotorcraft and low-and-slow preferred, below about 8,000 ft and 155 kt), each turning through at least 360° within about 1.5 km of a common centre in the last 20 min. Card: `{ kind:'circling', band:'sky', archetype:'track', title:'3 helicopters circling Midtown Manhattan', momentId:'live-circling-…', members:[{ key, layer, callsign, helicopter }], entityIds, detected:true }`. Its thumbnail draws the recorded orbit tracks. A single aircraft on its second full orbit is a lesser detection (severity 4.5, `single: true`, for example "Helicopter circling Las Vegas Strip"). `momentOfTheDay()` ignores single orbits.
|
||||
- `quake-cluster`: three or more M2.5+ earthquakes within 50 km in 24 h. Archetype `number`, with a magnitude series.
|
||||
- `squawk` cards now carry `momentId: 'live-squawk-<icao24>'`.
|
||||
|
||||
```js
|
||||
globe.detections() → Situation[] // current detections, sync
|
||||
// event 'detections' → detector output, when the set of circling detections changes
|
||||
```
|
||||
|
||||
## Moments
|
||||
|
||||
```js
|
||||
globe.moments() → Promise<Moment[]> // live (circling, squawks) first, then the bundled hero
|
||||
globe.moment(id) → Promise<Moment|null> // 'empire-state' | 'live-circling-…' | 'live-squawk-<hex>'
|
||||
globe.momentOfTheDay() → Promise<Moment> // best live Moment if any, else the hero
|
||||
globe.playMoment(idOrMoment, { speed, camera = true, autoplay = true, framing = 6 } = {}) → Promise<Moment>
|
||||
// speed defaults to moment.recommendedSpeed (hero: 30 → 50 s). camera:false leaves the camera alone.
|
||||
// framing: degrees the cinematic camera looks below the subject (puts it in the upper half).
|
||||
// Hides every live layer, draws tracks (glyph, 90 s bright trail, faint flown path, drop line),
|
||||
// capture view frustums that pop in at their t, the subject building and its beacon, and flies
|
||||
// the camera path keyed to the clock. Touching the globe releases the camera.
|
||||
globe.exitMoment({ restoreCamera = true } = {}) // back to live (or the time before) and the old camera
|
||||
globe.activeMoment() → { id, title, illustrative, t, start, end, playing, speed, cinematic, moment } | null
|
||||
globe.seekMoment(t) // epoch ms, or a 0..1 fraction of the Moment
|
||||
globe.momentCamera(on = true) // re-attach (or release) the cinematic camera → boolean
|
||||
// event 'moment' → { type, item, t, momentId }
|
||||
// type: 'start' (item = Moment) | 'capture' | 'chat' | 'post' | 'beat' | 'end' | 'exit'
|
||||
// | 'camera' (item = { cinematic:false } when the person takes the camera)
|
||||
// Items fire as the clock passes them while playing, and on small seeks. A seek back, or a jump of
|
||||
// more than 90 s while paused, repositions silently: render the Live/Record lanes by filtering
|
||||
// activeMoment().moment.chat/posts/captures by t <= activeMoment().t.
|
||||
```
|
||||
|
||||
```ts
|
||||
Moment = {
|
||||
id, title, place, lon, lat,
|
||||
time: { start, end }, // epoch ms
|
||||
illustrative: boolean, // true for the bundled hero; false for live detections
|
||||
illustrativeNote?: string,
|
||||
recommendedSpeed?: number,
|
||||
subject?: { lon, lat, altM, label, roofM?, antennaM?, decks? },
|
||||
cameraPath: [{ t, lon, lat, height /* m above ground */, heading, pitch }],
|
||||
beats?: [{ t, title }],
|
||||
tracks: [{ id, kind /* 'helicopter'|'aircraft' */, label, operator, callsign?, registration?, type?,
|
||||
color?, entityId? /* live: 'flights:<hex>' */, points: [{ t, lon, lat, altM /* MSL */ }] }],
|
||||
captures: [{ id, t, lon, lat, altM, headingDeg, pitchDeg, fovDeg, rangeM, title, handle, place,
|
||||
sealed, firstEyes?, kind /* 'photo'|'clip' */, image? }],
|
||||
chat: [{ t, handle, text, badge? /* 'first-eyes'|'witness'|'local'|'pilot'|'met' */ }],
|
||||
posts: [{ id, t, handle, kind /* 'witness'|'context'|'question'|'correction' */, text, votes, captureId? }],
|
||||
sources: [{ name, detail }],
|
||||
}
|
||||
```
|
||||
|
||||
`playMoment`, `exitMoment` and `setTime(null)` are safe to call at any time, including mid-playback or while a load is still pending: a later call supersedes an earlier one, and the stage frees its primitives only after the frame in progress.
|
||||
|
||||
`labels()` collision-avoids each label as it will be drawn (`title · detail`). When a Moment label collides, it falls back to callsign plus altitude, then to the callsign alone. Such entries carry `abbreviated: true` and the shortened `title` and `detail`.
|
||||
|
||||
The hero is `mobile/demo/moments/empire-state.json` (id `empire-state`): "Climbers on the Empire State Building", 25 min, 3 helicopters, 8 captures, 120 chat lines and 12 posts. Regenerate it with `node mobile/demo/build-empire-state.mjs`. Its Midtown massing is real NYC Open Data footprints and roof heights in `mobile/demo/midtown-buildings.json` (`node mobile/demo/build-midtown.mjs`). Its river surfaces are OpenStreetMap water polygons in `mobile/demo/midtown-water.json` (`node mobile/demo/build-midtown-water.mjs`). They are drawn over Esri's mismatched water mosaic across the Hudson.
|
||||
|
||||
## Entities and dossiers
|
||||
|
||||
```js
|
||||
globe.entity(ref) → Promise<Dossier> // ref: entity id string, a 'pick' payload, or { kind, id }
|
||||
Dossier = {
|
||||
id, kind, title, subtitle,
|
||||
identity: { icao24?, callsign?, registration?, type?, typeCode?, operator?, route?, country?, squawk?,
|
||||
norad?, intlDesignator?, tle?, mmsi?, provider?, magnitude?, illustrative?, ... },
|
||||
live: { lon, lat, altM, speed /* m/s */, heading, squawk?, updatedAt, speedKt?, elevationDeg? } | null,
|
||||
track: [{ t, lon, lat, altM }],
|
||||
events: [{ t, title }],
|
||||
links: [{ id, kind, title, relation, distanceKm? }],
|
||||
sources: [{ name, fetchedAt, url?, hash /* 'sha256:…' (or 'fnv1a:…' outside secure contexts) */, detail? }],
|
||||
privacy?: 'pia' | 'ladd' | 'no-public-identity', // identity then holds only what the transponder broadcasts
|
||||
trackPending?: true, // the server track was slow; call trackOf(id) later for the full one
|
||||
nextPass?: { start, max, end, maxElevationDeg, visible }, // satellites, for the viewer
|
||||
illustrative?: true, // hero Moment tracks and captures
|
||||
}
|
||||
globe.search(query) → Promise<[{ id, kind, title, subtitle }]> // ≤ 30; "N46567", "UAL", "7700",
|
||||
// "M5.2", "ISS", "Midtown", "empire"… across live aircraft, the record, satellites, quakes,
|
||||
// cameras, situations, places (neighbourhoods, airports, cities) and Moments
|
||||
globe.graph(ref) → { center, nodes: [{ id, kind, title }], edges: [{ from, to, relation }] } // ≤ 40 nodes, sync
|
||||
```
|
||||
|
||||
Kinds: `aircraft`, `helicopter`, `military`, `satellite`, `vessel`, `quake`, `camera`, `situation`, `place`, `moment`, `track`, `capture`. There are never person entities. Aircraft dossiers wait at most 2.5 s for the server track (aircraft identity comes from adsbdb, 3.5 s budget).
|
||||
|
||||
## Reactions and attention
|
||||
|
||||
```js
|
||||
globe.react({ lon, lat, kind: 'heart'|'fire'|'eyes'|'wow', count = 1, altM = 0 }) → number
|
||||
globe.attentionHeat(points | null) // points: [{ lon, lat, weight? }]; soft glow where viewers are
|
||||
```
|
||||
|
||||
Reactions draw on a screen-space canvas that the globe owns (`.gev-reactions`, pointer-events none, above the globe and below your HTML). Each glyph stays anchored to its point as the globe moves. At most 300 are alive at once, and the canvas animates only while some are alive.
|
||||
|
||||
## Your sky now
|
||||
|
||||
```js
|
||||
globe.skyNow({ lon, lat } = viewer) → Promise<{
|
||||
at, observer, sunElevationDeg,
|
||||
aircraft: [{ id, title, kind, lon, lat, altM, distanceKm, elevationDeg, azimuthDeg, headingDeg, speedMps, squawk }],
|
||||
satellites: [{ id, title, norad, altKm, elevationDeg, azimuthDeg }],
|
||||
iss: { visibleNow, aboveHorizon, elevationDeg, azimuthDeg,
|
||||
nextPass: { start, max, end, maxElevationDeg, riseAzimuthDeg, visible } | null },
|
||||
sources: [string],
|
||||
}> // aircraft within 50 km, satellites above the horizon; each list sorted by elevation, highest first
|
||||
```
|
||||
|
||||
## Dev harness
|
||||
|
||||
`mobile/globe/dev.html?moment=empire-state&speed=30` plays a Moment on load and prints its events in the HUD. `globe.debug` exposes `{ recorder, detector, stage, ghosts }` for tests. It is not part of the contract.
|
||||
|
||||
---
|
||||
|
||||
# Polish round
|
||||
|
||||
Additive. Nothing above changes.
|
||||
|
||||
## Bundled recording (the time machine always has ghosts)
|
||||
|
||||
`mobile/demo/history/{nyc,london,gulf}.json` hold a real recording of every aircraft over the New York area, London and the North Sea approaches, and the Gulf of Mexico, captured by `node mobile/demo/capture-history.mjs` (adsb.lol point snapshots around each region's anchors, the dev server's `/api/flights` and `/api/military` as fallbacks; one fix per 30 s below 6 km, per 120 s in cruise). Positions are quantised to 1e-4° and 10 m and delta-encoded (`format: 'gev-history/1'`). Timestamps are the true capture times and are **never shifted**: the time machine shows the recording where it happened.
|
||||
|
||||
The engine loads it about 5 s after the first frame into a second recorder (no expiry) and reads both through one view: ghosts, `setTime` history mode, `entity()`, `trackOf()` and `search()` see the recording; on a key present in both, the device's own recorder wins.
|
||||
|
||||
```js
|
||||
globe.history().archive → { start, end, regions: [{ id, label, bbox, source, start, end, tracks,
|
||||
busiest: { t, aircraft } }] } | null
|
||||
globe.loadHistory() → Promise<history().archive> // resolves once it is loaded
|
||||
globe.historyNear(t, lon, lat, km = 100)
|
||||
→ [{ id /* entity id */, key, kind, title, lon, lat, altM }] // recorded contacts at t, nearest first
|
||||
// event 'history' → history(), once the recording is loaded
|
||||
```
|
||||
|
||||
The time ribbon shades the recording in teal (`RECORDING · 23 OCT`) next to the device's own `RECORDED` range, and its readout names the day when the playhead is on another UTC day.
|
||||
|
||||
## Camera
|
||||
|
||||
```js
|
||||
globe.flyTo({ lon, lat, height, heading, pitch, eye: true }); // eye: lon/lat/height place the camera itself
|
||||
```
|
||||
|
||||
## Night close-ups
|
||||
|
||||
Below ~400 km, where Cesium's lighting is faded out, a night-side view grades the Esri mosaic toward night (darker, desaturated, slightly cooler) with a soft glow of the real Black Marble lights above ~40 km; the strength follows the sun's elevation under the camera at the **scene clock**, so a Moment keeps the lighting of its own time (the hero is daylight). A terrain watchdog falls back to the flat ellipsoid when the keyless terrain host stalls (the globe would otherwise show only the star field).
|
||||
|
||||
## Dossiers
|
||||
|
||||
`privacy` is set only for real privacy programmes (`'pia'`, `'ladd'` flags). An airframe with no public registry record gets `registryMissing: true` instead, and `identity.operator` falls back to the callsign's ICAO airline prefix (`mobile/globe/airlines.js`, about 150 operators).
|
||||
@@ -0,0 +1,169 @@
|
||||
/**
|
||||
* ICAO airline designators (the three letters that start an airline
|
||||
* callsign, e.g. AMX021 → Aeroméxico) for about 150 of the world's busiest
|
||||
* operators. Used to name the operator of a flight when the community
|
||||
* registry has no record for its airframe. Source: ICAO Doc 8585 designators
|
||||
* as published by the airlines; names are the common trading names.
|
||||
*/
|
||||
const AIRLINES = {
|
||||
AAL: 'American Airlines',
|
||||
AAR: 'Asiana Airlines',
|
||||
ABX: 'ABX Air',
|
||||
ACA: 'Air Canada',
|
||||
ADR: 'Air Serbia',
|
||||
AEA: 'Air Europa',
|
||||
AEE: 'Aegean Airlines',
|
||||
AFL: 'Aeroflot',
|
||||
AFR: 'Air France',
|
||||
AIC: 'Air India',
|
||||
AJX: 'Air Japan',
|
||||
ALK: 'SriLankan Airlines',
|
||||
AMX: 'Aeroméxico',
|
||||
ANA: 'All Nippon Airways',
|
||||
ANZ: 'Air New Zealand',
|
||||
ASA: 'Alaska Airlines',
|
||||
ASH: 'Mesa Airlines',
|
||||
AUA: 'Austrian Airlines',
|
||||
AVA: 'Avianca',
|
||||
AWE: 'American Airlines (US Airways)',
|
||||
AXM: 'AirAsia',
|
||||
AZA: 'ITA Airways',
|
||||
AZU: 'Azul',
|
||||
BAW: 'British Airways',
|
||||
BCS: 'European Air Transport (DHL)',
|
||||
BEL: 'Brussels Airlines',
|
||||
BTI: 'airBaltic',
|
||||
BOX: 'AeroLogic',
|
||||
CAL: 'China Airlines',
|
||||
CCA: 'Air China',
|
||||
CES: 'China Eastern Airlines',
|
||||
CFG: 'Condor',
|
||||
CHH: 'Hainan Airlines',
|
||||
CKS: 'Kalitta Air',
|
||||
CLX: 'Cargolux',
|
||||
CMP: 'Copa Airlines',
|
||||
CPA: 'Cathay Pacific',
|
||||
CPZ: 'Compass Airlines',
|
||||
CSN: 'China Southern Airlines',
|
||||
CSZ: 'Shenzhen Airlines',
|
||||
CTN: 'Croatia Airlines',
|
||||
CXA: 'Xiamen Airlines',
|
||||
DAL: 'Delta Air Lines',
|
||||
DHK: 'DHL Air',
|
||||
DLH: 'Lufthansa',
|
||||
EDV: 'Endeavor Air',
|
||||
EIN: 'Aer Lingus',
|
||||
EJA: 'NetJets',
|
||||
EJU: 'easyJet Europe',
|
||||
ELY: 'El Al',
|
||||
ENY: 'Envoy Air',
|
||||
ETD: 'Etihad Airways',
|
||||
ETH: 'Ethiopian Airlines',
|
||||
EVA: 'EVA Air',
|
||||
EWG: 'Eurowings',
|
||||
EXS: 'Jet2',
|
||||
EZS: 'easyJet Switzerland',
|
||||
EZY: 'easyJet',
|
||||
FDX: 'FedEx Express',
|
||||
FFT: 'Frontier Airlines',
|
||||
FIN: 'Finnair',
|
||||
FJI: 'Fiji Airways',
|
||||
FLE: 'Flair Airlines',
|
||||
GEC: 'Lufthansa Cargo',
|
||||
GFA: 'Gulf Air',
|
||||
GJS: 'GoJet Airlines',
|
||||
GLO: 'Gol',
|
||||
GTI: 'Atlas Air',
|
||||
HAL: 'Hawaiian Airlines',
|
||||
HVN: 'Vietnam Airlines',
|
||||
IBE: 'Iberia',
|
||||
IBS: 'Iberia Express',
|
||||
ICE: 'Icelandair',
|
||||
IGO: 'IndiGo',
|
||||
JAL: 'Japan Airlines',
|
||||
JBU: 'JetBlue',
|
||||
JIA: 'PSA Airlines',
|
||||
JJP: 'Jetstar Japan',
|
||||
JST: 'Jetstar',
|
||||
JZA: 'Jazz Aviation (Air Canada Express)',
|
||||
KAL: 'Korean Air',
|
||||
KLM: 'KLM Royal Dutch Airlines',
|
||||
KQA: 'Kenya Airways',
|
||||
LAN: 'LATAM Airlines',
|
||||
LDM: 'Lauda Europe',
|
||||
LOG: 'Loganair',
|
||||
LOT: 'LOT Polish Airlines',
|
||||
LXJ: 'Flexjet',
|
||||
MAS: 'Malaysia Airlines',
|
||||
MEA: 'Middle East Airlines',
|
||||
MSR: 'EgyptAir',
|
||||
NAX: 'Norwegian',
|
||||
NJE: 'NetJets Europe',
|
||||
NKS: 'Spirit Airlines',
|
||||
NOZ: 'Norwegian',
|
||||
NWG: 'Airwork',
|
||||
OAW: 'Helvetic Airways',
|
||||
OMA: 'Oman Air',
|
||||
PAL: 'Philippine Airlines',
|
||||
PAC: 'Polar Air Cargo',
|
||||
PDT: 'Piedmont Airlines',
|
||||
PGT: 'Pegasus Airlines',
|
||||
POE: 'Porter Airlines',
|
||||
QFA: 'Qantas',
|
||||
QTR: 'Qatar Airways',
|
||||
QXE: 'Horizon Air',
|
||||
RAM: 'Royal Air Maroc',
|
||||
RJA: 'Royal Jordanian',
|
||||
ROU: 'Air Canada Rouge',
|
||||
RPA: 'Republic Airways',
|
||||
RYR: 'Ryanair',
|
||||
RZO: 'Azores Airlines',
|
||||
SAS: 'Scandinavian Airlines',
|
||||
SAA: 'South African Airways',
|
||||
SBI: 'S7 Airlines',
|
||||
SCX: 'Sun Country Airlines',
|
||||
SIA: 'Singapore Airlines',
|
||||
SKW: 'SkyWest Airlines',
|
||||
SLK: 'SilkAir',
|
||||
SVA: 'Saudia',
|
||||
SWA: 'Southwest Airlines',
|
||||
SWR: 'Swiss',
|
||||
SXS: 'SunExpress',
|
||||
TAM: 'LATAM Brasil',
|
||||
TAP: 'TAP Air Portugal',
|
||||
TAR: 'Tunisair',
|
||||
TCX: 'Thomas Cook Airlines',
|
||||
THA: 'Thai Airways',
|
||||
THY: 'Turkish Airlines',
|
||||
TOM: 'TUI Airways',
|
||||
TRA: 'Transavia',
|
||||
TSC: 'Air Transat',
|
||||
TVF: 'Transavia France',
|
||||
TVS: 'Smartwings',
|
||||
UAE: 'Emirates',
|
||||
UAL: 'United Airlines',
|
||||
UPS: 'UPS Airlines',
|
||||
UZB: 'Uzbekistan Airways',
|
||||
VIR: 'Virgin Atlantic',
|
||||
VJT: 'VistaJet',
|
||||
VLG: 'Vueling',
|
||||
VOI: 'Volaris',
|
||||
VOZ: 'Virgin Australia',
|
||||
VTI: 'Vistara',
|
||||
WJA: 'WestJet',
|
||||
WIF: 'Widerøe',
|
||||
WMT: 'Wizz Air Malta',
|
||||
WUK: 'Wizz Air UK',
|
||||
WZZ: 'Wizz Air',
|
||||
XLF: 'XL Airways',
|
||||
};
|
||||
|
||||
/** Operator name from a callsign's ICAO airline prefix, or null. */
|
||||
export function airlineFromCallsign(callsign) {
|
||||
const m = /^([A-Z]{3})\d/.exec(
|
||||
String(callsign || '')
|
||||
.trim()
|
||||
.toUpperCase(),
|
||||
);
|
||||
return m ? AIRLINES[m[1]] || null : null;
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
/**
|
||||
* The bundled recording (DESIGN.md §12.5): a few hours of real recorded
|
||||
* positions over the busiest regions, captured with
|
||||
* `node mobile/demo/capture-history.mjs` into mobile/demo/history/*.json.
|
||||
*
|
||||
* It ships so the time machine always has real ghosts to replay, even on a
|
||||
* first run when the device's own recorder holds only minutes. Timestamps
|
||||
* are the true capture times and are never shifted: the time machine shows
|
||||
* the recording where it really happened (the director navigates there).
|
||||
*
|
||||
* The archive is a second recorder (no expiry, no persistence). `history`
|
||||
* is a read-only view over both that ghosts, the dossier and search use; on
|
||||
* a key present in both, the device's own recorder wins.
|
||||
*/
|
||||
import { createRecorder } from './recorder.js';
|
||||
|
||||
// Literal URLs so Vite copies the files into the production build.
|
||||
const FILES = [
|
||||
new URL('../demo/history/nyc.json', import.meta.url).href,
|
||||
new URL('../demo/history/london.json', import.meta.url).href,
|
||||
new URL('../demo/history/gulf.json', import.meta.url).href,
|
||||
];
|
||||
|
||||
/** Decode one `gev-history/1` file into [{ key, meta, points }]. */
|
||||
export function decodeHistory(j) {
|
||||
if (j?.format !== 'gev-history/1' || !Array.isArray(j.tracks)) return [];
|
||||
const out = [];
|
||||
for (const tr of j.tracks) {
|
||||
const p = typeof tr.p === 'string' ? tr.p.split(',').map(Number) : tr.p;
|
||||
if (!Array.isArray(p) || p.length < 12) continue;
|
||||
let t = j.t0;
|
||||
let lon = 0;
|
||||
let lat = 0;
|
||||
let alt = 0;
|
||||
const points = [];
|
||||
for (let i = 0; i + 5 < p.length; i += 6) {
|
||||
t += p[i];
|
||||
lon += p[i + 1];
|
||||
lat += p[i + 2];
|
||||
alt += p[i + 3];
|
||||
points.push({
|
||||
t: t * 1000,
|
||||
lon: lon / 1e4,
|
||||
lat: lat / 1e4,
|
||||
altM: alt * 10,
|
||||
headingDeg: p[i + 4],
|
||||
speed: p[i + 5],
|
||||
});
|
||||
}
|
||||
out.push({ key: String(tr.k), meta: tr.m || { kind: 'aircraft' }, points });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function createArchive() {
|
||||
const rec = createRecorder({
|
||||
persist: false,
|
||||
archive: true,
|
||||
windowMs: Infinity,
|
||||
maxInterpGapS: 11 * 60,
|
||||
});
|
||||
const regions = [];
|
||||
let loading = null;
|
||||
|
||||
function load() {
|
||||
if (loading) return loading;
|
||||
loading = Promise.all(
|
||||
FILES.map((url) =>
|
||||
fetch(url)
|
||||
.then((r) => (r.ok ? r.json() : null))
|
||||
.catch(() => null),
|
||||
),
|
||||
).then((files) => {
|
||||
for (const j of files) {
|
||||
if (!j) continue;
|
||||
const tracks = decodeHistory(j);
|
||||
let start = Infinity;
|
||||
let end = -Infinity;
|
||||
for (const tr of tracks) {
|
||||
rec.merge(tr.key, tr.points, tr.meta);
|
||||
start = Math.min(start, tr.points[0].t);
|
||||
end = Math.max(end, tr.points[tr.points.length - 1].t);
|
||||
}
|
||||
if (!tracks.length) continue;
|
||||
regions.push({
|
||||
id: j.region,
|
||||
label: j.label,
|
||||
bbox: j.bbox,
|
||||
source: j.source,
|
||||
start,
|
||||
end,
|
||||
tracks: tracks.length,
|
||||
busiest: busiestInstant(tracks, start, end),
|
||||
});
|
||||
}
|
||||
return regions;
|
||||
});
|
||||
return loading;
|
||||
}
|
||||
|
||||
return {
|
||||
recorder: rec,
|
||||
load,
|
||||
get loaded() {
|
||||
return regions.length > 0;
|
||||
},
|
||||
regions: () => regions.slice(),
|
||||
range() {
|
||||
if (!regions.length) return null;
|
||||
return {
|
||||
start: Math.min(...regions.map((r) => r.start)),
|
||||
end: Math.max(...regions.map((r) => r.end)),
|
||||
regions: regions.map((r) => ({ ...r })),
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** The 5-minute bucket with the most aircraft in the air. */
|
||||
function busiestInstant(tracks, start, end) {
|
||||
const B = 5 * 60_000;
|
||||
const n = Math.max(1, Math.ceil((end - start) / B));
|
||||
const counts = new Array(n).fill(0);
|
||||
for (const tr of tracks) {
|
||||
const a = Math.floor((tr.points[0].t - start) / B);
|
||||
const b = Math.floor((tr.points[tr.points.length - 1].t - start) / B);
|
||||
for (let i = Math.max(0, a); i <= Math.min(n - 1, b); i++) counts[i]++;
|
||||
}
|
||||
let best = 0;
|
||||
for (let i = 1; i < n; i++) if (counts[i] > counts[best]) best = i;
|
||||
return { t: start + best * B + B / 2, aircraft: counts[best] };
|
||||
}
|
||||
|
||||
/**
|
||||
* A read-only view over the device's recorder and the archive, with the
|
||||
* recorder's query surface (covers, snapshotAt, trail, samples, stateAt…).
|
||||
*/
|
||||
export function historyView(live, archive) {
|
||||
const a = archive.recorder;
|
||||
const pick = (key) => (live.has(key) ? live : a.has(key) ? a : null);
|
||||
return {
|
||||
covers: (ms, slack) => live.covers(ms, slack) || a.covers(ms, slack),
|
||||
has: (key) => live.has(key) || a.has(key),
|
||||
meta: (key) => live.meta(key) || a.meta(key),
|
||||
*keys() {
|
||||
yield* live.keys();
|
||||
for (const k of a.keys()) if (!live.has(k)) yield k;
|
||||
},
|
||||
samples(key, from, to) {
|
||||
const own = live.samples(key, from, to);
|
||||
return own.length ? own : a.samples(key, from, to);
|
||||
},
|
||||
stateAt(key, ms, opts) {
|
||||
return live.stateAt(key, ms, opts) || a.stateAt(key, ms, opts);
|
||||
},
|
||||
snapshotAt(ms, opts) {
|
||||
const own = live.snapshotAt(ms, opts);
|
||||
if (!a.covers(ms, 60_000)) return own;
|
||||
const seen = new Set(own.map((s) => s.key));
|
||||
for (const s of a.snapshotAt(ms, opts)) if (!seen.has(s.key)) own.push(s);
|
||||
return own;
|
||||
},
|
||||
trail(key, from, to, max) {
|
||||
const r = live.trail(key, from, to, max);
|
||||
return r.length >= 2 ? r : a.trail(key, from, to, max);
|
||||
},
|
||||
events: () => live.events(),
|
||||
range: () => live.range(),
|
||||
source: (key) => (pick(key) === a ? 'archive' : 'device'),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* Instant first frame (DESIGN.md §12.8): the last good situations and their
|
||||
* thumbnails, so the feed paints before any live feed answers.
|
||||
*
|
||||
* 1. IndexedDB: the last live situations() result in this browser
|
||||
* 2. else the bundled snapshot, mobile/demo/snapshot.json (real data,
|
||||
* captured by mobile/demo/capture-snapshot.mjs; see `capturedAt`)
|
||||
*
|
||||
* Thumbnails: the last rendered JPEG per situation id from the Cache API
|
||||
* (secure contexts), else the bundled mobile/demo/thumbs/<id>.jpg.
|
||||
*/
|
||||
import { idbGet, idbPut } from './idb.js';
|
||||
|
||||
const SNAPSHOT_URL = new URL('../demo/snapshot.json', import.meta.url).href;
|
||||
const BUNDLED_THUMBS = import.meta.glob('../demo/thumbs/*.jpg', {
|
||||
eager: true,
|
||||
query: '?url',
|
||||
import: 'default',
|
||||
});
|
||||
const LAST_CACHE = 'gev-thumbs-last-v1';
|
||||
const cacheApi = () =>
|
||||
typeof caches !== 'undefined' && globalThis.isSecureContext;
|
||||
|
||||
const bundledThumb = new Map();
|
||||
for (const [path, url] of Object.entries(BUNDLED_THUMBS)) {
|
||||
const id = decodeURIComponent(
|
||||
path
|
||||
.split('/')
|
||||
.pop()
|
||||
.replace(/\.jpg$/, ''),
|
||||
);
|
||||
bundledThumb.set(id, url);
|
||||
}
|
||||
const cachedThumb = new Map(); // id → blob: URL from the Cache API
|
||||
|
||||
export function thumbFileName(id) {
|
||||
return `${String(id).replace(/[^\w.-]+/g, '_')}.jpg`;
|
||||
}
|
||||
|
||||
function bundledFor(id) {
|
||||
return (
|
||||
bundledThumb.get(thumbFileName(id).replace(/\.jpg$/, '')) ||
|
||||
bundledThumb.get(id) ||
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
/** Sync: the best cached thumbnail URL for a situation, or null. */
|
||||
export function cachedThumbnail(situation) {
|
||||
const id = situation?.id;
|
||||
if (!id) return null;
|
||||
return cachedThumb.get(id) || bundledFor(id) || situation.thumbUrl || null;
|
||||
}
|
||||
|
||||
/** Remember the last rendered thumbnail for an id (Cache API). */
|
||||
export async function rememberThumbnail(id, blob) {
|
||||
if (!cacheApi() || !blob) return;
|
||||
try {
|
||||
const c = await caches.open(LAST_CACHE);
|
||||
await c.put(
|
||||
`${location.origin}/__gev-last/${encodeURIComponent(id)}`,
|
||||
new Response(blob, { headers: { 'Content-Type': 'image/jpeg' } }),
|
||||
);
|
||||
} catch {
|
||||
/* quota or private mode */
|
||||
}
|
||||
}
|
||||
|
||||
async function hydrateThumbs(list) {
|
||||
if (!cacheApi()) return;
|
||||
try {
|
||||
const c = await caches.open(LAST_CACHE);
|
||||
await Promise.all(
|
||||
list.map(async (s) => {
|
||||
if (cachedThumb.has(s.id)) return;
|
||||
const hit = await c.match(
|
||||
`${location.origin}/__gev-last/${encodeURIComponent(s.id)}`,
|
||||
);
|
||||
if (hit) cachedThumb.set(s.id, URL.createObjectURL(await hit.blob()));
|
||||
}),
|
||||
);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
let bundledPromise = null;
|
||||
function loadBundled() {
|
||||
if (!bundledPromise)
|
||||
bundledPromise = fetch(SNAPSHOT_URL)
|
||||
.then((r) => (r.ok ? r.json() : null))
|
||||
.catch(() => null);
|
||||
return bundledPromise;
|
||||
}
|
||||
// Start the bundled fetch at import so a cached call never waits on it.
|
||||
loadBundled();
|
||||
|
||||
function decorate(list, capturedAt, origin) {
|
||||
return list.map((s) => ({
|
||||
...s,
|
||||
cached: true,
|
||||
cacheOrigin: origin,
|
||||
capturedAt,
|
||||
live: false,
|
||||
thumbUrl: cachedThumb.get(s.id) || bundledFor(s.id) || s.thumbUrl || null,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* The last good situations, fast. Resolves [] only when there is nothing at
|
||||
* all (no IndexedDB snapshot and no bundled file).
|
||||
*/
|
||||
export async function loadCachedSituations() {
|
||||
const stored = await Promise.race([
|
||||
idbGet('kv', 'situations'),
|
||||
new Promise((r) => setTimeout(() => r(null), 60)),
|
||||
]);
|
||||
if (stored?.list?.length) {
|
||||
hydrateThumbs(stored.list);
|
||||
return decorate(stored.list, stored.capturedAt, 'device');
|
||||
}
|
||||
const bundled = await loadBundled();
|
||||
if (bundled?.situations?.length)
|
||||
return decorate(
|
||||
bundled.situations,
|
||||
Date.parse(bundled.capturedAt) || bundled.capturedAt,
|
||||
'bundled',
|
||||
);
|
||||
return [];
|
||||
}
|
||||
|
||||
/** Store a live situations() result as the next first frame. */
|
||||
export function saveSituations(list) {
|
||||
if (!list?.length) return;
|
||||
const clean = list.map((s) => {
|
||||
const { thumbUrl, cached, cacheOrigin, ...rest } = s;
|
||||
void thumbUrl;
|
||||
void cached;
|
||||
void cacheOrigin;
|
||||
return rest;
|
||||
});
|
||||
idbPut('kv', 'situations', { capturedAt: Date.now(), list: clean }).catch(
|
||||
() => {},
|
||||
);
|
||||
}
|
||||
|
||||
export const hasBundledThumb = (id) => Boolean(bundledFor(id));
|
||||
@@ -0,0 +1,70 @@
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
}
|
||||
body {
|
||||
margin: 0;
|
||||
background: #05070b;
|
||||
color: #dfe6f0;
|
||||
font:
|
||||
12px/1.35 system-ui,
|
||||
sans-serif;
|
||||
}
|
||||
#globe {
|
||||
position: fixed;
|
||||
left: -9999px;
|
||||
width: 320px;
|
||||
height: 320px;
|
||||
}
|
||||
#status {
|
||||
padding: 10px 14px;
|
||||
font:
|
||||
11px ui-monospace,
|
||||
monospace;
|
||||
color: #8aa0b8;
|
||||
}
|
||||
#sheet {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, var(--cw, 216px));
|
||||
gap: 10px;
|
||||
padding: 0 14px 14px;
|
||||
}
|
||||
figure {
|
||||
margin: 0;
|
||||
position: relative;
|
||||
width: var(--cw, 216px);
|
||||
}
|
||||
figure img {
|
||||
display: block;
|
||||
width: var(--cw, 216px);
|
||||
height: var(--ch, 288px);
|
||||
border-radius: 14px;
|
||||
object-fit: cover;
|
||||
background: #111;
|
||||
}
|
||||
figure .tm {
|
||||
position: absolute;
|
||||
left: 8px;
|
||||
right: 8px;
|
||||
bottom: 52px;
|
||||
font:
|
||||
600 8.5px ui-monospace,
|
||||
monospace;
|
||||
letter-spacing: 0.04em;
|
||||
color: #fff;
|
||||
text-shadow: 0 1px 2px #000;
|
||||
}
|
||||
figcaption {
|
||||
padding: 4px 2px 0;
|
||||
height: 44px;
|
||||
overflow: hidden;
|
||||
}
|
||||
figcaption b {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
}
|
||||
figcaption small {
|
||||
color: #8aa0b8;
|
||||
}
|
||||
.err {
|
||||
color: #ff6a3d;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="icon" href="data:," />
|
||||
<title>Globe cards · contact sheet</title>
|
||||
<link rel="stylesheet" href="./contact.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="globe"></div>
|
||||
<header id="status">building situations…</header>
|
||||
<main id="sheet"></main>
|
||||
<script type="module" src="./contact.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,59 @@
|
||||
// Contact sheet: every live situation with its composited thumbnail.
|
||||
// Open /mobile/globe/contact.html (optionally ?lat=&lon= for a viewer).
|
||||
import { createGlobe } from './index.js';
|
||||
|
||||
const params = new URLSearchParams(location.search);
|
||||
const status = document.getElementById('status');
|
||||
const sheet = document.getElementById('sheet');
|
||||
const globe = await createGlobe(document.getElementById('globe'), {
|
||||
layers: [],
|
||||
terrain: false,
|
||||
});
|
||||
window.globe = globe;
|
||||
if (params.has('lat'))
|
||||
globe.setViewer({
|
||||
lat: Number(params.get('lat')),
|
||||
lon: Number(params.get('lon')),
|
||||
});
|
||||
|
||||
const t0 = performance.now();
|
||||
const only = params.get('only');
|
||||
const all = await globe.situations();
|
||||
const list = only
|
||||
? all.filter((s) => only.split(',').some((o) => s.id.startsWith(o)))
|
||||
: all;
|
||||
const cw = Number(params.get('w') || 216);
|
||||
const ch = Math.round((cw * 4) / 3);
|
||||
document.documentElement.style.setProperty('--cw', `${cw}px`);
|
||||
document.documentElement.style.setProperty('--ch', `${ch}px`);
|
||||
window.situations = list;
|
||||
status.textContent = `${list.length} situations in ${Math.round(performance.now() - t0)} ms · rendering thumbnails…`;
|
||||
const t1 = performance.now();
|
||||
let done = 0;
|
||||
await Promise.all(
|
||||
list.map(async (s) => {
|
||||
const fig = document.createElement('figure');
|
||||
const img = document.createElement('img');
|
||||
const tm = document.createElement('div');
|
||||
tm.className = 'tm';
|
||||
tm.textContent = s.telemetry || '';
|
||||
const cap = document.createElement('figcaption');
|
||||
cap.innerHTML = `<b></b><small></small>`;
|
||||
cap.querySelector('b').textContent = s.title;
|
||||
cap.querySelector('small').textContent =
|
||||
`${s.band}/${s.archetype} · ${s.subtitle}`;
|
||||
fig.append(img, tm, cap);
|
||||
sheet.append(fig);
|
||||
try {
|
||||
img.src = await globe.thumbnail(s, { width: cw, height: ch, dpr: 2 });
|
||||
} catch (error) {
|
||||
cap.querySelector('small').className = 'err';
|
||||
cap.querySelector('small').textContent =
|
||||
`thumbnail failed: ${error.message}`;
|
||||
}
|
||||
done++;
|
||||
}),
|
||||
);
|
||||
status.textContent = `${list.length} situations · thumbnails in ${Math.round(performance.now() - t1)} ms · omitted: ${(globe.omitted?.() || []).map((o) => `${o.genre} (${o.reason})`).join('; ')}`;
|
||||
console.log('timings', JSON.stringify(window.globe.timings?.()));
|
||||
document.body.dataset.ready = '1';
|
||||
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* contactsNear(): aircraft, satellites and ships around a point, for the AR
|
||||
* Look mode. Reads the shared feeds directly (not layer state), so AR works
|
||||
* whether or not those layers are on the globe, and nudges a stale feed to
|
||||
* refresh in the background instead of blocking the caller's frame.
|
||||
*/
|
||||
import { twoline2satrec } from 'satellite.js';
|
||||
import { satPosition } from './layers/orbit.js';
|
||||
import { haversineKm, offsetLonLat } from './geo.js';
|
||||
|
||||
const SAT_GROUPS = ['stations', 'visual'];
|
||||
|
||||
export function createContactIndex(feeds, { active }) {
|
||||
let satCache = { stamp: '', sats: [] };
|
||||
|
||||
function satrecs() {
|
||||
const stamp = SAT_GROUPS.map((g) => feeds[g].updatedAt).join(':');
|
||||
if (stamp === satCache.stamp) return satCache.sats;
|
||||
const seen = new Set();
|
||||
const sats = [];
|
||||
for (const g of SAT_GROUPS) {
|
||||
for (const e of feeds[g].data?.entries || []) {
|
||||
const id = e.line1.slice(2, 7).trim();
|
||||
if (seen.has(id)) continue;
|
||||
seen.add(id);
|
||||
try {
|
||||
sats.push({
|
||||
id,
|
||||
name: e.name.trim(),
|
||||
satrec: twoline2satrec(e.line1, e.line2),
|
||||
});
|
||||
} catch {
|
||||
/* skip malformed */
|
||||
}
|
||||
}
|
||||
}
|
||||
satCache = { stamp, sats };
|
||||
return sats;
|
||||
}
|
||||
|
||||
function nudge(feed, maxAgeMs) {
|
||||
if (!active()) return;
|
||||
if (!feed.data || Date.now() - feed.updatedAt > maxAgeMs)
|
||||
feed.get({ maxAgeMs }).catch(() => {});
|
||||
}
|
||||
|
||||
return function contactsNear(
|
||||
lon,
|
||||
lat,
|
||||
km = 50,
|
||||
{ timeMs = Date.now() } = {},
|
||||
) {
|
||||
nudge(feeds.flights, 20_000);
|
||||
nudge(feeds.military, 30_000);
|
||||
nudge(feeds.vessels, 60_000);
|
||||
for (const g of SAT_GROUPS) nudge(feeds[g], 6 * 3600_000);
|
||||
|
||||
const out = [];
|
||||
const seen = new Set();
|
||||
const now = Date.now();
|
||||
const addMover = (r, kind, title) => {
|
||||
if (seen.has(r.id)) return;
|
||||
let [rlon, rlat] = [r.lon, r.lat];
|
||||
if (r.speed && !r.onGround && r.seenAt) {
|
||||
const dt = Math.min(90, Math.max(0, (now - r.seenAt) / 1000));
|
||||
[rlon, rlat] = offsetLonLat(r.lon, r.lat, r.heading || 0, r.speed * dt);
|
||||
}
|
||||
const d = haversineKm(lat, lon, rlat, rlon);
|
||||
if (d > km) return;
|
||||
seen.add(r.id);
|
||||
out.push({
|
||||
kind,
|
||||
id: r.id,
|
||||
title,
|
||||
lon: rlon,
|
||||
lat: rlat,
|
||||
altM: r.altM ?? 0,
|
||||
headingDeg: r.heading ?? null,
|
||||
speedMps: r.speed ?? null,
|
||||
distanceKm: d,
|
||||
});
|
||||
};
|
||||
for (const r of feeds.military.data?.records || [])
|
||||
addMover(r, r.helicopter ? 'helicopter' : 'military', r.callsign);
|
||||
for (const r of feeds.flights.data?.records || [])
|
||||
addMover(r, r.helicopter ? 'helicopter' : 'aircraft', r.callsign);
|
||||
for (const r of feeds.vessels.data?.records || [])
|
||||
addMover(
|
||||
{ ...r, altM: 0, seenAt: feeds.vessels.updatedAt },
|
||||
'vessel',
|
||||
r.name,
|
||||
);
|
||||
|
||||
// Satellites: anything whose sub-point is within km plus the horizon
|
||||
// reach of its altitude is potentially overhead.
|
||||
for (const s of satrecs()) {
|
||||
const p = satPosition(s.satrec, timeMs);
|
||||
if (!p) continue;
|
||||
const reachKm = km + Math.sqrt(2 * 6371 * (p.altM / 1000));
|
||||
const d = haversineKm(lat, lon, p.lat, p.lon);
|
||||
if (d > reachKm) continue;
|
||||
const q = satPosition(s.satrec, timeMs + 10_000);
|
||||
const heading = q
|
||||
? (Math.atan2(
|
||||
(q.lon - p.lon) * Math.cos((p.lat * Math.PI) / 180),
|
||||
q.lat - p.lat,
|
||||
) *
|
||||
180) /
|
||||
Math.PI +
|
||||
360
|
||||
: 0;
|
||||
out.push({
|
||||
kind: s.id === '25544' ? 'station' : 'satellite',
|
||||
id: s.id,
|
||||
title: s.name,
|
||||
lon: p.lon,
|
||||
lat: p.lat,
|
||||
altM: p.altM,
|
||||
headingDeg: heading % 360,
|
||||
speedMps: p.speedMps,
|
||||
distanceKm: d,
|
||||
});
|
||||
}
|
||||
return out.sort((a, b) => a.distanceKm - b.distanceKm);
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,318 @@
|
||||
/**
|
||||
* Detection on real data (DESIGN.md §12.4). Runs over the recorder, never
|
||||
* over anything invented: when nothing in the record matches, nothing is
|
||||
* raised.
|
||||
*
|
||||
* Circling: an aircraft "orbits" when, within the last ~20 min, its ground
|
||||
* track turns through at least 360° in one direction while its positions stay
|
||||
* within ~1.5 km of their centroid. Rotorcraft and low-and-slow contacts are
|
||||
* preferred (airliners in a holding stack are excluded by altitude and
|
||||
* speed). Two or more orbiters whose centres lie within ~1.5 km of a common
|
||||
* centre make a detection: "3 helicopters circling Midtown Manhattan". A
|
||||
* single aircraft on its second full orbit is a lesser detection
|
||||
* ("Helicopter circling Lower Manhattan").
|
||||
*
|
||||
* Quake clusters: three or more M2.5+ events within 50 km of each other in
|
||||
* 24 h, from the USGS feed.
|
||||
*/
|
||||
import { haversineKm } from './geo.js';
|
||||
import { localPlace, describePlace } from './gazetteer.js';
|
||||
|
||||
const WINDOW_MS = 20 * 60_000;
|
||||
const MAX_CENTER_KM = 1.5;
|
||||
const MAX_ALT_M = 2400; // ~8,000 ft
|
||||
const MAX_SPEED_MPS = 80; // ~155 kt
|
||||
const D2R = Math.PI / 180;
|
||||
|
||||
function bearing(a, b) {
|
||||
const dx = (b.lon - a.lon) * Math.cos(a.lat * D2R);
|
||||
const dy = b.lat - a.lat;
|
||||
return (Math.atan2(dx, dy) / D2R + 360) % 360;
|
||||
}
|
||||
const metres = (a, b) =>
|
||||
Math.hypot(
|
||||
(b.lon - a.lon) * 111_320 * Math.cos(a.lat * D2R),
|
||||
(b.lat - a.lat) * 110_574,
|
||||
);
|
||||
|
||||
/**
|
||||
* Orbit analysis of one track (samples sorted by t). Returns the most recent
|
||||
* full orbit: { center:{lon,lat}, radiusM, turns, dir, from, to, points } or null.
|
||||
*/
|
||||
export function analyseOrbit(samples) {
|
||||
if (samples.length < 6) return null;
|
||||
// Thin to points at least 60 m apart so jitter does not fake turning.
|
||||
const pts = [samples[0]];
|
||||
for (const p of samples) if (metres(pts.at(-1), p) >= 60) pts.push(p);
|
||||
if (pts.length < 6) return null;
|
||||
// Walk backwards from the newest point accumulating signed turn.
|
||||
let turn = 0;
|
||||
let prevB = bearing(pts.at(-2), pts.at(-1));
|
||||
let startIdx = -1;
|
||||
for (let i = pts.length - 2; i >= 1; i--) {
|
||||
// A gap in the record breaks the orbit.
|
||||
if (pts[i + 1].t - pts[i].t > 4 * 60_000) break;
|
||||
const b = bearing(pts[i - 1], pts[i]);
|
||||
let d = ((prevB - b + 540) % 360) - 180;
|
||||
// A step that turns > 150° is a sampling artefact, not a turn.
|
||||
if (Math.abs(d) > 150) d = 0;
|
||||
turn += d;
|
||||
prevB = b;
|
||||
if (Math.abs(turn) >= 360) {
|
||||
startIdx = i - 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (startIdx < 0) return null;
|
||||
const orbit = pts.slice(startIdx);
|
||||
let lon = 0;
|
||||
let lat = 0;
|
||||
for (const p of orbit) {
|
||||
lon += p.lon;
|
||||
lat += p.lat;
|
||||
}
|
||||
const center = { lon: lon / orbit.length, lat: lat / orbit.length };
|
||||
let r = 0;
|
||||
let rMax = 0;
|
||||
for (const p of orbit) {
|
||||
const d = metres(center, p);
|
||||
r += d;
|
||||
rMax = Math.max(rMax, d);
|
||||
}
|
||||
r /= orbit.length;
|
||||
if (r > MAX_CENTER_KM * 1000 || rMax > MAX_CENTER_KM * 1600) return null;
|
||||
if (r < 80) return null; // hovering jitter, not an orbit
|
||||
// Count full turns over the whole window (for "on its 6th orbit").
|
||||
let total = 0;
|
||||
for (let i = 2; i < pts.length; i++) {
|
||||
let d =
|
||||
((bearing(pts[i - 1], pts[i]) - bearing(pts[i - 2], pts[i - 1]) + 540) %
|
||||
360) -
|
||||
180;
|
||||
if (Math.abs(d) > 150) d = 0;
|
||||
total += d;
|
||||
}
|
||||
return {
|
||||
center,
|
||||
radiusM: Math.round(r),
|
||||
turns: Math.max(1, Math.floor(Math.abs(total) / 360)),
|
||||
dir: turn > 0 ? 'cw' : 'ccw',
|
||||
from: orbit[0].t,
|
||||
to: orbit.at(-1).t,
|
||||
};
|
||||
}
|
||||
|
||||
/** "near Port-Olry, Vanuatu" from a USGS place, else from the gazetteer. */
|
||||
function clusterPlace(q) {
|
||||
const usgs = q.place ? q.place.replace(/^.*\bof\s+/, '').trim() : '';
|
||||
if (usgs) return `near ${usgs}`;
|
||||
const p = describePlace(q.lat, q.lon);
|
||||
if (!p) return '';
|
||||
return /\bkm\b/.test(p) ? `, ${p}` : `near ${p}`;
|
||||
}
|
||||
|
||||
const isCandidate = (meta, s) =>
|
||||
meta &&
|
||||
meta.kind !== 'vessel' &&
|
||||
!meta.onGround &&
|
||||
s.altM < MAX_ALT_M &&
|
||||
(meta.helicopter || s.speed < MAX_SPEED_MPS);
|
||||
|
||||
/**
|
||||
* @param {ReturnType<import('./recorder.js').createRecorder>} recorder
|
||||
*/
|
||||
export function createDetector(recorder) {
|
||||
/** Detections by id (kept while they last, plus a history for pips). */
|
||||
const live = new Map();
|
||||
const history = [];
|
||||
let lastRun = 0;
|
||||
let lastOrbiters = [];
|
||||
|
||||
function circling(now = Date.now()) {
|
||||
const from = now - WINDOW_MS;
|
||||
const orbiters = [];
|
||||
for (const key of recorder.keys()) {
|
||||
const meta = recorder.meta(key);
|
||||
if (!meta || meta.kind === 'vessel') continue;
|
||||
const samples = recorder.samples(key, from, now);
|
||||
if (samples.length < 6) continue;
|
||||
const lastS = samples.at(-1);
|
||||
if (now - lastS.t > 5 * 60_000) continue; // gone quiet
|
||||
if (!isCandidate(meta, lastS)) continue;
|
||||
const orbit = analyseOrbit(samples);
|
||||
if (orbit) orbiters.push({ key, meta, orbit, last: lastS });
|
||||
}
|
||||
lastOrbiters = orbiters;
|
||||
// Greedy clustering on orbit centres.
|
||||
const used = new Set();
|
||||
const groups = [];
|
||||
orbiters.sort(
|
||||
(a, b) =>
|
||||
Number(b.meta.helicopter) - Number(a.meta.helicopter) ||
|
||||
b.orbit.turns - a.orbit.turns,
|
||||
);
|
||||
for (const a of orbiters) {
|
||||
if (used.has(a.key)) continue;
|
||||
const g = [a];
|
||||
for (const b of orbiters) {
|
||||
if (b === a || used.has(b.key)) continue;
|
||||
const km = haversineKm(
|
||||
a.orbit.center.lat,
|
||||
a.orbit.center.lon,
|
||||
b.orbit.center.lat,
|
||||
b.orbit.center.lon,
|
||||
);
|
||||
if (km <= MAX_CENTER_KM) g.push(b);
|
||||
}
|
||||
if (g.length >= 2) {
|
||||
for (const m of g) used.add(m.key);
|
||||
groups.push(g);
|
||||
}
|
||||
}
|
||||
// A single aircraft on its second full orbit is a detection too (a
|
||||
// police or news helicopter holding over a scene), ranked below groups.
|
||||
const singles = orbiters
|
||||
.filter((a) => !used.has(a.key) && a.orbit.turns >= 2)
|
||||
.sort((a, b) => b.orbit.turns - a.orbit.turns)
|
||||
.slice(0, 4);
|
||||
for (const a of singles) groups.push([a]);
|
||||
const out = [];
|
||||
for (const g of groups) {
|
||||
let lon = 0;
|
||||
let lat = 0;
|
||||
for (const m of g) {
|
||||
lon += m.orbit.center.lon;
|
||||
lat += m.orbit.center.lat;
|
||||
}
|
||||
lon /= g.length;
|
||||
lat /= g.length;
|
||||
const helis = g.filter((m) => m.meta.helicopter).length;
|
||||
const place = localPlace(lat, lon);
|
||||
const noun =
|
||||
g.length === 1
|
||||
? helis
|
||||
? 'Helicopter'
|
||||
: 'Aircraft'
|
||||
: helis === g.length
|
||||
? 'helicopters'
|
||||
: helis
|
||||
? 'aircraft'
|
||||
: 'low aircraft';
|
||||
let id = `circling-${lat.toFixed(2)}_${lon.toFixed(2)}`;
|
||||
const firstOrbit = Math.min(...g.map((m) => m.orbit.from));
|
||||
// The same aircraft drifting a few hundred metres is the same
|
||||
// detection: keep its id and start time.
|
||||
for (const [oid, d] of live)
|
||||
if (
|
||||
oid !== id &&
|
||||
d.members.some((m) => g.some((x) => x.key === m.key)) &&
|
||||
haversineKm(d.lat, d.lon, lat, lon) < 3
|
||||
) {
|
||||
id = oid;
|
||||
break;
|
||||
}
|
||||
const prev = live.get(id);
|
||||
const det = {
|
||||
id,
|
||||
kind: 'circling',
|
||||
title:
|
||||
g.length === 1
|
||||
? `${noun} circling ${place.name}`
|
||||
: `${g.length} ${noun} circling ${place.name}`,
|
||||
single: g.length === 1,
|
||||
place: place.name,
|
||||
lon,
|
||||
lat,
|
||||
since: prev?.since ?? firstOrbit,
|
||||
updatedAt: now,
|
||||
members: g.map((m) => ({
|
||||
key: m.key,
|
||||
layer: m.meta.layer,
|
||||
callsign: m.meta.callsign || m.key,
|
||||
helicopter: Boolean(m.meta.helicopter),
|
||||
radiusM: m.orbit.radiusM,
|
||||
turns: m.orbit.turns,
|
||||
dir: m.orbit.dir,
|
||||
altM: Math.round(m.last.altM),
|
||||
speed: m.last.speed,
|
||||
lon: m.last.lon,
|
||||
lat: m.last.lat,
|
||||
})),
|
||||
};
|
||||
live.set(id, det);
|
||||
if (!prev)
|
||||
history.push({
|
||||
t: firstOrbit,
|
||||
kind: 'circling',
|
||||
title: det.title,
|
||||
situationId: id,
|
||||
momentId: `live-${id}`,
|
||||
lon,
|
||||
lat,
|
||||
});
|
||||
out.push(det);
|
||||
}
|
||||
// Detections that stopped circling linger 10 min, then go.
|
||||
for (const [id, d] of live)
|
||||
if (now - d.updatedAt > 10 * 60_000) live.delete(id);
|
||||
if (history.length > 200) history.splice(0, history.length - 200);
|
||||
return out;
|
||||
}
|
||||
|
||||
function quakeClusters(records, now = Date.now()) {
|
||||
const recent = (records || []).filter(
|
||||
(q) => Number.isFinite(q.time) && now - q.time < 86_400_000,
|
||||
);
|
||||
const used = new Set();
|
||||
const out = [];
|
||||
const sorted = recent.slice().sort((a, b) => b.mag - a.mag);
|
||||
for (const q of sorted) {
|
||||
if (used.has(q.stableId)) continue;
|
||||
const near = recent.filter(
|
||||
(o) =>
|
||||
!used.has(o.stableId) &&
|
||||
haversineKm(q.lat, q.lon, o.lat, o.lon) <= 50,
|
||||
);
|
||||
if (near.length < 3) continue;
|
||||
for (const o of near) used.add(o.stableId);
|
||||
const times = near.map((o) => o.time).sort((a, b) => a - b);
|
||||
out.push({
|
||||
id: `quake-cluster-${q.stableId}`,
|
||||
kind: 'quake-cluster',
|
||||
title: `${near.length} earthquakes in 24 h ${clusterPlace(q)}`.trim(),
|
||||
lon: q.lon,
|
||||
lat: q.lat,
|
||||
maxMag: q.mag,
|
||||
count: near.length,
|
||||
since: times[0],
|
||||
updatedAt: times.at(-1),
|
||||
members: near.map((o) => ({
|
||||
id: o.stableId,
|
||||
mag: o.mag,
|
||||
time: o.time,
|
||||
lon: o.lon,
|
||||
lat: o.lat,
|
||||
place: o.place,
|
||||
})),
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
return {
|
||||
/** Run the circling detector at most every `minGapMs`. */
|
||||
run({ force = false, minGapMs = 20_000 } = {}) {
|
||||
const now = Date.now();
|
||||
if (!force && now - lastRun < minGapMs) return [...live.values()];
|
||||
lastRun = now;
|
||||
circling(now);
|
||||
return [...live.values()];
|
||||
},
|
||||
current: () => [...live.values()],
|
||||
get: (id) => live.get(id) || null,
|
||||
history: () => history.slice(),
|
||||
/** Single orbiters seen in the last run (diagnostics and dossiers). */
|
||||
orbiters: () => lastOrbiters.slice(),
|
||||
quakeClusters,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
/**
|
||||
* Situation cards for detections (circling, quake clusters). Same shape as
|
||||
* situations.js cards, each with a compositor scene of its real data.
|
||||
*/
|
||||
import { SCENES } from './situations.js';
|
||||
import { thumbnailAround } from './geo.js';
|
||||
|
||||
const M_TO_FT = 3.28084;
|
||||
const MPS_TO_KT = 1 / 0.514444;
|
||||
const hhmm = (ms) => new Date(ms).toISOString().slice(11, 16) + 'Z';
|
||||
const fmtLL = (lat, lon) =>
|
||||
`${Math.abs(lat).toFixed(2)}°${lat >= 0 ? 'N' : 'S'} ${Math.abs(lon).toFixed(2)}°${lon >= 0 ? 'E' : 'W'}`;
|
||||
|
||||
export function circlingCard(det, recorder) {
|
||||
const helis = det.members.filter((m) => m.helicopter).length;
|
||||
const alts = det.members.map((m) => m.altM).sort((a, b) => a - b);
|
||||
const radii = det.members.map((m) => m.radiusM).sort((a, b) => a - b);
|
||||
const turns = det.members.reduce((n, m) => n + m.turns, 0);
|
||||
const lead = det.members[0];
|
||||
const ft = (m) => Math.round((m * M_TO_FT) / 50) * 50;
|
||||
const id = det.id;
|
||||
const card = {
|
||||
id,
|
||||
kind: 'circling',
|
||||
family: 'Sky',
|
||||
section: 'now',
|
||||
band: 'sky',
|
||||
archetype: 'track',
|
||||
telemetry: [
|
||||
'SKY',
|
||||
`${det.members.length} ${helis === det.members.length ? 'ROTOR' : 'AIRCRAFT'} ORBITING`,
|
||||
`R ${radii[0]}–${radii.at(-1)} M`,
|
||||
`${ft(alts[0]).toLocaleString('en-US')}–${ft(alts.at(-1)).toLocaleString('en-US')} FT`,
|
||||
lead?.callsign ? String(lead.callsign).toUpperCase() : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · '),
|
||||
title: det.title,
|
||||
subtitle: `${det.members.map((m) => m.callsign).join(', ')} · ${turns} orbits since ${hhmm(det.since)} · detected from ADS-B`,
|
||||
place: det.place,
|
||||
lon: det.lon,
|
||||
lat: det.lat,
|
||||
camera: {
|
||||
lon: det.lon,
|
||||
lat: det.lat,
|
||||
height: 2600,
|
||||
heading: 25,
|
||||
pitch: -32,
|
||||
},
|
||||
layers: ['helicopters', 'flights', 'ga', 'military'],
|
||||
time: { start: det.since, end: det.updatedAt },
|
||||
metrics: [
|
||||
{ label: 'Aircraft', value: det.members.length, unit: '' },
|
||||
{ label: 'Orbits', value: turns, unit: '' },
|
||||
{
|
||||
label: 'Radius',
|
||||
value: radii[Math.floor(radii.length / 2)],
|
||||
unit: 'm',
|
||||
},
|
||||
...(lead?.speed
|
||||
? [
|
||||
{
|
||||
label: 'Speed',
|
||||
value: Math.round(lead.speed * MPS_TO_KT),
|
||||
unit: 'kt',
|
||||
},
|
||||
]
|
||||
: []),
|
||||
],
|
||||
headline: det.single
|
||||
? { value: turns, unit: '', label: 'orbits' }
|
||||
: {
|
||||
value: det.members.length,
|
||||
unit: '',
|
||||
label:
|
||||
helis === det.members.length
|
||||
? 'helicopters circling'
|
||||
: 'aircraft circling',
|
||||
},
|
||||
thumbnail: thumbnailAround(det.lon, det.lat, 2),
|
||||
live: true,
|
||||
updatedAt: det.updatedAt,
|
||||
source:
|
||||
'Detected by God’s Eye from live ADS-B (OpenSky Network / adsb.lol, ODbL)',
|
||||
severity: det.single ? 4.5 : 6.5,
|
||||
momentId: `live-${det.id}`,
|
||||
members: det.members.map(({ key, layer, callsign, helicopter }) => ({
|
||||
key,
|
||||
layer,
|
||||
callsign,
|
||||
helicopter,
|
||||
})),
|
||||
entityIds: det.members.map((m) => `${m.layer || 'flights'}:${m.key}`),
|
||||
detected: true,
|
||||
};
|
||||
SCENES.set(id, {
|
||||
type: 'circling',
|
||||
lon: det.lon,
|
||||
lat: det.lat,
|
||||
spanKm: 3.2,
|
||||
band: 'sky',
|
||||
tracks: det.members.map((m) => {
|
||||
const pts = recorder
|
||||
.samples(m.key, det.updatedAt - 20 * 60_000, det.updatedAt)
|
||||
.map((p) => [p.lon, p.lat]);
|
||||
return { points: pts, helicopter: m.helicopter, heading: 0 };
|
||||
}),
|
||||
});
|
||||
void fmtLL;
|
||||
return card;
|
||||
}
|
||||
|
||||
export function quakeClusterCard(c) {
|
||||
const id = c.id;
|
||||
const card = {
|
||||
id,
|
||||
kind: 'quake-cluster',
|
||||
family: 'Hazards',
|
||||
section: 'now',
|
||||
band: 'ground',
|
||||
archetype: 'number',
|
||||
telemetry: `GROUND · ${c.count} EVENTS · 24 H · MAX M${c.maxMag.toFixed(1)} · 50 KM`,
|
||||
title: c.title,
|
||||
subtitle: `Largest M${c.maxMag.toFixed(1)} · first ${hhmm(c.since)} · USGS`,
|
||||
place: c.members[0]?.place || fmtLL(c.lat, c.lon),
|
||||
lon: c.lon,
|
||||
lat: c.lat,
|
||||
camera: { lon: c.lon, lat: c.lat, height: 260_000, heading: 0, pitch: -55 },
|
||||
layers: ['earthquakes'],
|
||||
time: { start: c.since, end: c.updatedAt },
|
||||
metrics: [
|
||||
{ label: 'Quakes', value: c.count, unit: '' },
|
||||
{ label: 'Largest', value: c.maxMag.toFixed(1), unit: 'M' },
|
||||
],
|
||||
headline: { value: c.count, unit: '', label: 'quakes in 24 h' },
|
||||
series: c.members
|
||||
.slice()
|
||||
.sort((a, b) => a.time - b.time)
|
||||
.map((m) => ({ t: m.time, v: m.mag })),
|
||||
seriesLabel: 'magnitude per event',
|
||||
thumbnail: thumbnailAround(c.lon, c.lat, 60),
|
||||
live: true,
|
||||
updatedAt: c.updatedAt,
|
||||
source: 'U.S. Geological Survey · cluster detected by God’s Eye',
|
||||
severity: 3 + Math.max(0, c.maxMag - 4),
|
||||
detected: true,
|
||||
};
|
||||
SCENES.set(id, {
|
||||
type: 'quake',
|
||||
lon: c.lon,
|
||||
lat: c.lat,
|
||||
spanKm: 160,
|
||||
band: 'ground',
|
||||
quake: { lon: c.lon, lat: c.lat, mag: c.maxMag },
|
||||
nearby: c.members.map((m) => ({ lon: m.lon, lat: m.lat, mag: m.mag })),
|
||||
});
|
||||
return card;
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
height: 100%;
|
||||
background: #05070b;
|
||||
color: #e8eef7;
|
||||
font:
|
||||
12px/1.4 system-ui,
|
||||
sans-serif;
|
||||
overflow: hidden;
|
||||
}
|
||||
#globe {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
}
|
||||
#hud {
|
||||
position: fixed;
|
||||
left: 8px;
|
||||
bottom: 8px;
|
||||
max-width: 70vw;
|
||||
padding: 6px 8px;
|
||||
border-radius: 10px;
|
||||
background: rgba(5, 7, 11, 0.55);
|
||||
font-variant-numeric: tabular-nums;
|
||||
pointer-events: none;
|
||||
white-space: pre-line;
|
||||
}
|
||||
#hud:empty {
|
||||
display: none;
|
||||
}
|
||||
#labels {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
#labels span {
|
||||
position: absolute;
|
||||
transform: translate(10px, -50%);
|
||||
padding: 1px 5px;
|
||||
border-radius: 6px;
|
||||
background: rgba(5, 7, 11, 0.55);
|
||||
font-size: 10px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1.0, viewport-fit=cover"
|
||||
/>
|
||||
<meta name="theme-color" content="#05070B" />
|
||||
<link rel="icon" href="data:," />
|
||||
<title>Globe engine · dev</title>
|
||||
<link rel="stylesheet" href="./dev.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="globe"></div>
|
||||
<div id="labels"></div>
|
||||
<div id="hud" aria-live="polite"></div>
|
||||
<script type="module" src="./dev.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,93 @@
|
||||
// Dev harness: mounts the globe full-screen and exposes it as window.globe.
|
||||
// Query: ?quality=auto|low|high&lens=cinematic&layers=flights,iss&hud=0
|
||||
import { createGlobe } from './index.js';
|
||||
|
||||
const params = new URLSearchParams(location.search);
|
||||
const hud = document.getElementById('hud');
|
||||
const showHud = params.get('hud') !== '0';
|
||||
|
||||
const globe = await createGlobe(document.getElementById('globe'), {
|
||||
quality: params.get('quality') || 'auto',
|
||||
lens: params.get('lens') || 'cinematic',
|
||||
layers: params.get('layers')?.split(',').filter(Boolean),
|
||||
preserveDrawingBuffer: params.has('capture'),
|
||||
});
|
||||
window.globe = globe;
|
||||
window.__globeReady = performance.now();
|
||||
{
|
||||
const off = globe.viewer.scene.postRender.addEventListener(() => {
|
||||
window.__firstFrame = performance.now();
|
||||
off();
|
||||
});
|
||||
}
|
||||
|
||||
let quality = globe.quality();
|
||||
let lastPick = null;
|
||||
const render = () => {
|
||||
if (!showHud) return;
|
||||
const on = globe
|
||||
.layers()
|
||||
.filter((l) => l.on)
|
||||
.map((l) => `${l.id} ${l.count} ${l.status}`)
|
||||
.join('\n');
|
||||
hud.textContent = [
|
||||
`scale ${quality.scale} · ${quality.fps} fps · bloom ${quality.bloom ? 'on' : 'off'}`,
|
||||
on,
|
||||
lastPick ? `▸ ${lastPick.title} — ${lastPick.subtitle}` : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
};
|
||||
globe.on('quality', (q) => {
|
||||
quality = q;
|
||||
render();
|
||||
});
|
||||
globe.on('layers', render);
|
||||
globe.on('pick', (p) => {
|
||||
lastPick = p;
|
||||
render();
|
||||
});
|
||||
render();
|
||||
|
||||
// HTML labels (§5): at most 40, refreshed with the camera and once a second.
|
||||
const labelLayer = document.getElementById('labels');
|
||||
const drawLabels = () => {
|
||||
labelLayer.replaceChildren(
|
||||
...globe.labels(40).map((l) => {
|
||||
const el = document.createElement('span');
|
||||
el.textContent = l.detail ? `${l.title} · ${l.detail}` : l.title;
|
||||
el.style.left = `${l.x}px`;
|
||||
el.style.top = `${l.y}px`;
|
||||
return el;
|
||||
}),
|
||||
);
|
||||
};
|
||||
if (params.get('labels') !== '0') {
|
||||
globe.on('camera', drawLabels);
|
||||
setInterval(drawLabels, 1000);
|
||||
}
|
||||
|
||||
// ?moment=empire-state&speed=30 plays a Moment on load; its events print in the HUD.
|
||||
const momentLines = [];
|
||||
globe.on('moment', (e) => {
|
||||
if (!showHud) return;
|
||||
const it = e.item || {};
|
||||
const line =
|
||||
e.type === 'chat'
|
||||
? `💬 ${it.handle}: ${it.text}`
|
||||
: e.type === 'post'
|
||||
? `▤ ${it.kind} · ${it.handle}: ${it.text}`
|
||||
: e.type === 'capture'
|
||||
? `◉ ${it.firstEyes ? 'FIRST EYES ' : ''}@${it.handle} · ${it.title}`
|
||||
: e.type === 'beat'
|
||||
? `● ${it.title}`
|
||||
: `[${e.type}]`;
|
||||
momentLines.push(line);
|
||||
while (momentLines.length > 8) momentLines.shift();
|
||||
hud.textContent = momentLines.join('\n');
|
||||
});
|
||||
if (params.get('moment'))
|
||||
globe.playMoment(params.get('moment'), {
|
||||
speed: Number(params.get('speed')) || undefined,
|
||||
autoplay: params.get('autoplay') !== '0',
|
||||
});
|
||||
@@ -0,0 +1,692 @@
|
||||
/**
|
||||
* Shared, cached data feeds. Layers and situations read the same feed objects,
|
||||
* so a situation card never triggers a second download of data a visible layer
|
||||
* already holds, and a layer that is off costs nothing until something asks.
|
||||
*
|
||||
* Every feed resolves to `{ data, stale }` from its loader; `get()` returns the
|
||||
* cached data when it is younger than `maxAgeMs`, otherwise loads once (single
|
||||
* flight) and keeps the last good data when a refresh fails.
|
||||
*/
|
||||
import { parseTleText } from '../../src/sources/tle.js';
|
||||
import { normalizeEarthquakeSnapshot } from '../../src/layers/earthquakes/records.js';
|
||||
import { adaptFirmsRecords } from '../../src/data/firmsAdapt.js';
|
||||
import { validateCycloneSnapshot } from '../../src/layers/cyclones/source.js';
|
||||
import { classifyAircraft } from '../../src/data/aircraftClass.js';
|
||||
import { BHOTE_KOSHI_FLOOD_PATH } from '../../src/data/bhoteKoshiFloodPath.js';
|
||||
import { FT_TO_M, KT_TO_MPS, finite } from './geo.js';
|
||||
|
||||
const FETCH_TIMEOUT_MS = 25_000;
|
||||
|
||||
async function fetchWithTimeout(url, init = {}) {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
||||
try {
|
||||
return await fetch(url, { ...init, signal: controller.signal });
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
export class FeedError extends Error {
|
||||
constructor(message, { keyRequired = false, status = 0 } = {}) {
|
||||
super(message);
|
||||
this.keyRequired = keyRequired;
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
/** A cached single-flight feed. */
|
||||
export function createFeed(name, load, { ttlMs = 60_000 } = {}) {
|
||||
const listeners = new Set();
|
||||
const feed = {
|
||||
name,
|
||||
data: null,
|
||||
stale: false,
|
||||
updatedAt: 0,
|
||||
error: null,
|
||||
async get({ maxAgeMs = ttlMs, args } = {}) {
|
||||
if (feed.data && Date.now() - feed.updatedAt < maxAgeMs) return feed.data;
|
||||
// A server without the feed's key is not asked again for 10 minutes.
|
||||
if (feed.error?.keyRequired && Date.now() < feed._retryAt) {
|
||||
if (feed.data) return feed.data;
|
||||
throw feed.error;
|
||||
}
|
||||
if (!feed._inflight) {
|
||||
feed._inflight = (async () => {
|
||||
try {
|
||||
const result = await load(args);
|
||||
feed.data = result.data;
|
||||
feed.stale = Boolean(result.stale);
|
||||
feed.updatedAt = Date.now();
|
||||
feed.error = null;
|
||||
} catch (error) {
|
||||
feed.error = error;
|
||||
feed._retryAt = Date.now() + 10 * 60_000;
|
||||
if (feed.data) feed.stale = true;
|
||||
} finally {
|
||||
feed._inflight = null;
|
||||
for (const fn of listeners) fn(feed);
|
||||
}
|
||||
})();
|
||||
}
|
||||
await feed._inflight;
|
||||
if (!feed.data && feed.error) throw feed.error;
|
||||
return feed.data;
|
||||
},
|
||||
subscribe(fn) {
|
||||
listeners.add(fn);
|
||||
return () => listeners.delete(fn);
|
||||
},
|
||||
_inflight: null,
|
||||
_retryAt: 0,
|
||||
};
|
||||
return feed;
|
||||
}
|
||||
|
||||
/* ───────────────────────── Aircraft ───────────────────────── */
|
||||
|
||||
const REGISTRATION_CALLSIGN =
|
||||
/^(N\d{1,5}[A-Z]{0,2}|[CDFGI]-?[A-Z]{4}|VH-?[A-Z]{3}|D[A-Z]{4}|ZK-?[A-Z]{3}|JA\d{3,4}[A-Z]?)$/;
|
||||
|
||||
/** OpenSky state vector → flight record. */
|
||||
export function flightFromState(s) {
|
||||
const lon = finite(s[5]);
|
||||
const lat = finite(s[6]);
|
||||
if (lon === null || lat === null) return null;
|
||||
const callsign = String(s[1] || '').trim();
|
||||
const category = finite(s[17]);
|
||||
const cls = classifyAircraft({ category: category ?? undefined });
|
||||
const squawk = s[14] ? String(s[14]).trim() : null;
|
||||
const altM = finite(s[13]) ?? finite(s[7]);
|
||||
const onGround = s[8] === true;
|
||||
return {
|
||||
id: String(s[0]),
|
||||
callsign: callsign || String(s[0]).toUpperCase(),
|
||||
country: s[2] || '',
|
||||
lon,
|
||||
lat,
|
||||
altM: onGround ? 0 : (altM ?? 10_000),
|
||||
baroAltM: finite(s[7]),
|
||||
onGround,
|
||||
speed: finite(s[9]),
|
||||
heading: finite(s[10]) ?? 0,
|
||||
vRate: finite(s[11]),
|
||||
squawk,
|
||||
category,
|
||||
cls,
|
||||
ga:
|
||||
category === 2 ||
|
||||
category === 3 ||
|
||||
REGISTRATION_CALLSIGN.test(callsign.replace(/\s+/g, '')),
|
||||
helicopter: category === 8 || cls === 'helicopter',
|
||||
emergency: squawk === '7500' || squawk === '7600' || squawk === '7700',
|
||||
seenAt: (finite(s[4]) ?? Date.now() / 1000) * 1000,
|
||||
};
|
||||
}
|
||||
|
||||
export function createFlightsFeed(getAnchor) {
|
||||
return createFeed(
|
||||
'flights',
|
||||
async () => {
|
||||
const anchor = getAnchor?.();
|
||||
const query = anchor
|
||||
? `?lat=${anchor.lat.toFixed(3)}&lon=${anchor.lon.toFixed(3)}`
|
||||
: '';
|
||||
const response = await fetchWithTimeout(`/api/flights${query}`, {
|
||||
cache: 'no-store',
|
||||
});
|
||||
if (!response.ok)
|
||||
throw new FeedError(`Flights HTTP ${response.status}`, {
|
||||
status: response.status,
|
||||
});
|
||||
const payload = await response.json();
|
||||
if (!Array.isArray(payload?.states))
|
||||
throw new FeedError('Malformed flight snapshot');
|
||||
const records = [];
|
||||
for (const s of payload.states) {
|
||||
const r = flightFromState(s);
|
||||
if (r) records.push(r);
|
||||
}
|
||||
const cache = response.headers.get('x-opensky-cache') || '';
|
||||
// When OpenSky is down the proxy answers with a 250 nm adsb.lol
|
||||
// snapshot around the anchor: real, but regional.
|
||||
const regional = /adsb\.lol/i.test(
|
||||
response.headers.get('x-flight-source') || '',
|
||||
);
|
||||
const source = regional
|
||||
? 'adsb.lol (regional fallback, 250 nm)'
|
||||
: 'OpenSky Network';
|
||||
return {
|
||||
data: {
|
||||
records,
|
||||
source,
|
||||
regional,
|
||||
anchor: anchor || null,
|
||||
time: (payload.time || 0) * 1000,
|
||||
},
|
||||
stale: cache === 'STALE',
|
||||
};
|
||||
},
|
||||
{ ttlMs: 12_000 },
|
||||
);
|
||||
}
|
||||
|
||||
export function createMilitaryFeed() {
|
||||
return createFeed(
|
||||
'military',
|
||||
async () => {
|
||||
const response = await fetchWithTimeout('/api/military', {
|
||||
cache: 'no-store',
|
||||
});
|
||||
if (!response.ok)
|
||||
throw new FeedError(`Military HTTP ${response.status}`, {
|
||||
status: response.status,
|
||||
});
|
||||
const payload = await response.json();
|
||||
if (!Array.isArray(payload?.ac))
|
||||
throw new FeedError('Malformed military snapshot');
|
||||
const records = [];
|
||||
for (const a of payload.ac) {
|
||||
const lon = finite(a.lon);
|
||||
const lat = finite(a.lat);
|
||||
if (lon === null || lat === null) continue;
|
||||
const onGround = a.alt_baro === 'ground';
|
||||
const altFt = finite(a.alt_geom) ?? finite(a.alt_baro);
|
||||
const cls = classifyAircraft({ typeCode: a.t, category: a.category });
|
||||
records.push({
|
||||
id: String(a.hex),
|
||||
callsign: String(a.flight || a.r || a.hex).trim(),
|
||||
registration: a.r || '',
|
||||
type: a.t || '',
|
||||
lon,
|
||||
lat,
|
||||
altM: onGround ? 0 : Math.max(0, (altFt ?? 15_000) * FT_TO_M),
|
||||
onGround,
|
||||
speed: finite(a.gs) !== null ? a.gs * KT_TO_MPS : null,
|
||||
heading: finite(a.track) ?? finite(a.true_heading) ?? 0,
|
||||
squawk: a.squawk || null,
|
||||
cls,
|
||||
helicopter: cls === 'helicopter',
|
||||
military: true,
|
||||
// adsb.lol dbFlags: 4 = PIA, 8 = LADD (owner privacy programmes).
|
||||
pia: Boolean(a.dbFlags & 4),
|
||||
ladd: Boolean(a.dbFlags & 8),
|
||||
seenAt:
|
||||
Date.now() - (finite(a.seen_pos) ?? finite(a.seen) ?? 0) * 1000,
|
||||
});
|
||||
}
|
||||
return {
|
||||
data: { records, source: 'adsb.lol' },
|
||||
stale: response.headers.get('x-feed-cache') === 'STALE',
|
||||
};
|
||||
},
|
||||
{ ttlMs: 15_000 },
|
||||
);
|
||||
}
|
||||
|
||||
/* ───────────────────────── Sea ───────────────────────── */
|
||||
|
||||
export function createVesselsFeed() {
|
||||
return createFeed(
|
||||
'vessels',
|
||||
async () => {
|
||||
const response = await fetchWithTimeout('/api/vessels?maxRows=4000', {
|
||||
cache: 'no-store',
|
||||
});
|
||||
let payload = null;
|
||||
try {
|
||||
payload = await response.json();
|
||||
} catch {
|
||||
/* status decides */
|
||||
}
|
||||
if (payload?.status === 'missing-key')
|
||||
throw new FeedError('AIS needs AISSTREAM_API_KEY on the server', {
|
||||
keyRequired: true,
|
||||
status: response.status,
|
||||
});
|
||||
if (!response.ok || !Array.isArray(payload?.rows))
|
||||
throw new FeedError(`Vessels HTTP ${response.status}`, {
|
||||
status: response.status,
|
||||
});
|
||||
const records = [];
|
||||
for (const row of payload.rows) {
|
||||
const lat = finite(row.lat);
|
||||
const lon = finite(row.lon);
|
||||
if (lat === null || lon === null) continue;
|
||||
records.push({
|
||||
id: String(row.mmsi || `${lat},${lon}`),
|
||||
name: String(row.name || row.mmsi || 'Vessel'),
|
||||
type: String(row.type || ''),
|
||||
destination: String(row.destination || ''),
|
||||
lon,
|
||||
lat,
|
||||
speed: finite(row.speed) !== null ? row.speed * KT_TO_MPS : null,
|
||||
heading: finite(row.heading) ?? finite(row.course) ?? 0,
|
||||
});
|
||||
}
|
||||
return {
|
||||
data: { records, source: payload.source || 'AISStream' },
|
||||
stale: payload.status === 'stale',
|
||||
};
|
||||
},
|
||||
{ ttlMs: 20_000 },
|
||||
);
|
||||
}
|
||||
|
||||
/* ───────────────────────── Orbit ───────────────────────── */
|
||||
|
||||
export function createTleFeed(group, ttlMs = 2 * 3600_000) {
|
||||
return createFeed(
|
||||
`tle:${group}`,
|
||||
async () => {
|
||||
const response = await fetchWithTimeout(`/api/celestrak/${group}`);
|
||||
if (!response.ok)
|
||||
throw new FeedError(`CelesTrak HTTP ${response.status}`, {
|
||||
status: response.status,
|
||||
});
|
||||
const entries = parseTleText(await response.text());
|
||||
if (!entries.length) throw new FeedError('Empty TLE catalog');
|
||||
return {
|
||||
data: { entries, source: 'CelesTrak' },
|
||||
stale: response.headers.get('x-tle-cache') === 'STALE-ERROR',
|
||||
};
|
||||
},
|
||||
{ ttlMs },
|
||||
);
|
||||
}
|
||||
|
||||
function launchRecord(r, upcoming) {
|
||||
const lat = finite(r?.pad?.latitude);
|
||||
const lon = finite(r?.pad?.longitude);
|
||||
const net = Date.parse(r?.net);
|
||||
if (lat === null || lon === null || !Number.isFinite(net)) return null;
|
||||
return {
|
||||
id: String(r.id),
|
||||
name: String(r.name || 'Launch'),
|
||||
net,
|
||||
status: r.status?.abbrev || r.status?.name || '',
|
||||
provider: r.launch_service_provider?.name || r.lsp_name || '',
|
||||
pad: r.pad?.name || '',
|
||||
location: r.pad?.location?.name || r.location || '',
|
||||
mission: r.mission?.name || '',
|
||||
orbit: r.mission?.orbit?.abbrev || '',
|
||||
image: r.image?.thumbnail_url || r.image?.image_url || null,
|
||||
lon,
|
||||
lat,
|
||||
upcoming,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Recent launches from the app's proxy plus upcoming launches straight from
|
||||
* Launch Library 2 (keyless, CORS-enabled, rate limited to ~15/h, so the
|
||||
* feed TTL is long).
|
||||
*/
|
||||
export function createLaunchesFeed() {
|
||||
return createFeed(
|
||||
'launches',
|
||||
async () => {
|
||||
const [recent, upcoming] = await Promise.allSettled([
|
||||
fetchWithTimeout('/api/launches').then((r) =>
|
||||
r.ok ? r.json() : Promise.reject(new Error(`HTTP ${r.status}`)),
|
||||
),
|
||||
fetchWithTimeout(
|
||||
'https://ll.thespacedevs.com/2.3.0/launches/upcoming/?limit=12&mode=normal',
|
||||
).then((r) =>
|
||||
r.ok ? r.json() : Promise.reject(new Error(`HTTP ${r.status}`)),
|
||||
),
|
||||
]);
|
||||
const records = new Map();
|
||||
const add = (list, isUpcoming) => {
|
||||
for (const raw of list || []) {
|
||||
const rec = launchRecord(raw, isUpcoming);
|
||||
if (rec) records.set(rec.id, rec);
|
||||
}
|
||||
};
|
||||
if (recent.status === 'fulfilled')
|
||||
add(recent.value?.results ?? recent.value, false);
|
||||
if (upcoming.status === 'fulfilled') add(upcoming.value?.results, true);
|
||||
if (!records.size) throw new FeedError('Launch feeds unavailable');
|
||||
const now = Date.now();
|
||||
for (const r of records.values()) r.upcoming = r.net > now - 3600_000;
|
||||
return {
|
||||
data: {
|
||||
records: [...records.values()].sort((a, b) => a.net - b.net),
|
||||
source: 'The Space Devs · Launch Library 2',
|
||||
upcomingOk: upcoming.status === 'fulfilled',
|
||||
},
|
||||
stale: upcoming.status !== 'fulfilled',
|
||||
};
|
||||
},
|
||||
{ ttlMs: 30 * 60_000 },
|
||||
);
|
||||
}
|
||||
|
||||
/* ───────────────────────── Hazards ───────────────────────── */
|
||||
|
||||
export function createQuakesFeed() {
|
||||
return createFeed(
|
||||
'earthquakes',
|
||||
async () => {
|
||||
const response = await fetchWithTimeout(
|
||||
'https://earthquake.usgs.gov/earthquakes/feed/v1.0/summary/2.5_week.geojson',
|
||||
);
|
||||
if (!response.ok)
|
||||
throw new FeedError(`USGS HTTP ${response.status}`, {
|
||||
status: response.status,
|
||||
});
|
||||
const rows = normalizeEarthquakeSnapshot(await response.json());
|
||||
if (!rows) throw new FeedError('Malformed USGS response');
|
||||
return { data: { records: rows, source: 'USGS' } };
|
||||
},
|
||||
{ ttlMs: 5 * 60_000 },
|
||||
);
|
||||
}
|
||||
|
||||
/** NASA EONET open events: wildfires, volcanoes and severe storms. */
|
||||
export function createEonetFeed() {
|
||||
return createFeed(
|
||||
'eonet',
|
||||
async () => {
|
||||
// Volcano events are curated slowly (often months old) but stay open,
|
||||
// so they are asked for without the 45-day window.
|
||||
const base = 'https://eonet.gsfc.nasa.gov/api/v3/events?status=open';
|
||||
const [recent, volcanoes] = await Promise.all([
|
||||
fetchWithTimeout(`${base}&days=45&category=wildfires,severeStorms`),
|
||||
fetchWithTimeout(`${base}&category=volcanoes&limit=80`).catch(
|
||||
() => null,
|
||||
),
|
||||
]);
|
||||
if (!recent.ok)
|
||||
throw new FeedError(`EONET HTTP ${recent.status}`, {
|
||||
status: recent.status,
|
||||
});
|
||||
const events = [...((await recent.json())?.events || [])];
|
||||
if (volcanoes?.ok)
|
||||
events.push(...((await volcanoes.json())?.events || []));
|
||||
const out = { wildfires: [], volcanoes: [], severeStorms: [] };
|
||||
for (const e of events) {
|
||||
const cat = e.categories?.[0]?.id;
|
||||
if (!out[cat]) continue;
|
||||
const points = (e.geometry || [])
|
||||
.filter((g) => g.type === 'Point' && Array.isArray(g.coordinates))
|
||||
.map((g) => ({
|
||||
lon: g.coordinates[0],
|
||||
lat: g.coordinates[1],
|
||||
t: Date.parse(g.date),
|
||||
magnitude: finite(g.magnitudeValue),
|
||||
unit: g.magnitudeUnit || '',
|
||||
}))
|
||||
.filter((p) => Number.isFinite(p.lon) && Number.isFinite(p.lat))
|
||||
.sort((a, b) => a.t - b.t);
|
||||
if (!points.length) continue;
|
||||
const last = points.at(-1);
|
||||
out[cat].push({
|
||||
id: e.id,
|
||||
title: e.title,
|
||||
lon: last.lon,
|
||||
lat: last.lat,
|
||||
t: last.t,
|
||||
firstT: points[0].t,
|
||||
magnitude: last.magnitude,
|
||||
unit: last.unit,
|
||||
track: points,
|
||||
link: e.sources?.[0]?.url || e.link,
|
||||
});
|
||||
}
|
||||
return { data: { ...out, source: 'NASA EONET' } };
|
||||
},
|
||||
{ ttlMs: 20 * 60_000 },
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Active fires: NASA FIRMS hotspots through the app's proxy when a MAP_KEY is
|
||||
* configured, else NASA EONET wildfire events (keyless) so the layer still
|
||||
* says something true.
|
||||
*/
|
||||
export function createFiresFeed(eonet) {
|
||||
return createFeed(
|
||||
'fires',
|
||||
async () => {
|
||||
let keyRequired = false;
|
||||
try {
|
||||
// Ask the proxy whether it has a MAP_KEY before asking for fires,
|
||||
// so a keyless server costs one quiet 200 instead of a 503.
|
||||
const status = await fetchWithTimeout('/api/firms/status', {
|
||||
cache: 'no-store',
|
||||
}).then((r) => (r.ok ? r.json() : null));
|
||||
keyRequired = status?.hasKey === false;
|
||||
if (!keyRequired) {
|
||||
const response = await fetchWithTimeout('/api/firms', {
|
||||
cache: 'no-store',
|
||||
});
|
||||
const payload = await response.json().catch(() => null);
|
||||
if (response.ok && Array.isArray(payload?.fires)) {
|
||||
const records = adaptFirmsRecords(payload.fires).map((f) => ({
|
||||
id: `firms-${f.index}`,
|
||||
lon: f.lon,
|
||||
lat: f.lat,
|
||||
t: f.acqMs,
|
||||
frp: f.frp,
|
||||
confidence: f.confidence,
|
||||
title: 'Fire hotspot',
|
||||
}));
|
||||
return {
|
||||
data: { records, source: 'NASA FIRMS', mode: 'hotspots' },
|
||||
stale: Boolean(payload.stale),
|
||||
};
|
||||
}
|
||||
keyRequired = payload?.error === 'no_key';
|
||||
}
|
||||
} catch {
|
||||
/* fall through to EONET */
|
||||
}
|
||||
const ev = await eonet.get();
|
||||
const records = ev.wildfires.map((f) => ({
|
||||
id: f.id,
|
||||
lon: f.lon,
|
||||
lat: f.lat,
|
||||
t: f.t,
|
||||
frp: null,
|
||||
acres: f.unit === 'acres' ? f.magnitude : null,
|
||||
title: f.title,
|
||||
link: f.link,
|
||||
}));
|
||||
return {
|
||||
data: {
|
||||
records,
|
||||
source: 'NASA EONET wildfire events',
|
||||
mode: 'events',
|
||||
firmsKeyRequired: keyRequired,
|
||||
},
|
||||
};
|
||||
},
|
||||
{ ttlMs: 10 * 60_000 },
|
||||
);
|
||||
}
|
||||
|
||||
export function createCyclonesFeed(eonet) {
|
||||
return createFeed(
|
||||
'cyclones',
|
||||
async () => {
|
||||
const storms = [];
|
||||
let source = 'NOAA NHC / CPHC';
|
||||
let nhcOk = false;
|
||||
try {
|
||||
const response = await fetchWithTimeout('/api/cyclones');
|
||||
if (response.ok) {
|
||||
const raw = await response.json();
|
||||
let snap;
|
||||
try {
|
||||
snap = validateCycloneSnapshot(raw);
|
||||
} catch {
|
||||
snap = raw;
|
||||
}
|
||||
nhcOk = true;
|
||||
for (const s of snap.storms || []) {
|
||||
const lon = s.position?.longitude;
|
||||
const lat = s.position?.latitude;
|
||||
if (!Number.isFinite(lon) || !Number.isFinite(lat)) continue;
|
||||
storms.push({
|
||||
id: s.id,
|
||||
name: s.name,
|
||||
classification: s.classification,
|
||||
basin: s.basin,
|
||||
lon,
|
||||
lat,
|
||||
t: Date.parse(s.positionAt) || Date.now(),
|
||||
windKt: s.windKt,
|
||||
pressureHpa: s.pressureHpa,
|
||||
movement: s.movement,
|
||||
forecast: (s.forecastPoints || []).map((p) => ({
|
||||
lon: p.position.longitude,
|
||||
lat: p.position.latitude,
|
||||
tauHours: p.tauHours,
|
||||
windKt: p.windKt,
|
||||
})),
|
||||
cone: s.cone,
|
||||
track: s.track,
|
||||
link: s.advisoryUrl,
|
||||
source: 'NOAA NHC',
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* EONET below */
|
||||
}
|
||||
// EONET severe storms add the basins NHC does not cover (W. Pacific,
|
||||
// Indian Ocean, Southern Hemisphere).
|
||||
try {
|
||||
const ev = await eonet.get();
|
||||
for (const s of ev.severeStorms) {
|
||||
if (Date.now() - s.t > 3 * 86_400_000) continue;
|
||||
const dup = storms.some(
|
||||
(n) => Math.abs(n.lat - s.lat) < 3 && Math.abs(n.lon - s.lon) < 3,
|
||||
);
|
||||
if (dup) continue;
|
||||
const nameMatch =
|
||||
/(Super Typhoon|Typhoon|Hurricane|Tropical Cyclone|Cyclone|Tropical Storm|Tropical Depression|Storm|Depression)\s+([\w-]+)/i.exec(
|
||||
s.title,
|
||||
);
|
||||
storms.push({
|
||||
id: s.id,
|
||||
name: nameMatch ? nameMatch[2] : s.title,
|
||||
label: nameMatch
|
||||
? nameMatch[1].replace(/\b\w/g, (c) => c.toUpperCase())
|
||||
: null,
|
||||
classification: /typhoon/i.test(s.title)
|
||||
? 'TY'
|
||||
: /cyclone/i.test(s.title)
|
||||
? 'TC'
|
||||
: 'TS',
|
||||
basin: '',
|
||||
title: s.title,
|
||||
lon: s.lon,
|
||||
lat: s.lat,
|
||||
t: s.t,
|
||||
windKt: s.unit === 'kts' ? s.magnitude : null,
|
||||
pastTrack: s.track,
|
||||
forecast: [],
|
||||
link: s.link,
|
||||
source: 'NASA EONET',
|
||||
});
|
||||
}
|
||||
if (!nhcOk) source = 'NASA EONET';
|
||||
else source = 'NOAA NHC / CPHC + NASA EONET';
|
||||
} catch {
|
||||
if (!nhcOk) throw new FeedError('Cyclone feeds unavailable');
|
||||
}
|
||||
return { data: { records: storms, source } };
|
||||
},
|
||||
{ ttlMs: 15 * 60_000 },
|
||||
);
|
||||
}
|
||||
|
||||
/* ───────────────────────── Eyes ───────────────────────── */
|
||||
|
||||
export function createCctvFeed() {
|
||||
return createFeed(
|
||||
'cctv',
|
||||
async () => {
|
||||
const response = await fetchWithTimeout('/api/cctv/sources', {
|
||||
cache: 'no-store',
|
||||
});
|
||||
if (!response.ok)
|
||||
throw new FeedError(`CCTV HTTP ${response.status}`, {
|
||||
status: response.status,
|
||||
});
|
||||
const payload = await response.json();
|
||||
if (!Array.isArray(payload?.sources))
|
||||
throw new FeedError('Malformed camera catalog');
|
||||
const records = [];
|
||||
for (const c of payload.sources) {
|
||||
const lat = finite(c.lat);
|
||||
const lon = finite(c.lon);
|
||||
if (lat === null || lon === null) continue;
|
||||
records.push({
|
||||
id: String(c.id),
|
||||
name: String(c.name || c.code || 'Camera'),
|
||||
city: c.city || '',
|
||||
provider: c.provider || '',
|
||||
lon,
|
||||
lat,
|
||||
heading: finite(c.headingDeg) ?? 0,
|
||||
fovDeg: finite(c.fovDeg) ?? 50,
|
||||
pitchDeg: finite(c.pitchDeg) ?? -10,
|
||||
feedType: c.feedType || 'image',
|
||||
});
|
||||
}
|
||||
return { data: { records, source: 'Public traffic cameras' } };
|
||||
},
|
||||
{ ttlMs: 30 * 60_000 },
|
||||
);
|
||||
}
|
||||
|
||||
/** Same frame URL family the production CCTV layer uses. */
|
||||
export function cctvFrameUrl(camera) {
|
||||
const params = new URLSearchParams({
|
||||
label: camera.name,
|
||||
city: camera.city,
|
||||
lat: camera.lat.toFixed(6),
|
||||
lon: camera.lon.toFixed(6),
|
||||
heading: String(Math.round(camera.heading)),
|
||||
fov: String(Math.round(camera.fovDeg)),
|
||||
pitch: String(Math.round(camera.pitchDeg || -10)),
|
||||
ts: String(Math.floor(Date.now() / 10_000)),
|
||||
});
|
||||
return `/api/cctv/frame/${encodeURIComponent(camera.id)}?${params}`;
|
||||
}
|
||||
|
||||
/* ───────────────────────── Bundled event ───────────────────────── */
|
||||
|
||||
export function createFloodFeed() {
|
||||
return createFeed(
|
||||
'bhote-koshi',
|
||||
async () => {
|
||||
let event = null;
|
||||
try {
|
||||
const response = await fetchWithTimeout(
|
||||
'/events/bhote-koshi-2026/event.json',
|
||||
);
|
||||
if (response.ok) event = await response.json();
|
||||
} catch {
|
||||
/* the bundled centerline below still renders */
|
||||
}
|
||||
const corridor = event?.reconstruction?.corridor?.map((c) => [
|
||||
c.lon,
|
||||
c.lat,
|
||||
c.elevationM,
|
||||
]);
|
||||
return {
|
||||
data: {
|
||||
event,
|
||||
path: BHOTE_KOSHI_FLOOD_PATH.map(([lon, lat]) => [lon, lat]),
|
||||
corridor: corridor?.length ? corridor : null,
|
||||
source: 'GeoPera river centerline (CC BY-NC 4.0) · Vantor Open Data',
|
||||
},
|
||||
};
|
||||
},
|
||||
{ ttlMs: Infinity },
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,730 @@
|
||||
/**
|
||||
* Round-two feeds: weather rasters (NOAA nowCOAST through the app's
|
||||
* /api/weather proxy), wind fields (/api/wind GFS, else Open-Meteo), the jet
|
||||
* stream (Open-Meteo 250 hPa), NHC wind radii (api.weather.gov TCM text,
|
||||
* CORS-enabled), GNSS integrity (/api/gnss-integrity), GTFS-realtime transit
|
||||
* (/api/transit), air quality (Open-Meteo) and regional ADS-B scans.
|
||||
*
|
||||
* Every loader is keyless. Each returns real data or throws; nothing here
|
||||
* invents a value when an upstream is down.
|
||||
*/
|
||||
import { createFeed, FeedError } from './feeds.js';
|
||||
import { finite } from './geo.js';
|
||||
|
||||
const TIMEOUT_MS = 25_000;
|
||||
|
||||
async function getJson(url, { timeoutMs = TIMEOUT_MS, ...init } = {}) {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
try {
|
||||
const response = await fetch(url, { ...init, signal: controller.signal });
|
||||
if (!response.ok)
|
||||
throw new FeedError(`HTTP ${response.status} for ${url.split('?')[0]}`, {
|
||||
status: response.status,
|
||||
});
|
||||
return await response.json();
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
/** A small keyed cache of single-flight loads (per anchor / per feed id). */
|
||||
export function createKeyedFeed(name, load, { ttlMs = 60_000, max = 32 } = {}) {
|
||||
const entries = new Map();
|
||||
return {
|
||||
name,
|
||||
peek(key) {
|
||||
return entries.get(key)?.data ?? null;
|
||||
},
|
||||
entries() {
|
||||
return [...entries.values()].filter((e) => e.data);
|
||||
},
|
||||
async get(key, args, { maxAgeMs = ttlMs } = {}) {
|
||||
let e = entries.get(key);
|
||||
if (e?.data && Date.now() - e.at < maxAgeMs) return e.data;
|
||||
if (e?.error && Date.now() - e.failedAt < 30_000) {
|
||||
if (e.data) return e.data;
|
||||
throw e.error;
|
||||
}
|
||||
if (!e) {
|
||||
e = { key, data: null, at: 0, inflight: null, error: null };
|
||||
entries.set(key, e);
|
||||
while (entries.size > max) entries.delete(entries.keys().next().value);
|
||||
}
|
||||
if (!e.inflight)
|
||||
e.inflight = (async () => {
|
||||
try {
|
||||
e.data = await load(args ?? key);
|
||||
e.at = Date.now();
|
||||
e.error = null;
|
||||
} catch (error) {
|
||||
e.error = error;
|
||||
e.failedAt = Date.now();
|
||||
} finally {
|
||||
e.inflight = null;
|
||||
}
|
||||
})();
|
||||
await e.inflight;
|
||||
if (!e.data) throw e.error || new FeedError(`${name} unavailable`);
|
||||
return e.data;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/* ───────────────────────── Weather rasters ───────────────────────── */
|
||||
|
||||
export const WEATHER_TILE_LEVEL_MAX = 6;
|
||||
|
||||
/** nowCOAST manifest: { product, times[], latest, bounds, title, attribution }. */
|
||||
export function createWeatherManifestFeed(product) {
|
||||
return createFeed(
|
||||
`weather:${product}`,
|
||||
async () => {
|
||||
const m = await getJson(`/api/weather/manifest?product=${product}`, {
|
||||
cache: 'no-store',
|
||||
});
|
||||
if (m?.unavailable || !Array.isArray(m?.times) || !m.times.length)
|
||||
throw new FeedError(m?.reason || `${product} imagery unavailable`);
|
||||
return {
|
||||
data: {
|
||||
product,
|
||||
times: m.times,
|
||||
latest: m.latest,
|
||||
bounds: m.bounds,
|
||||
title: m.title,
|
||||
coverage: m.coverage,
|
||||
description: m.description,
|
||||
attribution: m.attribution,
|
||||
source: `NOAA nowCOAST · ${m.title}`,
|
||||
},
|
||||
stale: Boolean(m.stale),
|
||||
};
|
||||
},
|
||||
{ ttlMs: product === 'clouds' ? 10 * 60_000 : 2 * 60_000 },
|
||||
);
|
||||
}
|
||||
|
||||
/** Same-origin whole-extent image URL for a frame (2:1 sizes only). */
|
||||
export function weatherImageUrl(product, time, width = 2048) {
|
||||
const size = width === 4096 ? '' : `&size=${width}x${width / 2}`;
|
||||
return `/api/weather/image?product=${product}&time=${encodeURIComponent(time)}${size}`;
|
||||
}
|
||||
|
||||
/** Same-origin geographic tile URL (z ≤ 6; 2×1 tiles at level 0). */
|
||||
export function weatherTileUrl(product, time, z, x, y) {
|
||||
return `/api/weather/tile?product=${product}&time=${encodeURIComponent(time)}&z=${z}&x=${x}&y=${y}`;
|
||||
}
|
||||
|
||||
/** Latest frame at or before `ms` (the latest when ms is null). */
|
||||
export function frameAt(times, ms) {
|
||||
if (!times?.length) return null;
|
||||
if (ms == null) return times.at(-1);
|
||||
let best = null;
|
||||
for (const t of times) if (Date.parse(t) <= ms + 60_000) best = t;
|
||||
return best;
|
||||
}
|
||||
|
||||
/* ───────────────────────── Wind ───────────────────────── */
|
||||
|
||||
const MS_PER_KMH = 1 / 3.6;
|
||||
|
||||
/** u/v (m/s) from a meteorological speed (km/h) and from-direction (deg). */
|
||||
function uv(speedKmh, dirDeg) {
|
||||
const s = speedKmh * MS_PER_KMH;
|
||||
const r = (dirDeg * Math.PI) / 180;
|
||||
return [-s * Math.sin(r), -s * Math.cos(r)];
|
||||
}
|
||||
|
||||
/** A coarse global grid of Open-Meteo points, north to south, 12° spacing
|
||||
* (390 points: one request stays under Open-Meteo's per-minute budget). */
|
||||
function omGrid(step = 12, north = 72, south = -72) {
|
||||
const lats = [];
|
||||
const lons = [];
|
||||
for (let la = north; la >= south; la -= step)
|
||||
for (let lo = -180; lo < 180; lo += step) {
|
||||
lats.push(la);
|
||||
lons.push(lo);
|
||||
}
|
||||
return {
|
||||
lats,
|
||||
lons,
|
||||
nx: 360 / step,
|
||||
ny: (north - south) / step + 1,
|
||||
lo1: -180,
|
||||
la1: north,
|
||||
dx: step,
|
||||
dy: step,
|
||||
};
|
||||
}
|
||||
|
||||
const OM_CACHE_MS = 60 * 60_000;
|
||||
|
||||
/** Grids persist per browser for an hour: Open-Meteo counts every point. */
|
||||
function readGridCache(key) {
|
||||
try {
|
||||
const raw = localStorage.getItem(key);
|
||||
if (!raw) return null;
|
||||
const j = JSON.parse(raw);
|
||||
if (Date.now() - j.at > OM_CACHE_MS) return null;
|
||||
const dec = (b64) =>
|
||||
new Float32Array(
|
||||
Uint8Array.from(atob(b64), (c) => c.charCodeAt(0)).buffer,
|
||||
);
|
||||
return {
|
||||
field: { ...j.grid, u: dec(j.u), v: dec(j.v) },
|
||||
speed: dec(j.s),
|
||||
time: j.time,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function writeGridCache(key, value) {
|
||||
try {
|
||||
const enc = (arr) =>
|
||||
btoa(String.fromCharCode(...new Uint8Array(arr.buffer)));
|
||||
const { u, v, ...grid } = value.field;
|
||||
localStorage.setItem(
|
||||
key,
|
||||
JSON.stringify({
|
||||
at: Date.now(),
|
||||
time: value.time,
|
||||
grid,
|
||||
u: enc(u),
|
||||
v: enc(v),
|
||||
s: enc(value.speed),
|
||||
}),
|
||||
);
|
||||
} catch {
|
||||
/* storage full or blocked */
|
||||
}
|
||||
}
|
||||
|
||||
async function openMeteoField(opts = {}) {
|
||||
const key = `gev-om-grid:${opts.hourlyLevel || '10m'}:${opts.step || 12}`;
|
||||
const cached = readGridCache(key);
|
||||
if (cached) return cached;
|
||||
const value = await fetchOpenMeteoField(opts);
|
||||
writeGridCache(key, value);
|
||||
return value;
|
||||
}
|
||||
|
||||
async function fetchOpenMeteoField({ hourlyLevel = null, step = 12 } = {}) {
|
||||
const g = omGrid(step);
|
||||
const vars = hourlyLevel
|
||||
? `hourly=wind_speed_${hourlyLevel},wind_direction_${hourlyLevel}&forecast_hours=1`
|
||||
: 'current=wind_speed_10m,wind_direction_10m';
|
||||
// Three parallel chunks: a cold pressure-level request for the whole grid
|
||||
// can take Open-Meteo half a minute; thirds come back in seconds.
|
||||
const chunk = Math.ceil(g.lats.length / 3);
|
||||
const parts = await Promise.all(
|
||||
[0, 1, 2].map((k) => {
|
||||
const la = g.lats.slice(k * chunk, (k + 1) * chunk);
|
||||
const lo = g.lons.slice(k * chunk, (k + 1) * chunk);
|
||||
return getJson(
|
||||
`https://api.open-meteo.com/v1/forecast?latitude=${la.join(',')}&longitude=${lo.join(',')}&${vars}&timezone=GMT`,
|
||||
{ timeoutMs: 45_000 },
|
||||
);
|
||||
}),
|
||||
);
|
||||
const rows = parts.flat();
|
||||
if (rows.length !== g.lats.length)
|
||||
throw new FeedError('Malformed Open-Meteo wind grid');
|
||||
const n = rows.length;
|
||||
const u = new Float32Array(n);
|
||||
const v = new Float32Array(n);
|
||||
const speed = new Float32Array(n);
|
||||
let time = null;
|
||||
for (let i = 0; i < n; i++) {
|
||||
const r = rows[i];
|
||||
const s = hourlyLevel
|
||||
? r.hourly?.[`wind_speed_${hourlyLevel}`]?.[0]
|
||||
: r.current?.wind_speed_10m;
|
||||
const d = hourlyLevel
|
||||
? r.hourly?.[`wind_direction_${hourlyLevel}`]?.[0]
|
||||
: r.current?.wind_direction_10m;
|
||||
time ??= hourlyLevel ? r.hourly?.time?.[0] : r.current?.time;
|
||||
if (!Number.isFinite(s) || !Number.isFinite(d)) continue;
|
||||
[u[i], v[i]] = uv(s, d);
|
||||
speed[i] = s * MS_PER_KMH;
|
||||
}
|
||||
return {
|
||||
field: {
|
||||
u,
|
||||
v,
|
||||
nx: g.nx,
|
||||
ny: g.ny,
|
||||
lo1: g.lo1,
|
||||
la1: g.la1,
|
||||
dx: g.dx,
|
||||
dy: g.dy,
|
||||
},
|
||||
speed,
|
||||
time: time ? Date.parse(`${time}Z`) : Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Surface (10 m) wind: the app's GFS grid when the server can decode it,
|
||||
* otherwise a 12° Open-Meteo grid (GFS/ICON blend, keyless).
|
||||
*/
|
||||
export function createWindFeed() {
|
||||
return createFeed(
|
||||
'wind',
|
||||
async () => {
|
||||
try {
|
||||
const m = await getJson('/api/wind/manifest?model=gfs', {
|
||||
cache: 'no-store',
|
||||
timeoutMs: 45_000,
|
||||
});
|
||||
if (!m?.unavailable && m?.grid && m?.gridUrl) {
|
||||
const response = await fetch(m.gridUrl);
|
||||
if (response.ok) {
|
||||
const count = m.grid.nx * m.grid.ny;
|
||||
const values = new Float32Array(await response.arrayBuffer());
|
||||
if (values.length >= count * 2) {
|
||||
const u = values.slice(0, count);
|
||||
const v = values.slice(count, count * 2);
|
||||
return {
|
||||
data: {
|
||||
field: { ...m.grid, u, v },
|
||||
level: '10 m',
|
||||
time: m.fetchedAt || Date.now(),
|
||||
source: 'NOAA GFS 10 m wind',
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* fall through to Open-Meteo */
|
||||
}
|
||||
const om = await openMeteoField();
|
||||
return {
|
||||
data: {
|
||||
...om,
|
||||
level: '10 m',
|
||||
source: 'Open-Meteo forecast API (12° grid)',
|
||||
},
|
||||
};
|
||||
},
|
||||
{ ttlMs: 60 * 60_000 },
|
||||
);
|
||||
}
|
||||
|
||||
/** Jet stream: Open-Meteo 250 hPa (≈ FL340) wind on a 12° grid. */
|
||||
export function createJetFeed() {
|
||||
return createFeed(
|
||||
'jet',
|
||||
async () => {
|
||||
const om = await openMeteoField({ hourlyLevel: '250hPa' });
|
||||
return {
|
||||
data: {
|
||||
...om,
|
||||
level: '250 hPa',
|
||||
source: 'Open-Meteo forecast API · 250 hPa (12° grid)',
|
||||
},
|
||||
};
|
||||
},
|
||||
{ ttlMs: 2 * 3600_000 },
|
||||
);
|
||||
}
|
||||
|
||||
/** One point's current 10 m wind (for smoke plumes). */
|
||||
export const pointWind = createKeyedFeed(
|
||||
'point-wind',
|
||||
async ({ lon, lat }) => {
|
||||
// The app's own regional weather route first (server-side, cached).
|
||||
try {
|
||||
const r = await getJson(
|
||||
`/api/weather-effects?latitude=${lat.toFixed(2)}&longitude=${lon.toFixed(2)}`,
|
||||
{ timeoutMs: 15_000 },
|
||||
);
|
||||
const w = r?.weather;
|
||||
if (Number.isFinite(w?.windKph) && Number.isFinite(w?.windDirectionDeg))
|
||||
return {
|
||||
speedKmh: w.windKph,
|
||||
fromDeg: w.windDirectionDeg,
|
||||
gustKmh: null,
|
||||
time: Date.parse(w.observedAt) || Date.now(),
|
||||
source: 'Open-Meteo via /api/weather-effects',
|
||||
};
|
||||
} catch {
|
||||
/* direct below */
|
||||
}
|
||||
const j = await getJson(
|
||||
`https://api.open-meteo.com/v1/forecast?latitude=${lat.toFixed(2)}&longitude=${lon.toFixed(2)}¤t=wind_speed_10m,wind_direction_10m,wind_gusts_10m&timezone=GMT`,
|
||||
);
|
||||
const c = j?.current;
|
||||
if (!Number.isFinite(c?.wind_speed_10m))
|
||||
throw new FeedError('No point wind');
|
||||
return {
|
||||
speedKmh: c.wind_speed_10m,
|
||||
fromDeg: c.wind_direction_10m,
|
||||
gustKmh: c.wind_gusts_10m,
|
||||
time: Date.parse(`${c.time}Z`),
|
||||
source: 'Open-Meteo',
|
||||
};
|
||||
},
|
||||
{ ttlMs: 30 * 60_000, max: 64 },
|
||||
);
|
||||
|
||||
/** Bilinear sample of a north-to-south, seam-wrapped field. */
|
||||
export function sampleField(field, lon, lat) {
|
||||
const { u, v, nx, ny, lo1, la1, dx, dy } = field;
|
||||
const x = ((((lon - lo1) % 360) + 360) % 360) / dx;
|
||||
const y = Math.max(0, Math.min(ny - 1, (la1 - lat) / dy));
|
||||
const x0 = Math.floor(x);
|
||||
const y0 = Math.floor(y);
|
||||
const tx = x - x0;
|
||||
const ty = y - y0;
|
||||
const x1 = (x0 + 1) % nx;
|
||||
const y1 = Math.min(ny - 1, y0 + 1);
|
||||
const at = (arr, xi, yi) => arr[yi * nx + (xi % nx)];
|
||||
const lerp = (arr) =>
|
||||
(at(arr, x0, y0) * (1 - tx) + at(arr, x1, y0) * tx) * (1 - ty) +
|
||||
(at(arr, x0, y1) * (1 - tx) + at(arr, x1, y1) * tx) * ty;
|
||||
return { u: lerp(u), v: lerp(v) };
|
||||
}
|
||||
|
||||
/* ───────────────────────── NHC wind radii ───────────────────────── */
|
||||
|
||||
const NM_KM = 1.852;
|
||||
|
||||
/** Parse an NHC TCM (forecast/advisory) for the current position's radii. */
|
||||
export function parseTcm(text) {
|
||||
if (typeof text !== 'string') return null;
|
||||
const name =
|
||||
/(?:HURRICANE|TROPICAL STORM|TROPICAL DEPRESSION|SUBTROPICAL STORM|POST-TROPICAL CYCLONE|POTENTIAL TROPICAL CYCLONE)\s+([A-Z-]+)/.exec(
|
||||
text,
|
||||
)?.[1];
|
||||
const center =
|
||||
/CENTER LOCATED NEAR\s+(\d+\.\d)([NS])\s+(\d+\.\d)([EW])\s+AT\s+(\d\d)\/(\d\d)(\d\d)Z/.exec(
|
||||
text,
|
||||
);
|
||||
if (!center) return null;
|
||||
const lat = Number(center[1]) * (center[2] === 'S' ? -1 : 1);
|
||||
const lon = Number(center[3]) * (center[4] === 'W' ? -1 : 1);
|
||||
// The first radii block after "MAX SUSTAINED WINDS" is the current one.
|
||||
const block = text.split(/MAX SUSTAINED WINDS/)[1]?.split(/REPEAT\.\.\./)[0];
|
||||
const radii = [];
|
||||
for (const m of (block || '').matchAll(
|
||||
/(\d\d) KT\.+\s*(\d+)NE\s+(\d+)SE\s+(\d+)SW\s+(\d+)NW/g,
|
||||
)) {
|
||||
radii.push({
|
||||
kt: Number(m[1]),
|
||||
// NE, SE, SW, NW in km
|
||||
quadrantsKm: [m[2], m[3], m[4], m[5]].map((x) => Number(x) * NM_KM),
|
||||
});
|
||||
}
|
||||
// Forecast and outlook positions (used when the cone geometry is pending).
|
||||
const forecast = [];
|
||||
for (const m of text.matchAll(
|
||||
/(?:FORECAST|OUTLOOK) VALID\s+(\d\d)\/(\d\d)(\d\d)Z\s+(\d+\.\d)([NS])\s+(\d+\.\d)([EW])[\s\S]*?MAX WIND\s+(\d+) KT/g,
|
||||
)) {
|
||||
forecast.push({
|
||||
day: Number(m[1]),
|
||||
hourZ: Number(m[2]),
|
||||
lat: Number(m[4]) * (m[5] === 'S' ? -1 : 1),
|
||||
lon: Number(m[6]) * (m[7] === 'W' ? -1 : 1),
|
||||
windKt: Number(m[8]),
|
||||
});
|
||||
}
|
||||
const pressure = /MINIMUM CENTRAL PRESSURE\s+(\d+) MB/.exec(text)?.[1];
|
||||
const maxWind = /MAX SUSTAINED WINDS\s+(\d+) KT/.exec(block ? text : '')?.[1];
|
||||
return {
|
||||
name: name || null,
|
||||
lat,
|
||||
lon,
|
||||
radii,
|
||||
forecast,
|
||||
pressureHpa: pressure ? Number(pressure) : null,
|
||||
windKt: maxWind ? Number(maxWind) : null,
|
||||
};
|
||||
}
|
||||
|
||||
/** Latest TCM per active storm from api.weather.gov (CORS-enabled, keyless). */
|
||||
export function createTcmFeed() {
|
||||
return createFeed(
|
||||
'nhc-tcm',
|
||||
async () => {
|
||||
const list = await getJson('https://api.weather.gov/products/types/TCM', {
|
||||
headers: { Accept: 'application/ld+json' },
|
||||
});
|
||||
const graph = list?.['@graph'] || [];
|
||||
const now = Date.now();
|
||||
const latest = new Map();
|
||||
for (const p of graph) {
|
||||
const t = Date.parse(p.issuanceTime);
|
||||
if (!Number.isFinite(t) || now - t > 18 * 3600_000) continue;
|
||||
const key = p.wmoCollectiveId;
|
||||
if (!latest.has(key)) latest.set(key, p);
|
||||
}
|
||||
const storms = [];
|
||||
await Promise.all(
|
||||
[...latest.values()].slice(0, 8).map(async (p) => {
|
||||
try {
|
||||
const prod = await getJson(
|
||||
`https://api.weather.gov/products/${p.id}`,
|
||||
{ headers: { Accept: 'application/ld+json' } },
|
||||
);
|
||||
const parsed = parseTcm(prod?.productText);
|
||||
if (parsed)
|
||||
storms.push({
|
||||
...parsed,
|
||||
issuedAt: Date.parse(p.issuanceTime),
|
||||
wmo: p.wmoCollectiveId,
|
||||
});
|
||||
} catch {
|
||||
/* one advisory failing drops only its radii */
|
||||
}
|
||||
}),
|
||||
);
|
||||
return {
|
||||
data: {
|
||||
storms,
|
||||
source: 'NOAA NHC forecast/advisory via api.weather.gov',
|
||||
},
|
||||
};
|
||||
},
|
||||
{ ttlMs: 30 * 60_000 },
|
||||
);
|
||||
}
|
||||
|
||||
/* ───────────────────────── GNSS integrity ───────────────────────── */
|
||||
|
||||
/** Well-known interference regions worth sampling (anchors, whole degrees). */
|
||||
export const GNSS_ANCHORS = Object.freeze([
|
||||
{ id: 'baltic', name: 'Baltic Sea', lat: 56, lon: 21 },
|
||||
{ id: 'black-sea', name: 'Black Sea', lat: 45, lon: 33 },
|
||||
{ id: 'levant', name: 'Eastern Mediterranean', lat: 34, lon: 34 },
|
||||
{ id: 'gulf', name: 'Persian Gulf', lat: 27, lon: 52 },
|
||||
{ id: 'finland', name: 'Gulf of Finland', lat: 60, lon: 26 },
|
||||
]);
|
||||
|
||||
/** A cell is "anomalous" by the gpsjam band formula on aircraft counts. */
|
||||
function gnssCells(rows, cellDeg = 0.5) {
|
||||
const cells = new Map();
|
||||
for (const r of rows) {
|
||||
const key = `${Math.floor(r.lat / cellDeg)}:${Math.floor(r.lon / cellDeg)}`;
|
||||
let c = cells.get(key);
|
||||
if (!c)
|
||||
cells.set(
|
||||
key,
|
||||
(c = {
|
||||
lat: (Math.floor(r.lat / cellDeg) + 0.5) * cellDeg,
|
||||
lon: (Math.floor(r.lon / cellDeg) + 0.5) * cellDeg,
|
||||
total: 0,
|
||||
bad: 0,
|
||||
}),
|
||||
);
|
||||
c.total++;
|
||||
if (r.degraded || r.gpsLost) c.bad++;
|
||||
}
|
||||
const out = [];
|
||||
for (const c of cells.values()) {
|
||||
if (c.total < 3) continue;
|
||||
c.percent = (100 * Math.max(0, c.bad - 1)) / c.total;
|
||||
c.level = c.percent >= 10 ? 'high' : c.percent >= 2 ? 'medium' : 'low';
|
||||
out.push(c);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export const gnssIntegrity = createKeyedFeed(
|
||||
'gnss',
|
||||
async ({ lat, lon }) => {
|
||||
const j = await getJson(
|
||||
`/api/gnss-integrity?lat=${Math.round(lat)}&lon=${Math.round(lon)}`,
|
||||
{ cache: 'no-store' },
|
||||
);
|
||||
if (!Array.isArray(j?.rows)) throw new FeedError('Malformed GNSS snapshot');
|
||||
const rows = j.rows.filter(
|
||||
(r) => Number.isFinite(r.lat) && Number.isFinite(r.lon),
|
||||
);
|
||||
return {
|
||||
anchor: j.anchor,
|
||||
rows,
|
||||
cells: gnssCells(rows),
|
||||
degraded: rows.filter((r) => r.degraded || r.gpsLost).length,
|
||||
fetchedAt: j.fetchedAt || Date.now(),
|
||||
source: 'adsb.lol NIC/NACp via /api/gnss-integrity',
|
||||
};
|
||||
},
|
||||
{ ttlMs: 2 * 60_000, max: 24 },
|
||||
);
|
||||
|
||||
/* ───────────────────────── Transit ───────────────────────── */
|
||||
|
||||
export function createTransitCatalogFeed() {
|
||||
return createFeed(
|
||||
'transit-feeds',
|
||||
async () => {
|
||||
const j = await getJson('/api/transit/feeds');
|
||||
if (!Array.isArray(j?.feeds)) throw new FeedError('No transit catalog');
|
||||
return { data: { feeds: j.feeds, source: 'GTFS-realtime agencies' } };
|
||||
},
|
||||
{ ttlMs: 6 * 3600_000 },
|
||||
);
|
||||
}
|
||||
|
||||
export const transitVehicles = createKeyedFeed(
|
||||
'transit',
|
||||
async (feedId) => {
|
||||
const j = await getJson(
|
||||
`/api/transit/vehicles/${encodeURIComponent(feedId)}`,
|
||||
{ cache: 'no-store' },
|
||||
);
|
||||
if (!Array.isArray(j?.vehicles)) throw new FeedError('No vehicles');
|
||||
return {
|
||||
feedId,
|
||||
name: j.name,
|
||||
fetchedAt: j.fetchedAt,
|
||||
vehicles: j.vehicles
|
||||
.filter((v) => Number.isFinite(v.lat) && Number.isFinite(v.lon))
|
||||
.map((v) => ({
|
||||
id: String(v.id),
|
||||
lat: v.lat,
|
||||
lon: v.lon,
|
||||
bearing: finite(v.bearing),
|
||||
speedMps: finite(v.speedMps),
|
||||
routeId: v.routeId || '',
|
||||
status: v.status || '',
|
||||
t: (v.timestamp || 0) * 1000,
|
||||
})),
|
||||
};
|
||||
},
|
||||
{ ttlMs: 20_000, max: 8 },
|
||||
);
|
||||
|
||||
/* ───────────────────────── Air quality ───────────────────────── */
|
||||
|
||||
export const AQ_CITIES = Object.freeze([
|
||||
['Delhi', 77.21, 28.61],
|
||||
['Lahore', 74.35, 31.55],
|
||||
['Dhaka', 90.41, 23.81],
|
||||
['Kolkata', 88.36, 22.57],
|
||||
['Mumbai', 72.88, 19.08],
|
||||
['Beijing', 116.4, 39.9],
|
||||
['Shanghai', 121.47, 31.23],
|
||||
['Chengdu', 104.07, 30.67],
|
||||
['Seoul', 126.98, 37.57],
|
||||
['Tokyo', 139.69, 35.69],
|
||||
['Bangkok', 100.5, 13.76],
|
||||
['Hanoi', 105.85, 21.03],
|
||||
['Jakarta', 106.85, -6.21],
|
||||
['Manila', 120.98, 14.6],
|
||||
['Karachi', 67.01, 24.86],
|
||||
['Tehran', 51.39, 35.69],
|
||||
['Riyadh', 46.68, 24.71],
|
||||
['Cairo', 31.24, 30.04],
|
||||
['Lagos', 3.38, 6.52],
|
||||
['Kinshasa', 15.27, -4.44],
|
||||
['Johannesburg', 28.05, -26.2],
|
||||
['Istanbul', 28.98, 41.01],
|
||||
['Moscow', 37.62, 55.76],
|
||||
['Warsaw', 21.01, 52.23],
|
||||
['Paris', 2.35, 48.86],
|
||||
['London', -0.13, 51.51],
|
||||
['Madrid', -3.7, 40.42],
|
||||
['Milan', 9.19, 45.46],
|
||||
['New York', -74.01, 40.71],
|
||||
['Los Angeles', -118.24, 34.05],
|
||||
['Mexico City', -99.13, 19.43],
|
||||
['Chicago', -87.63, 41.88],
|
||||
['Houston', -95.37, 29.76],
|
||||
['São Paulo', -46.63, -23.55],
|
||||
['Lima', -77.04, -12.05],
|
||||
['Santiago', -70.67, -33.45],
|
||||
['Bogotá', -74.07, 4.71],
|
||||
['Sydney', 151.21, -33.87],
|
||||
['Ulaanbaatar', 106.92, 47.89],
|
||||
['Kathmandu', 85.32, 27.72],
|
||||
]);
|
||||
|
||||
export function createAirQualityFeed() {
|
||||
return createFeed(
|
||||
'air-quality',
|
||||
async () => {
|
||||
const lats = AQ_CITIES.map((c) => c[2]).join(',');
|
||||
const lons = AQ_CITIES.map((c) => c[1]).join(',');
|
||||
const rows = await getJson(
|
||||
`https://air-quality-api.open-meteo.com/v1/air-quality?latitude=${lats}&longitude=${lons}¤t=us_aqi_pm2_5,pm2_5&hourly=pm2_5&past_days=1&forecast_days=1&timezone=GMT`,
|
||||
{ timeoutMs: 40_000 },
|
||||
);
|
||||
const list = Array.isArray(rows) ? rows : [rows];
|
||||
if (list.length !== AQ_CITIES.length)
|
||||
throw new FeedError('Malformed air-quality response');
|
||||
const now = Date.now();
|
||||
const records = [];
|
||||
list.forEach((r, i) => {
|
||||
const aqi = r?.current?.us_aqi_pm2_5;
|
||||
if (!Number.isFinite(aqi)) return;
|
||||
const times = r.hourly?.time || [];
|
||||
const pm = r.hourly?.pm2_5 || [];
|
||||
const series = [];
|
||||
for (let k = 0; k < times.length; k++) {
|
||||
const t = Date.parse(`${times[k]}Z`);
|
||||
if (t <= now && now - t <= 26 * 3600_000 && Number.isFinite(pm[k]))
|
||||
series.push({ t, v: pm[k] });
|
||||
}
|
||||
records.push({
|
||||
id: `aq-${AQ_CITIES[i][0]}`,
|
||||
name: AQ_CITIES[i][0],
|
||||
lon: AQ_CITIES[i][1],
|
||||
lat: AQ_CITIES[i][2],
|
||||
aqi,
|
||||
pm25: r.current.pm2_5,
|
||||
series,
|
||||
t: Date.parse(`${r.current.time}Z`),
|
||||
});
|
||||
});
|
||||
if (!records.length) throw new FeedError('No air-quality values');
|
||||
return {
|
||||
data: {
|
||||
records,
|
||||
source: 'Open-Meteo Air Quality API (CAMS forecast model)',
|
||||
},
|
||||
};
|
||||
},
|
||||
{ ttlMs: 60 * 60_000 },
|
||||
);
|
||||
}
|
||||
|
||||
export function aqiColor(aqi) {
|
||||
if (aqi <= 50) return '#5BD46B';
|
||||
if (aqi <= 100) return '#F2D33B';
|
||||
if (aqi <= 150) return '#FF9A3D';
|
||||
if (aqi <= 200) return '#FF4D4D';
|
||||
if (aqi <= 300) return '#B04DFF';
|
||||
return '#8C1A3A';
|
||||
}
|
||||
|
||||
/* ───────────────────────── Regional ADS-B ───────────────────────── */
|
||||
|
||||
/**
|
||||
* The flights proxy answers with OpenSky's global snapshot, or (when
|
||||
* OpenSky is down) a 250 nm adsb.lol snapshot around the anchor. Either way
|
||||
* the records near the anchor are real; this cache keeps one per anchor.
|
||||
*/
|
||||
export function createRegionalFlights(flightFromState) {
|
||||
return createKeyedFeed(
|
||||
'regional-flights',
|
||||
async ({ lat, lon }) => {
|
||||
const j = await getJson(
|
||||
`/api/flights?lat=${lat.toFixed(3)}&lon=${lon.toFixed(3)}`,
|
||||
{ cache: 'no-store', timeoutMs: 30_000 },
|
||||
);
|
||||
if (!Array.isArray(j?.states))
|
||||
throw new FeedError('Malformed flight snapshot');
|
||||
const records = [];
|
||||
for (const s of j.states) {
|
||||
const r = flightFromState(s);
|
||||
if (r) records.push(r);
|
||||
}
|
||||
return { records, time: (j.time || 0) * 1000 };
|
||||
},
|
||||
{ ttlMs: 30_000, max: 12 },
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,377 @@
|
||||
/**
|
||||
* A small bundled gazetteer for naming where something is ("38 km SW of
|
||||
* Miami"). Coordinates are public city centres, rounded.
|
||||
*/
|
||||
import { haversineKm } from './geo.js';
|
||||
|
||||
/** US metros (radar coverage) — [name, lon, lat]. */
|
||||
export const US_METROS = Object.freeze([
|
||||
['New York', -74.01, 40.71],
|
||||
['Los Angeles', -118.24, 34.05],
|
||||
['Chicago', -87.63, 41.88],
|
||||
['Houston', -95.37, 29.76],
|
||||
['Phoenix', -112.07, 33.45],
|
||||
['Philadelphia', -75.17, 39.95],
|
||||
['San Antonio', -98.49, 29.42],
|
||||
['San Diego', -117.16, 32.72],
|
||||
['Dallas', -96.8, 32.78],
|
||||
['Austin', -97.74, 30.27],
|
||||
['Jacksonville', -81.66, 30.33],
|
||||
['Columbus', -83.0, 39.96],
|
||||
['Charlotte', -80.84, 35.23],
|
||||
['Indianapolis', -86.16, 39.77],
|
||||
['San Francisco', -122.42, 37.77],
|
||||
['Seattle', -122.33, 47.61],
|
||||
['Denver', -104.99, 39.74],
|
||||
['Washington', -77.04, 38.9],
|
||||
['Nashville', -86.78, 36.16],
|
||||
['Oklahoma City', -97.52, 35.47],
|
||||
['El Paso', -106.49, 31.76],
|
||||
['Boston', -71.06, 42.36],
|
||||
['Portland', -122.68, 45.52],
|
||||
['Las Vegas', -115.14, 36.17],
|
||||
['Detroit', -83.05, 42.33],
|
||||
['Memphis', -90.05, 35.15],
|
||||
['Louisville', -85.76, 38.25],
|
||||
['Baltimore', -76.61, 39.29],
|
||||
['Milwaukee', -87.91, 43.04],
|
||||
['Albuquerque', -106.65, 35.08],
|
||||
['Tucson', -110.97, 32.22],
|
||||
['Atlanta', -84.39, 33.75],
|
||||
['Kansas City', -94.58, 39.1],
|
||||
['Miami', -80.19, 25.76],
|
||||
['Tampa', -82.46, 27.95],
|
||||
['Orlando', -81.38, 28.54],
|
||||
['New Orleans', -90.07, 29.95],
|
||||
['Minneapolis', -93.27, 44.98],
|
||||
['Cleveland', -81.69, 41.5],
|
||||
['St. Louis', -90.2, 38.63],
|
||||
['Pittsburgh', -79.99, 40.44],
|
||||
['Cincinnati', -84.51, 39.1],
|
||||
['Raleigh', -78.64, 35.78],
|
||||
['Salt Lake City', -111.89, 40.76],
|
||||
['Birmingham', -86.8, 33.52],
|
||||
['Omaha', -95.94, 41.26],
|
||||
['Buffalo', -78.88, 42.89],
|
||||
['Richmond', -77.44, 37.54],
|
||||
['Corpus Christi', -97.4, 27.8],
|
||||
['Mobile', -88.04, 30.69],
|
||||
['Little Rock', -92.29, 34.75],
|
||||
['Des Moines', -93.61, 41.59],
|
||||
['Wichita', -97.34, 37.69],
|
||||
['Boise', -116.2, 43.62],
|
||||
['Spokane', -117.43, 47.66],
|
||||
['Billings', -108.5, 45.78],
|
||||
['Fargo', -96.79, 46.88],
|
||||
['Charleston', -79.93, 32.78],
|
||||
['Savannah', -81.1, 32.08],
|
||||
['Tallahassee', -84.28, 30.44],
|
||||
['Shreveport', -93.75, 32.53],
|
||||
['Amarillo', -101.83, 35.22],
|
||||
['Lubbock', -101.86, 33.58],
|
||||
['Brownsville', -97.5, 25.9],
|
||||
['Key West', -81.78, 24.56],
|
||||
]);
|
||||
|
||||
/** World places and seas for "near X" phrasing — [name, lon, lat]. */
|
||||
export const WORLD_PLACES = Object.freeze([
|
||||
...US_METROS,
|
||||
['Toronto', -79.38, 43.65],
|
||||
['Montréal', -73.57, 45.5],
|
||||
['Vancouver', -123.12, 49.28],
|
||||
['Calgary', -114.07, 51.05],
|
||||
['Mexico City', -99.13, 19.43],
|
||||
['Guadalajara', -103.35, 20.67],
|
||||
['Monterrey', -100.31, 25.69],
|
||||
['Mérida', -89.62, 20.97],
|
||||
['Cancún', -86.85, 21.16],
|
||||
['Puerto Vallarta', -105.23, 20.65],
|
||||
['Mazatlán', -106.41, 23.25],
|
||||
['Cabo San Lucas', -109.92, 22.89],
|
||||
['Acapulco', -99.88, 16.85],
|
||||
['Guatemala City', -90.51, 14.63],
|
||||
['San José', -84.09, 9.93],
|
||||
['Panama City', -79.52, 8.98],
|
||||
['Havana', -82.37, 23.11],
|
||||
['Santo Domingo', -69.93, 18.49],
|
||||
['San Juan', -66.1, 18.47],
|
||||
['Kingston', -76.79, 18.02],
|
||||
['Caracas', -66.9, 10.48],
|
||||
['Bogotá', -74.07, 4.71],
|
||||
['Medellín', -75.56, 6.24],
|
||||
['Quito', -78.47, -0.18],
|
||||
['Lima', -77.04, -12.05],
|
||||
['Manaus', -60.02, -3.12],
|
||||
['Belém', -48.5, -1.46],
|
||||
['Brasília', -47.88, -15.79],
|
||||
['São Paulo', -46.63, -23.55],
|
||||
['Rio de Janeiro', -43.17, -22.91],
|
||||
['Buenos Aires', -58.38, -34.6],
|
||||
['Santiago', -70.67, -33.45],
|
||||
['La Paz', -68.15, -16.5],
|
||||
['Asunción', -57.58, -25.26],
|
||||
['Reykjavík', -21.9, 64.15],
|
||||
['London', -0.13, 51.51],
|
||||
['Birmingham, UK', -1.89, 52.48],
|
||||
['Coventry', -1.51, 52.41],
|
||||
['Manchester', -2.24, 53.48],
|
||||
['Liverpool', -2.98, 53.41],
|
||||
['Leeds', -1.55, 53.8],
|
||||
['Sheffield', -1.47, 53.38],
|
||||
['Newcastle upon Tyne', -1.61, 54.98],
|
||||
['Bristol', -2.59, 51.45],
|
||||
['Cardiff', -3.18, 51.48],
|
||||
['Oxford', -1.26, 51.75],
|
||||
['Cambridge, UK', 0.12, 52.2],
|
||||
['Nottingham', -1.15, 52.95],
|
||||
['Leicester', -1.13, 52.64],
|
||||
['Southampton', -1.4, 50.9],
|
||||
['Brighton', -0.14, 50.82],
|
||||
['Norwich', 1.3, 52.63],
|
||||
['Glasgow', -4.25, 55.86],
|
||||
['Edinburgh', -3.19, 55.95],
|
||||
['Belfast', -5.93, 54.6],
|
||||
['Dublin', -6.26, 53.35],
|
||||
['Lyon', 4.84, 45.76],
|
||||
['Marseille', 5.37, 43.3],
|
||||
['Hamburg', 9.99, 53.55],
|
||||
['Munich', 11.58, 48.14],
|
||||
['Cologne', 6.96, 50.94],
|
||||
['Frankfurt', 8.68, 50.11],
|
||||
['Rotterdam', 4.48, 51.92],
|
||||
['Brussels', 4.35, 50.85],
|
||||
['Zürich', 8.54, 47.37],
|
||||
['Milan', 9.19, 45.46],
|
||||
['Paris', 2.35, 48.86],
|
||||
['Madrid', -3.7, 40.42],
|
||||
['Lisbon', -9.14, 38.72],
|
||||
['Rome', 12.5, 41.9],
|
||||
['Berlin', 13.4, 52.52],
|
||||
['Warsaw', 21.01, 52.23],
|
||||
['Stockholm', 18.07, 59.33],
|
||||
['Helsinki', 24.94, 60.17],
|
||||
['Tallinn', 24.75, 59.44],
|
||||
['Riga', 24.11, 56.95],
|
||||
['Vilnius', 25.28, 54.69],
|
||||
['Kaliningrad', 20.51, 54.71],
|
||||
['Gdańsk', 18.65, 54.35],
|
||||
['St Petersburg', 30.32, 59.94],
|
||||
['Moscow', 37.62, 55.76],
|
||||
['Kyiv', 30.52, 50.45],
|
||||
['Odesa', 30.73, 46.48],
|
||||
['Sevastopol', 33.52, 44.62],
|
||||
['Istanbul', 28.98, 41.01],
|
||||
['Athens', 23.73, 37.98],
|
||||
['Nicosia', 33.38, 35.19],
|
||||
['Beirut', 35.5, 33.89],
|
||||
['Tel Aviv', 34.78, 32.09],
|
||||
['Damascus', 36.29, 33.51],
|
||||
['Cairo', 31.24, 30.04],
|
||||
['Baghdad', 44.36, 33.31],
|
||||
['Tehran', 51.39, 35.69],
|
||||
['Kuwait City', 47.98, 29.38],
|
||||
['Riyadh', 46.68, 24.71],
|
||||
['Doha', 51.53, 25.29],
|
||||
['Dubai', 55.27, 25.2],
|
||||
['Muscat', 58.4, 23.59],
|
||||
['Bandar Abbas', 56.27, 27.18],
|
||||
['Karachi', 67.01, 24.86],
|
||||
['Lahore', 74.35, 31.55],
|
||||
['Delhi', 77.21, 28.61],
|
||||
['Mumbai', 72.88, 19.08],
|
||||
['Chennai', 80.27, 13.08],
|
||||
['Kolkata', 88.36, 22.57],
|
||||
['Dhaka', 90.41, 23.81],
|
||||
['Kathmandu', 85.32, 27.72],
|
||||
['Colombo', 79.86, 6.93],
|
||||
['Yangon', 96.2, 16.87],
|
||||
['Bangkok', 100.5, 13.76],
|
||||
['Hanoi', 105.85, 21.03],
|
||||
['Ho Chi Minh City', 106.63, 10.82],
|
||||
['Kuala Lumpur', 101.69, 3.14],
|
||||
['Singapore', 103.82, 1.35],
|
||||
['Jakarta', 106.85, -6.21],
|
||||
['Surabaya', 112.75, -7.25],
|
||||
['Manila', 120.98, 14.6],
|
||||
['Cebu', 123.89, 10.32],
|
||||
['Davao', 125.61, 7.07],
|
||||
['Hong Kong', 114.17, 22.32],
|
||||
['Taipei', 121.56, 25.03],
|
||||
['Guangzhou', 113.26, 23.13],
|
||||
['Shanghai', 121.47, 31.23],
|
||||
['Beijing', 116.4, 39.9],
|
||||
['Seoul', 126.98, 37.57],
|
||||
['Busan', 129.08, 35.18],
|
||||
['Tokyo', 139.69, 35.69],
|
||||
['Osaka', 135.5, 34.69],
|
||||
['Okinawa', 127.68, 26.21],
|
||||
['Sapporo', 141.35, 43.06],
|
||||
['Guam', 144.79, 13.44],
|
||||
['Port Moresby', 147.18, -9.44],
|
||||
['Darwin', 130.84, -12.46],
|
||||
['Cairns', 145.77, -16.92],
|
||||
['Brisbane', 153.03, -27.47],
|
||||
['Sydney', 151.21, -33.87],
|
||||
['Melbourne', 144.96, -37.81],
|
||||
['Perth', 115.86, -31.95],
|
||||
['Auckland', 174.76, -36.85],
|
||||
['Suva', 178.44, -18.14],
|
||||
['Nouméa', 166.46, -22.28],
|
||||
['Honolulu', -157.86, 21.31],
|
||||
['Anchorage', -149.9, 61.22],
|
||||
['Lagos', 3.38, 6.52],
|
||||
['Accra', -0.19, 5.6],
|
||||
['Dakar', -17.47, 14.72],
|
||||
['Kinshasa', 15.27, -4.44],
|
||||
['Nairobi', 36.82, -1.29],
|
||||
['Addis Ababa', 38.75, 9.03],
|
||||
['Khartoum', 32.53, 15.5],
|
||||
['Johannesburg', 28.05, -26.2],
|
||||
['Cape Town', 18.42, -33.92],
|
||||
['Antananarivo', 47.52, -18.88],
|
||||
['Maputo', 32.57, -25.97],
|
||||
['Luanda', 13.23, -8.84],
|
||||
['Ulaanbaatar', 106.92, 47.89],
|
||||
['Almaty', 76.89, 43.24],
|
||||
['Novosibirsk', 82.93, 55.03],
|
||||
['Vladivostok', 131.89, 43.12],
|
||||
]);
|
||||
|
||||
const DIRS = ['N', 'NE', 'E', 'SE', 'S', 'SW', 'W', 'NW'];
|
||||
|
||||
/** Compass bearing (deg) from a to b. */
|
||||
export function bearingDeg(lat1, lon1, lat2, lon2) {
|
||||
const r = Math.PI / 180;
|
||||
const y = Math.sin((lon2 - lon1) * r) * Math.cos(lat2 * r);
|
||||
const x =
|
||||
Math.cos(lat1 * r) * Math.sin(lat2 * r) -
|
||||
Math.sin(lat1 * r) * Math.cos(lat2 * r) * Math.cos((lon2 - lon1) * r);
|
||||
return (((Math.atan2(y, x) / r) % 360) + 360) % 360;
|
||||
}
|
||||
|
||||
export function compass(deg) {
|
||||
return DIRS[Math.round((((deg % 360) + 360) % 360) / 45) % 8];
|
||||
}
|
||||
|
||||
/** Nearest bundled place: { name, km, dir } (dir is from the place). */
|
||||
export function nearestPlace(lat, lon, list = WORLD_PLACES) {
|
||||
let best = null;
|
||||
for (const [name, plon, plat] of list) {
|
||||
const km = haversineKm(lat, lon, plat, plon);
|
||||
if (!best || km < best.km) best = { name, km, plon, plat };
|
||||
}
|
||||
if (!best) return null;
|
||||
return {
|
||||
name: best.name,
|
||||
km: Math.round(best.km),
|
||||
dir: compass(bearingDeg(best.plat, best.plon, lat, lon)),
|
||||
};
|
||||
}
|
||||
|
||||
/** "Miami", "38 km SW of Miami" or "640 km E of Manila". */
|
||||
export function describePlace(lat, lon, list) {
|
||||
const p = nearestPlace(lat, lon, list);
|
||||
if (!p) return '';
|
||||
if (p.km < 15) return p.name;
|
||||
return `${p.km.toLocaleString('en-US')} km ${p.dir} of ${p.name}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Neighbourhoods and landmarks in the cities where helicopters gather
|
||||
* (news, police, tours) — [name, lon, lat, radiusKm]. Public centroids,
|
||||
* rounded. Used to name a detection ("circling Midtown Manhattan").
|
||||
*/
|
||||
export const NEIGHBORHOODS = Object.freeze([
|
||||
['Midtown Manhattan', -73.9851, 40.7549, 1.6],
|
||||
['Lower Manhattan', -74.0091, 40.7075, 1.4],
|
||||
['Upper East Side', -73.9566, 40.7736, 1.5],
|
||||
['Upper West Side', -73.9754, 40.787, 1.5],
|
||||
['Central Park', -73.9654, 40.7829, 1.2],
|
||||
['Harlem', -73.9465, 40.8116, 1.6],
|
||||
['Chelsea, Manhattan', -74.0014, 40.7465, 0.9],
|
||||
['Greenwich Village', -74.0037, 40.7336, 0.9],
|
||||
['the Lower East Side', -73.9861, 40.715, 1.0],
|
||||
['Washington Heights', -73.9393, 40.8417, 1.4],
|
||||
['the South Bronx', -73.9162, 40.8166, 2.0],
|
||||
['the Bronx', -73.8648, 40.8448, 4.0],
|
||||
['Downtown Brooklyn', -73.9857, 40.6928, 1.2],
|
||||
['Williamsburg, Brooklyn', -73.9573, 40.7081, 1.4],
|
||||
['Brooklyn', -73.9442, 40.6782, 5.0],
|
||||
['Long Island City', -73.9485, 40.7447, 1.4],
|
||||
['Queens', -73.7949, 40.7282, 6.0],
|
||||
['Staten Island', -74.1502, 40.5795, 6.0],
|
||||
['Jersey City', -74.0776, 40.7282, 2.5],
|
||||
['Hoboken', -74.0324, 40.744, 1.0],
|
||||
['Newark', -74.1724, 40.7357, 3.0],
|
||||
['JFK Airport', -73.7781, 40.6413, 3.0],
|
||||
['LaGuardia Airport', -73.874, 40.7769, 1.6],
|
||||
['Newark Airport', -74.1745, 40.6895, 2.5],
|
||||
['Downtown Los Angeles', -118.2468, 34.0407, 2.0],
|
||||
['Hollywood', -118.3287, 34.0928, 2.0],
|
||||
['Koreatown, Los Angeles', -118.3009, 34.0618, 1.5],
|
||||
['South Los Angeles', -118.2827, 33.9897, 3.5],
|
||||
['Santa Monica', -118.4912, 34.0195, 2.5],
|
||||
['Venice, Los Angeles', -118.4695, 33.985, 1.6],
|
||||
['Long Beach', -118.1937, 33.7701, 4.0],
|
||||
['Pasadena', -118.1445, 34.1478, 3.0],
|
||||
['Burbank', -118.309, 34.1808, 3.0],
|
||||
['the San Fernando Valley', -118.45, 34.2, 8.0],
|
||||
['Inglewood', -118.3531, 33.9617, 2.5],
|
||||
['Compton', -118.2201, 33.8958, 2.5],
|
||||
['LAX', -118.4085, 33.9416, 2.5],
|
||||
['Anaheim', -117.9145, 33.8366, 3.5],
|
||||
['the San Gabriel Valley', -118.03, 34.08, 9.0],
|
||||
['Glendale, California', -118.2551, 34.1425, 3.0],
|
||||
['Riverside', -117.3962, 33.9533, 6.0],
|
||||
['San Bernardino', -117.2898, 34.1083, 6.0],
|
||||
['Santa Ana', -117.8678, 33.7455, 4.0],
|
||||
['Irvine', -117.8265, 33.6846, 5.0],
|
||||
['Downtown San Diego', -117.1611, 32.7157, 2.5],
|
||||
['Henderson, Nevada', -114.9817, 36.0395, 5.0],
|
||||
['Westminster, London', -0.1357, 51.4975, 1.4],
|
||||
['the City of London', -0.0918, 51.5155, 1.2],
|
||||
['Canary Wharf', -0.0235, 51.5054, 1.2],
|
||||
['Camden, London', -0.1426, 51.539, 1.6],
|
||||
['Hackney, London', -0.0553, 51.545, 2.0],
|
||||
['Brixton, London', -0.1145, 51.4613, 1.6],
|
||||
['Stratford, London', -0.0035, 51.5413, 1.6],
|
||||
['Croydon', -0.0982, 51.3762, 3.0],
|
||||
['Heathrow', -0.4543, 51.47, 3.0],
|
||||
['the Loop, Chicago', -87.6298, 41.8837, 1.4],
|
||||
['the South Side, Chicago', -87.62, 41.78, 5.0],
|
||||
['the West Side, Chicago', -87.72, 41.88, 4.0],
|
||||
['Downtown San Francisco', -122.4014, 37.7881, 1.5],
|
||||
['the Mission, San Francisco', -122.4194, 37.7599, 1.4],
|
||||
['Oakland', -122.2711, 37.8044, 4.0],
|
||||
['Downtown Seattle', -122.3321, 47.6062, 1.5],
|
||||
['Downtown Houston', -95.3698, 29.7604, 2.0],
|
||||
['Downtown Dallas', -96.797, 32.7767, 2.0],
|
||||
['Downtown Miami', -80.1918, 25.7743, 1.6],
|
||||
['Miami Beach', -80.13, 25.7907, 2.5],
|
||||
['Downtown Atlanta', -84.388, 33.749, 2.0],
|
||||
['Downtown Phoenix', -112.074, 33.4484, 2.5],
|
||||
['Las Vegas Strip', -115.1728, 36.1147, 2.5],
|
||||
['Downtown Toronto', -79.3832, 43.6532, 2.0],
|
||||
['Central Paris', 2.3488, 48.8534, 2.5],
|
||||
['La Défense', 2.2385, 48.8918, 1.4],
|
||||
['Mitte, Berlin', 13.4049, 52.52, 2.5],
|
||||
['Central Sydney', 151.2093, -33.8688, 2.0],
|
||||
['Shinjuku, Tokyo', 139.7036, 35.6938, 2.0],
|
||||
['Chiyoda, Tokyo', 139.7536, 35.6938, 2.0],
|
||||
]);
|
||||
|
||||
/**
|
||||
* The most local name for a point: a neighbourhood when it is inside one,
|
||||
* else "N km DIR of City". Returns { name, local:boolean }.
|
||||
*/
|
||||
export function localPlace(lat, lon) {
|
||||
let best = null;
|
||||
for (const [name, plon, plat, r] of NEIGHBORHOODS) {
|
||||
if (Math.abs(plat - lat) > 0.2) continue;
|
||||
const km = haversineKm(lat, lon, plat, plon);
|
||||
if (km <= r * 1.35 && (!best || km / r < best.score))
|
||||
best = { name, score: km / r };
|
||||
}
|
||||
if (best) return { name: best.name, local: true };
|
||||
return { name: describePlace(lat, lon), local: false };
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* Small, dependency-free geo helpers shared by the mobile globe modules.
|
||||
*/
|
||||
|
||||
export const D2R = Math.PI / 180;
|
||||
export const R2D = 180 / Math.PI;
|
||||
export const EARTH_RADIUS_KM = 6371.0088;
|
||||
export const KT_TO_MPS = 1852 / 3600;
|
||||
export const FT_TO_M = 0.3048;
|
||||
|
||||
/** Great-circle distance in km. */
|
||||
export function haversineKm(lat1, lon1, lat2, lon2) {
|
||||
const dLat = (lat2 - lat1) * D2R;
|
||||
const dLon = (lon2 - lon1) * D2R;
|
||||
const a =
|
||||
Math.sin(dLat / 2) ** 2 +
|
||||
Math.cos(lat1 * D2R) * Math.cos(lat2 * D2R) * Math.sin(dLon / 2) ** 2;
|
||||
return 2 * EARTH_RADIUS_KM * Math.asin(Math.min(1, Math.sqrt(a)));
|
||||
}
|
||||
|
||||
/** Move a point `distM` metres along `headingDeg` (flat-earth, fine for < 50 km). */
|
||||
export function offsetLonLat(lon, lat, headingDeg, distM) {
|
||||
const h = headingDeg * D2R;
|
||||
const dLat = (distM * Math.cos(h)) / 111_320;
|
||||
const dLon =
|
||||
(distM * Math.sin(h)) / (111_320 * Math.max(0.05, Math.cos(lat * D2R)));
|
||||
return [wrapLon(lon + dLon), Math.max(-89.9, Math.min(89.9, lat + dLat))];
|
||||
}
|
||||
|
||||
export function wrapLon(lon) {
|
||||
return ((((lon + 180) % 360) + 360) % 360) - 180;
|
||||
}
|
||||
|
||||
export const finite = (value) =>
|
||||
value !== null && value !== '' && Number.isFinite(Number(value))
|
||||
? Number(value)
|
||||
: null;
|
||||
|
||||
/** A bounding box of `radiusKm` around a point, as [west, south, east, north]. */
|
||||
export function bboxAround(lon, lat, radiusKm, aspect = 0.75) {
|
||||
const dLat = radiusKm / 111.32;
|
||||
const dLon =
|
||||
(radiusKm * aspect) / (111.32 * Math.max(0.05, Math.cos(lat * D2R)));
|
||||
return [lon - dLon, lat - dLat, lon + dLon, lat + dLat];
|
||||
}
|
||||
|
||||
/**
|
||||
* Keyless Esri World Imagery still for a bbox (portrait 600x800 by default).
|
||||
* The export endpoint widens the bbox to the image aspect in Web Mercator, so
|
||||
* callers pass a roughly matching bbox to keep the subject centred.
|
||||
*/
|
||||
export function esriThumbnail(
|
||||
[west, south, east, north],
|
||||
{ width = 600, height = 800 } = {},
|
||||
) {
|
||||
const bbox = [west, south, east, north].map((v) => v.toFixed(5)).join(',');
|
||||
return (
|
||||
'https://services.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/export' +
|
||||
`?bbox=${bbox}&bboxSR=4326&imageSR=3857&size=${width},${height}&format=jpg&f=image`
|
||||
);
|
||||
}
|
||||
|
||||
/** Thumbnail framing a radius around a point. */
|
||||
export function thumbnailAround(lon, lat, radiusKm) {
|
||||
return esriThumbnail(bboxAround(lon, lat, radiusKm));
|
||||
}
|
||||
|
||||
/** Sub-solar point for a UTC instant (low-precision, ~0.1 deg). */
|
||||
export function subsolarPoint(ms) {
|
||||
const d = (ms - Date.UTC(2000, 0, 1, 12)) / 86_400_000;
|
||||
const g = (357.529 + 0.98560028 * d) * D2R;
|
||||
const q = 280.459 + 0.98564736 * d;
|
||||
const L = (q + 1.915 * Math.sin(g) + 0.02 * Math.sin(2 * g)) * D2R;
|
||||
const e = (23.439 - 0.00000036 * d) * D2R;
|
||||
const dec = Math.asin(Math.sin(e) * Math.sin(L)) * R2D;
|
||||
const ra = Math.atan2(Math.cos(e) * Math.sin(L), Math.cos(L)) * R2D;
|
||||
const gmst = (18.697374558 + 24.06570982441908 * d) % 24;
|
||||
const lon = wrapLon(ra - gmst * 15);
|
||||
return { lon, lat: dec };
|
||||
}
|
||||
|
||||
/** Compact relative time, e.g. "3 h ago" / "in 2 d". */
|
||||
export function relTime(ms, now = Date.now()) {
|
||||
const diff = ms - now;
|
||||
const abs = Math.abs(diff);
|
||||
const unit =
|
||||
abs < 3_600_000
|
||||
? [Math.max(1, Math.round(abs / 60_000)), 'min']
|
||||
: abs < 172_800_000
|
||||
? [Math.round(abs / 3_600_000), 'h']
|
||||
: [Math.round(abs / 86_400_000), 'd'];
|
||||
return diff < 0 ? `${unit[0]} ${unit[1]} ago` : `in ${unit[0]} ${unit[1]}`;
|
||||
}
|
||||
|
||||
/** Bin points to a grid of `cellDeg` and return the densest cell. */
|
||||
export function densestCell(points, cellDeg = 2) {
|
||||
const bins = new Map();
|
||||
for (const p of points) {
|
||||
const key = `${Math.floor(p.lat / cellDeg)}:${Math.floor(p.lon / cellDeg)}`;
|
||||
let bin = bins.get(key);
|
||||
if (!bin) bins.set(key, (bin = { n: 0, lat: 0, lon: 0, items: [] }));
|
||||
bin.n++;
|
||||
bin.lat += p.lat;
|
||||
bin.lon += p.lon;
|
||||
bin.items.push(p);
|
||||
}
|
||||
let best = null;
|
||||
for (const bin of bins.values()) if (!best || bin.n > best.n) best = bin;
|
||||
if (!best) return null;
|
||||
return {
|
||||
count: best.n,
|
||||
lat: best.lat / best.n,
|
||||
lon: best.lon / best.n,
|
||||
items: best.items,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
/**
|
||||
* History view (DESIGN.md §12.5): when the clock is in the recorded past,
|
||||
* every recorded contact is drawn where it actually was, interpolated
|
||||
* between polls, with a ghost trail of the four minutes before.
|
||||
*
|
||||
* One BillboardCollection (glyphs move in place, keyed by entity) and one
|
||||
* PolylineCollection for trails, rebuilt at most 4×/s and only for the
|
||||
* contacts nearest the view centre.
|
||||
*/
|
||||
import * as Cesium from 'cesium';
|
||||
import { glyph } from './glyphs.js';
|
||||
|
||||
const D2R = Math.PI / 180;
|
||||
const GLYPH_CAP = 900;
|
||||
const TRAIL_CAP = 160;
|
||||
const TRAIL_MS = 4 * 60_000;
|
||||
const NEAR_FAR = new Cesium.NearFarScalar(2e4, 1.2, 3e6, 0.6);
|
||||
|
||||
const COLORS = {
|
||||
aircraft: Cesium.Color.fromCssColorString('#BFE6FF'),
|
||||
helicopter: Cesium.Color.fromCssColorString('#9BE15D'),
|
||||
military: Cesium.Color.fromCssColorString('#FFD166'),
|
||||
vessel: Cesium.Color.fromCssColorString('#38E1C6'),
|
||||
emergency: Cesium.Color.fromCssColorString('#FF3B30'),
|
||||
};
|
||||
|
||||
export function createGhosts({ scene, recorder, requestRender }) {
|
||||
const root = scene.primitives.add(new Cesium.PrimitiveCollection());
|
||||
root.show = false;
|
||||
const glyphs = root.add(new Cesium.BillboardCollection());
|
||||
const trails = root.add(new Cesium.PolylineCollection());
|
||||
const byKey = new Map(); // key → { bb, state }
|
||||
let lastTrails = 0;
|
||||
let shown = false;
|
||||
let lastMs = 0;
|
||||
|
||||
const colorOf = (meta) =>
|
||||
meta?.emergency ? COLORS.emergency : COLORS[meta?.kind] || COLORS.aircraft;
|
||||
|
||||
function hide() {
|
||||
if (!shown) return;
|
||||
shown = false;
|
||||
root.show = false;
|
||||
glyphs.removeAll();
|
||||
trails.removeAll();
|
||||
byKey.clear();
|
||||
requestRender();
|
||||
}
|
||||
|
||||
function update(ms, view, { force = false } = {}) {
|
||||
lastMs = ms;
|
||||
const rect = view?.rect
|
||||
? {
|
||||
west: view.rect.west - 0.5,
|
||||
east: view.rect.east + 0.5,
|
||||
south: view.rect.south - 0.5,
|
||||
north: view.rect.north + 0.5,
|
||||
}
|
||||
: null;
|
||||
const list = recorder.snapshotAt(ms, { rect });
|
||||
const c = view?.center || { lon: 0, lat: 0 };
|
||||
const cos = Math.cos(c.lat * D2R);
|
||||
for (const s of list)
|
||||
s.d = (s.lat - c.lat) ** 2 + ((s.lon - c.lon) * cos) ** 2;
|
||||
list.sort((a, b) => a.d - b.d);
|
||||
const keep = list.slice(0, GLYPH_CAP);
|
||||
const seen = new Set();
|
||||
for (const s of keep) {
|
||||
seen.add(s.key);
|
||||
let e = byKey.get(s.key);
|
||||
const pos = Cesium.Cartesian3.fromDegrees(s.lon, s.lat, s.altM || 0);
|
||||
if (!e) {
|
||||
const g = glyph(
|
||||
s.meta?.kind === 'vessel'
|
||||
? 'ship'
|
||||
: s.meta?.helicopter
|
||||
? 'heli'
|
||||
: 'plane',
|
||||
);
|
||||
e = {
|
||||
bb: glyphs.add({
|
||||
position: pos,
|
||||
image: g.image,
|
||||
imageId: g.imageId,
|
||||
color: colorOf(s.meta).withAlpha(0.92),
|
||||
scale: s.meta?.helicopter ? 0.55 : 0.46,
|
||||
alignedAxis: Cesium.Cartesian3.UNIT_Z,
|
||||
scaleByDistance: NEAR_FAR,
|
||||
disableDepthTestDistance: 150_000,
|
||||
id: { layer: 'history', key: s.key },
|
||||
}),
|
||||
};
|
||||
byKey.set(s.key, e);
|
||||
} else e.bb.position = pos;
|
||||
e.bb.rotation = -(s.headingDeg || 0) * D2R;
|
||||
e.state = s;
|
||||
}
|
||||
for (const [key, e] of byKey)
|
||||
if (!seen.has(key)) {
|
||||
glyphs.remove(e.bb);
|
||||
byKey.delete(key);
|
||||
}
|
||||
const now = performance.now();
|
||||
if (force || now - lastTrails > 250) {
|
||||
lastTrails = now;
|
||||
trails.removeAll();
|
||||
for (const s of keep.slice(0, TRAIL_CAP)) {
|
||||
const pts = recorder.trail(s.key, ms - TRAIL_MS, ms, 60);
|
||||
if (pts.length < 2) continue;
|
||||
trails.add({
|
||||
positions: pts.map(([lo, la, h]) =>
|
||||
Cesium.Cartesian3.fromDegrees(lo, la, h || 0),
|
||||
),
|
||||
width: 2.5,
|
||||
material: Cesium.Material.fromType('PolylineGlow', {
|
||||
color: colorOf(s.meta).withAlpha(0.7),
|
||||
glowPower: 0.25,
|
||||
taperPower: 0.4,
|
||||
}),
|
||||
});
|
||||
}
|
||||
}
|
||||
shown = true;
|
||||
root.show = true;
|
||||
requestRender();
|
||||
return keep.length;
|
||||
}
|
||||
|
||||
return {
|
||||
update,
|
||||
hide,
|
||||
get shown() {
|
||||
return shown;
|
||||
},
|
||||
get glyphs() {
|
||||
return glyphs;
|
||||
},
|
||||
pick(key) {
|
||||
const e = byKey.get(key);
|
||||
if (!e?.state) return null;
|
||||
const s = e.state;
|
||||
const m = s.meta || {};
|
||||
const layer = m.layer || 'flights';
|
||||
return {
|
||||
kind: m.kind || 'aircraft',
|
||||
id: key,
|
||||
entityId: `${layer}:${key}`,
|
||||
title: m.callsign || m.title || key.toUpperCase(),
|
||||
subtitle: [
|
||||
'Recorded',
|
||||
new Date(lastMs).toISOString().slice(11, 19) + 'Z',
|
||||
`${Math.round((s.altM * 3.28084) / 100) * 100} ft`,
|
||||
`${Math.round(s.speed / 0.514444)} kt`,
|
||||
].join(' · '),
|
||||
lon: s.lon,
|
||||
lat: s.lat,
|
||||
meta: {
|
||||
layer: 'history',
|
||||
entityLayer: layer,
|
||||
icao24: key,
|
||||
altitudeM: s.altM,
|
||||
speedMps: s.speed,
|
||||
headingDeg: s.headingDeg,
|
||||
recordedAt: lastMs,
|
||||
},
|
||||
};
|
||||
},
|
||||
destroy() {
|
||||
scene.primitives.remove(root);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,364 @@
|
||||
/**
|
||||
* Small canvas glyphs for billboards. Drawn once in white at 2x, tinted per
|
||||
* layer through `billboard.color`, and shared through a fixed `imageId` so the
|
||||
* texture atlas holds each glyph exactly once however many billboards use it.
|
||||
*/
|
||||
|
||||
const SIZE = 48; // drawn at 2x, displayed at scale 0.5 → 24 css px
|
||||
const cache = new Map();
|
||||
|
||||
function canvas(size = SIZE) {
|
||||
const c = document.createElement('canvas');
|
||||
c.width = c.height = size;
|
||||
return c;
|
||||
}
|
||||
|
||||
function stroke(ctx, width = 3) {
|
||||
ctx.lineJoin = 'round';
|
||||
ctx.lineCap = 'round';
|
||||
ctx.strokeStyle = 'rgba(0,0,0,0.55)';
|
||||
ctx.lineWidth = width;
|
||||
ctx.stroke();
|
||||
ctx.fillStyle = '#fff';
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
const painters = {
|
||||
dot(ctx, s) {
|
||||
const g = ctx.createRadialGradient(s / 2, s / 2, 0, s / 2, s / 2, s / 2);
|
||||
g.addColorStop(0, 'rgba(255,255,255,1)');
|
||||
g.addColorStop(0.18, 'rgba(255,255,255,0.95)');
|
||||
g.addColorStop(0.4, 'rgba(255,255,255,0.35)');
|
||||
g.addColorStop(1, 'rgba(255,255,255,0)');
|
||||
ctx.fillStyle = g;
|
||||
ctx.fillRect(0, 0, s, s);
|
||||
},
|
||||
plane(ctx) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(24, 3);
|
||||
ctx.lineTo(27.5, 9);
|
||||
ctx.lineTo(27.5, 19);
|
||||
ctx.lineTo(44, 29);
|
||||
ctx.lineTo(44, 33);
|
||||
ctx.lineTo(27.5, 28);
|
||||
ctx.lineTo(27, 38);
|
||||
ctx.lineTo(33, 43);
|
||||
ctx.lineTo(33, 45.5);
|
||||
ctx.lineTo(24, 43);
|
||||
ctx.lineTo(15, 45.5);
|
||||
ctx.lineTo(15, 43);
|
||||
ctx.lineTo(21, 38);
|
||||
ctx.lineTo(20.5, 28);
|
||||
ctx.lineTo(4, 33);
|
||||
ctx.lineTo(4, 29);
|
||||
ctx.lineTo(20.5, 19);
|
||||
ctx.lineTo(20.5, 9);
|
||||
ctx.closePath();
|
||||
stroke(ctx);
|
||||
},
|
||||
heli(ctx) {
|
||||
ctx.beginPath();
|
||||
ctx.ellipse(24, 22, 6.5, 9, 0, 0, Math.PI * 2);
|
||||
ctx.rect(22.5, 28, 3, 15);
|
||||
ctx.rect(18, 41, 12, 3);
|
||||
stroke(ctx);
|
||||
ctx.beginPath();
|
||||
ctx.lineWidth = 2.5;
|
||||
ctx.strokeStyle = '#fff';
|
||||
ctx.moveTo(7, 7);
|
||||
ctx.lineTo(41, 37);
|
||||
ctx.moveTo(41, 7);
|
||||
ctx.lineTo(7, 37);
|
||||
ctx.stroke();
|
||||
},
|
||||
ship(ctx) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(24, 4);
|
||||
ctx.quadraticCurveTo(34, 16, 32, 42);
|
||||
ctx.lineTo(16, 42);
|
||||
ctx.quadraticCurveTo(14, 16, 24, 4);
|
||||
ctx.closePath();
|
||||
stroke(ctx);
|
||||
},
|
||||
sat(ctx) {
|
||||
ctx.beginPath();
|
||||
ctx.rect(19, 18, 10, 12);
|
||||
ctx.rect(3, 20, 14, 8);
|
||||
ctx.rect(31, 20, 14, 8);
|
||||
stroke(ctx, 2.5);
|
||||
},
|
||||
iss(ctx) {
|
||||
ctx.beginPath();
|
||||
ctx.rect(21, 14, 6, 20);
|
||||
ctx.rect(3, 8, 6, 14);
|
||||
ctx.rect(11, 8, 6, 14);
|
||||
ctx.rect(31, 8, 6, 14);
|
||||
ctx.rect(39, 8, 6, 14);
|
||||
ctx.rect(3, 26, 6, 14);
|
||||
ctx.rect(11, 26, 6, 14);
|
||||
ctx.rect(31, 26, 6, 14);
|
||||
ctx.rect(39, 26, 6, 14);
|
||||
ctx.rect(9, 22.5, 30, 3);
|
||||
stroke(ctx, 2);
|
||||
},
|
||||
ring(ctx, s) {
|
||||
ctx.beginPath();
|
||||
ctx.arc(s / 2, s / 2, s / 2 - 5, 0, Math.PI * 2);
|
||||
ctx.lineWidth = 3;
|
||||
ctx.strokeStyle = '#fff';
|
||||
ctx.shadowColor = 'rgba(0,0,0,0.6)';
|
||||
ctx.shadowBlur = 3;
|
||||
ctx.stroke();
|
||||
for (let i = 0; i < 4; i++) {
|
||||
const a = (i * Math.PI) / 2;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(
|
||||
s / 2 + Math.cos(a) * (s / 2 - 1),
|
||||
s / 2 + Math.sin(a) * (s / 2 - 1),
|
||||
);
|
||||
ctx.lineTo(
|
||||
s / 2 + Math.cos(a) * (s / 2 - 12),
|
||||
s / 2 + Math.sin(a) * (s / 2 - 12),
|
||||
);
|
||||
ctx.stroke();
|
||||
}
|
||||
},
|
||||
quake(ctx, s) {
|
||||
for (const [r, a] of [
|
||||
[20, 0.45],
|
||||
[13, 0.8],
|
||||
]) {
|
||||
ctx.beginPath();
|
||||
ctx.arc(s / 2, s / 2, r, 0, Math.PI * 2);
|
||||
ctx.lineWidth = 3;
|
||||
ctx.strokeStyle = `rgba(255,255,255,${a})`;
|
||||
ctx.stroke();
|
||||
}
|
||||
ctx.beginPath();
|
||||
ctx.arc(s / 2, s / 2, 6, 0, Math.PI * 2);
|
||||
ctx.fillStyle = '#fff';
|
||||
ctx.fill();
|
||||
},
|
||||
fire(ctx) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(24, 4);
|
||||
ctx.bezierCurveTo(34, 16, 40, 24, 36, 34);
|
||||
ctx.bezierCurveTo(33, 43, 15, 43, 12, 34);
|
||||
ctx.bezierCurveTo(9, 26, 16, 22, 18, 14);
|
||||
ctx.bezierCurveTo(21, 20, 24, 20, 24, 4);
|
||||
ctx.closePath();
|
||||
stroke(ctx);
|
||||
},
|
||||
cyclone(ctx, s) {
|
||||
ctx.lineWidth = 5;
|
||||
ctx.strokeStyle = '#fff';
|
||||
ctx.lineCap = 'round';
|
||||
for (let k = 0; k < 2; k++) {
|
||||
ctx.beginPath();
|
||||
for (let t = 0; t <= 1.0001; t += 0.05) {
|
||||
const a = k * Math.PI + t * Math.PI * 1.25;
|
||||
const r = 4 + t * 17;
|
||||
const x = s / 2 + Math.cos(a) * r;
|
||||
const y = s / 2 + Math.sin(a) * r;
|
||||
if (t === 0) ctx.moveTo(x, y);
|
||||
else ctx.lineTo(x, y);
|
||||
}
|
||||
ctx.stroke();
|
||||
}
|
||||
ctx.beginPath();
|
||||
ctx.arc(s / 2, s / 2, 5, 0, Math.PI * 2);
|
||||
ctx.fillStyle = '#fff';
|
||||
ctx.fill();
|
||||
},
|
||||
rocket(ctx) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(24, 3);
|
||||
ctx.bezierCurveTo(31, 10, 31, 22, 30, 32);
|
||||
ctx.lineTo(36, 40);
|
||||
ctx.lineTo(29, 39);
|
||||
ctx.lineTo(27, 44);
|
||||
ctx.lineTo(21, 44);
|
||||
ctx.lineTo(19, 39);
|
||||
ctx.lineTo(12, 40);
|
||||
ctx.lineTo(18, 32);
|
||||
ctx.bezierCurveTo(17, 22, 17, 10, 24, 3);
|
||||
ctx.closePath();
|
||||
stroke(ctx);
|
||||
},
|
||||
camera(ctx) {
|
||||
ctx.beginPath();
|
||||
ctx.roundRect(7, 15, 26, 18, 4);
|
||||
ctx.moveTo(33, 20);
|
||||
ctx.lineTo(42, 15);
|
||||
ctx.lineTo(42, 33);
|
||||
ctx.lineTo(33, 28);
|
||||
ctx.closePath();
|
||||
stroke(ctx);
|
||||
},
|
||||
volcano(ctx) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(4, 42);
|
||||
ctx.lineTo(18, 16);
|
||||
ctx.lineTo(30, 16);
|
||||
ctx.lineTo(44, 42);
|
||||
ctx.closePath();
|
||||
stroke(ctx);
|
||||
ctx.beginPath();
|
||||
ctx.arc(24, 9, 5, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
},
|
||||
pin(ctx) {
|
||||
ctx.beginPath();
|
||||
ctx.arc(24, 18, 12, Math.PI, 0);
|
||||
ctx.bezierCurveTo(36, 28, 28, 34, 24, 45);
|
||||
ctx.bezierCurveTo(20, 34, 12, 28, 12, 18);
|
||||
ctx.closePath();
|
||||
stroke(ctx);
|
||||
ctx.beginPath();
|
||||
ctx.arc(24, 18, 4.5, 0, Math.PI * 2);
|
||||
ctx.fillStyle = 'rgba(0,0,0,0.6)';
|
||||
ctx.fill();
|
||||
},
|
||||
port(ctx) {
|
||||
ctx.beginPath();
|
||||
ctx.arc(24, 10, 5, 0, Math.PI * 2);
|
||||
ctx.moveTo(24, 15);
|
||||
ctx.lineTo(24, 42);
|
||||
ctx.moveTo(14, 22);
|
||||
ctx.lineTo(34, 22);
|
||||
ctx.moveTo(8, 30);
|
||||
ctx.quadraticCurveTo(10, 42, 24, 42);
|
||||
ctx.quadraticCurveTo(38, 42, 40, 30);
|
||||
ctx.lineWidth = 7;
|
||||
ctx.strokeStyle = 'rgba(0,0,0,0.5)';
|
||||
ctx.lineCap = 'round';
|
||||
ctx.stroke();
|
||||
ctx.lineWidth = 3.5;
|
||||
ctx.strokeStyle = '#fff';
|
||||
ctx.stroke();
|
||||
},
|
||||
bolt(ctx) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(28, 3);
|
||||
ctx.lineTo(12, 27);
|
||||
ctx.lineTo(23, 27);
|
||||
ctx.lineTo(19, 45);
|
||||
ctx.lineTo(37, 18);
|
||||
ctx.lineTo(26, 18);
|
||||
ctx.closePath();
|
||||
stroke(ctx, 3);
|
||||
},
|
||||
aq(ctx, s) {
|
||||
const g = ctx.createRadialGradient(s / 2, s / 2, 4, s / 2, s / 2, s / 2);
|
||||
g.addColorStop(0, 'rgba(255,255,255,0.9)');
|
||||
g.addColorStop(0.45, 'rgba(255,255,255,0.45)');
|
||||
g.addColorStop(1, 'rgba(255,255,255,0)');
|
||||
ctx.fillStyle = g;
|
||||
ctx.fillRect(0, 0, s, s);
|
||||
ctx.beginPath();
|
||||
ctx.arc(s / 2, s / 2, 9, 0, Math.PI * 2);
|
||||
stroke(ctx, 3);
|
||||
},
|
||||
bus(ctx) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(24, 4);
|
||||
ctx.lineTo(33, 14);
|
||||
ctx.lineTo(33, 42);
|
||||
ctx.lineTo(15, 42);
|
||||
ctx.lineTo(15, 14);
|
||||
ctx.closePath();
|
||||
stroke(ctx, 3);
|
||||
},
|
||||
post(ctx) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(8, 10);
|
||||
ctx.lineTo(40, 10);
|
||||
ctx.lineTo(40, 32);
|
||||
ctx.lineTo(22, 32);
|
||||
ctx.lineTo(13, 41);
|
||||
ctx.lineTo(14, 32);
|
||||
ctx.lineTo(8, 32);
|
||||
ctx.closePath();
|
||||
stroke(ctx, 3);
|
||||
},
|
||||
witness(ctx) {
|
||||
// a seal: a rosette with a check
|
||||
ctx.beginPath();
|
||||
for (let i = 0; i < 24; i++) {
|
||||
const a = (i / 24) * Math.PI * 2;
|
||||
const r = i % 2 ? 17 : 21;
|
||||
ctx.lineTo(24 + r * Math.cos(a), 24 + r * Math.sin(a));
|
||||
}
|
||||
ctx.closePath();
|
||||
stroke(ctx, 3);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(16, 24);
|
||||
ctx.lineTo(22, 30);
|
||||
ctx.lineTo(33, 18);
|
||||
ctx.lineWidth = 4;
|
||||
ctx.strokeStyle = 'rgba(0,0,0,0.75)';
|
||||
ctx.stroke();
|
||||
},
|
||||
eye(ctx) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(4, 24);
|
||||
ctx.quadraticCurveTo(24, 4, 44, 24);
|
||||
ctx.quadraticCurveTo(24, 44, 4, 24);
|
||||
ctx.closePath();
|
||||
stroke(ctx, 3);
|
||||
ctx.beginPath();
|
||||
ctx.arc(24, 24, 7, 0, Math.PI * 2);
|
||||
ctx.fillStyle = 'rgba(0,0,0,0.7)';
|
||||
ctx.fill();
|
||||
},
|
||||
live(ctx) {
|
||||
ctx.beginPath();
|
||||
ctx.arc(24, 24, 9, 0, Math.PI * 2);
|
||||
stroke(ctx, 3);
|
||||
for (const r of [15, 21]) {
|
||||
ctx.beginPath();
|
||||
ctx.arc(24, 24, r, -0.7, 0.7);
|
||||
ctx.moveTo(24 - r * Math.cos(0.7), 24 - r * Math.sin(0.7));
|
||||
ctx.arc(24, 24, r, Math.PI - 0.7, Math.PI + 0.7);
|
||||
ctx.lineWidth = 3;
|
||||
ctx.strokeStyle = '#fff';
|
||||
ctx.stroke();
|
||||
}
|
||||
},
|
||||
hologram(ctx) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(24, 3);
|
||||
ctx.lineTo(42, 14);
|
||||
ctx.lineTo(42, 34);
|
||||
ctx.lineTo(24, 45);
|
||||
ctx.lineTo(6, 34);
|
||||
ctx.lineTo(6, 14);
|
||||
ctx.closePath();
|
||||
ctx.lineWidth = 6;
|
||||
ctx.strokeStyle = 'rgba(0,0,0,0.5)';
|
||||
ctx.stroke();
|
||||
ctx.lineWidth = 3;
|
||||
ctx.strokeStyle = '#fff';
|
||||
ctx.stroke();
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(6, 14);
|
||||
ctx.lineTo(24, 25);
|
||||
ctx.lineTo(42, 14);
|
||||
ctx.moveTo(24, 25);
|
||||
ctx.lineTo(24, 45);
|
||||
ctx.stroke();
|
||||
},
|
||||
};
|
||||
|
||||
/** `{ image, imageId }` for a named glyph. */
|
||||
export function glyph(name) {
|
||||
let entry = cache.get(name);
|
||||
if (entry) return entry;
|
||||
const paint = painters[name] || painters.dot;
|
||||
const size = name === 'dot' ? 32 : SIZE;
|
||||
const c = canvas(size);
|
||||
paint(c.getContext('2d'), size);
|
||||
entry = { image: c, imageId: `gev-mobile-glyph-${name}` };
|
||||
cache.set(name, entry);
|
||||
return entry;
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* A minimal promise wrapper over IndexedDB for the engine's own stores
|
||||
* (recorder segments, the last good situations snapshot). Every call fails
|
||||
* soft: no IndexedDB (private mode, insecure frames) resolves to null.
|
||||
*/
|
||||
const DB_NAME = 'gev-globe';
|
||||
const DB_VERSION = 1;
|
||||
export const STORES = Object.freeze(['seg', 'meta', 'kv']);
|
||||
|
||||
let dbPromise = null;
|
||||
|
||||
function open() {
|
||||
if (dbPromise) return dbPromise;
|
||||
dbPromise = new Promise((resolve) => {
|
||||
let req;
|
||||
try {
|
||||
req = globalThis.indexedDB?.open(DB_NAME, DB_VERSION);
|
||||
} catch {
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
if (!req) {
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
req.onupgradeneeded = () => {
|
||||
const db = req.result;
|
||||
for (const name of STORES)
|
||||
if (!db.objectStoreNames.contains(name)) db.createObjectStore(name);
|
||||
};
|
||||
req.onsuccess = () => resolve(req.result);
|
||||
req.onerror = () => resolve(null);
|
||||
req.onblocked = () => resolve(null);
|
||||
});
|
||||
return dbPromise;
|
||||
}
|
||||
|
||||
function done(req) {
|
||||
return new Promise((resolve) => {
|
||||
req.onsuccess = () => resolve(req.result);
|
||||
req.onerror = () => resolve(null);
|
||||
});
|
||||
}
|
||||
|
||||
export async function idbGet(store, key) {
|
||||
const db = await open();
|
||||
if (!db) return null;
|
||||
try {
|
||||
return await done(db.transaction(store).objectStore(store).get(key));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function idbPut(store, key, value) {
|
||||
const db = await open();
|
||||
if (!db) return false;
|
||||
try {
|
||||
await done(
|
||||
db.transaction(store, 'readwrite').objectStore(store).put(value, key),
|
||||
);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Put many [key, value] pairs in one transaction. */
|
||||
export async function idbPutMany(store, entries) {
|
||||
const db = await open();
|
||||
if (!db || !entries.length) return false;
|
||||
return new Promise((resolve) => {
|
||||
try {
|
||||
const tx = db.transaction(store, 'readwrite');
|
||||
const os = tx.objectStore(store);
|
||||
for (const [k, v] of entries) os.put(v, k);
|
||||
tx.oncomplete = () => resolve(true);
|
||||
tx.onerror = () => resolve(false);
|
||||
tx.onabort = () => resolve(false);
|
||||
} catch {
|
||||
resolve(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** All [key, value] pairs with lower <= key (numeric keys). */
|
||||
export async function idbEntries(store, lower = null) {
|
||||
const db = await open();
|
||||
if (!db) return [];
|
||||
return new Promise((resolve) => {
|
||||
const out = [];
|
||||
try {
|
||||
const range =
|
||||
lower == null ? null : globalThis.IDBKeyRange.lowerBound(lower);
|
||||
const req = db.transaction(store).objectStore(store).openCursor(range);
|
||||
req.onsuccess = () => {
|
||||
const c = req.result;
|
||||
if (!c) return resolve(out);
|
||||
out.push([c.key, c.value]);
|
||||
c.continue();
|
||||
};
|
||||
req.onerror = () => resolve(out);
|
||||
} catch {
|
||||
resolve(out);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Delete keys below `upper` (numeric keys). */
|
||||
export async function idbDeleteBelow(store, upper) {
|
||||
const db = await open();
|
||||
if (!db) return false;
|
||||
try {
|
||||
await done(
|
||||
db
|
||||
.transaction(store, 'readwrite')
|
||||
.objectStore(store)
|
||||
.delete(globalThis.IDBKeyRange.upperBound(upper, true)),
|
||||
);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,351 @@
|
||||
/**
|
||||
* Planet surface for the mobile globe: Esri World Imagery by day, NASA Black
|
||||
* Marble city lights by night (blended across the terminator by Cesium's
|
||||
* per-layer dayAlpha/nightAlpha with the sun from the scene clock), keyless
|
||||
* Re:Earth terrain, and Google Photorealistic 3D Tiles below ~25 km when the
|
||||
* build carries a Google key or Cesium ion token.
|
||||
*/
|
||||
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 =
|
||||
'https://gibs.earthdata.nasa.gov/wmts/epsg3857/best/VIIRS_Black_Marble/default/2016-01-01/GoogleMapsCompatible_Level8/{z}/{y}/{x}.png';
|
||||
|
||||
export const GOOGLE_3D_MAX_HEIGHT_M = 25_000;
|
||||
const LIGHT_FULL_BELOW_M = 400_000;
|
||||
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 = 3000;
|
||||
|
||||
export const BASE_CREDITS = Object.freeze([
|
||||
{
|
||||
id: 'esri',
|
||||
text: 'Imagery: Esri, Maxar, Earthstar Geographics, and the GIS User Community',
|
||||
html: '<a href="https://www.esri.com" target="_blank" rel="noopener">Powered by Esri</a> — Source: Esri, Maxar, Earthstar Geographics, and the GIS User Community',
|
||||
},
|
||||
{
|
||||
id: 'black-marble',
|
||||
text: 'Night lights: NASA Earth Observatory / GIBS, VIIRS Black Marble',
|
||||
html: 'Night lights: <a href="https://earthdata.nasa.gov/gibs" target="_blank" rel="noopener">NASA GIBS</a> VIIRS Black Marble',
|
||||
},
|
||||
]);
|
||||
|
||||
const TERRAIN_CREDIT = {
|
||||
id: 'terrain',
|
||||
text: 'Terrain: Re:Earth / Mapterhorn (CC BY 4.0)',
|
||||
html: 'Terrain: <a href="https://reearth.io" target="_blank" rel="noopener">Re:Earth</a> / Mapterhorn, CC BY 4.0',
|
||||
};
|
||||
|
||||
export function createImagery(viewer, { terrain = true, google = {} } = {}) {
|
||||
const scene = viewer.scene;
|
||||
const globe = scene.globe;
|
||||
const layers = viewer.imageryLayers;
|
||||
|
||||
const day = Cesium.ImageryLayer.fromProviderAsync(createEsriImagery(), {
|
||||
dayAlpha: 1,
|
||||
nightAlpha: 1,
|
||||
});
|
||||
layers.add(day);
|
||||
|
||||
const night = new Cesium.ImageryLayer(
|
||||
new Cesium.UrlTemplateImageryProvider({
|
||||
url: BLACK_MARBLE_URL,
|
||||
tilingScheme: new Cesium.WebMercatorTilingScheme(),
|
||||
maximumLevel: 8,
|
||||
credit: 'NASA GIBS · VIIRS Black Marble',
|
||||
}),
|
||||
{
|
||||
dayAlpha: 0,
|
||||
nightAlpha: 1,
|
||||
brightness: NIGHT_LIGHTS_GAIN / NIGHT_DIFFUSE_FLOOR,
|
||||
contrast: 1.2,
|
||||
gamma: 0.85,
|
||||
saturation: 1.15,
|
||||
},
|
||||
);
|
||||
layers.add(night);
|
||||
|
||||
globe.enableLighting = true;
|
||||
globe.dynamicAtmosphereLighting = true;
|
||||
globe.dynamicAtmosphereLightingFromSun = true;
|
||||
// Cesium measures these from the Earth's centre. Lighting (and so the
|
||||
// terminator) is full above ~2,500 km and fades to fully lit below
|
||||
// ~400 km, where the 500 m night mosaic would only be a blur.
|
||||
const R = 6_371_000;
|
||||
globe.lightingFadeOutDistance = R + LIGHT_FULL_BELOW_M;
|
||||
globe.lightingFadeInDistance = R + LIGHT_FULL_ABOVE_M;
|
||||
// GlobeFS mixes the surface toward bare ground atmosphere, weighted by
|
||||
// the sun angle, by k = clamp((dist - nightFadeIn) / (nightFadeOut -
|
||||
// nightFadeIn), 0.05, 1). Cesium's defaults drive k to 0.05 from orbit,
|
||||
// which blacks out the night hemisphere and its city lights. Here k is
|
||||
// ~0.6 from orbit (a soft sun-angle falloff on the day side, lights kept
|
||||
// on the night side) and 1 below ~3,600 km. nightFadeOut also selects
|
||||
// per-fragment ground atmosphere above it, so it keeps the default π/2·R.
|
||||
globe.nightFadeOutDistance = (Math.PI / 2) * R;
|
||||
globe.nightFadeInDistance = 5.0e7;
|
||||
globe.baseColor = Cesium.Color.fromCssColorString('#03060b');
|
||||
|
||||
const state = {
|
||||
day,
|
||||
night,
|
||||
terrainCredit: false,
|
||||
google: null,
|
||||
googleRoute: null,
|
||||
googleLoading: null,
|
||||
nightMode: false,
|
||||
heightM: 2e7,
|
||||
googleWanted: Boolean(
|
||||
String(google.apiKey || '').trim() ||
|
||||
String(google.ionToken || '').trim(),
|
||||
),
|
||||
};
|
||||
|
||||
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, {
|
||||
googleApiKey: google.apiKey,
|
||||
cesiumToken: google.ionToken,
|
||||
})
|
||||
.then(({ tileset, route, errors }) => {
|
||||
if (!tileset) {
|
||||
state.googleWanted = false;
|
||||
console.warn('[globe] Google 3D unavailable', errors);
|
||||
return;
|
||||
}
|
||||
tileset.maximumScreenSpaceError = 16;
|
||||
tileset.cacheBytes = google.cacheBytes ?? 256 * 1024 * 1024;
|
||||
tileset.show = false;
|
||||
scene.primitives.add(tileset);
|
||||
state.google = tileset;
|
||||
state.googleRoute = route;
|
||||
tileset.tileLoad.addEventListener(() => scene.requestRender());
|
||||
})
|
||||
.catch((error) => {
|
||||
state.googleWanted = false;
|
||||
console.warn('[globe] Google 3D failed', error);
|
||||
})
|
||||
.finally(() => {
|
||||
state.googleLoading = null;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep city lights at a constant apparent brightness while Cesium fades
|
||||
* lighting out with altitude, and hand the night side back to day imagery
|
||||
* close in.
|
||||
*/
|
||||
function tuneNight() {
|
||||
if (state.nightMode) return;
|
||||
const h = state.heightM;
|
||||
const fade = Math.min(
|
||||
1,
|
||||
Math.max(
|
||||
0,
|
||||
(h - LIGHT_FULL_BELOW_M) / (LIGHT_FULL_ABOVE_M - LIGHT_FULL_BELOW_M),
|
||||
),
|
||||
);
|
||||
const diffuse = 1 + (NIGHT_DIFFUSE_FLOOR - 1) * fade;
|
||||
night.brightness = NIGHT_LIGHTS_GAIN / diffuse;
|
||||
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();
|
||||
if (!state.google) return;
|
||||
if (state.google.show !== close) {
|
||||
state.google.show = close;
|
||||
globe.show = !close;
|
||||
scene.requestRender();
|
||||
}
|
||||
}
|
||||
|
||||
/** 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();
|
||||
grade();
|
||||
}
|
||||
scene.requestRender();
|
||||
}
|
||||
|
||||
function credits() {
|
||||
const list = [...BASE_CREDITS];
|
||||
if (state.terrainCredit) list.push(TERRAIN_CREDIT);
|
||||
if (state.google?.show) {
|
||||
list.push({
|
||||
id: 'google',
|
||||
text: 'Google Photorealistic 3D Tiles',
|
||||
html: 'Google — 3D Tiles © Google',
|
||||
onScreen: true,
|
||||
logo: true,
|
||||
});
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
return {
|
||||
state,
|
||||
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);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
/**
|
||||
* Sky family: commercial flights, military aircraft, helicopters, private/GA
|
||||
* and emergency squawks. Every layer reads the shared flight and military
|
||||
* feeds; derived layers (helicopters, GA, squawks) are filters, never extra
|
||||
* downloads.
|
||||
*/
|
||||
import { ContactRenderer, Layer, color, FAMILY_COLORS } from './base.js';
|
||||
import { haversineKm } from '../geo.js';
|
||||
|
||||
const SKY = FAMILY_COLORS.Sky;
|
||||
const M_TO_FT = 3.28084;
|
||||
const MPS_TO_KT = 1 / 0.514444;
|
||||
const HISTORY = 24;
|
||||
|
||||
export class AircraftLayer extends Layer {
|
||||
/**
|
||||
* @param {object} ctx
|
||||
* @param {object} def layer definition
|
||||
* @param {{sources:{feed:object, filter:(r:object)=>boolean}[], tint:string,
|
||||
* lodHeightM?:number, cap?:number, alwaysGlyph?:boolean, kind?:string}} opts
|
||||
*/
|
||||
constructor(ctx, def, opts) {
|
||||
super(ctx, { pollMs: 12_000, ...def });
|
||||
this.sources = opts.sources;
|
||||
this.kind = opts.kind || 'aircraft';
|
||||
this.renderer = new ContactRenderer(this.root, {
|
||||
layerId: this.id,
|
||||
tint: color(opts.tint || SKY),
|
||||
glyphName: 'plane',
|
||||
lodHeightM: opts.lodHeightM ?? 1_800_000,
|
||||
cap: opts.cap ?? 700,
|
||||
deadReckon: true,
|
||||
dotScale: opts.dotScale ?? 0.36,
|
||||
alwaysGlyph: opts.alwaysGlyph,
|
||||
glyphScale: opts.glyphScale ?? 0.5,
|
||||
});
|
||||
this.records = new Map();
|
||||
this.history = new Map();
|
||||
this.groundTint = color(opts.tint || SKY).withAlpha(0.55);
|
||||
}
|
||||
|
||||
async refresh() {
|
||||
const results = await Promise.allSettled(
|
||||
this.sources.map((s) => s.feed.get({ maxAgeMs: 9_000 })),
|
||||
);
|
||||
const flightData = this.sources[0].feed.data;
|
||||
this._regionalNote = flightData?.regional
|
||||
? 'OpenSky unavailable — adsb.lol snapshot within 250 nm of the view'
|
||||
: '';
|
||||
const records = [];
|
||||
let anyOk = false;
|
||||
this.sources.forEach((s, i) => {
|
||||
this.fromFeed(s.feed);
|
||||
if (results[i].status !== 'fulfilled') return;
|
||||
anyOk = true;
|
||||
for (const r of results[i].value.records)
|
||||
if (s.filter(r)) records.push(r);
|
||||
});
|
||||
if (!anyOk) throw results[0].reason;
|
||||
if (this._regionalNote && !this.message) this.message = this._regionalNote;
|
||||
this.ingest(records);
|
||||
}
|
||||
|
||||
ingest(records) {
|
||||
const next = new Map();
|
||||
for (const r of records) {
|
||||
next.set(r.id, r);
|
||||
let h = this.history.get(r.id);
|
||||
if (!h) this.history.set(r.id, (h = []));
|
||||
const last = h.at(-1);
|
||||
if (!last || last[0] !== r.lon || last[1] !== r.lat) {
|
||||
h.push([r.lon, r.lat, r.altM]);
|
||||
if (h.length > HISTORY) h.shift();
|
||||
}
|
||||
}
|
||||
for (const id of this.history.keys())
|
||||
if (!next.has(id)) this.history.delete(id);
|
||||
this.records = next;
|
||||
this.count = next.size;
|
||||
this.renderer.setItems(
|
||||
records.map((r) => ({
|
||||
key: r.id,
|
||||
lon: r.lon,
|
||||
lat: r.lat,
|
||||
altM: r.altM,
|
||||
heading: r.heading,
|
||||
speed: r.speed,
|
||||
onGround: r.onGround,
|
||||
seenAt: r.seenAt || Date.now(),
|
||||
glyph: r.helicopter ? 'heli' : 'plane',
|
||||
// Parked traffic recedes so the apron does not bloom into a blob.
|
||||
color: r.emergency
|
||||
? color('#FF3B30')
|
||||
: r.onGround
|
||||
? this.groundTint
|
||||
: undefined,
|
||||
glyphScale: r.onGround
|
||||
? 0.6
|
||||
: r.helicopter
|
||||
? 0.95
|
||||
: r.cls === 'widebody'
|
||||
? 1.15
|
||||
: 1,
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
onView(view) {
|
||||
this.renderer.setView(view);
|
||||
// A regional fallback snapshot follows the camera: refetch once the
|
||||
// view has moved well away from the snapshot's anchor.
|
||||
const data = this.sources[0].feed.data;
|
||||
if (
|
||||
this.on &&
|
||||
data?.regional &&
|
||||
data.anchor &&
|
||||
view.center &&
|
||||
haversineKm(
|
||||
data.anchor.lat,
|
||||
data.anchor.lon,
|
||||
view.center.lat,
|
||||
view.center.lon,
|
||||
) > 300 &&
|
||||
Date.now() - (this._regionalKick || 0) > 8_000
|
||||
) {
|
||||
this._regionalKick = Date.now();
|
||||
this.kick();
|
||||
}
|
||||
}
|
||||
|
||||
onTime(ms, live) {
|
||||
// Inside the recorded past the history layer draws where everything
|
||||
// actually was, and inside a Moment its stage owns the sky: the live
|
||||
// snapshot hides. Beyond the record, aircraft dim to show they are not
|
||||
// a reconstruction of that moment.
|
||||
const mode = this.ctx.timeMode?.(ms, live) || 'none';
|
||||
this.root.show = this.on && mode === 'none';
|
||||
this.renderer.setAlpha(live ? 1 : 0.3);
|
||||
}
|
||||
|
||||
onTick(nowMs, live) {
|
||||
return live && this.renderer.tick(nowMs);
|
||||
}
|
||||
|
||||
locate(key) {
|
||||
const r = this.records.get(key);
|
||||
if (!r) return null;
|
||||
const it = this.renderer.byKey.get(key);
|
||||
const [lon, lat] = it ? this.renderer.where(it) : [r.lon, r.lat];
|
||||
return [lon, lat, r.altM];
|
||||
}
|
||||
|
||||
trail(key) {
|
||||
const h = this.history.get(key) || [];
|
||||
const now = this.locate(key);
|
||||
return now ? [...h, now] : h;
|
||||
}
|
||||
|
||||
pick(key) {
|
||||
const r = this.records.get(key);
|
||||
if (!r) return null;
|
||||
const [lon, lat, altM] = this.locate(key);
|
||||
const alt = r.onGround
|
||||
? 'On ground'
|
||||
: `${Math.round((r.altM * M_TO_FT) / 100) * 100} ft`;
|
||||
const spd = Number.isFinite(r.speed)
|
||||
? `${Math.round(r.speed * MPS_TO_KT)} kt`
|
||||
: null;
|
||||
return {
|
||||
kind: this.kind,
|
||||
id: r.id,
|
||||
title: r.callsign,
|
||||
subtitle: [
|
||||
r.military ? r.type || 'Military' : r.country,
|
||||
alt,
|
||||
spd,
|
||||
r.emergency ? `SQUAWK ${r.squawk}` : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · '),
|
||||
lon,
|
||||
lat,
|
||||
...(r.emergency ? { labelDetail: `SQ ${r.squawk}` } : {}),
|
||||
meta: {
|
||||
layer: this.id,
|
||||
icao24: r.id,
|
||||
altitudeM: r.altM,
|
||||
speedMps: r.speed,
|
||||
headingDeg: r.heading,
|
||||
verticalRateMps: r.vRate ?? null,
|
||||
squawk: r.squawk,
|
||||
type: r.type || null,
|
||||
class: r.cls,
|
||||
country: r.country || null,
|
||||
onGround: r.onGround,
|
||||
source: this.source,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
contacts() {
|
||||
const out = [];
|
||||
for (const r of this.records.values()) {
|
||||
const it = this.renderer.byKey.get(r.id);
|
||||
const [lon, lat] = it ? this.renderer.where(it) : [r.lon, r.lat];
|
||||
out.push({
|
||||
kind: this.kind,
|
||||
id: r.id,
|
||||
title: r.callsign,
|
||||
lon,
|
||||
lat,
|
||||
altM: r.altM,
|
||||
headingDeg: r.heading,
|
||||
speedMps: r.speed,
|
||||
layer: this.id,
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
onHide() {}
|
||||
}
|
||||
|
||||
/** Build the Sky family. */
|
||||
export function createSkyLayers(ctx, { flights, military }) {
|
||||
const all = () => true;
|
||||
return [
|
||||
new AircraftLayer(
|
||||
ctx,
|
||||
{ id: 'flights', family: 'Sky', name: 'Commercial flights' },
|
||||
{
|
||||
sources: [{ feed: flights, filter: (r) => !r.ga && !r.helicopter }],
|
||||
tint: SKY,
|
||||
},
|
||||
),
|
||||
new AircraftLayer(
|
||||
ctx,
|
||||
{ id: 'military', family: 'Sky', name: 'Military aircraft' },
|
||||
{
|
||||
sources: [{ feed: military, filter: all }],
|
||||
tint: '#E3F1FF',
|
||||
kind: 'military',
|
||||
lodHeightM: 6_000_000,
|
||||
alwaysGlyph: true,
|
||||
glyphScale: 0.62,
|
||||
dotScale: 0.6,
|
||||
},
|
||||
),
|
||||
new AircraftLayer(
|
||||
ctx,
|
||||
{ id: 'helicopters', family: 'Sky', name: 'Helicopters' },
|
||||
{
|
||||
sources: [
|
||||
{ feed: flights, filter: (r) => r.helicopter },
|
||||
{ feed: military, filter: (r) => r.helicopter },
|
||||
],
|
||||
tint: '#A8E0FF',
|
||||
kind: 'helicopter',
|
||||
alwaysGlyph: true,
|
||||
glyphScale: 0.6,
|
||||
},
|
||||
),
|
||||
new AircraftLayer(
|
||||
ctx,
|
||||
{ id: 'ga', family: 'Sky', name: 'Private / GA' },
|
||||
{
|
||||
sources: [{ feed: flights, filter: (r) => r.ga && !r.helicopter }],
|
||||
tint: '#B9D9F5',
|
||||
dotScale: 0.3,
|
||||
glyphScale: 0.42,
|
||||
},
|
||||
),
|
||||
new AircraftLayer(
|
||||
ctx,
|
||||
{ id: 'squawk', family: 'Sky', name: 'Emergency squawks' },
|
||||
{
|
||||
sources: [
|
||||
{ feed: flights, filter: (r) => r.emergency },
|
||||
{
|
||||
feed: military,
|
||||
filter: (r) => ['7500', '7600', '7700'].includes(r.squawk),
|
||||
},
|
||||
],
|
||||
tint: '#FF3B30',
|
||||
kind: 'emergency',
|
||||
alwaysGlyph: true,
|
||||
glyphScale: 0.8,
|
||||
dotScale: 0.9,
|
||||
},
|
||||
),
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,384 @@
|
||||
/**
|
||||
* Layer plumbing: lifecycle/polling/status for every live layer, and the
|
||||
* ContactRenderer that draws point contacts with semantic zoom.
|
||||
*
|
||||
* Semantic zoom: every contact is a soft glow dot (one BillboardCollection,
|
||||
* one draw call) so the planet reads as density at orbital altitude. Below the
|
||||
* layer's LOD height, the contacts nearest the view centre (capped) also get a
|
||||
* heading-aligned glyph that dead-reckons between polls.
|
||||
*/
|
||||
import * as Cesium from 'cesium';
|
||||
import { glyph } from '../glyphs.js';
|
||||
import { offsetLonLat } from '../geo.js';
|
||||
|
||||
export const FAMILY_COLORS = Object.freeze({
|
||||
Sky: '#7CC4FF',
|
||||
Sea: '#38E1C6',
|
||||
Orbit: '#C9A7FF',
|
||||
Hazards: '#FF6A3D',
|
||||
Eyes: '#FFD166',
|
||||
Ground: '#9BE15D',
|
||||
Weather: '#8AB4F8',
|
||||
Infra: '#B8C0CC',
|
||||
});
|
||||
|
||||
export const color = (css, alpha = 1) =>
|
||||
Cesium.Color.fromCssColorString(css).withAlpha(alpha);
|
||||
|
||||
export class Layer {
|
||||
/**
|
||||
* @param {object} ctx engine context from index.js
|
||||
* @param {{id:string,family:string,name:string,pollMs?:number}} def
|
||||
*/
|
||||
constructor(ctx, def) {
|
||||
this.ctx = ctx;
|
||||
this.id = def.id;
|
||||
this.family = def.family;
|
||||
this.name = def.name;
|
||||
this.pollMs = def.pollMs ?? 0;
|
||||
this.available = true;
|
||||
this.on = false;
|
||||
this.count = 0;
|
||||
this.status = 'off';
|
||||
this.message = '';
|
||||
this.source = def.source || '';
|
||||
this.updatedAt = null;
|
||||
this.loading = false;
|
||||
this.root = ctx.scene.primitives.add(new Cesium.PrimitiveCollection());
|
||||
this.root.show = false;
|
||||
this._timer = null;
|
||||
this._gen = 0;
|
||||
}
|
||||
|
||||
info() {
|
||||
return {
|
||||
id: this.id,
|
||||
family: this.family,
|
||||
name: this.name,
|
||||
on: this.on,
|
||||
count: this.on ? this.count : 0,
|
||||
status: this.on ? this.status : 'off',
|
||||
available: this.available,
|
||||
loading: this.on && this.loading,
|
||||
message: this.message,
|
||||
source: this.source,
|
||||
updatedAt: this.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
setOn(on) {
|
||||
on = Boolean(on);
|
||||
if (on === this.on) return;
|
||||
this.on = on;
|
||||
this.root.show = on;
|
||||
this._gen++;
|
||||
clearTimeout(this._timer);
|
||||
this._timer = null;
|
||||
if (on) {
|
||||
this.status = this.updatedAt ? this.status : 'stale';
|
||||
this.kick();
|
||||
} else {
|
||||
this.onHide?.();
|
||||
}
|
||||
this.ctx.layersChanged();
|
||||
this.ctx.requestRender();
|
||||
}
|
||||
|
||||
/** Refresh now (if on and active) and keep polling. */
|
||||
kick() {
|
||||
if (!this.on) return;
|
||||
clearTimeout(this._timer);
|
||||
this._timer = null;
|
||||
const gen = this._gen;
|
||||
if (!this.ctx.active()) return; // resume() kicks again
|
||||
this.loading = true;
|
||||
Promise.resolve()
|
||||
.then(() => this.refresh())
|
||||
.then(
|
||||
() => {
|
||||
if (gen !== this._gen) return;
|
||||
this.updatedAt = Date.now();
|
||||
},
|
||||
(error) => {
|
||||
if (gen !== this._gen) return;
|
||||
this.status = 'error';
|
||||
this.message = error?.message || String(error);
|
||||
console.warn(`[globe:${this.id}]`, this.message);
|
||||
},
|
||||
)
|
||||
.finally(() => {
|
||||
if (gen !== this._gen) return;
|
||||
this.loading = false;
|
||||
this.ctx.layersChanged();
|
||||
this.ctx.requestRender();
|
||||
if (this.pollMs && this.on)
|
||||
this._timer = setTimeout(() => this.kick(), this.pollMs);
|
||||
});
|
||||
}
|
||||
|
||||
/** Mark status from a feed after a refresh. */
|
||||
fromFeed(feed) {
|
||||
if (feed.error && !feed.data) {
|
||||
this.status = 'error';
|
||||
this.message = feed.error.message;
|
||||
} else {
|
||||
this.status = feed.stale ? 'stale' : 'live';
|
||||
this.message = feed.error ? `Refresh failed: ${feed.error.message}` : '';
|
||||
}
|
||||
if (feed.data?.source) this.source = feed.data.source;
|
||||
}
|
||||
|
||||
// Overridables
|
||||
async refresh() {}
|
||||
onView() {}
|
||||
onTime() {}
|
||||
onTick() {}
|
||||
pick() {
|
||||
return null;
|
||||
}
|
||||
contacts() {
|
||||
return [];
|
||||
}
|
||||
destroy() {
|
||||
clearTimeout(this._timer);
|
||||
this.ctx.scene.primitives.remove(this.root);
|
||||
}
|
||||
}
|
||||
|
||||
/** A catalog entry the prototype does not render yet. */
|
||||
export class UnavailableLayer {
|
||||
constructor(def) {
|
||||
Object.assign(this, def);
|
||||
this.on = false;
|
||||
this.available = false;
|
||||
}
|
||||
info() {
|
||||
return {
|
||||
id: this.id,
|
||||
family: this.family,
|
||||
name: this.name,
|
||||
on: false,
|
||||
count: 0,
|
||||
status: 'off',
|
||||
available: false,
|
||||
loading: false,
|
||||
message: this.message || 'Not in the prototype yet',
|
||||
source: '',
|
||||
updatedAt: null,
|
||||
};
|
||||
}
|
||||
setOn() {}
|
||||
kick() {}
|
||||
onView() {}
|
||||
onTime() {}
|
||||
onTick() {}
|
||||
pick() {
|
||||
return null;
|
||||
}
|
||||
contacts() {
|
||||
return [];
|
||||
}
|
||||
destroy() {}
|
||||
}
|
||||
|
||||
const DOT = () => glyph('dot');
|
||||
const GLYPH_NEAR_FAR = new Cesium.NearFarScalar(2e4, 1.25, 3e6, 0.7);
|
||||
const GLYPH_NO_DEPTH_WITHIN_M = 150_000;
|
||||
|
||||
/**
|
||||
* Point contacts with semantic zoom.
|
||||
* items: [{ key, lon, lat, altM?, heading?, speed?, seenAt?, color?, glyph?,
|
||||
* dotScale?, glyphScale? }]
|
||||
*/
|
||||
export class ContactRenderer {
|
||||
constructor(
|
||||
root,
|
||||
{
|
||||
tint,
|
||||
glyphName = 'dot',
|
||||
lodHeightM = 2_500_000,
|
||||
cap = 900,
|
||||
rotate = true,
|
||||
dotScale = 0.42,
|
||||
glyphScale = 0.5,
|
||||
layerId,
|
||||
deadReckon = false,
|
||||
dotAlpha = 0.85,
|
||||
alwaysGlyph = false,
|
||||
},
|
||||
) {
|
||||
this.layerId = layerId;
|
||||
this.tint = tint;
|
||||
this.glyphName = glyphName;
|
||||
this.lodHeightM = lodHeightM;
|
||||
this.cap = cap;
|
||||
this.rotate = rotate;
|
||||
this.dotScale = dotScale;
|
||||
this.glyphScale = glyphScale;
|
||||
this.deadReckon = deadReckon;
|
||||
this.dotAlpha = dotAlpha;
|
||||
this.alwaysGlyph = alwaysGlyph;
|
||||
this.dots = root.add(
|
||||
new Cesium.BillboardCollection({
|
||||
blendOption: Cesium.BlendOption.TRANSLUCENT,
|
||||
}),
|
||||
);
|
||||
this.glyphs = root.add(new Cesium.BillboardCollection());
|
||||
this.items = [];
|
||||
this.byKey = new Map();
|
||||
this.view = null;
|
||||
this.glyphMode = false;
|
||||
this.alpha = 1;
|
||||
this._scratch = new Cesium.Cartesian3();
|
||||
}
|
||||
|
||||
setItems(items) {
|
||||
this.items = items;
|
||||
this.byKey = new Map(items.map((it) => [it.key, it]));
|
||||
const dot = DOT();
|
||||
this.dots.removeAll();
|
||||
const near = new Cesium.NearFarScalar(4e5, 1.0, 2.2e7, 0.45);
|
||||
for (const it of items) {
|
||||
const c = it.color || this.tint;
|
||||
this.dots.add({
|
||||
position: Cesium.Cartesian3.fromDegrees(it.lon, it.lat, it.altM || 0),
|
||||
image: dot.image,
|
||||
imageId: dot.imageId,
|
||||
color: c.withAlpha(c.alpha * this.dotAlpha * this.alpha),
|
||||
scale: (it.dotScale ?? 1) * this.dotScale,
|
||||
scaleByDistance: near,
|
||||
id: { layer: this.layerId, key: it.key },
|
||||
});
|
||||
}
|
||||
this._rebuildGlyphs();
|
||||
}
|
||||
|
||||
setAlpha(alpha) {
|
||||
if (alpha === this.alpha) return;
|
||||
this.alpha = alpha;
|
||||
this.setItems(this.items);
|
||||
}
|
||||
|
||||
setView(view) {
|
||||
this.view = view;
|
||||
this._rebuildGlyphs();
|
||||
}
|
||||
|
||||
_rebuildGlyphs() {
|
||||
const view = this.view;
|
||||
this.glyphs.removeAll();
|
||||
this.glyphMode =
|
||||
this.alwaysGlyph || Boolean(view && view.heightM < this.lodHeightM);
|
||||
if (!this.glyphMode || !view) return;
|
||||
const { rect } = view;
|
||||
const cLat = view.center?.lat ?? 0;
|
||||
const cLon = view.center?.lon ?? 0;
|
||||
const inView = [];
|
||||
for (const it of this.items) {
|
||||
if (rect && !inRect(rect, it.lon, it.lat)) continue;
|
||||
const dLat = it.lat - cLat;
|
||||
const dLon = (it.lon - cLon) * Math.cos((cLat * Math.PI) / 180);
|
||||
inView.push([dLat * dLat + dLon * dLon, it]);
|
||||
}
|
||||
inView.sort((a, b) => a[0] - b[0]);
|
||||
const n = Math.min(this.cap, inView.length);
|
||||
for (let i = 0; i < n; i++) {
|
||||
const it = inView[i][1];
|
||||
const g = glyph(it.glyph || this.glyphName);
|
||||
it._bb = this.glyphs.add({
|
||||
position: Cesium.Cartesian3.fromDegrees(it.lon, it.lat, it.altM || 0),
|
||||
image: g.image,
|
||||
imageId: g.imageId,
|
||||
color: (it.color || this.tint).withAlpha(
|
||||
(it.color || this.tint).alpha * this.alpha,
|
||||
),
|
||||
scale: (it.glyphScale ?? 1) * this.glyphScale,
|
||||
rotation: this.rotate ? -Cesium.Math.toRadians(it.heading || 0) : 0,
|
||||
alignedAxis: this.rotate
|
||||
? Cesium.Cartesian3.UNIT_Z
|
||||
: Cesium.Cartesian3.ZERO,
|
||||
id: { layer: this.layerId, key: it.key },
|
||||
scaleByDistance: GLYPH_NEAR_FAR,
|
||||
// Contacts report ellipsoid/MSL heights; over terrain a grounded or
|
||||
// low contact would sink below the surface. Close in, glyphs ignore
|
||||
// depth (a God's-eye HUD), far out the globe still occludes them.
|
||||
disableDepthTestDistance: GLYPH_NO_DEPTH_WITHIN_M,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Move items in place (same keys, same order as the last setItems): no
|
||||
* billboard churn. `from`/`to` bound a round-robin slice of the dots;
|
||||
* glyph billboards always move.
|
||||
*/
|
||||
moveItems(from = 0, to = this.items.length) {
|
||||
const n = Math.min(to, this.items.length, this.dots.length);
|
||||
for (let i = from; i < n; i++) {
|
||||
const it = this.items[i];
|
||||
this.dots.get(i).position = Cesium.Cartesian3.fromDegrees(
|
||||
it.lon,
|
||||
it.lat,
|
||||
it.altM || 0,
|
||||
);
|
||||
}
|
||||
for (let i = 0; i < this.glyphs.length; i++) {
|
||||
const bb = this.glyphs.get(i);
|
||||
const it = this.byKey.get(bb.id.key);
|
||||
if (it)
|
||||
bb.position = Cesium.Cartesian3.fromDegrees(
|
||||
it.lon,
|
||||
it.lat,
|
||||
it.altM || 0,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Dead-reckon glyphs from their last fix. Returns true when anything moved. */
|
||||
tick(nowMs) {
|
||||
if (!this.deadReckon || !this.glyphMode) return false;
|
||||
let moved = false;
|
||||
for (let i = 0; i < this.glyphs.length; i++) {
|
||||
const bb = this.glyphs.get(i);
|
||||
const it = this.byKey.get(bb.id.key);
|
||||
if (!it || !it.speed || it.onGround || !it.seenAt) continue;
|
||||
const dt = Math.min(90, Math.max(0, (nowMs - it.seenAt) / 1000));
|
||||
const [lon, lat] = offsetLonLat(
|
||||
it.lon,
|
||||
it.lat,
|
||||
it.heading,
|
||||
it.speed * dt,
|
||||
);
|
||||
bb.position = Cesium.Cartesian3.fromDegrees(
|
||||
lon,
|
||||
lat,
|
||||
it.altM || 0,
|
||||
undefined,
|
||||
this._scratch,
|
||||
);
|
||||
moved = true;
|
||||
}
|
||||
return moved;
|
||||
}
|
||||
|
||||
/** Current (dead-reckoned) lon/lat of an item. */
|
||||
where(it, nowMs = Date.now()) {
|
||||
if (!this.deadReckon || !it.speed || it.onGround || !it.seenAt)
|
||||
return [it.lon, it.lat];
|
||||
const dt = Math.min(90, Math.max(0, (nowMs - it.seenAt) / 1000));
|
||||
return offsetLonLat(it.lon, it.lat, it.heading, it.speed * dt);
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.items = [];
|
||||
this.byKey.clear();
|
||||
this.dots.removeAll();
|
||||
this.glyphs.removeAll();
|
||||
}
|
||||
}
|
||||
|
||||
export function inRect(rect, lon, lat) {
|
||||
if (lat < rect.south || lat > rect.north) return false;
|
||||
if (rect.west <= rect.east) return lon >= rect.west && lon <= rect.east;
|
||||
return lon >= rect.west || lon <= rect.east; // crosses the antimeridian
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* The full layer catalog (DESIGN.md §8) in display order. Entries the
|
||||
* prototype renders are built by the family factories; the rest are listed
|
||||
* honestly as unavailable so the lens dock can show the whole catalog.
|
||||
*/
|
||||
import { UnavailableLayer } from './base.js';
|
||||
import { createSkyLayers } from './air.js';
|
||||
import { createOrbitLayers } from './orbit.js';
|
||||
import { createHazardLayers } from './hazards.js';
|
||||
import { createSeaLayers, createEyesLayers } from './sea.js';
|
||||
import { createWeatherLayers } from './weather.js';
|
||||
|
||||
export const FAMILIES = Object.freeze([
|
||||
'Sky',
|
||||
'Sea',
|
||||
'Orbit',
|
||||
'Hazards',
|
||||
'Eyes',
|
||||
'Ground',
|
||||
'Weather',
|
||||
'Infra',
|
||||
]);
|
||||
|
||||
export const CATALOG = Object.freeze([
|
||||
['Sky', 'flights', 'Commercial flights'],
|
||||
['Sky', 'military', 'Military aircraft'],
|
||||
['Sky', 'helicopters', 'Helicopters'],
|
||||
['Sky', 'ga', 'Private / GA'],
|
||||
['Sky', 'squawk', 'Emergency squawks'],
|
||||
['Sky', 'drones', 'Drones (Remote ID)'],
|
||||
['Sky', 'airport-boards', 'Airport boards'],
|
||||
['Sky', 'holding', 'Holding patterns'],
|
||||
['Sea', 'vessels', 'AIS vessels'],
|
||||
['Sea', 'ports', 'Ports'],
|
||||
['Sea', 'tankers', 'Tankers / cargo'],
|
||||
['Sea', 'fishing', 'Fishing effort'],
|
||||
['Sea', 'dark-vessels', 'Dark vessels (AIS gaps)'],
|
||||
['Sea', 'cables', 'Submarine cables'],
|
||||
['Orbit', 'satellites', 'Satellites (TLE)'],
|
||||
['Orbit', 'iss', 'ISS & crewed'],
|
||||
['Orbit', 'starlink', 'Starlink trains'],
|
||||
['Orbit', 'launches', 'Launches & trajectories'],
|
||||
['Orbit', 'debris', 'Debris'],
|
||||
['Orbit', 'passes', 'Ground tracks & passes'],
|
||||
['Hazards', 'earthquakes', 'Earthquakes'],
|
||||
['Hazards', 'fires', 'Active fires (FIRMS)'],
|
||||
['Hazards', 'fire-perimeters', 'Fire perimeters'],
|
||||
['Hazards', 'floods', 'Floods'],
|
||||
['Hazards', 'cyclones', 'Tropical cyclones'],
|
||||
['Hazards', 'volcanoes', 'Volcanoes'],
|
||||
['Hazards', 'air-quality', 'Air quality'],
|
||||
['Eyes', 'cctv', 'Public CCTV'],
|
||||
['Eyes', 'captures', 'Signed captures'],
|
||||
['Eyes', 'street-level', 'Street-level imagery'],
|
||||
['Eyes', 'holograms', 'Holograms'],
|
||||
['Eyes', 'streams', 'Live streams'],
|
||||
['Ground', 'transit', 'Transit vehicles'],
|
||||
['Ground', 'traffic', 'Traffic flow'],
|
||||
['Ground', 'bikeshare', 'Bike share'],
|
||||
['Ground', 'rail', 'Rail'],
|
||||
['Ground', 'venues', 'Events & venues'],
|
||||
['Ground', 'alpr', 'ALPR cameras'],
|
||||
['Weather', 'radar', 'Radar'],
|
||||
['Weather', 'clouds', 'Clouds'],
|
||||
['Weather', 'lightning', 'Lightning'],
|
||||
['Weather', 'wind', 'Wind (10 m)'],
|
||||
['Weather', 'jet', 'Jet stream (250 hPa)'],
|
||||
['Weather', 'forecast', 'Forecast models'],
|
||||
['Infra', 'datacenters', 'Datacenters'],
|
||||
['Infra', 'dams', 'Dams'],
|
||||
['Infra', 'power', 'Power plants'],
|
||||
['Infra', 'installations', 'Military installations'],
|
||||
['Infra', 'gnss', 'GNSS interference'],
|
||||
['Infra', 'recent-imagery', 'Recent satellite imagery'],
|
||||
]);
|
||||
|
||||
/** Build every catalog layer; returns a Map id → layer in catalog order. */
|
||||
export function buildLayers(ctx, feeds) {
|
||||
const live = new Map();
|
||||
for (const layer of [
|
||||
...createSkyLayers(ctx, feeds),
|
||||
...createSeaLayers(ctx, feeds),
|
||||
...createOrbitLayers(ctx, feeds),
|
||||
...createHazardLayers(ctx, feeds),
|
||||
...createEyesLayers(ctx, feeds),
|
||||
...createWeatherLayers(ctx, feeds),
|
||||
])
|
||||
live.set(layer.id, layer);
|
||||
const out = new Map();
|
||||
for (const [family, id, name] of CATALOG) {
|
||||
const layer = live.get(id);
|
||||
if (layer) {
|
||||
layer.family = family;
|
||||
out.set(id, layer);
|
||||
} else out.set(id, new UnavailableLayer({ id, family, name }));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,768 @@
|
||||
/**
|
||||
* Hazards family: earthquakes (USGS), active fires (NASA FIRMS, or NASA EONET
|
||||
* events when no FIRMS key), tropical cyclones (NOAA NHC + EONET) with
|
||||
* forecast tracks the time ribbon can scrub into, volcanoes (EONET) and the
|
||||
* bundled Bhote Koshi flood corridor.
|
||||
*/
|
||||
import * as Cesium from 'cesium';
|
||||
import { ContactRenderer, Layer, color, FAMILY_COLORS } from './base.js';
|
||||
import { relTime } from '../geo.js';
|
||||
|
||||
const HAZ = FAMILY_COLORS.Hazards;
|
||||
const WEEK = 7 * 86_400_000;
|
||||
|
||||
/** A point-event layer filtered to what had happened by the clock time. */
|
||||
class EventLayer extends Layer {
|
||||
constructor(ctx, def, opts) {
|
||||
super(ctx, def);
|
||||
this.feed = opts.feed;
|
||||
this.select = opts.select; // data → records [{id, lon, lat, t, ...}]
|
||||
this.style = opts.style; // (record, ageMs) → item fields
|
||||
this.describe = opts.describe; // (record, clockMs) → {title, subtitle, meta}
|
||||
this.windowMs = opts.windowMs ?? WEEK;
|
||||
this.kind = opts.kind;
|
||||
this.renderer = new ContactRenderer(this.root, {
|
||||
layerId: this.id,
|
||||
tint: color(opts.tint || HAZ),
|
||||
glyphName: opts.glyph,
|
||||
rotate: false,
|
||||
alwaysGlyph: opts.alwaysGlyph,
|
||||
lodHeightM: opts.lodHeightM ?? 3_000_000,
|
||||
cap: opts.cap ?? 400,
|
||||
dotScale: opts.dotScale ?? 0.5,
|
||||
glyphScale: opts.glyphScale ?? 0.5,
|
||||
});
|
||||
this.records = new Map();
|
||||
}
|
||||
|
||||
async refresh() {
|
||||
try {
|
||||
await this.feed.get();
|
||||
} finally {
|
||||
this.fromFeed(this.feed);
|
||||
}
|
||||
this._draw();
|
||||
}
|
||||
|
||||
_draw() {
|
||||
const data = this.feed.data;
|
||||
if (!data) return;
|
||||
const t = this.ctx.timeMs();
|
||||
const items = [];
|
||||
this.records = new Map();
|
||||
for (const r of this.select(data)) {
|
||||
const age = Number.isFinite(r.t) ? t - r.t : 0;
|
||||
if (age < 0 || age > this.windowMs) continue;
|
||||
this.records.set(r.id, r);
|
||||
items.push({
|
||||
key: r.id,
|
||||
lon: r.lon,
|
||||
lat: r.lat,
|
||||
altM: 0,
|
||||
...this.style(r, age),
|
||||
});
|
||||
}
|
||||
this.count = items.length;
|
||||
this.renderer.setItems(items);
|
||||
}
|
||||
|
||||
onView(view) {
|
||||
this.renderer.setView(view);
|
||||
}
|
||||
onTime() {
|
||||
if (this.on) this._draw();
|
||||
}
|
||||
locate(key) {
|
||||
const r = this.records.get(key);
|
||||
return r ? [r.lon, r.lat, 0] : null;
|
||||
}
|
||||
pick(key) {
|
||||
const r = this.records.get(key);
|
||||
if (!r) return null;
|
||||
const d = this.describe(r, this.ctx.timeMs());
|
||||
return {
|
||||
kind: this.kind,
|
||||
id: r.id,
|
||||
lon: r.lon,
|
||||
lat: r.lat,
|
||||
...d,
|
||||
meta: { layer: this.id, source: this.source, ...d.meta },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function quakeColor(mag) {
|
||||
if (mag >= 6) return '#FF3B30';
|
||||
if (mag >= 5) return '#FF6A3D';
|
||||
if (mag >= 4) return '#FFA23D';
|
||||
return '#FFD08A';
|
||||
}
|
||||
|
||||
export function createHazardLayers(ctx, feeds) {
|
||||
const quakes = new EventLayer(
|
||||
ctx,
|
||||
{
|
||||
id: 'earthquakes',
|
||||
family: 'Hazards',
|
||||
name: 'Earthquakes',
|
||||
pollMs: 5 * 60_000,
|
||||
},
|
||||
{
|
||||
feed: feeds.quakes,
|
||||
kind: 'earthquake',
|
||||
glyph: 'quake',
|
||||
alwaysGlyph: true,
|
||||
cap: 600,
|
||||
select: (d) =>
|
||||
d.records.map((q) => ({ ...q, id: q.stableId, t: q.time })),
|
||||
style: (q, age) => {
|
||||
const fresh = age < 86_400_000;
|
||||
return {
|
||||
color: color(quakeColor(q.mag), fresh ? 1 : 0.55),
|
||||
glyphScale: 0.32 + Math.max(0, q.mag - 2.5) * 0.24,
|
||||
dotScale: 0.6 + Math.max(0, q.mag - 2.5) * 0.4,
|
||||
};
|
||||
},
|
||||
describe: (q, t) => ({
|
||||
title: `M${q.mag.toFixed(1)} earthquake`,
|
||||
subtitle: `${q.place || 'Unknown location'} · ${relTime(q.time, t)}`,
|
||||
meta: {
|
||||
magnitude: q.mag,
|
||||
depthKm: q.depthKm,
|
||||
time: q.time ? new Date(q.time).toISOString() : null,
|
||||
usgsId: q.usgsId,
|
||||
url: q.usgsId
|
||||
? `https://earthquake.usgs.gov/earthquakes/eventpage/${q.usgsId}`
|
||||
: null,
|
||||
},
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
const fires = new EventLayer(
|
||||
ctx,
|
||||
{
|
||||
id: 'fires',
|
||||
family: 'Hazards',
|
||||
name: 'Active fires (FIRMS)',
|
||||
pollMs: 10 * 60_000,
|
||||
},
|
||||
{
|
||||
feed: feeds.fires,
|
||||
kind: 'fire',
|
||||
glyph: 'fire',
|
||||
windowMs: 3 * 86_400_000,
|
||||
lodHeightM: 1_500_000,
|
||||
cap: 500,
|
||||
select: (d) => {
|
||||
// EONET events keep their last report date; a 3-day window would
|
||||
// hide long-running fires, so events show while open.
|
||||
if (d.mode === 'events')
|
||||
return d.records.map((r) => ({ ...r, t: null }));
|
||||
return d.records;
|
||||
},
|
||||
style: (f) => {
|
||||
const big = (f.frp ?? 0) > 50 || (f.acres ?? 0) > 5000;
|
||||
return {
|
||||
color: color(big ? '#FF5A1F' : '#FF9A3D', 0.95),
|
||||
glyphScale: big ? 0.75 : 0.55,
|
||||
dotScale: big ? 0.8 : 0.5,
|
||||
};
|
||||
},
|
||||
describe: (f, t) => ({
|
||||
title: f.title || 'Fire hotspot',
|
||||
subtitle: [
|
||||
f.acres ? `${Math.round(f.acres).toLocaleString()} acres` : null,
|
||||
f.frp ? `${f.frp.toFixed(0)} MW FRP` : null,
|
||||
Number.isFinite(f.t) ? relTime(f.t, t) : 'open event',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · '),
|
||||
meta: {
|
||||
frpMw: f.frp ?? null,
|
||||
acres: f.acres ?? null,
|
||||
url: f.link || null,
|
||||
},
|
||||
}),
|
||||
},
|
||||
);
|
||||
fires.refresh = async function () {
|
||||
await EventLayer.prototype.refresh.call(this);
|
||||
const d = this.feed.data;
|
||||
this.name = 'Active fires (FIRMS)';
|
||||
if (d?.mode === 'events') {
|
||||
this.message = d.firmsKeyRequired
|
||||
? 'FIRMS key not set on server — showing NASA EONET wildfire events'
|
||||
: 'FIRMS unavailable — showing NASA EONET wildfire events';
|
||||
}
|
||||
};
|
||||
|
||||
const volcanoes = new EventLayer(
|
||||
ctx,
|
||||
{
|
||||
id: 'volcanoes',
|
||||
family: 'Hazards',
|
||||
name: 'Volcanoes',
|
||||
pollMs: 30 * 60_000,
|
||||
},
|
||||
{
|
||||
feed: feeds.eonet,
|
||||
kind: 'volcano',
|
||||
glyph: 'volcano',
|
||||
alwaysGlyph: true,
|
||||
windowMs: Infinity,
|
||||
select: (d) => d.volcanoes.map((v) => ({ ...v, t: null })),
|
||||
style: () => ({ color: color('#FF7A45'), glyphScale: 0.6 }),
|
||||
describe: (v) => ({
|
||||
title: v.title,
|
||||
subtitle: `Active · since ${new Date(v.firstT).toISOString().slice(0, 10)}`,
|
||||
meta: { url: v.link },
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
return [
|
||||
quakes,
|
||||
fires,
|
||||
new CycloneLayer(ctx, { feed: feeds.cyclones, tcm: feeds.tcm }),
|
||||
new FloodLayer(ctx, { feed: feeds.flood }),
|
||||
volcanoes,
|
||||
];
|
||||
}
|
||||
|
||||
/* ───────────────────────── Cyclones ───────────────────────── */
|
||||
|
||||
/** Position of a storm at a clock time: interpolated along the forecast. */
|
||||
export function stormPositionAt(storm, ms) {
|
||||
const f = storm.forecast;
|
||||
if (!f?.length)
|
||||
return { lon: storm.lon, lat: storm.lat, windKt: storm.windKt };
|
||||
const tau = (ms - storm.t) / 3_600_000;
|
||||
if (tau <= 0) return { lon: storm.lon, lat: storm.lat, windKt: storm.windKt };
|
||||
for (let i = 1; i < f.length; i++) {
|
||||
if (tau <= f[i].tauHours) {
|
||||
const a = f[i - 1];
|
||||
const b = f[i];
|
||||
const k = (tau - a.tauHours) / Math.max(1e-6, b.tauHours - a.tauHours);
|
||||
return {
|
||||
lon: a.lon + (b.lon - a.lon) * k,
|
||||
lat: a.lat + (b.lat - a.lat) * k,
|
||||
windKt: a.windKt + (b.windKt - a.windKt) * k,
|
||||
forecast: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
return null; // beyond the forecast
|
||||
}
|
||||
|
||||
const SAFFIR = [
|
||||
[137, 'CAT 5'],
|
||||
[113, 'CAT 4'],
|
||||
[96, 'CAT 3'],
|
||||
[83, 'CAT 2'],
|
||||
[64, 'CAT 1'],
|
||||
[34, 'TS'],
|
||||
[0, 'TD'],
|
||||
];
|
||||
|
||||
/** Saffir-Simpson category (or TS/TD) for a 1-min sustained wind in kt. */
|
||||
export function stormCategory(windKt) {
|
||||
if (!Number.isFinite(windKt)) return '';
|
||||
return SAFFIR.find(([kt]) => windKt >= kt)[1];
|
||||
}
|
||||
|
||||
/** Ring of a quadrant wind-radii shape ([NE, SE, SW, NW] km) around a point. */
|
||||
export function radiiRing(lon, lat, quadrantsKm, stepDeg = 6) {
|
||||
const ring = [];
|
||||
const cosLat = Math.max(0.05, Math.cos((lat * Math.PI) / 180));
|
||||
for (let a = 0; a <= 360; a += stepDeg) {
|
||||
const q = Math.min(3, Math.floor((a % 360) / 90));
|
||||
const r = quadrantsKm[q];
|
||||
const rad = (a * Math.PI) / 180;
|
||||
ring.push([
|
||||
lon + (r * Math.sin(rad)) / (111.32 * cosLat),
|
||||
lat + (r * Math.cos(rad)) / 111.32,
|
||||
]);
|
||||
}
|
||||
return ring;
|
||||
}
|
||||
|
||||
export const RADII_COLORS = { 34: '#FFD84D', 50: '#FF9A3D', 64: '#FF3B30' };
|
||||
|
||||
/** Match an NHC TCM (wind radii) to a storm by position (≤ 250 km). */
|
||||
export function radiiFor(storm, tcm) {
|
||||
let best = null;
|
||||
for (const t of tcm?.storms || []) {
|
||||
const d = Math.hypot(
|
||||
(t.lon - storm.lon) * Math.cos((storm.lat * Math.PI) / 180),
|
||||
t.lat - storm.lat,
|
||||
);
|
||||
if (d < 2.3 && (!best || d < best.d)) best = { d, t };
|
||||
}
|
||||
return best?.t || null;
|
||||
}
|
||||
|
||||
class CycloneLayer extends Layer {
|
||||
constructor(ctx, { feed, tcm }) {
|
||||
super(ctx, {
|
||||
id: 'cyclones',
|
||||
family: 'Hazards',
|
||||
name: 'Tropical cyclones',
|
||||
pollMs: 15 * 60_000,
|
||||
});
|
||||
this.feed = feed;
|
||||
this.tcm = tcm;
|
||||
this.fills = null;
|
||||
this.renderer = new ContactRenderer(this.root, {
|
||||
layerId: this.id,
|
||||
tint: color('#FFFFFF'),
|
||||
glyphName: 'cyclone',
|
||||
rotate: false,
|
||||
alwaysGlyph: true,
|
||||
glyphScale: 1.1,
|
||||
dotScale: 1.3,
|
||||
});
|
||||
this.lines = this.root.add(new Cesium.PolylineCollection());
|
||||
this.records = new Map();
|
||||
}
|
||||
|
||||
async refresh() {
|
||||
try {
|
||||
await this.feed.get();
|
||||
} finally {
|
||||
this.fromFeed(this.feed);
|
||||
}
|
||||
this._draw(true);
|
||||
// Wind radii arrive from a second source; the cone and track never wait.
|
||||
this.tcm
|
||||
?.get()
|
||||
.then(() => this.on && this._fills(this.feed.data?.records || []))
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
/** Filled cone of uncertainty and 34/50/64-kt wind-radii quadrants. */
|
||||
_fills(storms) {
|
||||
if (this.fills) this.root.remove(this.fills);
|
||||
this.fills = null;
|
||||
const instances = [];
|
||||
let relined = false;
|
||||
const poly = (ring, css, alpha, height) => {
|
||||
if (!ring || ring.length < 4) return;
|
||||
instances.push(
|
||||
new Cesium.GeometryInstance({
|
||||
geometry: new Cesium.PolygonGeometry({
|
||||
polygonHierarchy: new Cesium.PolygonHierarchy(
|
||||
Cesium.Cartesian3.fromDegreesArray(ring.flat()),
|
||||
),
|
||||
height,
|
||||
vertexFormat: Cesium.PerInstanceColorAppearance.FLAT_VERTEX_FORMAT,
|
||||
}),
|
||||
attributes: {
|
||||
color: Cesium.ColorGeometryInstanceAttribute.fromColor(
|
||||
color(css, alpha),
|
||||
),
|
||||
},
|
||||
}),
|
||||
);
|
||||
};
|
||||
for (const s of storms) {
|
||||
poly(outerRing(s.cone), '#FFFFFF', 0.1, 1500);
|
||||
const tcm = radiiFor(s, this.tcm?.data);
|
||||
if (tcm) s.radii = tcm.radii;
|
||||
// Cone geometry pending: the advisory text still has the positions.
|
||||
if (tcm?.forecast?.length && !s.forecast?.length) {
|
||||
const t0 = new Date(s.t);
|
||||
s.forecast = tcm.forecast.map((p) => {
|
||||
let at = Date.UTC(
|
||||
t0.getUTCFullYear(),
|
||||
t0.getUTCMonth(),
|
||||
p.day,
|
||||
p.hourZ,
|
||||
);
|
||||
if (at < s.t - 15 * 86_400_000)
|
||||
at = Date.UTC(
|
||||
t0.getUTCFullYear(),
|
||||
t0.getUTCMonth() + 1,
|
||||
p.day,
|
||||
p.hourZ,
|
||||
);
|
||||
return {
|
||||
lon: p.lon,
|
||||
lat: p.lat,
|
||||
windKt: p.windKt,
|
||||
tauHours: Math.round((at - s.t) / 3_600_000),
|
||||
};
|
||||
});
|
||||
relined = true;
|
||||
}
|
||||
for (const r of [...(s.radii || [])].sort((a, b) => a.kt - b.kt)) {
|
||||
if (!r.quadrantsKm.some((k) => k > 0)) continue;
|
||||
poly(
|
||||
radiiRing(s.lon, s.lat, r.quadrantsKm),
|
||||
RADII_COLORS[r.kt] || '#FFD84D',
|
||||
r.kt === 64 ? 0.26 : r.kt === 50 ? 0.18 : 0.12,
|
||||
2500 + r.kt * 20,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (relined) this._lines(storms);
|
||||
if (!instances.length) return;
|
||||
this.fills = this.root.add(
|
||||
new Cesium.Primitive({
|
||||
geometryInstances: instances,
|
||||
appearance: new Cesium.PerInstanceColorAppearance({
|
||||
flat: true,
|
||||
translucent: true,
|
||||
}),
|
||||
asynchronous: true,
|
||||
allowPicking: false,
|
||||
}),
|
||||
);
|
||||
this.ctx.requestRender();
|
||||
}
|
||||
|
||||
_draw(full) {
|
||||
const data = this.feed.data;
|
||||
if (!data) return;
|
||||
const t = this.ctx.timeMs();
|
||||
const items = [];
|
||||
this.records = new Map();
|
||||
for (const s of data.records) {
|
||||
if (t < s.t - 6 * 86_400_000) continue;
|
||||
const p = stormPositionAt(s, t);
|
||||
if (!p) continue;
|
||||
this.records.set(s.id, { ...s, now: p });
|
||||
items.push({
|
||||
key: s.id,
|
||||
lon: p.lon,
|
||||
lat: p.lat,
|
||||
altM: 0,
|
||||
color: color(p.forecast ? '#FFD2C2' : '#FFFFFF', p.forecast ? 0.8 : 1),
|
||||
glyphScale: 0.8 + Math.min(1, (p.windKt || 40) / 140) * 0.7,
|
||||
});
|
||||
}
|
||||
this.count = items.length;
|
||||
this.renderer.setItems(items);
|
||||
if (full) {
|
||||
this._lines(data.records);
|
||||
this._fills(data.records);
|
||||
}
|
||||
}
|
||||
|
||||
_lines(storms) {
|
||||
this.lines.removeAll();
|
||||
const dash = (css, a) =>
|
||||
Cesium.Material.fromType('PolylineDash', {
|
||||
color: color(css, a),
|
||||
dashLength: 14,
|
||||
});
|
||||
for (const s of storms) {
|
||||
if (s.forecast?.length > 1) {
|
||||
this.lines.add({
|
||||
positions: Cesium.Cartesian3.fromDegreesArray(
|
||||
[{ lon: s.lon, lat: s.lat }, ...s.forecast].flatMap((p) => [
|
||||
p.lon,
|
||||
p.lat,
|
||||
]),
|
||||
),
|
||||
width: 2.5,
|
||||
material: dash('#FFFFFF', 0.85),
|
||||
});
|
||||
}
|
||||
const cone = outerRing(s.cone);
|
||||
if (cone) {
|
||||
this.lines.add({
|
||||
positions: Cesium.Cartesian3.fromDegreesArray(cone.flat()),
|
||||
width: 1.5,
|
||||
material: Cesium.Material.fromType('Color', {
|
||||
color: color(HAZ, 0.7),
|
||||
}),
|
||||
});
|
||||
}
|
||||
if (s.pastTrack?.length > 1) {
|
||||
this.lines.add({
|
||||
positions: Cesium.Cartesian3.fromDegreesArray(
|
||||
s.pastTrack.flatMap((p) => [p.lon, p.lat]),
|
||||
),
|
||||
width: 2,
|
||||
material: Cesium.Material.fromType('Color', {
|
||||
color: color('#FFFFFF', 0.6),
|
||||
}),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onView(view) {
|
||||
this.renderer.setView(view);
|
||||
}
|
||||
onTime() {
|
||||
if (this.on) this._draw(false);
|
||||
}
|
||||
locate(key) {
|
||||
const s = this.records.get(key);
|
||||
return s ? [s.now.lon, s.now.lat, 0] : null;
|
||||
}
|
||||
trail(key) {
|
||||
const s = this.records.get(key);
|
||||
if (!s) return [];
|
||||
return (
|
||||
s.pastTrack || [{ lon: s.lon, lat: s.lat }, ...(s.forecast || [])]
|
||||
).map((p) => [p.lon, p.lat, 0]);
|
||||
}
|
||||
pick(key) {
|
||||
const s = this.records.get(key);
|
||||
if (!s) return null;
|
||||
const label =
|
||||
s.label ||
|
||||
{
|
||||
HU: 'Hurricane',
|
||||
TS: 'Tropical storm',
|
||||
TD: 'Tropical depression',
|
||||
TY: 'Typhoon',
|
||||
TC: 'Cyclone',
|
||||
STS: 'Subtropical storm',
|
||||
}[s.classification] ||
|
||||
'Cyclone';
|
||||
const wind = s.now.windKt
|
||||
? `${Math.round(s.now.windKt * 1.852)} km/h`
|
||||
: null;
|
||||
return {
|
||||
kind: 'cyclone',
|
||||
id: s.id,
|
||||
title: `${label} ${s.name}`,
|
||||
subtitle: [
|
||||
wind,
|
||||
s.pressureHpa ? `${s.pressureHpa} hPa` : null,
|
||||
s.now.forecast ? 'forecast' : 'observed',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · '),
|
||||
lon: s.now.lon,
|
||||
lat: s.now.lat,
|
||||
labelDetail: [
|
||||
stormCategory(s.now.windKt),
|
||||
s.now.windKt ? `${Math.round(s.now.windKt)} KT` : null,
|
||||
s.pressureHpa ? `${s.pressureHpa} HPA` : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · '),
|
||||
meta: {
|
||||
layer: this.id,
|
||||
windKt: s.now.windKt ?? null,
|
||||
radii: s.radii ?? null,
|
||||
pressureHpa: s.pressureHpa ?? null,
|
||||
movement: s.movement ?? null,
|
||||
basin: s.basin,
|
||||
url: s.link,
|
||||
source: s.source,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/** Elevation of the corridor vertex nearest a point (0 without a corridor). */
|
||||
function nearestElevation(corridor, lon, lat) {
|
||||
if (!corridor?.length) return 0;
|
||||
let best = corridor[0];
|
||||
let bestD = Infinity;
|
||||
for (const c of corridor) {
|
||||
const d = (c[0] - lon) ** 2 + (c[1] - lat) ** 2;
|
||||
if (d < bestD) {
|
||||
bestD = d;
|
||||
best = c;
|
||||
}
|
||||
}
|
||||
return best[2] || 0;
|
||||
}
|
||||
|
||||
function outerRing(geom) {
|
||||
if (!geom) return null;
|
||||
if (geom.type === 'Polygon') return geom.coordinates[0];
|
||||
if (geom.type === 'MultiPolygon') {
|
||||
let best = null;
|
||||
for (const poly of geom.coordinates)
|
||||
if (!best || poly[0].length > best.length) best = poly[0];
|
||||
return best;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/* ───────────────────────── Bhote Koshi flood ───────────────────────── */
|
||||
|
||||
class FloodLayer extends Layer {
|
||||
constructor(ctx, { feed }) {
|
||||
super(ctx, { id: 'floods', family: 'Hazards', name: 'Floods' });
|
||||
this.feed = feed;
|
||||
this.lines = this.root.add(new Cesium.PolylineCollection());
|
||||
this.renderer = new ContactRenderer(this.root, {
|
||||
layerId: this.id,
|
||||
tint: color(HAZ),
|
||||
glyphName: 'pin',
|
||||
rotate: false,
|
||||
alwaysGlyph: true,
|
||||
glyphScale: 0.62,
|
||||
});
|
||||
this.anchors = new Map();
|
||||
this.overlay = null;
|
||||
}
|
||||
|
||||
async refresh() {
|
||||
const data = await this.feed.get();
|
||||
this.status = 'live';
|
||||
this.source = data.source;
|
||||
const corridor = data.corridor || data.path;
|
||||
this._drawLines(data, null);
|
||||
this._sampleTerrain(data);
|
||||
const ev = data.event;
|
||||
const items = [];
|
||||
this.anchors = new Map();
|
||||
const head = corridor[0];
|
||||
this.anchors.set('bhote-koshi', {
|
||||
id: 'bhote-koshi',
|
||||
title: ev?.title || 'Bhote Koshi outburst flood',
|
||||
subtitle: `Observed ${ev?.observedDate || '2026-08-26'} · reconstruction`,
|
||||
lon: head[0],
|
||||
lat: head[1],
|
||||
meta: { caveat: ev?.reconstruction?.caveat || null },
|
||||
});
|
||||
for (const a of ev?.witnessAnchors || []) {
|
||||
this.anchors.set(a.id, {
|
||||
id: a.id,
|
||||
title: a.title,
|
||||
subtitle: (a.details || []).join(' · '),
|
||||
lon: a.lon,
|
||||
lat: a.lat,
|
||||
meta: { url: a.sourceUrl || null },
|
||||
});
|
||||
}
|
||||
for (const a of this.anchors.values())
|
||||
items.push({
|
||||
key: a.id,
|
||||
lon: a.lon,
|
||||
lat: a.lat,
|
||||
altM: nearestElevation(data.corridor, a.lon, a.lat) + 30,
|
||||
glyphScale: a.id === 'bhote-koshi' ? 1.2 : 0.8,
|
||||
});
|
||||
this.renderer.setItems(items);
|
||||
this.count = 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* The corridor carries surveyed elevations; the longer GeoPera centreline
|
||||
* has none, so it takes terrain heights once terrain is available (until
|
||||
* then, the nearest corridor elevation). Lines sit 20 m above ground so
|
||||
* they neither sink into terrain nor float in parallax.
|
||||
*/
|
||||
_drawLines(data, pathHeights) {
|
||||
this.lines.removeAll();
|
||||
const glow = (css, power, alpha) =>
|
||||
Cesium.Material.fromType('PolylineGlow', {
|
||||
color: color(css, alpha),
|
||||
glowPower: power,
|
||||
taperPower: 1,
|
||||
});
|
||||
const pathPositions = data.path.flatMap(([lon, lat], i) => [
|
||||
lon,
|
||||
lat,
|
||||
(pathHeights?.[i] ?? nearestElevation(data.corridor, lon, lat)) + 20,
|
||||
]);
|
||||
this.lines.add({
|
||||
positions: Cesium.Cartesian3.fromDegreesArrayHeights(pathPositions),
|
||||
width: 5,
|
||||
material: glow('#5EC8FF', 0.16, 0.85),
|
||||
});
|
||||
if (data.corridor)
|
||||
this.lines.add({
|
||||
positions: Cesium.Cartesian3.fromDegreesArrayHeights(
|
||||
data.corridor.flatMap(([lon, lat, h]) => [lon, lat, (h || 0) + 20]),
|
||||
),
|
||||
width: 7,
|
||||
material: glow('#A6ECFF', 0.14, 0.9),
|
||||
});
|
||||
this.ctx.requestRender();
|
||||
}
|
||||
|
||||
async _sampleTerrain(data) {
|
||||
if (this._sampled) return;
|
||||
this._sampled = true;
|
||||
const viewer = this.ctx.viewer;
|
||||
// Terrain arrives asynchronously after boot.
|
||||
for (
|
||||
let i = 0;
|
||||
i < 20 &&
|
||||
!(viewer.terrainProvider instanceof Cesium.CesiumTerrainProvider);
|
||||
i++
|
||||
)
|
||||
await new Promise((r) => setTimeout(r, 1000));
|
||||
if (!(viewer.terrainProvider instanceof Cesium.CesiumTerrainProvider))
|
||||
return;
|
||||
try {
|
||||
const cartos = data.path.map(([lon, lat]) =>
|
||||
Cesium.Cartographic.fromDegrees(lon, lat),
|
||||
);
|
||||
const sampled = await Cesium.sampleTerrainMostDetailed(
|
||||
viewer.terrainProvider,
|
||||
cartos,
|
||||
);
|
||||
if (viewer.isDestroyed()) return;
|
||||
this._drawLines(
|
||||
data,
|
||||
sampled.map((c) => (Number.isFinite(c.height) ? c.height : null)),
|
||||
);
|
||||
} catch (error) {
|
||||
console.warn('[globe:floods] terrain sample', error);
|
||||
}
|
||||
}
|
||||
|
||||
/** The 1 MB post-event image loads only once the camera is close. */
|
||||
_overlay() {
|
||||
const ev = this.feed.data?.event;
|
||||
const rect = ev?.imagery?.rectangle;
|
||||
const path = ev?.imagery?.after?.path;
|
||||
if (!rect || !path || this.overlay) return;
|
||||
const provider = Cesium.SingleTileImageryProvider.fromUrl(
|
||||
`/events/bhote-koshi-2026/${path}`,
|
||||
{
|
||||
rectangle: Cesium.Rectangle.fromDegrees(
|
||||
rect.west,
|
||||
rect.south,
|
||||
rect.east,
|
||||
rect.north,
|
||||
),
|
||||
credit: ev.imagery.after.source,
|
||||
},
|
||||
);
|
||||
this.overlay = Cesium.ImageryLayer.fromProviderAsync(provider, {
|
||||
alpha: 0.95,
|
||||
});
|
||||
this.overlay.show = this.on;
|
||||
this.ctx.viewer.imageryLayers.add(this.overlay);
|
||||
}
|
||||
|
||||
setOn(on) {
|
||||
super.setOn(on);
|
||||
if (this.overlay) this.overlay.show = Boolean(on);
|
||||
}
|
||||
|
||||
onView(view) {
|
||||
this.renderer.setView(view);
|
||||
if (this.on && view.heightM < 400_000) this._overlay();
|
||||
}
|
||||
locate(key) {
|
||||
const a = this.anchors.get(key);
|
||||
return a ? [a.lon, a.lat, 0] : null;
|
||||
}
|
||||
trail() {
|
||||
return this.feed.data?.path?.map(([lon, lat]) => [lon, lat, 0]) || [];
|
||||
}
|
||||
pick(key) {
|
||||
const a = this.anchors.get(key);
|
||||
if (!a) return null;
|
||||
return {
|
||||
kind: 'flood',
|
||||
...a,
|
||||
meta: { layer: this.id, source: this.source, ...a.meta },
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,435 @@
|
||||
/**
|
||||
* Orbit family: satellites (CelesTrak TLE + SGP4), ISS & crewed stations with
|
||||
* a live orbit ribbon, Starlink, and launches. Positions follow the scene
|
||||
* clock, so scrubbing the time ribbon flies every satellite to that instant.
|
||||
*/
|
||||
import * as Cesium from 'cesium';
|
||||
import {
|
||||
twoline2satrec,
|
||||
propagate,
|
||||
gstime,
|
||||
eciToGeodetic,
|
||||
degreesLat,
|
||||
degreesLong,
|
||||
} from 'satellite.js';
|
||||
import { ContactRenderer, Layer, color, FAMILY_COLORS } from './base.js';
|
||||
import { relTime } from '../geo.js';
|
||||
|
||||
const ORBIT = FAMILY_COLORS.Orbit;
|
||||
const CREWED = new Set([25544, 48274]); // ISS (ZARYA), CSS (TIANHE)
|
||||
|
||||
/** Sub-satellite point and height for a satrec at a time, or null. */
|
||||
export function satPosition(satrec, ms) {
|
||||
const date = new Date(ms);
|
||||
let pv;
|
||||
try {
|
||||
pv = propagate(satrec, date);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const p = pv?.position;
|
||||
if (!p || typeof p === 'boolean' || !Number.isFinite(p.x)) return null;
|
||||
const g = eciToGeodetic(p, gstime(date));
|
||||
const v = pv.velocity;
|
||||
return {
|
||||
lon: degreesLong(g.longitude),
|
||||
lat: degreesLat(g.latitude),
|
||||
altM: g.height * 1000,
|
||||
speedMps: v ? Math.hypot(v.x, v.y, v.z) * 1000 : null,
|
||||
};
|
||||
}
|
||||
|
||||
/** Ground-track heading (deg) from two nearby samples. */
|
||||
function trackHeading(satrec, ms) {
|
||||
const a = satPosition(satrec, ms);
|
||||
const b = satPosition(satrec, ms + 10_000);
|
||||
if (!a || !b) return 0;
|
||||
const y =
|
||||
Math.sin(((b.lon - a.lon) * Math.PI) / 180) *
|
||||
Math.cos((b.lat * Math.PI) / 180);
|
||||
const x =
|
||||
Math.cos((a.lat * Math.PI) / 180) * Math.sin((b.lat * Math.PI) / 180) -
|
||||
Math.sin((a.lat * Math.PI) / 180) *
|
||||
Math.cos((b.lat * Math.PI) / 180) *
|
||||
Math.cos(((b.lon - a.lon) * Math.PI) / 180);
|
||||
return ((Math.atan2(y, x) * 180) / Math.PI + 360) % 360;
|
||||
}
|
||||
|
||||
export class SatelliteLayer extends Layer {
|
||||
constructor(ctx, def, opts) {
|
||||
super(ctx, { pollMs: 3 * 3600_000, ...def });
|
||||
this.feeds = opts.feeds;
|
||||
this.filter = opts.filter || (() => true);
|
||||
this.kind = opts.kind || 'satellite';
|
||||
this.slice = opts.slice ?? Infinity;
|
||||
this.orbitRibbon = Boolean(opts.orbitRibbon);
|
||||
this.renderer = new ContactRenderer(this.root, {
|
||||
layerId: this.id,
|
||||
tint: color(opts.tint || ORBIT),
|
||||
glyphName: opts.glyph || 'sat',
|
||||
lodHeightM: opts.lodHeightM ?? 9_000_000,
|
||||
cap: opts.cap ?? 250,
|
||||
rotate: false,
|
||||
dotScale: opts.dotScale ?? 0.32,
|
||||
glyphScale: opts.glyphScale ?? 0.42,
|
||||
alwaysGlyph: opts.alwaysGlyph,
|
||||
});
|
||||
this.sats = []; // { key, name, satrec, norad }
|
||||
this.byKey = new Map();
|
||||
this._rr = 0;
|
||||
this._lastMs = 0;
|
||||
if (this.orbitRibbon) {
|
||||
this.ribbons = this.root.add(new Cesium.PolylineCollection());
|
||||
}
|
||||
}
|
||||
|
||||
async refresh() {
|
||||
const results = await Promise.allSettled(this.feeds.map((f) => f.get()));
|
||||
const seen = new Set();
|
||||
const sats = [];
|
||||
let ok = false;
|
||||
this.feeds.forEach((feed, i) => {
|
||||
this.fromFeed(feed);
|
||||
if (results[i].status !== 'fulfilled') return;
|
||||
ok = true;
|
||||
for (const e of results[i].value.entries) {
|
||||
const norad = Number(e.line1.slice(2, 7));
|
||||
const key = String(e.line1.slice(2, 7).trim());
|
||||
if (seen.has(key) || !this.filter(e, norad)) continue;
|
||||
seen.add(key);
|
||||
let satrec;
|
||||
try {
|
||||
satrec = twoline2satrec(e.line1, e.line2);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
sats.push({
|
||||
key,
|
||||
name: e.name.replace(/^0 /, '').trim(),
|
||||
satrec,
|
||||
norad,
|
||||
});
|
||||
}
|
||||
});
|
||||
if (!ok) throw results[0].reason;
|
||||
this.sats = sats;
|
||||
this.byKey = new Map(sats.map((s) => [s.key, s]));
|
||||
this._place(this.ctx.timeMs(), true);
|
||||
}
|
||||
|
||||
_place(ms, full) {
|
||||
const items = [];
|
||||
const keep = [];
|
||||
for (const s of this.sats) {
|
||||
const p = satPosition(s.satrec, ms);
|
||||
if (!p) continue;
|
||||
s.pos = p;
|
||||
keep.push(s);
|
||||
const crewed = CREWED.has(s.norad);
|
||||
items.push({
|
||||
key: s.key,
|
||||
lon: p.lon,
|
||||
lat: p.lat,
|
||||
altM: p.altM,
|
||||
glyph: s.norad === 25544 ? 'iss' : undefined,
|
||||
glyphScale: s.norad === 25544 ? 1.6 : crewed ? 1.2 : 1,
|
||||
dotScale: crewed ? 2.2 : 1,
|
||||
color: crewed ? color('#FFFFFF') : undefined,
|
||||
});
|
||||
}
|
||||
this.count = keep.length;
|
||||
if (full) this.renderer.setItems(items);
|
||||
this._lastMs = ms;
|
||||
this._drawRibbons(ms);
|
||||
}
|
||||
|
||||
_drawRibbons(ms) {
|
||||
if (!this.ribbons) return;
|
||||
this.ribbons.removeAll();
|
||||
for (const s of this.sats) {
|
||||
if (!CREWED.has(s.norad)) continue;
|
||||
const positions = [];
|
||||
// One orbit ahead, 30 min behind, sampled every 60 s.
|
||||
for (let t = -30; t <= 93; t++) {
|
||||
const p = satPosition(s.satrec, ms + t * 60_000);
|
||||
if (p)
|
||||
positions.push(Cesium.Cartesian3.fromDegrees(p.lon, p.lat, p.altM));
|
||||
}
|
||||
if (positions.length < 2) continue;
|
||||
this.ribbons.add({
|
||||
positions,
|
||||
width: s.norad === 25544 ? 3 : 2,
|
||||
material: Cesium.Material.fromType('PolylineGlow', {
|
||||
color: color(s.norad === 25544 ? '#E9DDFF' : ORBIT, 0.32),
|
||||
glowPower: 0.18,
|
||||
taperPower: 1,
|
||||
}),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** Propagate to `ms`: the glyph-visible satellites plus a round-robin slice. */
|
||||
_advance(ms) {
|
||||
const items = this.renderer.items;
|
||||
if (!items.length) return false;
|
||||
const n = items.length;
|
||||
const slice = Math.min(n, this.slice);
|
||||
const from = this._rr % n;
|
||||
const to = Math.min(n, from + slice);
|
||||
for (let i = from; i < to; i++) this._updateItem(items[i], ms);
|
||||
for (let i = 0; i < this.renderer.glyphs.length; i++) {
|
||||
const it = this.renderer.byKey.get(this.renderer.glyphs.get(i).id.key);
|
||||
if (it) this._updateItem(it, ms);
|
||||
}
|
||||
this.renderer.moveItems(from, to);
|
||||
this._rr = to >= n ? 0 : to;
|
||||
return true;
|
||||
}
|
||||
|
||||
_updateItem(it, ms) {
|
||||
const s = this.byKey.get(it.key);
|
||||
const p = s && satPosition(s.satrec, ms);
|
||||
if (!p) return;
|
||||
s.pos = p;
|
||||
it.lon = p.lon;
|
||||
it.lat = p.lat;
|
||||
it.altM = p.altM;
|
||||
}
|
||||
|
||||
onView(view) {
|
||||
this.renderer.setView(view);
|
||||
}
|
||||
|
||||
onTime(ms) {
|
||||
if (!this.sats.length) return;
|
||||
if (this.sats.length <= 3000) {
|
||||
for (const it of this.renderer.items) this._updateItem(it, ms);
|
||||
this.renderer.moveItems();
|
||||
} else this._advance(ms);
|
||||
this._lastMs = ms;
|
||||
this._drawRibbons(ms);
|
||||
}
|
||||
|
||||
onTick(nowMs, live, clockMs) {
|
||||
if (!this.sats.length) return false;
|
||||
if (Math.abs(clockMs - this._lastMs) < 900) return false;
|
||||
this._advance(clockMs);
|
||||
if (this.ribbons && Math.abs(clockMs - (this._ribbonMs || 0)) > 60_000) {
|
||||
this._ribbonMs = clockMs;
|
||||
this._drawRibbons(clockMs);
|
||||
}
|
||||
this._lastMs = clockMs;
|
||||
return true;
|
||||
}
|
||||
|
||||
locate(key) {
|
||||
const s = this.byKey.get(key);
|
||||
const p = s && satPosition(s.satrec, this.ctx.timeMs());
|
||||
return p ? [p.lon, p.lat, p.altM] : null;
|
||||
}
|
||||
|
||||
trail(key) {
|
||||
const s = this.byKey.get(key);
|
||||
if (!s) return [];
|
||||
const ms = this.ctx.timeMs();
|
||||
const out = [];
|
||||
for (let t = -45; t <= 45; t += 1) {
|
||||
const p = satPosition(s.satrec, ms + t * 60_000);
|
||||
if (p) out.push([p.lon, p.lat, p.altM]);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
satrecFor(norad) {
|
||||
return this.sats.find((s) => s.norad === norad)?.satrec || null;
|
||||
}
|
||||
|
||||
pick(key) {
|
||||
const s = this.byKey.get(key);
|
||||
if (!s) return null;
|
||||
const p = satPosition(s.satrec, this.ctx.timeMs());
|
||||
if (!p) return null;
|
||||
return {
|
||||
kind: this.kind,
|
||||
id: s.key,
|
||||
title: s.name,
|
||||
subtitle: `NORAD ${s.norad} · ${Math.round(p.altM / 1000)} km · ${(p.speedMps / 1000).toFixed(1)} km/s`,
|
||||
lon: p.lon,
|
||||
lat: p.lat,
|
||||
meta: {
|
||||
layer: this.id,
|
||||
norad: s.norad,
|
||||
altitudeM: p.altM,
|
||||
speedMps: p.speedMps,
|
||||
headingDeg: trackHeading(s.satrec, this.ctx.timeMs()),
|
||||
source: this.source,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
contacts() {
|
||||
const ms = this.ctx.timeMs();
|
||||
return this.sats
|
||||
.filter((s) => s.pos)
|
||||
.map((s) => ({
|
||||
kind: this.kind,
|
||||
id: s.key,
|
||||
title: s.name,
|
||||
lon: s.pos.lon,
|
||||
lat: s.pos.lat,
|
||||
altM: s.pos.altM,
|
||||
headingDeg: CREWED.has(s.norad) ? trackHeading(s.satrec, ms) : null,
|
||||
layer: this.id,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
/** Per pad: the next pending launch beats any past one; else the latest. */
|
||||
function betterLaunch(a, b, t) {
|
||||
const ap = a.net >= t;
|
||||
const bp = b.net >= t;
|
||||
if (ap !== bp) return ap;
|
||||
return ap ? a.net < b.net : a.net > b.net;
|
||||
}
|
||||
|
||||
export class LaunchLayer extends Layer {
|
||||
constructor(ctx, def, { feed }) {
|
||||
super(ctx, { pollMs: 30 * 60_000, ...def });
|
||||
this.feed = feed;
|
||||
this.renderer = new ContactRenderer(this.root, {
|
||||
layerId: this.id,
|
||||
tint: color(ORBIT),
|
||||
glyphName: 'rocket',
|
||||
rotate: false,
|
||||
alwaysGlyph: true,
|
||||
glyphScale: 0.62,
|
||||
dotScale: 0.8,
|
||||
});
|
||||
this.records = new Map();
|
||||
}
|
||||
|
||||
async refresh() {
|
||||
try {
|
||||
await this.feed.get();
|
||||
} finally {
|
||||
this.fromFeed(this.feed);
|
||||
}
|
||||
this._draw();
|
||||
}
|
||||
|
||||
_draw() {
|
||||
const data = this.feed.data;
|
||||
if (!data) return;
|
||||
const t = this.ctx.timeMs();
|
||||
const items = [];
|
||||
this.records = new Map();
|
||||
// One marker per pad: the next launch from it, or its most recent one.
|
||||
const byPad = new Map();
|
||||
for (const r of data.records) {
|
||||
const key = `${r.lat.toFixed(3)},${r.lon.toFixed(3)}`;
|
||||
const cur = byPad.get(key);
|
||||
if (!cur || betterLaunch(r, cur, t)) byPad.set(key, r);
|
||||
}
|
||||
for (const r of byPad.values()) {
|
||||
this.records.set(r.id, r);
|
||||
const pending = r.net > t;
|
||||
const soon = pending && r.net - t < 7 * 86_400_000;
|
||||
items.push({
|
||||
key: r.id,
|
||||
lon: r.lon,
|
||||
lat: r.lat,
|
||||
altM: 0,
|
||||
color: pending
|
||||
? color(soon ? '#FFFFFF' : ORBIT, soon ? 1 : 0.85)
|
||||
: color(ORBIT, 0.45),
|
||||
glyphScale: soon ? 1.25 : 0.9,
|
||||
});
|
||||
}
|
||||
this.count = data.records.filter((r) => r.net > t).length;
|
||||
this.renderer.setItems(items);
|
||||
}
|
||||
|
||||
onView(view) {
|
||||
this.renderer.setView(view);
|
||||
}
|
||||
|
||||
onTime() {
|
||||
this._draw();
|
||||
}
|
||||
|
||||
locate(key) {
|
||||
const r = this.records.get(key);
|
||||
return r ? [r.lon, r.lat, 0] : null;
|
||||
}
|
||||
|
||||
pick(key) {
|
||||
const r = this.records.get(key);
|
||||
if (!r) return null;
|
||||
const t = this.ctx.timeMs();
|
||||
return {
|
||||
kind: 'launch',
|
||||
id: r.id,
|
||||
title: r.name,
|
||||
subtitle: `${r.provider || r.pad} · ${r.net > t ? 'T−' : ''}${relTime(r.net, t)}${r.status ? ` · ${r.status}` : ''}`,
|
||||
lon: r.lon,
|
||||
lat: r.lat,
|
||||
meta: {
|
||||
layer: this.id,
|
||||
net: new Date(r.net).toISOString(),
|
||||
pad: r.pad,
|
||||
location: r.location,
|
||||
mission: r.mission,
|
||||
orbit: r.orbit,
|
||||
image: r.image,
|
||||
status: r.status,
|
||||
source: this.source,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function createOrbitLayers(
|
||||
ctx,
|
||||
{ visual, stations, gps, starlink, launches },
|
||||
) {
|
||||
return [
|
||||
new SatelliteLayer(
|
||||
ctx,
|
||||
{ id: 'satellites', family: 'Orbit', name: 'Satellites (TLE)' },
|
||||
{
|
||||
feeds: [visual, gps],
|
||||
filter: (e, norad) => !CREWED.has(norad),
|
||||
dotScale: 0.34,
|
||||
},
|
||||
),
|
||||
new SatelliteLayer(
|
||||
ctx,
|
||||
{ id: 'iss', family: 'Orbit', name: 'ISS & crewed' },
|
||||
{
|
||||
feeds: [stations],
|
||||
filter: (e, norad) => CREWED.has(norad),
|
||||
kind: 'station',
|
||||
alwaysGlyph: true,
|
||||
orbitRibbon: true,
|
||||
glyph: 'sat',
|
||||
},
|
||||
),
|
||||
new SatelliteLayer(
|
||||
ctx,
|
||||
{ id: 'starlink', family: 'Orbit', name: 'Starlink trains' },
|
||||
{
|
||||
feeds: [starlink],
|
||||
slice: 1500,
|
||||
dotScale: 0.22,
|
||||
tint: '#B9A2F0',
|
||||
cap: 200,
|
||||
lodHeightM: 3_000_000,
|
||||
},
|
||||
),
|
||||
new LaunchLayer(
|
||||
ctx,
|
||||
{ id: 'launches', family: 'Orbit', name: 'Launches & trajectories' },
|
||||
{ feed: launches },
|
||||
),
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
/**
|
||||
* Sea family (AIS vessels, tankers/cargo, ports) and Eyes family (public
|
||||
* CCTV). AIS needs the server's AISSTREAM_API_KEY; without it the layers say
|
||||
* so instead of pretending the ocean is empty.
|
||||
*/
|
||||
import { ContactRenderer, Layer, color, FAMILY_COLORS } from './base.js';
|
||||
import { PORTS } from '../places.js';
|
||||
import { cctvFrameUrl } from '../feeds.js';
|
||||
|
||||
const SEA = FAMILY_COLORS.Sea;
|
||||
const EYES = FAMILY_COLORS.Eyes;
|
||||
const MPS_TO_KT = 1 / 0.514444;
|
||||
|
||||
class VesselLayer extends Layer {
|
||||
constructor(ctx, def, { feed, filter, tint }) {
|
||||
super(ctx, { pollMs: 20_000, ...def });
|
||||
this.feed = feed;
|
||||
this.filter = filter;
|
||||
this.renderer = new ContactRenderer(this.root, {
|
||||
layerId: this.id,
|
||||
tint: color(tint),
|
||||
glyphName: 'ship',
|
||||
lodHeightM: 1_200_000,
|
||||
cap: 800,
|
||||
deadReckon: true,
|
||||
dotScale: 0.34,
|
||||
glyphScale: 0.42,
|
||||
});
|
||||
this.records = new Map();
|
||||
}
|
||||
|
||||
async refresh() {
|
||||
try {
|
||||
await this.feed.get({ maxAgeMs: 15_000 });
|
||||
} catch (error) {
|
||||
this.fromFeed(this.feed);
|
||||
if (error.keyRequired) {
|
||||
this.status = 'error';
|
||||
this.message = error.message;
|
||||
this.count = 0;
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
this.fromFeed(this.feed);
|
||||
const records = this.feed.data.records.filter(this.filter);
|
||||
this.records = new Map(records.map((r) => [r.id, r]));
|
||||
this.count = records.length;
|
||||
const now = Date.now();
|
||||
this.renderer.setItems(
|
||||
records.map((r) => ({
|
||||
key: r.id,
|
||||
lon: r.lon,
|
||||
lat: r.lat,
|
||||
heading: r.heading,
|
||||
speed: r.speed,
|
||||
seenAt: now,
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
onView(view) {
|
||||
this.renderer.setView(view);
|
||||
}
|
||||
onTime(ms, live) {
|
||||
this.renderer.setAlpha(live ? 1 : 0.3);
|
||||
}
|
||||
onTick(nowMs, live) {
|
||||
return live && this.renderer.tick(nowMs);
|
||||
}
|
||||
locate(key) {
|
||||
const it = this.renderer.byKey.get(key);
|
||||
if (!it) return null;
|
||||
const [lon, lat] = this.renderer.where(it);
|
||||
return [lon, lat, 0];
|
||||
}
|
||||
pick(key) {
|
||||
const r = this.records.get(key);
|
||||
if (!r) return null;
|
||||
const [lon, lat] = this.locate(key);
|
||||
return {
|
||||
kind: 'vessel',
|
||||
id: r.id,
|
||||
title: r.name,
|
||||
subtitle: [
|
||||
r.type || 'Vessel',
|
||||
Number.isFinite(r.speed)
|
||||
? `${(r.speed * MPS_TO_KT).toFixed(1)} kn`
|
||||
: null,
|
||||
r.destination ? `→ ${r.destination}` : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · '),
|
||||
lon,
|
||||
lat,
|
||||
meta: {
|
||||
layer: this.id,
|
||||
mmsi: r.id,
|
||||
type: r.type,
|
||||
speedMps: r.speed,
|
||||
headingDeg: r.heading,
|
||||
destination: r.destination,
|
||||
source: this.source,
|
||||
},
|
||||
};
|
||||
}
|
||||
contacts() {
|
||||
return [...this.records.values()].map((r) => {
|
||||
const [lon, lat] = this.locate(r.id) || [r.lon, r.lat];
|
||||
return {
|
||||
kind: 'vessel',
|
||||
id: r.id,
|
||||
title: r.name,
|
||||
lon,
|
||||
lat,
|
||||
altM: 0,
|
||||
headingDeg: r.heading,
|
||||
speedMps: r.speed,
|
||||
layer: this.id,
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
class PortsLayer extends Layer {
|
||||
constructor(ctx) {
|
||||
super(ctx, { id: 'ports', family: 'Sea', name: 'Ports' });
|
||||
this.source = 'Bundled reference (2024 throughput, M TEU)';
|
||||
this.renderer = new ContactRenderer(this.root, {
|
||||
layerId: this.id,
|
||||
tint: color(SEA),
|
||||
glyphName: 'port',
|
||||
rotate: false,
|
||||
alwaysGlyph: true,
|
||||
glyphScale: 0.5,
|
||||
});
|
||||
this.records = new Map(PORTS.map((p) => [p.id, p]));
|
||||
}
|
||||
async refresh() {
|
||||
this.status = 'live';
|
||||
this.count = PORTS.length;
|
||||
this.renderer.setItems(
|
||||
PORTS.map((p) => ({
|
||||
key: p.id,
|
||||
lon: p.lon,
|
||||
lat: p.lat,
|
||||
glyphScale: 0.7 + Math.min(1, p.teu / 40) * 0.6,
|
||||
})),
|
||||
);
|
||||
}
|
||||
onView(view) {
|
||||
this.renderer.setView(view);
|
||||
}
|
||||
locate(key) {
|
||||
const p = this.records.get(key);
|
||||
return p ? [p.lon, p.lat, 0] : null;
|
||||
}
|
||||
pick(key) {
|
||||
const p = this.records.get(key);
|
||||
if (!p) return null;
|
||||
return {
|
||||
kind: 'port',
|
||||
id: p.id,
|
||||
title: p.name,
|
||||
subtitle: `${p.teu.toFixed(1)} M TEU / year`,
|
||||
lon: p.lon,
|
||||
lat: p.lat,
|
||||
meta: { layer: this.id, teu: p.teu, source: this.source },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
class CctvLayer extends Layer {
|
||||
constructor(ctx, { feed }) {
|
||||
super(ctx, {
|
||||
id: 'cctv',
|
||||
family: 'Eyes',
|
||||
name: 'Public CCTV',
|
||||
pollMs: 30 * 60_000,
|
||||
});
|
||||
this.feed = feed;
|
||||
this.renderer = new ContactRenderer(this.root, {
|
||||
layerId: this.id,
|
||||
tint: color(EYES),
|
||||
glyphName: 'camera',
|
||||
lodHeightM: 250_000,
|
||||
cap: 250,
|
||||
dotScale: 0.3,
|
||||
glyphScale: 0.45,
|
||||
});
|
||||
this.records = new Map();
|
||||
}
|
||||
async refresh() {
|
||||
try {
|
||||
await this.feed.get();
|
||||
} finally {
|
||||
this.fromFeed(this.feed);
|
||||
}
|
||||
const records = this.feed.data.records;
|
||||
this.records = new Map(records.map((r) => [r.id, r]));
|
||||
this.count = records.length;
|
||||
this.renderer.setItems(
|
||||
records.map((r) => ({
|
||||
key: r.id,
|
||||
lon: r.lon,
|
||||
lat: r.lat,
|
||||
altM: 0,
|
||||
heading: r.heading,
|
||||
})),
|
||||
);
|
||||
}
|
||||
onView(view) {
|
||||
this.renderer.setView(view);
|
||||
}
|
||||
locate(key) {
|
||||
const r = this.records.get(key);
|
||||
return r ? [r.lon, r.lat, 0] : null;
|
||||
}
|
||||
pick(key) {
|
||||
const r = this.records.get(key);
|
||||
if (!r) return null;
|
||||
return {
|
||||
kind: 'camera',
|
||||
id: r.id,
|
||||
title: r.name,
|
||||
subtitle: [r.city, r.provider].filter(Boolean).join(' · '),
|
||||
lon: r.lon,
|
||||
lat: r.lat,
|
||||
meta: {
|
||||
layer: this.id,
|
||||
frameUrl: cctvFrameUrl(r),
|
||||
headingDeg: r.heading,
|
||||
fovDeg: r.fovDeg,
|
||||
feedType: r.feedType,
|
||||
provider: r.provider,
|
||||
source: this.source,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const TANKER = /tanker|cargo|container|bulk/i;
|
||||
|
||||
export function createSeaLayers(ctx, { vessels }) {
|
||||
return [
|
||||
new VesselLayer(
|
||||
ctx,
|
||||
{ id: 'vessels', family: 'Sea', name: 'AIS vessels' },
|
||||
{ feed: vessels, filter: () => true, tint: SEA },
|
||||
),
|
||||
new PortsLayer(ctx),
|
||||
new VesselLayer(
|
||||
ctx,
|
||||
{ id: 'tankers', family: 'Sea', name: 'Tankers / cargo' },
|
||||
{ feed: vessels, filter: (r) => TANKER.test(r.type), tint: '#7FF0DC' },
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
export function createEyesLayers(ctx, { cctv }) {
|
||||
return [new CctvLayer(ctx, { feed: cctv })];
|
||||
}
|
||||
@@ -0,0 +1,906 @@
|
||||
/**
|
||||
* Weather family (radar, satellite IR clouds, lightning, surface wind, jet
|
||||
* stream) plus the round-two Ground/Infra/Hazards layers that share the same
|
||||
* plumbing (transit vehicles, GNSS interference, air quality).
|
||||
*
|
||||
* Rasters come from NOAA nowCOAST through the app's /api/weather proxy as
|
||||
* whole-extent frames and are drawn as one unlit, translucent rectangle each
|
||||
* (a few kilometres above the ground, where the weather is), so the night
|
||||
* side does not swallow them the way lit imagery layers would. Scrubbing the
|
||||
* time ribbon swaps to the frame valid at that time (radar ≈ 50 min of 4-min
|
||||
* frames, lightning 3 h of 15-min frames, IR 6 h of hourly frames). Wind uses
|
||||
* the desktop app's GPU streamline renderer (src/layers/wind/gpuRendering.js).
|
||||
*/
|
||||
import * as Cesium from 'cesium';
|
||||
import {
|
||||
ContactRenderer,
|
||||
Layer,
|
||||
color,
|
||||
FAMILY_COLORS,
|
||||
inRect,
|
||||
} from './base.js';
|
||||
import { createWindGpuRendering } from '../../../src/layers/wind/gpuRendering.js';
|
||||
import {
|
||||
aqiColor,
|
||||
frameAt,
|
||||
gnssIntegrity,
|
||||
GNSS_ANCHORS,
|
||||
transitVehicles,
|
||||
weatherImageUrl,
|
||||
} from '../feedsWeather.js';
|
||||
import { haversineKm, relTime } from '../geo.js';
|
||||
|
||||
const WX = FAMILY_COLORS.Weather;
|
||||
|
||||
/* ───────────────────────── Frame decoding (shared) ───────────────────────── */
|
||||
|
||||
const frameCache = new Map(); // url → Promise<canvas>
|
||||
const FRAME_CACHE_MAX = 8;
|
||||
|
||||
/** Decode a same-origin weather frame into a canvas (IR gets an alpha key). */
|
||||
async function decodeFrame(url, product) {
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) throw new Error(`Weather frame HTTP ${response.status}`);
|
||||
const bitmap = await createImageBitmap(await response.blob());
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = bitmap.width;
|
||||
canvas.height = bitmap.height;
|
||||
const g = canvas.getContext('2d', { willReadFrequently: true });
|
||||
g.drawImage(bitmap, 0, 0);
|
||||
bitmap.close?.();
|
||||
if (product === 'clouds' || product === 'clouds-regional') {
|
||||
// Key the IR to clouds: warm surfaces clear, cloud tops white with a
|
||||
// soft ramp (the repo's cloud key is tuned for a lit globe; this one
|
||||
// also has to read on the unlit night side).
|
||||
const px = g.getImageData(0, 0, canvas.width, canvas.height);
|
||||
const d = px.data;
|
||||
for (let i = 0; i < d.length; i += 4) {
|
||||
const l = d[i];
|
||||
const t = Math.max(0, Math.min(1, (l - 95) / 95));
|
||||
const a = t * t * (3 - 2 * t);
|
||||
d[i + 3] = (d[i + 3] * a * 215) / 255;
|
||||
// Unlit clouds must not blow out under bloom: grey-white body,
|
||||
// coldest tops leaning icy blue.
|
||||
const cold = Math.max(0, (l - 200) / 55);
|
||||
d[i] = 168 + 42 * a - 22 * cold;
|
||||
d[i + 1] = 176 + 40 * a - 6 * cold;
|
||||
d[i + 2] = 192 + 36 * a + 10 * cold;
|
||||
}
|
||||
g.putImageData(px, 0, 0);
|
||||
}
|
||||
return canvas;
|
||||
}
|
||||
|
||||
function cached(url, make, keep) {
|
||||
let p = frameCache.get(url);
|
||||
if (p) {
|
||||
frameCache.delete(url);
|
||||
frameCache.set(url, p);
|
||||
return p;
|
||||
}
|
||||
p = make();
|
||||
p.catch(() => frameCache.delete(url));
|
||||
if (!keep) return p;
|
||||
frameCache.set(url, p);
|
||||
while (frameCache.size > FRAME_CACHE_MAX)
|
||||
frameCache.delete(frameCache.keys().next().value);
|
||||
return p;
|
||||
}
|
||||
|
||||
/** Decode a same-origin weather frame into a canvas (IR gets a cloud key). */
|
||||
export function loadWeatherFrame(
|
||||
product,
|
||||
time,
|
||||
width = 2048,
|
||||
{ keep = true } = {},
|
||||
) {
|
||||
const url = weatherImageUrl(product, time, width);
|
||||
return cached(url, () => decodeFrame(url, product), keep);
|
||||
}
|
||||
|
||||
/** A 2:1 detail window (0.25° grid) covering a view rect inside bounds. */
|
||||
export function detailWindow(rect, bounds) {
|
||||
if (!rect || rect.west > rect.east) return null;
|
||||
const lonSpan = rect.east - rect.west;
|
||||
const latSpan = rect.north - rect.south;
|
||||
let width = Math.max(1, lonSpan * 1.1, latSpan * 2.2);
|
||||
width = Math.ceil(width / 0.5) * 0.5;
|
||||
const height = width / 2;
|
||||
if (width >= bounds.east - bounds.west - 0.5) return null;
|
||||
if (height >= bounds.north - bounds.south - 0.5) return null;
|
||||
const snap = (v) => Math.round(v / 0.25) * 0.25;
|
||||
const cLon = (rect.west + rect.east) / 2;
|
||||
const cLat = (rect.south + rect.north) / 2;
|
||||
const minW = Math.ceil(bounds.west / 0.25) * 0.25;
|
||||
const maxW = Math.floor((bounds.east - width) / 0.25) * 0.25;
|
||||
const minS = Math.ceil(bounds.south / 0.25) * 0.25;
|
||||
const maxS = Math.floor((bounds.north - height) / 0.25) * 0.25;
|
||||
const west = Math.max(minW, Math.min(maxW, snap(cLon - width / 2)));
|
||||
const south = Math.max(minS, Math.min(maxS, snap(cLat - height / 2)));
|
||||
return { west, south, east: west + width, north: south + height };
|
||||
}
|
||||
|
||||
/** Decode a detail window of a frame (2048×1024). */
|
||||
export function loadWeatherDetail(product, time, box) {
|
||||
const url = `/api/weather/image?product=${product}&time=${encodeURIComponent(time)}&bbox=${box.west},${box.south},${box.east},${box.north}&size=2048x1024`;
|
||||
return cached(url, () => decodeFrame(url, product), true);
|
||||
}
|
||||
|
||||
/* ───────────────────────── Raster layer ───────────────────────── */
|
||||
|
||||
class WeatherRasterLayer extends Layer {
|
||||
/**
|
||||
* @param {object} ctx
|
||||
* @param {object} def
|
||||
* @param {{feed:object, product:string, heightM:number, alpha:number,
|
||||
* width?:number}} opts
|
||||
*/
|
||||
constructor(ctx, def, opts) {
|
||||
super(ctx, { pollMs: 2 * 60_000, ...def });
|
||||
this.feed = opts.feed;
|
||||
this.product = opts.product;
|
||||
this.heightM = opts.heightM;
|
||||
this.alpha = opts.alpha;
|
||||
this.width = opts.width ?? 2048;
|
||||
this.primitive = null;
|
||||
this.material = null;
|
||||
this.frame = null;
|
||||
this._want = null;
|
||||
}
|
||||
|
||||
async refresh() {
|
||||
try {
|
||||
await this.feed.get();
|
||||
} finally {
|
||||
this.fromFeed(this.feed);
|
||||
}
|
||||
await this._apply();
|
||||
if (this.frame && this.feed.data)
|
||||
this.message = `${this.feed.data.title} · ${relTime(this.frameTime, Date.now())}`;
|
||||
else if (this.feed.data?.coverage) this.message ||= this.feed.data.coverage;
|
||||
}
|
||||
|
||||
_ensurePrimitive(bounds) {
|
||||
if (this.primitive) return;
|
||||
this.material = Cesium.Material.fromType('Image', {
|
||||
image: Cesium.Material.DefaultImageId,
|
||||
color: new Cesium.Color(1, 1, 1, this.alpha),
|
||||
});
|
||||
const rect = Cesium.Rectangle.fromDegrees(
|
||||
bounds.west,
|
||||
bounds.south,
|
||||
bounds.east,
|
||||
bounds.north,
|
||||
);
|
||||
this.primitive = this.root.add(
|
||||
new Cesium.Primitive({
|
||||
geometryInstances: new Cesium.GeometryInstance({
|
||||
geometry: new Cesium.RectangleGeometry({
|
||||
rectangle: rect,
|
||||
height: this.heightM,
|
||||
granularity: Cesium.Math.toRadians(1),
|
||||
vertexFormat: Cesium.EllipsoidSurfaceAppearance.VERTEX_FORMAT,
|
||||
}),
|
||||
}),
|
||||
appearance: new Cesium.EllipsoidSurfaceAppearance({
|
||||
material: this.material,
|
||||
flat: true,
|
||||
aboveGround: true,
|
||||
translucent: true,
|
||||
renderState: { depthMask: false },
|
||||
}),
|
||||
asynchronous: true,
|
||||
allowPicking: false,
|
||||
show: false,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async _apply() {
|
||||
const data = this.feed.data;
|
||||
if (!data) return;
|
||||
const time = frameAt(
|
||||
data.times,
|
||||
this.ctx.isLive() ? null : this.ctx.timeMs(),
|
||||
);
|
||||
this._want = time;
|
||||
if (!time) {
|
||||
if (this.primitive) this.primitive.show = false;
|
||||
this.count = 0;
|
||||
this.message = 'No observation at this time';
|
||||
this.ctx.requestRender();
|
||||
return;
|
||||
}
|
||||
if (time === this.frame) {
|
||||
if (this.primitive) this.primitive.show = true;
|
||||
return;
|
||||
}
|
||||
this._ensurePrimitive(data.bounds);
|
||||
const canvas = await loadWeatherFrame(this.product, time, this.width);
|
||||
if (this._want !== time || !this.on) return;
|
||||
this.material.uniforms.image = canvas;
|
||||
this.primitive.show = true;
|
||||
this.frame = time;
|
||||
this.frameCanvas = canvas;
|
||||
this.frameTime = Date.parse(time);
|
||||
this.message = `${data.title} · ${relTime(this.frameTime, Date.now())}`;
|
||||
if (this.afterFrame) this.afterFrame(canvas, data);
|
||||
else this.count = 1;
|
||||
this._detail().catch(() => {});
|
||||
this.ctx.requestRender();
|
||||
}
|
||||
|
||||
/** Close in, a sharper window of the same frame is laid over the global one. */
|
||||
onView(view) {
|
||||
this.lastView = view;
|
||||
if (!this.on) return;
|
||||
clearTimeout(this._detailTimer);
|
||||
this._detailTimer = setTimeout(
|
||||
() => this._detail().catch((e) => console.warn(`[globe:${this.id}]`, e)),
|
||||
350,
|
||||
);
|
||||
}
|
||||
|
||||
async _detail() {
|
||||
const v = this.lastView;
|
||||
const data = this.feed.data;
|
||||
const box =
|
||||
v && v.heightM < 3_500_000 && this.frame && data
|
||||
? detailWindow(v.rect, data.bounds)
|
||||
: null;
|
||||
if (!box) {
|
||||
if (this.detailPrim) this.detailPrim.show = false;
|
||||
this._detailKey = null;
|
||||
this.ctx.requestRender();
|
||||
return;
|
||||
}
|
||||
const key = `${this.frame}|${box.west},${box.south},${box.east},${box.north}`;
|
||||
if (key === this._detailKey) {
|
||||
if (this.detailPrim) this.detailPrim.show = true;
|
||||
return;
|
||||
}
|
||||
this._detailKey = key;
|
||||
const canvas = await loadWeatherDetail(this.product, this.frame, box);
|
||||
if (this._detailKey !== key || !this.on) return;
|
||||
if (this.detailPrim) this.root.remove(this.detailPrim);
|
||||
const material = Cesium.Material.fromType('Image', {
|
||||
image: canvas,
|
||||
color: new Cesium.Color(1, 1, 1, this.alpha),
|
||||
});
|
||||
this.detailPrim = this.root.add(
|
||||
new Cesium.Primitive({
|
||||
geometryInstances: new Cesium.GeometryInstance({
|
||||
geometry: new Cesium.RectangleGeometry({
|
||||
rectangle: Cesium.Rectangle.fromDegrees(
|
||||
box.west,
|
||||
box.south,
|
||||
box.east,
|
||||
box.north,
|
||||
),
|
||||
height: this.heightM + 60,
|
||||
granularity: Cesium.Math.toRadians(0.25),
|
||||
vertexFormat: Cesium.EllipsoidSurfaceAppearance.VERTEX_FORMAT,
|
||||
}),
|
||||
}),
|
||||
appearance: new Cesium.EllipsoidSurfaceAppearance({
|
||||
material,
|
||||
flat: true,
|
||||
aboveGround: true,
|
||||
translucent: true,
|
||||
renderState: { depthMask: false },
|
||||
}),
|
||||
asynchronous: true,
|
||||
allowPicking: false,
|
||||
}),
|
||||
);
|
||||
// The coarse global frame stays underneath only where the window ends.
|
||||
this.ctx.requestRender();
|
||||
}
|
||||
|
||||
onTime() {
|
||||
if (!this.on) return;
|
||||
clearTimeout(this._timeTimer);
|
||||
// Frames are whole-extent images: decode at most ~4 per second while
|
||||
// the ribbon is being dragged.
|
||||
this._timeTimer = setTimeout(
|
||||
() => this._apply().catch((e) => console.warn(`[globe:${this.id}]`, e)),
|
||||
250,
|
||||
);
|
||||
}
|
||||
|
||||
onHide() {
|
||||
if (this.primitive) this.primitive.show = false;
|
||||
if (this.detailPrim) this.detailPrim.show = false;
|
||||
this._detailKey = null;
|
||||
this.frame = null;
|
||||
}
|
||||
|
||||
info() {
|
||||
const i = super.info();
|
||||
if (this.on && this.feed.data) i.frames = this.feed.data.times.length;
|
||||
return i;
|
||||
}
|
||||
}
|
||||
|
||||
/* ───────────────────────── Lightning ───────────────────────── */
|
||||
|
||||
const STRIKE_BIN_DEG = 0.5;
|
||||
const STRIKE_CAP = 1500;
|
||||
|
||||
/**
|
||||
* Strike-density raster plus a glow per active 0.5° cell, so storms read as
|
||||
* flickering points of light from orbit. nowCOAST's palette runs yellow →
|
||||
* orange → red → magenta with density; the glow grades the same way.
|
||||
*/
|
||||
class LightningLayer extends WeatherRasterLayer {
|
||||
constructor(ctx, opts) {
|
||||
super(
|
||||
ctx,
|
||||
{ id: 'lightning', family: 'Weather', name: 'Lightning' },
|
||||
{ ...opts, product: 'lightning', heightM: 9000, alpha: 0.95 },
|
||||
);
|
||||
this.renderer = new ContactRenderer(this.root, {
|
||||
layerId: this.id,
|
||||
tint: color('#FFF2A8'),
|
||||
glyphName: 'bolt',
|
||||
rotate: false,
|
||||
lodHeightM: 1_500_000,
|
||||
cap: 300,
|
||||
dotScale: 0.55,
|
||||
glyphScale: 0.42,
|
||||
dotAlpha: 1,
|
||||
});
|
||||
this.cells = new Map();
|
||||
}
|
||||
|
||||
onView(view) {
|
||||
super.onView(view);
|
||||
this.renderer.setView(view);
|
||||
}
|
||||
|
||||
afterFrame(canvas, data) {
|
||||
const cells = strikeCells(canvas, data.bounds);
|
||||
this.cells = new Map(cells.map((c) => [c.key, c]));
|
||||
this.count = cells.length;
|
||||
this.renderer.setItems(
|
||||
cells.map((c) => ({
|
||||
key: c.key,
|
||||
lon: c.lon,
|
||||
lat: c.lat,
|
||||
altM: 9500,
|
||||
color: color(
|
||||
c.level >= 3 ? '#FF8AE6' : c.level === 2 ? '#FFD27A' : '#FFF6C8',
|
||||
),
|
||||
dotScale: 0.32 + Math.min(0.55, c.pixels / 12),
|
||||
glyphScale: 0.8 + c.level * 0.12,
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
/** 1 Hz flicker: a few cells flare each second (cheap, no render loop). */
|
||||
onTick() {
|
||||
const n = this.renderer.dots.length;
|
||||
if (!n) return false;
|
||||
for (let k = 0; k < Math.min(24, n); k++) {
|
||||
const i = Math.floor(Math.random() * n);
|
||||
const bb = this.renderer.dots.get(i);
|
||||
bb.scale = bb._base ??= bb.scale;
|
||||
bb.scale = bb._base * (1.4 + Math.random() * 0.9);
|
||||
setTimeout(() => {
|
||||
if (this.renderer.dots.contains(bb)) bb.scale = bb._base;
|
||||
}, 220);
|
||||
}
|
||||
setTimeout(() => this.ctx.requestRender(), 230);
|
||||
return true;
|
||||
}
|
||||
|
||||
locate(key) {
|
||||
const c = this.cells.get(key);
|
||||
return c ? [c.lon, c.lat, 9500] : null;
|
||||
}
|
||||
|
||||
pick(key) {
|
||||
const c = this.cells.get(key);
|
||||
if (!c) return null;
|
||||
return {
|
||||
kind: 'lightning',
|
||||
id: key,
|
||||
title: 'Lightning',
|
||||
subtitle: `Strike density class ${c.level} · ${relTime(this.frameTime, Date.now())}`,
|
||||
lon: c.lon,
|
||||
lat: c.lat,
|
||||
meta: { layer: this.id, level: c.level, source: this.source },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/** Classify a nowCOAST lightning-density pixel 1 (low) .. 4 (extreme). */
|
||||
export function strikeLevel(r, g, b) {
|
||||
if (r > 200 && g > 200 && b < 140) return 1; // yellow
|
||||
if (r > 200 && g > 90) return 2; // orange
|
||||
if (r > 170 && b > 120) return 4; // magenta / pink
|
||||
if (r > 150) return 3; // red
|
||||
return 1;
|
||||
}
|
||||
|
||||
/** Bin the lit pixels of a whole-extent lightning frame into 0.5° cells. */
|
||||
export function strikeCells(canvas, bounds, bbox = null) {
|
||||
const g = canvas.getContext('2d', { willReadFrequently: true });
|
||||
const { width, height } = canvas;
|
||||
const px = g.getImageData(0, 0, width, height).data;
|
||||
const dLon = (bounds.east - bounds.west) / width;
|
||||
const dLat = (bounds.north - bounds.south) / height;
|
||||
const bins = new Map();
|
||||
for (let y = 0; y < height; y++) {
|
||||
const lat = bounds.north - (y + 0.5) * dLat;
|
||||
if (bbox && (lat < bbox.south || lat > bbox.north)) continue;
|
||||
for (let x = 0; x < width; x++) {
|
||||
const i = (y * width + x) * 4;
|
||||
if (px[i + 3] < 40) continue;
|
||||
const lon = bounds.west + (x + 0.5) * dLon;
|
||||
if (bbox && (lon < bbox.west || lon > bbox.east)) continue;
|
||||
const level = strikeLevel(px[i], px[i + 1], px[i + 2]);
|
||||
const key = `${Math.floor(lat / STRIKE_BIN_DEG)}:${Math.floor(lon / STRIKE_BIN_DEG)}`;
|
||||
let c = bins.get(key);
|
||||
if (!c)
|
||||
bins.set(
|
||||
key,
|
||||
(c = { key, lat: 0, lon: 0, pixels: 0, level: 0, weight: 0 }),
|
||||
);
|
||||
c.lat += lat;
|
||||
c.lon += lon;
|
||||
c.pixels++;
|
||||
c.weight += level;
|
||||
c.level = Math.max(c.level, level);
|
||||
}
|
||||
}
|
||||
const out = [];
|
||||
for (const c of bins.values()) {
|
||||
c.lat /= c.pixels;
|
||||
c.lon /= c.pixels;
|
||||
out.push(c);
|
||||
}
|
||||
out.sort((a, b) => b.weight - a.weight);
|
||||
return out.slice(0, STRIKE_CAP);
|
||||
}
|
||||
|
||||
/* ───────────────────────── Wind (GPU streamlines) ───────────────────────── */
|
||||
|
||||
class WindLayer extends Layer {
|
||||
constructor(ctx, def, { feed }) {
|
||||
super(ctx, { pollMs: 60 * 60_000, ...def });
|
||||
this.feed = feed;
|
||||
this.gpu = createWindGpuRendering({
|
||||
cesium: Cesium,
|
||||
getViewer: () => ctx.viewer,
|
||||
});
|
||||
this.field = null;
|
||||
this.started = performance.now();
|
||||
this._raf = 0;
|
||||
this._lastFrame = 0;
|
||||
const scene = ctx.scene;
|
||||
this._pre = scene.preRender.addEventListener(() => {
|
||||
if (!this.on || !this.field) return;
|
||||
this.gpu.tick((performance.now() - this.started) / 1000);
|
||||
this.gpu.updateVisibility(scene.camera);
|
||||
});
|
||||
}
|
||||
|
||||
async refresh() {
|
||||
try {
|
||||
await this.feed.get();
|
||||
} finally {
|
||||
this.fromFeed(this.feed);
|
||||
}
|
||||
const d = this.feed.data;
|
||||
if (!d) return;
|
||||
if (d.field !== this.field) {
|
||||
this.field = d.field;
|
||||
if (this.on) this.gpu.setField(this.field);
|
||||
}
|
||||
this.count = this.gpu.getParticleCount();
|
||||
this.message = `${d.level} · ${d.source}`;
|
||||
this._loop();
|
||||
}
|
||||
|
||||
setOn(on) {
|
||||
super.setOn(on);
|
||||
if (on && this.field) {
|
||||
this.gpu.setField(this.field);
|
||||
this._loop();
|
||||
}
|
||||
}
|
||||
|
||||
onHide() {
|
||||
this.gpu.clear();
|
||||
cancelAnimationFrame(this._raf);
|
||||
this._raf = 0;
|
||||
}
|
||||
|
||||
/** Flowing streaks need frames: ~30 fps while on and visible, 0 otherwise. */
|
||||
_loop() {
|
||||
if (this._raf || !this.on) return;
|
||||
const step = (t) => {
|
||||
this._raf = 0;
|
||||
if (!this.on) return;
|
||||
if (this.ctx.active() && t - this._lastFrame > 33) {
|
||||
this._lastFrame = t;
|
||||
this.ctx.requestRender();
|
||||
}
|
||||
this._raf = requestAnimationFrame(step);
|
||||
};
|
||||
this._raf = requestAnimationFrame(step);
|
||||
}
|
||||
|
||||
destroy() {
|
||||
cancelAnimationFrame(this._raf);
|
||||
this._pre?.();
|
||||
this.gpu.destroy();
|
||||
super.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
/* ───────────────────────── Air quality ───────────────────────── */
|
||||
|
||||
class AirQualityLayer extends Layer {
|
||||
constructor(ctx, { feed }) {
|
||||
super(ctx, {
|
||||
id: 'air-quality',
|
||||
family: 'Hazards',
|
||||
name: 'Air quality',
|
||||
pollMs: 60 * 60_000,
|
||||
});
|
||||
this.feed = feed;
|
||||
this.renderer = new ContactRenderer(this.root, {
|
||||
layerId: this.id,
|
||||
tint: color('#F2D33B'),
|
||||
glyphName: 'aq',
|
||||
rotate: false,
|
||||
alwaysGlyph: true,
|
||||
dotScale: 0.9,
|
||||
glyphScale: 0.5,
|
||||
});
|
||||
this.records = new Map();
|
||||
}
|
||||
async refresh() {
|
||||
try {
|
||||
await this.feed.get();
|
||||
} finally {
|
||||
this.fromFeed(this.feed);
|
||||
}
|
||||
const recs = this.feed.data.records;
|
||||
this.records = new Map(recs.map((r) => [r.id, r]));
|
||||
this.count = recs.length;
|
||||
this.renderer.setItems(
|
||||
recs.map((r) => ({
|
||||
key: r.id,
|
||||
lon: r.lon,
|
||||
lat: r.lat,
|
||||
color: color(aqiColor(r.aqi)),
|
||||
dotScale: 0.8 + Math.min(2, r.aqi / 100),
|
||||
glyphScale: 0.55 + Math.min(0.6, r.aqi / 300),
|
||||
})),
|
||||
);
|
||||
}
|
||||
onView(view) {
|
||||
this.renderer.setView(view);
|
||||
}
|
||||
locate(key) {
|
||||
const r = this.records.get(key);
|
||||
return r ? [r.lon, r.lat, 0] : null;
|
||||
}
|
||||
pick(key) {
|
||||
const r = this.records.get(key);
|
||||
if (!r) return null;
|
||||
return {
|
||||
kind: 'air-quality',
|
||||
id: r.id,
|
||||
title: `${r.name} · AQI ${Math.round(r.aqi)}`,
|
||||
subtitle: `PM2.5 ${r.pm25?.toFixed(0) ?? '–'} µg/m³ · modelled (CAMS)`,
|
||||
lon: r.lon,
|
||||
lat: r.lat,
|
||||
labelDetail: `AQI ${Math.round(r.aqi)}`,
|
||||
meta: { layer: this.id, aqi: r.aqi, pm25: r.pm25, source: this.source },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/* ───────────────────────── GNSS interference ───────────────────────── */
|
||||
|
||||
const GNSS_COLORS = { high: '#FF3B6B', medium: '#FFB13D', low: '#4ADE80' };
|
||||
|
||||
class GnssLayer extends Layer {
|
||||
constructor(ctx) {
|
||||
super(ctx, {
|
||||
id: 'gnss',
|
||||
family: 'Infra',
|
||||
name: 'GNSS interference',
|
||||
pollMs: 2 * 60_000,
|
||||
});
|
||||
this.source = 'adsb.lol NIC/NACp (gpsjam.org bands)';
|
||||
this.cellsPrim = null;
|
||||
this.anchor = null;
|
||||
this.cells = new Map();
|
||||
}
|
||||
|
||||
_anchors() {
|
||||
const v = this.lastView;
|
||||
if (v && v.heightM < 3_000_000 && v.center)
|
||||
return [{ id: 'view', lat: v.center.lat, lon: v.center.lon }];
|
||||
return GNSS_ANCHORS;
|
||||
}
|
||||
|
||||
async refresh() {
|
||||
const anchors = this._anchors();
|
||||
const results = await Promise.allSettled(
|
||||
anchors.map((a) =>
|
||||
gnssIntegrity.get(`${Math.round(a.lat)}:${Math.round(a.lon)}`, a),
|
||||
),
|
||||
);
|
||||
const ok = results
|
||||
.filter((r) => r.status === 'fulfilled')
|
||||
.map((r) => r.value);
|
||||
if (!ok.length) {
|
||||
this.status = 'error';
|
||||
this.message =
|
||||
results[0]?.reason?.message || 'GNSS integrity source unavailable';
|
||||
return;
|
||||
}
|
||||
this.status = 'live';
|
||||
this.message = 'Low NIC/NACp is a proxy for jamming or spoofing, not proof';
|
||||
const cells = new Map();
|
||||
for (const snap of ok)
|
||||
for (const c of snap.cells)
|
||||
cells.set(`${c.lat.toFixed(2)}:${c.lon.toFixed(2)}`, c);
|
||||
this.cells = cells;
|
||||
this._draw();
|
||||
}
|
||||
|
||||
_draw() {
|
||||
if (this.cellsPrim) this.root.remove(this.cellsPrim);
|
||||
this.cellsPrim = null;
|
||||
const shown = [...this.cells.entries()].filter(
|
||||
([, c]) => c.level !== 'low' || c.total >= 6,
|
||||
);
|
||||
this.count = shown.filter(([, c]) => c.level !== 'low').length;
|
||||
if (!shown.length) return;
|
||||
const instances = shown.map(([key, c]) => {
|
||||
const h = 0.25;
|
||||
return new Cesium.GeometryInstance({
|
||||
id: { layer: this.id, key },
|
||||
geometry: new Cesium.RectangleGeometry({
|
||||
rectangle: Cesium.Rectangle.fromDegrees(
|
||||
c.lon - h,
|
||||
c.lat - h,
|
||||
c.lon + h,
|
||||
c.lat + h,
|
||||
),
|
||||
height: 2000,
|
||||
vertexFormat: Cesium.PerInstanceColorAppearance.FLAT_VERTEX_FORMAT,
|
||||
}),
|
||||
attributes: {
|
||||
color: Cesium.ColorGeometryInstanceAttribute.fromColor(
|
||||
color(GNSS_COLORS[c.level], c.level === 'low' ? 0.18 : 0.5),
|
||||
),
|
||||
},
|
||||
});
|
||||
});
|
||||
this.cellsPrim = this.root.add(
|
||||
new Cesium.Primitive({
|
||||
geometryInstances: instances,
|
||||
appearance: new Cesium.PerInstanceColorAppearance({
|
||||
flat: true,
|
||||
translucent: true,
|
||||
}),
|
||||
asynchronous: true,
|
||||
}),
|
||||
);
|
||||
this.ctx.requestRender();
|
||||
}
|
||||
|
||||
onView(view) {
|
||||
const prev = this.lastView;
|
||||
this.lastView = view;
|
||||
const moved =
|
||||
!prev ||
|
||||
view.heightM < 3_000_000 !== prev.heightM < 3_000_000 ||
|
||||
(view.heightM < 3_000_000 &&
|
||||
haversineKm(
|
||||
view.center.lat,
|
||||
view.center.lon,
|
||||
prev.center.lat,
|
||||
prev.center.lon,
|
||||
) > 150);
|
||||
if (moved && this.on) this.kick();
|
||||
}
|
||||
|
||||
locate(key) {
|
||||
const c = this.cells.get(key);
|
||||
return c ? [c.lon, c.lat, 0] : null;
|
||||
}
|
||||
|
||||
pick(key) {
|
||||
const c = this.cells.get(key);
|
||||
if (!c) return null;
|
||||
return {
|
||||
kind: 'gnss',
|
||||
id: key,
|
||||
title: `GNSS ${c.level === 'high' ? 'interference likely' : c.level === 'medium' ? 'degraded' : 'nominal'}`,
|
||||
subtitle: `${c.bad}/${c.total} aircraft with low NIC/NACp · ${c.percent.toFixed(0)}%`,
|
||||
lon: c.lon,
|
||||
lat: c.lat,
|
||||
meta: { layer: this.id, ...c, source: this.source },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/* ───────────────────────── Transit ───────────────────────── */
|
||||
|
||||
function routeColor(routeId) {
|
||||
let h = 0;
|
||||
for (const ch of String(routeId)) h = (h * 31 + ch.charCodeAt(0)) >>> 0;
|
||||
return `hsl(${h % 360} 85% 62%)`;
|
||||
}
|
||||
|
||||
class TransitLayer extends Layer {
|
||||
constructor(ctx, { catalog }) {
|
||||
super(ctx, {
|
||||
id: 'transit',
|
||||
family: 'Ground',
|
||||
name: 'Transit vehicles',
|
||||
pollMs: 20_000,
|
||||
});
|
||||
this.catalog = catalog;
|
||||
this.renderer = new ContactRenderer(this.root, {
|
||||
layerId: this.id,
|
||||
tint: color(FAMILY_COLORS.Ground),
|
||||
glyphName: 'bus',
|
||||
lodHeightM: 400_000,
|
||||
cap: 600,
|
||||
dotScale: 0.32,
|
||||
glyphScale: 0.38,
|
||||
});
|
||||
this.records = new Map();
|
||||
this.feedId = null;
|
||||
}
|
||||
|
||||
async refresh() {
|
||||
let cat;
|
||||
try {
|
||||
cat = await this.catalog.get();
|
||||
} finally {
|
||||
this.fromFeed(this.catalog);
|
||||
}
|
||||
const c = this.lastView?.center;
|
||||
const feeds = cat.feeds;
|
||||
let best = null;
|
||||
for (const f of feeds) {
|
||||
const d = c ? haversineKm(c.lat, c.lon, f.center.lat, f.center.lon) : 0;
|
||||
if (!best || d - f.loadRadiusKm < best.d - best.f.loadRadiusKm)
|
||||
best = { f, d };
|
||||
}
|
||||
if (
|
||||
!best ||
|
||||
(c && best.d > best.f.loadRadiusKm + 400 && this.lastView?.heightM < 3e6)
|
||||
) {
|
||||
this.count = 0;
|
||||
this.message = 'No open transit feed near this view';
|
||||
this.renderer.clear();
|
||||
return;
|
||||
}
|
||||
// From orbit the nearest feed is arbitrary: show Helsinki's (busy, CC BY).
|
||||
const feed =
|
||||
this.lastView?.heightM >= 3e6
|
||||
? feeds.find((f) => f.id === 'hsl-helsinki') || best.f
|
||||
: best.f;
|
||||
this.feedId = feed.id;
|
||||
const snap = await transitVehicles.get(feed.id);
|
||||
this.source = `${feed.name} · ${feed.attribution}`;
|
||||
this.message = `${feed.region} · ${snap.vehicles.length} vehicles`;
|
||||
this.status = 'live';
|
||||
this.records = new Map(snap.vehicles.map((v) => [v.id, v]));
|
||||
this.count = snap.vehicles.length;
|
||||
this.renderer.setItems(
|
||||
snap.vehicles.map((v) => ({
|
||||
key: v.id,
|
||||
lon: v.lon,
|
||||
lat: v.lat,
|
||||
heading: v.bearing ?? 0,
|
||||
color: color(routeColor(v.routeId)),
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
onView(view) {
|
||||
const prev = this.lastView;
|
||||
this.lastView = view;
|
||||
this.renderer.setView(view);
|
||||
if (
|
||||
this.on &&
|
||||
prev?.center &&
|
||||
haversineKm(
|
||||
view.center.lat,
|
||||
view.center.lon,
|
||||
prev.center.lat,
|
||||
prev.center.lon,
|
||||
) > 120
|
||||
)
|
||||
this.kick();
|
||||
}
|
||||
|
||||
locate(key) {
|
||||
const v = this.records.get(key);
|
||||
return v ? [v.lon, v.lat, 0] : null;
|
||||
}
|
||||
|
||||
pick(key) {
|
||||
const v = this.records.get(key);
|
||||
if (!v) return null;
|
||||
return {
|
||||
kind: 'transit',
|
||||
id: v.id,
|
||||
title: `Route ${v.routeId || '?'}`,
|
||||
subtitle: [
|
||||
v.speedMps != null ? `${Math.round(v.speedMps * 3.6)} km/h` : null,
|
||||
v.status ? v.status.replace(/_/g, ' ').toLowerCase() : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · '),
|
||||
lon: v.lon,
|
||||
lat: v.lat,
|
||||
meta: { layer: this.id, feedId: this.feedId, source: this.source },
|
||||
};
|
||||
}
|
||||
|
||||
contacts() {
|
||||
return [...this.records.values()].map((v) => ({
|
||||
kind: 'transit',
|
||||
id: v.id,
|
||||
title: `Route ${v.routeId}`,
|
||||
lon: v.lon,
|
||||
lat: v.lat,
|
||||
altM: 0,
|
||||
headingDeg: v.bearing ?? 0,
|
||||
speedMps: v.speedMps,
|
||||
layer: this.id,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
export { inRect };
|
||||
|
||||
export function createWeatherLayers(ctx, feeds) {
|
||||
return [
|
||||
new WeatherRasterLayer(
|
||||
ctx,
|
||||
{ id: 'radar', family: 'Weather', name: 'Radar' },
|
||||
{ feed: feeds.radar, product: 'radar', heightM: 4000, alpha: 0.9 },
|
||||
),
|
||||
new WeatherRasterLayer(
|
||||
ctx,
|
||||
{
|
||||
id: 'clouds',
|
||||
family: 'Weather',
|
||||
name: 'Clouds (satellite IR)',
|
||||
pollMs: 10 * 60_000,
|
||||
},
|
||||
{ feed: feeds.clouds, product: 'clouds', heightM: 7000, alpha: 0.85 },
|
||||
),
|
||||
new LightningLayer(ctx, { feed: feeds.lightning, width: 2048 }),
|
||||
new WindLayer(
|
||||
ctx,
|
||||
{ id: 'wind', family: 'Weather', name: 'Wind (10 m)' },
|
||||
{ feed: feeds.wind },
|
||||
),
|
||||
new WindLayer(
|
||||
ctx,
|
||||
{ id: 'jet', family: 'Weather', name: 'Jet stream (250 hPa)' },
|
||||
{ feed: feeds.jet },
|
||||
),
|
||||
new AirQualityLayer(ctx, { feed: feeds.airQuality }),
|
||||
new GnssLayer(ctx),
|
||||
new TransitLayer(ctx, { catalog: feeds.transitCatalog }),
|
||||
];
|
||||
}
|
||||
|
||||
export { WX };
|
||||
@@ -0,0 +1,196 @@
|
||||
/**
|
||||
* Moments (DESIGN.md §12.4): 4D reconstructions on one clock.
|
||||
*
|
||||
* bundled the hero, "Climbers on the Empire State Building"
|
||||
* (mobile/demo/moments/empire-state.json, illustrative: true)
|
||||
* live built on demand from the recorder for every detection
|
||||
* (circling, emergency squawk): real tracks, illustrative: false
|
||||
*/
|
||||
import { localPlace } from './gazetteer.js';
|
||||
|
||||
const HERO_URL = new URL('../demo/moments/empire-state.json', import.meta.url)
|
||||
.href;
|
||||
const BUILDINGS_URL = new URL('../demo/midtown-buildings.json', import.meta.url)
|
||||
.href;
|
||||
const D2R = Math.PI / 180;
|
||||
|
||||
let heroPromise = null;
|
||||
let buildingsPromise = null;
|
||||
|
||||
export function loadHero() {
|
||||
if (!heroPromise)
|
||||
heroPromise = fetch(HERO_URL)
|
||||
.then((r) => (r.ok ? r.json() : Promise.reject(new Error(r.status))))
|
||||
.catch((error) => {
|
||||
heroPromise = null;
|
||||
throw error;
|
||||
});
|
||||
return heroPromise;
|
||||
}
|
||||
|
||||
const WATER_URL = new URL('../demo/midtown-water.json', import.meta.url).href;
|
||||
let waterPromise = null;
|
||||
|
||||
/** River surfaces drawn over the imagery seam (see build-midtown-water.mjs). */
|
||||
export function loadWater(name) {
|
||||
if (name !== 'midtown-water.json') return Promise.resolve(null);
|
||||
if (!waterPromise)
|
||||
waterPromise = fetch(WATER_URL)
|
||||
.then((r) => (r.ok ? r.json() : null))
|
||||
.catch(() => {
|
||||
waterPromise = null;
|
||||
return null;
|
||||
});
|
||||
return waterPromise;
|
||||
}
|
||||
|
||||
export function loadBuildings(name) {
|
||||
if (name !== 'midtown-buildings.json') return Promise.resolve(null);
|
||||
if (!buildingsPromise)
|
||||
buildingsPromise = fetch(BUILDINGS_URL)
|
||||
.then((r) => (r.ok ? r.json() : null))
|
||||
.catch(() => {
|
||||
buildingsPromise = null;
|
||||
return null;
|
||||
});
|
||||
return buildingsPromise;
|
||||
}
|
||||
|
||||
/** A slow orbit of camera keys around a point (for live Moments). */
|
||||
export function orbitCameraPath(
|
||||
lon,
|
||||
lat,
|
||||
start,
|
||||
end,
|
||||
{ rangeM = 2600, heightM = 1100, lookAltM = 250, startBearing = 200 } = {},
|
||||
) {
|
||||
const keys = [];
|
||||
const n = 9;
|
||||
const kx = 111_320 * Math.cos(lat * D2R);
|
||||
for (let i = 0; i < n; i++) {
|
||||
const f = i / (n - 1);
|
||||
const b = (startBearing + 140 * f) % 360;
|
||||
const r = rangeM * (1.2 - 0.35 * Math.sin(Math.PI * f));
|
||||
const h = heightM * (1.15 - 0.3 * Math.sin(Math.PI * f));
|
||||
const x = Math.sin(b * D2R) * r;
|
||||
const y = Math.cos(b * D2R) * r;
|
||||
keys.push({
|
||||
t: Math.round(start + (end - start) * f),
|
||||
lon: lon + x / kx,
|
||||
lat: lat + y / 110_574,
|
||||
height: Math.round(h),
|
||||
heading: (b + 180) % 360,
|
||||
pitch: (Math.atan2(lookAltM - h, r) * 180) / Math.PI,
|
||||
});
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
|
||||
/**
|
||||
* A live Moment from a circling detection, from the recorder's samples.
|
||||
* @param {object} det detector output
|
||||
* @param {object} recorder
|
||||
*/
|
||||
export function momentFromCircling(det, recorder) {
|
||||
const start = Math.max(det.since - 4 * 60_000, Date.now() - 45 * 60_000);
|
||||
const end = Math.max(det.updatedAt, Date.now());
|
||||
const tracks = [];
|
||||
for (const m of det.members) {
|
||||
const pts = recorder
|
||||
.samples(m.key, start, end)
|
||||
.map((p) => ({ t: p.t, lon: p.lon, lat: p.lat, altM: p.altM }));
|
||||
if (pts.length < 2) continue;
|
||||
const meta = recorder.meta(m.key) || {};
|
||||
tracks.push({
|
||||
id: m.key,
|
||||
entityId: `${meta.layer || 'flights'}:${m.key}`,
|
||||
kind: meta.helicopter ? 'helicopter' : 'aircraft',
|
||||
label: meta.callsign || m.key.toUpperCase(),
|
||||
callsign: meta.callsign || null,
|
||||
registration: meta.registration || null,
|
||||
type: meta.type || null,
|
||||
operator: '',
|
||||
points: pts,
|
||||
});
|
||||
}
|
||||
let maxAlt = 0;
|
||||
for (const tr of tracks)
|
||||
for (const p of tr.points) maxAlt = Math.max(maxAlt, p.altM);
|
||||
const place = localPlace(det.lat, det.lon).name;
|
||||
return {
|
||||
id: `live-${det.id}`,
|
||||
title: det.title,
|
||||
place,
|
||||
lon: det.lon,
|
||||
lat: det.lat,
|
||||
time: { start, end },
|
||||
illustrative: false,
|
||||
live: true,
|
||||
situationId: det.id,
|
||||
recommendedSpeed: 30,
|
||||
subject: { lon: det.lon, lat: det.lat, altM: 0, label: place },
|
||||
cameraPath: orbitCameraPath(det.lon, det.lat, start, end, {
|
||||
rangeM: 2000,
|
||||
heightM: Math.max(1400, maxAlt * 3),
|
||||
lookAltM: maxAlt * 0.4,
|
||||
}),
|
||||
beats: [{ t: det.since, title: 'First complete orbit in the record' }],
|
||||
tracks,
|
||||
captures: [],
|
||||
chat: [],
|
||||
posts: [],
|
||||
sources: [
|
||||
{
|
||||
name: 'God’s Eye recorder',
|
||||
detail:
|
||||
'Positions recorded from live ADS-B polls (OpenSky Network / adsb.lol, ODbL) in this browser.',
|
||||
},
|
||||
{
|
||||
name: 'Circling detector',
|
||||
detail: `${det.members.length} aircraft each completed at least one orbit within 1.5 km of a common centre.`,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/** A live Moment for one aircraft (an emergency squawk) from its track. */
|
||||
export function momentFromAircraft({ id, title, key, points, meta, place }) {
|
||||
if (!points?.length) return null;
|
||||
const start = points[0].t;
|
||||
const end = Math.max(points.at(-1).t, start + 60_000);
|
||||
const last = points.at(-1);
|
||||
return {
|
||||
id,
|
||||
title,
|
||||
place: place || localPlace(last.lat, last.lon).name,
|
||||
lon: last.lon,
|
||||
lat: last.lat,
|
||||
time: { start, end },
|
||||
illustrative: false,
|
||||
live: true,
|
||||
recommendedSpeed: 60,
|
||||
subject: null,
|
||||
follow: { trackId: key, rangeM: 22_000, heightM: 7_000 },
|
||||
cameraPath: [],
|
||||
beats: [],
|
||||
tracks: [
|
||||
{
|
||||
id: key,
|
||||
entityId: `${meta?.layer || 'flights'}:${key}`,
|
||||
kind: meta?.helicopter ? 'helicopter' : 'aircraft',
|
||||
label: meta?.callsign || key.toUpperCase(),
|
||||
operator: '',
|
||||
points,
|
||||
},
|
||||
],
|
||||
captures: [],
|
||||
chat: [],
|
||||
posts: [],
|
||||
sources: [
|
||||
{
|
||||
name: 'God’s Eye recorder',
|
||||
detail: 'Positions recorded from live ADS-B polls in this browser.',
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
/**
|
||||
* Pins for discourse and capture (DESIGN.md §11.3–11.4): posts, sealed
|
||||
* witness captures, public camera eyes, live broadcasts and holograms. Eyes
|
||||
* and live pins draw their view frustum as a translucent wedge on the ground
|
||||
* along the camera heading. Pins are few (tens), so the wedge primitive is
|
||||
* simply rebuilt on change.
|
||||
*/
|
||||
import * as Cesium from 'cesium';
|
||||
import { glyph } from './glyphs.js';
|
||||
import { offsetLonLat } from './geo.js';
|
||||
|
||||
export const PIN_KINDS = Object.freeze([
|
||||
'post',
|
||||
'witness',
|
||||
'eye',
|
||||
'live',
|
||||
'hologram',
|
||||
]);
|
||||
|
||||
const PIN_COLORS = Object.freeze({
|
||||
post: '#E8EEF7',
|
||||
witness: '#FFC857',
|
||||
eye: '#FFD166',
|
||||
live: '#FF4D6D',
|
||||
hologram: '#C9A7FF',
|
||||
});
|
||||
|
||||
const PIN_TITLES = Object.freeze({
|
||||
post: 'Post',
|
||||
witness: 'Sealed witness capture',
|
||||
eye: 'Public camera',
|
||||
live: 'Live broadcast',
|
||||
hologram: 'Hologram',
|
||||
});
|
||||
|
||||
export function createPins({ scene, requestRender }) {
|
||||
const root = scene.primitives.add(new Cesium.PrimitiveCollection());
|
||||
const billboards = root.add(new Cesium.BillboardCollection());
|
||||
const edges = root.add(new Cesium.PolylineCollection());
|
||||
let wedges = null;
|
||||
const pins = new Map();
|
||||
let pulse = 0;
|
||||
|
||||
function rebuildWedges() {
|
||||
if (wedges) root.remove(wedges);
|
||||
wedges = null;
|
||||
edges.removeAll();
|
||||
const instances = [];
|
||||
for (const p of pins.values()) {
|
||||
if (p.kind !== 'eye' && p.kind !== 'live') continue;
|
||||
if (!Number.isFinite(p.headingDeg)) continue;
|
||||
const half = (p.fovDeg ?? 60) / 2;
|
||||
const ring = [[p.lon, p.lat]];
|
||||
for (let a = -half; a <= half + 0.01; a += Math.max(2, half / 6))
|
||||
ring.push(offsetLonLat(p.lon, p.lat, p.headingDeg + a, p.rangeM));
|
||||
const css = p.color || PIN_COLORS[p.kind];
|
||||
instances.push(
|
||||
new Cesium.GeometryInstance({
|
||||
id: { layer: 'pins', key: p.id },
|
||||
geometry: new Cesium.PolygonGeometry({
|
||||
polygonHierarchy: new Cesium.PolygonHierarchy(
|
||||
Cesium.Cartesian3.fromDegreesArray(ring.flat()),
|
||||
),
|
||||
height: (p.altM || 0) + 3,
|
||||
vertexFormat: Cesium.PerInstanceColorAppearance.FLAT_VERTEX_FORMAT,
|
||||
}),
|
||||
attributes: {
|
||||
color: Cesium.ColorGeometryInstanceAttribute.fromColor(
|
||||
Cesium.Color.fromCssColorString(css).withAlpha(0.28),
|
||||
),
|
||||
},
|
||||
}),
|
||||
);
|
||||
edges.add({
|
||||
positions: Cesium.Cartesian3.fromDegreesArrayHeights(
|
||||
[...ring, ring[0]].flatMap(([lo, la]) => [lo, la, (p.altM || 0) + 4]),
|
||||
),
|
||||
width: 1.5,
|
||||
material: Cesium.Material.fromType('Color', {
|
||||
color: Cesium.Color.fromCssColorString(css).withAlpha(0.85),
|
||||
}),
|
||||
});
|
||||
}
|
||||
if (instances.length)
|
||||
wedges = root.add(
|
||||
new Cesium.Primitive({
|
||||
geometryInstances: instances,
|
||||
appearance: new Cesium.PerInstanceColorAppearance({
|
||||
flat: true,
|
||||
translucent: true,
|
||||
}),
|
||||
asynchronous: true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function rebuildBillboards() {
|
||||
billboards.removeAll();
|
||||
for (const p of pins.values()) {
|
||||
const g = glyph(p.kind);
|
||||
p._bb = billboards.add({
|
||||
position: Cesium.Cartesian3.fromDegrees(
|
||||
p.lon,
|
||||
p.lat,
|
||||
(p.altM || 0) + 8,
|
||||
),
|
||||
image: g.image,
|
||||
imageId: g.imageId,
|
||||
color: Cesium.Color.fromCssColorString(p.color || PIN_COLORS[p.kind]),
|
||||
scale: 0.62,
|
||||
scaleByDistance: new Cesium.NearFarScalar(2e3, 1.15, 8e6, 0.55),
|
||||
verticalOrigin: Cesium.VerticalOrigin.CENTER,
|
||||
disableDepthTestDistance: 2e5,
|
||||
id: { layer: 'pins', key: p.id },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function changed() {
|
||||
rebuildBillboards();
|
||||
rebuildWedges();
|
||||
requestRender();
|
||||
}
|
||||
|
||||
return {
|
||||
add(pin) {
|
||||
if (!pin || pin.id == null) throw new TypeError('addPin needs an id');
|
||||
if (!Number.isFinite(pin.lon) || !Number.isFinite(pin.lat))
|
||||
throw new TypeError('addPin needs lon and lat');
|
||||
const kind = PIN_KINDS.includes(pin.kind) ? pin.kind : 'post';
|
||||
const p = {
|
||||
id: String(pin.id),
|
||||
lon: pin.lon,
|
||||
lat: pin.lat,
|
||||
kind,
|
||||
headingDeg: Number.isFinite(pin.headingDeg) ? pin.headingDeg : null,
|
||||
fovDeg: Number.isFinite(pin.fovDeg) ? pin.fovDeg : 60,
|
||||
rangeM: Number.isFinite(pin.rangeM) ? pin.rangeM : 350,
|
||||
color: pin.color || null,
|
||||
label: pin.label || '',
|
||||
altM: Number.isFinite(pin.altM) ? pin.altM : 0,
|
||||
};
|
||||
pins.set(p.id, p);
|
||||
changed();
|
||||
return p.id;
|
||||
},
|
||||
remove(id) {
|
||||
const ok = pins.delete(String(id));
|
||||
if (ok) changed();
|
||||
return ok;
|
||||
},
|
||||
list() {
|
||||
return [...pins.values()].map(({ _bb, ...p }) => ({ ...p }));
|
||||
},
|
||||
locate(id) {
|
||||
const p = pins.get(String(id));
|
||||
return p ? [p.lon, p.lat, p.altM || 0] : null;
|
||||
},
|
||||
pick(id) {
|
||||
const p = pins.get(String(id));
|
||||
if (!p) return null;
|
||||
return {
|
||||
kind: 'pin',
|
||||
pinKind: p.kind,
|
||||
id: p.id,
|
||||
title: p.label || PIN_TITLES[p.kind],
|
||||
subtitle: PIN_TITLES[p.kind],
|
||||
lon: p.lon,
|
||||
lat: p.lat,
|
||||
meta: {
|
||||
layer: 'pins',
|
||||
headingDeg: p.headingDeg,
|
||||
fovDeg: p.fovDeg,
|
||||
label: p.label,
|
||||
},
|
||||
};
|
||||
},
|
||||
/** Live pins breathe once a second; returns true when anything changed. */
|
||||
tick() {
|
||||
let any = false;
|
||||
pulse = (pulse + 1) % 2;
|
||||
for (const p of pins.values())
|
||||
if (p.kind === 'live' && p._bb) {
|
||||
p._bb.scale = pulse ? 0.74 : 0.6;
|
||||
any = true;
|
||||
}
|
||||
return any;
|
||||
},
|
||||
get billboards() {
|
||||
return billboards;
|
||||
},
|
||||
destroy() {
|
||||
scene.primitives.remove(root);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
/**
|
||||
* Bundled reference places: major ports (the Ports layer) and the "Always on"
|
||||
* evergreen situations. Coordinates are public, rounded, and need no feed.
|
||||
*/
|
||||
|
||||
export const PORTS = Object.freeze([
|
||||
{
|
||||
id: 'shanghai',
|
||||
name: 'Port of Shanghai',
|
||||
lon: 121.9,
|
||||
lat: 30.63,
|
||||
teu: 49.0,
|
||||
},
|
||||
{
|
||||
id: 'singapore',
|
||||
name: 'Port of Singapore',
|
||||
lon: 103.84,
|
||||
lat: 1.26,
|
||||
teu: 39.0,
|
||||
},
|
||||
{ id: 'ningbo', name: 'Ningbo-Zhoushan', lon: 121.85, lat: 29.93, teu: 35.3 },
|
||||
{
|
||||
id: 'shenzhen',
|
||||
name: 'Port of Shenzhen',
|
||||
lon: 113.88,
|
||||
lat: 22.49,
|
||||
teu: 30.0,
|
||||
},
|
||||
{ id: 'qingdao', name: 'Port of Qingdao', lon: 120.25, lat: 36.0, teu: 28.8 },
|
||||
{
|
||||
id: 'guangzhou',
|
||||
name: 'Guangzhou Nansha',
|
||||
lon: 113.6,
|
||||
lat: 22.72,
|
||||
teu: 25.4,
|
||||
},
|
||||
{ id: 'busan', name: 'Port of Busan', lon: 129.04, lat: 35.08, teu: 23.0 },
|
||||
{
|
||||
id: 'tianjin',
|
||||
name: 'Port of Tianjin',
|
||||
lon: 117.78,
|
||||
lat: 38.98,
|
||||
teu: 22.0,
|
||||
},
|
||||
{
|
||||
id: 'hong-kong',
|
||||
name: 'Port of Hong Kong',
|
||||
lon: 114.12,
|
||||
lat: 22.33,
|
||||
teu: 14.0,
|
||||
},
|
||||
{
|
||||
id: 'rotterdam',
|
||||
name: 'Port of Rotterdam',
|
||||
lon: 4.05,
|
||||
lat: 51.95,
|
||||
teu: 13.4,
|
||||
},
|
||||
{ id: 'jebel-ali', name: 'Jebel Ali', lon: 55.03, lat: 25.01, teu: 14.5 },
|
||||
{ id: 'port-klang', name: 'Port Klang', lon: 101.32, lat: 2.99, teu: 14.0 },
|
||||
{
|
||||
id: 'antwerp',
|
||||
name: 'Port of Antwerp-Bruges',
|
||||
lon: 4.3,
|
||||
lat: 51.28,
|
||||
teu: 12.5,
|
||||
},
|
||||
{ id: 'xiamen', name: 'Port of Xiamen', lon: 118.07, lat: 24.47, teu: 12.6 },
|
||||
{
|
||||
id: 'tanjung-pelepas',
|
||||
name: 'Tanjung Pelepas',
|
||||
lon: 103.55,
|
||||
lat: 1.36,
|
||||
teu: 10.5,
|
||||
},
|
||||
{
|
||||
id: 'kaohsiung',
|
||||
name: 'Port of Kaohsiung',
|
||||
lon: 120.28,
|
||||
lat: 22.6,
|
||||
teu: 9.5,
|
||||
},
|
||||
{
|
||||
id: 'los-angeles',
|
||||
name: 'Port of Los Angeles',
|
||||
lon: -118.26,
|
||||
lat: 33.73,
|
||||
teu: 9.9,
|
||||
},
|
||||
{
|
||||
id: 'long-beach',
|
||||
name: 'Port of Long Beach',
|
||||
lon: -118.21,
|
||||
lat: 33.75,
|
||||
teu: 9.6,
|
||||
},
|
||||
{ id: 'hamburg', name: 'Port of Hamburg', lon: 9.93, lat: 53.53, teu: 7.7 },
|
||||
{
|
||||
id: 'new-york',
|
||||
name: 'Port of New York & New Jersey',
|
||||
lon: -74.14,
|
||||
lat: 40.67,
|
||||
teu: 8.7,
|
||||
},
|
||||
{
|
||||
id: 'laem-chabang',
|
||||
name: 'Laem Chabang',
|
||||
lon: 100.88,
|
||||
lat: 13.08,
|
||||
teu: 8.9,
|
||||
},
|
||||
{ id: 'tanger-med', name: 'Tanger Med', lon: -5.5, lat: 35.89, teu: 8.6 },
|
||||
{ id: 'colombo', name: 'Port of Colombo', lon: 79.85, lat: 6.95, teu: 7.2 },
|
||||
{
|
||||
id: 'valencia',
|
||||
name: 'Port of Valencia',
|
||||
lon: -0.31,
|
||||
lat: 39.44,
|
||||
teu: 5.5,
|
||||
},
|
||||
{ id: 'piraeus', name: 'Port of Piraeus', lon: 23.61, lat: 37.94, teu: 5.1 },
|
||||
{ id: 'santos', name: 'Port of Santos', lon: -46.3, lat: -23.97, teu: 5.0 },
|
||||
{
|
||||
id: 'savannah',
|
||||
name: 'Port of Savannah',
|
||||
lon: -81.14,
|
||||
lat: 32.12,
|
||||
teu: 5.6,
|
||||
},
|
||||
{ id: 'mundra', name: 'Mundra', lon: 69.7, lat: 22.74, teu: 7.4 },
|
||||
{
|
||||
id: 'jeddah',
|
||||
name: 'Jeddah Islamic Port',
|
||||
lon: 39.15,
|
||||
lat: 21.47,
|
||||
teu: 4.8,
|
||||
},
|
||||
{
|
||||
id: 'felixstowe',
|
||||
name: 'Port of Felixstowe',
|
||||
lon: 1.32,
|
||||
lat: 51.95,
|
||||
teu: 3.7,
|
||||
},
|
||||
]);
|
||||
|
||||
/** Evergreen "Always on" situations. */
|
||||
export const EVERGREEN = Object.freeze([
|
||||
{
|
||||
id: 'rotterdam',
|
||||
title: 'Port of Rotterdam',
|
||||
subtitle: "Europe's largest port · Maasvlakte to the Botlek",
|
||||
place: 'Rotterdam, Netherlands',
|
||||
lon: 4.12,
|
||||
lat: 51.95,
|
||||
radiusKm: 16,
|
||||
camera: { height: 14_000, heading: 80, pitch: -38 },
|
||||
layers: ['vessels', 'ports', 'flights'],
|
||||
family: 'Sea',
|
||||
},
|
||||
{
|
||||
id: 'jfk',
|
||||
title: 'JFK at rush hour',
|
||||
subtitle: 'Arrivals stacking over Jamaica Bay',
|
||||
place: 'New York, USA',
|
||||
lon: -73.7781,
|
||||
lat: 40.6413,
|
||||
radiusKm: 18,
|
||||
camera: { height: 9_000, heading: 30, pitch: -35 },
|
||||
layers: ['flights', 'ga', 'helicopters'],
|
||||
family: 'Sky',
|
||||
airport: true,
|
||||
},
|
||||
{
|
||||
id: 'hormuz',
|
||||
title: 'Strait of Hormuz',
|
||||
subtitle: 'A fifth of the world’s oil passes this 33 km neck',
|
||||
place: 'Strait of Hormuz',
|
||||
lon: 56.45,
|
||||
lat: 26.55,
|
||||
radiusKm: 70,
|
||||
camera: { height: 160_000, heading: 330, pitch: -55 },
|
||||
layers: ['vessels', 'tankers', 'military'],
|
||||
family: 'Sea',
|
||||
},
|
||||
{
|
||||
id: 'singapore-strait',
|
||||
title: 'Singapore Strait',
|
||||
subtitle: 'Ships queuing for the world’s busiest transhipment hub',
|
||||
place: 'Singapore',
|
||||
lon: 103.85,
|
||||
lat: 1.22,
|
||||
radiusKm: 22,
|
||||
camera: { height: 30_000, heading: 20, pitch: -45 },
|
||||
layers: ['vessels', 'ports'],
|
||||
family: 'Sea',
|
||||
},
|
||||
{
|
||||
id: 'suez',
|
||||
title: 'Suez Canal',
|
||||
subtitle: 'Northbound convoy at Port Said',
|
||||
place: 'Egypt',
|
||||
lon: 32.35,
|
||||
lat: 30.6,
|
||||
radiusKm: 45,
|
||||
camera: { height: 90_000, heading: 350, pitch: -50 },
|
||||
layers: ['vessels', 'tankers'],
|
||||
family: 'Sea',
|
||||
},
|
||||
{
|
||||
id: 'panama',
|
||||
title: 'Panama Canal',
|
||||
subtitle: 'Gatun locks and the anchorage off Colón',
|
||||
place: 'Panama',
|
||||
lon: -79.82,
|
||||
lat: 9.2,
|
||||
radiusKm: 30,
|
||||
camera: { height: 60_000, heading: 320, pitch: -50 },
|
||||
layers: ['vessels'],
|
||||
family: 'Sea',
|
||||
},
|
||||
{
|
||||
id: 'heathrow',
|
||||
title: 'Heathrow holding stacks',
|
||||
subtitle: 'Bovingdon, Lambourne, Ockham, Biggin',
|
||||
place: 'London, UK',
|
||||
lon: -0.4543,
|
||||
lat: 51.47,
|
||||
radiusKm: 30,
|
||||
camera: { height: 45_000, heading: 90, pitch: -50 },
|
||||
layers: ['flights', 'ga'],
|
||||
family: 'Sky',
|
||||
airport: true,
|
||||
},
|
||||
{
|
||||
id: 'cape-canaveral',
|
||||
title: 'Cape Canaveral',
|
||||
subtitle: 'Space Coast pads LC-39A and SLC-40',
|
||||
place: 'Florida, USA',
|
||||
lon: -80.6,
|
||||
lat: 28.56,
|
||||
radiusKm: 18,
|
||||
camera: { height: 16_000, heading: 0, pitch: -40 },
|
||||
layers: ['launches', 'satellites'],
|
||||
family: 'Orbit',
|
||||
},
|
||||
]);
|
||||
|
||||
/** Busy airports used to find the busiest airspace right now. */
|
||||
export const AIRPORTS = Object.freeze([
|
||||
{ code: 'ATL', name: 'Atlanta Hartsfield-Jackson', lon: -84.43, lat: 33.64 },
|
||||
{ code: 'DXB', name: 'Dubai International', lon: 55.36, lat: 25.25 },
|
||||
{ code: 'DFW', name: 'Dallas/Fort Worth', lon: -97.04, lat: 32.9 },
|
||||
{ code: 'LHR', name: 'London Heathrow', lon: -0.45, lat: 51.47 },
|
||||
{ code: 'HND', name: 'Tokyo Haneda', lon: 139.78, lat: 35.55 },
|
||||
{ code: 'DEN', name: 'Denver International', lon: -104.67, lat: 39.86 },
|
||||
{ code: 'ORD', name: "Chicago O'Hare", lon: -87.9, lat: 41.98 },
|
||||
{ code: 'IST', name: 'Istanbul Airport', lon: 28.74, lat: 41.26 },
|
||||
{ code: 'LAX', name: 'Los Angeles International', lon: -118.41, lat: 33.94 },
|
||||
{ code: 'CDG', name: 'Paris Charles de Gaulle', lon: 2.55, lat: 49.01 },
|
||||
{ code: 'AMS', name: 'Amsterdam Schiphol', lon: 4.76, lat: 52.31 },
|
||||
{ code: 'FRA', name: 'Frankfurt', lon: 8.57, lat: 50.04 },
|
||||
{ code: 'JFK', name: 'New York JFK', lon: -73.78, lat: 40.64 },
|
||||
{ code: 'SIN', name: 'Singapore Changi', lon: 103.99, lat: 1.36 },
|
||||
{ code: 'ICN', name: 'Seoul Incheon', lon: 126.45, lat: 37.46 },
|
||||
{ code: 'PEK', name: 'Beijing Capital', lon: 116.6, lat: 40.08 },
|
||||
{ code: 'PVG', name: 'Shanghai Pudong', lon: 121.81, lat: 31.14 },
|
||||
{ code: 'CAN', name: 'Guangzhou Baiyun', lon: 113.3, lat: 23.39 },
|
||||
{ code: 'DEL', name: 'Delhi Indira Gandhi', lon: 77.1, lat: 28.56 },
|
||||
{ code: 'MAD', name: 'Madrid Barajas', lon: -3.57, lat: 40.49 },
|
||||
{ code: 'CLT', name: 'Charlotte Douglas', lon: -80.94, lat: 35.21 },
|
||||
{ code: 'LAS', name: 'Las Vegas Harry Reid', lon: -115.15, lat: 36.08 },
|
||||
{ code: 'MIA', name: 'Miami International', lon: -80.29, lat: 25.79 },
|
||||
{ code: 'SFO', name: 'San Francisco', lon: -122.38, lat: 37.62 },
|
||||
{ code: 'BKK', name: 'Bangkok Suvarnabhumi', lon: 100.75, lat: 13.69 },
|
||||
{ code: 'GRU', name: 'São Paulo Guarulhos', lon: -46.47, lat: -23.43 },
|
||||
{ code: 'SYD', name: 'Sydney Kingsford Smith', lon: 151.18, lat: -33.95 },
|
||||
{ code: 'MEX', name: 'Mexico City', lon: -99.07, lat: 19.44 },
|
||||
{ code: 'DOH', name: 'Doha Hamad', lon: 51.61, lat: 25.27 },
|
||||
{ code: 'BOM', name: 'Mumbai', lon: 72.87, lat: 19.09 },
|
||||
]);
|
||||
|
||||
/** Fort Novosel (formerly Fort Rucker), US Army helicopter flight school. */
|
||||
export const FORT_NOVOSEL = Object.freeze({
|
||||
lon: -85.71,
|
||||
lat: 31.35,
|
||||
radiusKm: 60,
|
||||
});
|
||||
@@ -0,0 +1,289 @@
|
||||
/**
|
||||
* Mobile post chain, budgeted for a phone GPU:
|
||||
*
|
||||
* scene (atmosphere + night-lights blend in the globe shader)
|
||||
* → [bloom: bright-pass ½ res → separable blur ¼ res ×2]
|
||||
* → grade (bloom add + lens look + vignette + grain), one full-res pass
|
||||
* → FXAA (Cesium's, always last)
|
||||
*
|
||||
* Cesium's built-in bloom runs a full-resolution multi-pass blur; this one
|
||||
* touches full resolution exactly once. Without bloom (low quality, thermal
|
||||
* pressure) the grade runs alone. The 'clear' lens disables both.
|
||||
*/
|
||||
import * as Cesium from 'cesium';
|
||||
|
||||
export const LENSES = Object.freeze([
|
||||
'cinematic',
|
||||
'clear',
|
||||
'night',
|
||||
'thermal',
|
||||
'nvg',
|
||||
]);
|
||||
const LENS_MODE = { cinematic: 0, clear: 1, night: 2, thermal: 3, nvg: 4 };
|
||||
|
||||
const GRADE = /* glsl */ `
|
||||
uniform sampler2D colorTexture;
|
||||
#ifdef BLOOM
|
||||
uniform sampler2D bloomTexture;
|
||||
uniform float bloomStrength;
|
||||
#endif
|
||||
uniform float mode;
|
||||
uniform float vignette;
|
||||
uniform float exposure;
|
||||
uniform float saturation;
|
||||
uniform float contrast;
|
||||
uniform float grain;
|
||||
uniform float seed;
|
||||
in vec2 v_textureCoordinates;
|
||||
|
||||
vec3 ironbow(float t) {
|
||||
t = clamp(t, 0.0, 1.0);
|
||||
vec3 a = mix(vec3(0.0, 0.0, 0.04), vec3(0.20, 0.0, 0.42), smoothstep(0.0, 0.22, t));
|
||||
a = mix(a, vec3(0.70, 0.0, 0.48), smoothstep(0.18, 0.42, t));
|
||||
a = mix(a, vec3(0.95, 0.25, 0.10), smoothstep(0.38, 0.62, t));
|
||||
a = mix(a, vec3(1.0, 0.68, 0.0), smoothstep(0.58, 0.80, t));
|
||||
return mix(a, vec3(1.0, 1.0, 0.85), smoothstep(0.78, 1.0, t));
|
||||
}
|
||||
|
||||
float hash(vec2 p) {
|
||||
vec3 p3 = fract(vec3(p.xyx) * 0.1031);
|
||||
p3 += dot(p3, p3.yzx + 33.33);
|
||||
return fract((p3.x + p3.y) * p3.z);
|
||||
}
|
||||
|
||||
void main() {
|
||||
vec2 uv = v_textureCoordinates;
|
||||
vec4 src = texture(colorTexture, uv);
|
||||
vec3 c = src.rgb;
|
||||
#ifdef BLOOM
|
||||
vec3 glow = texture(bloomTexture, uv).rgb;
|
||||
c += glow * bloomStrength;
|
||||
#endif
|
||||
float l = dot(c, vec3(0.2126, 0.7152, 0.0722));
|
||||
if (mode < 0.5) {
|
||||
// Cinematic: gentle exposure lift, cool shadows / warm highlights,
|
||||
// a soft shoulder, slightly richer colour.
|
||||
c *= exposure;
|
||||
vec3 cool = vec3(0.90, 0.99, 1.12);
|
||||
vec3 warm = vec3(1.07, 1.0, 0.92);
|
||||
c *= mix(cool, warm, smoothstep(0.08, 0.75, l));
|
||||
c = c / (1.0 + 0.22 * c) * 1.22;
|
||||
float g = dot(c, vec3(0.2126, 0.7152, 0.0722));
|
||||
c = mix(vec3(g), c, saturation);
|
||||
c = (c - 0.45) * contrast + 0.45;
|
||||
} else if (mode < 2.5) {
|
||||
// Night: deep blue-black ground, sodium-warm lights.
|
||||
float g = l;
|
||||
vec3 tint = mix(vec3(0.35, 0.50, 0.95), vec3(1.0, 0.82, 0.55), smoothstep(0.15, 0.7, g));
|
||||
c = mix(c, g * tint * 1.6, 0.55);
|
||||
c = (c - 0.04) * 1.12;
|
||||
} else if (mode < 3.5) {
|
||||
// Thermal: luminance as temperature through an ironbow ramp.
|
||||
float t = pow(clamp(l * 1.15, 0.0, 1.0), 0.85);
|
||||
c = ironbow(t);
|
||||
} else {
|
||||
// NVG: P43 phosphor, intensifier gain and scintillation.
|
||||
float n = hash(uv * vec2(1733.0, 977.0) + seed);
|
||||
float v = pow(clamp(l * 2.2, 0.0, 1.0), 0.7) + (n - 0.5) * 0.05 * (1.0 - l);
|
||||
c = vec3(0.30, 1.0, 0.45) * v + vec3(0.0, 0.035, 0.01);
|
||||
}
|
||||
vec2 d = (uv - 0.5) * vec2(1.0, 1.1);
|
||||
float vig = 1.0 - vignette * smoothstep(0.25, 0.85, length(d));
|
||||
if (mode > 3.5) vig *= smoothstep(0.78, 0.62, length(d * vec2(0.9, 0.6)));
|
||||
c *= vig;
|
||||
c += (hash(uv * 911.0 + seed) - 0.5) * grain;
|
||||
out_FragColor = vec4(clamp(c, 0.0, 1.0), src.a);
|
||||
}
|
||||
`;
|
||||
|
||||
const BRIGHT = /* glsl */ `
|
||||
uniform sampler2D colorTexture;
|
||||
uniform vec2 colorTextureDimensions;
|
||||
uniform float threshold;
|
||||
in vec2 v_textureCoordinates;
|
||||
void main() {
|
||||
vec2 t = 0.5 / colorTextureDimensions;
|
||||
vec3 c = texture(colorTexture, v_textureCoordinates + vec2(-t.x, -t.y)).rgb;
|
||||
c += texture(colorTexture, v_textureCoordinates + vec2(t.x, -t.y)).rgb;
|
||||
c += texture(colorTexture, v_textureCoordinates + vec2(-t.x, t.y)).rgb;
|
||||
c += texture(colorTexture, v_textureCoordinates + vec2(t.x, t.y)).rgb;
|
||||
c *= 0.25;
|
||||
float l = max(c.r, max(c.g, c.b));
|
||||
float k = smoothstep(threshold, threshold + 0.3, l);
|
||||
out_FragColor = vec4(c * k, 1.0);
|
||||
}
|
||||
`;
|
||||
|
||||
const BLUR = /* glsl */ `
|
||||
uniform sampler2D colorTexture;
|
||||
uniform vec2 colorTextureDimensions;
|
||||
uniform vec2 direction;
|
||||
in vec2 v_textureCoordinates;
|
||||
void main() {
|
||||
vec2 o = direction / colorTextureDimensions;
|
||||
vec3 c = texture(colorTexture, v_textureCoordinates).rgb * 0.2270270270;
|
||||
c += texture(colorTexture, v_textureCoordinates + o * 1.3846153846).rgb * 0.3162162162;
|
||||
c += texture(colorTexture, v_textureCoordinates - o * 1.3846153846).rgb * 0.3162162162;
|
||||
c += texture(colorTexture, v_textureCoordinates + o * 3.2307692308).rgb * 0.0702702703;
|
||||
c += texture(colorTexture, v_textureCoordinates - o * 3.2307692308).rgb * 0.0702702703;
|
||||
out_FragColor = vec4(c, 1.0);
|
||||
}
|
||||
`;
|
||||
|
||||
const LOOKS = {
|
||||
cinematic: {
|
||||
exposure: 0.98,
|
||||
saturation: 1.12,
|
||||
contrast: 1.1,
|
||||
vignette: 0.38,
|
||||
grain: 0.012,
|
||||
bloom: 0.85,
|
||||
},
|
||||
night: {
|
||||
exposure: 1,
|
||||
saturation: 1,
|
||||
contrast: 1,
|
||||
vignette: 0.45,
|
||||
grain: 0.015,
|
||||
bloom: 1.2,
|
||||
},
|
||||
thermal: {
|
||||
exposure: 1,
|
||||
saturation: 1,
|
||||
contrast: 1,
|
||||
vignette: 0.3,
|
||||
grain: 0.006,
|
||||
bloom: 0.5,
|
||||
},
|
||||
nvg: {
|
||||
exposure: 1,
|
||||
saturation: 1,
|
||||
contrast: 1,
|
||||
vignette: 0.2,
|
||||
grain: 0.012,
|
||||
bloom: 0.9,
|
||||
},
|
||||
clear: {
|
||||
exposure: 1,
|
||||
saturation: 1,
|
||||
contrast: 1,
|
||||
vignette: 0,
|
||||
grain: 0,
|
||||
bloom: 0,
|
||||
},
|
||||
};
|
||||
|
||||
export function createPostChain(scene) {
|
||||
const params = {
|
||||
mode: 0,
|
||||
lens: 'cinematic',
|
||||
bloomAllowed: true,
|
||||
seed: 0,
|
||||
...LOOKS.cinematic,
|
||||
};
|
||||
const gradeUniforms = {
|
||||
mode: () => params.mode,
|
||||
vignette: () => params.vignette,
|
||||
exposure: () => params.exposure,
|
||||
saturation: () => params.saturation,
|
||||
contrast: () => params.contrast,
|
||||
grain: () => params.grain,
|
||||
seed: () => params.seed,
|
||||
};
|
||||
|
||||
const stages = scene.postProcessStages;
|
||||
stages.fxaa.enabled = true;
|
||||
|
||||
const grade = stages.add(
|
||||
new Cesium.PostProcessStage({
|
||||
name: 'gev_mobile_grade',
|
||||
fragmentShader: GRADE,
|
||||
uniforms: gradeUniforms,
|
||||
}),
|
||||
);
|
||||
|
||||
const generate = new Cesium.PostProcessStageComposite({
|
||||
name: 'gev_mobile_bloom_generate',
|
||||
stages: [
|
||||
new Cesium.PostProcessStage({
|
||||
name: 'gev_mobile_bloom_bright',
|
||||
fragmentShader: BRIGHT,
|
||||
textureScale: 0.5,
|
||||
sampleMode: Cesium.PostProcessStageSampleMode.LINEAR,
|
||||
uniforms: { threshold: () => (params.mode === 2 ? 0.35 : 0.72) },
|
||||
}),
|
||||
new Cesium.PostProcessStage({
|
||||
name: 'gev_mobile_bloom_blur_x',
|
||||
fragmentShader: BLUR,
|
||||
textureScale: 0.25,
|
||||
sampleMode: Cesium.PostProcessStageSampleMode.LINEAR,
|
||||
uniforms: { direction: new Cesium.Cartesian2(1.6, 0) },
|
||||
}),
|
||||
new Cesium.PostProcessStage({
|
||||
name: 'gev_mobile_bloom_blur_y',
|
||||
fragmentShader: BLUR,
|
||||
textureScale: 0.25,
|
||||
sampleMode: Cesium.PostProcessStageSampleMode.LINEAR,
|
||||
uniforms: { direction: new Cesium.Cartesian2(0, 1.6) },
|
||||
}),
|
||||
],
|
||||
});
|
||||
const bloom = stages.add(
|
||||
new Cesium.PostProcessStageComposite({
|
||||
name: 'gev_mobile_bloom',
|
||||
inputPreviousStageTexture: false,
|
||||
stages: [
|
||||
generate,
|
||||
new Cesium.PostProcessStage({
|
||||
name: 'gev_mobile_grade_bloom',
|
||||
fragmentShader: `#define BLOOM\n${GRADE}`,
|
||||
uniforms: {
|
||||
...gradeUniforms,
|
||||
bloomTexture: generate.name,
|
||||
bloomStrength: () => params.bloom,
|
||||
},
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
function apply() {
|
||||
const clear = params.lens === 'clear';
|
||||
const useBloom = !clear && params.bloomAllowed && params.bloom > 0;
|
||||
grade.enabled = !clear && !useBloom;
|
||||
bloom.enabled = useBloom;
|
||||
scene.requestRender();
|
||||
}
|
||||
|
||||
// Refresh the grain/scintillation seed only on frames that render anyway.
|
||||
const removeSeed = scene.preRender.addEventListener(() => {
|
||||
params.seed = (params.seed + 0.6180339887) % 1;
|
||||
});
|
||||
|
||||
apply();
|
||||
|
||||
return {
|
||||
setLens(lens) {
|
||||
if (!LENSES.includes(lens)) throw new RangeError(`Unknown lens: ${lens}`);
|
||||
params.lens = lens;
|
||||
params.mode = LENS_MODE[lens];
|
||||
Object.assign(params, LOOKS[lens]);
|
||||
apply();
|
||||
},
|
||||
get lens() {
|
||||
return params.lens;
|
||||
},
|
||||
setBloomAllowed(on) {
|
||||
params.bloomAllowed = Boolean(on);
|
||||
apply();
|
||||
},
|
||||
get bloomActive() {
|
||||
return bloom.enabled;
|
||||
},
|
||||
destroy() {
|
||||
removeSeed();
|
||||
stages.remove(grade);
|
||||
stages.remove(bloom);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
/**
|
||||
* Adaptive quality governor (DESIGN.md §5).
|
||||
*
|
||||
* The scene renders on demand, so idle time is not slow time: only intervals
|
||||
* between back-to-back frames (< 250 ms apart, i.e. while something animates)
|
||||
* count. Over a rolling 2 s window, a mean frame time above 20 ms steps the
|
||||
* pixel ratio down 2 → 1.5 → 1.25 → 1 and, from 1.25 down, switches bloom
|
||||
* off. Eight calm seconds at vsync (< 17.5 ms) step back up toward the mode's ceiling.
|
||||
* Battery saver (Battery API) and 'low' pin the floor.
|
||||
*/
|
||||
|
||||
export const SCALE_STEPS = Object.freeze([1, 1.25, 1.5, 2]);
|
||||
const WINDOW_MS = 2000;
|
||||
const UPGRADE_CALM_MS = 8000;
|
||||
const SLOW_MS = 20;
|
||||
const FAST_MS = 17.5; // holding vsync at 60 Hz
|
||||
|
||||
export function createGovernor(
|
||||
viewer,
|
||||
{
|
||||
quality = 'auto',
|
||||
post,
|
||||
onChange,
|
||||
devicePixelRatio = globalThis.devicePixelRatio || 1,
|
||||
} = {},
|
||||
) {
|
||||
const scene = viewer.scene;
|
||||
const globe = scene.globe;
|
||||
const dprCap = Math.min(devicePixelRatio, 2);
|
||||
const ceiling = SCALE_STEPS.filter((s) => s <= dprCap + 1e-6).length - 1;
|
||||
let mode = quality;
|
||||
let index = mode === 'low' ? 0 : ceiling;
|
||||
let lowPower = false;
|
||||
let samples = [];
|
||||
let last = 0;
|
||||
let calmSince = 0;
|
||||
let fps = 0;
|
||||
let bloomCut = false; // set when still slow at the floor
|
||||
|
||||
function apply() {
|
||||
const scale = SCALE_STEPS[index];
|
||||
viewer.useBrowserRecommendedResolution = true; // resolutionScale = CSS→device ratio
|
||||
viewer.resolutionScale = scale;
|
||||
// Screen-space error is measured in drawing-buffer pixels: scale it with
|
||||
// the pixel ratio so a 2x phone fetches the tiles a 1x screen would,
|
||||
// with a compact-screen bias (§5: 2 → 4).
|
||||
const compact =
|
||||
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 ? 100 : 320;
|
||||
// The first step down keeps bloom; the second (or being slow at the
|
||||
// floor) drops it.
|
||||
post?.setBloomAllowed(
|
||||
mode !== 'low' && !lowPower && !bloomCut && ceiling - index < 2,
|
||||
);
|
||||
scene.requestRender();
|
||||
onChange?.({
|
||||
scale,
|
||||
fps: Math.round(fps),
|
||||
bloom: Boolean(post?.bloomActive),
|
||||
quality: mode,
|
||||
});
|
||||
}
|
||||
|
||||
let renderStart = 0;
|
||||
const removePre = scene.preRender.addEventListener(() => {
|
||||
renderStart = performance.now();
|
||||
});
|
||||
const remove = scene.postRender.addEventListener(() => {
|
||||
const now = performance.now();
|
||||
const dt = now - last;
|
||||
const cost = now - renderStart;
|
||||
last = now;
|
||||
// Back-to-back frames measure the real frame time; an isolated frame
|
||||
// after an idle gap still reports what the render itself cost.
|
||||
const sample = dt > 0 && dt <= 250 ? dt : cost;
|
||||
samples.push([now, sample]);
|
||||
while (samples.length && now - samples[0][0] > WINDOW_MS) samples.shift();
|
||||
let slowCount = 0;
|
||||
for (const s of samples) if (s[1] > 100) slowCount++;
|
||||
if (samples.length < 20 && slowCount < 3) return;
|
||||
let sum = 0;
|
||||
for (const s of samples) sum += s[1];
|
||||
const mean = sum / samples.length;
|
||||
fps = 1000 / mean;
|
||||
if (mode !== 'auto') return;
|
||||
if (mean > SLOW_MS && (index > 0 || (!bloomCut && post?.bloomActive))) {
|
||||
if (index > 0) index--;
|
||||
else bloomCut = true;
|
||||
samples = [];
|
||||
calmSince = now;
|
||||
apply();
|
||||
} else if (mean < FAST_MS) {
|
||||
if (!calmSince) calmSince = now;
|
||||
if (
|
||||
now - calmSince > UPGRADE_CALM_MS &&
|
||||
(index < ceiling || bloomCut) &&
|
||||
!lowPower
|
||||
) {
|
||||
if (bloomCut) bloomCut = false;
|
||||
else index++;
|
||||
samples = [];
|
||||
calmSince = now;
|
||||
apply();
|
||||
}
|
||||
} else calmSince = now;
|
||||
});
|
||||
|
||||
// Battery saver: drop to the floor while discharging under 20 %.
|
||||
globalThis.navigator?.getBattery?.().then(
|
||||
(battery) => {
|
||||
const check = () => {
|
||||
const next = !battery.charging && battery.level < 0.2;
|
||||
if (next === lowPower) return;
|
||||
lowPower = next;
|
||||
if (lowPower) index = 0;
|
||||
apply();
|
||||
};
|
||||
battery.addEventListener('levelchange', check);
|
||||
battery.addEventListener('chargingchange', check);
|
||||
check();
|
||||
},
|
||||
() => {},
|
||||
);
|
||||
|
||||
apply();
|
||||
|
||||
return {
|
||||
setQuality(next) {
|
||||
mode = next;
|
||||
index = mode === 'low' ? 0 : ceiling;
|
||||
bloomCut = false;
|
||||
apply();
|
||||
},
|
||||
state() {
|
||||
return {
|
||||
scale: SCALE_STEPS[index],
|
||||
fps: Math.round(fps),
|
||||
bloom: Boolean(post?.bloomActive),
|
||||
quality: mode,
|
||||
lowPower,
|
||||
};
|
||||
},
|
||||
destroy() {
|
||||
remove();
|
||||
removePre();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
/**
|
||||
* Geolocated reactions (DESIGN.md §12.6): hearts, fire, eyes and wow rise
|
||||
* from the reactor's place on the globe. A screen-space canvas over the
|
||||
* viewer (pointer-events: none) draws pre-rendered emoji sprites; each
|
||||
* particle is re-projected every frame so the burst stays anchored to its
|
||||
* point while the globe moves. The loop runs only while particles live.
|
||||
*
|
||||
* attentionHeat(points) draws a soft additive glow where viewers are; it is
|
||||
* redrawn after every globe render, so it costs nothing at rest.
|
||||
*/
|
||||
const KINDS = { heart: '❤️', fire: '🔥', eyes: '👀', wow: '😮' };
|
||||
const CAP = 300;
|
||||
const LIFE_S = 2.6;
|
||||
|
||||
export function createReactions({ host, project, onFrame }) {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.className = 'gev-reactions';
|
||||
canvas.style.cssText =
|
||||
'position:absolute;inset:0;width:100%;height:100%;pointer-events:none;z-index:2;';
|
||||
host.appendChild(canvas);
|
||||
const g = canvas.getContext('2d');
|
||||
const sprites = new Map();
|
||||
const parts = [];
|
||||
let heat = null;
|
||||
let raf = 0;
|
||||
let dpr = 1;
|
||||
|
||||
function sprite(kind) {
|
||||
let s = sprites.get(kind);
|
||||
if (s) return s;
|
||||
const size = 64;
|
||||
const c = document.createElement('canvas');
|
||||
c.width = c.height = size;
|
||||
const x = c.getContext('2d');
|
||||
x.textAlign = 'center';
|
||||
x.textBaseline = 'middle';
|
||||
x.font = `${size * 0.78}px "Apple Color Emoji","Segoe UI Emoji","Noto Color Emoji",sans-serif`;
|
||||
x.shadowColor = 'rgba(0,0,0,0.45)';
|
||||
x.shadowBlur = 6;
|
||||
x.fillText(KINDS[kind] || KINDS.heart, size / 2, size / 2 + 3);
|
||||
s = c;
|
||||
sprites.set(kind, s);
|
||||
return s;
|
||||
}
|
||||
|
||||
function resize() {
|
||||
dpr = Math.min(2, globalThis.devicePixelRatio || 1);
|
||||
const w = Math.round(host.clientWidth * dpr);
|
||||
const h = Math.round(host.clientHeight * dpr);
|
||||
if (canvas.width !== w || canvas.height !== h) {
|
||||
canvas.width = w;
|
||||
canvas.height = h;
|
||||
}
|
||||
}
|
||||
|
||||
function drawHeat() {
|
||||
if (!heat?.length) return;
|
||||
g.save();
|
||||
g.globalCompositeOperation = 'lighter';
|
||||
for (const p of heat) {
|
||||
const s = project(p.lon, p.lat, 0);
|
||||
if (!s) continue;
|
||||
const r = (26 + 40 * Math.sqrt(p.weight ?? 1)) * dpr;
|
||||
const grad = g.createRadialGradient(
|
||||
s.x * dpr,
|
||||
s.y * dpr,
|
||||
0,
|
||||
s.x * dpr,
|
||||
s.y * dpr,
|
||||
r,
|
||||
);
|
||||
grad.addColorStop(0, 'rgba(255,120,150,0.30)');
|
||||
grad.addColorStop(0.45, 'rgba(255,90,140,0.12)');
|
||||
grad.addColorStop(1, 'rgba(255,90,140,0)');
|
||||
g.fillStyle = grad;
|
||||
g.fillRect(s.x * dpr - r, s.y * dpr - r, r * 2, r * 2);
|
||||
}
|
||||
g.restore();
|
||||
}
|
||||
|
||||
function draw(now) {
|
||||
resize();
|
||||
g.clearRect(0, 0, canvas.width, canvas.height);
|
||||
drawHeat();
|
||||
for (let i = parts.length - 1; i >= 0; i--) {
|
||||
const p = parts[i];
|
||||
const age = (now - p.born) / 1000;
|
||||
if (age > LIFE_S) {
|
||||
parts.splice(i, 1);
|
||||
continue;
|
||||
}
|
||||
if (age < 0) continue;
|
||||
const s = project(p.lon, p.lat, p.altM);
|
||||
if (!s) continue;
|
||||
const f = age / LIFE_S;
|
||||
const rise = p.rise * (1 - (1 - f) ** 2.2);
|
||||
const x = s.x + p.dx * f + Math.sin(age * p.wobble + p.phase) * 9;
|
||||
const y = s.y - rise;
|
||||
const size = p.size * (age < 0.18 ? age / 0.18 : 1) * (1 - 0.25 * f);
|
||||
g.globalAlpha = f < 0.7 ? 1 : 1 - (f - 0.7) / 0.3;
|
||||
g.drawImage(
|
||||
sprite(p.kind),
|
||||
(x - size / 2) * dpr,
|
||||
(y - size / 2) * dpr,
|
||||
size * dpr,
|
||||
size * dpr,
|
||||
);
|
||||
}
|
||||
g.globalAlpha = 1;
|
||||
}
|
||||
|
||||
function loop(now) {
|
||||
draw(now);
|
||||
raf = parts.length ? requestAnimationFrame(loop) : 0;
|
||||
if (!parts.length) onFrame?.();
|
||||
}
|
||||
|
||||
return {
|
||||
react({ lon, lat, kind = 'heart', count = 1, altM = 0 } = {}) {
|
||||
if (!Number.isFinite(lon) || !Number.isFinite(lat)) return 0;
|
||||
const k = KINDS[kind] ? kind : 'heart';
|
||||
const n = Math.max(1, Math.min(40, Math.round(count)));
|
||||
const now = performance.now();
|
||||
for (let i = 0; i < n; i++) {
|
||||
if (parts.length >= CAP) parts.shift();
|
||||
parts.push({
|
||||
lon,
|
||||
lat,
|
||||
altM,
|
||||
kind: k,
|
||||
born: now + i * (90 + Math.random() * 70),
|
||||
rise: 120 + Math.random() * 110,
|
||||
dx: (Math.random() - 0.5) * 70,
|
||||
wobble: 2 + Math.random() * 3,
|
||||
phase: Math.random() * 6.28,
|
||||
size: 22 + Math.random() * 16,
|
||||
});
|
||||
}
|
||||
if (!raf) raf = requestAnimationFrame(loop);
|
||||
return n;
|
||||
},
|
||||
heat(points) {
|
||||
heat = Array.isArray(points)
|
||||
? points.filter(
|
||||
(p) => Number.isFinite(p?.lon) && Number.isFinite(p?.lat),
|
||||
)
|
||||
: null;
|
||||
if (!raf) {
|
||||
resize();
|
||||
g.clearRect(0, 0, canvas.width, canvas.height);
|
||||
drawHeat();
|
||||
}
|
||||
},
|
||||
/** Redraw the heat after a globe render (camera moved). */
|
||||
afterRender() {
|
||||
if (raf || !heat?.length) return;
|
||||
resize();
|
||||
g.clearRect(0, 0, canvas.width, canvas.height);
|
||||
drawHeat();
|
||||
},
|
||||
get count() {
|
||||
return parts.length;
|
||||
},
|
||||
destroy() {
|
||||
cancelAnimationFrame(raf);
|
||||
canvas.remove();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,558 @@
|
||||
/**
|
||||
* The recorder (DESIGN.md §12.5, "it remembers"): every live contact's
|
||||
* position on every poll, kept for about six hours.
|
||||
*
|
||||
* Memory layout: one growable Int32Array per entity, five ints per sample:
|
||||
* t (epoch s) · lon×1e6 · lat×1e6 · alt (m) · heading×10 << 12 | speed (dm/s)
|
||||
* That is 20 bytes a sample. Low traffic (helicopters, approaches, anything
|
||||
* below ~6 km) keeps every poll (≥ 6 s apart); cruise traffic keeps one fix
|
||||
* every 50 s, since straight lines interpolate. Samples older than 30 min
|
||||
* are thinned (1/min low, 1/3 min cruise), and the whole buffer is capped at
|
||||
* 1.2 M samples (~24 MB; oldest minutes are evicted first).
|
||||
*
|
||||
* Persistence: once a minute the new samples go to IndexedDB as one segment
|
||||
* (at most one sample per entity per 30 s, per 2 min for cruise), with each
|
||||
* entity's metadata.
|
||||
* Segments older than the window are deleted. On start the recorder replays
|
||||
* the stored segments, so a reload keeps its history.
|
||||
*/
|
||||
import { idbDeleteBelow, idbEntries, idbPut, idbPutMany } from './idb.js';
|
||||
|
||||
const STRIDE = 5;
|
||||
const MIN_GAP_S = 6;
|
||||
// Cruise traffic (above ~6 km) flies straight lines: one fix a minute
|
||||
// interpolates to within metres of the truth, so it is recorded sparsely.
|
||||
// Low traffic (helicopters, approaches, circling) keeps every poll.
|
||||
const CRUISE_ALT_M = 6000;
|
||||
const CRUISE_GAP_S = 50;
|
||||
const FINE_WINDOW_S = 30 * 60;
|
||||
const COARSE_GAP_S = 60;
|
||||
const CRUISE_COARSE_GAP_S = 180;
|
||||
const PERSIST_GAP_S = 30;
|
||||
const CRUISE_PERSIST_GAP_S = 120;
|
||||
const MAX_INTERP_GAP_S = 6 * 60;
|
||||
const D2R = Math.PI / 180;
|
||||
|
||||
function packHs(heading, speed) {
|
||||
const h = Math.round(((((heading ?? 0) % 360) + 360) % 360) * 10) & 0xfff;
|
||||
const s = Math.max(0, Math.min(4095, Math.round((speed ?? 0) * 10)));
|
||||
return (h << 12) | s;
|
||||
}
|
||||
const unpackH = (v) => ((v >>> 12) & 0xfff) / 10;
|
||||
const unpackS = (v) => (v & 0xfff) / 10;
|
||||
|
||||
class Track {
|
||||
constructor(key, meta) {
|
||||
this.key = key;
|
||||
this.meta = meta;
|
||||
this.buf = new Int32Array(STRIDE * 16);
|
||||
this.n = 0;
|
||||
this.persistedT = 0;
|
||||
}
|
||||
get firstT() {
|
||||
return this.n ? this.buf[0] : 0;
|
||||
}
|
||||
get lastT() {
|
||||
return this.n ? this.buf[(this.n - 1) * STRIDE] : 0;
|
||||
}
|
||||
push(t, lon, lat, alt, hs) {
|
||||
if (this.n && t <= this.lastT) {
|
||||
// Out-of-order (a merged backfill): insert in place.
|
||||
return this.insert(t, lon, lat, alt, hs);
|
||||
}
|
||||
if ((this.n + 1) * STRIDE > this.buf.length) {
|
||||
const next = new Int32Array(this.buf.length * 2);
|
||||
next.set(this.buf);
|
||||
this.buf = next;
|
||||
}
|
||||
const o = this.n * STRIDE;
|
||||
this.buf[o] = t;
|
||||
this.buf[o + 1] = lon;
|
||||
this.buf[o + 2] = lat;
|
||||
this.buf[o + 3] = alt;
|
||||
this.buf[o + 4] = hs;
|
||||
this.n++;
|
||||
return true;
|
||||
}
|
||||
insert(t, lon, lat, alt, hs) {
|
||||
const i = this.lowerBound(t);
|
||||
if (i < this.n && Math.abs(this.buf[i * STRIDE] - t) < 2) return false;
|
||||
if (i > 0 && Math.abs(this.buf[(i - 1) * STRIDE] - t) < 2) return false;
|
||||
if ((this.n + 1) * STRIDE > this.buf.length) {
|
||||
const next = new Int32Array(this.buf.length * 2);
|
||||
next.set(this.buf);
|
||||
this.buf = next;
|
||||
}
|
||||
this.buf.copyWithin((i + 1) * STRIDE, i * STRIDE, this.n * STRIDE);
|
||||
const o = i * STRIDE;
|
||||
this.buf[o] = t;
|
||||
this.buf[o + 1] = lon;
|
||||
this.buf[o + 2] = lat;
|
||||
this.buf[o + 3] = alt;
|
||||
this.buf[o + 4] = hs;
|
||||
this.n++;
|
||||
return true;
|
||||
}
|
||||
/** First index with t >= value. */
|
||||
lowerBound(t) {
|
||||
let lo = 0;
|
||||
let hi = this.n;
|
||||
while (lo < hi) {
|
||||
const mid = (lo + hi) >> 1;
|
||||
if (this.buf[mid * STRIDE] < t) lo = mid + 1;
|
||||
else hi = mid;
|
||||
}
|
||||
return lo;
|
||||
}
|
||||
sample(i) {
|
||||
const o = i * STRIDE;
|
||||
const b = this.buf;
|
||||
return {
|
||||
t: b[o] * 1000,
|
||||
lon: b[o + 1] / 1e6,
|
||||
lat: b[o + 2] / 1e6,
|
||||
altM: b[o + 3],
|
||||
headingDeg: unpackH(b[o + 4]),
|
||||
speed: unpackS(b[o + 4]),
|
||||
};
|
||||
}
|
||||
/** Remove samples before t; thin samples between t and fineFrom. */
|
||||
compact(dropBefore, fineFrom) {
|
||||
const b = this.buf;
|
||||
let w = 0;
|
||||
let lastKept = -Infinity;
|
||||
for (let i = 0; i < this.n; i++) {
|
||||
const o = i * STRIDE;
|
||||
const t = b[o];
|
||||
if (t < dropBefore) continue;
|
||||
const isLast = i === this.n - 1;
|
||||
const gap = b[o + 3] > CRUISE_ALT_M ? CRUISE_COARSE_GAP_S : COARSE_GAP_S;
|
||||
if (t < fineFrom && !isLast && t - lastKept < gap) continue;
|
||||
if (w !== i) b.copyWithin(w * STRIDE, o, o + STRIDE);
|
||||
lastKept = t;
|
||||
w++;
|
||||
}
|
||||
const removed = this.n - w;
|
||||
this.n = w;
|
||||
if (this.buf.length > 64 && this.n * STRIDE * 4 < this.buf.length) {
|
||||
this.buf = this.buf.slice(0, Math.max(STRIDE * 16, this.n * STRIDE * 2));
|
||||
}
|
||||
return removed;
|
||||
}
|
||||
}
|
||||
|
||||
const lerp = (a, b, f) => a + (b - a) * f;
|
||||
function lerpAngle(a, b, f) {
|
||||
const d = ((b - a + 540) % 360) - 180;
|
||||
return (a + d * f + 360) % 360;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{ windowMs?: number, maxSamples?: number, persist?: boolean }} [opts]
|
||||
*/
|
||||
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;
|
||||
let pending = []; // [key, t, lon, lat, alt, hs]
|
||||
let segSeq = 0;
|
||||
const dirtyMeta = new Set();
|
||||
const listeners = new Set();
|
||||
const events = []; // { t, kind, key, title } (first squawk, first seen helicopters…)
|
||||
let loaded = false;
|
||||
let loading = null;
|
||||
let minT = Infinity;
|
||||
let maxT = -Infinity;
|
||||
|
||||
function bounds() {
|
||||
minT = Infinity;
|
||||
maxT = -Infinity;
|
||||
for (const tr of tracks.values()) {
|
||||
if (!tr.n) continue;
|
||||
if (tr.firstT < minT) minT = tr.firstT;
|
||||
if (tr.lastT > maxT) maxT = tr.lastT;
|
||||
}
|
||||
}
|
||||
|
||||
function append(key, meta, t, lon, lat, alt, hs, { fromStore = false } = {}) {
|
||||
let tr = tracks.get(key);
|
||||
if (!tr) {
|
||||
tr = new Track(key, meta);
|
||||
tracks.set(key, tr);
|
||||
} else if (meta) {
|
||||
const prev = tr.meta;
|
||||
if (
|
||||
!prev ||
|
||||
prev.squawk !== meta.squawk ||
|
||||
prev.callsign !== meta.callsign ||
|
||||
(meta.registration && prev.registration !== meta.registration)
|
||||
) {
|
||||
// Flags learned from any source stick (one feed may omit the
|
||||
// rotorcraft category another one reported).
|
||||
tr.meta = { ...prev, ...meta };
|
||||
for (const flag of ['helicopter', 'military', 'pia', 'ladd'])
|
||||
if (prev?.[flag]) tr.meta[flag] = true;
|
||||
if (!fromStore) dirtyMeta.add(key);
|
||||
}
|
||||
}
|
||||
const cruise = alt > CRUISE_ALT_M;
|
||||
if (
|
||||
tr.n &&
|
||||
t > tr.lastT &&
|
||||
t - tr.lastT < (cruise && !fromStore ? CRUISE_GAP_S : MIN_GAP_S)
|
||||
)
|
||||
return false;
|
||||
if (!tr.push(t, lon, lat, alt, hs)) return false;
|
||||
total++;
|
||||
if (t < minT) minT = t;
|
||||
if (t > maxT) maxT = t;
|
||||
if (
|
||||
!fromStore &&
|
||||
persist &&
|
||||
t - tr.persistedT >= (cruise ? CRUISE_PERSIST_GAP_S : PERSIST_GAP_S)
|
||||
) {
|
||||
tr.persistedT = t;
|
||||
pending.push([key, t, lon, lat, alt, hs]);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a feed's records. `kind` names the source family:
|
||||
* 'aircraft' (flights / regional), 'military', 'vessel'.
|
||||
*/
|
||||
function ingest(records, { kind = 'aircraft', at = Date.now() } = {}) {
|
||||
if (!records?.length) return 0;
|
||||
let n = 0;
|
||||
const nowS = Math.floor(at / 1000);
|
||||
for (const r of records) {
|
||||
if (!Number.isFinite(r.lon) || !Number.isFinite(r.lat)) continue;
|
||||
// The transponder's own last-contact time is the truth: a stale
|
||||
// upstream repeats old positions with old times, never "now".
|
||||
const seen = Number.isFinite(r.seenAt)
|
||||
? Math.floor(Math.min(r.seenAt, at + 5000) / 1000)
|
||||
: nowS;
|
||||
if (nowS - seen > 15 * 60) continue;
|
||||
const key = kind === 'vessel' ? `v:${r.id}` : String(r.id);
|
||||
const meta =
|
||||
kind === 'vessel'
|
||||
? { kind: 'vessel', layer: 'vessels', title: r.name, type: r.type }
|
||||
: {
|
||||
kind: r.military
|
||||
? 'military'
|
||||
: r.helicopter
|
||||
? 'helicopter'
|
||||
: 'aircraft',
|
||||
layer: r.military ? 'military' : 'flights',
|
||||
callsign: r.callsign,
|
||||
registration: r.registration || undefined,
|
||||
type: r.type || undefined,
|
||||
country: r.country || undefined,
|
||||
squawk: r.squawk || undefined,
|
||||
helicopter: Boolean(r.helicopter),
|
||||
military: Boolean(r.military),
|
||||
ga: Boolean(r.ga),
|
||||
category: r.category ?? undefined,
|
||||
onGround: Boolean(r.onGround),
|
||||
...(r.pia ? { pia: true } : {}),
|
||||
...(r.ladd ? { ladd: true } : {}),
|
||||
};
|
||||
const before = tracks.get(key);
|
||||
const hadEmergency = before?.meta?.emergency;
|
||||
if (r.emergency) meta.emergency = true;
|
||||
if (
|
||||
append(
|
||||
key,
|
||||
meta,
|
||||
seen,
|
||||
Math.round(r.lon * 1e6),
|
||||
Math.round(r.lat * 1e6),
|
||||
Math.round(r.altM ?? 0),
|
||||
packHs(r.heading, r.speed),
|
||||
)
|
||||
)
|
||||
n++;
|
||||
if (r.emergency && !hadEmergency)
|
||||
events.push({
|
||||
t: seen * 1000,
|
||||
kind: 'squawk',
|
||||
key,
|
||||
title: `${r.callsign || r.id} squawking ${r.squawk}`,
|
||||
lon: r.lon,
|
||||
lat: r.lat,
|
||||
});
|
||||
}
|
||||
if (events.length > 400) events.splice(0, events.length - 400);
|
||||
if (n) for (const fn of listeners) fn({ added: n, kind });
|
||||
return n;
|
||||
}
|
||||
|
||||
/** Merge an externally fetched track ([{t, lon, lat, altM, headingDeg?, speed?}]). */
|
||||
function merge(key, points, meta) {
|
||||
let n = 0;
|
||||
for (const p of points) {
|
||||
if (!Number.isFinite(p.t) || !Number.isFinite(p.lon)) continue;
|
||||
if (
|
||||
append(
|
||||
key,
|
||||
meta,
|
||||
Math.floor(p.t / 1000),
|
||||
Math.round(p.lon * 1e6),
|
||||
Math.round(p.lat * 1e6),
|
||||
Math.round(p.altM ?? 0),
|
||||
packHs(p.headingDeg, p.speed),
|
||||
{ fromStore: true },
|
||||
)
|
||||
)
|
||||
n++;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
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;
|
||||
let removed = 0;
|
||||
for (const [key, tr] of tracks) {
|
||||
removed += tr.compact(dropBefore, fineFrom);
|
||||
if (!tr.n) tracks.delete(key);
|
||||
}
|
||||
total -= removed;
|
||||
// Over budget: evict the oldest ten minutes at a time.
|
||||
while (total > maxSamples && dropBefore < nowS - 600) {
|
||||
dropBefore += 600;
|
||||
for (const [key, tr] of tracks) {
|
||||
total -= tr.compact(dropBefore, fineFrom);
|
||||
if (!tr.n) tracks.delete(key);
|
||||
}
|
||||
}
|
||||
bounds();
|
||||
}
|
||||
|
||||
async function flush() {
|
||||
if (!persist || !pending.length) return;
|
||||
const rows = pending;
|
||||
pending = [];
|
||||
const keys = [];
|
||||
const index = new Map();
|
||||
const data = new Int32Array(rows.length * 6);
|
||||
rows.forEach(([key, t, lon, lat, alt, hs], i) => {
|
||||
let k = index.get(key);
|
||||
if (k == null) {
|
||||
k = keys.length;
|
||||
keys.push(key);
|
||||
index.set(key, k);
|
||||
}
|
||||
data.set([k, t, lon, lat, alt, hs], i * 6);
|
||||
});
|
||||
const segKey = rows[0][1] + (segSeq++ % 1000) / 1000;
|
||||
await idbPut('seg', segKey, { keys, data: data.buffer });
|
||||
if (dirtyMeta.size) {
|
||||
const metas = [];
|
||||
for (const key of dirtyMeta) {
|
||||
const tr = tracks.get(key);
|
||||
if (tr?.meta) metas.push([key, tr.meta]);
|
||||
}
|
||||
dirtyMeta.clear();
|
||||
await idbPutMany('meta', metas);
|
||||
}
|
||||
idbDeleteBelow(
|
||||
'seg',
|
||||
Math.floor((Date.now() - windowMs) / 1000) - 60,
|
||||
).catch(() => {});
|
||||
}
|
||||
|
||||
async function load() {
|
||||
if (loaded) return;
|
||||
if (loading) return loading;
|
||||
loading = (async () => {
|
||||
if (!persist) return;
|
||||
const since = Math.floor((Date.now() - windowMs) / 1000);
|
||||
const [segs, metas] = await Promise.all([
|
||||
idbEntries('seg', since),
|
||||
idbEntries('meta'),
|
||||
]);
|
||||
const metaBy = new Map(metas);
|
||||
for (const [, seg] of segs) {
|
||||
if (!seg?.keys || !seg.data) continue;
|
||||
const d = new Int32Array(seg.data);
|
||||
for (let i = 0; i + 5 < d.length; i += 6) {
|
||||
const key = seg.keys[d[i]];
|
||||
append(
|
||||
key,
|
||||
metaBy.get(key) || { kind: 'aircraft' },
|
||||
d[i + 1],
|
||||
d[i + 2],
|
||||
d[i + 3],
|
||||
d[i + 4],
|
||||
d[i + 5],
|
||||
{ fromStore: true },
|
||||
);
|
||||
}
|
||||
}
|
||||
// Stored history is coarse (30 s); new polls continue each track.
|
||||
for (const tr of tracks.values()) tr.persistedT = tr.lastT;
|
||||
})().finally(() => {
|
||||
loaded = true;
|
||||
});
|
||||
return loading;
|
||||
}
|
||||
|
||||
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(() => {});
|
||||
};
|
||||
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 } = {}) {
|
||||
if (!tr?.n) return null;
|
||||
const s = ms / 1000;
|
||||
const first = tr.firstT;
|
||||
const last = tr.lastT;
|
||||
if (s < first - 5 || s > last + holdMs / 1000) return null;
|
||||
const i = tr.lowerBound(s);
|
||||
if (i >= tr.n) {
|
||||
// Past the last fix: dead-reckon briefly along the last heading.
|
||||
const p = tr.sample(tr.n - 1);
|
||||
const dt = s - last;
|
||||
if (p.speed > 1 && dt > 0) {
|
||||
const h = p.headingDeg * D2R;
|
||||
const d = p.speed * Math.min(dt, 45);
|
||||
p.lat += (d * Math.cos(h)) / 111_320;
|
||||
p.lon += (d * Math.sin(h)) / (111_320 * Math.cos(p.lat * D2R));
|
||||
}
|
||||
p.t = ms;
|
||||
return p;
|
||||
}
|
||||
if (i === 0) return { ...tr.sample(0), t: ms };
|
||||
const a = tr.sample(i - 1);
|
||||
const b = tr.sample(i);
|
||||
const gap = (b.t - a.t) / 1000;
|
||||
if (gap > maxInterpGapS) return null;
|
||||
const f = (ms - a.t) / (b.t - a.t || 1);
|
||||
return {
|
||||
t: ms,
|
||||
lon: lerp(a.lon, b.lon, f),
|
||||
lat: lerp(a.lat, b.lat, f),
|
||||
altM: lerp(a.altM, b.altM, f),
|
||||
headingDeg: lerpAngle(a.headingDeg, b.headingDeg, f),
|
||||
speed: lerp(a.speed, b.speed, f),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
ingest,
|
||||
merge,
|
||||
load,
|
||||
flush,
|
||||
compact: compactAll,
|
||||
events: () => events.slice(),
|
||||
subscribe(fn) {
|
||||
listeners.add(fn);
|
||||
return () => listeners.delete(fn);
|
||||
},
|
||||
/** { start, end, entities, points } in epoch ms; null start/end when empty. */
|
||||
range() {
|
||||
if (!Number.isFinite(minT)) bounds();
|
||||
return {
|
||||
start: Number.isFinite(minT) ? minT * 1000 : null,
|
||||
end: Number.isFinite(maxT) ? maxT * 1000 : null,
|
||||
entities: tracks.size,
|
||||
points: total,
|
||||
bytes: total * STRIDE * 4,
|
||||
};
|
||||
},
|
||||
covers(ms, slackMs = 60_000) {
|
||||
if (!Number.isFinite(minT)) return false;
|
||||
return ms >= minT * 1000 - slackMs && ms <= maxT * 1000 + slackMs;
|
||||
},
|
||||
has: (key) => tracks.has(String(key)),
|
||||
meta: (key) => tracks.get(String(key))?.meta || null,
|
||||
keys: () => tracks.keys(),
|
||||
/** All samples of one entity: [{ t, lon, lat, altM, headingDeg, speed }]. */
|
||||
samples(key, fromMs = -Infinity, toMs = Infinity) {
|
||||
const tr = tracks.get(String(key));
|
||||
if (!tr) return [];
|
||||
const out = [];
|
||||
const i0 = Number.isFinite(fromMs) ? tr.lowerBound(fromMs / 1000) : 0;
|
||||
for (let i = i0; i < tr.n; i++) {
|
||||
const p = tr.sample(i);
|
||||
if (p.t > toMs) break;
|
||||
out.push(p);
|
||||
}
|
||||
return out;
|
||||
},
|
||||
stateAt(key, ms, opts) {
|
||||
return stateAt(tracks.get(String(key)), ms, opts);
|
||||
},
|
||||
/**
|
||||
* Every entity with a state at `ms` (optionally inside `rect`):
|
||||
* [{ key, meta, lon, lat, altM, headingDeg, speed }].
|
||||
*/
|
||||
snapshotAt(ms, { rect = null, holdMs = 45_000 } = {}) {
|
||||
const out = [];
|
||||
for (const tr of tracks.values()) {
|
||||
if (!tr.n) continue;
|
||||
const s = ms / 1000;
|
||||
if (s < tr.firstT - 5 || s > tr.lastT + holdMs / 1000) continue;
|
||||
if (rect) {
|
||||
// Cheap reject on the nearest raw fix before interpolating.
|
||||
const i = Math.min(tr.n - 1, tr.lowerBound(s));
|
||||
const lat = tr.buf[i * STRIDE + 2] / 1e6;
|
||||
const lon = tr.buf[i * STRIDE + 1] / 1e6;
|
||||
if (lat < rect.south - 1 || lat > rect.north + 1) continue;
|
||||
if (
|
||||
rect.west <= rect.east &&
|
||||
(lon < rect.west - 1.5 || lon > rect.east + 1.5)
|
||||
)
|
||||
continue;
|
||||
}
|
||||
const p = stateAt(tr, ms, { holdMs });
|
||||
if (!p) continue;
|
||||
if (rect && !inRectLL(rect, p.lon, p.lat)) continue;
|
||||
out.push({ key: tr.key, meta: tr.meta, ...p });
|
||||
}
|
||||
return out;
|
||||
},
|
||||
/** Positions of one entity between two instants (for ghost trails). */
|
||||
trail(key, fromMs, toMs, maxPoints = 120) {
|
||||
const tr = tracks.get(String(key));
|
||||
if (!tr?.n) return [];
|
||||
const i0 = tr.lowerBound(fromMs / 1000);
|
||||
const i1 = tr.lowerBound(toMs / 1000);
|
||||
const step = Math.max(1, Math.ceil((i1 - i0) / maxPoints));
|
||||
const out = [];
|
||||
for (let i = i0; i < i1; i += step) {
|
||||
const p = tr.sample(i);
|
||||
out.push([p.lon, p.lat, p.altM]);
|
||||
}
|
||||
const end = stateAt(tr, toMs);
|
||||
if (end) out.push([end.lon, end.lat, end.altM]);
|
||||
return out;
|
||||
},
|
||||
destroy() {
|
||||
clearInterval(flushTimer);
|
||||
clearInterval(compactTimer);
|
||||
document.removeEventListener('visibilitychange', onHide);
|
||||
flush().catch(() => {});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function inRectLL(rect, lon, lat) {
|
||||
if (lat < rect.south || lat > rect.north) return false;
|
||||
if (rect.west <= rect.east) return lon >= rect.west && lon <= rect.east;
|
||||
return lon >= rect.west || lon <= rect.east;
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
/**
|
||||
* "Your sky now" (DESIGN.md §12.3): what is above a person this minute.
|
||||
* Aircraft within ~50 km from the live ADS-B feeds (fetching a regional
|
||||
* snapshot around the person when the current one does not cover them),
|
||||
* satellites above the horizon from CelesTrak elements (SGP4), and the ISS
|
||||
* with its next pass.
|
||||
*/
|
||||
import { twoline2satrec } from 'satellite.js';
|
||||
import { findNextIssPass, lookAnglesAt } from '../../src/data/issPass.js';
|
||||
import { haversineKm, subsolarPoint } from './geo.js';
|
||||
import { bearingDeg } from './gazetteer.js';
|
||||
|
||||
const R = 6_371_000;
|
||||
const D2R = Math.PI / 180;
|
||||
const ISS_NORAD = '25544';
|
||||
|
||||
function elevationDeg(distM, altM) {
|
||||
// Flat-earth elevation corrected for the curvature drop over distM.
|
||||
const drop = (distM * distM) / (2 * R);
|
||||
return Math.atan2(altM - drop, distM) / D2R;
|
||||
}
|
||||
|
||||
function sunElevation(lat, lon, ms) {
|
||||
const s = subsolarPoint(ms);
|
||||
const cosZ =
|
||||
Math.sin(lat * D2R) * Math.sin(s.lat * D2R) +
|
||||
Math.cos(lat * D2R) * Math.cos(s.lat * D2R) * Math.cos((lon - s.lon) * D2R);
|
||||
return 90 - Math.acos(Math.max(-1, Math.min(1, cosZ))) / D2R;
|
||||
}
|
||||
|
||||
const satCache = { stamp: '', sats: [] };
|
||||
function satrecs(feeds) {
|
||||
const groups = ['stations', 'visual'];
|
||||
const stamp = groups.map((g) => feeds[g].updatedAt).join(':');
|
||||
if (stamp === satCache.stamp) return satCache.sats;
|
||||
const seen = new Set();
|
||||
const sats = [];
|
||||
for (const g of groups)
|
||||
for (const e of feeds[g].data?.entries || []) {
|
||||
const id = e.line1.slice(2, 7).trim();
|
||||
if (seen.has(id)) continue;
|
||||
seen.add(id);
|
||||
try {
|
||||
sats.push({
|
||||
id,
|
||||
name: e.name.trim(),
|
||||
satrec: twoline2satrec(e.line1, e.line2),
|
||||
});
|
||||
} catch {
|
||||
/* malformed */
|
||||
}
|
||||
}
|
||||
satCache.stamp = stamp;
|
||||
satCache.sats = sats;
|
||||
return sats;
|
||||
}
|
||||
|
||||
export async function skyNow(feeds, { lon, lat }, { km = 50 } = {}) {
|
||||
if (!Number.isFinite(lon) || !Number.isFinite(lat))
|
||||
throw new TypeError('skyNow needs lon and lat');
|
||||
const now = Date.now();
|
||||
// Aircraft: whatever the feeds hold near the person, plus a regional
|
||||
// snapshot around them when nothing current covers the spot.
|
||||
const lists = [];
|
||||
const flights = feeds.flights.data;
|
||||
if (flights?.records) lists.push(flights.records);
|
||||
if (feeds.military.data?.records) lists.push(feeds.military.data.records);
|
||||
for (const e of feeds.regionalFlights.entries?.() || [])
|
||||
if (e.data?.records) lists.push(e.data.records);
|
||||
const covered =
|
||||
flights &&
|
||||
(!flights.regional ||
|
||||
(flights.anchor &&
|
||||
haversineKm(lat, lon, flights.anchor.lat, flights.anchor.lon) < 300));
|
||||
if (!covered) {
|
||||
const key = `sky:${lat.toFixed(1)}:${lon.toFixed(1)}`;
|
||||
const reg = await Promise.race([
|
||||
feeds.regionalFlights.get(key, { lat, lon }).catch(() => null),
|
||||
new Promise((r) => setTimeout(() => r(null), 9000)),
|
||||
]);
|
||||
if (reg?.records) lists.push(reg.records);
|
||||
}
|
||||
const seen = new Set();
|
||||
const aircraft = [];
|
||||
for (const list of lists)
|
||||
for (const r of list) {
|
||||
if (seen.has(r.id) || r.onGround) continue;
|
||||
if (Math.abs(r.lat - lat) > km / 100 + 0.2) continue;
|
||||
const d = haversineKm(lat, lon, r.lat, r.lon);
|
||||
if (d > km) continue;
|
||||
seen.add(r.id);
|
||||
aircraft.push({
|
||||
id: `${r.military ? 'military' : 'flights'}:${r.id}`,
|
||||
title: r.callsign || r.id.toUpperCase(),
|
||||
kind: r.military
|
||||
? 'military'
|
||||
: r.helicopter
|
||||
? 'helicopter'
|
||||
: 'aircraft',
|
||||
lon: r.lon,
|
||||
lat: r.lat,
|
||||
altM: Math.round(r.altM ?? 0),
|
||||
distanceKm: Math.round(d * 10) / 10,
|
||||
elevationDeg: Math.round(elevationDeg(d * 1000, r.altM ?? 0) * 10) / 10,
|
||||
azimuthDeg: Math.round(bearingDeg(lat, lon, r.lat, r.lon)),
|
||||
headingDeg: r.heading ?? null,
|
||||
speedMps: r.speed ?? null,
|
||||
squawk: r.squawk || null,
|
||||
});
|
||||
}
|
||||
aircraft.sort((a, b) => b.elevationDeg - a.elevationDeg);
|
||||
|
||||
// Satellites above the horizon.
|
||||
// Cached elements answer at once; a cold catalog gets 4 s.
|
||||
await Promise.race([
|
||||
Promise.allSettled([feeds.stations.get(), feeds.visual.get()]),
|
||||
new Promise((r) => setTimeout(r, 4000)),
|
||||
]);
|
||||
const satellites = [];
|
||||
let iss = null;
|
||||
for (const s of satrecs(feeds)) {
|
||||
const look = lookAnglesAt(s.satrec, now, lat, lon);
|
||||
if (!look) continue;
|
||||
if (s.id === ISS_NORAD) iss = { s, look };
|
||||
if (look.elevDeg <= 0) continue;
|
||||
const p = look.satECI;
|
||||
satellites.push({
|
||||
id: `satellites:${s.id}`,
|
||||
title: s.name,
|
||||
norad: s.id,
|
||||
altKm: p ? Math.round(Math.hypot(p.x, p.y, p.z) - 6371) : null,
|
||||
elevationDeg: Math.round(look.elevDeg * 10) / 10,
|
||||
azimuthDeg: Math.round(look.azDeg),
|
||||
});
|
||||
}
|
||||
satellites.sort((a, b) => b.elevationDeg - a.elevationDeg);
|
||||
|
||||
const sunEl = sunElevation(lat, lon, now);
|
||||
let issOut = {
|
||||
visibleNow: false,
|
||||
elevationDeg: null,
|
||||
azimuthDeg: null,
|
||||
nextPass: null,
|
||||
};
|
||||
if (iss) {
|
||||
const pass = findNextIssPass({
|
||||
satrec: iss.s.satrec,
|
||||
latDeg: lat,
|
||||
lonDeg: lon,
|
||||
fromMs: now,
|
||||
minElevDeg: 10,
|
||||
horizonHours: 36,
|
||||
});
|
||||
issOut = {
|
||||
visibleNow: iss.look.elevDeg > 10 && sunEl < -6,
|
||||
aboveHorizon: iss.look.elevDeg > 0,
|
||||
elevationDeg: Math.round(iss.look.elevDeg * 10) / 10,
|
||||
azimuthDeg: Math.round(iss.look.azDeg),
|
||||
nextPass: pass
|
||||
? {
|
||||
start: pass.riseMs,
|
||||
max: pass.maxElevMs,
|
||||
end: pass.setMs,
|
||||
maxElevationDeg: Math.round(pass.maxElevDeg),
|
||||
riseAzimuthDeg: Math.round(pass.riseAzDeg ?? 0),
|
||||
visible: Boolean(pass.visible),
|
||||
}
|
||||
: null,
|
||||
};
|
||||
}
|
||||
return {
|
||||
at: now,
|
||||
observer: { lon, lat },
|
||||
sunElevationDeg: Math.round(sunEl * 10) / 10,
|
||||
aircraft: aircraft.slice(0, 60),
|
||||
satellites: satellites.slice(0, 40),
|
||||
iss: issOut,
|
||||
sources: ['OpenSky Network / adsb.lol (ODbL)', 'CelesTrak GP data, SGP4'],
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
<!doctype html>
|
||||
<html lang="en" dir="ltr">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1, viewport-fit=cover, interactive-widget=resizes-content"
|
||||
/>
|
||||
<title>God's Eye</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="The window through which you see the world, and the eye through which the world sees itself."
|
||||
/>
|
||||
<meta name="theme-color" content="#050607" />
|
||||
<meta name="color-scheme" content="dark" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
<meta
|
||||
name="apple-mobile-web-app-status-bar-style"
|
||||
content="black-translucent"
|
||||
/>
|
||||
<meta name="apple-mobile-web-app-title" content="God's Eye" />
|
||||
<meta name="format-detection" content="telephone=no" />
|
||||
<link rel="icon" href="/logo.svg" type="image/svg+xml" />
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/icons/icon-180.png" />
|
||||
<link rel="preconnect" href="https://server.arcgisonline.com" crossorigin />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&display=swap"
|
||||
/>
|
||||
<link rel="stylesheet" href="./styles/tokens.css" />
|
||||
<link rel="stylesheet" href="./styles/shell.css" />
|
||||
<link rel="stylesheet" href="./styles/feed.css" />
|
||||
<link rel="stylesheet" href="./styles/sheet.css" />
|
||||
<link rel="stylesheet" href="./styles/globe-ui.css" />
|
||||
<link rel="stylesheet" href="./styles/capture.css" />
|
||||
<link rel="stylesheet" href="./styles/threads.css" />
|
||||
<link rel="stylesheet" href="./styles/cards.css" />
|
||||
<link rel="stylesheet" href="./styles/room.css" />
|
||||
<link rel="stylesheet" href="./styles/today.css" />
|
||||
<link rel="stylesheet" href="./styles/timemachine.css" />
|
||||
<link rel="stylesheet" href="./styles/moment.css" />
|
||||
<link rel="stylesheet" href="./styles/dossier.css" />
|
||||
<link rel="stylesheet" href="./styles/watch.css" />
|
||||
<link rel="stylesheet" href="./styles/layout.css" />
|
||||
<script type="module" src="./main.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app" class="app" data-tab="today" data-booting="true">
|
||||
<!-- The one persistent globe. Everything else is glass over it. -->
|
||||
<div id="stage" class="stage" aria-label="Globe">
|
||||
<div id="globe" class="globe-host"></div>
|
||||
<div id="globe-veil" class="globe-veil" aria-hidden="true"></div>
|
||||
<div id="globe-chrome" class="globe-chrome"></div>
|
||||
</div>
|
||||
|
||||
<main id="lead" class="lead">
|
||||
<section
|
||||
id="view-today"
|
||||
class="view view--scroll"
|
||||
data-view="today"
|
||||
aria-label="Today"
|
||||
></section>
|
||||
<section
|
||||
id="view-live"
|
||||
class="view view--scroll"
|
||||
data-view="live"
|
||||
aria-label="Live layers"
|
||||
></section>
|
||||
<section
|
||||
id="view-rooms"
|
||||
class="view view--scroll"
|
||||
data-view="rooms"
|
||||
aria-label="Rooms"
|
||||
hidden
|
||||
></section>
|
||||
<section
|
||||
id="view-you"
|
||||
class="view view--scroll"
|
||||
data-view="you"
|
||||
aria-label="You"
|
||||
hidden
|
||||
></section>
|
||||
</main>
|
||||
|
||||
<section
|
||||
id="view-capture"
|
||||
class="capture"
|
||||
data-view="capture"
|
||||
aria-label="Capture"
|
||||
hidden
|
||||
></section>
|
||||
|
||||
<!-- Sheets that belong to the leading pane on a tablet (dossier, watch,
|
||||
your sky); on a phone they cover the whole screen. -->
|
||||
<div id="pane-sheets" class="sheets pane-sheets"></div>
|
||||
<div id="sheets" class="sheets"></div>
|
||||
<nav id="tabbar" class="tabbar" aria-label="Primary"></nav>
|
||||
</div>
|
||||
<noscript>
|
||||
<p class="noscript">God's Eye needs JavaScript to draw the planet.</p>
|
||||
</noscript>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,860 @@
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Card archetypes: Sensor · Eye · Track · Number · Witness · Room */
|
||||
/* Sensor framing: corner brackets, hairline reticle, one strip. */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
.card {
|
||||
position: relative;
|
||||
border-radius: 6px;
|
||||
background: var(--ground-1);
|
||||
overflow: hidden;
|
||||
content-visibility: auto;
|
||||
contain-intrinsic-size: auto 300px;
|
||||
transition: transform var(--dur-2) var(--spring);
|
||||
box-shadow: inset 0 0 0 0.5px var(--hairline);
|
||||
}
|
||||
|
||||
.card:has(.card-hit:active) {
|
||||
transform: scale(0.97);
|
||||
}
|
||||
|
||||
.card.is-selected {
|
||||
box-shadow:
|
||||
inset 0 0 0 1.5px var(--band),
|
||||
0 0 0 3px color-mix(in srgb, var(--band) 22%, transparent);
|
||||
}
|
||||
|
||||
.card:has(.card-hit:focus-visible) {
|
||||
outline: 2.5px solid var(--tint);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.card-hit {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 5;
|
||||
border-radius: inherit;
|
||||
}
|
||||
|
||||
.card-hit:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.card:not([data-band]) {
|
||||
--band: var(--ink-3);
|
||||
}
|
||||
|
||||
.card-media {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background:
|
||||
radial-gradient(
|
||||
120% 90% at 50% 40%,
|
||||
color-mix(in srgb, var(--band) 10%, #0c0e11),
|
||||
#07080a
|
||||
),
|
||||
#07080a;
|
||||
}
|
||||
|
||||
.card-media > img,
|
||||
.card-media > video {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
opacity: 0;
|
||||
transform: scale(1.03);
|
||||
transition:
|
||||
opacity 520ms var(--ease-out),
|
||||
transform 900ms var(--ease-out);
|
||||
}
|
||||
|
||||
.card-media > img.loaded,
|
||||
.card-media > video.loaded {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.card-media > img.failed {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* The plain still waits, desaturated, for its composited frame. */
|
||||
.card-media > img.still {
|
||||
filter: saturate(0.4) brightness(0.62);
|
||||
}
|
||||
|
||||
.card-media > img.composited {
|
||||
animation: composite-in 600ms var(--ease-out);
|
||||
}
|
||||
|
||||
@keyframes composite-in {
|
||||
from {
|
||||
filter: saturate(0.4) brightness(0.62);
|
||||
}
|
||||
to {
|
||||
filter: none;
|
||||
}
|
||||
}
|
||||
|
||||
.card.is-stale .card-media img {
|
||||
filter: saturate(0.25) brightness(0.8);
|
||||
}
|
||||
|
||||
/* Corner brackets */
|
||||
.fr {
|
||||
position: absolute;
|
||||
inset: 8px;
|
||||
z-index: 2;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.fr i {
|
||||
position: absolute;
|
||||
width: 11px;
|
||||
height: 11px;
|
||||
border: 0 solid color-mix(in srgb, var(--band) 70%, #fff);
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.fr i:nth-child(1) {
|
||||
left: 0;
|
||||
top: 0;
|
||||
border-left-width: 1.25px;
|
||||
border-top-width: 1.25px;
|
||||
}
|
||||
|
||||
.fr i:nth-child(2) {
|
||||
right: 0;
|
||||
top: 0;
|
||||
border-right-width: 1.25px;
|
||||
border-top-width: 1.25px;
|
||||
}
|
||||
|
||||
.fr i:nth-child(3) {
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
border-left-width: 1.25px;
|
||||
border-bottom-width: 1.25px;
|
||||
}
|
||||
|
||||
.fr i:nth-child(4) {
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
border-right-width: 1.25px;
|
||||
border-bottom-width: 1.25px;
|
||||
}
|
||||
|
||||
/* Hairline reticle: a 16 px cross with an open centre. */
|
||||
.reticle {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
width: 17px;
|
||||
height: 17px;
|
||||
margin: -8.5px 0 0 -8.5px;
|
||||
opacity: 0.5;
|
||||
pointer-events: none;
|
||||
background:
|
||||
linear-gradient(#fff, #fff) center top / 1px 6px no-repeat,
|
||||
linear-gradient(#fff, #fff) center bottom / 1px 6px no-repeat,
|
||||
linear-gradient(#fff, #fff) left center / 6px 1px no-repeat,
|
||||
linear-gradient(#fff, #fff) right center / 6px 1px no-repeat;
|
||||
}
|
||||
|
||||
.c-top {
|
||||
position: absolute;
|
||||
z-index: 3;
|
||||
left: 14px;
|
||||
right: 14px;
|
||||
top: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
color: rgba(255, 255, 255, 0.86);
|
||||
font-size: 0.5625rem;
|
||||
text-shadow: 0 1px 4px rgba(0, 0, 0, 0.7);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.c-top .tele {
|
||||
font-size: 0.5625rem;
|
||||
}
|
||||
|
||||
.c-sid {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.c-sid .i {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.c-live {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.c-live i {
|
||||
position: relative;
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
border-radius: 50%;
|
||||
background: var(--band);
|
||||
box-shadow: 0 0 6px var(--band);
|
||||
}
|
||||
|
||||
.c-live i::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: 50%;
|
||||
background: inherit;
|
||||
animation: pulse-ring 1.8s var(--ease-out) infinite;
|
||||
}
|
||||
|
||||
.c-live.recon {
|
||||
color: var(--b-inside);
|
||||
}
|
||||
|
||||
.c-live.onair i {
|
||||
background: var(--b-inside);
|
||||
}
|
||||
|
||||
/* The telemetry strip, burned into the frame. */
|
||||
.c-strip {
|
||||
position: absolute;
|
||||
z-index: 3;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
padding: 18px 12px 8px;
|
||||
background: linear-gradient(to top, rgba(3, 4, 5, 0.82), rgba(3, 4, 5, 0));
|
||||
color: rgba(255, 255, 255, 0.82);
|
||||
font-size: 0.5625rem;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.card-body {
|
||||
padding: 9px 10px 11px;
|
||||
}
|
||||
|
||||
.c-band {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
color: var(--ink-3);
|
||||
font-size: 0.5625rem;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.c-band b {
|
||||
font-weight: 400;
|
||||
color: var(--ink-4);
|
||||
}
|
||||
|
||||
.c-band .bd {
|
||||
flex: none;
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
border-radius: 1px;
|
||||
background: var(--band);
|
||||
}
|
||||
|
||||
.card-title {
|
||||
margin: 5px 0 0;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 650;
|
||||
line-height: 1.26;
|
||||
letter-spacing: -0.012em;
|
||||
min-height: 2.52em;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.card-place {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-top: 3px;
|
||||
font-size: 0.6875rem;
|
||||
color: var(--ink-3);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.card-place span {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.c-stale {
|
||||
flex: none;
|
||||
color: var(--ink-4);
|
||||
font-size: 0.5rem;
|
||||
}
|
||||
|
||||
.c-cta {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
height: 24px;
|
||||
margin-top: 8px;
|
||||
padding: 0 9px;
|
||||
border-radius: 3px;
|
||||
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--band) 55%, transparent);
|
||||
color: var(--band);
|
||||
font-size: 0.5625rem;
|
||||
}
|
||||
|
||||
.c-cta .i {
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
/* ---------- Track: one moving thing, a big readout ---------- */
|
||||
.c-readout {
|
||||
position: absolute;
|
||||
z-index: 3;
|
||||
left: 14px;
|
||||
right: 14px;
|
||||
bottom: 30px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
column-gap: 4px;
|
||||
color: #fff;
|
||||
text-shadow: 0 1px 12px rgba(0, 0, 0, 0.6);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.c-readout b {
|
||||
font-family: var(--mono);
|
||||
font-size: 1.75rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.03em;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.c-readout small {
|
||||
font-family: var(--mono);
|
||||
font-size: 0.75rem;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.c-readout .tele {
|
||||
flex-basis: 100%;
|
||||
margin-top: 4px;
|
||||
color: var(--band);
|
||||
font-size: 0.5625rem;
|
||||
}
|
||||
|
||||
.c-track.is-harm .c-readout b,
|
||||
.c-track.is-harm .c-readout .tele {
|
||||
color: var(--harm);
|
||||
}
|
||||
|
||||
.c-track.is-harm .fr i {
|
||||
border-color: var(--harm);
|
||||
}
|
||||
|
||||
/* ---------- Number: typography is the picture ---------- */
|
||||
.c-number .card-media {
|
||||
background: #07080a;
|
||||
}
|
||||
|
||||
/* The composite is evidence, so it stays in colour; it fades out toward the
|
||||
bottom, where the number sits, so the type keeps its contrast. */
|
||||
.c-number .card-media > img.c-num-bg.loaded {
|
||||
opacity: 0.85;
|
||||
-webkit-mask-image: linear-gradient(
|
||||
to bottom,
|
||||
#000 40%,
|
||||
rgba(0, 0, 0, 0.15) 85%
|
||||
);
|
||||
mask-image: linear-gradient(to bottom, #000 40%, rgba(0, 0, 0, 0.15) 85%);
|
||||
}
|
||||
|
||||
.c-number .fr i {
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.c-num {
|
||||
position: absolute;
|
||||
z-index: 3;
|
||||
inset: 16px 14px 28px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.c-big {
|
||||
margin-top: auto;
|
||||
font-family: var(--font-display);
|
||||
font-size: clamp(2.2rem, 11vw, 3rem);
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.045em;
|
||||
line-height: 0.95;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
@media (min-width: 600px) {
|
||||
.c-big {
|
||||
font-size: 2.6rem;
|
||||
}
|
||||
}
|
||||
|
||||
.c-big small {
|
||||
margin-left: 3px;
|
||||
font-size: 0.4em;
|
||||
font-weight: 650;
|
||||
letter-spacing: 0;
|
||||
color: var(--band);
|
||||
}
|
||||
|
||||
.c-big-l {
|
||||
margin-top: 4px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: var(--ink-2);
|
||||
}
|
||||
|
||||
.c-spark {
|
||||
width: 100%;
|
||||
height: 42px;
|
||||
margin-top: 10px;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.c-spark .a {
|
||||
fill: color-mix(in srgb, var(--band) 16%, transparent);
|
||||
}
|
||||
|
||||
.c-spark .l {
|
||||
fill: none;
|
||||
stroke: var(--band);
|
||||
stroke-width: 1.5;
|
||||
vector-effect: non-scaling-stroke;
|
||||
}
|
||||
|
||||
.c-spark-l {
|
||||
margin-top: 3px;
|
||||
font-size: 0.5rem;
|
||||
color: var(--ink-4);
|
||||
}
|
||||
|
||||
.c-number .card-title {
|
||||
min-height: 0;
|
||||
margin-top: 0;
|
||||
-webkit-line-clamp: 2;
|
||||
}
|
||||
|
||||
/* ---------- Eye: the street, live ---------- */
|
||||
.c-eye .card-media > img {
|
||||
transform: none;
|
||||
transition: opacity 700ms var(--ease-out);
|
||||
}
|
||||
|
||||
.c-eye .card-media > img.eye-b {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.c-eye .card-media::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 1;
|
||||
pointer-events: none;
|
||||
background:
|
||||
linear-gradient(to top, rgba(3, 4, 5, 0.78) 0, rgba(3, 4, 5, 0) 48%),
|
||||
linear-gradient(to bottom, rgba(3, 4, 5, 0.45) 0, rgba(3, 4, 5, 0) 22%);
|
||||
}
|
||||
|
||||
.c-eye.flash .c-live {
|
||||
animation: eye-tick 900ms var(--ease-out);
|
||||
}
|
||||
|
||||
@keyframes eye-tick {
|
||||
0% {
|
||||
color: var(--b-ground);
|
||||
}
|
||||
100% {
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
.c-eye-cap {
|
||||
position: absolute;
|
||||
z-index: 3;
|
||||
left: 14px;
|
||||
right: 14px;
|
||||
bottom: 30px;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 10px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.c-eye-cap .card-title {
|
||||
min-height: 0;
|
||||
margin: 0;
|
||||
font-size: 1.125rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
color: #fff;
|
||||
text-shadow: 0 1px 10px rgba(0, 0, 0, 0.6);
|
||||
-webkit-line-clamp: 1;
|
||||
}
|
||||
|
||||
.c-eye-cap .card-place {
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
|
||||
/* View direction: a compass ring with the camera's cone. */
|
||||
.c-fov {
|
||||
flex: none;
|
||||
position: relative;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.45);
|
||||
}
|
||||
|
||||
.c-fov::before {
|
||||
content: 'N';
|
||||
position: absolute;
|
||||
top: -1px;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -100%);
|
||||
font-family: var(--mono);
|
||||
font-size: 7px;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
|
||||
.c-fov i {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: 50%;
|
||||
background: conic-gradient(
|
||||
from calc(var(--hd) - 25deg),
|
||||
color-mix(in srgb, var(--b-ground) 80%, transparent) 0 50deg,
|
||||
transparent 50deg
|
||||
);
|
||||
-webkit-mask: radial-gradient(circle, #000 0 62%, transparent 63%);
|
||||
mask: radial-gradient(circle, #000 0 62%, transparent 63%);
|
||||
}
|
||||
|
||||
.c-eye .card-body.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
padding: 8px 12px 10px;
|
||||
}
|
||||
|
||||
.c-src {
|
||||
flex: none;
|
||||
max-width: 50%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--ink-4);
|
||||
font-size: 0.5rem;
|
||||
}
|
||||
|
||||
.eye-offline .c-live {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
/* ---------- Witness: you, sealed ---------- */
|
||||
.c-witness .fr i {
|
||||
border-color: var(--b-inside);
|
||||
}
|
||||
|
||||
.c-seal {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
color: var(--b-inside);
|
||||
--seal-ink: #1d1406;
|
||||
filter: drop-shadow(0 1px 4px rgba(0, 0, 0, 0.6));
|
||||
}
|
||||
|
||||
.c-seal .i {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.c-seal.un {
|
||||
color: var(--ink-2);
|
||||
}
|
||||
|
||||
.c-witness.is-unverified .fr i {
|
||||
border-color: var(--ink-3);
|
||||
border-style: dashed;
|
||||
}
|
||||
|
||||
.c-witness.is-onair {
|
||||
box-shadow: inset 0 0 0 1px
|
||||
color-mix(in srgb, var(--b-inside) 60%, transparent);
|
||||
}
|
||||
|
||||
/* The prompt: an empty frame that asks you to fill it. */
|
||||
.c-prompt .card-media {
|
||||
background:
|
||||
repeating-linear-gradient(
|
||||
135deg,
|
||||
rgba(235, 199, 119, 0.035) 0 6px,
|
||||
transparent 6px 12px
|
||||
),
|
||||
#08090a;
|
||||
}
|
||||
|
||||
.c-prompt .fr i {
|
||||
border-color: var(--b-inside);
|
||||
}
|
||||
|
||||
.c-prompt-in {
|
||||
position: absolute;
|
||||
inset: 22px 18px 34px;
|
||||
z-index: 3;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-end;
|
||||
gap: 6px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.c-prompt-ic {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
color: var(--b-inside);
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.c-prompt-in b {
|
||||
font-size: 0.9375rem;
|
||||
line-height: 1.25;
|
||||
letter-spacing: -0.015em;
|
||||
}
|
||||
|
||||
.c-prompt-in > span:last-child {
|
||||
font-size: 0.71875rem;
|
||||
color: var(--ink-3);
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.c-prompt .card-body {
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
.c-prompt .c-cta {
|
||||
--band: var(--b-inside);
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
/* ---------- Room: a conversation heating up ---------- */
|
||||
.c-room {
|
||||
background:
|
||||
radial-gradient(
|
||||
120% 140% at 0% 0%,
|
||||
rgba(235, 199, 119, 0.06),
|
||||
rgba(235, 199, 119, 0) 60%
|
||||
),
|
||||
var(--ground-1);
|
||||
box-shadow: inset 0 0 0 0.5px var(--hairline-strong);
|
||||
}
|
||||
|
||||
.c-room-in {
|
||||
padding: 13px 14px 12px;
|
||||
}
|
||||
|
||||
.c-room-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.c-room-head .c-band {
|
||||
color: var(--ink-2);
|
||||
}
|
||||
|
||||
.c-watch {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
color: var(--ink-2);
|
||||
font-size: 0.5625rem;
|
||||
}
|
||||
|
||||
.c-watch i {
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
border-radius: 50%;
|
||||
background: var(--b-inside);
|
||||
animation: breathe 1.6s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.c-claim {
|
||||
margin: 10px 0 10px;
|
||||
padding: 0 0 0 12px;
|
||||
border-left: 2px solid var(--b-inside);
|
||||
font-size: 1.0625rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.32;
|
||||
letter-spacing: -0.016em;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.c-claim.is-example {
|
||||
border-left-style: dashed;
|
||||
color: var(--ink-2);
|
||||
}
|
||||
|
||||
.c-room[data-status='disputed'] .c-claim {
|
||||
border-left-style: dashed;
|
||||
border-left-color: var(--ink-3);
|
||||
}
|
||||
|
||||
.c-room-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 6px 10px;
|
||||
}
|
||||
|
||||
.c-status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
height: 22px;
|
||||
padding: 0 8px;
|
||||
border-radius: 3px;
|
||||
font-size: 0.5625rem;
|
||||
color: var(--b-inside);
|
||||
box-shadow: inset 0 0 0 1px
|
||||
color-mix(in srgb, var(--b-inside) 50%, transparent);
|
||||
--seal-ink: #1d1406;
|
||||
}
|
||||
|
||||
.c-status .i {
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.c-status[data-status='disputed'] {
|
||||
color: var(--ink);
|
||||
box-shadow: inset 0 0 0 1px var(--ink-3);
|
||||
background: repeating-linear-gradient(
|
||||
135deg,
|
||||
rgba(255, 255, 255, 0.05) 0 4px,
|
||||
transparent 4px 8px
|
||||
);
|
||||
}
|
||||
|
||||
.c-status[data-status='open'] {
|
||||
color: var(--ink-3);
|
||||
box-shadow: inset 0 0 0 1px var(--hairline-strong);
|
||||
}
|
||||
|
||||
.c-kind {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
color: var(--ink-3);
|
||||
font-size: 0.5625rem;
|
||||
}
|
||||
|
||||
.c-kind .i {
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.c-room-foot {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-top: 12px;
|
||||
padding-top: 10px;
|
||||
border-top: 0.5px solid var(--hairline);
|
||||
color: var(--ink-3);
|
||||
}
|
||||
|
||||
.c-room-foot .tele {
|
||||
flex: 1;
|
||||
font-size: 0.5625rem;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.c-join {
|
||||
color: var(--ink-2);
|
||||
}
|
||||
|
||||
.avs {
|
||||
display: inline-flex;
|
||||
padding-left: 6px;
|
||||
}
|
||||
|
||||
.av {
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
margin-left: -6px;
|
||||
border-radius: 50%;
|
||||
background: hsl(var(--h) 28% 30%);
|
||||
box-shadow: 0 0 0 1.5px var(--ground-1);
|
||||
font-style: normal;
|
||||
font-size: 0.625rem;
|
||||
font-weight: 700;
|
||||
color: hsl(var(--h) 60% 86%);
|
||||
}
|
||||
|
||||
.av.self {
|
||||
background: var(--b-inside);
|
||||
color: #1d1406;
|
||||
}
|
||||
|
||||
.av.live {
|
||||
box-shadow:
|
||||
0 0 0 1.5px var(--ground-1),
|
||||
0 0 0 3px var(--b-inside);
|
||||
}
|
||||
|
||||
.card.pulse {
|
||||
animation: card-pulse 1.6s var(--ease-out);
|
||||
}
|
||||
|
||||
@keyframes card-pulse {
|
||||
0% {
|
||||
box-shadow: inset 0 0 0 1.5px var(--band);
|
||||
}
|
||||
100% {
|
||||
box-shadow: inset 0 0 0 0.5px var(--hairline);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.c-live i::after,
|
||||
.c-watch i {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,794 @@
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Dossiers, link graph and universal search (DESIGN.md §12.1) */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
.dossier-sheet {
|
||||
--band-c: var(--ink-2);
|
||||
}
|
||||
|
||||
.dossier-sheet[data-band='orbit'] {
|
||||
--band-c: var(--b-orbit);
|
||||
}
|
||||
.dossier-sheet[data-band='sky'] {
|
||||
--band-c: var(--b-sky);
|
||||
}
|
||||
.dossier-sheet[data-band='ground'] {
|
||||
--band-c: var(--b-ground);
|
||||
}
|
||||
.dossier-sheet[data-band='inside'] {
|
||||
--band-c: var(--b-inside);
|
||||
}
|
||||
|
||||
.dossier-sheet::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0 0 auto;
|
||||
height: 120px;
|
||||
background: radial-gradient(
|
||||
80% 100% at 20% 0,
|
||||
color-mix(in srgb, var(--band-c) 16%, transparent),
|
||||
transparent 70%
|
||||
);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.ds-head {
|
||||
position: relative;
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
|
||||
.ds-back {
|
||||
position: absolute;
|
||||
left: -10px;
|
||||
top: -6px;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
color: var(--tint);
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
|
||||
.ds-back + .ds-kicker {
|
||||
padding-left: 26px;
|
||||
}
|
||||
|
||||
.ds-kicker {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
color: var(--ink-3);
|
||||
font-size: 0.5625rem;
|
||||
min-height: 18px;
|
||||
padding-right: 40px;
|
||||
}
|
||||
|
||||
.ds-kicker .bd {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 2px;
|
||||
background: var(--band-c);
|
||||
box-shadow: 0 0 8px var(--band-c);
|
||||
}
|
||||
|
||||
.ds-kicker .live-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
color: #ff6b61;
|
||||
}
|
||||
|
||||
.ds-title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin: 6px 0 10px;
|
||||
}
|
||||
|
||||
.ds-ic {
|
||||
flex: none;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 13px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-size: 1.35rem;
|
||||
color: var(--band-c);
|
||||
background: color-mix(in srgb, var(--band-c) 15%, rgba(255, 255, 255, 0.03));
|
||||
box-shadow: inset 0 0 0 0.5px
|
||||
color-mix(in srgb, var(--band-c) 40%, transparent);
|
||||
}
|
||||
|
||||
.ds-title-row .sheet-title {
|
||||
font-family: var(--font-display);
|
||||
margin: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.ds-skel {
|
||||
width: 60%;
|
||||
border-radius: 8px;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.ds-seg {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.ds-seg button {
|
||||
flex: 1;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.ds-seg .i {
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.ds-live {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(84px, 1fr));
|
||||
gap: 8px;
|
||||
padding-top: 4px;
|
||||
}
|
||||
|
||||
.ds-live div {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 14px;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.ds-live .tele {
|
||||
color: var(--ink-3);
|
||||
font-size: 0.5625rem;
|
||||
}
|
||||
|
||||
.ds-live b {
|
||||
font-family: var(--font-display);
|
||||
font-size: 1.3125rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ds-live small {
|
||||
margin-left: 3px;
|
||||
color: var(--ink-3);
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.ds-updated {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 2px 0;
|
||||
color: var(--ink-3);
|
||||
font-size: 0.5625rem;
|
||||
}
|
||||
|
||||
.ds-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding: 14px 0 4px;
|
||||
overflow-x: auto;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.ds-act {
|
||||
flex: 1 0 auto;
|
||||
min-width: 70px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 10px 8px;
|
||||
border-radius: 14px;
|
||||
color: var(--tint);
|
||||
background: rgba(163, 216, 255, 0.1);
|
||||
font-size: var(--t-cap2);
|
||||
font-weight: 600;
|
||||
transition:
|
||||
background var(--dur-1) linear,
|
||||
transform var(--dur-1) var(--ease-out);
|
||||
}
|
||||
|
||||
.ds-act .i {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.ds-act:hover {
|
||||
background: rgba(163, 216, 255, 0.16);
|
||||
}
|
||||
|
||||
.ds-act:active {
|
||||
transform: scale(0.96);
|
||||
}
|
||||
|
||||
.ds-act[aria-pressed='true'] {
|
||||
color: #2a1a00;
|
||||
background: var(--b-inside);
|
||||
}
|
||||
|
||||
.ds-act.primary {
|
||||
color: #050607;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.ds-privacy {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-top: 12px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 12px;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
color: var(--ink-2);
|
||||
font-size: var(--t-cap);
|
||||
}
|
||||
|
||||
.ds-block {
|
||||
margin-top: 18px;
|
||||
}
|
||||
|
||||
.ds-h {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
margin: 0 0 8px;
|
||||
font-size: var(--t-sub);
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.ds-h .i {
|
||||
align-self: center;
|
||||
color: var(--ink-3);
|
||||
}
|
||||
|
||||
.ds-h small {
|
||||
margin-left: auto;
|
||||
color: var(--ink-3);
|
||||
font-weight: 500;
|
||||
font-size: var(--t-cap2);
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.ds-track {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 96px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.ds-fig {
|
||||
margin: 0;
|
||||
padding: 10px 10px 6px;
|
||||
border-radius: 14px;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
.ds-fig svg {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 70px;
|
||||
}
|
||||
|
||||
.ds-ground svg {
|
||||
height: 70px;
|
||||
}
|
||||
|
||||
.ds-fig figcaption {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-top: 4px;
|
||||
color: var(--ink-3);
|
||||
font-size: 0.5rem;
|
||||
}
|
||||
|
||||
.ds-area {
|
||||
fill: color-mix(in srgb, var(--band-c) 22%, transparent);
|
||||
}
|
||||
|
||||
.ds-line,
|
||||
.ds-gline {
|
||||
fill: none;
|
||||
stroke: var(--band-c);
|
||||
stroke-width: 1.6;
|
||||
vector-effect: non-scaling-stroke;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
|
||||
.ds-end {
|
||||
fill: #fff;
|
||||
}
|
||||
|
||||
.ds-alt {
|
||||
width: 100%;
|
||||
height: 64px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.ds-id {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
gap: 7px 16px;
|
||||
margin: 0;
|
||||
padding: 12px 14px;
|
||||
border-radius: 14px;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
font-size: var(--t-foot);
|
||||
}
|
||||
|
||||
.ds-id dt {
|
||||
color: var(--ink-3);
|
||||
}
|
||||
|
||||
.ds-id dd {
|
||||
margin: 0;
|
||||
text-align: right;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.ds-events {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0 0 0 4px;
|
||||
border-left: 1px solid var(--hairline-strong);
|
||||
}
|
||||
|
||||
.ds-events button {
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-columns: 52px 1fr auto;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
padding: 8px 6px 8px 14px;
|
||||
text-align: left;
|
||||
color: var(--ink);
|
||||
font-size: var(--t-foot);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.ds-events button:hover {
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
.ds-events button::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: -8.5px;
|
||||
top: 13px;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--band-c);
|
||||
box-shadow: 0 0 0 3px var(--ground-1);
|
||||
}
|
||||
|
||||
.ds-events .tele {
|
||||
color: var(--ink-3);
|
||||
font-size: 0.625rem;
|
||||
}
|
||||
|
||||
.ds-events small {
|
||||
color: var(--ink-4);
|
||||
font-size: var(--t-cap2);
|
||||
}
|
||||
|
||||
.ds-links {
|
||||
display: grid;
|
||||
border-radius: 14px;
|
||||
overflow: hidden;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
.ds-link {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 11px;
|
||||
padding: 9px 12px;
|
||||
text-align: left;
|
||||
color: var(--ink);
|
||||
--lc: var(--ink-2);
|
||||
}
|
||||
|
||||
.ds-link + .ds-link::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 52px;
|
||||
right: 0;
|
||||
height: 0.5px;
|
||||
background: var(--hairline);
|
||||
}
|
||||
|
||||
.ds-link:hover {
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
.ds-link[data-band='orbit'] {
|
||||
--lc: var(--b-orbit);
|
||||
}
|
||||
.ds-link[data-band='sky'] {
|
||||
--lc: var(--b-sky);
|
||||
}
|
||||
.ds-link[data-band='ground'] {
|
||||
--lc: var(--b-ground);
|
||||
}
|
||||
.ds-link[data-band='inside'] {
|
||||
--lc: var(--b-inside);
|
||||
}
|
||||
|
||||
.ds-link-ic {
|
||||
flex: none;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: 9px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: var(--lc);
|
||||
background: color-mix(in srgb, var(--lc) 14%, transparent);
|
||||
}
|
||||
|
||||
.ds-link-main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
.ds-link-main b {
|
||||
font-size: var(--t-foot);
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.ds-link-main small {
|
||||
color: var(--ink-3);
|
||||
font-size: var(--t-cap2);
|
||||
}
|
||||
|
||||
.ds-link .chev {
|
||||
color: var(--ink-4);
|
||||
}
|
||||
|
||||
.ds-receipts {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.ds-rc {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 12px;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
.ds-rc div {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.ds-rc b {
|
||||
font-size: var(--t-foot);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.ds-rc small {
|
||||
color: var(--ink-3);
|
||||
font-size: var(--t-cap2);
|
||||
}
|
||||
|
||||
.ds-rc a {
|
||||
color: var(--tint);
|
||||
}
|
||||
|
||||
.ds-hash {
|
||||
flex: none;
|
||||
padding: 6px 8px;
|
||||
border-radius: 8px;
|
||||
color: var(--b-inside);
|
||||
background: rgba(235, 199, 119, 0.1);
|
||||
font-size: 0.625rem;
|
||||
}
|
||||
|
||||
.ds-hash:hover {
|
||||
background: rgba(235, 199, 119, 0.18);
|
||||
}
|
||||
|
||||
.ds-foot {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: flex-start;
|
||||
margin: 18px 0 0;
|
||||
color: var(--ink-3);
|
||||
font-size: var(--t-cap);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.ds-foot .i {
|
||||
flex: none;
|
||||
margin-top: 1px;
|
||||
}
|
||||
|
||||
/* Link graph */
|
||||
.dossier-sheet[data-seg='graph'] .sheet-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding-bottom: calc(var(--safe-b) + 12px);
|
||||
}
|
||||
|
||||
.ds-graph {
|
||||
flex: 1;
|
||||
min-height: 320px;
|
||||
position: relative;
|
||||
border-radius: 18px;
|
||||
background:
|
||||
radial-gradient(
|
||||
70% 60% at 50% 50%,
|
||||
rgba(163, 216, 255, 0.06),
|
||||
transparent 70%
|
||||
),
|
||||
rgba(255, 255, 255, 0.025);
|
||||
box-shadow: inset 0 0 0 0.5px var(--hairline);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.lg {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
.lg-canvas {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.lg-tip {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
padding: 4px 8px;
|
||||
border-radius: 6px;
|
||||
background: rgba(8, 10, 14, 0.92);
|
||||
box-shadow: inset 0 0 0 0.5px var(--hairline-strong);
|
||||
color: var(--ink);
|
||||
font-size: 0.5625rem;
|
||||
white-space: nowrap;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.lg-legend {
|
||||
position: absolute;
|
||||
left: 12px;
|
||||
bottom: 10px;
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
color: var(--ink-3);
|
||||
font-size: 0.5rem;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.lg-legend span {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.lg-legend i {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.lg-legend [data-band='orbit'] i {
|
||||
background: var(--b-orbit);
|
||||
}
|
||||
.lg-legend [data-band='sky'] i {
|
||||
background: var(--b-sky);
|
||||
}
|
||||
.lg-legend [data-band='ground'] i {
|
||||
background: var(--b-ground);
|
||||
}
|
||||
.lg-legend [data-band='inside'] i {
|
||||
background: var(--b-inside);
|
||||
}
|
||||
|
||||
.ds-graph-note {
|
||||
flex: none;
|
||||
margin: 8px 0 0;
|
||||
color: var(--ink-3);
|
||||
font-size: 0.5625rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* ---------- Universal search ---------- */
|
||||
.search-sheet .sheet-head {
|
||||
padding-top: 4px;
|
||||
}
|
||||
|
||||
.results-h {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
margin: 16px 2px 6px;
|
||||
color: var(--ink-3);
|
||||
font-size: var(--t-cap);
|
||||
font-weight: 650;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
|
||||
.results-h small {
|
||||
font-family: var(--mono);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.results-h .link-btn {
|
||||
margin-left: auto;
|
||||
color: var(--tint);
|
||||
text-transform: none;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.sr-chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.sr-chips .chip {
|
||||
height: 34px;
|
||||
padding: 0 13px;
|
||||
font-size: var(--t-foot);
|
||||
color: var(--ink);
|
||||
background: rgba(255, 255, 255, 0.07);
|
||||
}
|
||||
|
||||
.sr-chips .chip:hover {
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
|
||||
.sr-chips .chip .i {
|
||||
color: var(--ink-3);
|
||||
}
|
||||
|
||||
.sr-list {
|
||||
display: grid;
|
||||
border-radius: 16px;
|
||||
overflow: hidden;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
.sr {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 10px 12px;
|
||||
text-align: left;
|
||||
color: var(--ink);
|
||||
--sc: var(--ink-2);
|
||||
}
|
||||
|
||||
.sr + .sr::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 56px;
|
||||
right: 0;
|
||||
height: 0.5px;
|
||||
background: var(--hairline);
|
||||
}
|
||||
|
||||
.sr:hover,
|
||||
.sr[aria-selected='true'] {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.sr[data-band='orbit'] {
|
||||
--sc: var(--b-orbit);
|
||||
}
|
||||
.sr[data-band='sky'] {
|
||||
--sc: var(--b-sky);
|
||||
}
|
||||
.sr[data-band='ground'] {
|
||||
--sc: var(--b-ground);
|
||||
}
|
||||
.sr[data-band='inside'] {
|
||||
--sc: var(--b-inside);
|
||||
}
|
||||
|
||||
.sr-ic {
|
||||
flex: none;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 10px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-size: 1.05rem;
|
||||
color: var(--sc);
|
||||
background: color-mix(in srgb, var(--sc) 14%, transparent);
|
||||
}
|
||||
|
||||
.sr-main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
.sr-main b {
|
||||
font-size: var(--t-sub);
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.sr-main small {
|
||||
color: var(--ink-3);
|
||||
font-size: var(--t-cap);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.sr-tag {
|
||||
flex: none;
|
||||
color: var(--ink-4);
|
||||
font-size: 0.5rem;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.sr-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding: 40px 10px;
|
||||
text-align: center;
|
||||
color: var(--ink-3);
|
||||
}
|
||||
|
||||
.sr-empty b {
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.sr-note {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: flex-start;
|
||||
margin: 18px 4px 0;
|
||||
color: var(--ink-3);
|
||||
font-size: var(--t-cap);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.sr-note .i {
|
||||
flex: none;
|
||||
margin-top: 1px;
|
||||
}
|
||||
|
||||
/* Grid rows never grow wider than their list. */
|
||||
.sr-list,
|
||||
.ds-links,
|
||||
.ds-receipts {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
@@ -0,0 +1,333 @@
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Now feed: header, altitude filter, masonry + wide rows */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
#view-today {
|
||||
background: var(--ground);
|
||||
}
|
||||
|
||||
.feed-inner {
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.feed-hero {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding-right: calc(var(--gutter) + var(--safe-r));
|
||||
}
|
||||
|
||||
.feed-hero .large-title {
|
||||
letter-spacing: -0.035em;
|
||||
}
|
||||
|
||||
.feed-clock {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 2px;
|
||||
padding-bottom: 6px;
|
||||
color: var(--ink-3);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.feed-clock b {
|
||||
color: var(--ink);
|
||||
font-weight: 600;
|
||||
font-size: 0.75rem;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
#view-today .title-sub {
|
||||
gap: 7px;
|
||||
color: var(--ink-3);
|
||||
font-size: 0.625rem;
|
||||
}
|
||||
|
||||
#view-today .title-sub .live-dot {
|
||||
--fam: var(--ink);
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
}
|
||||
|
||||
/* Altitude filter: an altimeter, not a segmented control. */
|
||||
.bandbar {
|
||||
position: sticky;
|
||||
top: calc(var(--safe-t) + 52px);
|
||||
z-index: 4;
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
gap: 2px;
|
||||
margin-top: 14px;
|
||||
padding: 0 calc(8px + var(--safe-r)) 0 calc(8px + var(--safe-l));
|
||||
overflow-x: auto;
|
||||
scrollbar-width: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.bandbar::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.bandbar::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: -1;
|
||||
background: rgba(5, 6, 7, 0.82);
|
||||
-webkit-backdrop-filter: var(--glass-blur);
|
||||
backdrop-filter: var(--glass-blur);
|
||||
border-bottom: 0.5px solid var(--hairline);
|
||||
opacity: 0;
|
||||
transition: opacity var(--dur-1) linear;
|
||||
}
|
||||
|
||||
#view-today.is-scrolled .bandbar::before {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
#view-today.is-scrolled .navbar::before {
|
||||
border-bottom-color: transparent;
|
||||
}
|
||||
|
||||
.bandbar [data-band] {
|
||||
position: relative;
|
||||
flex: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
gap: 3px;
|
||||
min-height: 50px;
|
||||
padding: 7px 10px 8px;
|
||||
color: var(--ink-3);
|
||||
transition: color var(--dur-1) linear;
|
||||
}
|
||||
|
||||
.bandbar [data-band]::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 10px;
|
||||
right: 10px;
|
||||
top: 0;
|
||||
height: 1px;
|
||||
background: var(--hairline-strong);
|
||||
transition:
|
||||
height var(--dur-2) var(--ease-out),
|
||||
background var(--dur-2) linear;
|
||||
}
|
||||
|
||||
.bandbar [data-band][aria-selected='true'] {
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.bandbar [data-band][aria-selected='true']::before {
|
||||
height: 2px;
|
||||
background: var(--band, var(--ink));
|
||||
}
|
||||
|
||||
.bb-n {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 650;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.bb-n i {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 1px;
|
||||
background: var(--band);
|
||||
}
|
||||
|
||||
.bb-r {
|
||||
font-size: 0.5625rem;
|
||||
color: var(--ink-4);
|
||||
}
|
||||
|
||||
[aria-selected='true'] > .bb-r {
|
||||
color: var(--ink-3);
|
||||
}
|
||||
|
||||
.navbar .nav-trailing {
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.near-chip {
|
||||
flex: none;
|
||||
align-self: center;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
height: 32px;
|
||||
padding: 0 12px;
|
||||
border-radius: var(--r-pill);
|
||||
box-shadow: inset 0 0 0 1px var(--hairline-strong);
|
||||
color: var(--ink-2);
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.near-chip .i {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.near-chip[aria-pressed='true'] {
|
||||
background: var(--ink);
|
||||
color: var(--ground);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.feed-note {
|
||||
padding: 10px calc(8px + var(--safe-r)) 0 calc(8px + var(--safe-l));
|
||||
}
|
||||
|
||||
.near-prompt {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 12px 12px 14px;
|
||||
border-radius: 10px;
|
||||
font-size: var(--t-foot);
|
||||
background: var(--ground-2);
|
||||
box-shadow: inset 0 0 0 0.5px var(--hairline-strong);
|
||||
}
|
||||
|
||||
.near-prompt > .i {
|
||||
font-size: 1.2rem;
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.near-prompt div {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.near-prompt span {
|
||||
color: var(--ink-3);
|
||||
font-size: var(--t-cap);
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.near-prompt .pill-btn {
|
||||
min-height: 36px;
|
||||
padding: 0 14px;
|
||||
font-size: var(--t-foot);
|
||||
}
|
||||
|
||||
.feed-body {
|
||||
padding: 10px calc(8px + var(--safe-r)) 0 calc(8px + var(--safe-l));
|
||||
}
|
||||
|
||||
.feed-body.swap {
|
||||
animation: view-in 320ms var(--ease-out) both;
|
||||
}
|
||||
|
||||
.masonry {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.mcol {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.masonry + .wide-row,
|
||||
.wide-row + .masonry,
|
||||
.wide-row + .wide-row {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.feed-empty {
|
||||
padding: 60px 0;
|
||||
text-align: center;
|
||||
color: var(--ink-4);
|
||||
}
|
||||
|
||||
/* Skeleton */
|
||||
.card.skel .card-media,
|
||||
.sk-line {
|
||||
background: linear-gradient(
|
||||
100deg,
|
||||
rgba(255, 255, 255, 0) 30%,
|
||||
rgba(255, 255, 255, 0.06) 50%,
|
||||
rgba(255, 255, 255, 0) 70%
|
||||
)
|
||||
var(--ground-3);
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 1.4s linear infinite;
|
||||
}
|
||||
|
||||
.sk-line {
|
||||
height: 10px;
|
||||
border-radius: 2px;
|
||||
margin: 3px 0 8px;
|
||||
}
|
||||
|
||||
.sk-line.short {
|
||||
width: 55%;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.feed-end {
|
||||
text-align: center;
|
||||
color: var(--ink-4);
|
||||
padding: 28px 0 0;
|
||||
}
|
||||
|
||||
/* Pull to refresh: an orbit of eight ticks. */
|
||||
.ptr {
|
||||
position: absolute;
|
||||
z-index: 3;
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: calc(var(--safe-t) + 62px);
|
||||
height: 40px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
--p: 0;
|
||||
}
|
||||
|
||||
.ptr-spinner {
|
||||
position: relative;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
transform: rotate(calc(var(--p) * 200deg));
|
||||
}
|
||||
|
||||
.ptr-spinner i {
|
||||
position: absolute;
|
||||
left: 14px;
|
||||
top: 12px;
|
||||
width: 2px;
|
||||
height: 6px;
|
||||
border-radius: 1px;
|
||||
background: var(--ink);
|
||||
transform: rotate(calc(var(--i) * 45deg)) translateY(-11px);
|
||||
opacity: clamp(0, calc((var(--p) * 8 - var(--i)) * 1), 1);
|
||||
}
|
||||
|
||||
.ptr.spinning .ptr-spinner {
|
||||
animation: spin 900ms steps(8) infinite;
|
||||
}
|
||||
|
||||
.ptr.spinning .ptr-spinner i {
|
||||
opacity: calc(0.25 + var(--i) * 0.1);
|
||||
}
|
||||
|
||||
/* The Near lens: the feed becomes a distance-ordered list around you. */
|
||||
#view-today.is-near .feed-count::after {
|
||||
content: ' · nearest first';
|
||||
}
|
||||