Compare commits

..
Author SHA1 Message Date
Claude 541bb78282 fix(intro): keep restricted volumes to areas that fit the scene
Fort Bragg's reservation polygon is larger than the scene, which turned
every road into a convoy and floated curtain walls past the ground edge.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01KqP857pippaUs6konSDfe8
2026-10-19 02:14:10 +00:00
Claude a0f8fcc2ae chore(intro): final city fetch scripts (Overpass fallback endpoints)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01KqP857pippaUs6konSDfe8
2026-10-19 01:14:23 +00:00
Claude 02e535cb55 feat(intro): v2 — cities, accelerating sweep, detections, thriller sound design
- City engine: Esri ground, OSM buildings that rise with the scan, road
  ribbons carrying modelled traffic coloured by congestion, NYC DOT cameras,
  real ADS-B, MBTA GTFS lines + realtime vehicles, LAX runways with approach
  strobes, Fort Bragg restricted volumes, Everest terrain with contours.
- Indexing keeps accelerating into a 260-capture global sweep while the
  planet whips a full turn.
- The eye opens: aperture blades, sensor array waking, optics calibration,
  radar sweep, orbiting coordinate ring.
- Camera wall with YOLOv8n detections and a city-pulse trace.
- Ends on GOD'S EYE VIEW / OMNISCIENCE.
- audio/sfx.py: sound design only, built to sit under a music track.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01KqP857pippaUs6konSDfe8
2026-10-19 01:10:39 +00:00
Claude e9ddc37788 fix(intro): punch exposure on impacts instead of adding a grey veil
Additive flashes lifted the blacks to visible grey after gamma; scale the
exposure instead so only lit pixels brighten. Also drop the scratch GL test.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01KqP857pippaUs6konSDfe8
2026-10-18 03:37:49 +00:00
Claude c9421a3f33 feat(intro): add the First Light intro sequence
A deterministic WebGL film of God's Eye View coming online: a seed on Null
Island grows into a culture that draws the planet, indexes the surface
tile by tile, ingests the live feeds and outgrows its dish, ending on the
logo. Renders 16:9 and 9:16 frame by frame in headless Chromium; the
procedural score is locked to the same clock. Data and imagery are
fetched by the scripts in tools/ and are not committed.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01KqP857pippaUs6konSDfe8
2026-10-18 03:21:30 +00:00
289 changed files with 40498 additions and 24805 deletions

No files matched your search

+2 -10
View File
@@ -83,12 +83,6 @@ OPENAI_REALTIME_TRANSCRIBE_MODEL=gpt-4o-mini-transcribe
# keep them while debugging; delete the log when you are done.
GEV_VOICE_LOG_CONTENT=
OPENAI_HUD_SUMMARY_MODEL=gpt-5-nano
# Optional: Gemini Live voice, selected in the GEV MIC provider chooser.
# Server-only key. Do not prefix with VITE_. Provider Settings can save it.
GEMINI_API_KEY=
GEMINI_LIVE_MODEL=gemini-3.8-live
# Token starts per IP per minute; 0 disables the throttle, not provider quotas.
# GEV_RATELIMIT_GEMINI_PER_MIN=30
# Per-IP rate limit for the OpenAI cost endpoints
# (/api/realtime/token + /api/openai/hud-summary), in requests per minute.
# DEFAULTS TO 30, the value the Pinokio build also ships. That clears ordinary
@@ -124,8 +118,8 @@ OPENSKY_AUTH_MODE=oauth
OPENSKY_CLIENT_ID=
OPENSKY_CLIENT_SECRET=
# Launch Library 2: leave blank for standard public access (no key needed).
# Optional paid Patreon tokens raise request limits; there is no free API key.
# Optional: higher Launch Library 2 request allowance. Public access works
# without a token.
LL2_API_TOKEN=
# Optional: OpenSky credentials JSON file path (alternative to above)
@@ -243,8 +237,6 @@ AISSTREAM_API_KEY=
# CCTV_TORONTO_LIST_URL= # override the Toronto RESCU camera list URL
# CCTV_LITHUANIA_ENABLED= # off by default; set 1 to load Via Lietuva / Eismoinfo road cameras (photo licence unconfirmed; see DATA_SOURCES.md)
# CCTV_LITHUANIA_MAX_SOURCES=400 # cap Lithuanian cameras; no API key required
# CCTV_FLORIDA_ENABLED= # off by default; set 1 to load FL511 (Florida) cameras (individual, personal use only; any other use, including a public deployment, needs FDOT's express written consent; see DATA_SOURCES.md)
# CCTV_FLORIDA_MAX_SOURCES=500 # cap cameras loaded from the FL511 catalog (nearest regional anchors first)
# CCTV_MAX_SOURCES=4000 # catalog-wide ceiling, shared round-robin across packs
# CCTV_PREFER_AUSTIN=1 # prioritize Austin cameras in the registry
# CCTV_FORCE_AUSTIN= # force live default sources when a source-pack JSON is configured
+6
View File
@@ -67,6 +67,8 @@ jobs:
timeout-minutes: 30
env:
GEV_REQUIRE_ALLOCATION_GATE: '0'
# Fail on a missed header press unless the panel itself moved.
QA_FAIL_ON_RETRY: '1'
steps:
- name: Check out source
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
@@ -102,6 +104,10 @@ jobs:
- name: Street Level gate with Mapillary fixtures
run: npm run qa:street-level:fixtures -- --strict --url http://localhost:4173
- name: Portable panel gate
if: success() || failure()
run: npm run qa:panel-resize -- --url http://localhost:4173
- name: Preview server log
if: failure()
run: cat preview.log
-4
View File
@@ -16,10 +16,6 @@ output/
.gev-cache/
# Browser QA gate evidence (screenshots, console and network dumps).
qa-artifacts/
# Playwright MCP session logs: browser consoles can carry tokens in URLs.
.playwright-mcp/
# Local Claude Code settings and preview configs.
.claude/
.DS_Store
.gstack/
3d-models/
+20 -14
View File
@@ -317,8 +317,9 @@
[NotProniss](https://github.com/NotProniss) (#1084).
- Street Level: Panoramax is the fourth provider, keyless. Light its chip
(orange; off by default, one provider at a time): below about 60 km the
federated commons' sequences draw as lines from `api.panoramax.xyz` vector tiles. A click on a
(orange; off by default, one provider at a time): from orbit dots show
where the federated commons has pictures, and below about 60 km its
sequences draw as lines from `api.panoramax.xyz` vector tiles. A click on a
line opens that sequence's picture nearest the click without moving the
camera, flat or 360°, with cones along the sequence around it and ‹ › /
← → following the picture's own links. The 360° and since filters apply,
@@ -376,10 +377,10 @@
Maps JavaScript API) and click a street, or press OPEN STREET VIEW, to
open Google's own panorama in
the panel, with its arrows, the address and month in the caption, a link
to Google Maps. Share links carry it in the provider switches
to Google Maps and FOLLOW. Share links carry it in the provider switches
(`r.google-1`). One provider is lit at a time (lighting one switches the
other off), Mapillary by default since it is free. Street View's panoramas
are all 360°, so the IMAGERY and SINCE filters apply to
are all 360°, so the IMAGERY and SINCE filters and the legend apply to
Mapillary only and hide while GOOGLE STREET VIEW is lit; its globe marker
is a Google-blue view cone.
@@ -418,16 +419,21 @@
proposed diagnosis in #927, and [kvnloo](https://github.com/kvnloo) for identifying
the repair race. Windows/Claude Desktop Cowork confirmation remains outstanding.
- Street Level: street-level imagery from providers, Mapillary first (free
client token, CC BY-SA 4.0). A right-rail panel holds a chip per provider,
360°/flat and captured-since filters and an embedded viewer with EXPAND and
FIT/FILL. Coverage lines (below 60 km) and image cones drape on terrain and
Google 3D tiles; opening a photo frames it with one camera flight. Share
links carry the provider switches, the filters and the panel's collapsed
state. A server proxy adds the token to Mapillary coverage tiles, trims and
caches them for 24 h, refuses cross-site requests and redirects, and limits
load per IP and upstream. The panel reads KEY REQUIRED without a token and
KEY REJECTED when Mapillary refuses it.
- Street Level: a street-level imagery layer modelled on the iD editor's photo
overlay, with Mapillary as its first provider (free client token, CC BY-SA 4.0
imagery). One right-rail panel holds a chip per provider, shared 360°/flat and
captured-since filters, and an embedded viewer with EXPAND, FIT/FILL and
FOLLOW (Google 3D only). Coverage lines and image cones are drawn from orbit
down to a single street; on Google 3D they sit on the bare earth, refined
against the rendered mesh near the camera, so trees and buildings hide them.
Share links carry the provider switches, the filters and the panel's collapsed
state. A server proxy caches Mapillary coverage tiles (24 h, 1 GiB on disk),
refuses cross-site requests, rate-limits per IP and backs off when Mapillary
rejects the token or rate-limits.
- The CCTV and Street Level panels are portable: drag the header to float the
panel, resize it from any edge or corner, and double-click the header to dock
it again. The position persists across reloads.
- Add Norway's city bikes to the Bike Share layer: Oslo, Bergen and
Trondheim Bysykkel (Urban Sharing), Kolumbus Bysykkel in Stavanger and
+8 -6
View File
@@ -54,8 +54,9 @@ head -40 scripts/qa-radio.mjs # what this runnable gate proves and how to run i
Runnable gate entrypoints document what they assert and how to invoke them.
Most need a dev server; some also need a specific provider key or port. Follow
the entrypoint's header rather than assuming every matching file accepts the
same arguments. Four have an `npm run` alias: `qa:map-source-tray`,
`qa:street-level`, `qa:street-level:fixtures` and `qa:transit`.
same arguments. Five have an `npm run` alias: `qa:map-source-tray`,
`qa:panel-resize`, `qa:street-level`, `qa:street-level:fixtures` and
`qa:transit`.
If you aren't sure which gate covers your change, search `docs/CURRENT-STATE.md`
for the feature: it names the gate for many of them, and it's the authoritative
@@ -65,10 +66,11 @@ gate for the feature you changed.
> **CI covers only two of these for you.** The workflow runs the setup policy
> checks, the formatting and package-boundary checks, the unit suite and the
> production build, plus a Windows onboarding job. It runs neither
> `npm run test:track` nor any `qa-*.mjs` gate except the Street Level fixture
> gate (`street-level-browser` job) — they need a live dev server and a
> browser. Include the applicable local run in your PR's validation evidence.
> production build, plus a Windows onboarding job, and against the built preview
> it runs `qa:street-level:fixtures` and `qa:panel-resize`. It does not run
> `npm run test:track` or any other `qa-*.mjs` gate — those need a live dev
> server and a browser. Include the applicable local run in your PR's
> validation evidence.
## Share-link layer tokens
+2 -4
View File
@@ -20,10 +20,10 @@ How to read this:
| **NOAA nowCOAST observed weather** (`nowcoast.noaa.gov/geoserver/observations/{weather_radar,satellite}/ows`, WMS 1.1.1) | Rain radar (MRMS base reflectivity, contiguous US, about 4-minute updates) and Satellite clouds (GOES-19/18 Band 14 regional infrared, about 5-minute updates; global infrared mosaic, hourly). Keyless; layer metadata refreshed every 2 minutes, exact-time images cached for up to 24 hours | [NOAA disclaimer](https://oceanservice.noaa.gov/disclaimer.html) | "NOAA nowCOAST · NWS/OAR MRMS radar; NESDIS GOES and global satellite partners" |
| **NOAA nowCOAST lightning density** (`nowcoast.noaa.gov/geoserver/observations/lightning_detection/ows`) | Lightning density: 15-minute accumulated strike density on an approximately 8 km grid, Americas and Pacific. Keyless; layer metadata refreshed every 10 minutes, exact-time images cached for up to 24 hours | NOAA Level-5 derived product from Vaisala NLDN/GLD360; the [product description](https://ocean.weather.gov/lightning/lightning_pdd.php) permits public distribution of this density product, not of raw Vaisala detections | "NOAA/NWS nowCOAST; derived from Vaisala NLDN/GLD360" |
| **NOAA NHC / CPHC advisories** (`www.nhc.noaa.gov/CurrentStorms.json` + the `mapservices.weather.noaa.gov` tropical weather summary MapServer) | Cyclone advisories: storm positions, forecast tracks, points and cones for the Atlantic and eastern/central North Pacific. Keyless through `/api/cyclones`; cached for 5 minutes | [NWS public-data terms](https://www.weather.gov/disclaimer); no endorsement implied | "NOAA/NWS NHC / CPHC" |
| **Mapillary** (vector tiles, Graph API, MapillaryJS) | Street Level: coverage lines, image cones and the embedded viewer. Coverage tiles kept in server memory for 24 h | Imagery [CC BY-SA 4.0](https://www.mapillary.com/terms); API under the Mapillary terms with a client token | "© Mapillary contributors, CC BY-SA 4.0", shown on the globe while the provider is on |
| **Mapillary** (vector tiles, Graph API, MapillaryJS) | Street Level: coverage lines, image cones and the embedded viewer. Coverage tiles cached for 24 h | Imagery [CC BY-SA 4.0](https://www.mapillary.com/terms); API under the Mapillary terms with a client token | "© Mapillary contributors, CC BY-SA 4.0", shown on the globe while the provider is on |
| **Google Street View** (Maps JavaScript API) | Street Level, on the Google 3D map only (Google's terms): Google's panorama viewer and nearest-panorama lookups; panorama dates and addresses kept for the session only | Google Maps Platform ToS (your own key + billing; every panorama load is billed) | "Street View imagery © Google" on the globe while the provider is on, and Google's logo and terms in the viewer |
| **KartaView** (`api.kartaview.org` coverage tiles and photo API, `cdn.kartaview.org` image proxy, `storage*.openstreetcam.org` photos) | Street Level: coverage lines (z13–14 JSON tiles; with the imagery filter on, KartaView's filtered PNG tiles as a mask), nearest-photo lookups, the photo's sequence and the photo itself, fetched by the browser. Keyless; nothing cached beyond the session | Imagery [CC BY-SA 4.0](https://kartaview.org/terms) (KartaView terms §4); the public API needs no token | "© KartaView contributors, CC BY-SA 4.0", shown on the globe while the provider is on |
| **Panoramax** (`api.panoramax.xyz` meta-catalogue: vector tiles and STAC search; pictures from the federated instance holding each one, e.g. `panoramax.openstreetmap.fr`, `panoramax.ign.fr`) | Street Level: coverage lines, nearest-picture lookups, a sequence's pictures as cones, and the picture itself, fetched by the browser. Keyless; tiles cached by the browser for an hour (the API's own header) | Per picture: mostly [CC BY-SA 4.0](https://creativecommons.org/licenses/by-sa/4.0/), IGN's [Licence Ouverte 2.0](https://www.etalab.gouv.fr/licence-ouverte-open-licence/); the API needs no token | "© Panoramax contributors, licence per picture" on the globe while the provider is on; the caption names each picture's author and licence |
| **Panoramax** (`api.panoramax.xyz` meta-catalogue: vector tiles and STAC search; pictures from the federated instance holding each one, e.g. `panoramax.openstreetmap.fr`, `panoramax.ign.fr`) | Street Level: coverage lines and density points, nearest-picture lookups, a sequence's pictures as cones, and the picture itself, fetched by the browser. Keyless; tiles cached by the browser for an hour (the API's own header) | Per picture: mostly [CC BY-SA 4.0](https://creativecommons.org/licenses/by-sa/4.0/), IGN's [Licence Ouverte 2.0](https://www.etalab.gouv.fr/licence-ouverte-open-licence/); the API needs no token | "© Panoramax contributors, licence per picture" on the globe while the provider is on; the caption names each picture's author and licence |
| **OpenStreetMap** | Map and place data (roads, military areas and names, ALPR, place search, outlines); via vector tiles, hourly ALPR extract, bundled data, Nominatim and optional self-configured Overpass | [ODbL 1.0](https://opendatacommons.org/licenses/odbl/1-0/) | © OpenStreetMap contributors |
| **Google Map Tiles API** (Photorealistic 3D Tiles) + Places/Geocoding | The 3D globe, voice scene context, and on-demand nearby installation search | Google Maps Platform ToS (proprietary, your own key + billing) | "Google" / "Google Maps" logo — **shown in-app**, required |
| **OpenSky Network** | Primary worldwide live-flight snapshot | Non-commercial research/education license | Schäfer et al., _"Bringing Up OpenSky"_, IPSN 2014 + opensky-network.org |
@@ -48,7 +48,6 @@ How to read this:
| **GDELT Project DOC 2.0** | Fail-soft fallback for location-matched cockpit headlines | [GDELT Terms of Use](https://www.gdeltproject.org/about.html#termsofuse): unrestricted academic/commercial/governmental dataset use, with citation and link required; linked articles retain publisher terms | "GDELT Project" plus each article's linked publisher/domain |
| **City of Austin Open Data** | CCTV camera catalog + frames | City of Austin Open Data Terms of Use | "City of Austin, TX — data.austintexas.gov" |
| **TxDOT ITS (its.txdot.gov)** | CCTV camera catalogs + frames, Texas districts | Public TxDOT traffic camera data | "Texas Department of Transportation" (courtesy) |
| **FL511 (Florida DOT)** | CCTV camera catalog + frames, Florida statewide | FL511 content, individual personal use only; any other use needs FDOT's express written consent ([terms](https://fl511.com/privacy#disclaimer)). **Off by default**; set `CCTV_FLORIDA_ENABLED=1` to enable | "Florida DOT — FL511.com" (courtesy) |
| **City of Tallinn (ristmikud.tallinn.ee)** | CCTV camera catalog + frames, Tallinn intersections | Public City of Tallinn traffic camera data | "City of Tallinn — ristmikud.tallinn.ee" (courtesy) |
| **Transpordiamet / Tarktee** | CCTV camera catalog + frames, Estonia road-weather cameras | Public Transpordiamet / Tarktee road camera data | "Transpordiamet / Tarktee — tarktee.transpordiamet.ee" (courtesy) |
| **Stadt Warendorf webcam** | Webcam frames, Marktplatz / Historisches Rathaus (`config/cctv_sources.warendorf.json`) | Public municipal webcam data | "Stadt Warendorf — webcam.warendorf.de" (courtesy) |
@@ -163,7 +162,6 @@ The optional Local ADS-B layer and the broadcast-FM receiver use radio you recei
- **Warendorf webcam.** The Stadt Warendorf Marktplatz webcam, registered from the curated `config/cctv_sources.warendorf.json` (the Kreis Warendorf registration-office cameras from the contributing PR are indoor waiting rooms and were left out); only the municipal image host is proxied, frames are fetched at request time and never stored or redistributed. Disable with `CCTV_WARENDORF_ENABLED=0`.
- **Live Traffic NSW cameras.** The camera list is the keyless public `data.livetraffic.com/cameras/traffic-cam.json` feed (Transport for NSW open data, CC BY 4.0); frames are stills on `webcams.transport.nsw.gov.au`, and only that host is registered. That image host answers non-browser clients with an HTML placeholder, so the proxy identifies as a browser for that one host and as itself everywhere else. Frames are fetched at request time and never stored or redistributed. Disable with `CCTV_NSW_ENABLED=0`.
- **Open Calgary traffic cameras.** Keyless. The camera list is the public Socrata dataset `k7p9-kppz` on `data.calgary.ca`; frames are stills on the city's own `trafficcam.calgary.ca` host, and only that host is registered. Most rows publish an `http://` frame URL and the host redirects those to HTTPS, so URLs are upgraded and pinned to that origin before registration. Frames are fetched at request time and never stored or redistributed. The dataset publishes no camera facing: the `quadrant` field and the quadrant suffix on each camera name are Calgary's address grid, not a bearing, so headings use the shared id-hash fallback at low confidence and are corrected with the in-app calibration gizmo. The licence requires the attribution statement above. Disable with `CCTV_CALGARY_ENABLED=0`.
- **FL511 Florida cameras.** Off by default; set `CCTV_FLORIDA_ENABLED=1` to load them. The camera list is the keyless FL511 traffic camera layer on ArcGIS (`services.arcgis.com/.../FL511_Traffic_Cameras`), published from a Florida Division of Emergency Management account rather than by FL511 directly, and the service carries no licence text of its own. It is paged up to 2,000 rows at a time, ordered by the layer's object id and following ArcGIS's `exceededTransferLimit`, to cover the ~4,000-camera statewide set. Frames are stills on two Divas Cloud hosts (`images-dis.divas.cloud`, `snapshots.divas.cloud`); both are host-pinned and a row whose image resolves elsewhere is dropped. `CCTV_FLORIDA_MAX_SOURCES` caps the pack (default 500, anchored per region across the state). Each catalog page is read under a 2 MiB cap and redirects are refused. Frames are fetched live at request time and never stored or redistributed. FL511's terms (fl511.com/privacy#disclaimer) license its content for individual, personal use only; any other use, including a public deployment, needs FDOT's express written consent.
- **Catalonia (Servei Català de Trànsit) cameras.** The camera list is the keyless `https://www.gencat.cat/transit/opendata/cameres.xml` WFS/GML feed, fetched over https with redirects refused and the body read capped at 1 MiB, since the list decides ids, coordinates and which allowlisted frame path is proxied. It carries a `font` field naming the real publisher per camera; this pack keeps SCT's own highway cameras plus hotlinked Barcelona (IMI) and Terrassa municipal cameras (~130 total), each confirmed to return a real frame, so frame URLs are checked against a small host allowlist (`mct.gencat.cat`, `www.bcn.cat`, `emap.terrassa.cat`) rather than one pinned origin (a link carrying `user:pass@` userinfo or an explicit port, or pointing outside that host's frame directory, is rejected even on an allowed host; an SCT `sctidcam` must be a bare `.gif`/`.jpg` filename), and every non-SCT camera carries its real owner as a `credit` shown beside the shared `provider`. The feed carries no compass heading, so every camera loads at low heading confidence (same as TfL and Fintraffic). SCT's own feed link (`RenderService`) needed a workaround: it always 302s to a second endpoint (`TransitCamera`) on a Location the upstream hardcodes as plain http even when requested over https — refused by `fetchWithinHost`'s same-origin (scheme-included) redirect policy — and separately, `mct.gencat.cat`'s TLS setup fails the handshake with Node's fetch outright (`ERR_SSL_WRONG_SIGNATURE_TYPE`) even though other HTTP clients complete it. The loader therefore builds the `TransitCamera` URL itself, over plain http, from `RenderService`'s `sctidcam` id, rather than registering the (broken) upstream link — the only transport this one host actually serves to this runtime. Camera ids are built from the SCT camera file or the partner image path, not the feed's `fid` (which embeds an upstream server-start stamp and changes on every upstream restart), and rows the feed lists more than once collapse to one camera. The feed carries no camera status field, so a camera its publisher has taken offline still registers and shows the publisher's own "image unavailable" still (SCT and Barcelona both serve one with HTTP 200), the same as other packs without a status field. Frames are fetched at request time and never stored or redistributed. The feed itself states no licence; the pack relies on the Generalitat de Catalunya open-data portal's terms for this dataset (the licence and attribution above). Disable with `CCTV_CATALONIA_ENABLED=0`.
- **King County road cameras.** Keyless. The camera list is one public ArcGIS FeatureServer GeoJSON query (King County DOT Road Services, "King County and WSDOT Traffic Cameras"). The `outFields` list is explicit on purpose: the layer also carries asset-administration columns — including staff names — that are never fetched. Only live, active cameras with frames on the county's own `info.kingcounty.gov` image handler are registered (the feed writes `http://`; the handler serves HTTPS, so URLs are upgraded and pinned before registration). WSDOT-owned rows in the layer are skipped because their frames are WSDOT-hosted, outside the pinned image origin; partner-city cameras hosted on the county image handler (e.g. Newcastle) name their owner as the per-camera credit. The layer publishes no facing, so headings use the shared id-hash fallback at low confidence. Uniquely, the county publishes each camera's hardware `Manufacturer`/`Model` (Cohu 3950/3965, Bosch AUTODOME, Axis PTZ domes); both are carried on the source (and served by `/api/cctv/sources`) as `model` and `manufacturer` for identified-hardware consumers (vendor-generic strings such as `PTZ` name no model and are omitted). Frames are fetched at request time and never stored or redistributed. Disable with `CCTV_KINGCOUNTY_ENABLED=0`.
- **WSDOT highway cameras.** Keyless. The camera list is the Esri JSON layer `data.wsdot.wa.gov/travelcenter/Cameras.json` — the same layer WSDOT's own Travel Center map consumes (~1,700 point features, EPSG:3857 geometry converted to WGS84 on load; the body carries Windows-1252 bytes and is decoded as such). Only frames on WSDOT's own `images.wsdot.wa.gov` host are registered — the catalog also lists ~70 partner cameras (Oregon DOT, City of Seattle, lodges, airports) whose own terms apply, and those are skipped rather than proxied. WSDOT's layer metadata marks the feed for "low volume" use (see WSDOT's [travel information disclaimer](https://wsdot.wa.gov/about/policies/travel-information-disclaimer)). The catalog is fetched once per CCTV catalog refresh (every 15 minutes), and a camera's frame is fetched only when that camera is viewed; frames are never stored or redistributed. The live feed's `CompassDirection` is a single cardinal (N/E/S/W), `B` (both directions — most rows), `O`, or null. A single cardinal gives a high-confidence heading (the diagonals NE/SE/SW/NW are mapped too should they appear); `B`, `O`, null, and any other code carry no single facing and use the shared id-hash fallback at low confidence. By default the 250 cameras nearest Seattle and Spokane load; `CCTV_WSDOT_MAX_SOURCES` changes the cap (8–1,200) and `CCTV_WSDOT_ENABLED=0` turns the pack off. A keyed WSDOT Traveler Information API serves the same population for higher-volume use and is deliberately not required here.
+6 -12
View File
@@ -309,10 +309,6 @@ _Why cockpit mode exists: you're riding a real aircraft over real terrain — an
## 🎙️ Talk to It
The standalone app offers **OpenAI** and **Gemini** in the GEV MIC provider chooser. OpenAI is the initial default; your choice is saved in this browser. To use Gemini, add `GEMINI_API_KEY` in Provider Settings or your local `.env` file, then select Gemini. `GEMINI_LIVE_MODEL` defaults to `gemini-3.8-live`. The permanent key stays on the local server, which issues a constrained, single-use session credential.
Changing provider ends the current conversation. If voice is on, the new provider starts with the same open-mic or push-to-talk mode; a held Space key is not carried over. Gemini uses the same map actions and captions, but has no OpenAI cost meter or MINI tier. When Gemini reports that its session has expired, start voice again for a new conversation. The provider chooser does not change the OpenAI-backed AI HUD summary.
> Voice can use an **OpenAI API key** or, on the same machine, an opt-in signed-in **ChatGPT/Codex OAuth** session. The API-key path remains the default. Selecting ChatGPT OAuth in Provider Settings reuses an existing local sign-in or starts the Codex browser sign-in flow and selects OAuth when it completes. The OpenAI key still drives the **AI HUD summary**: a terse, five-word intelligence-style readout of the current view that regenerates as you move.
The OAuth option is experimental. OpenAI documents ChatGPT plan usage for the [Responses API](https://developers.openai.com/siwc/token-sharing-open-source/models-and-inference); a supported third-party Realtime contract for reusing Codex tokens has not been confirmed. A successful token mint does not establish billing or support terms.
@@ -381,15 +377,15 @@ Twenty layers and map sources. **Eighteen have a keyless path.** Some offer addi
| 🌍 **Earthquakes** | Global seismic activity, last 24h | USGS | 🟢 |
| ◍ **Air quality** | Live AQHI on the published 1–10+ health-risk scale, with a Weather-panel card showing the band legend and the highest readings in view. **Canada only** — ECCC is the first registered provider | ECCC MSC GeoMet | 🟢 |
| 🚗 **Traffic** | Simulated vehicles on OSM roads. With TomTom, live flow speeds drive the simulation and congestion colors below ~8 km; individual vehicle positions are not live observations | TomTom + OSM | 🟢 simulation · 🟡 live flow speeds |
| 📹 **CCTV Mesh** | ~4,000 public cameras (shared cap) projected _into_ the 3D space — Austin · Texas (TxDOT) · California (Caltrans) · London (TfL) · Ontario (511) · Finland (Fintraffic) · British Columbia (DriveBC) · Estonia (Tallinn, Tarktee) · Delaware (DelDOT live video) · New South Wales (Live Traffic NSW) · Calgary · Norway (Statens vegvesen) · Toronto · King County (WA) · Washington (WSDOT) · Catalonia (Servei Català de Trànsit), plus opt-in New York City (NYC DOT TMC, needs your own NYC DOT agreement), opt-in Lithuania (Via Lietuva / Eismoinfo, photo licence unconfirmed) and opt-in Florida (FL511, individual personal use only; other use needs FDOT's written consent). Positions are published; poses are estimated priors **you calibrate by dragging a gizmo on the camera itself** | City APIs | 🟢 |
| 📹 **CCTV Mesh** | ~4,000 public cameras (shared cap) projected _into_ the 3D space — Austin · Texas (TxDOT) · California (Caltrans) · London (TfL) · Ontario (511) · Finland (Fintraffic) · British Columbia (DriveBC) · Estonia (Tallinn, Tarktee) · Delaware (DelDOT live video) · New South Wales (Live Traffic NSW) · Calgary · Norway (Statens vegvesen) · Toronto · King County (WA) · Washington (WSDOT) · Catalonia (Servei Català de Trànsit), plus opt-in New York City (NYC DOT TMC, needs your own NYC DOT agreement) and opt-in Lithuania (Via Lietuva / Eismoinfo, photo licence unconfirmed). Positions are published; poses are estimated priors **you calibrate by dragging a gizmo on the camera itself** | City APIs | 🟢 |
| 📷 **Mapped ALPR Cameras** | License-plate-reader camera locations tagged by OpenStreetMap contributors, loaded one city-sized view at a time, with **SHOW NEAREST**. Locations and tags only: no plate data, no video | OpenStreetMap (incl. DeFlock mapping) | 🟢 |
| 📷 **Street Level** | Street-level photos behind one panel, one chip per provider: coverage on the streets, image cones, an embedded viewer with full-screen **EXPAND**. Mapillary, Google Street View, KartaView and Panoramax | Mapillary · Google · KartaView · Panoramax | 🟢 KartaView, Panoramax · 🟡 Mapillary token · 🔴 Google Maps key |
| 📷 **Street Level** | Street-level photos behind one panel, one chip per provider: coverage on the streets, image cones, an embedded viewer and **FOLLOW**. Mapillary, Google Street View, KartaView and Panoramax | Mapillary · Google · KartaView · Panoramax | 🟢 KartaView, Panoramax · 🟡 Mapillary token · 🔴 Google Maps key |
| 📻 **Radio** | Geolocated world radio with an **analog tuner** — drag the needle across up to 750 stations and the globe flies to each broadcaster | Radio Browser / broadcasters | 🟢 |
| 🚌 **Transit** | Live buses, trams, metros, trains and ferries with delayed playback between reports, selected-vehicle trails, and mode-coloured DETECT labels — Boston, Austin, Minneapolis, Helsinki, the Netherlands, Norway, South East Queensland, and nine in Ontario: Toronto (surface vehicles; the subway publishes no positions), Mississauga, Hamilton, Durham Region, York Region, Guelph, Burlington, Barrie and Kingston | Operator GTFS-Realtime feeds | 🟢 |
| 🚲 **Bikeshare** | Live station availability | GBFS | 🟢 |
| 🧭 **Directions** | Click A and B on the globe for a street-following drive, walk or cycle route draped on the terrain with turn-by-turn steps — then FLY the camera along it. No key, no geocoder, no mic | OSRM on FOSSGIS servers (OpenStreetMap) | 🟢 |
| 🔥 **Active Fires** | Live NASA FIRMS detections, trailing 24h | NASA FIRMS | 🟡 |
| 🚀 **Space Missions** | Rolling 30-day launches with payload, stage, and recovery detail | Launch Library 2 | 🟢 (🔴 optional paid token raises the allowance) |
| 🚀 **Space Missions** | Rolling 30-day launches with payload, stage, and recovery detail | Launch Library 2 | 🟢 (🟡 optional token raises the allowance) |
| 🎖️ **Mapped Installations** | Viewport-bounded military-site context from community mapping — incomplete by nature, and labeled that way | OpenStreetMap | 🟢 |
| 🌬️ **Wind** | Animated 10 m forecast wind with an optional speed, temperature or pressure color field and a reading at map center | NOAA GFS / ECMWF IFS | 🟢 |
| 🌦️ **Observed Weather** | Rain radar (contiguous US), Satellite clouds (GOES regional or global infrared, Clouds only or Full image) and Lightning density (Americas and Pacific) on one history timeline | NOAA nowCOAST | 🟢 |
@@ -425,9 +421,9 @@ _The Space Missions layer replaying a Falcon 9 ascent — labeled `RECONSTRUCTED
**Setup.** At [mapillary.com/dashboard/developers](https://www.mapillary.com/dashboard/developers), register an application with **Read** access and copy its **Client Token** (`MLY|…`). Paste it into **POWER UP → Mapillary**, or set `MAPILLARY_CLIENT_TOKEN` in `.env` and restart. Without it the panel reads **KEY REQUIRED**. **Google Street View** uses `GOOGLE_MAPS_API_KEY` with the **Maps JavaScript API** enabled for it in Google Cloud; its chip starts off, since every panorama is billed to the key. **KartaView** and **Panoramax** need nothing: their public APIs are keyless.
**Use.** Turn on **Street Level** under DATA LAYERS → Cameras, or with the pill in the STREET LEVEL panel header. Each provider has a chip in its own colour (Mapillary green), and one is lit at a time: lighting one switches the others off. Mapillary, which is free, is lit by default. Zoom in to street level (below 60 km) for the coverage lines; from orbit the panel says to zoom in. The **IMAGERY** (ALL / 360° / FLAT) and **SINCE** filters apply to Mapillary, KartaView and Panoramax; Street View panoramas are all 360°, so with Street View lit the filters are hidden. Click a coverage line for its image cones and a cone to open the photo. Light **KARTAVIEW** (purple) and zoom in below about 6 km: its lines are the streets KartaView has photos of, and a click on one opens the photo nearest the click, flat or 360° (drag to look around), with cones along its drive; **‹ ›** or ← → step through the drive (Shift+← →, ↑ ↓ and + − look around a 360° photo; pinch zooms on a phone). Photos KartaView has archived are skipped, and where all are archived the panel says so. For KartaView, **SINCE** goes by upload date, as KartaView's own map does (it can filter by nothing else), and the panel notes it under SINCE; KartaView filters its lines on its own server, and a filtered area KartaView has not drawn before can take several seconds. Light **PANORAMAX** (orange) for the open, federated commons (strongest in France and Belgium): below about 60 km its lines are sequences. A click on a line opens that sequence's picture nearest the click without moving the camera, flat or 360°, with cones along the sequence around it; **‹ ›** or ← → step through it, and both filters apply. Its caption names the picture's authors (or the instance holding it) and licence (mostly CC BY-SA 4.0; pictures on the IGN instance are Licence Ouverte 2.0). On the **Google 3D** map (Google's terms keep Street View off other maps, where it does not appear), light **GOOGLE STREET VIEW** and click a street from below 3 km, or press **OPEN STREET VIEW** for the view centre, to open Google's panorama there; step along its arrows. Above the photo: **EXPAND** (full screen; Esc leaves) and **FIT / FILL** (Mapillary, and flat KartaView and Panoramax photos). The caption links to the image on the provider's site, and the globe credits each provider that is on ("© Mapillary contributors, CC BY-SA 4.0", "Street View imagery © Google", "© KartaView contributors, CC BY-SA 4.0", "© Panoramax contributors, licence per picture"). KartaView's own photo pages fail for signed-out visitors, so its link opens the full-size photo.
**Use.** Turn on **Street Level** under DATA LAYERS → Cameras, or with the pill in the STREET LEVEL panel header. Each provider has a chip in its own colour (Mapillary green), and one is lit at a time: lighting one switches the others off. Mapillary, which is free, is lit by default. The **IMAGERY** (ALL / 360° / FLAT) and **SINCE** filters apply to Mapillary, KartaView and Panoramax; Street View panoramas are all 360°, so with Street View lit the filters and legend are hidden. Click a coverage line for its image cones and a cone to open the photo. Light **KARTAVIEW** (purple) and zoom in below about 6 km: its lines are the streets KartaView has photos of, and a click on one opens the photo nearest the click, flat or 360° (drag to look around), with cones along its drive; **‹ ›** or ← → step through the drive (Shift+← →, ↑ ↓ and + − look around a 360° photo; pinch zooms on a phone). Photos KartaView has archived are skipped, and where all are archived the panel says so. For KartaView, **SINCE** goes by upload date, as KartaView's own map does (it can filter by nothing else), and the panel notes it under the slider; KartaView filters its lines on its own server, and a filtered area KartaView has not drawn before can take several seconds, and the unfiltered lines show meanwhile. Light **PANORAMAX** (orange) for the open, federated commons (strongest in France and Belgium): from orbit its dots show where it has pictures, and below about 60 km its lines are sequences. A click on a line opens that sequence's picture nearest the click without moving the camera, flat or 360°, with cones along the sequence around it; **‹ ›** or ← → step through it, and both filters apply. Its caption names the picture's authors (or the instance holding it) and licence (mostly CC BY-SA 4.0; pictures on the IGN instance are Licence Ouverte 2.0). On the **Google 3D** map (Google's terms keep Street View off other maps, where it does not appear), light **GOOGLE STREET VIEW** and click a street from below 3 km, or press **OPEN STREET VIEW** for the view centre, to open Google's panorama there; step along its arrows. Above the photo: **EXPAND**, **FIT / FILL** (Mapillary, and flat KartaView and Panoramax photos), and **FOLLOW** (the globe camera follows the photo's view; Google 3D map only). The caption links to the image on the provider's site, and the globe credits each provider that is on ("© Mapillary contributors, CC BY-SA 4.0", "Street View imagery © Google", "© KartaView contributors, CC BY-SA 4.0", "© Panoramax contributors, licence per picture"). KartaView's own photo pages fail for signed-out visitors, so its link opens the full-size photo.
Share links carry the provider switches and filters (`0.m.0` Mapillary off, `0.m.0_0.r.google-1` Street View on, `0.m.0_0.r.google-0*kartaview-1*panoramax-0` KartaView on and `0.m.0_0.r.google-0*kartaview-0*panoramax-1` Panoramax on, as the app writes them (`0.m.0_0.r.kartaview-1` and `0.m.0_0.r.panoramax-1` work too; a link that leaves out `m` keeps Mapillary, the default, lit), `0.p.p` panoramas only, `0.s.365` the last year). To add a provider, implement the contract in `src/layers/streetLevel/registry.js` and register it in `src/app/layers/streetLevel.js`.
Drag the panel header to float the panel, and resize it from any edge. Double-click the header or collapse it to put it back in the rail. Share links carry the provider switches and filters (`0.m.0` Mapillary off, `0.m.0_0.r.google-1` Street View on, `0.m.0_0.r.google-0*kartaview-1*panoramax-0` KartaView on and `0.m.0_0.r.google-0*kartaview-0*panoramax-1` Panoramax on, as the app writes them (`0.m.0_0.r.kartaview-1` and `0.m.0_0.r.panoramax-1` work too; a link that leaves out `m` keeps Mapillary, the default, lit), `0.p.p` panoramas only, `0.s.365` the last year). To add a provider, implement the contract in `src/layers/streetLevel/registry.js` and register it in `src/app/layers/streetLevel.js`.
## 🎖️ Field Missions
@@ -531,11 +527,9 @@ _What the TomTom key buys you: rush-hour density painted on the city — then di
| | Key | Why | Get it |
| --- | -------------------- | ------------------------------------------------------------- | -------------------------------------------------- |
| 🟡 | **OpenSky** | ✈️ More flight-polling credits (🟢 anonymous works without) | [opensky-network.org](https://opensky-network.org) |
| 🔴 | **Launch Library 2** | 🚀 Optional paid token for higher limits (🟢 no key needed for public access) | [thespacedevs.com/supportus](https://thespacedevs.com/supportus) |
| 🟡 | **Launch Library 2** | 🚀 Higher space-missions request allowance (🟢 works without) | [thespacedevs.com](https://thespacedevs.com) |
Add these if you need higher polling allowances.
Leave `LL2_API_TOKEN` blank for standard public access. LL2 tokens are optional
paid Patreon upgrades for higher limits; there is no free API-key signup.
`npm run doctor` reports Node/npm readiness, the primary provider routes, and
where each configured provider was found without printing credential values.
+2 -2
View File
@@ -29,7 +29,7 @@ These are designed to be used directly in the browser (like a Mapbox public toke
1. **Google Maps API key** — loads Photorealistic 3D Tiles directly and powers Street View (Maps JavaScript API; add it to the key's API restrictions). **Restrict it** (HTTP referrer + API restriction to the required Google APIs) in the Google Cloud Console. An unrestricted key in a public deployment can be abused and billed to you. Restrict it to the **Map Tiles API and Maps JavaScript API only**: a referrer restriction applies to the Maps JavaScript API and the client SDKs, not to the web services, so a key carrying Geocoding under one is refused with `API keys with referer restrictions cannot be used with this API`. Search does not need it — geocoding goes through the server proxies above (#363).
2. **Cesium ion token** (`CESIUM_ION_TOKEN`, optional — for ion-hosted Google Photorealistic 3D Tiles, Bing world imagery, and world terrain) — used as `Cesium.Ion.defaultAccessToken` client-side. Use a public **`assets:read`** token with **URL restrictions** for any hosted deployment. The Community plan has eligibility and usage limits; a public token is not a secret, but it can still consume the account's quota.
3. **Mapillary client token** (`MAPILLARY_CLIENT_TOKEN`, optional, Street Level) — used by the MapillaryJS viewer and Graph API lookups in the browser, and by the server's coverage-tile proxy, which sends it only to `tiles.mapillary.com` over HTTPS (an upstream redirect is refused and answered 502, never followed) and caches tiles in memory only. Mapillary client tokens are meant for browser use.
3. **Mapillary client token** (`MAPILLARY_CLIENT_TOKEN`, optional, Street Level) — used by the MapillaryJS viewer and Graph API lookups in the browser, and by the server's coverage-tile proxy. Mapillary client tokens are meant for browser use.
> The explicit browser `define` block in `build/vite.js` controls exactly what reaches the client: only these three keys. Everything else stays server-side.
@@ -73,7 +73,7 @@ The data proxies under `server/providers/` are written so the browser cannot tur
- **Sanitized errors** — internal error details are not echoed back to clients.
- **Coalesced OAuth refresh** and cached successful responses only (OpenSky).
- **Redacted debug logging.** The voice debug log (`.gev-logs/`, gitignored) strips API keys, bearer tokens, client secrets, and image data URLs before writing.
- **Cross-site gate on cost/log endpoints.** The cost-bearing (`/api/realtime/token`, `/api/openai/hud-summary`, `/api/google/nearby-places`, `/api/google/text-search`), quota-bearing (`/api/mapillary/*`, with a per-IP limit on tiles, and `/api/tomtom/*`, backed by its persistent daily tile budget) and log (`/api/realtime/debug-log`) endpoints refuse cross-site browser requests: a foreign or opaque `Origin`, a `Sec-Fetch-Site` other than `same-origin`/`none` (this is what blocks an `<img>`/navigation that carries no `Origin`), or any reverse-proxy/CDN forwarding header returns `403`. Non-browser loopback tools (the repo QA harnesses POST the token endpoint with no `Origin`) and the explicit `HOST=0.0.0.0` LAN opt-in keep working — the gate deliberately does not require a loopback remote address; that stricter requirement belongs only to the credential panel (`admitKeySetupRequest`). The Host check (`allowedHosts`, applied by `hostCheckPlugin` ahead of every route, since Vite's own check runs only after plugin middleware) rejects a rebinding `Host` before any handler runs, in every mode, and the gate is defense in depth behind it: the LAN opt-in keeps that check and accepts only IP addresses, `localhost` and names listed in `GEV_ALLOWED_HOSTS`. **Limit:** a name you list there is trusted like the app's own address, so list only names you control. The keyless terrain route (`/api/terrain/heights`) uses the same cross-site checks, minus the forwarding-header refusal: it guards only the per-client quota and point cache, not a key or a bill, and the map needs it to render behind a reverse proxy.
- **Cross-site gate on cost/log endpoints.** The cost-bearing (`/api/realtime/token`, `/api/openai/hud-summary`, `/api/google/nearby-places`, `/api/google/text-search`), quota-bearing (`/api/mapillary/*`, with a per-IP limit on tiles, and `/api/tomtom/*`, backed by its persistent daily tile budget) and log (`/api/realtime/debug-log`) endpoints refuse cross-site browser requests: a foreign or opaque `Origin`, a `Sec-Fetch-Site` other than `same-origin`/`none` (this is what blocks an `<img>`/navigation that carries no `Origin`), or any reverse-proxy/CDN forwarding header returns `403`. Non-browser loopback tools (the repo QA harnesses POST the token endpoint with no `Origin`) and the explicit `HOST=0.0.0.0` LAN opt-in keep working — the gate deliberately does not require a loopback remote address; that stricter requirement belongs only to the credential panel (`admitKeySetupRequest`). The Host check (`allowedHosts`, applied by `hostCheckPlugin` ahead of every route, since Vite's own check runs only after plugin middleware) rejects a rebinding `Host` before any handler runs, in every mode, and the gate is defense in depth behind it: the LAN opt-in keeps that check and accepts only IP addresses, `localhost` and names listed in `GEV_ALLOWED_HOSTS`. **Limit:** a name you list there is trusted like the app's own address, so list only names you control.
- **Full Content-Security-Policy.** The dev and preview servers send a real CSP (`script-src 'self' 'unsafe-eval' blob:` plus the YouTube, Facebook and X scripts the Nepal event's embedded media loads and the Maps JavaScript API that Street View runs on, with a matching `frame-src` for their players and posts: no inline script and no other script origin; `blob:` covers the object URLs Cesium's bundled workers bootstrap through in the built app), alongside the existing `X-Frame-Options: DENY` and `frame-ancestors 'none'`. `'unsafe-eval'` stays because Knockout, bundled inside Cesium's widgets package, resolves the global object with `eval` at load time and the globe does not initialize without it; styles permit `'unsafe-inline'` and Google Fonts because Vite's dev client injects `<style>` elements.
Allowing `maps.googleapis.com` in `script-src` also trusts its JSONP endpoints, which can bypass the script allowlist if an HTML-injection bug exists; this is an accepted limitation of loading the Maps JavaScript API, alongside the existing `'unsafe-eval'` allowance.
-12
View File
@@ -1,12 +0,0 @@
- `aircraft_in_area` and `vessels_in_area` take `group_by` and `stats`, so an
MCP client can ask which airlines, countries, aircraft types or ship types
dominate an area, or the average altitude or speed, over every match instead
of estimating from the returned rows. Airlines come from the ICAO designator
in each callsign (SWA1234 counts as SWA), so the breakdown works on feeds
that report no operator; operator and type are counted only where the feed
reports them. Ship types count by family, so AIS codes 70 to 79 are all
cargo; type 0 ("not available") and the reserved codes 100 to 255 count as
unknown. Altitude and speed stats cover airborne aircraft only, so parked
aircraft do not pull the means down. Every vessel answer, `find_vessel`
included, now reads AIS ship-type codes as their family and a destination
with no letters as unknown (quanticsoul4772, #1056).
@@ -1,5 +0,0 @@
- Share Launch Library request cache keys across installations refreshing in the
same UTC minute, identify the app to the provider, and explain that standard
space-missions access needs no key. Optional higher-rate LL2 tokens are paid
upgrades; existing local cache and server-only token handling are unchanged
(rudycelekli, #1197).
@@ -1,3 +0,0 @@
- Keep Space Missions marked STALE when Launch Library 2 falls back to cached
launch data, and return to NOMINAL after a fresh response without discarding
retained missions (rudycelekli, #1199).
-5
View File
@@ -1,5 +0,0 @@
- Color CCTV cameras with a video feed violet. Cameras with an HLS, MP4 or
WebM feed were the same cyan as stills, so a DelDOT or Statens vegvesen
video camera could only be found by opening it. Stills stay cyan and the
active camera stays amber, and focus de-emphasis fades the violet the same
way (Danielslay86, #1200).
-4
View File
@@ -1,4 +0,0 @@
- Draw CCTV stills at their source aspect on the monitor plane instead of
stretching them to the 16:9 canvas. Most DOT cameras publish 4:3 frames, so
every still was widened; they are now centered and pillarboxed on the dark
the placeholder already ends on (Danielslay86, #1201).
@@ -1,5 +0,0 @@
- Street Level panel: SINCE is a dropdown of its nine windows (the tooltip
names the cut-off date), IMAGERY and FIT / FILL are plain toggle buttons,
and the panel opens by itself only when a photo opens or a click fails to
load one, no longer when the layer is switched on. FIT / FILL starts at
FIT each session (gekh, #1241).
-9
View File
@@ -1,9 +0,0 @@
- Add FL511 (Florida DOT) traffic cameras as an opt-in CCTV source pack:
the statewide ArcGIS catalog, read a page at a time under a byte cap with
redirects refused, and frames pinned to FL511's two image hosts over HTTPS.
FL511's terms limit its content to individual, personal use, and any other
use (a public deployment included) needs FDOT's express written consent, so
the pack stays off unless `CCTV_FLORIDA_ENABLED=1`. Headings come from the
catalog's direction codes, and camera ids come from the image channel, since
the catalog's ID column repeats across regions. `CCTV_FLORIDA_MAX_SOURCES`
sets the cap (Danielslay86, #605).
@@ -1,9 +0,0 @@
- Bound what a caller can grow through `/api/terrain/heights`. Every distinct
5-decimal point was a permanent in-memory entry, a permanent line in an
uncapped `.gev-cache/terrain-heights.json`, and a live upstream call, with
nothing limiting the rate. The point cache now holds at most 50,000 entries
and evicts oldest-first, a cache file above
24 MB is not read back, and the route is limited to 90 requests/minute per
client (270 global). Evicting a point costs one refetch, never a wrong
answer, since terrain does not move. Ordinary use is unaffected: a full
`test:track` run drives 10 requests, peaking at 8/min (daikaginza, #686).
@@ -1,7 +0,0 @@
- Close the compact Radio disclosure when an adaptive tactical right-rail
restore directly expands Context, before Space Missions can be covered.
A repeated Context-open request also clears stale disclosure state.
Real PanelChrome constructor wiring and the rail reflow path are covered
by focused regressions. Credits: kvnloo (#816), raysung-2026's original
#804 report, asturksever's review, and quanticsoul4772's independent
Windows diagnosis.
-4
View File
@@ -1,4 +0,0 @@
- Add optional Gemini Live voice to the standalone app, with a provider chooser,
server-held API key, microphone streaming, shared map actions and captions.
Switching providers ends the old conversation and preserves the input mode
(Manjunath, #872).
-6
View File
@@ -1,6 +0,0 @@
- Extract a reusable model-free World Packet from the existing voice
`get_current_view_state` owner without restoring disabled layers to the
model context. Keep the enabled-only payload, disabled-layer count, source
provenance, action result and view-state contract; read the layer roster once
per packet; voice retains the speech/card envelope at its own boundary.
Credits: original GEV voice/layer contributors and #880 implementation.
-6
View File
@@ -1,6 +0,0 @@
- Street Level is rebuilt around one small provider registry: Mapillary,
KartaView, Panoramax and Google Street View share one coverage drawer and
one photo viewer, one source lit at a time. Floating and docked panels,
FOLLOW, surface modes, the legend and the Mapillary proxy's disk cache are
gone; `.gev-cache/mapillary/` from earlier runs is no longer read and can be
deleted (gekh, #974).
@@ -1,8 +0,0 @@
- Cross-site pages can no longer fill the terrain-height cache or spend the
visitor's own `/api/terrain/heights` rate-limit quota through the visitor's
browser. The route refuses a cross-site `Sec-Fetch-Site` or a foreign or
opaque `Origin` before rate limiting, cache access or upstream work. Unlike
the cost-bearing routes it still answers same-origin requests that arrive
through a reverse proxy, so terrain keeps rendering behind nginx, Caddy or
Cloudflare instead of falling back to the geoid (Sunil56224972, daikaginza,
#975).
+2 -2
View File
@@ -356,8 +356,8 @@ drag or final change. Feature adapters own data mapping and service subscription
generic modules own DOM/listeners/timers and release them in `destroy()`.
Keep persistence with panel chrome: collapse uses
`godsEyeView.v6.panelCollapsed.<panelId>`, positions are
not stored (`godsEyeView.v8.panelPos.<panelId>` stays reserved), and share state uses the registered panel id.
`godsEyeView.v6.panelCollapsed.<panelId>`, position uses
`godsEyeView.v8.panelPos.<panelId>`, and share state uses the registered panel id.
Do not rename existing
ids or reset persistence versions when adding a body. The WEATHER adapter is
`src/ui/weatherPanel.js`; its observed-history selection remains transient.
+74 -125
View File
@@ -112,14 +112,7 @@ perimeters, terrain height, military installations and map features, plus a
combined situation brief, military awareness around a point, the app's heads-up display caption, and a link that
opens the app over an area. Tools reuse the layers' portable source factories, take a
shared `area` argument (place name, bounding box, or point and radius) and cap
lists at 25 rows by default. `aircraft_in_area` and `vessels_in_area` can also
count every match by a field (`group_by`: airline, read from the ICAO
designator in the callsign, or operator, country or type for aircraft; type or
destination for ships; the ten largest groups, then other and unknown counts;
the summary says when a feed reports none of the field) and summarize airborne
altitude and speed (`stats`: count, min, median, mean, max).
Both cover all matches, not just the rows returned; voice leaves these two
tools out, so they reach MCP clients only. Voice offers the same queries next to its app
lists at 25 rows by default. Voice offers the same queries next to its app
actions: the session lists them, and the browser runs them through the same
catalog, loaded on first use. See [tools and the MCP server](TOOLS.md).
Aircraft altitude-band queries require a reported barometric or geometric altitude
@@ -496,11 +489,6 @@ to layer toggles, fly_to_location, frame_overhead, select_nearest_aircraft,
get_entity_context, annotate_map and analyst_query. Analyst speech and the card
share one deterministic count/scope headline; lower bounds use "At least" and
partial answers name the unanswered layers. `get_current_view_state` lists enabled layers only.
The model-free `gods-eye-view/agent/world-packet` export exposes
`getWorldPacket({ viewer, styleManager, dataManager, sceneDirector })` to
non-voice consumers. It reads the same enabled-only state and feed
provenance before voice adds speech/card fields, with no new network requests
or mutations.
The voice card nested in the mic control (`voiceCard.js`) shows captions, plan
steps and the result. `resultDisplay.js` is the one adapter from a result to
the card: builder results show as built, and analyst results show a title
@@ -618,9 +606,7 @@ Reference feed construction is exported through `sources/reference`; the cable s
Source factories have dedicated `layers/<family>/source` exports. ALPR and earthquake record normalization and CCTV source policy no longer pull rendering into source consumers. Catalog construction and voice feed reads use their focused owners; compatibility entries remain available. Settings filesystem hardening lives under `server/standalone/`. Import-direction checks complement export ownership checks; source behavior, settings policy and rendering are unchanged.
Voice controls bind to a protocol-independent session factory. The default WebRTC adapter preserves the existing Realtime connection, push-to-talk, cost controls and radio handoff. Session subscriptions expose state, transcript, action call/progress/result, interruption, completion and turn-metrics events; stopping or replacing a session cancels pending actions. An active session's normal Stop publishes one idle transition, including adapters that emit their own terminal state; a restart triggered by that transition keeps ownership of its new startup state. Alternate adapters can use the same controls and action runner.
The standalone app offers OpenAI and Gemini Live through an explicit provider chooser. Gemini keeps its permanent API key on the local server and mints a short-lived, single-use credential with server-owned instructions and the standalone action catalog. Its native WebSocket adapter supports open microphone and push-to-talk input, streaming audio, captions and the existing action runner. In push-to-talk, a tool call that arrives with no input, reply or tool result outstanding is answered with a failure and never runs, and neither does any other call in the reply it starts; open-microphone turns start on Gemini's server-side activity detection, which reports no boundary to check. Switching providers ends the old conversation and releases its microphone and connection; only the input mode carries over. Gemini uses bounded shutdown draining and explicit manual restart, without automatic reconnection or a promise of uninterrupted session duration. Hosts that do not opt into the provider facade retain their existing default or custom adapter.
Voice controls bind to a protocol-independent session factory. The default WebRTC adapter preserves the existing Realtime connection, push-to-talk, cost controls and radio handoff. Session subscriptions expose state, transcript, action call/progress/result, interruption, completion and turn-metrics events; stopping or replacing a session cancels pending actions. Alternate adapters can use the same controls and action runner.
Voice action argument schemas have one portable owner under `src/voice/actionSchemas.js`. The Realtime provider builds 30 tools using separate description-only metadata. Description customization cannot replace argument types, enum values or required fields.
@@ -651,7 +637,7 @@ The UI facade composes separate owners for navigation authority, share restorati
visual settings and panel chrome. Navigation owns request generations and follow
handoff; restoration owns its coordinator, deferred notices, gestures and timer;
visual settings own style/detection overrides and sensor cleanup; panel chrome
owns disclosure and Cockpit panel snapshots. Each takes explicit
owns disclosure, docking and Cockpit panel snapshots. Each takes explicit
operations and reads instead of the complete shell. Existing controls, share
formats and one-application-per-page behavior are preserved.
@@ -826,11 +812,7 @@ position stay off the map. Genuine zero coordinates remain valid. Undisclosed
stage coordinates, downrange distance and flight numbers remain unknown; the
mission panel omits the flight-number label when no number was reported.
Pending mission requests abort on disable/destruction and malformed launch
snapshots preserve the last accepted display. A Launch Library stale-cache fallback
retains its missions but reports STALE in Data Layers; a fresh snapshot clears that
provenance. Hiding retained missions does not relabel them as fresh, and teardown
clears the instance state. Injected getLaunches-only sources remain supported.
Catalog groups, propagation cadence,
snapshots preserve the last accepted display. Catalog groups, propagation cadence,
tracking intent, replay timing, controls and source attribution remain unchanged.
The fire layer now exposes `./layers/firms`: an instance factory with separate
@@ -977,9 +959,10 @@ start with current state, isolate listener failures and stop on disposal.
The standalone entry composes the UI with the application's existing layer,
terrain, navigation and rendering operations. The shell receives those instances
and assembles the controls. Panel layout scheduling, loading notices, recording presentation and DOM lookup have focused
and assembles the controls. Panel layout scheduling, position preferences and
active drags, loading notices, recording presentation and DOM lookup have focused
owners. Disposal revokes queued presentation and listeners before asynchronous
Context restoration, restores the
Context restoration, cancels an unfinished drag without saving it, restores the
recording HUD and releases status decoration without replacing accessible text.
The ordinary control snapshot includes the current 3D model toggle and mode.
@@ -1209,10 +1192,6 @@ Automatic collapse remains presentation only, prioritizes the latest explicit
panel, and honors keyboard focus on the right. Mobile layout still releases
desktop allocation styles. Stable Display allocation avoids unnecessary style
writes. This extraction does not change panel positions or layout defaults.
Context expansion through the panel owner or adaptive right-rail restoration
closes an open compact Radio disclosure before Context/Space Missions becomes
visible. The mini remains available while Context is collapsed. This fixes the
Radio-overlap path reported in #804 without changing z-index or layout policy.
The right rail skips panels hidden by other CSS (computed `display: none`), but
keeps the collapsed panels its own exclusive presentation hides, so automatic
collapse can still be undone.
@@ -1462,18 +1441,6 @@ an empty dock. Explicit zero and caller-supplied numeric fallbacks remain valid.
## Terrain height cache bound
The `/api/terrain/heights` route refuses cross-site browser requests before
rate limiting, cache access or upstream work: a `Sec-Fetch-Site` other than
`same-origin`/`none` (an `<img>` on another page), or a foreign or opaque
`Origin`, gets `403`. The per-client limiter keys on the socket address, which
for such a request is the visitor's own browser, so without the gate any page
could spend the visitor's 90/min quota and leave the app's own lookups on
`429`. Unlike the cost-bearing routes, the terrain gate does not refuse
reverse-proxy forwarding headers: terrain is keyless and needed for rendering,
and a `403` there would send every chunk to the geoid fallback for an instance
served behind the documented reverse proxy. Same-origin app requests,
non-browser local callers and proxied same-origin requests keep working.
The terrain proxy parser rejects finite longitudes outside [-180, 180] and
latitudes outside [-90, 90] for every client before upstream resolution. One
out-of-range point rejects the whole batch; inclusive WGS84 bounds remain valid.
@@ -1511,14 +1478,6 @@ The Node-only `gods-eye-view/server/providers/space` export supplies factories;
`sources/space` supplies fixed upstream URL builders with no I/O or environment
access. Callers retain validation, transport and response policy.
LL2 recent-launch requests use UTC minute-aligned bounds over the same 30-day
window, so installations refreshing in the same minute share an upstream cache
key. The upper bound trails the current time by less than one minute. Requests
identify the app and repository through User-Agent; the existing 15-minute local
cache, stale fallback and optional server-only Authorization remain unchanged.
Provider Settings identifies LL2 tokens as optional paid higher-rate upgrades:
standard public access needs no key, and its field can remain blank.
The shared TLE text parser skips incomplete named sets and resumes at the next
complete set. A missing orbital line does not discard the later satellites in
the catalog. A set whose two lines carry different catalog numbers, or a
@@ -2705,17 +2664,6 @@ down | auth-failed` (plus the unchanged `missing-key`/`unsupported`) with
> tunings. Ambient overlap includes each sprite's own rendered extent. The
> gated AIS, CCTV, and satellite passes retain an active-emphasis count so a
> settled dim contact always completes its release after tracking ends.
> - **Terrain-height bounds:** because every distinct 5-decimal point is its own
> cache key, its own line in `.gev-cache/terrain-heights.json`, and its own
> upstream call, four limits bound what a caller can grow. Coordinates outside
> WGS-84 range are rejected with `400` at parse (finite is not the same as on
> Earth); the point cache holds at most 50,000 entries, evicting oldest-first
> — terrain does not move, so an evicted point costs one refetch and never a
> wrong answer; a cache file above 24 MB is not read back into memory (the
> ceiling `rocketLaunchesProxy` already applies); and the route carries an
> always-on limiter of 90 requests/minute/client, 270 global. That cap is an
> order of magnitude above measured demand — a full `npm run test:track` run
> drove 10 requests, peaking at 8/min.
> - **Terrain-height resilience:** `/api/terrain/heights` caches canonical
> 5-decimal points individually, reconstructs reordered/overlapping batches
> in exact request order, and refreshes only missing or stale points. Upstream
@@ -3447,7 +3395,7 @@ its criteria cannot be silently ignored.
| Satellites | CelesTrak | `src/data/satellites.js` | `/api/celestrak` | 120s |
| Space Missions (30d) | Launch Library 2 + CelesTrak | `src/data/rocketLaunches.js` | `/api/launches` + `/api/celestrak/active` | 5 min |
| Traffic | Selectable TomTom / OpenStreetMap / Hybrid roads; optional TomTom flow (BYOK) | `src/data/traffic.js` | browser-direct OpenFreeMap tiles; `/api/tomtom` for flow | viewport-driven; capped tile cache |
| CCTV | Austin + Caltrans (CA) + TfL London + Ontario 511 + Fintraffic (FI) + DriveBC (BC) + TxDOT (TX) + Estonia (Tallinn, Tarktee) + Live Traffic NSW + Open Calgary Open Data + Statens vegvesen (NO, stills + live HLS) + King County (WA) Open Data + WSDOT (WA) Open Data + NYC DOT TMC (opt-in) + City of Toronto RESCU + Catalonia (Servei Català de Trànsit) + FL511 (FL, opt-in) + Street View fallback | `src/data/cctv.js` | `/api/cctv` | 10s (active) |
| CCTV | Austin + Caltrans (CA) + TfL London + Ontario 511 + Fintraffic (FI) + DriveBC (BC) + TxDOT (TX) + Estonia (Tallinn, Tarktee) + Live Traffic NSW + Open Calgary Open Data + Statens vegvesen (NO, stills + live HLS) + King County (WA) Open Data + WSDOT (WA) Open Data + NYC DOT TMC (opt-in) + City of Toronto RESCU + Catalonia (Servei Català de Trànsit) + Street View fallback | `src/data/cctv.js` | `/api/cctv` | 10s (active) |
| Radio | Radio Browser (public-domain station directory) | `src/data/radio.js` | `/api/radio/stations`, `/api/radio/click/:uuid` | 45 min directory refresh |
| Transit 🚌 | Operator GTFS-Realtime VehiclePositions (7 keyless regions, `src/data/transitFeeds.js`) | `src/layers/transit/` via `src/app/layers/transit.js` | `/api/transit` | 15s (poll + delayed playback) |
| Bikeshare 🚲 | GBFS (Lyft + BCycle + Social Bicycles + PBSC incl. Bike Share Toronto, Bike Itaú (Brazil), Bicing (Barcelona) and seven other Spanish cities + Urban Sharing, Entur, JCDecaux (Lillestrøm, Valencia, Seville) + nextbike (AMBici)) | `src/data/bikeshare.js` | `/api/gbfs` | 60s |
@@ -3456,7 +3404,7 @@ its criteria cannot be silently ignored.
| Dams ▰ | OpenInfraMap/OSM extract (bundled) | `src/data/localLayers.js` | — | static |
| Submarine Cables ◠ | TeleGeography public map (bundled) | `src/data/telegeographySubmarineCables.js` | — | static |
| FIRMS Active Fires ▲ | NASA FIRMS live (VIIRS ×3 NRT + MODIS NRT, trailing 24h) | `src/data/firmsHeatmap.js` | `/api/firms` (`FIRMS_MAP_KEY`) | 10 min (proxy TTL 30 min) |
| Street Level 📷 | Mapillary street-level imagery (vector tiles, Graph API, MapillaryJS) | `src/layers/streetLevel/` via `src/app/layers/streetLevel.js` | `/api/mapillary/status`, `/api/mapillary/tiles/coverage/{z}/{x}/{y}` (`MAPILLARY_CLIENT_TOKEN`) | camera-driven (320 ms debounce, ≤9 tiles, server memory cache 24 h) |
| Street Level 📷 | Street-level imagery; Mapillary is the first provider (vector tiles, Graph API, MapillaryJS) | `src/layers/streetLevel/` via `src/app/layers/streetLevel.js` | `/api/mapillary/status`, `/api/mapillary/tiles/coverage/{z}/{x}/{y}` (`MAPILLARY_CLIENT_TOKEN`) | camera-driven (320 ms debounce, ≤9 tiles, cached 24 h) |
| Wind 🌬 | NOAA GFS 10 m wind (keyless, 0.25°→1° grid; animated particles) | `src/data/wind.js` | `/api/wind` | 1 h (forecast cycle) |
| GNSS Integrity 📡 (`gnss-integrity`) | adsb.lol point snapshot, low navigation accuracy binned per 0.5° cell (GEV NIC/NACp threshold + gpsjam.org formula and bands, keyless, ODbL) | `src/layers/gnss/` via `src/app/layers/gnss.js` | `/api/gnss-integrity` | camera-driven (600 ms debounce, minimum 2.5 s between reads) + 60s poll (server cache 60 s; 30 min rolling window) |
| Fire Perimeters 🔥 | NIFC WFIGS current interagency perimeters (keyless, paged past the 2000-record cap); InciWeb catalog + incident page origin and update time checks for verified incident-page links | `src/layers/perimeters/` via `src/app/layers/perimeters.js` | `/api/fire-perimeters` + `/api/fire-perimeters/inciweb/*` | 5 min (server caches: catalog 1 h; publication 30 min) |
@@ -3488,8 +3436,8 @@ by `scripts/qa-emergency-squawk.mjs`. Both accept `--timeout-ms` for slow boot.
Fire Perimeters uses capped, timed server reads with stale-on-error caching and a per-client limit. Unchanged snapshots retain geometry; link checks cancel on disable or selection change, and the row legend shows reported containment.
Street Level is a docked, collapsible right-rail panel (`#street-level-panel`,
layer token `0`, option owner `street-level`, panel `ui` token `t`) that starts
Street Level is a collapsible right-rail panel (`#street-level-panel`, layer
token `0`, option owner `street-level`, panel `ui` token `t`) that starts
collapsed. It opens itself only when the user, voice or a tool switches the
layer on or a photo opens, never on a restore, and those opens are not stored.
Like the iD editor's photo overlay, it has one chip per registered provider,
@@ -3501,30 +3449,31 @@ sequence is GEV cyan. Share options: `m` (Mapillary), `r` (the other providers'
e.g. `google-1` for Street View), `p` (`a`/`p`/`f` panoramas), `s` (since, days). Mapillary, Google
Street View, KartaView and Panoramax are registered. Without
`MAPILLARY_CLIENT_TOKEN` the panel reads KEY REQUIRED and the controls are
disabled. With it, coverage draws as z11–14 sequence lines below 60 km; the proxy strips the unused `image` layer from z14
disabled. With it, coverage draws as z0–5 overview points from orbit and z11–14
sequence lines below 60 km; the proxy strips the unused `image` layer from z14
tiles (12 MB → ~80 KB) and shares one upstream fetch between concurrent
requests for a tile.
Coverage is z11–14 sequence lines below 60 km; above that nothing is drawn and
the panel says to zoom in. Tiles come from the visible ground within range of
the camera, nearest first, at most 9 (the halves of a view across
the date line count a shared tile once). Lines are draped
`GroundPolylinePrimitive`s (`ClassificationType.BOTH`), so they follow terrain
and Google 3D tiles alike; cones and the photo marker are ground-clamped
billboards with a horizon cull. Without `MAPILLARY_CLIENT_TOKEN` the panel
reads KEY REQUIRED, says which key to add and disables the filters; a rejected
key reads KEY REJECTED and stops tile requests until the layer goes off; a 429
pauses them for its Retry-After.
Mapillary coverage ranks unique tile addresses before applying the 16-tile
overview budget. Overlapping dateline halves in a polar view therefore do not
waste overview slots on the same tile; loading already skips duplicate keys.
The server proxy (`server/providers/mapillary.js`) adds the token to
coverage-tile requests (the browser holds it too, for MapillaryJS and the Graph
API), serves only z11–14 coverage, refuses upstream redirects, strips the
unused `image` layer, shares one fetch per tile, runs at most six at once
(cancelling those every client has left) and keeps tiles in memory for 24 h
(a body too big to be a trimmed tile is served but not kept). While Mapillary
rate-limits, the proxy answers 429 itself until the Retry-After (seconds or an
HTTP date, at most 10 minutes) is over, so the rest of a view does not go on
asking.
On Google 3D (`photoreal`) at street zoom (in below 1,400 m above ground, out
above 1,800 m) the surface mode switches from `draped` to `terrain`: draped
lines land on roofs and tree tops, so `groundCast.js` instead places lines,
cones and the marker 2 m above the bare earth from `/api/terrain/heights`, and
the mesh hides what is behind buildings and trees. A tile is drawn draped until
its heights arrive; a geoid fallback leaves it draped. Within 900 m of the
camera, `meshSampler.js` samples the rendered surface (`scene.sampleHeight`,
~11 m cells, nearest first, 6 ms idle budget) so lines also follow trenches,
steep streets and roads under trees. It uses the mesh floor's rules (the
visible tileset has finished streaming, a real bare-earth prior, the shared
mesh window) and probes a cell again once the camera is half as far from it,
down to 40 m, so coarse early samples are replaced as finer tiles load. A
failed probe keeps the last good sample and distance and is retried after the
miss cooldown. Roads on elevated decks are still drawn at
ground level. Framing a photo ignores mesh samples far below the bare earth
(unloaded tiles).
The header pill is the layer switch. Lighting a chip swaps the source;
clicking the lit chip turns the layer off. A chip without a usable key
@@ -3535,25 +3484,19 @@ their keys but a switched-off one would draw with a key already set (no
Mapillary token, Street View on Google 3D with a Google key), the layer
reports it (`keyAlternatives`), and the panel's KEY REQUIRED, its coverage
line and the layer row's tooltip say to light it. The
viewer sits under the header with EXPAND, FIT/FILL and close above the
image; FIT/FILL starts at FIT each session. IMAGERY and FIT/FILL are rows of
toggle buttons (`aria-pressed`), each its own tab stop. SINCE is a select of
the nine windows; a link's day count between them gets its own option, and
the tooltip names the cut-off date. The panel opens by itself only when a
photo opens, or a click fails to open one or to load its sequence (the
result, or its error, would otherwise be out of sight), never when the layer
is switched on.
MapillaryJS loads on the first photo. Opening one frames it with a single
camera flight through the application's deferred navigation (`begin` /
`reassert`): it releases tracking, is refused in the cockpit, and is skipped
if newer navigation took the camera while the photo loaded; closing the photo
cancels it. EXPAND is the browser's Fullscreen API on the viewer element; on
iPhone, which has no element fullscreen, the viewer opens as a top-layer
popover instead (hidden where neither API exists). Clean View, recording and
the cockpit take an expanded photo out of full screen: the shell calls the
panel's `shrinkViewer()` as it enters them. At phone width the
viewer height comes from the rail band so the whole photo fits.
viewer sits under the header with EXPAND, FIT/FILL, FOLLOW and close above the
image. FOLLOW needs the Google 3D map stack (`attachMapStackController`) and
stops when the stack changes. Camera moves go through the application's
navigation (`attachNavigation`), which releases aircraft and satellite tracking
and is refused in the cockpit: turning FOLLOW on claims the camera at once, and
FOLLOW stops when another feature takes it; a photo takes a deferred ticket
when it starts opening and frames only if nothing newer took the camera while
it loaded. SINCE is a stepped slider whose readout names the
cut-off date. The panel is portable (`street-level-panel` spec, minimum
320 × 280, `dockOnCollapse`): a floating window gives spare height to the
viewer, and collapsing it, double-clicking the header or SHRINK after EXPAND
docks it. At phone width (≤720 px) the viewer height is derived from the rail
band so the whole photo fits.
Providers implement the contract in `src/layers/streetLevel/registry.js`. The
core routes clicks by pick prefix, swaps viewer adapters in the one host,
@@ -3562,10 +3505,9 @@ add a provider: implement the definition, register it in
`src/app/layers/streetLevel.js` and add its modules to
`scripts/package-boundaries.json`; its share switch travels in `r` by id.
A provider with line coverage builds it on `lineCoverage.js` (camera-driven
XYZ tiles near the ground, draped, the selected sequence drawn again over
them) and its cones
XYZ tiles, draped or cast to bare earth, selection recolouring) and its cones
on `sequenceCones.js`, passing its tile fetch, zoom ladder, colours and pick
prefixes; Mapillary, KartaView and Panoramax all do. With providers on different keys the
prefixes; Mapillary's `coverage.js` and `sequences.js` are such wrappers. With providers on different keys the
layer's `requiresKeyId` is null and each chip reports its own key.
Google Street View (`providers/google/`) runs on the Maps JavaScript API with
@@ -3574,7 +3516,7 @@ on first use (`mapsLoader.js`, CSP `script-src https://maps.googleapis.com`).
Its chip starts off (`defaultOn: false`; a link without its switch keeps it off) because every
panorama load is billed to the key. Google's terms forbid Street View beside
non-Google maps, so the provider declares `requiresMapStack: 'photoreal'`:
off Google 3D it does not appear at all (no chip, globe
off Google 3D it does not appear at all (no chip, legend swatch, globe
credit, layer source or button, and Google's script is not loaded), lookups
and opens skip it, and leaving Google 3D closes its panorama; the share
option keeps the user's choice for when Google 3D returns. Google offers no coverage layer
@@ -3596,8 +3538,9 @@ marker is a Google-blue view cone (`marker: {kind: 'view-cone'}`) in place
of GEV's amber pin. Its
panoramas are all 360° and it draws no coverage, so it declares `filters: []`
(a provider's `filters` default to both `pano` and `since`): lookups ignore
the IMAGERY and SINCE filters, and the panel hides each filter row no lit provider
honours (`filterControls`). The viewer is Google's `StreetViewPanorama` in its own
the IMAGERY and SINCE filters, the panel hides each filter row no lit provider
honours (`filterControls`), and the legend lists only providers that draw
lines, with "Selected" only beside one of them. The viewer is Google's `StreetViewPanorama` in its own
element in the viewer host, without Google's fullscreen, close and address
controls (the address goes in the caption); a pose goes out at once and
again when the panorama's month, photographer and address arrive (one
@@ -3624,7 +3567,8 @@ are no KartaView photos in it. A tile's lines are OSM way segments with
photos and carry no sequence, date or projection, so `decode.js` chains
segments that meet end to start (about four times fewer lines; linear time,
at most 20,000 segments a tile and 5,000 points a line), they are
drawn as `kv:line:<z/x/y/n>`, up to 1,600 per tile, and never highlighted, and the
drawn as `kv:line:<z/x/y/n>`, up to 1,600 per tile, and never highlighted
(`highlightsSequence: false`, so the legend has no "Selected"), and the
provider declares `filters: ['pano', 'since']` and `coverageLabel` ("N
streets in view · click a line for its nearest photo"). KartaView filters
coverage only in its PNG tiles (`/sequence/tiles/{x}/{y}/{z}.png?fieldOfView=360|plane&startDate=YYYY-MM-DD`;
@@ -3633,7 +3577,8 @@ asked for), so a filtered tile is drawn from the cached JSON segments that
lie on the filtered PNG's drawn pixels (`maskedSegments`: at least half the
points sampled every 2 px have a drawn pixel within one; on Jakarta's 360°
filter, 332 of 1,041 segments lie on it, 676 miss it, 33 fall between).
A filter change reloads the lines (`lineCoverage.reload`), keeps the JSON segments, and caches masks per
A filter change reloads the lines (`lineCoverage.reload`: the drawn ones show
until the new ones are in), keeps the JSON segments, and caches masks per
filter and tile. A dense filtered PNG can take KartaView over a minute the
first time (it answers 504 at 60 s and keeps drawing), so a timeout or 5xx is
asked again 4 s later, four tries in all; then the panel says it did not
@@ -3645,7 +3590,7 @@ lookup asks the photo API for the projection (`SPHERE`/`PLANE`; it ignores
photos are near but none passes, the panel says so), and the cones pass on
each photo's projection and upload date. The provider declares
`sinceBasis: 'upload'`, so while it is lit the panel shows "KartaView: by
upload date" under the SINCE select (`sinceNote` in the UI state). Tiles come uncompressed and
upload date" under the SINCE slider (`sinceNote` in the UI state). Tiles come uncompressed and
uncacheable, so the last 24 decoded tiles are kept for 10 minutes while
KartaView is lit (`createTileCache`, dropped when it is switched off); one
that does not answer in 20 s is asked once more, and one that is not a line
@@ -3660,7 +3605,7 @@ remembered; a storage timeout or 5xx is asked again. The open photo's
sequence draws as cones from the 250-photo page holding it
(`/2.0/sequence/{id}/photos`); the panel counts the drive's own photos
(`countActivePhotos`, asked once per sequence). The camera direction (the
marker, cones and a panorama's centre) is the direction of travel
marker, cones, FOLLOW, and a panorama's centre) is the direction of travel
to the next photo a few metres on (`assignCourses`): KartaView is dashcam
imagery, while its `heading` is the device compass, which reads 0 for whole
drives without one and can sit a steady 110° off inside a car. Until the
@@ -3717,18 +3662,23 @@ to 30 km and z10 to 60 km, as Mapillary's (a dense z10 tile over Paris is
line (Douglas–Peucker to the segment, 4 units of a 4,096 tile: about a
pixel where it is drawn, 6–10% of a GPS track's points), and the tiles in
view draw their longest lines within 90,000 points (`maxPoints`,
`keepOrder`), split evenly between the tiles in view (10,000 points
each). Every point becomes ~8 ground-line vertices:
`keepOrder`), shared out once loading settles so a sparse tile keeps all
it has and a dense one (a city centre) takes the rest; the selected
sequence is always drawn. Every point becomes ~8 ground-line vertices:
unsimplified, the lines hung the tab. Decoded tiles are kept while the
provider is on (16, ten minutes, `tileCache.js`, shared with KartaView).
Above 60 km nothing is drawn and the panel says to zoom in.
provider is on (16, ten minutes, `tileCache.js`, shared with KartaView),
and above
60 km the `grid` layer's 0.1°
density cells, binned to at most 96×96 points a tile (a z0 tile holds
~70,000 cells) and not counted as sequences: the panel says to zoom in.
A tile decodes in slices of about 8 ms, the page getting a turn between
them (a 2 MB z10 tile is ~200 ms of work). A busy API (429/503) holds the
tiles back for its Retry-After, which the browser cannot read here (the API
exposes no headers), so the wait doubles from 5 s to 5 minutes until it
answers; an answer may take as long as it keeps arriving (silent for 15 s
is given up), so a slow link still gets its megabyte tiles. Both
filters apply: lines carry their day and camera type. A click on a
filters apply: lines carry their day and camera type, and a cell stays while
it holds pictures of the kind asked for (cells are undated). A click on a
line asks the core (`actions.openNearest`) for that sequence's picture
nearest the click, within the camera height / 100 (50–400 m: from high up
a click lands well off a line), without framing, then any sequence's within
@@ -3777,9 +3727,8 @@ runs against real Mapillary and needs `MAPILLARY_CLIENT_TOKEN`. `npm run
qa:street-level:fixtures -- --url <server>` answers every Mapillary request
from fixtures (`providers/mapillary/coverageFixture.mjs`,
`scripts/fixtures/street-level/`), and KartaView's and Panoramax's too, so it
needs no network. It opens and closes a photo from Mapillary, KartaView and
Panoramax; stepping, EXPAND, FIT/FILL, the filters and Street View stay with
the unit tests. CI runs the fixture gate against a production build served by `vite preview` with a dummy token.
needs no network and covers the whole photo flow. CI runs the fixture gate and `qa:panel-resize`
against a production build served by `vite preview` with a dummy token.
Directions is a keyless front end to the routing the voice agent already
uses. Its row chips are the whole interface: DRIVE / WALK / BIKE pick the
@@ -4417,7 +4366,7 @@ silently demoting every later lookup for the session.
- **FIRMS**: no ground clamping (zero 3D-tiles height sampling), ≤18 screen-decluttered ambient labels, click-to-inspect detail card, 2.5k/3k sprite budgets viewport-clipped by FRP.
- **CCTV v2 foundation:** a pitched
frustum wireframe (4 corner rays + far-cap rectangle) with a monitor plane at the frustum's
far cap, retargeting the existing video/canvas texture pipeline. Stills are drawn at their source aspect, centered on the placeholder dark (`#01070c`), instead of being stretched to the 16:9 canvas. Manual calibration only —
far cap, retargeting the existing video/canvas texture pipeline. Manual calibration only —
auto-calibration and the drape mesh pipeline are deleted. A one-shot activation obstruction
probe (`pickFromRay` on camera activation, clamping the plane short of the first hit) remains;
ground placement is superseded by the shared-floor v3 behavior below. Calibration persists to
@@ -4435,7 +4384,7 @@ silently demoting every later lookup for the session.
ground clamp now lifts the cap _center_ only so the wireframe stays a true pyramid welded to
the plane (was a flattened fan / the ~47.5 m divergence — RESOLVED), re-selecting the active
camera is a no-op (killed a click-flash), and texture swaps gate on canvas content (killed a
periodic white flash). Camera icons are cyan for stills, violet for cameras with a video feed, and amber for the active camera. Coverage is now **metro-wide: 250 cameras** (`CCTV_AUSTIN_MAX_SOURCES`
periodic white flash). Coverage is now **metro-wide: 250 cameras** (`CCTV_AUSTIN_MAX_SOURCES`
default 36 → 250, hard bound 300), filtered to `camera_status === TURNED_ON` (~815 live of
1,003 rows). City packs (2026-07-04): Caltrans (districts 4/7/11/3 — SF, LA, San Diego,
Sacramento; cap 300) and TfL London JamCams (cap 250) join Austin (cap 250) as keyless default
@@ -5072,7 +5021,7 @@ pins and share links already supply complete boxes.
Pixel-mode pinch deltas receive bounded amplification before reaching the
canvas; ordinary wheel, line/page-mode and native touch-pinch paths are left
unchanged. The application scene owns and removes the relay listener.
- Z ladder: panels around 100, voice pill 150, toast 200, clean-view exit 300.
- Z ladder: panels promote within 100–139 (renormalized on wrap), voice pill 150, toast 200, clean-view exit 300.
- Panel POSITION keys are versioned `v8` (`godsEyeView.v8.panelPos.<id>`); collapsed-state keys remain `v6`. The one-time position reset clears stale DISPLAY placements that could overlap the Context rail.
- Map Source lives in the bottom Visual Presets tray. The left accordion contains no MAP STACK panel, and the `k` panel token that addressed it is gone from the share registry, so legacy `ui=k...` state takes the ordinary unknown-token skip.
- A dock popover (Visual Presets, Location) auto-dismisses on mouse-away unless pinned. Focus inside the tray defers that dismissal only when the browser reports `:focus-visible` — keyboard focus and typed-into fields hold the tray open; a mouse-clicked tile does not, because Chromium focuses a `<button>` on press.
@@ -5283,8 +5232,8 @@ easier to meet (detection is now on more often), but does not create it.
samples and constant elevation during E/N drag; one shared-floor resolution on
release; late one-shot shared-cell work is permitted during viewshed idle. The
A+B harness intentionally excludes citywide LOD assertions.
- Panel positions have a versioned storage name, `godsEyeView.v8.panelPos.<panel-id>`, but the current rails lay panels out adaptively and write none. Collapsed state does persist for every panel at `godsEyeView.v6.panelCollapsed.<panel-id>` (`'0'` open, `'1'` closed, absent means the panel's own default).
- Legacy draggable-panel position keys may remain in local storage for backward compatibility, but the map-mode right rail ignores them; collapsed states still persist at `godsEyeView.v6.panelCollapsed.<panel-id>`.
- Panel positions have a versioned storage name, `godsEyeView.v8.panelPos.<panel-id>`. Docked panels store nothing; a portable panel (CCTV, Street Level) stores `{ left, top, width, height, floating: true }` once a header drag lifts it out of the rail, and a header double-click removes it. Double presses are detected in the header's `pointerdown` handler, because its `preventDefault()` suppresses native `dblclick`. Collapsed state persists for every panel at `godsEyeView.v6.panelCollapsed.<panel-id>` (`'0'` open, `'1'` closed, absent means the panel's own default).
- Legacy draggable-panel position keys may remain in local storage for backward compatibility, but the map-mode right rail ignores them unless they describe a portable panel's floating window; collapsed states still persist at `godsEyeView.v6.panelCollapsed.<panel-id>`.
- Flight/military tracked entities cache dead-reckoned positions per frame to avoid callback desync flicker.
- Aircraft 3D-model and tracking invariants are covered by `npm run test:track`; run this before touching `flights.js`, `militaryFlights.js`, `detection.js`, or `trackedReadout.js`.
- Annotation resolver behavior is pinned by `src/annotations/annotationResolver.test.mjs`; re-run that suite before changing place-resolution scoring.
+3 -2
View File
@@ -57,8 +57,9 @@ Related keys (current versions):
- Panel collapsed state: `godsEyeView.v6.panelCollapsed.<panel-id>` — `'0'` open,
`'1'` closed, absent means the panel's own default. A view opened from a share
link ignores the stored value entirely.
- Panel positions: `godsEyeView.v8.panelPos.<panel-id>` — the current layout
writes none.
- Panel positions: `godsEyeView.v8.panelPos.<panel-id>` — written only for a
floating portable panel (CCTV, Street Level). Deleting it, or double-clicking
the panel header, docks the panel again.
- CCTV calibration: `godsEyeView.cctv.calibration.v2`
---
+2 -2
View File
@@ -263,11 +263,11 @@ and the development server's internal routes. See SECURITY.md.
| `get_earthquakes` | `earthquakes` | USGS M2.5+ events in the last 24 hours, strongest first |
| `get_active_fires` | `fires` | NASA FIRMS detections in an area, highest radiative power first |
| `get_recent_launches` | `launches` | Launch Library 2 launches in the last 30 days, newest first |
| `aircraft_in_area` | `aircraft` | Aircraft in an area, nearest first; `military: true` reads the `military` feed; `group_by` and `stats` count and summarize every match |
| `aircraft_in_area` | `aircraft` | Aircraft in an area, nearest first; `military: true` reads the `military` feed |
| `find_aircraft` | `aircraft` | Aircraft anywhere by callsign, ICAO address or registration |
| `get_aircraft_track` | `aircraft` | Recent positions of one aircraft, thinned to 200 points |
| `get_aircraft_info` | `aircraft` | Aircraft type and registration, and flight route, from adsbdb |
| `vessels_in_area` | `vessels` | Ships reported by AIS in an area, nearest first, optionally by type; `group_by` and `stats` count and summarize every match |
| `vessels_in_area` | `vessels` | Ships reported by AIS in an area, nearest first, optionally by type |
| `find_vessel` | `vessels` | Ships anywhere by MMSI, IMO number or name |
| `get_vessel_track` | `vessels` | Recent positions of one ship, thinned to 200 points |
| `next_satellite_pass` | `satellites` | Next pass over a place or point (default the ISS), with naked-eye visibility |
+22
View File
@@ -0,0 +1,22 @@
# Fetched snapshots and renders are regenerated by tools/; imagery and camera
# stills carry third-party terms and are not redistributed with the repo.
node_modules/
/out/
data/*.json
data/*.txt
!data/SOURCES.md
/imagery/
/cctv/
audio/*.wav
tools/imagery/cache/
__pycache__/
# fetch caches
tools/*/cache/
# city snapshots (Esri imagery, live feeds) and review/model artefacts
/city/
tools/flurry/review/
tools/flurry/*.jpg
tools/live/models/
tools/live/*.jpg
tools/*/check*.png
+58
View File
@@ -0,0 +1,58 @@
# God's Eye View — First Light
The intro sequence: God's Eye View coming online for the first time. A seed of
light on Null Island grows into a culture that turns out to be drawing the
planet. It then indexes the surface tile by tile, ingests every public feed,
and outgrows the dish it was grown in.
Every number on screen comes from a real snapshot: 5,550 aircraft from OpenSky,
15,928 satellites from CelesTrak, 246,411 fire detections from FIRMS, 10,120
quakes from USGS, GFS wind, 1,948 subsea cable segments, AIS from Kystverket and
Digitraffic, and 117 real public camera frames. Those cameras come from a
catalog of 16,607 across 18 keyless agencies. The imagery is Esri World Imagery
and NASA GIBS, fetched keyless. The score is synthesised from scratch and
frame-locked to the same clock as the picture.
## Acts
| Time | Act | What happens |
|---|---|---|
| 0–5 s | Void | Boot console types, then implodes into a point |
| 5–16 s | Seed / culture | Heartbeat, petri-dish ring. Mycelium grows out from 0°N 0°E; real night lights and coastlines light up as the front passes; the culture learns city names |
| 16–30 s | Indexing | Quadtree tiles z3→z5 index in with rising resolution; the sun model is applied; 24 synthetic Esri captures ("enhance" z13→z18); full-frame Sobel feature extraction over Midtown |
| 30–52 s | Ingestion | Aircraft, vessels, military, satellites, quakes, fires, wind and cloud, subsea cables, public cameras. Each feed has its own visual and sonic signature. Ends on the camera wall |
| 52–60 s | Saturation | Every layer at once, data arcs, the digital-twin lattice, and the dish ring's return; containment falls, the ring shatters, OMNIPRESENT |
| 61–72 s | Resolve | The planet becomes the iris of the logo. GOD'S EYE VIEW · NO PLACE LEFT BEHIND · ONLINE |
## Rebuild
```sh
npm install # three, playwright-core, satellite.js, fonts
python3 -m http.server 8765 --bind 127.0.0.1 &
# data + imagery snapshots (see data/SOURCES.md)
bash tools/fetch/fetch_basic.sh # plus the other scripts in tools/fetch, tools/imagery
node tools/export-cues.mjs # film clock -> audio/cues.json
tools/render-all.sh 1920 1080 16x9 # deterministic frames -> out/video_16x9.mp4
tools/render-all.sh 1080 1920 9x16
tools/finalize.sh # score, -14 LUFS, mux -> out/final/
```
Single stills for review:
`node tools/render.mjs --w 1920 --h 1080 --frames 12s,30s,58.4s --out out/stills`
## Real-time / loading screen
`index.html?play` runs the same frames live in a GPU browser, driven by the audio
clock (the first click starts the sound). `?w=1080&h=1920&play` gives the
vertical cut.
## Layout
- `src/timeline.js`: the clock. Every visual and audio event keys off it.
- `src/main.js`: asset loading, camera choreography, per-frame state.
- `src/globe.js`: planet shader (substrate → growth → quadtree indexing → lit Earth), clouds, atmosphere.
- `src/lines.js`: coastline and mycelium growth fronts, cables, arcs, twin lattice.
- `src/layers.js`: aircraft, vessels, satellites, quakes, fires, wind particles, stars.
- `src/hud.js`: all typography and 2D: boot console, dish, captures, feed stack, camera wall, title.
- `src/post.js`: HDR composite, dual-filter bloom, lens, grain, glitch.
- `audio/score.py`: procedural score and sound design.
File diff suppressed because it is too large. Load diff
+679
View File
@@ -0,0 +1,679 @@
#!/usr/bin/env python3
"""God's Eye View — First Light: procedural score and sound design.
Everything here is synthesised from scratch (no samples) and placed against
audio/cues.json, which tools/export-cues.mjs exports from src/timeline.js, so
every hit lands on its frame.
python3 audio/score.py -> audio/score.wav (48 kHz, 24-bit stereo)
"""
import json
import os
import numpy as np
from scipy import signal
from scipy.io import wavfile
SR = 48000
HERE = os.path.dirname(os.path.abspath(__file__))
C = json.load(open(os.path.join(HERE, 'cues.json')))
T = C['T']
DUR = C['DURATION']
N = int(SR * DUR)
rng = np.random.default_rng(20261018)
# ---------------------------------------------------------------- buses
def bus():
return np.zeros((2, N), dtype=np.float64)
B = {k: bus() for k in ('drone', 'music', 'perc', 'hits', 'sfx', 'ui', 'verb')}
def place(dst, sig, t, gain=1.0, pan=0.0, verb=0.0):
"""Mix mono/stereo `sig` into bus `dst` at time t (s), equal-power pan."""
if sig.ndim == 1:
p = (pan + 1) * np.pi / 4
sig = np.vstack([sig * np.cos(p), sig * np.sin(p)])
i0 = int(round(t * SR))
if i0 >= N:
return
s0 = max(0, -i0)
i0 = max(0, i0)
n = min(sig.shape[1] - s0, N - i0)
if n <= 0:
return
seg = sig[:, s0:s0 + n] * gain
B[dst][:, i0:i0 + n] += seg
if verb > 0:
B['verb'][:, i0:i0 + n] += seg * verb
def tt(dur):
return np.arange(int(dur * SR)) / SR
def note(name):
names = {'C': -9, 'C#': -8, 'Db': -8, 'D': -7, 'D#': -6, 'Eb': -6, 'E': -5, 'F': -4, 'F#': -3, 'Gb': -3, 'G': -2, 'G#': -1, 'Ab': -1, 'A': 0, 'A#': 1, 'Bb': 1, 'B': 2}
n, o = name[:-1], int(name[-1])
return 440.0 * 2 ** ((names[n] + (o - 4) * 12) / 12)
def sos_lp(fc, order=2):
return signal.butter(order, min(fc, SR * 0.45), 'low', fs=SR, output='sos')
def sos_hp(fc, order=2):
return signal.butter(order, max(fc, 10), 'high', fs=SR, output='sos')
def sos_bp(lo, hi, order=2):
return signal.butter(order, [max(lo, 10), min(hi, SR * 0.45)], 'band', fs=SR, output='sos')
def filt(x, sos):
return signal.sosfilt(sos, x, axis=-1)
def sweep_filter(x, cut_fn, kind='low', block=256, order=2):
"""Time-varying Butterworth: coefficients per block, state carried across."""
y = np.zeros_like(x)
zi = None
for b in range(0, len(x), block):
tc = b / SR
fc = float(cut_fn(tc))
if kind == 'low':
sos = sos_lp(fc, order)
elif kind == 'high':
sos = sos_hp(fc, order)
else:
bw = fc * 0.35
sos = sos_bp(fc - bw, fc + bw, order)
if zi is None:
zi = signal.sosfilt_zi(sos) * 0
y[b:b + block], zi = signal.sosfilt(sos, x[b:b + block], zi=zi)
return y
def saw(f, dur, phase=0.0):
t = tt(dur)
if np.isscalar(f):
ph = f * t + phase
else:
ph = np.cumsum(f) / SR + phase
return 2 * (ph - np.floor(ph + 0.5))
def sine(f, dur, phase=0.0):
t = tt(dur)
if np.isscalar(f):
return np.sin(2 * np.pi * f * t + phase)
return np.sin(2 * np.pi * np.cumsum(f) / SR + phase)
def noise(dur):
return rng.standard_normal(int(dur * SR))
def env_exp(dur, decay, attack=0.002):
t = tt(dur)
a = np.minimum(1, t / max(attack, 1e-4))
return a * np.exp(-t * decay)
def env_ar(dur, attack, release, curve=2.0):
t = tt(dur)
up = np.clip(t / max(attack, 1e-4), 0, 1) ** curve
down = np.clip((dur - t) / max(release, 1e-4), 0, 1) ** curve
return np.minimum(up, down)
# ---------------------------------------------------------------- instruments
def click(freq=4000, dur=0.012, q=0.5):
x = noise(dur) * env_exp(dur, 900, 0.0003)
return filt(x, sos_bp(freq * (1 - q * 0.5), freq * (1 + q)))
def blip(f, dur=0.05, harm=0.25, decay=60):
t = tt(dur)
e = env_exp(dur, decay, 0.001)
return (np.sin(2 * np.pi * f * t) + harm * np.sin(4 * np.pi * f * t)) * e
def thump(f0=70, f1=34, dur=0.9, decay=5.0, punch=24):
t = tt(dur)
f = f1 + (f0 - f1) * np.exp(-t * punch)
body = sine(f, dur) * env_exp(dur, decay, 0.001)
tr = filt(noise(0.02), sos_lp(2500)) * env_exp(0.02, 200)
out = body
out[: len(tr)] += tr * 0.4
return out
def braam(dur=4.0, notes=('D1', 'A1', 'D2', 'A2'), bright=2600, gain=1.0, decay=1.6):
"""Detuned saw stack through a fast-opening, slow-closing lowpass + drive."""
n = int(dur * SR)
acc = np.zeros(n)
for nm in notes:
f = note(nm)
for d in (-0.012, -0.005, 0.0, 0.006, 0.013):
acc += saw(f * (1 + d), dur, rng.random())
acc /= len(notes) * 3.0
cut = lambda tc: 160 + (bright - 160) * min(1.0, tc / 0.07) * np.exp(-max(0.0, tc - 0.07) * decay)
y = sweep_filter(acc, cut, 'low', order=2)
y = np.tanh(y * 2.2) * env_ar(dur, 0.012, dur * 0.85, 1.2)
sub = sine(note(notes[0]) * np.ones(n) * (1 + 0.5 * np.exp(-tt(dur) * 18)), dur) * env_exp(dur, 1.3, 0.004)
imp = filt(noise(0.6), sos_lp(900)) * env_exp(0.6, 9)
out = y * 0.85 + sub * 0.9
out[: len(imp)] += imp * 0.5
return out * gain
def whoosh(dur=1.2, f0=400, f1=4000, peak=0.5):
x = noise(dur)
t0 = tt(dur)
cut = lambda tc: f0 * (f1 / f0) ** min(1.0, tc / dur)
y = sweep_filter(x, cut, 'band', order=2)
shape = np.exp(-((t0 / dur - peak) ** 2) / 0.05)
return y * shape
def riser(dur=3.0, f0=200, f1=6000):
x = noise(dur)
cut = lambda tc: f0 * (f1 / f0) ** ((tc / dur) ** 1.6)
y = sweep_filter(x, cut, 'band', order=2)
t0 = tt(dur)
tone = sine(f0 * (f1 / f0 / 8) ** ((t0 / dur) ** 1.6) * np.ones_like(t0), dur) * 0.2
return (y + tone) * (t0 / dur) ** 2.2
def shepard(dur, rate_oct_per_s, base=40.0, layers=9, accel=0.0):
t0 = tt(dur)
pos = rate_oct_per_s * t0 + 0.5 * accel * t0 ** 2
out = np.zeros_like(t0)
for k in range(layers):
o = (k + pos) % layers
f = base * 2 ** o
a = np.exp(-((o - layers / 2) ** 2) / (2 * (layers / 5) ** 2))
ph = np.cumsum(f) / SR
out += np.sin(2 * np.pi * ph) * a
return out / layers * 2
def fm_bell(f, dur=2.0, ratio=3.5, index=4.0, decay=2.5):
t = tt(dur)
e = env_exp(dur, decay, 0.002)
mod = np.sin(2 * np.pi * f * ratio * t) * index * np.exp(-t * 3)
return np.sin(2 * np.pi * f * t + mod) * e
def pluck(f, dur=0.22, cutoff=1800, decay=14):
x = saw(f, dur, rng.random()) + 0.5 * saw(f * 1.005, dur, rng.random())
e = env_exp(dur, decay, 0.002)
cut = lambda tc: 120 + cutoff * np.exp(-tc * 22)
return sweep_filter(x * e, cut, 'low', block=128, order=2) * 0.7
def pad(freqs, dur, cutoff=900, attack=1.2, release=1.5):
n = int(dur * SR)
acc = np.zeros(n)
for f in freqs:
for d in (-0.004, 0.0, 0.0045):
acc += saw(f * (1 + d), dur, rng.random())
acc /= len(freqs) * 3
y = filt(acc, sos_lp(cutoff, 2))
return y * env_ar(dur, attack, release, 1.5)
def static(dur, lo=1500, hi=9000):
return filt(noise(dur), sos_bp(lo, hi))
def stereo_noise_bed(dur, lo, hi):
return np.vstack([filt(noise(dur), sos_bp(lo, hi)), filt(noise(dur), sos_bp(lo, hi))])
# =================================================================== ACT 0
# room tone that the whole film sits on
bed = stereo_noise_bed(DUR, 30, 140) * 0.05
ramp_bed = np.clip(tt(DUR) / 3.0, 0, 1)
B['drone'] += bed * ramp_bed
# sub drone: D1 + A1, breathing; intensity rises with the film, dies at the cut
t_all = tt(DUR)
intensity = np.interp(t_all, [0, 5, 9, 16, 30, 52, 60.4, 60.5, 61.2, 66, 72], [0.0, 0.25, 0.45, 0.6, 0.7, 0.85, 1.0, 0.0, 0.0, 0.7, 0.0])
dr = (np.sin(2 * np.pi * note('D1') * t_all) * 0.6 + np.sin(2 * np.pi * note('A1') * 1.001 * t_all) * 0.25 + np.sin(2 * np.pi * note('D2') * 0.999 * t_all) * 0.18)
dr *= 0.85 + 0.15 * np.sin(2 * np.pi * 0.11 * t_all)
B['drone'] += np.vstack([dr, dr]) * intensity * 0.35
# electrical hum under the boot console
hum_e = np.interp(t_all, [0.4, 1.2, T['collapse'], T['seed']], [0, 1, 1, 0], left=0, right=0)
hum = (sine(100, DUR) * 0.3 + sine(150, DUR) * 0.12 + filt(noise(DUR), sos_bp(90, 400)) * 0.05) * hum_e
place('sfx', hum, 0, 0.18)
# keystrokes
for k in C['boot']:
if k['ch'] == ' ':
continue
f = 3500 + rng.random() * 2500
place('ui', click(f, 0.01), k['t'], 0.22 + rng.random() * 0.08, pan=(rng.random() - 0.5) * 0.3)
# "OK" confirmation
place('ui', blip(1760, 0.09) * 0.6 + blip(2640, 0.09) * 0.3, T['boot'] + 0.75 + 41 / 55, 0.25, verb=0.2)
# cursor appears
place('ui', blip(1200, 0.03), T['cursor'], 0.12)
# collapse: suck-in riser then silence, then the seed ignites
place('sfx', riser(T['seed'] - T['collapse'] + 0.05, 300, 9000)[::-1][::-1] * 1.0, T['collapse'], 0.35)
rev = whoosh(0.6, 6000, 300, 0.85)
place('sfx', rev, T['collapse'] - 0.15, 0.4)
place('hits', thump(90, 32, 1.4, 3.5), T['seed'], 0.85, verb=0.3)
place('ui', fm_bell(note('D6'), 2.5, 2.0, 1.5, 1.6), T['seed'], 0.16, verb=0.6)
# heartbeat
for bt, g in ((T['beat1'], 0.9), (T['beat2'], 1.0)):
place('hits', thump(62, 30, 0.7, 7.5, 30), bt, g * 1.4, verb=0.25)
place('hits', thump(52, 28, 0.6, 9, 30), bt + 0.19, g * 0.55, verb=0.2)
# dish ticks: 120 ticks swept around the ring, panned with the sweep
for i in range(120):
k = i / 120
# easeInOut timing of the sweep
u = np.interp(k, np.linspace(0, 1, 400), [((4 * x ** 3) if x < 0.5 else 1 - (-2 * x + 2) ** 3 / 2) for x in np.linspace(0, 1, 400)])
tk = T['dishDraw'] + u * (T['dishDone'] - T['dishDraw'])
loud = 0.12 if i % 10 == 0 else 0.05
place('ui', click(6500 if i % 10 else 3800, 0.008), tk, loud, pan=np.sin(k * 2 * np.pi) * 0.8)
# =================================================================== ACT 1 growth
gs, ge = T['growStart'], T['growEnd']
# granular crackle, density following exponential growth
for _ in range(2600):
x = rng.random() ** 0.45 # skewed late
tc = gs + x * (ge - gs + 0.3)
k = (np.exp(3 * (tc - gs) / (ge - gs)) - 1) / (np.exp(3) - 1)
if rng.random() > 0.15 + 0.85 * k:
continue
f = 1800 + rng.random() * 7000
place('sfx', click(f, 0.006 + rng.random() * 0.01, 0.8), tc, 0.05 + 0.1 * rng.random() * (0.4 + k), pan=rng.uniform(-0.9, 0.9))
# organic tone: saw cluster gliding up an octave, filter opening
dur = ge - gs + 0.6
tg = tt(dur)
glide = note('D2') * 2 ** (((tg / dur) ** 1.8))
cl = (saw(glide, dur) + saw(glide * 1.007, dur) + saw(glide * 1.5, dur) * 0.4)
cl = sweep_filter(cl, lambda tc: 150 + 2200 * (tc / dur) ** 2, 'low', order=2)
cl *= env_ar(dur, 2.5, 0.35, 1.4) * 0.18
place('music', cl, gs, 1.0, verb=0.25)
# city pings as the culture learns names
for i in range(30):
tc = 10.2 + (i / 30) ** 0.8 * 5.2 + rng.random() * 0.1
place('ui', blip(2000 + rng.choice([0, 300, 500, 700, 1000]), 0.06, 0.2, 45), tc, 0.08, pan=rng.uniform(-0.8, 0.8), verb=0.25)
# reverse swell into the substrate hit
place('sfx', riser(1.6, 120, 5000), T['substrate'] - 1.6, 0.3)
# =================================================================== hits
place('hits', braam(4.5, ('D1', 'A1', 'D2'), 1800, 1.0, 1.4), T['substrate'], 0.75, verb=0.35)
# =================================================================== ACT 2 indexing
BPM = 120.0
beat = 60 / BPM
s16 = beat / 4
grid0 = 17.0
pattern = ['D3', 'D3', 'F3', 'D3', 'A3', 'D3', 'C4', 'D3', 'D3', 'D3', 'F3', 'D3', 'G3', 'D3', 'A3', 'F3']
prog = [('D', ['D3', 'F3', 'A3']), ('Bb', ['Bb2', 'D3', 'F3']), ('G', ['G2', 'Bb2', 'D3']), ('A', ['A2', 'C#3', 'E3'])]
def ostinato(t0, t1, cutoff_fn, gain_fn, transpose=0, every=1):
i = 0
t = t0
while t < t1 - 1e-6:
if i % every == 0:
nm = pattern[i % 16]
bar = int((t - grid0) / (beat * 8)) % 4
f = note(nm) * 2 ** (transpose / 12)
# follow the chord root on bar changes
if prog[bar][0] == 'Bb' and nm == 'A3':
f = note('Bb3')
if prog[bar][0] == 'A' and nm == 'C4':
f = note('C#4')
acc = 1.0 if i % 4 == 0 else 0.7
place('music', pluck(f, 0.25, cutoff_fn(t)), t, gain_fn(t) * acc, pan=0.25 if i % 2 else -0.25, verb=0.12)
i += 1
t += s16
ostinato(17.0, 29.9, lambda t: 500 + 2200 * ((t - 17) / 13) ** 1.5, lambda t: 0.06 + 0.1 * (t - 17) / 13)
# pads under indexing
for k, t0 in enumerate(np.arange(17.0, 30.0, 4.0)):
nm, ch = prog[k % 4]
place('music', pad([note(x) for x in ch], 4.3, 700 + k * 120, 1.0, 1.2), t0, 0.12, verb=0.3)
# tile flips: a spray of pitched blips, hemisphere in view only
scale = [note(x) for x in ('D6', 'F6', 'G6', 'A6', 'C7', 'D7')]
for tl in C['tiles']:
if not tl['vis'] or tl['t'] > 26.5:
continue
z = tl['z']
if z == 5 and rng.random() > 0.35:
continue
f = scale[rng.integers(0, len(scale))] / (2 if z == 3 else 1) * (1 if z < 5 else 1.5)
g = {3: 0.07, 4: 0.04, 5: 0.025}[z]
place('ui', blip(f, 0.05, 0.1, 70), tl['t'], g, pan=tl['pan'] * 0.8, verb=0.1)
if z == 3:
place('ui', click(3000, 0.012), tl['t'], 0.06, pan=tl['pan'] * 0.8)
def shutter(t, g=1.0, pan=0.3):
place('sfx', click(3800, 0.008, 0.6), t, 0.5 * g, pan)
place('sfx', click(2200, 0.014, 0.6), t + 0.045, 0.4 * g, pan)
place('sfx', thump(160, 90, 0.05, 60, 80), t, 0.15 * g, pan)
place('ui', blip(2637, 0.05, 0.1, 60), t + 0.07, 0.12 * g, pan)
for cp in C['caps']:
if cp['slow']:
t0 = cp['t']
shutter(t0, 1.0, -0.35)
# the enhance: rising sweep with data chatter
d = 1.05
tz = tt(d)
f = 300 * (8 ** ((tz / d) ** 1.5))
sw = sine(f, d) * 0.5 + sine(f * 1.5, d) * 0.15
sw *= env_ar(d, 0.1, 0.15) * (0.7 + 0.3 * np.sin(2 * np.pi * 18 * tz))
place('sfx', sw, t0 + 0.28, 0.06, -0.3, verb=0.2)
for j in range(16):
place('ui', blip(1500 + rng.random() * 3000, 0.02, 0, 120), t0 + 0.3 + j * 0.06, 0.05, rng.uniform(-0.6, 0.2))
place('sfx', whoosh(0.5, 800, 5000, 0.5), t0 + 1.35, 0.18, 0.4)
place('ui', click(5000, 0.01), t0 + 1.75, 0.2, 0.6)
else:
shutter(cp['t'], 0.55, 0.6)
# full-frame feature extraction
cf = T['capFull']
place('sfx', whoosh(0.7, 300, 3000, 0.4), cf, 0.3)
place('hits', thump(80, 40, 0.8, 6), cf + 0.5, 0.35, verb=0.3)
scan_d = 1.6
ts = tt(scan_d)
scan = (sine(220, scan_d) + 0.5 * sine(440 * (1 + 0.002 * np.sin(2 * np.pi * 6 * ts)), scan_d) + 0.3 * sine(1320, scan_d)) * env_ar(scan_d, 0.08, 0.2)
scan *= 0.6 + 0.4 * np.sin(2 * np.pi * 12 * ts)
place('sfx', scan, cf + 0.75, 0.07, 0, verb=0.2)
for j in range(70):
place('ui', click(5000 + rng.random() * 3000, 0.006), cf + 0.75 + j * (1.6 / 70), 0.06, rng.uniform(-0.5, 0.5))
# collapse into the planet
place('sfx', riser(0.9, 300, 8000), T['indexed'] - 0.9, 0.4)
place('sfx', whoosh(0.5, 6000, 200, 0.8), T['indexed'] - 0.45, 0.35)
# SURFACE INDEXED
place('hits', braam(5.0, ('D1', 'A1', 'D2', 'F2'), 2600, 1.0, 1.1), T['indexed'], 0.95, verb=0.4)
# =================================================================== ACT 3 ingestion
ostinato(30.5, 52.4, lambda t: 900 + 2600 * min(1, (t - 30.5) / 18), lambda t: 0.09 + 0.06 * (t - 30.5) / 22)
# bass
for k, t0 in enumerate(np.arange(30.5, 52.4, beat * 2)):
bar = int((t0 - grid0) / (beat * 8)) % 4
root = {'D': 'D1', 'Bb': 'Bb0', 'G': 'G1', 'A': 'A1'}[prog[bar][0]]
b = saw(note(root), 0.9) + saw(note(root) * 1.004, 0.9)
b = filt(b, sos_lp(260)) * env_exp(0.9, 3.5, 0.005)
place('music', b, t0, 0.22)
for k, t0 in enumerate(np.arange(30.5, 52.4, 4.0)):
nm, ch = prog[k % 4]
place('music', pad([note(x) for x in ch] + [note(ch[0][:-1] + str(int(ch[0][-1]) + 1))], 4.3, 1100, 0.8, 1.2), t0, 0.12, verb=0.3)
# percussion: low taiko-ish hits + ticks
for i, t0 in enumerate(np.arange(30.5, 52.4, beat)):
if i % 4 in (0, 2) or (i % 8 == 7):
place('perc', thump(95, 45, 0.5, 9, 40), t0, 0.45 if i % 4 == 0 else 0.3, verb=0.12)
place('perc', click(9000, 0.02, 0.4), t0 + beat / 2, 0.06)
if i % 4 == 2:
sn = filt(noise(0.2), sos_bp(900, 4000)) * env_exp(0.2, 22) + sine(190, 0.2) * env_exp(0.2, 30) * 0.5
place('perc', sn, t0, 0.13, verb=0.25)
def feed_sig(key, t):
"""Each feed gets its own sonic signature as it comes online."""
place('ui', blip(1760, 0.06, 0.2, 40) + blip(2349, 0.06, 0.1, 40) * 0.6, t, 0.15, -0.5, verb=0.15)
place('ui', click(4500, 0.01), t, 0.25, -0.5)
if key == 'air':
for j in range(500):
x = rng.random() ** 0.7 * 1.9
place('ui', click(5000 + rng.random() * 4000, 0.004), t + x, 0.05 + rng.random() * 0.05, rng.uniform(-0.9, 0.9))
place('sfx', whoosh(1.4, 500, 7000, 0.35), t, 0.18, 0.3)
elif key == 'sea':
ping = sine(1150 * np.exp(-tt(2.2) * 0.05), 2.2) * env_exp(2.2, 2.2, 0.003)
place('sfx', ping, t + 0.05, 0.22, 0.2, verb=0.8)
place('sfx', ping, t + 1.05, 0.1, -0.3, verb=0.8)
place('sfx', filt(noise(2.0), sos_lp(400)) * env_ar(2.0, 0.5, 1.2), t, 0.2)
elif key == 'mil':
for j in range(3):
tone = (np.sign(np.sin(2 * np.pi * 740 * tt(0.12))) * 0.3 + sine(740, 0.12)) * env_ar(0.12, 0.005, 0.03)
place('ui', filt(tone, sos_lp(3000)), t + 0.1 + j * 0.22, 0.12, 0.3)
tone2 = (np.sign(np.sin(2 * np.pi * 554 * tt(0.12))) * 0.3 + sine(554, 0.12)) * env_ar(0.12, 0.005, 0.03)
place('ui', filt(tone2, sos_lp(3000)), t + 0.21 + j * 0.22, 0.1, 0.3)
elif key == 'orbit':
for j in range(26):
f = scale[rng.integers(0, len(scale))] * rng.choice([0.5, 1, 1])
place('music', fm_bell(f, 2.5, 3.5, 2.5, 2.2), t + rng.random() * 2.2, 0.035, rng.uniform(-0.9, 0.9), verb=0.6)
place('sfx', whoosh(2.8, 2500, 200, 0.4), t + 0.2, 0.3, 0)
place('hits', thump(60, 30, 1.6, 2.5), t + 0.1, 0.4, verb=0.3)
elif key == 'quake':
for j, dt in enumerate((0.05, 0.42, 0.95)):
place('hits', thump(55, 26, 1.4, 3.2, 18), t + dt, 0.6 - j * 0.12, rng.uniform(-0.5, 0.5), verb=0.25)
place('sfx', filt(noise(2.5), sos_lp(90)) * env_ar(2.5, 0.2, 1.8), t, 0.9)
elif key == 'fire':
d = 2.2
for j in range(900):
x = rng.random() ** 1.3 * d
g = (1 - x / d) * 0.12
place('sfx', click(800 + rng.random() * 5000, 0.004 + rng.random() * 0.01, 0.9), t + x, g * rng.random(), rng.uniform(-0.8, 0.8))
place('sfx', filt(noise(d), sos_bp(200, 1500)) * env_ar(d, 0.05, 1.6), t, 0.08)
elif key == 'wind':
d = 3.0
w = sweep_filter(noise(d), lambda tc: 250 + 900 * np.sin(np.pi * tc / d), 'band', order=2)
w2 = sweep_filter(noise(d), lambda tc: max(180, 300 + 700 * np.sin(np.pi * tc / d + 0.6)), 'band', order=2)
place('sfx', np.vstack([w, w2]) * env_ar(d, 0.6, 1.6), t, 0.4)
elif key == 'cable':
d = 1.8
buzz = filt(saw(60, d) + 0.5 * saw(120.5, d), sos_bp(300, 2500)) * env_ar(d, 0.02, 1.0)
gate = (np.sin(2 * np.pi * 7 * tt(d)) > 0.2).astype(float)
place('sfx', buzz * (0.4 + 0.6 * gate), t, 0.08, 0.2)
place('sfx', sine(3000 * np.exp(-tt(0.25) * 12) + 150, 0.25) * env_exp(0.25, 12), t, 0.1, -0.2)
elif key == 'cctv':
for j in range(9):
tc = t + 0.25 + j * 0.12
place('sfx', click(2500, 0.01), tc, 0.3, rng.uniform(-0.7, 0.7))
place('sfx', static(0.12) * env_exp(0.12, 25), tc, 0.05, rng.uniform(-0.7, 0.7))
for fd in C['feeds']:
feed_sig(fd['key'], fd['t'])
# camera moves
place('sfx', whoosh(2.3, 200, 2500, 0.5), 33.6, 0.22, -0.4)
place('sfx', whoosh(2.8, 2200, 180, 0.5), 37.2, 0.22, 0.4)
place('sfx', whoosh(2.0, 300, 3000, 0.5), 40.4, 0.15, -0.2)
# THE WALL
wall_t = T['cctvWall']
place('hits', braam(3.5, ('D1', 'A1', 'D2'), 1900, 0.9, 1.6), wall_t, 0.85, verb=0.3)
for j in range(100):
tc = wall_t + (rng.random() ** 0.8) * 0.85 + rng.random() * 0.12
place('sfx', click(2000 + rng.random() * 2500, 0.012), tc, 0.18, rng.uniform(-0.95, 0.95))
place('sfx', static(0.08) * env_exp(0.08, 30), tc, 0.04, rng.uniform(-0.95, 0.95))
wd = T['cctvOut'] - wall_t + 0.3
wbed = stereo_noise_bed(wd, 800, 7000) * env_ar(wd, 0.4, 0.25)
place('sfx', wbed, wall_t, 0.05)
murmur = (sine(15625 / 2, wd) * 0.02 + filt(noise(wd), sos_bp(100, 300)) * 0.4) * env_ar(wd, 0.3, 0.2)
place('sfx', murmur, wall_t, 0.12)
# the watcher hopping cell to cell (7 Hz)
for j in range(int((T['cctvOut'] - wall_t - 0.8) * 7)):
tc = wall_t + 0.8 + j / 7
place('ui', blip(3136, 0.025, 0.0, 90), tc, 0.06, rng.uniform(-0.8, 0.8))
# rapid list
for j, tc in enumerate(C['rapid']):
place('ui', blip(note('D6') * 2 ** (j / 12 * 2), 0.06, 0.2, 45), tc, 0.12, -0.5, verb=0.15)
# collapse
place('sfx', whoosh(0.6, 5000, 150, 0.7), T['cctvOut'] - 0.1, 0.4)
place('hits', thump(80, 30, 1.6, 2.8), T['cctvOut'] + 0.4, 0.7, verb=0.35)
# =================================================================== ACT 4 saturation
sat0, cut = T['saturate'], T['cut']
# pulse intensifies: 16ths, then doubled at the octave
ostinato(52.5, cut, lambda t: 2400 + 3000 * (t - 52.5) / 8, lambda t: 0.12 + 0.06 * (t - 52.5) / 8)
ostinato(55.5, cut, lambda t: 3000, lambda t: 0.05 + 0.06 * (t - 55.5) / 5, transpose=12)
for i, t0 in enumerate(np.arange(52.5, cut, beat)):
k = (t0 - 52.5) / 8
place('perc', thump(100, 42, 0.55, 8, 40), t0, 0.5 + 0.25 * k, verb=0.15)
if t0 > 55.0:
place('perc', thump(100, 42, 0.4, 10, 40), t0 + beat / 2, 0.3 + 0.2 * k)
if i % 2 == 1:
sn = filt(noise(0.25), sos_bp(900, 5000)) * env_exp(0.25, 16) + sine(190, 0.25) * env_exp(0.25, 26) * 0.5
place('perc', sn, t0, 0.18 + 0.12 * k, verb=0.3)
# 32nd-note snare roll in the last bar
if t0 > 58.4:
for q in range(4):
sn = filt(noise(0.08), sos_bp(1200, 6000)) * env_exp(0.08, 40)
place('perc', sn, t0 + q * beat / 4, 0.1 + 0.05 * q)
for k, t0 in enumerate(np.arange(52.5, cut, 2.0)):
nm, ch = prog[k % 4]
place('music', pad([note(x) for x in ch] + [note(x[:-1] + str(int(x[-1]) + 1)) for x in ch], 2.2, 1600 + k * 300, 0.3, 0.6), t0, 0.14, verb=0.3)
# Shepard riser across the whole act
sd = cut - sat0
place('music', shepard(sd, 0.45, 40, 9, 0.12) * env_ar(sd, 2.5, 0.01, 1.3), sat0, 0.22, verb=0.2)
place('sfx', riser(sd, 150, 9000), sat0, 0.25)
# status lines type in
for st in C['status']:
for j in range(14):
place('ui', click(4000 + rng.random() * 2000, 0.008), st + j * 0.028, 0.12, 0.4)
place('ui', blip(2093, 0.05, 0.2, 50), st + 0.42, 0.1, 0.4)
# lattice building shimmer
for j in range(400):
tc = T['twin'] + rng.random() ** 0.7 * (T['dishBreak'] - T['twin'])
place('ui', blip(4000 + rng.random() * 5000, 0.015, 0, 150), tc, 0.025, rng.uniform(-1, 1), verb=0.3)
# containment alarm
for j, tc in enumerate(np.arange(56.6, T['dishBreak'], 0.36)):
k = (tc - 56.6) / (T['dishBreak'] - 56.6)
tone = filt((np.sign(np.sin(2 * np.pi * 880 * tt(0.16))) * 0.4 + sine(880, 0.16)), sos_lp(2500)) * env_ar(0.16, 0.01, 0.05)
place('ui', tone, tc, 0.04 + 0.08 * k, 0.0, verb=0.2)
place('ui', filt((np.sign(np.sin(2 * np.pi * 660 * tt(0.16))) * 0.4 + sine(660, 0.16)), sos_lp(2500)) * env_ar(0.16, 0.01, 0.05), tc + 0.18, 0.04 + 0.08 * k, verb=0.2)
# the breach: impact + shattering ring
db = T['dishBreak']
place('hits', braam(3.0, ('D1', 'A1', 'D2', 'A2'), 3200, 1.0, 1.2), db, 1.0, verb=0.4)
place('hits', thump(120, 28, 2.5, 1.8, 14), db, 0.9)
for j in range(220):
tc = db + (rng.random() ** 2.2) * 1.1
f = 2500 + rng.random() * 7000
gr = filt(noise(0.25), sos_bp(f * 0.97, f * 1.03)) * env_exp(0.25, 10 + rng.random() * 25)
place('sfx', gr, tc, 0.18 * (1 - (tc - db) / 1.2), rng.uniform(-1, 1), verb=0.3)
# OMNIPRESENT
om = T['omni']
place('hits', braam(2.5, ('D1', 'D2', 'A2', 'D3'), 3800, 1.0, 0.8), om, 0.9, verb=0.45)
place('ui', fm_bell(note('A6'), 1.5, 2.0, 1.2, 2.0), om, 0.08, verb=0.5)
# =================================================================== ACT 5 title
ti = T['title']
# the hush after the cut, then a warm low bloom
d = DUR - ti
warm = (sine(note('D1'), d) * 0.6 + sine(note('A1') * 1.002, d) * 0.3 + sine(note('D2') * 0.998, d) * 0.2)
warm *= np.interp(tt(d), [0, 0.8, 2.0, d - 3.5, d], [0, 0.4, 0.7, 0.6, 0])
place('drone', warm, ti, 0.35)
air = stereo_noise_bed(d, 2000, 12000) * np.interp(tt(d), [0, 1.5, d - 3, d], [0, 1, 1, 0])
place('drone', air, ti, 0.012)
# eye opens
eo = T['eyeOpen']
place('sfx', riser(0.9, 400, 7000), eo - 0.9, 0.18)
place('hits', thump(70, 34, 2.5, 2.2), eo, 0.55, verb=0.4)
for f, g in ((note('D6'), 0.06), (note('A6'), 0.05), (note('E7'), 0.035), (note('D5'), 0.05)):
place('music', fm_bell(f, 4.5, 2.0, 0.8, 0.8), eo, g, rng.uniform(-0.5, 0.5), verb=0.8)
# wordmark: decode chatter then the warm hit
wm = T['wordmark']
for j in range(40):
place('ui', click(3000 + rng.random() * 4000, 0.007), wm + j * 0.022, 0.08, rng.uniform(-0.4, 0.4))
place('hits', braam(6.0, ('D1', 'A1', 'D2', 'E2'), 1400, 1.0, 0.5), wm + 0.85, 0.7, verb=0.5)
place('music', pad([note(x) for x in ('D3', 'A3', 'E4', 'F#4', 'A4')], 7.5, 1400, 1.5, 3.5), wm + 0.85, 0.16, verb=0.5)
# tagline keystrokes
tg_t = T['tagline']
for j, ch in enumerate('NO PLACE LEFT BEHIND.'):
if ch != ' ':
place('ui', click(3800 + rng.random() * 1500, 0.009), tg_t + j / 22, 0.14)
# ONLINE
on = T['online']
place('ui', blip(note('A5'), 0.12, 0.3, 18), on, 0.16, verb=0.5)
place('ui', blip(note('D6'), 0.35, 0.3, 8), on + 0.12, 0.16, verb=0.6)
place('hits', thump(60, 30, 3.5, 1.4, 10), on, 0.6, verb=0.5)
place('music', pad([note(x) for x in ('D2', 'A2', 'D3', 'E3', 'A3')], DUR - on, 900, 0.8, 3.5), on, 0.12, verb=0.4)
# =================================================================== reverb
def make_ir(rt60=3.4, pre=0.025):
n = int((rt60 + pre) * SR)
t0 = np.arange(n) / SR
ir = np.zeros((2, n))
for c in range(2):
x = rng.standard_normal(n) * np.exp(-6.9 * t0 / rt60)
# darker tail: blend toward a lowpassed copy over time
lp = filt(x, sos_lp(2500))
mix = np.clip(t0 / 1.5, 0, 1)
ir[c] = x * (1 - mix) + lp * mix
ir[c, : int(pre * SR)] = 0
return ir / np.sqrt(np.sum(ir ** 2) / 2)
ir = make_ir()
wet = np.vstack([signal.fftconvolve(B['verb'][c], ir[c])[:N] for c in range(2)])
# =================================================================== mix + master
# pre-hit dips: the bed drops out for a beat so each impact lands in a hole
dip = np.ones(N)
for ht, depth in ((T['substrate'], 0.3), (T['indexed'], 0.25), (T['cctvWall'], 0.4), (T['cctvOut'] + 0.4, 0.4), (T['dishBreak'], 0.2), (T['omni'], 0.45), (T['wordmark'] + 0.85, 0.4)):
a, b, c = int((ht - 0.28) * SR), int((ht - 0.03) * SR), int((ht + 0.5) * SR)
dip[a:b] = np.minimum(dip[a:b], np.linspace(1, depth, b - a))
dip[b:c] = np.minimum(dip[b:c], np.linspace(depth, 1, c - b) ** 0.7)
bed_mix = (B['drone'] * 1.0 + B['music'] * 0.9 + B['perc'] * 0.8 + B['sfx'] * 0.9 + B['ui'] * 0.85) * dip
mix = bed_mix + B['hits'] * 1.25 + wet * 0.32
mix = filt(mix, sos_hp(24, 2))
# glitch stutter into the cut, then hard silence until the title
def stutter(x, a, b, slice_s=0.045):
ia, ib = int(a * SR), int(b * SR)
sl = int(slice_s * SR)
src = x[:, ia:ia + sl].copy()
for i in range(ia, ib, sl):
n = min(sl, ib - i)
x[:, i:i + n] = src[:, :n] * (0.9 if ((i - ia) // sl) % 2 == 0 else 0.6)
return x
mix = stutter(mix, cut - 0.22, cut, 0.04)
ic = int(cut * SR)
it = int(T['title'] * SR)
fade = int(0.004 * SR)
mix[:, ic - fade:ic] *= np.linspace(1, 0, fade)
mix[:, ic:it] = 0
# collapse glitch: tiny stutter as the console implodes
mix = stutter(mix, T['collapse'] + 0.02, T['collapse'] + 0.14, 0.03)
# glue: slow RMS compressor, then soft clip and a lookahead peak limiter
def rms_env(x, win_s=0.05):
w = int(win_s * SR)
p = np.mean(x ** 2, axis=0)
k = np.ones(w) / w
return np.sqrt(np.convolve(p, k, mode='same') + 1e-12)
env = rms_env(mix, 0.08)
thr = 10 ** (-15 / 20)
ratio = 1.8
gain = np.where(env > thr, (thr * (env / thr) ** (1 / ratio)) / env, 1.0)
gain = signal.sosfiltfilt(sos_lp(8), gain)
mix *= gain
mix = np.tanh(mix * 1.6) / 1.6
look = int(0.004 * SR)
peak = np.max(np.abs(mix), axis=0)
peak = np.lib.stride_tricks.sliding_window_view(np.pad(peak, (0, look)), look + 1).max(axis=1)[:N]
ceiling = 10 ** (-1.2 / 20)
lg = np.minimum(1.0, ceiling / np.maximum(peak, 1e-9))
lg = np.minimum.reduce([lg, np.roll(lg, 1), np.roll(lg, -1)])
lg = signal.sosfiltfilt(sos_lp(60), lg)
mix *= np.minimum(lg, 1.0)
mix *= ceiling / max(1e-9, np.max(np.abs(mix)))
out = os.path.join(HERE, 'score.wav')
wavfile.write(out, SR, (mix.T * (2 ** 31 - 1)).astype(np.int32))
print('wrote', out, f'{DUR}s', 'peak', np.max(np.abs(mix)))
+527
View File
@@ -0,0 +1,527 @@
#!/usr/bin/env python3
"""God's Eye View — First Light (v2): sound design, built to sit under a music track.
No melody, no beeps. Sub, air, metal, impacts, swells, data texture — a tech
thriller palette that leaves the mids for the score. Everything is synthesised
and placed against audio/cues.json (exported from src/timeline.js).
python3 audio/sfx.py -> audio/sfx.wav (48 kHz, 24-bit stereo)
"""
import json
import os
import numpy as np
from scipy import signal
from scipy.io import wavfile
SR = 48000
HERE = os.path.dirname(os.path.abspath(__file__))
C = json.load(open(os.path.join(HERE, 'cues.json')))
T = C['T']
DUR = C['DURATION']
N = int(SR * DUR)
rng = np.random.default_rng(20261019)
B = {k: np.zeros((2, N)) for k in ('bed', 'hits', 'fx', 'ui', 'verb')}
def place(dst, sig, t, gain=1.0, pan=0.0, verb=0.0):
if sig.ndim == 1:
p = (pan + 1) * np.pi / 4
sig = np.vstack([sig * np.cos(p), sig * np.sin(p)])
i0 = int(round(t * SR))
if i0 >= N:
return
s0 = max(0, -i0)
i0 = max(0, i0)
n = min(sig.shape[1] - s0, N - i0)
if n <= 0:
return
seg = sig[:, s0:s0 + n] * gain
B[dst][:, i0:i0 + n] += seg
if verb > 0:
B['verb'][:, i0:i0 + n] += seg * verb
def tt(d):
return np.arange(int(d * SR)) / SR
def lp(fc, o=2):
return signal.butter(o, min(fc, SR * 0.45), 'low', fs=SR, output='sos')
def hp(fc, o=2):
return signal.butter(o, max(fc, 10), 'high', fs=SR, output='sos')
def bp(lo, hi, o=2):
lo = max(lo, 10)
hi = min(max(hi, lo * 1.05), SR * 0.45)
return signal.butter(o, [lo, hi], 'band', fs=SR, output='sos')
def F(x, sos):
return signal.sosfilt(sos, x, axis=-1)
def sweep(x, fn, kind='low', block=256):
y = np.zeros_like(x)
zi = None
for b in range(0, len(x), block):
fc = float(fn(b / SR))
sos = lp(fc) if kind == 'low' else hp(fc) if kind == 'high' else bp(fc * 0.7, fc * 1.35)
if zi is None:
zi = signal.sosfilt_zi(sos) * 0
y[b:b + block], zi = signal.sosfilt(sos, x[b:b + block], zi=zi)
return y
def noise(d):
return rng.standard_normal(int(d * SR))
def env_exp(d, k, a=0.002):
t = tt(d)
return np.minimum(1, t / max(a, 1e-4)) * np.exp(-t * k)
def env_ar(d, a, r, c=2.0):
t = tt(d)
return np.minimum(np.clip(t / max(a, 1e-4), 0, 1) ** c, np.clip((d - t) / max(r, 1e-4), 0, 1) ** c)
def sine(f, d, ph=0.0):
t = tt(d)
if np.isscalar(f):
return np.sin(2 * np.pi * f * t + ph)
return np.sin(2 * np.pi * np.cumsum(f) / SR + ph)
def saw(f, d):
t = tt(d)
ph = f * t if np.isscalar(f) else np.cumsum(f) / SR
return 2 * (ph - np.floor(ph + 0.5))
# ---------------------------------------------------------------- designed sounds
def tick(d=0.006, f=5000, q=0.6):
"""A dry, expensive-sounding UI tick: filtered noise, no pitch."""
return F(noise(d) * env_exp(d, 1200, 0.0002), bp(f * (1 - q / 2), f * (1 + q)))
def granule(f=6000):
d = 0.012 + rng.random() * 0.02
return F(noise(d) * env_ar(d, 0.002, d * 0.8), bp(f * 0.8, f * 1.25))
def sub_boom(d=3.0, f0=55, f1=24, k=1.4):
t = tt(d)
f = f1 + (f0 - f1) * np.exp(-t * 7)
return sine(f, d) * env_exp(d, k, 0.003)
def metal(d=3.0, base=180.0, k=1.2):
"""Inharmonic struck-metal resonance — the cinematic 'tail'."""
t = tt(d)
out = np.zeros(len(t))
for r, a in ((1.0, 1.0), (2.76, 0.6), (5.4, 0.45), (8.93, 0.3), (13.3, 0.2), (1.51, 0.5)):
out += a * np.sin(2 * np.pi * base * r * (1 + 0.002 * rng.standard_normal()) * t + rng.random() * 6) * np.exp(-t * k * (0.6 + r * 0.25))
return out / 3.0
def crack(d=0.4):
return F(noise(d) * env_exp(d, 18, 0.0005), bp(400, 7000))
def impact(size=1.0, dark=0.0, tail=3.5, base=150):
"""Trailer hit: sub drop + body thud + noise crack + metallic tail."""
d = tail + 0.5
s = sub_boom(d, 62, 22, 1.1) * 1.0
body = F(noise(0.5) * env_exp(0.5, 9, 0.001), lp(420)) * 0.9
out = s
out[: len(body)] += body
c = crack(0.45) * (0.5 - dark * 0.35)
out[: len(c)] += c
m = metal(d, base * (1 - dark * 0.3), 1.0) * 0.28
out += F(m, lp(4000 - dark * 2500))
return np.tanh(out * 1.4 * size) * 0.8
def braam(d=4.0, root=36.71, bright=1400, gain=1.0):
"""Low, distorted brass-like swell — filtered and dirty, not synthy."""
acc = np.zeros(int(d * SR))
for mult in (1, 1.5, 2, 3):
for det in (-0.01, 0, 0.012):
acc += saw(root * mult * (1 + det), d)
acc /= 10
y = sweep(acc, lambda tc: 120 + bright * min(1, tc / 0.09) * np.exp(-max(0, tc - 0.09) * 1.3))
y = np.tanh(y * 3.0) * env_ar(d, 0.015, d * 0.85, 1.2)
return y * gain
def whoosh(d=1.0, f0=300, f1=4000, peak=0.55, width=0.06):
x = noise(d)
y = sweep(x, lambda tc: f0 * (f1 / f0) ** min(1, tc / d), 'band')
t0 = tt(d)
return y * np.exp(-((t0 / d - peak) ** 2) / width)
def swell(d=1.5, f0=200, f1=6000):
"""Reverse-swell into a hit."""
x = noise(d)
y = sweep(x, lambda tc: f0 * (f1 / f0) ** ((tc / d) ** 1.8), 'band')
t0 = tt(d)
m = metal(d, 220, 0.2)[::-1] * 0.25
return (y + m) * (t0 / d) ** 2.6
def servo(d=0.6, f0=180, f1=420):
t0 = tt(d)
f = f0 + (f1 - f0) * np.sin(np.pi * t0 / d / 2)
x = saw(f, d) + 0.4 * saw(f * 2.01, d)
return F(x, bp(200, 2200)) * env_ar(d, 0.05, 0.12) * 0.5
def glitch(d=0.25):
"""Digital stutter: gated, bit-crushed noise and a buzzing carrier."""
x = noise(d)
gate = (np.sin(2 * np.pi * (35 + rng.random() * 40) * tt(d)) > 0).astype(float)
crushed = np.round(x * 4) / 4
buzz = np.sign(np.sin(2 * np.pi * (90 + rng.random() * 80) * tt(d))) * 0.3
return F((crushed * 0.6 + buzz) * gate, bp(300, 9000)) * env_ar(d, 0.002, 0.03)
def drone_bed(d, notes=(36.71, 55.0, 73.42), lpf=180):
t0 = tt(d)
x = sum(np.sin(2 * np.pi * f * (1 + 0.0008 * k) * t0 + k) * (0.6 / (k + 1)) for k, f in enumerate(notes))
x += F(noise(d), lp(lpf)) * 0.25
return x
def tension(d, base=55.0):
"""Dissonant cluster, heavily low-passed: dread, not tune."""
t0 = tt(d)
x = np.zeros(len(t0))
for r in (1.0, 1.059, 1.5, 2.12, 2.0):
x += saw(base * r * (1 + 0.003 * np.sin(2 * np.pi * 0.07 * t0 * r)), d)
x = F(x / 5, lp(380))
return x * (0.7 + 0.3 * np.sin(2 * np.pi * 0.11 * t0))
def rotor(d, rate=5.6):
t0 = tt(d)
thump = (0.5 + 0.5 * np.sin(2 * np.pi * rate * t0)) ** 6
x = F(noise(d), lp(220)) * thump * 1.6 + F(noise(d), bp(900, 3000)) * thump * 0.08
return x
def jet(d):
t0 = tt(d)
whine = sine(2400 + 120 * np.sin(2 * np.pi * 0.2 * t0), d) * 0.04 + sine(3600, d) * 0.015
roar = F(noise(d), bp(120, 900)) * 0.6
return roar + whine
def wind(d):
a = sweep(noise(d), lambda tc: 260 + 500 * (0.5 + 0.5 * np.sin(tc * 0.9)), 'band')
b = sweep(noise(d), lambda tc: 300 + 600 * (0.5 + 0.5 * np.sin(tc * 0.7 + 1.3)), 'band')
return np.vstack([a, b])
def squelch(d=0.35):
x = F(noise(d), bp(800, 4500)) * env_ar(d, 0.005, 0.06)
return x
def heartbeat(t, g=1.0):
for dt, gg, f0 in ((0.0, 1.0, 64), (0.19, 0.55, 54)):
d = 0.7
t0 = tt(d)
f = 30 + (f0 - 30) * np.exp(-t0 * 28)
place('hits', sine(f, d) * env_exp(d, 7.5, 0.002), t + dt, g * gg, verb=0.2)
# =================================================================== the film
t_all = tt(DUR)
# bed: sub + air, riding an intensity curve, silent through the cut
inten = np.interp(t_all, [0, 4, 9, 16, 30, 43, 44, 72, 76.6, T['cut'] - 0.01, T['cut'], T['title'], T['title'] + 3, DUR - 2.5, DUR],
[0, 0.25, 0.4, 0.55, 0.65, 0.7, 0.55, 0.65, 0.75, 1.0, 0, 0, 0.6, 0.5, 0])
bed = drone_bed(DUR) * inten
B['bed'] += np.vstack([bed, bed]) * 0.32
airbed = np.vstack([F(noise(DUR), bp(2500, 11000)), F(noise(DUR), bp(2500, 11000))]) * 0.012 * inten
B['bed'] += airbed
# tension cluster under the indexing and the cities
tz = tension(DUR) * np.interp(t_all, [16, 20, 30, 31, 43, 44, 70, 76, 84.4, 84.41], [0, 0.5, 0.6, 0.35, 0.45, 0.6, 0.7, 0.8, 1.0, 0], left=0, right=0)
B['bed'] += np.vstack([tz, tz * 0.95]) * 0.22
# ACT 0 — console
hum = (sine(100, DUR) * 0.25 + F(noise(DUR), bp(80, 300)) * 0.05) * np.interp(t_all, [0.4, 1.2, T['collapse'], T['seed']], [0, 1, 1, 0], left=0, right=0)
place('fx', hum, 0, 0.12)
for k in C['boot']:
if k['ch'] != ' ':
place('ui', tick(0.006, 4200 + rng.random() * 2500), k['t'], 0.16, pan=(rng.random() - 0.5) * 0.3)
place('fx', swell(T['seed'] - T['collapse'] + 0.05, 300, 9000), T['collapse'], 0.35)
place('fx', glitch(0.18), T['collapse'], 0.25)
place('hits', impact(0.7, dark=0.6, tail=3.0, base=110), T['seed'], 0.8, verb=0.35)
for bt in (T['beat1'], T['beat2']):
heartbeat(bt, 1.1)
# aperture: servos as the blades retract
place('fx', servo(1.2, 140, 360), T['aperture'], 0.22, -0.3, verb=0.2)
place('fx', servo(1.0, 160, 300), T['aperture'] + 0.25, 0.18, 0.3, verb=0.2)
for i in range(120):
place('ui', tick(0.004, 7000 if i % 10 else 4200), T['dishDraw'] + (i / 120) * (T['dishDone'] - T['dishDraw']), 0.05 if i % 10 else 0.1, pan=np.sin(i / 120 * 6.28) * 0.8)
# ACT 1 — growth: organic granular crackle densifying
gs, ge = T['growStart'], T['growEnd']
for _ in range(2400):
x = rng.random() ** 0.45
tc = gs + x * (ge - gs + 0.3)
k = (np.exp(3 * (tc - gs) / (ge - gs)) - 1) / (np.exp(3) - 1)
if rng.random() > 0.15 + 0.85 * k:
continue
place('fx', granule(2000 + rng.random() * 7000), tc, 0.05 + 0.08 * rng.random() * (0.4 + k), pan=rng.uniform(-0.9, 0.9))
# radar sweep: a slow low whoosh per revolution
sweep_period = 2 * np.pi / 2.4
for i in range(int((T['substrate'] - gs) / sweep_period) + 1):
place('fx', whoosh(sweep_period, 120, 900, 0.5, 0.12), gs + i * sweep_period, 0.08, pan=0.0)
place('fx', swell(1.6, 120, 5000), T['substrate'] - 1.6, 0.3)
place('hits', impact(0.9, 0.2, 4.0, 150), T['substrate'], 0.9, verb=0.35)
place('hits', braam(3.5, 36.71, 1100), T['substrate'], 0.35, verb=0.3)
# ACT 2 — indexing: tiles as data grains, captures as shutter clicks
for tl in C['tiles']:
if not tl['vis'] or tl['t'] > 23.5:
continue
if tl['z'] == 5 and rng.random() > 0.3:
continue
place('ui', granule(3000 + rng.random() * 5000), tl['t'], {3: 0.07, 4: 0.04, 5: 0.025}[tl['z']], pan=tl['pan'] * 0.8)
def shutter(t, g=1.0, pan=0.0):
place('fx', tick(0.008, 3800, 0.6), t, 0.45 * g, pan)
place('fx', tick(0.014, 2100, 0.6), t + 0.045, 0.35 * g, pan)
place('fx', F(noise(0.05) * env_exp(0.05, 60), lp(500)), t, 0.25 * g, pan)
for cp in C['caps']:
if cp['slow']:
shutter(cp['t'], 1.0, -0.3)
place('fx', servo(0.9, 220, 900), cp['t'] + 0.3, 0.12, -0.3, verb=0.2)
place('fx', whoosh(0.45, 900, 5000, 0.5), cp['t'] + 1.15, 0.16, 0.4)
else:
shutter(cp['t'], 0.5, 0.5)
# the flurry: shutters dissolving into a roar of capture
fl0, fl1 = T['flurry'], T['flurryEnd']
for i in range(420):
u = rng.random() ** 0.6
tc = fl0 + u * (fl1 - fl0)
shutter(tc, 0.18 + 0.25 * u, rng.uniform(-0.9, 0.9))
sw_d = fl1 - fl0
spin = whoosh(sw_d, 150, 3500, 0.62, 0.05)
place('fx', np.vstack([spin, np.roll(spin, 900)]), fl0, 0.35)
place('fx', swell(1.2, 200, 8000), T['indexed'] - 1.2, 0.35)
place('hits', impact(1.1, 0.0, 4.5, 170), T['indexed'], 1.0, verb=0.4)
place('hits', braam(4.0, 36.71, 1800), T['indexed'], 0.4, verb=0.3)
# ACT 3 — feeds: each a designed arrival, not a beep
def feed(key, t):
place('ui', tick(0.01, 3000), t, 0.25, -0.5)
place('fx', whoosh(0.5, 2500, 400, 0.3), t - 0.05, 0.12, -0.4)
if key == 'air':
for _ in range(380):
place('ui', tick(0.003, 6000 + rng.random() * 3000), t + rng.random() ** 0.7 * 1.6, 0.04 + rng.random() * 0.04, rng.uniform(-0.9, 0.9))
place('fx', jet(1.6) * env_ar(1.6, 0.3, 1.0), t, 0.25, 0.3)
elif key == 'sea':
p = sine(820 * np.exp(-tt(2.5) * 0.06), 2.5) * env_exp(2.5, 2.0)
place('fx', F(p, lp(2000)), t + 0.05, 0.12, 0.2, verb=0.9)
place('fx', F(noise(2.0), lp(300)) * env_ar(2.0, 0.6, 1.2), t, 0.25)
elif key == 'mil':
place('fx', squelch(0.3), t + 0.05, 0.25, 0.4)
place('fx', F(saw(48, 1.2), bp(60, 600)) * env_ar(1.2, 0.1, 0.8), t, 0.18)
elif key == 'orbit':
place('fx', whoosh(2.6, 2600, 160, 0.4, 0.1), t + 0.2, 0.3)
place('hits', sub_boom(2.0, 48, 24, 2.0), t + 0.1, 0.5, verb=0.3)
for _ in range(40):
place('ui', granule(7000 + rng.random() * 4000), t + rng.random() * 2.0, 0.03, rng.uniform(-1, 1), verb=0.6)
elif key == 'quake':
for j, dt in enumerate((0.05, 0.45, 0.95)):
place('hits', sub_boom(1.4, 50, 22, 3.0), t + dt, 0.6 - j * 0.12, verb=0.25)
place('fx', F(noise(2.4), lp(90)) * env_ar(2.4, 0.2, 1.6), t, 1.0)
elif key == 'fire':
for _ in range(700):
x = rng.random() ** 1.3 * 2.0
place('fx', granule(700 + rng.random() * 4000), t + x, (1 - x / 2) * 0.1 * rng.random(), rng.uniform(-0.8, 0.8))
place('fx', F(noise(2.0), bp(150, 1200)) * env_ar(2.0, 0.05, 1.5), t, 0.08)
elif key == 'wind':
place('fx', wind(2.6) * env_ar(2.6, 0.5, 1.5), t, 0.45)
elif key == 'cable':
buzz = F(saw(60, 1.5) + 0.5 * saw(120.4, 1.5), bp(300, 2500)) * env_ar(1.5, 0.02, 0.9)
place('fx', buzz, t, 0.05, 0.2)
place('fx', glitch(0.2), t, 0.12, -0.2)
for fd in C['feeds']:
feed(fd['key'], fd['t'])
for t0, d in ((31.6, 1.8), (33.6, 2.2), (35.0, 2.2)):
place('fx', whoosh(d, 200, 2400, 0.5), t0, 0.16, rng.uniform(-0.5, 0.5))
# ACT 4 — descent and the cities
place('fx', swell(1.4, 120, 9000), T['terrain'] - 1.4, 0.45)
place('fx', whoosh(1.2, 4000, 120, 0.7, 0.08), T['terrain'] - 1.0, 0.4)
place('hits', impact(1.0, 0.4, 3.5, 120), T['terrain'], 0.85, verb=0.4)
for c in C['cities']:
t0, t1 = c['t0'], c['t1']
d = t1 - t0
if c['slug'] != 'everest':
place('fx', glitch(0.22), t0 - 0.05, 0.35)
place('hits', impact(0.6, 0.5, 2.5, 140), t0, 0.5, verb=0.3)
place('fx', whoosh(0.6, 3000, 300, 0.25), t0 - 0.1, 0.25)
amb = F(noise(d), bp(60, 400)) * env_ar(d, 0.4, 0.4) * 0.5
place('fx', np.vstack([amb, np.roll(amb, 2000)]), t0, 0.25)
if c['slug'] == 'everest':
place('fx', wind(d) * env_ar(d, 0.6, 0.6), t0, 0.7)
# LiDAR sweep: a soft rising tone per pass
for i in range(int(d / (1 / 0.35 * 1.2 / 1.2)) + 1):
place('fx', F(sine(np.linspace(300, 1200, int(1.2 * SR)), 1.2), bp(200, 1600)) * env_ar(1.2, 0.3, 0.6), t0 + i * 2.86, 0.03, verb=0.4)
elif c['slug'] == 'nyc':
traffic = F(noise(d), bp(80, 700)) * env_ar(d, 0.8, 0.5)
place('fx', np.vstack([traffic, np.roll(traffic, 5000)]), t0, 0.22)
place('fx', rotor(d) * env_ar(d, 1.0, 1.0), t0 + 0.2, 0.3, 0.4)
for j in range(8):
place('fx', tick(0.01, 2400), t0 + 2.2 + j * 0.35, 0.18, 0.7)
place('fx', F(noise(0.1), bp(1500, 7000)) * env_exp(0.1, 25), t0 + 2.2 + j * 0.35, 0.05, 0.7)
elif c['slug'] == 'boston':
for j in range(4):
place('fx', F(noise(1.4), bp(100, 900)) * env_ar(1.4, 0.4, 0.8) * (1 + 0.3 * np.sin(2 * np.pi * 9 * tt(1.4))), t0 + 0.6 + j * 1.0, 0.1, rng.uniform(-0.7, 0.7))
elif c['slug'] == 'lax':
place('fx', jet(d) * env_ar(d, 0.8, 0.8), t0, 0.35, -0.3)
place('fx', whoosh(2.4, 200, 1400, 0.5, 0.1), t0 + 1.4, 0.3, 0.5)
elif c['slug'] == 'bragg':
place('fx', rotor(d, 4.6) * env_ar(d, 0.8, 0.8), t0 + 0.4, 0.25, -0.4)
for j in range(3):
place('fx', squelch(0.3), t0 + 0.8 + j * 1.3, 0.16, 0.5)
# the camera wall
wt = T['cctvWall']
place('hits', impact(0.9, 0.3, 3.0, 160), wt, 0.8, verb=0.3)
place('hits', braam(3.0, 36.71, 1400), wt, 0.3, verb=0.3)
for _ in range(100):
tc = wt + rng.random() ** 0.8 * 0.85 + rng.random() * 0.12
place('fx', tick(0.012, 2200 + rng.random() * 2000), tc, 0.14, rng.uniform(-0.95, 0.95))
place('fx', F(noise(0.08), bp(1500, 8000)) * env_exp(0.08, 30), tc, 0.03, rng.uniform(-0.95, 0.95))
wd = T['cctvOut'] - wt + 0.3
place('fx', np.vstack([F(noise(wd), bp(800, 6000)), F(noise(wd), bp(800, 6000))]) * env_ar(wd, 0.4, 0.25), wt, 0.035)
for j in range(int((T['cctvOut'] - wt - 0.8) * 7)):
place('ui', tick(0.006, 5200), wt + 0.8 + j / 7, 0.06, rng.uniform(-0.8, 0.8))
place('fx', whoosh(0.6, 5000, 150, 0.7), T['cctvOut'] - 0.1, 0.4)
place('hits', impact(0.9, 0.5, 3.0, 120), T['cctvOut'] + 0.4, 0.7, verb=0.35)
# ACT 5 — saturation: rising pressure, the breach, the state
sat0, cut = T['saturate'], T['cut']
sd = cut - sat0
t0s = tt(sd)
pos = 0.45 * t0s + 0.06 * t0s ** 2
sh = np.zeros(len(t0s))
for k in range(9):
o = (k + pos) % 9
a = np.exp(-((o - 4.5) ** 2) / (2 * 1.8 ** 2))
sh += np.sin(2 * np.pi * np.cumsum(30 * 2 ** o) / SR) * a
sh = F(sh / 9 * 2, lp(2200)) * env_ar(sd, 2.0, 0.01, 1.3)
place('fx', sh, sat0, 0.3, verb=0.2)
place('fx', swell(sd, 120, 9000), sat0, 0.3)
# pulse of low hits accelerating toward the breach
tk = sat0 + 0.5
gap = 0.9
while tk < T['dishBreak'] - 0.2:
place('hits', sub_boom(0.9, 58, 30, 4.0), tk, 0.35 + 0.35 * (tk - sat0) / sd)
place('fx', F(noise(0.12), bp(500, 4000)) * env_exp(0.12, 30), tk, 0.08)
tk += gap
gap = max(0.22, gap * 0.86)
for st in C['status']:
for j in range(16):
place('ui', tick(0.005, 4500 + rng.random() * 1500), st + j * 0.026, 0.1, 0.4)
for _ in range(300):
tc = T['twin'] + rng.random() ** 0.7 * (T['dishBreak'] - T['twin'])
place('ui', granule(5000 + rng.random() * 5000), tc, 0.025, rng.uniform(-1, 1), verb=0.3)
db = T['dishBreak']
place('hits', impact(1.25, 0.0, 5.0, 190), db, 1.1, verb=0.45)
place('hits', braam(3.0, 36.71, 2600), db, 0.45, verb=0.4)
for _ in range(200):
tc = db + rng.random() ** 2.2 * 1.1
f = 2500 + rng.random() * 7000
g = F(noise(0.25), bp(f * 0.97, f * 1.03)) * env_exp(0.25, 10 + rng.random() * 25)
place('fx', g, tc, 0.16 * (1 - (tc - db) / 1.2), rng.uniform(-1, 1), verb=0.3)
place('hits', impact(1.0, 0.3, 4.0, 110), T['omni'], 0.8, verb=0.45)
# ACT 6 — title: low bloom, the eye, the word
ti = T['title']
d = DUR - ti
warm = drone_bed(d, (36.71, 55.0, 73.42, 110.0), 140) * np.interp(tt(d), [0, 0.8, 2.0, d - 3.0, d], [0, 0.4, 0.7, 0.6, 0])
place('bed', warm, ti, 0.3)
place('fx', swell(0.9, 400, 7000), T['eyeOpen'] - 0.9, 0.2)
place('hits', impact(0.8, 0.6, 5.0, 90), T['eyeOpen'], 0.6, verb=0.5)
place('fx', metal(6.0, 220, 0.5) * 0.4, T['eyeOpen'], 0.12, verb=0.7)
for j in range(36):
place('ui', tick(0.006, 3500 + rng.random() * 4000), T['wordmark'] + j * 0.024, 0.07, rng.uniform(-0.4, 0.4))
place('hits', impact(1.0, 0.4, 6.5, 100), T['wordmark'] + 0.85, 0.8, verb=0.55)
place('hits', braam(6.0, 36.71, 900), T['wordmark'] + 0.85, 0.35, verb=0.5)
for j in range(11):
place('ui', tick(0.008, 4000), T['tagline'] + j * 0.08, 0.1)
place('fx', metal(6.0, 330, 0.45) * 0.35, T['tagline'] + 0.9, 0.1, verb=0.8)
# =================================================================== reverb, mix, master
def ir(rt=3.6, pre=0.03):
n = int((rt + pre) * SR)
t0 = np.arange(n) / SR
out = np.zeros((2, n))
for c in range(2):
x = rng.standard_normal(n) * np.exp(-6.9 * t0 / rt)
l2 = F(x, lp(2200))
m = np.clip(t0 / 1.2, 0, 1)
out[c] = x * (1 - m) + l2 * m
out[c, : int(pre * SR)] = 0
return out / np.sqrt(np.sum(out ** 2) / 2)
R = ir()
wet = np.vstack([signal.fftconvolve(B['verb'][c], R[c])[:N] for c in range(2)])
dip = np.ones(N)
for ht, depth in ((T['substrate'], 0.3), (T['indexed'], 0.25), (T['terrain'], 0.3), (T['cctvWall'], 0.4), (T['dishBreak'], 0.2), (T['wordmark'] + 0.85, 0.4)):
a, b, c = int((ht - 0.28) * SR), int((ht - 0.03) * SR), int((ht + 0.5) * SR)
dip[a:b] = np.minimum(dip[a:b], np.linspace(1, depth, b - a))
dip[b:c] = np.minimum(dip[b:c], np.linspace(depth, 1, c - b) ** 0.7)
mix = (B['bed'] + B['fx'] * 0.9 + B['ui'] * 0.8) * dip + B['hits'] * 1.2 + wet * 0.3
mix = F(mix, hp(22))
def stutter(x, a, b, sl=0.04):
ia, ib, s = int(a * SR), int(b * SR), int(sl * SR)
src = x[:, ia:ia + s].copy()
for i in range(ia, ib, s):
n = min(s, ib - i)
x[:, i:i + n] = src[:, :n] * (0.9 if ((i - ia) // s) % 2 == 0 else 0.55)
return x
mix = stutter(mix, cut - 0.22, cut)
ic, it = int(cut * SR), int(T['title'] * SR)
f = int(0.004 * SR)
mix[:, ic - f:ic] *= np.linspace(1, 0, f)
mix[:, ic:it] = 0
# gentle glue + ceiling
p = np.sqrt(np.convolve(np.mean(mix ** 2, axis=0), np.ones(4000) / 4000, mode='same') + 1e-12)
thr = 10 ** (-16 / 20)
g = np.where(p > thr, (thr * (p / thr) ** (1 / 1.8)) / p, 1.0)
g = signal.sosfiltfilt(lp(8), g)
mix *= g
mix = np.tanh(mix * 1.5) / 1.5
look = int(0.004 * SR)
pk = np.lib.stride_tricks.sliding_window_view(np.pad(np.max(np.abs(mix), axis=0), (0, look)), look + 1).max(axis=1)[:N]
ceil = 10 ** (-1.2 / 20)
lg = signal.sosfiltfilt(lp(60), np.minimum(1.0, ceil / np.maximum(pk, 1e-9)))
mix *= np.minimum(lg, 1.0)
mix *= ceil / max(1e-9, np.max(np.abs(mix)))
out = os.path.join(HERE, 'sfx.wav')
wavfile.write(out, SR, (mix.T * (2 ** 31 - 1)).astype(np.int32))
print('wrote', out, DUR, 's')
+62
View File
@@ -0,0 +1,62 @@
# Intro-sequence data snapshot: sources
All files are real downloads from public keyless endpoints, fetched on **2026-10-18 between 02:24 and 02:42 UTC**. Nothing here is synthesized. The scripts only filter, convert units and round. Helper scripts are in `../tools/fetch/` (`fetch_basic.sh`, `process_basic.py`, `process_tle.py`, `process_vessels.py`, `fetch_wind.py`, `fetch_bonus.py`, `cctv_fetch.py`, `cctv_select.py`).
JSON files are compact. Tabular layers use `{"cols":[...],"rows":[[...],...]}`, and each file's `cols` lists its columns. Coordinates are `[lon, lat]` in WGS84.
| File | Rows | Source URL(s) | Fetched (UTC) | Notes / licence |
|---|---|---|---|---|
| `flights.json` | 5,550 airborne aircraft | https://opensky-network.org/api/states/all | 02:24:18 (`t`=1792290258) | OpenSky Network state vectors, anonymous tier. Airborne only (`on_ground=false`), non-null positions. `alt_m` is geometric altitude, or barometric when geometric is missing. Licence: OpenSky Network, non-commercial/research terms; credit "The OpenSky Network, https://opensky-network.org". |
| `military.json` | 45 airborne aircraft | https://api.adsb.lol/v2/mil, plus https://opendata.adsb.fi/api/v2/mil (merged and de-duplicated by ICAO hex; adsb.lol wins) | 02:24 | Includes the `type` column (ICAO type code, e.g. C17, K35R). alt ft→m, gs kt→m/s. `country` is null because these feeds do not provide it. The count is low because the snapshot was taken at a quiet hour. A re-poll at 02:42 gave fewer (30 and 31 airborne). adsb.lol data is ODbL 1.0; adsb.fi open data. airplanes.live /v2/mil returned 403 and was not used. |
| `tle.txt` | 15,928 satellites (3-line TLE) | https://celestrak.org/NORAD/elements/gp.php?GROUP=active&FORMAT=3le | 02:26 | CelesTrak "active" group. The FORMAT=tle request kept failing with connection resets after about 11 s through the proxy, while FORMAT=3le answered in 4 s. Element epochs range from day 264 to 293 of 2026. A descriptive User-Agent is required (as in server/providers/space/celestrak.js). Credit: CelesTrak / Dr T.S. Kelso. Data originates from 18th SDS / space-track.org. |
| `tle_groups.json` | stations 17, starlink 10,651, gps-ops 32, geo 567, weather 72 | `gp.php?GROUP={stations,starlink,gps-ops,geo,weather}&FORMAT=tle` | 02:24–02:27 | `groups_norad`: NORAD ids per group, to join against `tle.txt` (columns 3–7 of line 1). `notable`: name→group for the first entries of each group (ISS (ZARYA), CSS (TIANHE), GOES, NOAA, GPS, …). |
| `quakes.json` | 10,120 events | https://earthquake.usgs.gov/earthquakes/feed/v1.0/summary/all_month.geojson | 02:24 (`t`=generated ms) | USGS ANSS ComCat. Covers 2026-09-18 02:26 to 2026-10-18 02:20 UTC, max M7.7. Sorted by time. Public domain (USGS). |
| `fires.json` | 246,411 hotspots | https://firms.modaps.eosdis.nasa.gov/data/active_fire/suomi-npp-viirs-c2/csv/SUOMI_VIIRS_C2_Global_24h.csv (111,551); https://firms.modaps.eosdis.nasa.gov/data/active_fire/noaa-20-viirs-c2/csv/J1_VIIRS_C2_Global_24h.csv (118,991); https://firms.modaps.eosdis.nasa.gov/data/active_fire/modis-c6.1/csv/MODIS_C6_1_Global_24h.csv (15,869) | 02:24 | Rows are `[lon, lat, frp_MW, brightness_K]`. Brightness is VIIRS `bright_ti4` or MODIS `brightness`. Despite the "24h" names, the acquisitions run from 2026-10-16 00:11 to 2026-10-17 23:56 UTC. SNPP and NOAA-20 overlap, so the same fires appear twice. NASA FIRMS (LANCE); free use with credit "NASA FIRMS". |
| `vessels.json` | 4,385 vessels | https://kystdatahuset.kystverket.no/ws/api/ais/realtime/geojson (Norway, 3,272 with a position); https://meri.digitraffic.fi/api/ais/v1/locations?from=<now-48h> + https://meri.digitraffic.fi/api/ais/v1/vessels for names (Finland/Baltic, 1,113 after de-duplication against Norway) | 02:28–02:29 | **Not global.** Coverage is the Norwegian coast and EEZ including Svalbard (Kystverket Kystdatahuset open API; NLOD licence) plus Finnish waters and the northern Baltic (Fintraffic Digitraffic; CC BY 4.0). Kystverket returns short trails, and the script uses the last vertex as the current position, checked against COG. `sog_kn`/`cog_deg` are null when AIS sends "not available". No keyless global AIS source was found. AISStream (the repo's source) needs a key, BarentsWatch needs OAuth (401), the Danish DMA host has an expired TLS certificate, and Kystverket's raw TCP NMEA feed cannot pass the HTTPS-only proxy. |
| `wind.json` | 360×181 grid (65,160 cells) | https://noaa-gfs-bdp-pds.s3.amazonaws.com/gfs.20261017/18/atmos/gfs.t18z.pgrb2.1p00.f006 (byte ranges from its `.idx`, UGRD/VGRD at "10 m above ground") | 02:34 | NOAA GFS 1.0° run 2026-10-17 18Z, +6 h forecast, valid 2026-10-18 00Z. This is the same AWS Open Data bucket and `.idx` range approach as server/providers/wind/gfs.js, decoded with ecCodes (pip `eccodes`). The grid is re-indexed so `lo1=-180, la1=90, dx=dy=1`, row-major from north to south, m/s. The 00Z run of 10-18 was not yet published. Public domain (NOAA). |
| `cables.json` | 1,948 polylines (732 cables) | https://www.submarinecablemap.com/api/v3/cable/cable-geo.json | 02:24 | Each LineString part is a separate polyline, rounded to 2 decimals. © TeleGeography, Submarine Cable Map (CC BY-SA 4.0); attribute TeleGeography. |
| `coastline.json` | 1,429 lines, 60,392 vertices | https://raw.githubusercontent.com/nvkelso/natural-earth-vector/master/geojson/ne_50m_coastline.geojson | 02:24 | Natural Earth 1:50m coastline, rounded to 2 decimals with consecutive duplicate points removed. Public domain. |
| `borders.json` | 288 rings | https://raw.githubusercontent.com/nvkelso/natural-earth-vector/master/geojson/ne_110m_admin_0_countries.geojson | 02:24 | Natural Earth 1:110m countries, each polygon ring as a line. Public domain. |
| `cities.json` | 3,000 | https://raw.githubusercontent.com/nvkelso/natural-earth-vector/master/geojson/ne_10m_populated_places_simple.geojson | 02:24 | Top 3,000 by `pop_max`. Natural Earth, public domain. |
| `airports.json` (bonus) | 5,280 (1,176 large, 4,104 medium) | https://davidmegginson.github.io/ourairports-data/airports.csv | 02:24 | OurAirports large and medium airports. `iata` falls back to the ICAO ident when there is no IATA code. `size` is L or M. Public domain. |
| `radio.json` (bonus) | 12,983 stations | https://de1.api.radio-browser.info/json/stations/search?has_geo_info=true&hidebroken=true&limit=100000 | 02:40 | Radio Browser community directory (the repo's radio provider). Stations with geo coordinates only. Public domain / open. |
| `bikes.json` (bonus) | 31,176 docks | 18 GBFS `station_information.json` feeds (Citi Bike, Divvy, Bay Wheels, Capital Bikeshare via gbfs.lyft.com; Bluebikes; Toronto, Barcelona, Madrid, São Paulo, Rio, Santiago, Bogotá via publicbikesystem.net; BIXI; Ecobici; Vélib'; Indego and LA Metro via bcycle; Oslo via urbansharing) + https://api.nextbike.net/maps/nextbike-live.json (all nextbike cities, 16,926 stations) | 02:41 | Per-system counts are in `systems`. Operator open-data terms apply (mostly GBFS open licences). |
| `launch_sites.json` (bonus) | 249 pads | https://ll.thespacedevs.com/2.3.0/pads/?limit=100&mode=list (paged) | 02:42 | Launch Library 2 (The Space Devs), sorted by total launches. Free tier. |
| `power_plants.json` (bonus) | 34,936 plants | https://raw.githubusercontent.com/wri/global-power-plant-database/master/output_database/global_power_plant_database.csv | 02:42 | WRI Global Power Plant Database v1.3 (static, 2021). CC BY 4.0. |
Not produced: **military installations**. The repo's provider queries OSM Overpass by viewport only and rejects global requests. A global Overpass pull would be slow and against that design. **Datacenters**: the repo has no keyless source.
## CCTV (`../cctv/`)
`cctv/cam_001.jpg` … `cam_117.jpg`: 117 real frames, each fetched 2026-10-18 02:38:46–02:39:30 UTC and cover-cropped and resized to 640×360 (JPEG q88). `cctv/index.json` gives `file, name, city, country, lat, lon, source`, plus `fetched_utc`, `orig_size` and the frame `url`.
About 235 frames were downloaded from 20 sources, and every one was reviewed on contact sheets. Placeholders were rejected: Caltrans "Temporarily Unavailable", Vegvesen "Kamera er midlertidig ute av drift", Tallinn "NB! ÜHENDUS KAAMERAGA PUUDUB", Barcelona "Interrupció temporal de la imatge", and one all-black King County frame. Frames whose average hash repeats within the set were also dropped as placeholders, as were frames under 320 px wide or with luminance mean ≤25 or std ≤15.
Local time at capture: daylight in Sydney, NZ, Hong Kong and Singapore; evening or night in the Americas; pre-dawn night in Europe. The Hong Kong TD frames carry the department's own privacy-mask polygons.
| Source | Frames | Country | Catalog endpoint (frames from) |
|---|---|---|---|
| Live Traffic NSW | 12 | Australia | https://data.livetraffic.com/cameras/traffic-cam.json (webcams.transport.nsw.gov.au) |
| NZTA Waka Kotahi | 12 | New Zealand | https://trafficnz.info/service/traffic/rest/4/cameras/all (trafficnz.info/camera/*.jpg) |
| HK Transport Department | 10 | Hong Kong | https://static.data.gov.hk/td/traffic-snapshot-images/code/Traffic_Camera_Locations_En.xml (tdcctv.data.one.gov.hk) |
| LTA Singapore | 6 | Singapore | https://api.data.gov.sg/v1/transport/traffic-images (the feed listed only 8 cameras) |
| NYC DOT | 9 | USA | https://webcams.nyctmc.org/api/cameras (…/{id}/image) |
| Caltrans CCTV | 6 | USA | https://cwwp2.dot.ca.gov/data/d{4,7,11,3}/cctv/cctvStatusD0X.json |
| WSDOT | 6 | USA | https://data.wsdot.wa.gov/travelcenter/Cameras.json (images.wsdot.wa.gov only) |
| King County DOT | 3 | USA | ArcGIS TRAFFICCAMERA_POINT_2029 FeatureServer (info.kingcounty.gov) |
| TxDOT ITS | 4 | USA | https://its.txdot.gov/its/DistrictIts/GetCctvStatusListByDistrict?districtCode=… (base64 JSON snapshot; Dallas and San Antonio worked, Houston and El Paso returned null) |
| City of Austin | 4 | USA | https://data.austintexas.gov/api/views/b4k4-adkb/rows.json (cctv.austinmobility.io) |
| DriveBC | 6 | Canada | https://www.drivebc.ca/api/webcams/ |
| City of Toronto RESCU | 6 | Canada | https://opendata.toronto.ca/transportation/tmc/rescucameraimages/Data/tmcearthcameras.json |
| City of Calgary | 4 | Canada | https://data.calgary.ca/resource/k7p9-kppz.json |
| TfL JamCams | 8 | UK | https://api.tfl.gov.uk/Place/Type/JamCam (jamcams.tfl.gov.uk S3) |
| Fintraffic Digitraffic | 4 | Finland | https://tie.digitraffic.fi/api/weathercam/v1/stations (weathercam.digitraffic.fi) |
| Statens vegvesen | 4 | Norway | ogckart-sn1.atlas.vegvesen.no datex_3_1:CctvSimple (kamera.atlas.vegvesen.no) |
| City of Tallinn | 3 | Estonia | repo config/cctv_sources.tallinn.json (ristmikud.tallinn.ee) |
| Transpordiamet Tarktee | 3 | Estonia | tarktee.transpordiamet.ee DATEX2 roadCameraLocations + roadCameraImages |
| Via Lietuva eismoinfo | 4 | Lithuania | https://eismoinfo.lt/eismoinfo-backend/camera-info-table + layer-static-features/VKR |
| Servei Català de Trànsit catalog | 3 | Spain | https://www.gencat.cat/transit/opendata/cameres.xml (the 3 kept frames are Barcelona IMI cameras on www.bcn.cat; SCT's own mct.gencat.cat host would not answer over HTTPS) |
Skipped: Ontario 511 (now answers "Invalid Key"), DelDOT (video only), Warendorf (http-only host, and the proxy allows HTTPS only).
The frames are public agency imagery, used here under each agency's open-data or terms-of-use conditions. Credit the agency named in `source`. TfL: "Powered by TfL Open Data". NSW: © Transport for NSW. HK: © HKSAR Transport Department (data.gov.hk). Singapore: LTA via data.gov.sg (Singapore Open Data Licence). NZ: © NZ Transport Agency Waka Kotahi (CC BY 4.0).
+26
View File
@@ -0,0 +1,26 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>God's Eye View — First Light</title>
<style>
@font-face { font-family: 'JetBrains Mono'; font-weight: 400; src: url(node_modules/@fontsource/jetbrains-mono/files/jetbrains-mono-latin-400-normal.woff2) format('woff2'); }
@font-face { font-family: 'JetBrains Mono'; font-weight: 500; src: url(node_modules/@fontsource/jetbrains-mono/files/jetbrains-mono-latin-500-normal.woff2) format('woff2'); }
@font-face { font-family: 'Inter'; font-weight: 200; src: url(node_modules/@fontsource/inter/files/inter-latin-200-normal.woff2) format('woff2'); }
@font-face { font-family: 'Inter'; font-weight: 300; src: url(node_modules/@fontsource/inter/files/inter-latin-300-normal.woff2) format('woff2'); }
@font-face { font-family: 'Inter'; font-weight: 500; src: url(node_modules/@fontsource/inter/files/inter-latin-500-normal.woff2) format('woff2'); }
html, body { margin: 0; background: #000; overflow: hidden; }
canvas { display: block; }
</style>
<script type="importmap">
{ "imports": {
"three": "./node_modules/three/build/three.module.js",
"satellite.js": "./node_modules/satellite.js/dist/satellite.es.js"
} }
</script>
</head>
<body>
<canvas id="c"></canvas>
<script type="module" src="src/main.js"></script>
</body>
</html>
+53
View File
@@ -0,0 +1,53 @@
{
"name": "gev-intro-sequence",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "gev-intro-sequence",
"dependencies": {
"@fontsource/inter": "^5.1.1",
"@fontsource/jetbrains-mono": "^5.1.1",
"playwright-core": "^1.56.1",
"satellite.js": "^5.0.0",
"three": "^0.169.0"
}
},
"node_modules/@fontsource/inter": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/@fontsource/inter/-/inter-5.1.1.tgz",
"integrity": "sha512-weN3E+rq0Xb3Z93VHJ+Rc7WOQX9ETJPTAJ+gDcaMHtjft67L58sfS65rAjC5tZUXQ2FdZ/V1/sSzCwZ6v05kJw==",
"license": "OFL-1.1"
},
"node_modules/@fontsource/jetbrains-mono": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/@fontsource/jetbrains-mono/-/jetbrains-mono-5.1.1.tgz",
"integrity": "sha512-5rwvmdQQpXev4LlBX1P+7h2dguu6iwW/9Npjde4+DEq+HgpVJI/7QY8DI1NVVFdvLtXZNP+vO2L/5BQED6FUhA==",
"license": "OFL-1.1"
},
"node_modules/playwright-core": {
"version": "1.56.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.56.1.tgz",
"integrity": "sha512-hutraynyn31F+Bifme+Ps9Vq59hKuUCz7H1kDOcBs+2oGguKkWTU50bBWrtz34OUWmIwpBTWDxaRPXrIXkgvmQ==",
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/satellite.js": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/satellite.js/-/satellite.js-5.0.0.tgz",
"integrity": "sha512-ie3yiJ2LJAJIhVUKdYhgp7V0btXKAMImDjRnuaNfJGl8rjwP2HwVIh4HLFcpiXYEiYwXc5fqh5+yZqCe6KIwWw==",
"license": "MIT"
},
"node_modules/three": {
"version": "0.169.0",
"resolved": "https://registry.npmjs.org/three/-/three-0.169.0.tgz",
"integrity": "sha512-Ed906MA3dR4TS5riErd4QBsRGPcx+HBDX2O5yYE5GqJeFQTPU+M56Va/f/Oph9X7uZo3W3o4l2ZhBZ6f6qUv0w==",
"license": "MIT"
}
}
}
+14
View File
@@ -0,0 +1,14 @@
{
"name": "gev-intro-sequence",
"private": true,
"type": "module",
"description": "God's Eye View: the intro sequence where the system comes online. Deterministic WebGL renderer + procedural score.",
"scripts": {},
"dependencies": {
"@fontsource/inter": "^5.1.1",
"@fontsource/jetbrains-mono": "^5.1.1",
"playwright-core": "^1.56.1",
"satellite.js": "^5.0.0",
"three": "^0.169.0"
}
}
+916
View File
@@ -0,0 +1,916 @@
import * as THREE from 'three';
import { mulberry32, clamp } from './timeline.js';
// City scenes in a local ENU frame (data in metres, scene units in km;
// three.js x = east, y = up, z = -north). One engine renders every location:
// Esri ground, OSM buildings that rise as the scan passes, road ribbons that
// carry modelled traffic coloured by congestion, cameras, aircraft, transit,
// airports, restricted areas and terrain.
const KM = 0.001;
async function getJSON(p) {
try {
const r = await fetch(p);
return r.ok ? await r.json() : null;
} catch {
return null;
}
}
async function getBin(p) {
try {
const r = await fetch(p);
return r.ok ? await r.arrayBuffer() : null;
} catch {
return null;
}
}
function loadTex(p) {
return new Promise((res) =>
new THREE.TextureLoader().load(
p,
(t) => {
t.colorSpace = THREE.SRGBColorSpace;
t.anisotropy = 8;
res(t);
},
undefined,
() => res(null),
),
);
}
export async function loadCity(slug) {
const base = `city/${slug}/`;
const [meta, buildings, roads, extra, cameras, air, transit, vehicles, aeroway, military, peaks, elevMeta, elev, ground] = await Promise.all([
getJSON(base + 'meta.json'),
getJSON(base + 'buildings.json'),
getJSON(base + 'roads.json'),
getJSON(base + 'extra.json'),
getJSON(base + 'cameras.json'),
getJSON(base + 'air.json'),
getJSON(base + 'transit.json'),
getJSON(base + 'vehicles.json'),
getJSON(base + 'aeroway.json'),
getJSON(base + 'military.json'),
getJSON(base + 'peaks.json'),
getJSON(base + 'elev_meta.json'),
getBin(base + 'elev.f32'),
loadTex(base + 'ground.jpg'),
]);
return { slug, meta, buildings, roads, extra, cameras, air, transit, vehicles, aeroway, military, peaks, elevMeta, elev: elev ? new Float32Array(elev) : null, ground };
}
// ---------------------------------------------------------------- shared shader bits
const HASH = /* glsl */ `
float h11(float n) { return fract(sin(n * 127.1) * 43758.5453); }
float h21(vec2 p) { return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453); }
`;
function congestionColor() {
return /* glsl */ `
vec3 congCol(float c) {
vec3 g = vec3(0.10, 1.00, 0.45);
vec3 y = vec3(1.00, 0.80, 0.10);
vec3 r = vec3(1.00, 0.16, 0.12);
return c < 0.5 ? mix(g, y, c * 2.0) : mix(y, r, (c - 0.5) * 2.0);
}
`;
}
// ---------------------------------------------------------------- ground
function makeGround(d, half, elevGeo) {
const size = 2 * half * KM;
const geo = elevGeo || new THREE.PlaneGeometry(size, size, 1, 1).rotateX(-Math.PI / 2);
const mat = new THREE.ShaderMaterial({
uniforms: {
uTex: { value: d.ground },
uReveal: { value: 0 },
uT: { value: 0 },
uHalf: { value: half * KM },
uNight: { value: 0.55 },
uTerrain: { value: elevGeo ? 1 : 0 },
uScan: { value: -10 },
uFade: { value: 1 },
},
vertexShader: /* glsl */ `
attribute float aElev;
varying vec2 vUv;
varying vec3 vP;
varying float vElev;
varying vec3 vN;
void main() {
vUv = uv;
vP = position;
vElev = aElev;
vN = normal;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`,
fragmentShader: /* glsl */ `
uniform sampler2D uTex;
uniform float uReveal, uT, uHalf, uNight, uTerrain, uScan, uFade;
varying vec2 vUv;
varying vec3 vP;
varying float vElev;
varying vec3 vN;
void main() {
vec3 img = texture2D(uTex, vUv).rgb;
float l = dot(img, vec3(0.3, 0.59, 0.11));
// night grade: cool, dim, contrasty — real pixels, but the city's lights do the talking
vec3 night = pow(mix(vec3(l), img, 0.35), vec3(1.7)) * vec3(0.5, 0.7, 0.95) * 0.56;
float r = length(vP.xz) / uHalf;
float rev = smoothstep(uReveal, uReveal - 0.08, r);
// tactical grid: 100 m, brighter every km
vec2 g = vP.xz * 1000.0;
vec2 fw = fwidth(g);
vec2 gl = abs(fract(g / 100.0 - 0.5) - 0.5) * 100.0 / fw;
float grid = 1.0 - smoothstep(0.0, 1.0, min(gl.x, gl.y));
vec2 gk = abs(fract(g / 1000.0 - 0.5) - 0.5) * 1000.0 / fw;
float gridK = 1.0 - smoothstep(0.0, 1.3, min(gk.x, gk.y));
vec3 col = night * rev;
col += vec3(0.0, 0.55, 0.7) * (grid * 0.05 + gridK * 0.14) * (0.4 + 0.6 * rev);
// the scan front
float ring = exp(-pow((r - uReveal) * 60.0, 2.0));
col += vec3(0.2, 0.9, 1.0) * ring * 0.6;
if (uTerrain > 0.5) {
// terrain: sunlit relief, 100 m contours, a LiDAR line sweeping across
vec3 L = normalize(vec3(-0.5, 0.7, 0.4));
float sh = clamp(dot(normalize(vN), L), 0.0, 1.0);
vec3 tg = pow(mix(vec3(l), img, 0.5), vec3(1.35));
col = tg * (0.08 + 0.62 * sh) * vec3(0.62, 0.78, 1.0) * rev;
float ce = vElev / 100.0;
float cw = fwidth(ce);
float cont = 1.0 - smoothstep(0.0, 1.2 * cw, abs(fract(ce - 0.5) - 0.5));
float major = 1.0 - smoothstep(0.0, 1.2 * fwidth(vElev / 500.0), abs(fract(vElev / 500.0 - 0.5) - 0.5));
col += vec3(0.1, 0.8, 1.0) * (cont * 0.16 + major * 0.5) * rev;
float sweep = exp(-pow((vP.x - uScan) * 18.0, 2.0));
col += vec3(0.4, 1.0, 1.0) * sweep * 0.9;
col += vec3(0.0, 0.6, 0.8) * smoothstep(uScan, uScan - 3.0, vP.x) * 0.03 * step(vP.x, uScan);
}
float edge = smoothstep(1.0, 0.82, max(abs(vP.x), abs(vP.z)) / uHalf);
gl_FragColor = vec4(col * edge * uFade, 1.0);
}
`,
});
// the ground never writes depth: it is the base layer, and road ribbons a
// metre above it would otherwise lose the depth test at city distances
if (!elevGeo) mat.depthWrite = false;
const mesh = new THREE.Mesh(geo, mat);
mesh.renderOrder = -1;
return mesh;
}
function makeTerrainGeo(d, half) {
const n = d.elevMeta?.size || 1024;
const seg = 384;
const size = 2 * half * KM;
const geo = new THREE.PlaneGeometry(size, size, seg, seg).rotateX(-Math.PI / 2);
const pos = geo.getAttribute('position');
const uv = geo.getAttribute('uv');
const el = new Float32Array(pos.count);
for (let i = 0; i < pos.count; i++) {
const u = uv.getX(i);
const v = uv.getY(i);
const px = Math.min(n - 1, Math.max(0, Math.round(u * (n - 1))));
const py = Math.min(n - 1, Math.max(0, Math.round((1 - v) * (n - 1))));
const h = d.elev[py * n + px];
el[i] = h;
pos.setY(i, h * KM);
}
geo.setAttribute('aElev', new THREE.BufferAttribute(el, 1));
geo.computeVertexNormals();
return geo;
}
// ---------------------------------------------------------------- buildings
function makeBuildings(list) {
const P = [];
const UV = [];
const A = []; // height m, edge length m, dist km, rand
const idx = [];
let vi = 0;
const rnd = mulberry32(5);
const rings = [];
for (const b of list) {
const h = Math.max(4, Math.min(560, b[0] || 10));
const pts = [];
for (let i = 2; i < b.length - 1; i += 2) pts.push([b[i], b[i + 1]]);
if (pts.length < 3) continue;
let cx = 0;
let cy = 0;
for (const p of pts) {
cx += p[0];
cy += p[1];
}
cx /= pts.length;
cy /= pts.length;
const dist = Math.hypot(cx, cy) * KM;
const r = rnd();
// walls
for (let i = 0; i < pts.length; i++) {
const [x1, y1] = pts[i];
const [x2, y2] = pts[(i + 1) % pts.length];
const L = Math.hypot(x2 - x1, y2 - y1);
if (L < 0.3) continue;
P.push(x1 * KM, 0, -y1 * KM, x2 * KM, 0, -y2 * KM, x2 * KM, h * KM, -y2 * KM, x1 * KM, h * KM, -y1 * KM);
UV.push(0, 0, 1, 0, 1, 1, 0, 1);
for (let k = 0; k < 4; k++) A.push(h, L, dist, r);
idx.push(vi, vi + 1, vi + 2, vi, vi + 2, vi + 3);
vi += 4;
}
// roof
const contour = pts.map((p) => new THREE.Vector2(p[0], p[1]));
let tris = [];
try {
tris = THREE.ShapeUtils.triangulateShape(contour, []);
} catch {
tris = [];
}
const base = vi;
for (const p of pts) {
P.push(p[0] * KM, h * KM, -p[1] * KM);
UV.push(-1, 1); // u < 0 marks roof
A.push(h, 0, dist, r);
vi++;
}
for (const t of tris) idx.push(base + t[0], base + t[2], base + t[1]);
rings.push({ pts, h, dist });
}
const geo = new THREE.BufferGeometry();
geo.setAttribute('position', new THREE.Float32BufferAttribute(P, 3));
geo.setAttribute('uv', new THREE.Float32BufferAttribute(UV, 2));
geo.setAttribute('aB', new THREE.Float32BufferAttribute(A, 4));
geo.setIndex(vi > 65535 ? new THREE.Uint32BufferAttribute(idx, 1) : new THREE.Uint16BufferAttribute(idx, 1));
const mat = new THREE.ShaderMaterial({
uniforms: { uReveal: { value: 0 }, uT: { value: 0 }, uAlpha: { value: 1 }, uTint: { value: new THREE.Color('#38e8ff') } },
vertexShader: /* glsl */ `
attribute vec4 aB;
uniform float uReveal;
varying vec2 vUv;
varying vec4 vB;
varying vec3 vW;
varying float vRise;
void main() {
vUv = uv;
vB = aB;
float k = clamp((uReveal - aB.z) / 0.35, 0.0, 1.0);
float rise = 1.0 - pow(1.0 - k, 3.0);
vRise = k;
vec3 p = position;
p.y *= rise;
vec4 wp = modelMatrix * vec4(p, 1.0);
vW = wp.xyz;
gl_Position = projectionMatrix * viewMatrix * wp;
}
`,
fragmentShader: /* glsl */ `
${HASH}
uniform float uReveal, uT, uAlpha;
uniform vec3 uTint;
varying vec2 vUv;
varying vec4 vB;
varying vec3 vW;
varying float vRise;
void main() {
if (vRise <= 0.0) discard;
float h = vB.x;
float L = vB.y;
bool roof = vUv.x < -0.5;
vec3 N = normalize(cross(dFdx(vW), dFdy(vW)));
float lit = 0.35 + 0.65 * abs(dot(N, normalize(vec3(0.4, 0.8, 0.3))));
float tall = smoothstep(30.0, 250.0, h);
vec3 base = mix(vec3(0.010, 0.016, 0.026), vec3(0.016, 0.032, 0.05), tall) * lit;
vec3 col = base;
float fresh = exp(-max(uReveal - vB.z, 0.0) * 5.0);
if (roof) {
col = base * 1.3 + uTint * 0.02;
} else {
// floors and windows
float fy = vUv.y * h / 3.4;
float fx = vUv.x * L / 3.2;
vec2 cell = floor(vec2(fx, fy));
vec2 f = fract(vec2(fx, fy));
float win = step(0.25, f.x) * step(f.x, 0.8) * step(0.3, f.y) * step(f.y, 0.75);
float on = step(0.9, h21(cell + vB.w * 91.0));
float warm = h21(cell * 1.7 + 3.0);
vec3 wc = mix(vec3(1.0, 0.75, 0.45), vec3(0.6, 0.9, 1.0), step(0.7, warm));
float flick = 0.85 + 0.15 * sin(uT * 2.0 + h21(cell) * 40.0);
col += wc * win * on * 0.32 * flick * smoothstep(0.0, 0.1, vRise);
// edges: crown line, vertical corners, base
float wy = fwidth(vUv.y);
float wx = fwidth(vUv.x);
float crown = 1.0 - smoothstep(0.0, wy * 1.5, 1.0 - vUv.y);
float corner = 1.0 - smoothstep(0.0, wx * 1.5, min(vUv.x, 1.0 - vUv.x));
col += uTint * (crown * (0.08 + tall * 0.9) + corner * (0.03 + tall * 0.12)) * (0.6 + fresh * 2.5);
// the scan passing up the facade as it rises
col += uTint * fresh * 0.25 * smoothstep(0.2, 1.0, vUv.y);
}
gl_FragColor = vec4(col * uAlpha, 1.0);
}
`,
});
const mesh = new THREE.Mesh(geo, mat);
mesh.frustumCulled = false;
return { mesh, count: rings.length, rings };
}
// ---------------------------------------------------------------- roads + traffic
// Congestion is modelled, not measured: road class, distance to the core, and
// a slow per-road noise. Labelled MODELED in the HUD.
function makeRoads(list, half, { tint = null, inside = null, scale = 1 } = {}) {
const W = [34, 24, 20, 16, 11, 7];
const P = [];
const A = []; // arc m, side, cong, oneway
const B = []; // class, rnd, width
const idx = [];
let vi = 0;
const rnd = mulberry32(17);
let segs = 0;
for (const r of list) {
const cls = r[0];
const one = r[1] ? 1 : 0;
const pts = [];
for (let i = 3; i < r.length - 1; i += 2) pts.push([r[i], r[i + 1]]);
if (pts.length < 2) continue;
if (inside && !inside(pts[0][0], pts[0][1])) continue;
const w = W[cls] ?? 6;
const cx = pts[Math.floor(pts.length / 2)];
const core = Math.exp(-Math.hypot(cx[0], cx[1]) / (half * 0.55));
const base = [0.55, 0.5, 0.42, 0.35, 0.22, 0.3][cls] ?? 0.3;
const rr = rnd();
const cong = clamp(base * 0.55 + core * 0.55 + (rr - 0.5) * 0.95, 0, 1);
let arc = 0;
for (let i = 0; i < pts.length - 1; i++) {
const [x1, y1] = pts[i];
const [x2, y2] = pts[i + 1];
const L = Math.hypot(x2 - x1, y2 - y1);
if (L < 0.5) continue;
const nx = -(y2 - y1) / L;
const ny = (x2 - x1) / L;
const hw = w / 2;
const y = 0.6 + cls * 0.02;
const v = [
[x1 - nx * hw, y1 - ny * hw, -1, arc],
[x1 + nx * hw, y1 + ny * hw, 1, arc],
[x2 + nx * hw, y2 + ny * hw, 1, arc + L],
[x2 - nx * hw, y2 - ny * hw, -1, arc + L],
];
for (const [x, yy, s, a] of v) {
P.push(x * KM, y * KM, -yy * KM);
A.push(a, s, cong, one);
B.push(cls, rr, w);
}
idx.push(vi, vi + 1, vi + 2, vi, vi + 2, vi + 3);
vi += 4;
arc += L;
segs++;
}
}
const geo = new THREE.BufferGeometry();
geo.setAttribute('position', new THREE.Float32BufferAttribute(P, 3));
geo.setAttribute('aA', new THREE.Float32BufferAttribute(A, 4));
geo.setAttribute('aC', new THREE.Float32BufferAttribute(B, 3));
geo.setIndex(new THREE.Uint32BufferAttribute(idx, 1));
const mat = new THREE.ShaderMaterial({
transparent: true,
side: THREE.DoubleSide,
depthWrite: false,
blending: THREE.AdditiveBlending,
uniforms: {
uT: { value: 0 },
uReveal: { value: 0 },
uHalf: { value: half * KM },
uAlpha: { value: 1 },
uTint: { value: tint ? new THREE.Color(tint) : new THREE.Color(0, 0, 0) },
uUseTint: { value: tint ? 1 : 0 },
uScale: { value: scale },
},
vertexShader: /* glsl */ `
attribute vec4 aA;
attribute vec3 aC;
varying vec4 vA;
varying vec3 vC;
varying float vR;
void main() {
vA = aA;
vC = aC;
vR = length(position.xz);
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`,
fragmentShader: /* glsl */ `
${HASH}
${congestionColor()}
uniform float uT, uReveal, uHalf, uAlpha, uUseTint, uScale;
uniform vec3 uTint;
varying vec4 vA;
varying vec3 vC;
varying float vR;
void main() {
float r = vR / uHalf;
float k = uReveal - r;
if (k < 0.0) discard;
float cong = vA.z;
float side = vA.y;
float dir = vA.w > 0.5 ? 1.0 : (side > 0.0 ? 1.0 : -1.0);
vec3 cc = mix(congCol(cong), uTint, uUseTint);
// ribbon cross-profile: soft edges, dark median
float a = abs(side);
float body = smoothstep(1.0, 0.7, a);
// cars: dashes flowing along each lane, slower and denser when jammed
float speed = mix(16.0, 2.5, cong) * 7.0 * uScale;
float spacing = mix(70.0, 14.0, cong);
float lane = vA.w > 0.5 ? 1.0 : smoothstep(0.05, 0.3, a) * smoothstep(0.98, 0.7, a);
float ph = (vA.x - dir * uT * speed) / spacing + vC.y * 13.0 + (side > 0.0 ? 0.37 : 0.0);
float fp = fract(ph);
float car = smoothstep(0.0, 0.04, fp) * (1.0 - smoothstep(0.08, 0.22, fp));
float present = step(0.25 + 0.5 * (1.0 - cong), h11(floor(ph) + vC.y * 77.0));
float head = car * present * lane;
vec3 carCol = dir > 0.0 ? vec3(1.0, 0.95, 0.85) : vec3(1.0, 0.25, 0.2);
vec3 col = cc * body * (0.2 + 0.12 * cong) + mix(cc, carCol, 0.22) * head * 1.25;
float fresh = exp(-k * 25.0);
col += cc * fresh * 0.8 * body;
gl_FragColor = vec4(col * uAlpha, 1.0);
}
`,
});
const mesh = new THREE.Mesh(geo, mat);
mesh.frustumCulled = false;
return { mesh, segs };
}
// ---------------------------------------------------------------- lines (generic, glowing)
function makePolylines(lines, { color = '#38e8ff', y = 1, width = 0, alpha = 1, colors = null } = {}) {
const P = [];
const C = [];
const A = [];
for (let li = 0; li < lines.length; li++) {
const l = lines[li];
const col = new THREE.Color(colors ? colors[li] : color);
let arc = 0;
for (let i = 0; i < l.length - 1; i++) {
const [x1, y1, z1 = y] = l[i];
const [x2, y2, z2 = y] = l[i + 1];
const L = Math.hypot(x2 - x1, y2 - y1);
P.push(x1 * KM, z1 * KM, -y1 * KM, x2 * KM, z2 * KM, -y2 * KM);
C.push(col.r, col.g, col.b, col.r, col.g, col.b);
A.push(arc, arc + L);
arc += L;
}
}
const geo = new THREE.BufferGeometry();
geo.setAttribute('position', new THREE.Float32BufferAttribute(P, 3));
geo.setAttribute('color', new THREE.Float32BufferAttribute(C, 3));
geo.setAttribute('aArc', new THREE.Float32BufferAttribute(A, 1));
const mat = new THREE.ShaderMaterial({
transparent: true,
depthWrite: false,
blending: THREE.AdditiveBlending,
vertexColors: true,
uniforms: { uT: { value: 0 }, uAlpha: { value: alpha }, uDraw: { value: 1e9 }, uPulse: { value: 0 } },
vertexShader: /* glsl */ `
attribute float aArc;
varying float vArc;
varying vec3 vCol;
void main() { vArc = aArc; vCol = color; gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); }
`,
fragmentShader: /* glsl */ `
uniform float uT, uAlpha, uDraw, uPulse;
varying float vArc;
varying vec3 vCol;
void main() {
if (vArc > uDraw) discard;
float p = uPulse > 0.0 ? pow(fract(vArc / 900.0 - uT * 0.8), 8.0) * 2.0 : 0.0;
gl_FragColor = vec4(vCol * (0.7 + p) * uAlpha, 1.0);
}
`,
});
return new THREE.LineSegments(geo, mat);
}
// glowing point sprites updated from the CPU
function makePoints(n, { size = 6, color = '#ffffff' } = {}) {
const geo = new THREE.BufferGeometry();
const pos = new Float32Array(n * 3);
const col = new Float32Array(n * 3);
const sz = new Float32Array(n).fill(1);
const c = new THREE.Color(color);
for (let i = 0; i < n; i++) col.set([c.r, c.g, c.b], i * 3);
geo.setAttribute('position', new THREE.BufferAttribute(pos, 3));
geo.setAttribute('color', new THREE.BufferAttribute(col, 3));
geo.setAttribute('aS', new THREE.BufferAttribute(sz, 1));
const mat = new THREE.ShaderMaterial({
transparent: true,
depthWrite: false,
depthTest: false,
blending: THREE.AdditiveBlending,
vertexColors: true,
uniforms: { uSize: { value: size }, uAlpha: { value: 1 }, uPx: { value: 1 } },
vertexShader: /* glsl */ `
attribute float aS;
uniform float uSize, uPx;
varying vec3 vCol;
void main() { vCol = color; gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); gl_PointSize = uSize * aS * uPx; }
`,
fragmentShader: /* glsl */ `
uniform float uAlpha;
varying vec3 vCol;
void main() {
float r = length(gl_PointCoord - 0.5) * 2.0;
float a = smoothstep(1.0, 0.0, r);
float core = smoothstep(0.35, 0.0, r);
gl_FragColor = vec4((vCol * a * 0.8 + vec3(1.0) * core) * uAlpha, 1.0);
}
`,
});
const pts = new THREE.Points(geo, mat);
pts.frustumCulled = false;
return { pts, pos, col, sz, geo };
}
// ---------------------------------------------------------------- the engine
export function buildCity(d, opts = {}) {
const half = d.meta?.half || 3500;
const scene = new THREE.Scene();
const group = new THREE.Group();
scene.add(group);
const out = { scene, d, half, labels: [], stats: {} };
const terrainGeo = d.elev ? makeTerrainGeo(d, half) : null;
const ground = makeGround(d, half, terrainGeo);
group.add(ground);
out.ground = ground;
if (d.elev) {
out.elevAt = (x, y) => {
const n = d.elevMeta?.size || 1024;
const px = Math.round(((x + half) / (2 * half)) * (n - 1));
const py = Math.round(((half - y) / (2 * half)) * (n - 1));
return d.elev[clamp(py, 0, n - 1) * n + clamp(px, 0, n - 1)] || 0;
};
}
if (d.buildings?.b?.length) {
const bl = makeBuildings(d.buildings.b);
group.add(bl.mesh);
out.buildings = bl;
out.stats.buildings = bl.count;
}
// military polygons: roads inside get the amber treatment
let insideMil = null;
if (d.military?.military?.length) {
const polys = d.military.military.map((m) => {
const pts = [];
for (let i = 1; i < m.length - 1; i += 2) pts.push([m[i], m[i + 1]]);
return { name: m[0], pts };
}).filter((p) => p.pts.length > 2 && p.pts.every(([x, y]) => Math.abs(x) < half * 1.35 && Math.abs(y) < half * 1.35));
// the reservation itself is bigger than the scene: keep what fits
out.milPolys = polys;
insideMil = (x, y) => polys.some((p) => pip(x, y, p.pts));
}
if (d.roads?.roads?.length) {
const rd = makeRoads(d.roads.roads, half);
group.add(rd.mesh);
out.roads = rd;
out.stats.roads = rd.segs;
if (insideMil) {
const conv = makeRoads(d.roads.roads, half, { tint: '#ffb547', inside: insideMil, scale: 0.6 });
conv.mesh.material.uniforms.uAlpha.value = 0.9;
group.add(conv.mesh);
out.convoy = conv;
}
}
// restricted areas: translucent curtains with scanning bands
if (out.milPolys) {
const P = [];
const U = [];
for (const p of out.milPolys) {
let arc = 0;
for (let i = 0; i < p.pts.length; i++) {
const [x1, y1] = p.pts[i];
const [x2, y2] = p.pts[(i + 1) % p.pts.length];
const L = Math.hypot(x2 - x1, y2 - y1);
const H = 0.35;
P.push(x1 * KM, 0, -y1 * KM, x2 * KM, 0, -y2 * KM, x2 * KM, H, -y2 * KM, x1 * KM, 0, -y1 * KM, x2 * KM, H, -y2 * KM, x1 * KM, H, -y1 * KM);
U.push(arc, 0, arc + L, 0, arc + L, 1, arc, 0, arc + L, 1, arc, 1);
arc += L;
}
}
const g = new THREE.BufferGeometry();
g.setAttribute('position', new THREE.Float32BufferAttribute(P, 3));
g.setAttribute('uv', new THREE.Float32BufferAttribute(U, 2));
const m = new THREE.ShaderMaterial({
transparent: true,
depthWrite: false,
side: THREE.DoubleSide,
blending: THREE.AdditiveBlending,
uniforms: { uT: { value: 0 }, uAlpha: { value: 0 } },
vertexShader: `varying vec2 vUv; void main(){ vUv = uv; gl_Position = projectionMatrix * modelViewMatrix * vec4(position,1.0); }`,
fragmentShader: /* glsl */ `
uniform float uT, uAlpha;
varying vec2 vUv;
void main() {
float band = pow(fract(vUv.y * 6.0 - uT * 0.6), 6.0);
float top = 1.0 - smoothstep(0.0, 0.04, 1.0 - vUv.y);
float fade = 1.0 - vUv.y;
float hatch = step(0.5, fract((vUv.x / 60.0 + vUv.y * 4.0)));
vec3 c = vec3(1.0, 0.62, 0.18) * (fade * 0.10 + band * 0.25 + top * 0.8 + hatch * fade * 0.03);
gl_FragColor = vec4(c * uAlpha, 1.0);
}
`,
});
out.curtain = new THREE.Mesh(g, m);
out.curtain.frustumCulled = false;
group.add(out.curtain);
}
// airport: runways, approach strobes, taxiways
const aw = d.aeroway || d.military;
if (aw?.runways?.length) {
const P = [];
const U = [];
const strobes = [];
for (const rw of aw.runways) {
const w = Math.max(30, rw[0] || 45);
const pts = [];
for (let i = 2; i < rw.length - 1; i += 2) pts.push([rw[i], rw[i + 1]]);
if (pts.length < 2) continue;
const [x1, y1] = pts[0];
const [x2, y2] = pts[pts.length - 1];
const L = Math.hypot(x2 - x1, y2 - y1);
const ux = (x2 - x1) / L;
const uy = (y2 - y1) / L;
const nx = -uy * w / 2;
const ny = ux * w / 2;
const q = [[x1 - nx, y1 - ny, 0, 0], [x1 + nx, y1 + ny, 1, 0], [x2 + nx, y2 + ny, 1, L], [x1 - nx, y1 - ny, 0, 0], [x2 + nx, y2 + ny, 1, L], [x2 - nx, y2 - ny, 0, L]];
for (const [x, y, s, a] of q) {
P.push(x * KM, 0.0012, -y * KM);
U.push(s, a);
}
// approach lighting: 900 m of strobes off each threshold
for (const [sx, sy, dx, dy] of [[x1, y1, -ux, -uy], [x2, y2, ux, uy]]) {
for (let k = 1; k <= 30; k++) strobes.push([sx + dx * k * 30, sy + dy * k * 30, k]);
}
out.labels.push({ kind: 'runway', x: (x1 + x2) / 2, y: (y1 + y2) / 2, text: `RWY ${rw[1] || ''}`.trim() });
}
const g = new THREE.BufferGeometry();
g.setAttribute('position', new THREE.Float32BufferAttribute(P, 3));
g.setAttribute('uv', new THREE.Float32BufferAttribute(U, 2));
const m = new THREE.ShaderMaterial({
transparent: true,
side: THREE.DoubleSide,
depthWrite: false,
blending: THREE.AdditiveBlending,
uniforms: { uT: { value: 0 }, uAlpha: { value: 1 } },
vertexShader: `varying vec2 vUv; void main(){ vUv = uv; gl_Position = projectionMatrix * modelViewMatrix * vec4(position,1.0); }`,
fragmentShader: /* glsl */ `
uniform float uT, uAlpha;
varying vec2 vUv;
void main() {
float edge = 1.0 - smoothstep(0.0, 0.06, min(vUv.x, 1.0 - vUv.x));
float cl = (1.0 - smoothstep(0.0, 0.03, abs(vUv.x - 0.5))) * step(0.5, fract(vUv.y / 60.0));
vec3 c = vec3(0.9, 0.95, 1.0) * (edge * 0.9 + cl * 0.7) + vec3(0.05, 0.08, 0.1);
gl_FragColor = vec4(c * uAlpha, 1.0);
}
`,
});
out.runways = new THREE.Mesh(g, m);
out.runways.frustumCulled = false;
group.add(out.runways);
out.strobes = makePoints(strobes.length, { size: 5, color: '#ffffff' });
strobes.forEach((s, i) => out.strobes.pos.set([s[0] * KM, 0.004, -s[1] * KM], i * 3));
out.strobeK = strobes.map((s) => s[2]);
out.strobes.geo.getAttribute('position').needsUpdate = true;
group.add(out.strobes.pts);
}
if (aw?.taxiways?.length) {
const lines = aw.taxiways.map((t) => {
const pts = [];
for (let i = 0; i < t.length - 1; i += 2) pts.push([t[i], t[i + 1], 1.2]);
return pts;
});
out.taxi = makePolylines(lines, { color: '#ffc04a', alpha: 0.75 });
group.add(out.taxi);
}
if (aw?.aprons?.length) {
const lines = aw.aprons.map((t) => {
const pts = [];
for (let i = 0; i < t.length - 1; i += 2) pts.push([t[i], t[i + 1], 1]);
if (pts.length) pts.push(pts[0]);
return pts;
});
out.aprons = makePolylines(lines, { color: '#2fd0ff', alpha: 0.4 });
group.add(out.aprons);
}
// transit: route-coloured lines with pulses, vehicles riding them
if (d.transit?.shapes?.length) {
const lines = [];
const colors = [];
out.shapes = [];
for (const s of d.transit.shapes) {
const rid = s[0];
const route = d.transit.routes[rid] || {};
const pts = [];
for (let i = 1; i < s.length - 1; i += 2) pts.push([s[i], s[i + 1], route.type === 3 ? 2 : 6]);
const col = route.color ? (route.color.startsWith('#') ? route.color : '#' + route.color) : '#38e8ff';
// subway and rail glow brighter than buses
lines.push(pts);
colors.push(route.type === 3 ? new THREE.Color(col).multiplyScalar(0.45).getStyle() : col);
out.shapes.push({ rid, pts, type: route.type });
}
out.transitLines = makePolylines(lines, { colors });
out.transitLines.material.uniforms.uPulse.value = 1;
group.add(out.transitLines);
out.stats.routes = Object.keys(d.transit.routes).length;
}
if (d.vehicles?.v?.length) {
const vs = d.vehicles.v;
out.veh = makePoints(vs.length, { size: 9 });
vs.forEach((v, i) => {
const r = d.transit?.routes?.[v[4]];
const c = new THREE.Color(r?.color ? (r.color.startsWith('#') ? r.color : '#' + r.color) : '#ffffff');
out.veh.col.set([c.r, c.g, c.b], i * 3);
out.veh.sz[i] = r?.type === 3 ? 0.65 : 1.2;
});
out.veh.geo.getAttribute('color').needsUpdate = true;
out.veh.geo.getAttribute('aS').needsUpdate = true;
group.add(out.veh.pts);
out.stats.vehicles = vs.length;
}
// street cameras: beams + base rings
if (d.cameras?.length) {
const lines = d.cameras.map((c) => [[c[0], c[1], 0], [c[0], c[1], 90]]);
out.camBeams = makePolylines(lines, { color: '#eafcff', alpha: 0.5 });
group.add(out.camBeams);
out.camPts = makePoints(d.cameras.length, { size: 7, color: '#bff6ff' });
d.cameras.forEach((c, i) => out.camPts.pos.set([c[0] * KM, 0.09, -c[1] * KM], i * 3));
out.camPts.geo.getAttribute('position').needsUpdate = true;
group.add(out.camPts.pts);
out.stats.cameras = d.cameras.length;
}
// aircraft (real ADS-B): glyphs + real track history
if (d.air?.a?.length) {
const a = d.air.a;
out.airPts = makePoints(a.length + 8, { size: 8, color: '#ffffff' });
a.forEach((p, i) => {
const heli = p[8] === 'A7' || /^(H|EC|AS|S7|S9|B06|B407|B429|A109|A119|A139|R44|R66|EC|H1|H6)/.test(p[7] || '');
const mil = /^(RCH|CNV|PAT|EVAC|TOPCT|HKY|SPAR|SAM)/.test(p[6] || '');
const c = new THREE.Color(heli ? '#7dffd6' : mil ? '#ffb547' : '#ffffff');
out.airPts.col.set([c.r, c.g, c.b], i * 3);
p.heli = heli;
});
out.airPts.geo.getAttribute('color').needsUpdate = true;
group.add(out.airPts.pts);
const tr = d.air.tracks || {};
const tl = Object.values(tr).filter((t) => t.length > 1).map((t) => t.map((q) => [q[0], q[1], q[2] * 1.5]));
if (tl.length) {
out.airTracks = makePolylines(tl.map((t) => t.filter((q) => Math.abs(q[0]) < half * 1.2 && Math.abs(q[1]) < half * 1.2)).filter((t) => t.length > 1), { color: '#9fe8ff', alpha: 0.3 });
group.add(out.airTracks);
}
out.stats.aircraft = a.length;
out.stats.helis = a.filter((p) => p.heli).length;
}
// modelled extras (labelled MODELED): helicopter tours, circuits
out.modeled = [];
if (opts.modeled) for (const m of opts.modeled) out.modeled.push(m);
if (out.modeled.length) {
out.modPts = makePoints(out.modeled.length, { size: 9, color: '#7dffd6' });
out.modeled.forEach((m, i) => {
const c = new THREE.Color(m.color || '#7dffd6');
out.modPts.col.set([c.r, c.g, c.b], i * 3);
});
out.modPts.geo.getAttribute('color').needsUpdate = true;
group.add(out.modPts.pts);
out.modTrails = makePolylines(out.modeled.map((m) => m.path.concat([m.path[0]])), { color: '#7dffd6', alpha: 0.25 });
out.modTrails.material.vertexColors = true;
group.add(out.modTrails);
}
// ---------------------------------------------------------------- per-frame
out.update = (lt, k, pxScale = 1) => {
// lt: seconds into this scene; k: 0..1 progress
const reveal = Math.min(1.6, 0.22 + lt * 1.05);
ground.material.uniforms.uReveal.value = reveal * 1.3;
ground.material.uniforms.uT.value = lt;
if (d.elev) ground.material.uniforms.uScan.value = -half * KM + ((lt * 0.35) % 1.2) * 2.4 * half * KM;
if (out.buildings) {
out.buildings.mesh.material.uniforms.uReveal.value = reveal * half * KM * 0.9;
out.buildings.mesh.material.uniforms.uT.value = lt;
}
if (out.roads) {
out.roads.mesh.material.uniforms.uReveal.value = reveal * 1.1;
out.roads.mesh.material.uniforms.uT.value = lt;
}
if (out.convoy) {
out.convoy.mesh.material.uniforms.uReveal.value = reveal * 1.1;
out.convoy.mesh.material.uniforms.uT.value = lt;
}
if (out.curtain) {
out.curtain.material.uniforms.uT.value = lt;
out.curtain.material.uniforms.uAlpha.value = clamp((lt - 0.6) * 1.5);
}
if (out.runways) out.runways.material.uniforms.uAlpha.value = clamp(lt * 1.2);
if (out.strobes) {
// the "rabbit": a pulse running in toward each threshold, twice a second
const ph = (lt * 2) % 1;
out.strobeK.forEach((kk, i) => (out.strobes.sz[i] = Math.abs((1 - kk / 30) - ph) < 0.05 ? 2.2 : 0.35));
out.strobes.geo.getAttribute('aS').needsUpdate = true;
out.strobes.pts.material.uniforms.uPx.value = pxScale;
}
if (out.transitLines) {
out.transitLines.material.uniforms.uT.value = lt;
out.transitLines.material.uniforms.uDraw.value = lt * 9000;
}
if (out.veh) {
const vs = d.vehicles.v;
for (let i = 0; i < vs.length; i++) {
const v = vs[i];
// dead-reckon along the reported bearing at the reported speed, sped up
const sp = Math.max(4, v[3] || 8) * 6;
const b = ((v[2] || 0) * Math.PI) / 180;
const x = v[0] + Math.sin(b) * sp * lt;
const y = v[1] + Math.cos(b) * sp * lt;
out.veh.pos.set([x * KM, 0.012, -y * KM], i * 3);
}
out.veh.geo.getAttribute('position').needsUpdate = true;
out.veh.pts.material.uniforms.uAlpha.value = clamp((lt - 0.8) * 2);
out.veh.pts.material.uniforms.uPx.value = pxScale;
}
if (out.camPts) {
out.camPts.pts.material.uniforms.uAlpha.value = clamp((lt - 0.9) * 2);
out.camBeams.material.uniforms.uAlpha.value = clamp((lt - 0.9) * 2) * 0.45;
out.camPts.pts.material.uniforms.uPx.value = pxScale;
}
if (out.airPts) {
const a = d.air.a;
for (let i = 0; i < a.length; i++) {
const p = a[i];
const b = ((p[3] || 0) * Math.PI) / 180;
const sp = (p[4] || 0) * 3;
const x = p[0] + Math.sin(b) * sp * lt;
const y = p[1] + Math.cos(b) * sp * lt;
const z = Math.max(0, (p[2] || 0) + (p[5] || 0) * 3 * lt) * 1.5;
out.airPts.pos.set([x * KM, z * KM + 0.004, -y * KM], i * 3);
p.now = [x, y, z];
}
out.airPts.geo.getAttribute('position').needsUpdate = true;
out.airPts.pts.material.uniforms.uAlpha.value = clamp((lt - 0.5) * 2);
out.airPts.pts.material.uniforms.uPx.value = pxScale;
if (out.airTracks) out.airTracks.material.uniforms.uAlpha.value = clamp((lt - 0.5) * 2) * 0.4;
}
if (out.modPts) {
out.modeled.forEach((m, i) => {
const p = pathAt(m.path, (lt * m.speed + m.phase) % 1);
out.modPts.pos.set([p[0] * KM, p[2] * KM, -p[1] * KM], i * 3);
m.now = p;
});
out.modPts.geo.getAttribute('position').needsUpdate = true;
out.modPts.pts.material.uniforms.uAlpha.value = clamp((lt - 0.7) * 2);
out.modPts.pts.material.uniforms.uPx.value = pxScale;
out.modTrails.material.uniforms.uAlpha.value = clamp((lt - 0.7) * 2) * 0.22;
}
if (out.taxi) out.taxi.material.uniforms.uAlpha.value = clamp((lt - 0.4) * 2) * 0.7;
if (out.aprons) out.aprons.material.uniforms.uAlpha.value = clamp((lt - 0.4) * 2) * 0.35;
};
return out;
}
function pathAt(path, f) {
const n = path.length;
const x = f * n;
const i = Math.floor(x) % n;
const j = (i + 1) % n;
const u = x - Math.floor(x);
const a = path[i];
const b = path[j];
return [a[0] + (b[0] - a[0]) * u, a[1] + (b[1] - a[1]) * u, (a[2] ?? 0) + ((b[2] ?? 0) - (a[2] ?? 0)) * u];
}
function pip(x, y, pts) {
let c = false;
for (let i = 0, j = pts.length - 1; i < pts.length; j = i++) {
const [xi, yi] = pts[i];
const [xj, yj] = pts[j];
if (yi > y !== yj > y && x < ((xj - xi) * (y - yi)) / (yj - yi) + xi) c = !c;
}
return c;
}
// An ellipse/loop path in metres, for modelled helicopter tours and circuits.
export function loopPath(cx, cy, rx, ry, alt, rot = 0, n = 48) {
const out = [];
for (let i = 0; i < n; i++) {
const a = (i / n) * Math.PI * 2;
const x = Math.cos(a) * rx;
const y = Math.sin(a) * ry;
out.push([cx + x * Math.cos(rot) - y * Math.sin(rot), cy + x * Math.sin(rot) + y * Math.cos(rot), alt]);
}
return out;
}
+72
View File
@@ -0,0 +1,72 @@
// Geodesy for the film. Convention (matches the globe shader):
// x = cos(lat) cos(lon), y = sin(lat), z = -cos(lat) sin(lon) (Y up, radius 1)
export const D2R = Math.PI / 180;
export const R2D = 180 / Math.PI;
export const EARTH_KM = 6371;
export function llToVec(lon, lat, r = 1, out = [0, 0, 0]) {
const la = lat * D2R;
const lo = lon * D2R;
const c = Math.cos(la);
out[0] = r * c * Math.cos(lo);
out[1] = r * Math.sin(la);
out[2] = -r * c * Math.sin(lo);
return out;
}
export function vecToLL(x, y, z) {
const r = Math.hypot(x, y, z);
return [Math.atan2(-z, x) * R2D, Math.asin(y / r) * R2D];
}
// Central angle in radians between two lon/lat points.
export function angDist(lon1, lat1, lon2, lat2) {
const p1 = lat1 * D2R;
const p2 = lat2 * D2R;
const dp = p2 - p1;
const dl = (lon2 - lon1) * D2R;
const a = Math.sin(dp / 2) ** 2 + Math.cos(p1) * Math.cos(p2) * Math.sin(dl / 2) ** 2;
return 2 * Math.asin(Math.min(1, Math.sqrt(a)));
}
// Destination point given start, bearing (deg) and central angle (rad).
export function destination(lon, lat, bearingDeg, ang) {
const p1 = lat * D2R;
const l1 = lon * D2R;
const b = bearingDeg * D2R;
const p2 = Math.asin(Math.sin(p1) * Math.cos(ang) + Math.cos(p1) * Math.sin(ang) * Math.cos(b));
const l2 = l1 + Math.atan2(Math.sin(b) * Math.sin(ang) * Math.cos(p1), Math.cos(ang) - Math.sin(p1) * Math.sin(p2));
return [((l2 * R2D + 540) % 360) - 180, p2 * R2D];
}
export function bearing(lon1, lat1, lon2, lat2) {
const p1 = lat1 * D2R;
const p2 = lat2 * D2R;
const dl = (lon2 - lon1) * D2R;
const y = Math.sin(dl) * Math.cos(p2);
const x = Math.cos(p1) * Math.sin(p2) - Math.sin(p1) * Math.cos(p2) * Math.cos(dl);
return (Math.atan2(y, x) * R2D + 360) % 360;
}
// Subsolar point (lon, lat) for a JS Date — NOAA low-precision solar position.
export function subsolar(date) {
const jd = date.getTime() / 86400000 + 2440587.5;
const n = jd - 2451545.0;
const L = (280.46 + 0.9856474 * n) % 360;
const g = ((357.528 + 0.9856003 * n) % 360) * D2R;
const lambda = (L + 1.915 * Math.sin(g) + 0.02 * Math.sin(2 * g)) * D2R;
const eps = (23.439 - 0.0000004 * n) * D2R;
const decl = Math.asin(Math.sin(eps) * Math.sin(lambda));
const ra = Math.atan2(Math.cos(eps) * Math.sin(lambda), Math.cos(lambda));
const gmst = (280.46061837 + 360.98564736629 * n) % 360;
let lon = ra * R2D - gmst;
lon = ((lon + 540) % 360) - 180;
return [lon, decl * R2D];
}
export function fmtLat(lat, dp = 4) {
return `${Math.abs(lat).toFixed(dp)}°${lat >= 0 ? 'N' : 'S'}`;
}
export function fmtLon(lon, dp = 4) {
return `${Math.abs(lon).toFixed(dp)}°${lon >= 0 ? 'E' : 'W'}`;
}
+296
View File
@@ -0,0 +1,296 @@
import * as THREE from 'three';
// The planet: one shader that walks the surface through every state the film
// needs — dark substrate, the growth front lighting up night cities, quadtree
// tiles indexing in with rising resolution, then the lit, living Earth.
const COMMON = /* glsl */ `
#define PI 3.141592653589793
float hash3(vec3 p) {
p = fract(p * 0.3183099 + vec3(0.71, 0.113, 0.419));
p *= 17.0;
return fract(p.x * p.y * p.z * (p.x + p.y + p.z));
}
vec3 llDir(float lon, float lat) {
float c = cos(lat);
return vec3(c * cos(lon), sin(lat), -c * sin(lon));
}
// u wraps 1 -> 0 at the antimeridian; take derivatives from whichever of u or
// u+0.5 is continuous here so mip selection doesn't spike into a seam line
void seamGrad(vec2 uv, out vec2 gx, out vec2 gy) {
vec2 a = uv;
vec2 b = vec2(fract(uv.x + 0.5), uv.y);
vec2 ax = dFdx(a), ay = dFdy(a), bx = dFdx(b), by = dFdy(b);
gx = vec2(abs(ax.x) < abs(bx.x) ? ax.x : bx.x, ax.y);
gy = vec2(abs(ay.x) < abs(by.x) ? ay.x : by.x, ay.y);
}
`;
export function createGlobe({ dayTex, nightTex, cloudTex }) {
const uniforms = {
uDay: { value: dayTex },
uNight: { value: nightTex },
uTime: { value: 0 },
uSeed: { value: new THREE.Vector3(1, 0, 0) },
uSun: { value: new THREE.Vector3(1, 0, 0) },
uFront: { value: -1 }, // growth front, radians from the seed
uHolo: { value: 0 }, // substrate grid visibility
uLights: { value: 0 }, // night-light "neurons" during growth
uWave: { value: new THREE.Vector3(1e3, 1e3, 1e3) },
uWaveSpread: { value: 2.6 },
uImagery: { value: 0 }, // forces imagery fully on (after indexing)
uLit: { value: 0 }, // 0 = flat imagery, 1 = real sun lighting
uTexel: { value: new THREE.Vector2(1 / 8192, 1 / 4096) },
uGridAmt: { value: 1 },
uTint: { value: 0 }, // cyan analysis tint on the whole surface
uDim: { value: 1 },
};
const mat = new THREE.ShaderMaterial({
uniforms,
vertexShader: /* glsl */ `
varying vec3 vObj;
varying vec3 vWorldN;
varying vec3 vView;
void main() {
vObj = position;
vec4 wp = modelMatrix * vec4(position, 1.0);
vWorldN = normalize(mat3(modelMatrix) * position);
vView = normalize(cameraPosition - wp.xyz);
gl_Position = projectionMatrix * viewMatrix * wp;
}
`,
fragmentShader: /* glsl */ `
${COMMON}
uniform sampler2D uDay, uNight;
uniform float uTime, uFront, uHolo, uLights, uWaveSpread, uImagery, uLit, uGridAmt, uTint, uDim;
uniform vec3 uSeed, uSun, uWave;
uniform vec2 uTexel;
varying vec3 vObj;
varying vec3 vWorldN;
varying vec3 vView;
float mercY(float lat) {
lat = clamp(lat, -1.4844, 1.4844);
return 0.5 - log(tan(PI * 0.25 + lat * 0.5)) / (2.0 * PI);
}
float invMercY(float my) {
return atan(sinh(PI * (1.0 - 2.0 * my)));
}
// Per-level quadtree state at this fragment.
// returns x = phase since this tile revealed, y = edge line (0..1)
vec2 tileState(float z, float waveT, vec2 uv, float my, float jitter) {
float n = exp2(z);
vec2 tc = vec2(uv.x * n, my * n);
vec2 ti = floor(tc);
vec2 f = fract(tc);
float lonc = ((ti.x + 0.5) / n) * 2.0 * PI - PI;
float latc = invMercY((ti.y + 0.5) / n);
float d = acos(clamp(dot(uSeed, llDir(lonc, latc)), -1.0, 1.0));
float tRev = waveT + d / PI * uWaveSpread + hash3(vec3(ti, z)) * jitter;
vec2 tcb = vec2(fract(uv.x + 0.5) * n, tc.y);
vec2 w = vec2(min(fwidth(tc.x), fwidth(tcb.x)), fwidth(tc.y));
vec2 e = min(f, 1.0 - f) / max(w, vec2(1e-5));
float line = 1.0 - smoothstep(0.0, 1.25, min(e.x, e.y));
return vec2(uTime - tRev, line);
}
void main() {
vec3 n = normalize(vObj);
float lat = asin(clamp(n.y, -1.0, 1.0));
float lon = atan(-n.z, n.x);
vec2 uv = vec2(lon / (2.0 * PI) + 0.5, lat / PI + 0.5);
float my = mercY(lat);
vec3 N = normalize(vWorldN);
vec3 V = normalize(vView);
float dSeed = acos(clamp(dot(n, uSeed), -1.0, 1.0));
// ---------- substrate (pre-imagery)
float gLat = abs(fract(lat * 180.0 / PI / 10.0 + 0.5) - 0.5) * 10.0;
float gLon = abs(fract(lon * 180.0 / PI / 10.0 + 0.5) - 0.5) * 10.0 * cos(lat);
float gw = fwidth(lat * 180.0 / PI) * 1.2;
float gwl = min(fwidth(lon), fwidth(mod(lon + 2.0 * PI, 2.0 * PI))) * 180.0 / PI * 1.2 * cos(lat);
float grid = max(1.0 - smoothstep(0.0, gw, gLat), 1.0 - smoothstep(0.0, max(gwl, 1e-4), gLon));
float behind = smoothstep(uFront + 0.02, uFront - 0.10, dSeed);
vec3 base = vec3(0.0018, 0.0035, 0.006) * behind;
vec3 holo = base + vec3(0.0, 0.35, 0.42) * grid * 0.10 * uHolo * behind;
// growth front: a thin bright ring with a soft wake
float ring = exp(-pow((dSeed - uFront) * 55.0, 2.0)) * step(0.0, uFront);
float wake = exp(-max(uFront - dSeed, 0.0) * 6.0) * behind;
holo += vec3(0.1, 0.85, 1.0) * (ring * 0.55 + wake * 0.035) * uHolo;
// real night lights, colonised by the front: the "neurons"
vec2 gx, gy;
seamGrad(uv, gx, gy);
float nl = textureGrad(uNight, uv, gx, gy).r;
float nlc = pow(max(nl - 0.06, 0.0) * 1.07, 1.7);
float fresh = exp(-max(uFront - dSeed, 0.0) * 3.0);
holo += mix(vec3(0.55, 0.95, 1.0), vec3(1.0), fresh) * nlc * (1.2 + fresh * 2.5) * behind * uLights;
// ---------- quadtree indexing
vec2 s3 = tileState(3.0, uWave.x, uv, my, 0.35);
vec2 s4 = tileState(4.0, uWave.y, uv, my, 0.30);
vec2 s5 = tileState(5.0, uWave.z, uv, my, 0.25);
float reveal = max(smoothstep(0.05, 0.42, s3.x), uImagery);
// resolution climbs as each finer wave passes
float bias = mix(4.5, 0.0, max(smoothstep(0.0, 0.5, s5.x), uImagery));
bias = min(bias, mix(4.5, 2.2, smoothstep(0.0, 0.5, s4.x)));
float bs = exp2(bias);
vec2 bgx = gx * bs, bgy = gy * bs;
vec3 day = textureGrad(uDay, uv, bgx, bgy).rgb;
// analysis edges (luminance gradient) — only while a tile is being indexed
float ph0 = s3.x;
float edgeAmt0 = step(0.0, ph0) * exp(-max(ph0 - 0.1, 0.0) * 2.2) * (1.0 - uImagery);
float edge = 0.0;
if (edgeAmt0 > 0.002) {
float l0 = dot(textureGrad(uDay, uv + vec2(uTexel.x, 0.0), bgx, bgy).rgb, vec3(0.3, 0.59, 0.11));
float l1 = dot(textureGrad(uDay, uv - vec2(uTexel.x, 0.0), bgx, bgy).rgb, vec3(0.3, 0.59, 0.11));
float l2 = dot(textureGrad(uDay, uv + vec2(0.0, uTexel.y), bgx, bgy).rgb, vec3(0.3, 0.59, 0.11));
float l3 = dot(textureGrad(uDay, uv - vec2(0.0, uTexel.y), bgx, bgy).rgb, vec3(0.3, 0.59, 0.11));
edge = clamp(length(vec2(l0 - l1, l2 - l3)) * 9.0, 0.0, 1.0);
}
// ---------- lighting
float sd = dot(n, uSun);
float dayF = smoothstep(-0.10, 0.20, sd);
vec3 lit = day * (0.035 + 1.25 * pow(max(sd, 0.0), 0.75)) * dayF + day * 0.012;
float ocean = smoothstep(0.015, 0.06, day.b - day.r) * (1.0 - smoothstep(0.08, 0.25, dot(day, vec3(0.33))));
vec3 Hh = normalize(normalize(mat3(1.0) * uSun) + V);
float graze = smoothstep(0.15, 0.5, dot(N, V));
float spec = (pow(max(dot(N, Hh), 0.0), 240.0) * 0.4 + pow(max(dot(N, Hh), 0.0), 30.0) * 0.03) * ocean * dayF * graze;
vec3 cityGlow = vec3(1.0, 0.72, 0.42) * pow(nl, 1.9) * 1.6 * (1.0 - dayF);
float fres = pow(1.0 - max(dot(N, V), 0.0), 3.0);
vec3 haze = vec3(0.25, 0.5, 1.0) * fres * (0.15 + 0.85 * dayF) * 0.7;
vec3 earthLit = lit + vec3(1.0, 0.92, 0.8) * spec + cityGlow + haze;
vec3 earthFlat = day * 0.6;
vec3 earth = mix(earthFlat, earthLit, uLit);
// reveal treatment: flash, edges, then settle
float ph = s3.x;
float flash = step(0.0, ph) * exp(-max(ph, 0.0) * 9.0);
float edgeAmt = step(0.0, ph) * exp(-max(ph - 0.1, 0.0) * 2.2) * (1.0 - uImagery);
vec3 surf = mix(holo, earth, reveal);
surf += vec3(0.0, 0.8, 1.0) * edge * edgeAmt * 0.9;
surf += vec3(0.6, 0.95, 1.0) * flash * 0.10 * (1.0 - uImagery);
surf = mix(surf, vec3(dot(surf, vec3(0.3, 0.59, 0.11))) * vec3(0.4, 1.0, 1.15), uTint);
// tile edges: faint before, bright at their own wave, then gone
float g3 = s3.y * (step(ph, 0.0) * 0.18 * uHolo * behind + step(0.0, ph) * exp(-max(ph, 0.0) * 1.6) * 1.3);
float g4 = s4.y * step(0.0, s4.x) * exp(-max(s4.x, 0.0) * 2.0) * 0.9;
float g5 = s5.y * step(0.0, s5.x) * exp(-max(s5.x, 0.0) * 2.6) * 0.7;
surf += vec3(0.25, 0.9, 1.0) * max(g3, max(g4, g5)) * uGridAmt;
gl_FragColor = vec4(surf * uDim, 1.0);
}
`,
});
const geo = new THREE.SphereGeometry(1, 384, 192);
const mesh = new THREE.Mesh(geo, mat);
// Clouds — real GIBS-derived opacity, lit by the same sun.
const cloudU = {
uClouds: { value: cloudTex },
uSun: uniforms.uSun,
uAmt: { value: 0 },
uTime: uniforms.uTime,
};
const cloudMat = new THREE.ShaderMaterial({
uniforms: cloudU,
transparent: true,
depthWrite: false,
vertexShader: /* glsl */ `
varying vec3 vObj;
varying vec3 vN;
varying vec3 vView;
void main() {
vObj = position;
vec4 wp = modelMatrix * vec4(position, 1.0);
vN = normalize(mat3(modelMatrix) * position);
vView = normalize(cameraPosition - wp.xyz);
gl_Position = projectionMatrix * viewMatrix * wp;
}
`,
fragmentShader: /* glsl */ `
#define PI 3.141592653589793
uniform sampler2D uClouds;
uniform vec3 uSun;
uniform float uAmt, uTime;
varying vec3 vObj;
varying vec3 vN;
varying vec3 vView;
void main() {
vec3 n = normalize(vObj);
float lat = asin(clamp(n.y, -1.0, 1.0));
float lon = atan(-n.z, n.x) - uTime * 0.0015;
vec2 uv = vec2(fract(lon / (2.0 * PI) + 0.5), lat / PI + 0.5);
vec2 ua = uv, ub = vec2(fract(uv.x + 0.5), uv.y);
vec2 ax = dFdx(ua), ay = dFdy(ua), bx = dFdx(ub), by = dFdy(ub);
vec2 gx = vec2(abs(ax.x) < abs(bx.x) ? ax.x : bx.x, ax.y);
vec2 gy = vec2(abs(ay.x) < abs(by.x) ? ay.x : by.x, ay.y);
float c = textureGrad(uClouds, uv, gx, gy).r;
c = smoothstep(0.08, 0.85, c);
float sd = dot(n, uSun);
float dayF = smoothstep(-0.12, 0.25, sd);
vec3 col = vec3(1.0, 0.98, 0.95) * (0.02 + 1.1 * dayF * (0.35 + 0.65 * max(sd, 0.0)));
float edge = pow(1.0 - max(dot(normalize(vN), normalize(vView)), 0.0), 2.0);
gl_FragColor = vec4(col, c * uAmt * (0.92 - edge * 0.4));
}
`,
});
const clouds = new THREE.Mesh(new THREE.SphereGeometry(1.0065, 256, 128), cloudMat);
// Atmosphere shell — limb glow, warm-to-blue at the terminator.
const atmoU = { uSun: uniforms.uSun, uAmt: { value: 0 }, uCyan: { value: 0 } };
const atmoMat = new THREE.ShaderMaterial({
uniforms: atmoU,
side: THREE.BackSide,
transparent: true,
depthWrite: false,
blending: THREE.AdditiveBlending,
vertexShader: /* glsl */ `
varying vec3 vN;
varying vec3 vWP;
void main() {
vec4 wp = modelMatrix * vec4(position, 1.0);
vWP = wp.xyz;
vN = normalize(mat3(modelMatrix) * position);
gl_Position = projectionMatrix * viewMatrix * wp;
}
`,
fragmentShader: /* glsl */ `
uniform vec3 uSun;
uniform float uAmt, uCyan;
varying vec3 vN;
varying vec3 vWP;
void main() {
vec3 V = normalize(cameraPosition - vWP);
vec3 N = normalize(vN);
// distance of this view ray from the planet centre, in radii
vec3 ro = cameraPosition;
vec3 rd = -V;
float b = dot(ro, rd);
float h = length(ro - b * rd);
float shell = 1.065;
float t = clamp((shell - h) / (shell - 1.0), 0.0, 1.0);
float glow = pow(t, 2.6) * (1.0 - smoothstep(0.985, 1.0, t) * 0.0);
if (h < 1.0) glow *= 0.0;
// light side vs dark side of the limb
vec3 limbP = normalize(ro + rd * b);
float sd = dot(limbP, uSun);
float lit = smoothstep(-0.35, 0.4, sd);
vec3 col = mix(vec3(0.02, 0.08, 0.14), vec3(0.35, 0.62, 1.0), lit);
col += vec3(1.0, 0.45, 0.2) * exp(-pow(sd * 6.0, 2.0)) * 0.35;
col = mix(col, vec3(0.1, 0.9, 1.0) * 0.8, uCyan);
gl_FragColor = vec4(col * glow * uAmt * 1.05, 1.0);
}
`,
});
const atmo = new THREE.Mesh(new THREE.SphereGeometry(1.065, 128, 64), atmoMat);
const group = new THREE.Group();
group.add(mesh, clouds, atmo);
return { group, mesh, uniforms, cloudU, atmoU };
}
File diff suppressed because it is too large. Load diff
+512
View File
@@ -0,0 +1,512 @@
import * as THREE from 'three';
import { llToVec, D2R, EARTH_KM } from './geo.js';
import { mulberry32 } from './timeline.js';
// Point and particle layers for the live feeds. Every layer exposes
// `update(t, ctx)`; reveal timing and motion are pure functions of film time.
const GC = /* glsl */ `
vec3 llDir(float lon, float lat) {
float c = cos(lat);
return vec3(c * cos(lon), sin(lat), -c * sin(lon));
}
// great-circle destination: start (lon,lat) rad, bearing rad, distance rad
vec2 gcDest(vec2 ll, float b, float d) {
float lat2 = asin(sin(ll.y) * cos(d) + cos(ll.y) * sin(d) * cos(b));
float lon2 = ll.x + atan(sin(b) * sin(d) * cos(ll.y), cos(d) - sin(ll.y) * sin(lat2));
return vec2(lon2, lat2);
}
`;
// Moving contacts (aircraft, vessels): heads as points, trails as segments.
// rows: [lon, lat, alt_m, track_deg, vel_ms]
export function createMovers(rows, { color, trailColor, size = 2.2, altScale = 8, trail = 0.010, revealSpread = 1.4, seed = 1, gain = 1 }) {
const rnd = mulberry32(seed);
const n = rows.length;
const ll = new Float32Array(n * 2);
const hv = new Float32Array(n * 2);
const alt = new Float32Array(n);
const rr = new Float32Array(n);
rows.forEach((r, i) => {
ll[i * 2] = r[0] * D2R;
ll[i * 2 + 1] = r[1] * D2R;
hv[i * 2] = (r[3] || 0) * D2R;
hv[i * 2 + 1] = (r[4] || 0) / (EARTH_KM * 1000); // rad per second
alt[i] = 1 + ((r[2] || 0) / (EARTH_KM * 1000)) * altScale + 0.0015;
rr[i] = rnd();
});
const uniforms = {
uSim: { value: 0 }, // simulated seconds since the feed went live
uReveal: { value: 0 }, // 0..1 + spread
uAlpha: { value: 0 },
uGain: { value: gain },
uPx: { value: 1 },
uSize: { value: size },
uTrail: { value: trail },
uColor: { value: new THREE.Color(color) },
uTrailColor: { value: new THREE.Color(trailColor || color) },
uSpread: { value: revealSpread },
};
const vs = (isTrail) => /* glsl */ `
${GC}
attribute vec2 aLL;
attribute vec2 aHV;
attribute float aAlt;
attribute float aR;
${isTrail ? 'attribute float aEnd;' : ''}
uniform float uSim, uReveal, uPx, uSize, uTrail, uSpread;
varying float vA;
varying float vFacing;
void main() {
float d = aHV.y * uSim;
${isTrail ? 'd -= aEnd * max(uTrail, aHV.y * 40.0);' : ''}
vec2 p = gcDest(aLL, aHV.x, d);
vec3 pos = llDir(p.x, p.y) * aAlt;
vec4 wp = modelMatrix * vec4(pos, 1.0);
vFacing = dot(normalize(wp.xyz), normalize(cameraPosition - wp.xyz));
float rv = clamp((uReveal - aR * uSpread) * 6.0, 0.0, 1.0);
vA = rv ${isTrail ? '* (1.0 - aEnd)' : ''};
gl_Position = projectionMatrix * viewMatrix * wp;
${isTrail ? '' : 'gl_PointSize = uSize * uPx * (1.0 + 2.5 * exp(-(uReveal - aR * uSpread) * 10.0) * step(0.0, uReveal - aR * uSpread));'}
}
`;
const fs = (isTrail) => /* glsl */ `
uniform vec3 ${isTrail ? 'uTrailColor' : 'uColor'};
uniform float uAlpha, uGain;
varying float vA;
varying float vFacing;
void main() {
float back = smoothstep(-0.02, 0.12, vFacing);
${
isTrail
? 'gl_FragColor = vec4(uTrailColor * vA * uAlpha * uGain * back * 0.9, 1.0);'
: `vec2 c = gl_PointCoord - 0.5;
float r = length(c);
float disc = smoothstep(0.5, 0.15, r);
if (vA * back <= 0.001) discard;
gl_FragColor = vec4(uColor * disc * vA * uAlpha * uGain * back, 1.0);`
}
}
`;
const mk = (isTrail) =>
new THREE.ShaderMaterial({
uniforms,
transparent: true,
depthWrite: false,
blending: THREE.AdditiveBlending,
vertexShader: vs(isTrail),
fragmentShader: fs(isTrail),
});
const gp = new THREE.BufferGeometry();
gp.setAttribute('position', new THREE.BufferAttribute(new Float32Array(n * 3), 3));
gp.setAttribute('aLL', new THREE.BufferAttribute(ll, 2));
gp.setAttribute('aHV', new THREE.BufferAttribute(hv, 2));
gp.setAttribute('aAlt', new THREE.BufferAttribute(alt, 1));
gp.setAttribute('aR', new THREE.BufferAttribute(rr, 1));
gp.boundingSphere = new THREE.Sphere(new THREE.Vector3(), 2);
const heads = new THREE.Points(gp, mk(false));
// trails: two vertices per contact, aEnd 0 (head) / 1 (tail)
const dup = (src, k) => {
const out = new Float32Array(src.length * 2);
for (let i = 0; i < src.length / k; i++)
for (let j = 0; j < k; j++) {
out[i * 2 * k + j] = src[i * k + j];
out[i * 2 * k + k + j] = src[i * k + j];
}
return out;
};
const gt = new THREE.BufferGeometry();
gt.setAttribute('position', new THREE.BufferAttribute(new Float32Array(n * 6), 3));
gt.setAttribute('aLL', new THREE.BufferAttribute(dup(ll, 2), 2));
gt.setAttribute('aHV', new THREE.BufferAttribute(dup(hv, 2), 2));
gt.setAttribute('aAlt', new THREE.BufferAttribute(dup(alt, 1), 1));
gt.setAttribute('aR', new THREE.BufferAttribute(dup(rr, 1), 1));
const ends = new Float32Array(n * 2);
for (let i = 0; i < n; i++) ends[i * 2 + 1] = 1;
gt.setAttribute('aEnd', new THREE.BufferAttribute(ends, 1));
gt.boundingSphere = new THREE.Sphere(new THREE.Vector3(), 2);
const trails = new THREE.LineSegments(gt, mk(true));
const group = new THREE.Group();
group.add(trails, heads);
return { group, uniforms, count: n };
}
// Static or pulsing point events (fires, quakes, cities, cameras).
// rows: [lon, lat, weight]
export function createEvents(rows, { color, size = 3, mode = 'flicker', r = 1.002, spread = 1.2, seed = 3, gain = 1, weightFn = (w) => w }) {
const rnd = mulberry32(seed);
const n = rows.length;
const pos = new Float32Array(n * 3);
const w = new Float32Array(n);
const rr = new Float32Array(n);
const v = [0, 0, 0];
rows.forEach((row, i) => {
llToVec(row[0], row[1], r, v);
pos.set(v, i * 3);
w[i] = weightFn(row[2] ?? 1);
rr[i] = rnd();
});
const uniforms = {
uT: { value: 0 },
uReveal: { value: 0 },
uAlpha: { value: 0 },
uGain: { value: gain },
uPx: { value: 1 },
uSize: { value: size },
uColor: { value: new THREE.Color(color) },
uSpread: { value: spread },
};
const g = new THREE.BufferGeometry();
g.setAttribute('position', new THREE.BufferAttribute(pos, 3));
g.setAttribute('aW', new THREE.BufferAttribute(w, 1));
g.setAttribute('aR', new THREE.BufferAttribute(rr, 1));
const ringMode = mode === 'ring';
const m = new THREE.ShaderMaterial({
uniforms,
transparent: true,
depthWrite: false,
blending: THREE.AdditiveBlending,
vertexShader: /* glsl */ `
attribute float aW;
attribute float aR;
uniform float uT, uReveal, uPx, uSize, uSpread;
varying float vA;
varying float vPh;
varying float vW;
varying float vFacing;
void main() {
vec4 wp = modelMatrix * vec4(position, 1.0);
vFacing = dot(normalize(wp.xyz), normalize(cameraPosition - wp.xyz));
float k = uReveal - aR * uSpread;
vA = clamp(k * 5.0, 0.0, 1.0);
vPh = k;
vW = aW;
gl_Position = projectionMatrix * viewMatrix * wp;
${
ringMode
? 'gl_PointSize = uSize * uPx * (0.6 + aW) * 9.0;'
: 'gl_PointSize = uSize * uPx * (0.7 + aW) * (1.0 + 3.0 * exp(-max(k, 0.0) * 8.0) * step(0.0, k));'
}
}
`,
fragmentShader: /* glsl */ `
uniform vec3 uColor;
uniform float uT, uAlpha, uGain;
varying float vA;
varying float vPh;
varying float vW;
varying float vFacing;
void main() {
float back = smoothstep(-0.02, 0.12, vFacing);
if (vA * back <= 0.001) discard;
vec2 c = gl_PointCoord - 0.5;
float r = length(c) * 2.0;
${
ringMode
? `float ph = fract(vPh * 0.55 + vW * 0.37);
float ringR = ph;
float ring = exp(-pow((r - ringR) * 14.0, 2.0)) * (1.0 - ph);
float core = smoothstep(0.16, 0.0, r);
float first = exp(-max(vPh, 0.0) * 3.0);
float a = ring * (0.8 + first) + core * 1.4;`
: `float flick = 0.65 + 0.35 * sin(uT * (9.0 + vW * 7.0) + vW * 91.0);
float a = smoothstep(1.0, 0.0, r) * smoothstep(1.0, 0.25, r) * 1.5 * flick;`
}
gl_FragColor = vec4(uColor * a * vA * uAlpha * uGain * back, 1.0);
}
`,
});
const pts = new THREE.Points(g, m);
return { group: pts, uniforms, count: n };
}
// Satellites: positions arrive from satellite.js each frame.
export function createSatellites(count, { color = '#d6d0ff', size = 1.7 } = {}) {
const pos = new Float32Array(count * 3);
const rr = new Float32Array(count);
const kind = new Float32Array(count);
const rnd = mulberry32(11);
for (let i = 0; i < count; i++) rr[i] = rnd();
const g = new THREE.BufferGeometry();
const pa = new THREE.BufferAttribute(pos, 3);
pa.setUsage(THREE.DynamicDrawUsage);
g.setAttribute('position', pa);
g.setAttribute('aR', new THREE.BufferAttribute(rr, 1));
g.setAttribute('aK', new THREE.BufferAttribute(kind, 1));
g.boundingSphere = new THREE.Sphere(new THREE.Vector3(), 20);
const uniforms = {
uReveal: { value: 0 },
uAlpha: { value: 0 },
uPx: { value: 1 },
uSize: { value: size },
uColor: { value: new THREE.Color(color) },
};
const m = new THREE.ShaderMaterial({
uniforms,
transparent: true,
depthWrite: false,
depthTest: true,
blending: THREE.AdditiveBlending,
vertexShader: /* glsl */ `
attribute float aR;
attribute float aK;
uniform float uReveal, uPx, uSize;
varying float vA;
varying float vK;
void main() {
float k = uReveal - aR * 1.3;
vA = clamp(k * 5.0, 0.0, 1.0);
vK = aK;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
float big = aK > 0.5 ? 1.6 : 1.0;
gl_PointSize = uSize * uPx * big * (1.0 + 3.0 * exp(-max(k, 0.0) * 9.0) * step(0.0, k));
}
`,
fragmentShader: /* glsl */ `
uniform vec3 uColor;
uniform float uAlpha;
varying float vA;
varying float vK;
void main() {
if (vA <= 0.001) discard;
float r = length(gl_PointCoord - 0.5) * 2.0;
float a = smoothstep(1.0, 0.1, r);
vec3 c = mix(uColor * 0.75, vec3(0.75, 1.0, 1.0) * 1.4, step(0.5, vK));
gl_FragColor = vec4(c * a * vA * uAlpha, 1.0);
}
`,
});
const pts = new THREE.Points(g, m);
return { group: pts, uniforms, positions: pos, attr: pa, kind, count };
}
// Orbit rings for a handful of notable objects.
export function createOrbitRings(paths, color = '#bfb6ff') {
const pos = [];
const u = [];
for (const path of paths) {
for (let i = 0; i < path.length - 1; i++) {
pos.push(...path[i], ...path[i + 1]);
u.push(i / (path.length - 1), (i + 1) / (path.length - 1));
}
}
const g = new THREE.BufferGeometry();
g.setAttribute('position', new THREE.Float32BufferAttribute(pos, 3));
g.setAttribute('aU', new THREE.Float32BufferAttribute(u, 1));
const m = new THREE.ShaderMaterial({
transparent: true,
depthWrite: false,
blending: THREE.AdditiveBlending,
uniforms: { uDraw: { value: 0 }, uAlpha: { value: 0 }, uColor: { value: new THREE.Color(color) } },
vertexShader: /* glsl */ `
attribute float aU;
varying float vU;
varying float vNear;
void main() {
vU = aU;
vec4 mv = modelViewMatrix * vec4(position, 1.0);
vNear = smoothstep(1.2, 4.5, -mv.z);
gl_Position = projectionMatrix * mv;
}
`,
fragmentShader: /* glsl */ `
uniform float uDraw, uAlpha;
uniform vec3 uColor;
varying float vU;
varying float vNear;
void main() {
float k = uDraw - vU;
if (k < 0.0) discard;
float hot = exp(-k * 30.0) * 2.0;
gl_FragColor = vec4((uColor * 0.4 + vec3(1.0) * hot) * uAlpha * vNear, 1.0);
}
`,
});
return new THREE.LineSegments(g, m);
}
// Wind: particles advected through a real GFS u/v field, drawn as fading trails.
export function createWind(field, { count = 7000, trailLen = 14, seed = 21 } = {}) {
const { nx, ny, lo1, la1, dx, dy, u, v } = field;
const sample = (lon, lat) => {
let x = (((lon - lo1) % 360) + 360) % 360 / dx;
let y = (la1 - lat) / dy;
const x0 = Math.floor(x);
const y0 = Math.max(0, Math.min(ny - 2, Math.floor(y)));
const fx = x - x0;
const fy = Math.max(0, Math.min(1, y - y0));
const i00 = y0 * nx + (x0 % nx);
const i10 = y0 * nx + ((x0 + 1) % nx);
const i01 = (y0 + 1) * nx + (x0 % nx);
const i11 = (y0 + 1) * nx + ((x0 + 1) % nx);
const bl = (arr) => (arr[i00] * (1 - fx) + arr[i10] * fx) * (1 - fy) + (arr[i01] * (1 - fx) + arr[i11] * fx) * fy;
return [bl(u), bl(v)];
};
const SEGS = trailLen - 1;
const pos = new Float32Array(count * SEGS * 2 * 3);
const alpha = new Float32Array(count * SEGS * 2);
const g = new THREE.BufferGeometry();
const pa = new THREE.BufferAttribute(pos, 3);
pa.setUsage(THREE.DynamicDrawUsage);
const aa = new THREE.BufferAttribute(alpha, 1);
aa.setUsage(THREE.DynamicDrawUsage);
g.setAttribute('position', pa);
g.setAttribute('aA', aa);
g.boundingSphere = new THREE.Sphere(new THREE.Vector3(), 2);
const uniforms = { uAlpha: { value: 0 }, uColor: { value: new THREE.Color('#a6dcff') } };
const m = new THREE.ShaderMaterial({
uniforms,
transparent: true,
depthWrite: false,
blending: THREE.AdditiveBlending,
vertexShader: /* glsl */ `
attribute float aA;
varying float vA;
varying float vFacing;
void main() {
vA = aA;
vec4 wp = modelMatrix * vec4(position, 1.0);
vFacing = dot(normalize(wp.xyz), normalize(cameraPosition - wp.xyz));
gl_Position = projectionMatrix * viewMatrix * wp;
}
`,
fragmentShader: /* glsl */ `
uniform vec3 uColor;
uniform float uAlpha;
varying float vA;
varying float vFacing;
void main() {
float back = smoothstep(0.0, 0.15, vFacing);
gl_FragColor = vec4(uColor * vA * uAlpha * back, 1.0);
}
`,
});
const lines = new THREE.LineSegments(g, m);
// particle state
const P = { lon: new Float32Array(count), lat: new Float32Array(count), age: new Float32Array(count), life: new Float32Array(count), spd: new Float32Array(count) };
const hist = new Float32Array(count * trailLen * 2);
let rnd;
let simT = -1;
const spawn = (i) => {
const lon = rnd() * 360 - 180;
const lat = Math.asin(rnd() * 2 - 1) * (180 / Math.PI) * 0.92;
P.lon[i] = lon;
P.lat[i] = lat;
P.age[i] = 0;
P.life[i] = 1.2 + rnd() * 2.2;
for (let k = 0; k < trailLen; k++) {
hist[(i * trailLen + k) * 2] = lon;
hist[(i * trailLen + k) * 2 + 1] = lat;
}
};
const reset = () => {
rnd = mulberry32(seed);
for (let i = 0; i < count; i++) {
spawn(i);
P.age[i] = rnd() * P.life[i];
}
simT = 0;
};
const DT = 1 / 30;
const K = 0.85; // deg per (m/s) per second of film — visual speed
const step = () => {
for (let i = 0; i < count; i++) {
const [uu, vv] = sample(P.lon[i], P.lat[i]);
P.spd[i] = Math.hypot(uu, vv);
const c = Math.max(0.2, Math.cos(P.lat[i] * D2R));
P.lon[i] += (uu * K * DT) / c;
P.lat[i] += vv * K * DT;
if (P.lon[i] > 180) P.lon[i] -= 360;
if (P.lon[i] < -180) P.lon[i] += 360;
P.age[i] += DT;
const base = i * trailLen * 2;
hist.copyWithin(base + 2, base, base + (trailLen - 1) * 2);
hist[base] = P.lon[i];
hist[base + 1] = P.lat[i];
if (P.age[i] > P.life[i] || Math.abs(P.lat[i]) > 84) spawn(i);
}
simT += DT;
};
const a = [0, 0, 0];
const b = [0, 0, 0];
function update(tSinceStart) {
if (simT < 0 || tSinceStart < simT - 1e-6) reset();
while (simT < tSinceStart) step();
let o = 0;
let q = 0;
for (let i = 0; i < count; i++) {
const base = i * trailLen * 2;
const life = P.age[i] / P.life[i];
const fade = Math.min(1, life * 5) * Math.min(1, (1 - life) * 4);
const sp = Math.min(1, P.spd[i] / 18);
for (let k = 0; k < SEGS; k++) {
const lo1 = hist[base + k * 2];
const la1 = hist[base + k * 2 + 1];
const lo2 = hist[base + (k + 1) * 2];
const la2 = hist[base + (k + 1) * 2 + 1];
const jump = Math.abs(lo2 - lo1) > 90;
llToVec(lo1, la1, 1.004, a);
llToVec(jump ? lo1 : lo2, jump ? la1 : la2, 1.004, b);
pos[o++] = a[0]; pos[o++] = a[1]; pos[o++] = a[2];
pos[o++] = b[0]; pos[o++] = b[1]; pos[o++] = b[2];
const f1 = (1 - k / SEGS) * fade * (0.25 + sp);
const f2 = (1 - (k + 1) / SEGS) * fade * (0.25 + sp);
alpha[q++] = f1;
alpha[q++] = f2;
}
}
pa.needsUpdate = true;
aa.needsUpdate = true;
}
return { group: lines, uniforms, update, count };
}
export function createStars(n = 2600, seed = 77) {
const rnd = mulberry32(seed);
const pos = new Float32Array(n * 3);
const mag = new Float32Array(n);
for (let i = 0; i < n; i++) {
const z = rnd() * 2 - 1;
const t = rnd() * Math.PI * 2;
const s = Math.sqrt(1 - z * z);
pos[i * 3] = 400 * s * Math.cos(t);
pos[i * 3 + 1] = 400 * z;
pos[i * 3 + 2] = 400 * s * Math.sin(t);
mag[i] = Math.pow(rnd(), 6);
}
const g = new THREE.BufferGeometry();
g.setAttribute('position', new THREE.BufferAttribute(pos, 3));
g.setAttribute('aM', new THREE.BufferAttribute(mag, 1));
const uniforms = { uAlpha: { value: 0 }, uPx: { value: 1 } };
const m = new THREE.ShaderMaterial({
uniforms,
transparent: true,
depthWrite: false,
blending: THREE.AdditiveBlending,
vertexShader: /* glsl */ `
attribute float aM;
uniform float uPx;
varying float vM;
void main() {
vM = aM;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
gl_PointSize = (1.0 + aM * 2.2) * uPx;
}
`,
fragmentShader: /* glsl */ `
uniform float uAlpha;
varying float vM;
void main() {
float r = length(gl_PointCoord - 0.5) * 2.0;
float a = smoothstep(1.0, 0.0, r);
gl_FragColor = vec4(vec3(0.8, 0.9, 1.0) * a * (0.12 + vM * 0.9) * uAlpha, 1.0);
}
`,
});
return { group: new THREE.Points(g, m), uniforms };
}
+323
View File
@@ -0,0 +1,323 @@
import * as THREE from 'three';
import { llToVec, angDist, destination, bearing, D2R } from './geo.js';
import { mulberry32 } from './timeline.js';
// Line layers that reveal along a scalar "arrival" attribute (radians from the
// seed, or arc length), with a hot tip at the front and a configurable wake.
export function frontLineMaterial({ color, alpha = 1, after = 0.25, tip = 3.0, decay = 4.0, additive = true }) {
return new THREE.ShaderMaterial({
transparent: true,
depthWrite: false,
depthTest: true,
blending: additive ? THREE.AdditiveBlending : THREE.NormalBlending,
uniforms: {
uColor: { value: new THREE.Color(color) },
uFront: { value: -1 },
uAlpha: { value: alpha },
uAfter: { value: after },
uTip: { value: tip },
uDecay: { value: decay },
uFadeBack: { value: 1 },
},
vertexShader: /* glsl */ `
attribute float aDist;
varying float vDist;
varying float vFacing;
void main() {
vDist = aDist;
vec4 wp = modelMatrix * vec4(position, 1.0);
vec3 n = normalize(wp.xyz);
vFacing = dot(n, normalize(cameraPosition - wp.xyz));
gl_Position = projectionMatrix * viewMatrix * wp;
}
`,
fragmentShader: /* glsl */ `
uniform vec3 uColor;
uniform float uFront, uAlpha, uAfter, uTip, uDecay, uFadeBack;
varying float vDist;
varying float vFacing;
void main() {
float behind = uFront - vDist;
if (behind < 0.0) discard;
float a = uAfter + (1.0 - uAfter) * exp(-behind * uDecay);
float hot = exp(-behind * 60.0) * uTip;
float back = mix(1.0, smoothstep(-0.15, 0.25, vFacing), uFadeBack);
vec3 c = uColor * (a + hot) + vec3(1.0) * hot * 0.6;
gl_FragColor = vec4(c * uAlpha * back, 1.0);
}
`,
});
}
function segmentsFromPolylines(lines, r, distFn) {
const pos = [];
const dist = [];
const a = [0, 0, 0];
const b = [0, 0, 0];
for (const line of lines) {
for (let i = 0; i < line.length - 1; i++) {
const [lo1, la1] = line[i];
const [lo2, la2] = line[i + 1];
if (Math.abs(lo2 - lo1) > 180) continue; // antimeridian jump
llToVec(lo1, la1, r, a);
llToVec(lo2, la2, r, b);
pos.push(...a, ...b);
dist.push(distFn(lo1, la1, i, line), distFn(lo2, la2, i + 1, line));
}
}
const g = new THREE.BufferGeometry();
g.setAttribute('position', new THREE.Float32BufferAttribute(pos, 3));
g.setAttribute('aDist', new THREE.Float32BufferAttribute(dist, 1));
return g;
}
export function createCoastline(lines, seedLL, color = '#3ff0ff') {
const g = segmentsFromPolylines(lines, 1.0008, (lo, la) => angDist(seedLL[0], seedLL[1], lo, la));
const m = frontLineMaterial({ color, after: 0.32, tip: 5.0, decay: 3.0 });
return new THREE.LineSegments(g, m);
}
export function createBorders(lines, seedLL, color = '#1aa6c4') {
const g = segmentsFromPolylines(lines, 1.0006, (lo, la) => angDist(seedLL[0], seedLL[1], lo, la));
const m = frontLineMaterial({ color, after: 0.18, tip: 1.5, decay: 3.0 });
return new THREE.LineSegments(g, m);
}
// Mycelium: a branching random walk out from the seed. Each vertex carries its
// path length so the network visibly *grows* rather than appearing.
export function createMycelium(seedLL, { primaries = 22, maxSegs = 60000, seed = 7 } = {}) {
const rnd = mulberry32(seed);
const pos = [];
const dist = [];
const a = [0, 0, 0];
const b = [0, 0, 0];
const stack = [];
for (let i = 0; i < primaries; i++) {
stack.push({ lon: seedLL[0], lat: seedLL[1], brg: (360 * i) / primaries + rnd() * 12, len: 0, depth: 0, life: 150 + rnd() * 120 });
}
let segs = 0;
while (stack.length && segs < maxSegs) {
const br = stack.pop();
let { lon, lat, brg, len } = br;
const step = (0.5 + rnd() * 0.5) * D2R * (br.depth === 0 ? 1.2 : 0.9);
for (let s = 0; s < br.life && segs < maxSegs; s++) {
// outward bias keeps the culture expanding rather than knotting
const away = (bearing(lon, lat, seedLL[0], seedLL[1]) + 180) % 360;
let diff = ((away - brg + 540) % 360) - 180;
brg += diff * 0.04 + (rnd() - 0.5) * 26;
const [nl, nt] = destination(lon, lat, brg, step);
if (Math.abs(nl - lon) < 180) {
llToVec(lon, lat, 1.0012, a);
llToVec(nl, nt, 1.0012, b);
pos.push(...a, ...b);
dist.push(len * 0.82, (len + step) * 0.82);
segs++;
}
lon = nl;
lat = nt;
len += step;
if (len > Math.PI * 1.05) break;
const pBranch = br.depth < 5 ? 0.07 : 0.02;
if (rnd() < pBranch) {
stack.push({ lon, lat, brg: brg + (rnd() < 0.5 ? -1 : 1) * (22 + rnd() * 45), len, depth: br.depth + 1, life: (br.life - s) * (0.35 + rnd() * 0.5) });
}
}
}
const g = new THREE.BufferGeometry();
g.setAttribute('position', new THREE.Float32BufferAttribute(pos, 3));
g.setAttribute('aDist', new THREE.Float32BufferAttribute(dist, 1));
const m = frontLineMaterial({ color: '#2fe8ff', after: 0.07, tip: 4.0, decay: 2.2 });
const obj = new THREE.LineSegments(g, m);
obj.userData.segments = segs;
return obj;
}
// Subsea cables: drawn on, then light pulses ride them.
export function createCables(cables) {
const pos = [];
const arc = [];
const rnd = [];
const a = [0, 0, 0];
const b = [0, 0, 0];
const r = mulberry32(99);
for (const line of cables) {
let L = 0;
const off = r();
for (let i = 0; i < line.length - 1; i++) {
const [lo1, la1] = line[i];
const [lo2, la2] = line[i + 1];
if (Math.abs(lo2 - lo1) > 180) continue;
const d = angDist(lo1, la1, lo2, la2);
llToVec(lo1, la1, 1.0015, a);
llToVec(lo2, la2, 1.0015, b);
pos.push(...a, ...b);
arc.push(L, L + d);
rnd.push(off, off);
L += d;
}
}
const g = new THREE.BufferGeometry();
g.setAttribute('position', new THREE.Float32BufferAttribute(pos, 3));
g.setAttribute('aArc', new THREE.Float32BufferAttribute(arc, 1));
g.setAttribute('aRnd', new THREE.Float32BufferAttribute(rnd, 1));
const m = new THREE.ShaderMaterial({
transparent: true,
depthWrite: false,
blending: THREE.AdditiveBlending,
uniforms: { uT: { value: 0 }, uDraw: { value: 0 }, uAlpha: { value: 0 }, uColor: { value: new THREE.Color('#2bd9ff') } },
vertexShader: /* glsl */ `
attribute float aArc;
attribute float aRnd;
varying float vArc;
varying float vRnd;
varying float vFacing;
void main() {
vArc = aArc; vRnd = aRnd;
vec4 wp = modelMatrix * vec4(position, 1.0);
vFacing = dot(normalize(wp.xyz), normalize(cameraPosition - wp.xyz));
gl_Position = projectionMatrix * viewMatrix * wp;
}
`,
fragmentShader: /* glsl */ `
uniform float uT, uDraw, uAlpha;
uniform vec3 uColor;
varying float vArc;
varying float vRnd;
varying float vFacing;
void main() {
float drawn = uDraw * 3.2 - vArc - vRnd * 0.4;
if (drawn < 0.0) discard;
float tip = exp(-drawn * 30.0) * 3.0;
float p = fract(vArc * 3.0 - uT * 0.9 + vRnd);
float pulse = pow(smoothstep(0.85, 1.0, p), 3.0) * 2.5;
float back = smoothstep(-0.1, 0.25, vFacing);
vec3 c = uColor * (0.28 + pulse) + vec3(1.0) * (tip + pulse * 0.4);
gl_FragColor = vec4(c * uAlpha * back, 1.0);
}
`,
});
return new THREE.LineSegments(g, m);
}
// Great-circle arcs that lift off the surface; a bright head travels along.
export function createArcs(pairs, seed = 5) {
const rnd = mulberry32(seed);
const pos = [];
const u = [];
const t0 = [];
const SEG = 48;
const a = [0, 0, 0];
const b = [0, 0, 0];
for (const [p, q] of pairs) {
const d = angDist(p[0], p[1], q[0], q[1]);
if (d < 0.05) continue;
const brg = bearing(p[0], p[1], q[0], q[1]);
const lift = 0.015 + d * 0.07;
const start = rnd();
let prev = null;
for (let i = 0; i <= SEG; i++) {
const f = i / SEG;
const [lo, la] = destination(p[0], p[1], brg, d * f);
const h = 1.002 + Math.sin(Math.PI * f) * lift;
llToVec(lo, la, h, b);
if (prev) {
pos.push(...prev, ...b);
u.push((i - 1) / SEG, f);
t0.push(start, start);
}
prev = [...b];
}
}
const g = new THREE.BufferGeometry();
g.setAttribute('position', new THREE.Float32BufferAttribute(pos, 3));
g.setAttribute('aU', new THREE.Float32BufferAttribute(u, 1));
g.setAttribute('aT0', new THREE.Float32BufferAttribute(t0, 1));
const m = new THREE.ShaderMaterial({
transparent: true,
depthWrite: false,
blending: THREE.AdditiveBlending,
uniforms: { uT: { value: 0 }, uAlpha: { value: 0 }, uColor: { value: new THREE.Color('#5ff6ff') } },
vertexShader: /* glsl */ `
attribute float aU;
attribute float aT0;
varying float vU;
varying float vT0;
void main() {
vU = aU; vT0 = aT0;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`,
fragmentShader: /* glsl */ `
uniform float uT, uAlpha;
uniform vec3 uColor;
varying float vU;
varying float vT0;
void main() {
float head = uT * 0.55 - vT0 * 1.6; // each arc launches at its own time
float k = head - vU;
if (k < 0.0) discard;
float trail = exp(-k * 3.5);
float hot = exp(-k * 40.0) * 2.0;
vec3 c = uColor * (0.15 + trail) + vec3(1.0) * hot;
gl_FragColor = vec4(c * uAlpha, 1.0);
}
`,
});
return new THREE.LineSegments(g, m);
}
// The digital twin's lattice: a geodesic shell that grows outward.
export function createLattice() {
const ico = new THREE.IcosahedronGeometry(1, 4);
const edges = new THREE.WireframeGeometry(ico);
const p = edges.getAttribute('position');
const rnd = [];
// build order: a sweep out from the seed (+X, Null Island) with a little jitter,
// so the shell visibly constructs itself the way the culture grew
for (let i = 0; i < p.count; i += 2) {
const mx = (p.getX(i) + p.getX(i + 1)) / 2;
const my = (p.getY(i) + p.getY(i + 1)) / 2;
const mz = (p.getZ(i) + p.getZ(i + 1)) / 2;
const ang = Math.acos(Math.max(-1, Math.min(1, mx / Math.hypot(mx, my, mz)))) / Math.PI;
const h = Math.sin(mx * 91.7 + my * 47.3 + mz * 13.1) * 43758.5;
const v = Math.min(0.999, ang * 0.9 + (h - Math.floor(h)) * 0.08);
rnd.push(v, v);
}
edges.setAttribute('aRnd', new THREE.Float32BufferAttribute(rnd, 1));
const m = new THREE.ShaderMaterial({
transparent: true,
depthWrite: false,
blending: THREE.AdditiveBlending,
uniforms: { uAlpha: { value: 0 }, uBuild: { value: 0 }, uT: { value: 0 }, uColor: { value: new THREE.Color('#38e8ff') } },
vertexShader: /* glsl */ `
attribute float aRnd;
varying float vRnd;
varying vec3 vP;
varying float vFacing;
void main() {
vRnd = aRnd; vP = normalize(position);
vec4 wp = modelMatrix * vec4(position, 1.0);
vFacing = dot(normalize(wp.xyz), normalize(cameraPosition - wp.xyz));
gl_Position = projectionMatrix * viewMatrix * wp;
}
`,
fragmentShader: /* glsl */ `
uniform float uAlpha, uBuild, uT;
uniform vec3 uColor;
varying float vRnd;
varying vec3 vP;
varying float vFacing;
void main() {
if (vRnd > uBuild) discard;
float fresh = exp(-(uBuild - vRnd) * 18.0);
float shimmer = 0.6 + 0.4 * sin(uT * 3.0 + vRnd * 40.0);
// silhouette-weighted: the shell reads as a sphere, not a net over the planet
float rim = 1.0 - abs(vFacing);
float back = mix(0.18, 1.0, pow(rim, 1.5));
vec3 c = uColor * (0.3 * shimmer + fresh * 1.6) + vec3(1.0) * fresh * 0.7;
gl_FragColor = vec4(c * uAlpha * back, 1.0);
}
`,
});
return new THREE.LineSegments(edges, m);
}
+832
View File
@@ -0,0 +1,832 @@
import * as THREE from 'three';
import * as satellite from 'satellite.js';
import { T, FPS, FEEDS, CITIES, DURATION, captureTimes, clamp, lerp, ramp, smooth, win, easeInOut, expoOut, mulberry32 } from './timeline.js';
import { loadCity, buildCity, loopPath } from './city.js';
import { llToVec, subsolar, angDist, D2R, R2D, EARTH_KM } from './geo.js';
import { createGlobe } from './globe.js';
import { createCoastline, createBorders, createMycelium, createCables, createArcs, createLattice } from './lines.js';
import { createMovers, createEvents, createSatellites, createOrbitRings, createWind, createStars } from './layers.js';
import { createPost } from './post.js';
import { createHUD } from './hud.js';
const qs = new URLSearchParams(location.search);
const W = +qs.get('w') || 1920;
const H = +qs.get('h') || 1080;
const PORTRAIT = H > W;
const SEED_LL = [0, 0]; // `seed --origin null` — Null Island
const status = (m) => {
window.__status = m;
console.log('[gev]', m);
};
async function json(path) {
try {
const r = await fetch(path);
if (!r.ok) throw new Error(r.status);
return await r.json();
} catch (e) {
console.warn('missing', path, e.message);
return null;
}
}
async function text(path) {
try {
const r = await fetch(path);
return r.ok ? await r.text() : null;
} catch {
return null;
}
}
function tex(path, { srgb = true, mips = true } = {}) {
return new Promise((res) => {
new THREE.TextureLoader().load(
path,
(t) => {
if (srgb) t.colorSpace = THREE.SRGBColorSpace;
t.anisotropy = 8;
t.generateMipmaps = mips;
t.minFilter = mips ? THREE.LinearMipmapLinearFilter : THREE.LinearFilter;
t.wrapS = THREE.RepeatWrapping;
res(t);
},
undefined,
() => {
console.warn('missing texture', path);
const d = new THREE.DataTexture(new Uint8Array([0, 0, 0, 255]), 1, 1);
d.needsUpdate = true;
res(d);
},
);
});
}
function img(path) {
return new Promise((res) => {
const im = new Image();
im.onload = () => res(im);
im.onerror = () => res(null);
im.src = path;
});
}
// ------------------------------------------------------------------ boot
async function init() {
status('loading');
const canvas = document.getElementById('c');
canvas.width = W;
canvas.height = H;
canvas.style.width = W + 'px';
canvas.style.height = H + 'px';
await document.fonts.ready;
await Promise.all([
document.fonts.load('400 20px "JetBrains Mono"'),
document.fonts.load('500 20px "JetBrains Mono"'),
document.fonts.load('200 20px Inter'),
document.fonts.load('300 20px Inter'),
document.fonts.load('500 20px Inter'),
]);
const [flights, mil, vessels, quakes, fires, wind, cables, coast, borders, cities, airports, radio, bikes, tleText, tleGroups, heroIdx, camIdx, extra, plants, pads] =
await Promise.all([
json('data/flights.json'),
json('data/military.json'),
json('data/vessels.json'),
json('data/quakes.json'),
json('data/fires.json'),
json('data/wind.json'),
json('data/cables.json'),
json('data/coastline.json'),
json('data/borders.json'),
json('data/cities.json'),
json('data/airports.json'),
json('data/radio.json'),
json('data/bikes.json'),
text('data/tle.txt'),
json('data/tle_groups.json'),
json('imagery/hero/index.json'),
json('cctv/index.json'),
json('data/extra_counts.json'),
json('data/power_plants.json'),
json('data/launch_sites.json'),
]);
const [dayTex, nightTex, cloudTex] = await Promise.all([
tex('imagery/earth_day.jpg'),
tex('imagery/earth_night.jpg'),
tex('imagery/clouds.jpg', { srgb: false }),
]);
// Hero captures (Esri synthetic screenshots) and CCTV stills
const heroes = [];
for (const h of heroIdx || []) {
const [wide, close] = await Promise.all([img(`imagery/hero/${h.slug}_wide.jpg`), img(`imagery/hero/${h.slug}_close.jpg`)]);
if (wide && close) heroes.push({ ...h, wide, close });
}
const cams = [];
for (const c of camIdx || []) {
const im = await img(`cctv/${c.file}`);
if (im) cams.push({ ...c, img: im });
}
status(`assets: ${heroes.length} heroes, ${cams.length} cams`);
// ---------------------------------------------------------------- scene
const renderer = new THREE.WebGLRenderer({ canvas, antialias: false, preserveDrawingBuffer: true, powerPreference: 'high-performance' });
renderer.setPixelRatio(1);
renderer.setSize(W, H, false);
renderer.autoClear = false;
const scene = new THREE.Scene();
const FOV = 26;
const camera = new THREE.PerspectiveCamera(FOV, W / H, 0.005, 3000);
const snapT = new Date((flights?.t || Date.now() / 1000) * 1000);
const sunLL = subsolar(snapT);
const sunV = new THREE.Vector3(...llToVec(sunLL[0], sunLL[1]));
const globe = createGlobe({ dayTex, nightTex, cloudTex });
globe.uniforms.uSeed.value.set(...llToVec(SEED_LL[0], SEED_LL[1]));
globe.uniforms.uSun.value.copy(sunV);
globe.uniforms.uWave.value.set(...T.zWave);
scene.add(globe.group);
const stars = createStars();
scene.add(stars.group);
const coastline = coast ? createCoastline(coast.lines, SEED_LL) : null;
const borderL = borders ? createBorders(borders.lines, SEED_LL) : null;
const myc = createMycelium(SEED_LL);
[coastline, borderL, myc].forEach((o) => o && scene.add(o));
const cableL = cables ? createCables(cables.cables) : null;
if (cableL) scene.add(cableL);
// live feeds
const L = {};
if (flights) L.air = createMovers(flights.rows, { color: '#e8fbff', trailColor: '#4fd8ff', size: 2.0, altScale: 10, seed: 2 });
if (mil) L.mil = createMovers(mil.rows, { color: '#ffbf5a', trailColor: '#ff9a2a', size: 4.0, altScale: 10, revealSpread: 0.6, seed: 4 });
if (vessels) L.sea = createMovers(vessels.rows.map((r) => [r[0], r[1], 0, r[3], (r[2] || 0) * 0.5144]), { color: '#52ffc8', trailColor: '#1fd6a0', size: 1.5, gain: 0.45, altScale: 0, seed: 6 });
const quakeRowsX = (quakes?.rows || []).filter((r) => r[3] >= 2.5).map((r) => [r[0], r[1], r[3]]);
const quakeRows = quakeRowsX;
L.quake = createEvents(quakeRows, { color: '#ff3d3d', size: 1.6, mode: 'ring', weightFn: (m) => clamp((m - 2.4) / 4.5, 0.05, 1.3), spread: 1.6 });
const fireRows = (fires?.rows || []).map((r) => [r[0], r[1], r[2]]);
L.fire = createEvents(fireRows, { color: '#ff6a14', size: 1.15, gain: 0.32, mode: 'flicker', weightFn: (f) => clamp(Math.log10(1 + f) / 2.5, 0, 1.2), spread: 1.4 });
if (wind) L.wind = createWind(wind, { count: PORTRAIT ? 6000 : 7000 });
Object.values(L).forEach((l) => scene.add(l.group));
// satellites
const sats = [];
if (tleText) {
const lines = tleText.split(/\r?\n/).map((s) => s.trimEnd()).filter(Boolean);
for (let i = 0; i < lines.length - 1; i++) {
if (lines[i].startsWith('1 ') && lines[i + 1].startsWith('2 ')) {
const name = i > 0 && !lines[i - 1].startsWith('2 ') && !lines[i - 1].startsWith('1 ') ? lines[i - 1].replace(/^0 /, '').trim() : '';
try {
const rec = satellite.twoline2satrec(lines[i], lines[i + 1]);
sats.push({ rec, name, norad: +lines[i].slice(2, 7) || 0 });
} catch {}
i++;
}
}
}
const stationSet = new Set(tleGroups?.groups_norad?.stations || [25544]);
const satL = createSatellites(Math.max(1, sats.length));
sats.forEach((s, i) => (satL.kind[i] = stationSet.has(s.norad) ? 1 : 0));
satL.group.geometry.getAttribute('aK').needsUpdate = true;
scene.add(satL.group);
const satByNorad = new Map(sats.map((s, i) => [s.norad, i]));
// orbit rings in an ECI-aligned group, rotated by -GMST each frame
const ringGroup = new THREE.Group();
scene.add(ringGroup);
// LEO rings for the orbital beat; GPS/GEO rings only once the camera is far out
const ringIds = [25544, 20580, 48274, 41866, 51850, 40105, 28474, 43226];
const ringPaths = [];
const highPaths = [];
for (const id of ringIds) {
const i = satByNorad.get(id);
if (i === undefined) continue;
const rec = sats[i].rec;
const period = (2 * Math.PI) / rec.no; // minutes
const path = [];
for (let k = 0; k <= 240; k++) {
const d = new Date(snapT.getTime() + (k / 240) * period * 60000);
const pv = satellite.propagate(rec, d);
if (!pv.position) continue;
const p = pv.position;
path.push([p.x / EARTH_KM, p.z / EARTH_KM, -p.y / EARTH_KM]);
}
if (path.length > 10) (Math.hypot(...path[0]) < 1.3 ? ringPaths : highPaths).push(path);
}
const rings = createOrbitRings(ringPaths);
const ringsHigh = createOrbitRings(highPaths, '#a9a2ff');
ringGroup.add(rings, ringsHigh);
// data arcs for saturation: between big cities, cameras and cable ends
const arcPairs = [];
{
const r = mulberry32(42);
const pool = (cities?.rows || []).slice(0, 400).map((c) => [c[0], c[1]]);
for (let i = 0; i < 150 && pool.length > 2; i++) {
const a = pool[Math.floor(Math.pow(r(), 1.6) * pool.length)];
const b = pool[Math.floor(r() * pool.length)];
if (angDist(a[0], a[1], b[0], b[1]) > 0.15) arcPairs.push([a, b]);
}
}
const arcs = createArcs(arcPairs);
scene.add(arcs);
const lattice = createLattice();
scene.add(lattice);
const pxScale = Math.min(W, H) / 1080;
for (const l of [...Object.values(L), satL, stars]) if (l.uniforms.uPx) l.uniforms.uPx.value = pxScale;
// ---------------------------------------------------------------- HUD + post
const hudCanvas = document.createElement('canvas');
hudCanvas.width = W;
hudCanvas.height = H;
const post = createPost(renderer, W, H, hudCanvas);
const hudCtx = hudCanvas.getContext('2d');
const counts = {
air: flights?.rows.length || 0,
mil: mil?.rows.length || 0,
sea: vessels?.rows.length || 0,
orbit: sats.length,
quake: quakes?.rows.length || 0,
fire: fires?.rows.length || 0,
wind: wind ? wind.nx * wind.ny : 0,
cable: cables?.cables.length || 0,
cctv: extra?.cctv_catalog || cams.length,
cities: cities?.rows.length || 0,
cctvCountries: extra?.cctv_countries || new Set(cams.map((c) => c.country)).size,
cctvSources: extra?.cctv_sources ? Object.keys(extra.cctv_sources).length : new Set(cams.map((c) => c.source)).size,
};
const rapidList = [];
if (airports) rapidList.push(['AIRPORTS', airports.rows.length]);
if (radio) rapidList.push(['RADIO STATIONS', radio.rows.length]);
if (bikes) rapidList.push(['BIKESHARE STATIONS', bikes.rows.length]);
if (plants) rapidList.push(['POWER PLANTS', plants.rows.length]);
if (pads) rapidList.push(['LAUNCH PADS', pads.rows.length]);
rapidList.push(['POPULATED PLACES', counts.cities]);
rapidList.push(['LAND BORDERS', borders?.lines.length || 0]);
// notable callouts — real records picked from the feeds
const pick = {
flights: pickFlights(flights),
mil: (mil?.rows || []).filter((r) => r[5] && r[7] && r[2] > 1500 && r[4] > 80).slice(0, 8),
ships: (vessels?.rows || []).filter((r) => r[5] && r[2] > 6).slice(0, 40),
quakeTop: (quakes?.rows || []).slice().sort((a, b) => b[3] - a[3]).slice(0, 4),
fireTop: (fires?.rows || []).slice().sort((a, b) => b[2] - a[2]).slice(0, 3),
cities: (cities?.rows || []).slice(0, 220),
};
const capT = captureTimes(heroes.length);
// v2 assets: the capture flurry, street cameras, detections
const flurryIdx = (await json('imagery/flurry/index.json')) || [];
const flurry = [];
for (const f of flurryIdx) {
const im = await img(`imagery/flurry/${f.file}`);
if (im) flurry.push({ ...f, img: im });
}
const detections = (await json('cctv/detections.json')) || {};
const nycIdx = (await json('cctv/nyc/index.json')) || [];
const nycCams = [];
for (const c of nycIdx) {
const im = await img(`cctv/nyc/${c.file}`);
if (im) nycCams.push({ ...c, img: im, det: detections[`nyc/${c.file}`] || detections[`cctv/nyc/${c.file}`] || [] });
}
for (const c of cams) c.det = detections[c.file] || detections[`cctv/${c.file}`] || [];
status(`v2 assets: ${flurry.length} flurry, ${nycCams.length} nyc cams, ${Object.keys(detections).length} detection sets`);
// ---------------------------------------------------------------- cities
const cityCam = new THREE.PerspectiveCamera(PORTRAIT ? 60 : 36, W / H, 0.05, 600);
const cityScenes = [];
for (const c of CITIES) {
const d = await loadCity(c.slug);
if (!d.meta) {
console.warn('city missing', c.slug);
continue;
}
const built = buildCity(d, { modeled: modeledFor(c.slug, d) });
cityScenes.push({ ...c, d, built });
status(`city ${c.slug}: ${JSON.stringify(built.stats)}`);
}
window.__cities = cityScenes;
const CITY_CAMS = {
everest: [[[17, 26, 22], [0, 4.5, 0]], [[7, 16, 17], [0, 6, -1]], [[-9, 12.5, 13], [0, 6.5, 0]]],
nyc: [[[0.2, 7.5, 2.2], [0, 0, -0.3]], [[1.7, 2.7, 2.5], [0, 0.1, -0.4]], [[2.7, 1.15, 0.5], [0.1, 0.15, -0.6]]],
boston: [[[-0.4, 5.6, 3.4], [0, 0, 0]], [[1.3, 2.5, 2.7], [0, 0, -0.2]], [[2.5, 1.35, 1.0], [0.1, 0.05, -0.3]]],
lax: [[[-4.2, 2.8, 3.6], [0, 0, 0]], [[-1.0, 1.5, 3.0], [0.3, 0, 0]], [[1.9, 0.95, 2.3], [0.6, 0, -0.2]]],
bragg: [[[0, 9, 8], [0, 0, 0]], [[-3.2, 4.8, 5.2], [0.3, 0, 0]], [[-5.2, 2.8, 2.1], [0.5, 0, 0]]],
};
const camCurves = {};
for (const [k, keys] of Object.entries(CITY_CAMS)) {
const s = PORTRAIT ? 1.18 : 1;
camCurves[k] = {
pos: new THREE.CatmullRomCurve3(keys.map(([p, q]) => new THREE.Vector3(q[0] + (p[0] - q[0]) * s, q[1] + (p[1] - q[1]) * s, q[2] + (p[2] - q[2]) * s))),
tgt: new THREE.CatmullRomCurve3(keys.map(([, q]) => new THREE.Vector3(...q))),
};
}
const KMV = 0.001;
const cv = new THREE.Vector3();
function projectCity(x, y, z = 0) {
cv.set(x * KMV, z * KMV, -y * KMV).project(cityCam);
return { x: (cv.x * 0.5 + 0.5) * W, y: (-cv.y * 0.5 + 0.5) * H, vis: cv.z < 1 && cv.z > -1 };
}
const hud = createHUD({ W, H, PORTRAIT, heroes, cams, counts, rapidList, pick, capT, snapT, sunLL, flurry, nycCams });
// ---------------------------------------------------------------- camera director
// Keys: [t, lon, lat, g (globe radius / half the short side), shiftX, shiftY, roll]
const sx = PORTRAIT ? 0 : 0.26;
const sy = PORTRAIT ? 0.2 : 0;
const KEYS = [
[0, 0, 0, 0.6, 0, 0, 0],
[8.7, 0, 0, 0.6, 0, 0, 0],
[16.6, -1.5, 1.5, 0.62, 0, 0, 0],
[19.5, 4, 6, 0.7, 0, 0, -0.01],
[22.6, 14, 12, 0.74, 0, 0, -0.02],
// the flurry: the planet whips a full turn, accelerating then braking
[23.6, 20, 12, 0.74, 0, 0, -0.02],
[25.0, 50, 14, 0.74, 0, 0, -0.03],
[26.2, 120, 16, 0.72, 0, 0, -0.04],
[27.2, 230, 18, 0.72, 0, 0, -0.04],
[28.2, 330, 24, 0.78, 0, 0, -0.03],
[29.2, 375, 30, 0.9, sx * 0.3, sy * 0.3, -0.02],
[30.0, 384, 36, 0.98, sx * 0.5, sy * 0.5, -0.02],
[31.8, 374, 58, 1.85, sx, sy, -0.03],
[32.8, 368, 59, 1.9, sx, sy, -0.03],
[34.4, 305, 43, 1.0, sx, sy, -0.01],
[35.6, 268, 30, 0.5, sx, sy, 0.0],
[37.6, 225, 20, 0.62, sx, sy, 0.02],
[39.4, 175, 2, 0.7, sx, sy, 0.01],
[41.0, 150, 6, 0.78, sx, sy, 0.0],
// descent: swing to the Himalaya and fall in
[42.4, 100, 22, 1.1, sx * 0.4, sy * 0.4, 0.0],
[43.2, 88, 27.5, 3.5, 0, 0, 0.0],
[43.7, 87, 28, 9.0, 0, 0, 0.0],
// behind the camera wall, and back out for saturation
[71.0, 205, 16, 0.9, 0, 0, 0.0],
[76.4, 200, 18, 0.9, 0, 0, 0.0],
[80.0, 178, 24, 0.5, 0, 0, 0.0],
[84.4, 148, 22, 0.16, 0, 0, 0.0],
];
// Title framing: the planet becomes the iris of the logo.
const eye = hud.eyeLayout;
// Each flurry capture fires as its real location rotates into view on the
// leading (east) side of the disc during the spin (18° east of centre).
{
const samples = [];
for (let i = 0; i <= 600; i++) {
const tt = T.flurry + (i / 600) * (T.flurryEnd - T.flurry);
samples.push([tt, camAt(tt).lon]);
}
const lo = samples[0][1];
const hi = samples[samples.length - 1][1];
for (const f of flurry) {
let L = f.lon - 18;
while (L < lo) L += 360;
if (L > hi) continue;
const s = samples.find((q) => q[1] >= L);
if (s) f.t = s[0] + (hash01(f.lat * 13.3) - 0.5) * 0.08;
}
}
function camAt(t) {
if (t >= T.title - 0.05) {
const g = eye.irisR / (Math.min(W, H) / 2);
const k = t - T.title;
return { lon: 108 + k * 1.6, lat: 12, g, shiftX: 0, shiftY: (H / 2 - eye.cy) / (Math.min(W, H) / 2), roll: 0 };
}
let i = 0;
while (i < KEYS.length - 2 && t > KEYS[i + 1][0]) i++;
const k0 = KEYS[Math.max(0, i - 1)];
const k1 = KEYS[i];
const k2 = KEYS[i + 1];
const k3 = KEYS[Math.min(KEYS.length - 1, i + 2)];
const u = clamp((t - k1[0]) / (k2[0] - k1[0]));
const out = [];
for (let c = 1; c < 7; c++) {
const dt = k2[0] - k1[0];
const m1 = ((k2[c] - k0[c]) / Math.max(1e-3, k2[0] - k0[0])) * dt;
const m2 = ((k3[c] - k1[c]) / Math.max(1e-3, k3[0] - k1[0])) * dt;
const u2 = u * u;
const u3 = u2 * u;
out[c] = (2 * u3 - 3 * u2 + 1) * k1[c] + (u3 - 2 * u2 + u) * m1 + (-2 * u3 + 3 * u2) * k2[c] + (u3 - u2) * m2;
}
// breathing drift so the shot never feels locked off
const drift = smooth(t, 8, 12);
return {
lon: out[1] + Math.sin(t * 0.21) * 0.6 * drift,
lat: out[2] + Math.sin(t * 0.17 + 1.3) * 0.4 * drift,
g: out[3],
shiftX: out[4],
shiftY: out[5],
roll: out[6],
};
}
const HITS = [
[T.collapse, 0.5],
[T.substrate, 0.7],
[T.indexed, 0.9],
[T.cctvWall, 0.5],
[T.cctvOut, 0.6],
[T.dishBreak, 1.0],
[T.eyeOpen, 0.4],
];
function impulse(t, decay = 6) {
let v = 0;
for (const [h, a] of HITS) if (t >= h) v += a * Math.exp(-(t - h) * decay);
return v;
}
function placeCamera(t) {
const c = camAt(t);
const minDim = Math.min(W, H);
const tanHalf = Math.tan((FOV * D2R) / 2);
const tanA = (c.g * minDim) / H * tanHalf;
const dist = 1 / Math.sin(Math.atan(tanA));
const shake = impulse(t, 9) * 0.0025;
const sh1 = Math.sin(t * 91.0) * shake;
const sh2 = Math.cos(t * 77.0) * shake;
camera.position.set(...llToVec(c.lon, c.lat, dist));
camera.up.set(0, 1, 0);
camera.lookAt(0, 0, 0);
camera.rotateZ(c.roll + sh1 * 2);
camera.near = Math.max(0.002, dist - 30);
camera.far = dist + 2000;
// shift the globe on screen without changing perspective
const offX = -c.shiftX * (minDim / 2) + sh1 * W;
const offY = c.shiftY * (minDim / 2) + sh2 * H;
camera.setViewOffset(W, H, offX, offY, W, H);
camera.updateProjectionMatrix();
camera.updateMatrixWorld();
return { ...c, dist };
}
// ---------------------------------------------------------------- per-frame state
const tmpV = new THREE.Vector3();
function project(lon, lat, r = 1) {
tmpV.set(...llToVec(lon, lat, r));
const n = tmpV.clone().normalize();
const toCam = camera.position.clone().sub(tmpV).normalize();
const facing = r <= 1.2 ? n.dot(toCam) : 1;
tmpV.project(camera);
return { x: (tmpV.x * 0.5 + 0.5) * W, y: (-tmpV.y * 0.5 + 0.5) * H, vis: facing > 0.05 && tmpV.z < 1, facing };
}
function projectVec(v) {
tmpV.set(v[0], v[1], v[2]);
tmpV.project(camera);
return { x: (tmpV.x * 0.5 + 0.5) * W, y: (-tmpV.y * 0.5 + 0.5) * H, z: tmpV.z };
}
function frontAngle(t) {
if (t < T.growStart) return -1;
const x = ramp(t, T.growStart, T.growEnd);
const k = 3;
const base = Math.PI * (Math.exp(k * x) - 1) / (Math.exp(k) - 1);
return base + Math.max(0, t - T.growEnd) * 0.6;
}
const satScratch = { idx: -1 };
function updateSatellites(t, simDate) {
const gmst = satellite.gstime(simDate);
ringGroup.rotation.y = -gmst;
if (satScratch.t === t) return gmst;
satScratch.t = t;
const p = satL.positions;
for (let i = 0; i < sats.length; i++) {
const pv = satellite.propagate(sats[i].rec, simDate);
const e = pv && pv.position;
if (!e || isNaN(e.x)) {
p[i * 3] = p[i * 3 + 1] = p[i * 3 + 2] = 0;
continue;
}
const ecf = satellite.eciToEcf(e, gmst);
p[i * 3] = ecf.x / EARTH_KM;
p[i * 3 + 1] = ecf.z / EARTH_KM;
p[i * 3 + 2] = -ecf.y / EARTH_KM;
}
satL.attr.needsUpdate = true;
return gmst;
}
function feedAlpha(t, start) {
// everything stays on through saturation, then clears for the title
return smooth(t, start, start + 0.25) * (1 - smooth(t, T.cut - 0.05, T.cut));
}
function renderFrame(frame) {
const t = frame / FPS;
const cam = placeCamera(t);
const cityNow = cityScenes.find((c) => t >= c.t0 && t < c.t1);
const G = globe.uniforms;
const front = frontAngle(t);
const titleOn = t >= T.title - 0.05;
G.uTime.value = t;
G.uFront.value = titleOn ? 10 : front;
G.uHolo.value = smooth(t, T.growStart - 0.4, T.growStart + 1.2) * (1 - smooth(t, T.indexed - 1, T.indexed + 1));
G.uLights.value = smooth(t, T.growStart, T.growStart + 0.8);
G.uImagery.value = titleOn ? 1 : smooth(t, 26.5, 29.5);
G.uLit.value = titleOn ? 1 : smooth(t, 22.6, 26.0);
G.uGridAmt.value = titleOn ? 0 : 1;
G.uDim.value = 1 - 0.35 * win(t, T.cctvWall - 0.2, T.cctvOut + 0.3, 0.3, 0.3);
globe.cloudU.uAmt.value = titleOn ? 0.6 : smooth(t, T.wind, T.wind + 1.6) * 0.62;
globe.atmoU.uAmt.value = titleOn ? 1 : smooth(t, 21.0, 25.0);
globe.group.children[1].visible = globe.cloudU.uAmt.value > 0.0005;
globe.group.children[2].visible = globe.atmoU.uAmt.value > 0.0005;
globe.group.visible = !(t >= T.cut && t < T.title - 0.05) && t >= T.seed - 0.1;
stars.uniforms.uAlpha.value = titleOn ? 0.5 : smooth(t, T.substrate - 0.5, T.substrate + 2.5) * 0.85 * (t < T.cut ? 1 : 0);
const vecFade = 1 - smooth(t, T.indexStart + 1.5, T.indexed - 2);
if (coastline) {
coastline.material.uniforms.uFront.value = front;
coastline.material.uniforms.uAlpha.value = 0.95 * vecFade;
coastline.visible = vecFade > 0.001 && !titleOn;
}
if (borderL) {
borderL.material.uniforms.uFront.value = front - 0.25;
borderL.material.uniforms.uAlpha.value = 0.7 * vecFade;
borderL.visible = vecFade > 0.001 && !titleOn;
}
myc.material.uniforms.uFront.value = front;
myc.material.uniforms.uAlpha.value = 0.85 * (1 - smooth(t, T.substrate, T.indexStart + 3));
myc.visible = t > T.growStart && t < T.indexStart + 3.2;
// feeds
const dens = clamp((cam.g - 0.12) / 0.75, 0.22, 1);
const reveal = (start, dur = 1.6, spread = 1.4) => ((t - start) * (1 + spread)) / dur;
const setMover = (l, start, accel) => {
if (!l) return;
l.uniforms.uSim.value = Math.max(0, t - start) * accel;
l.uniforms.uReveal.value = reveal(start, 1.6, l.uniforms.uSpread.value);
l.uniforms.uAlpha.value = feedAlpha(t, start) * dens;
l.group.visible = t >= start && t < T.cut;
};
setMover(L.air, T.air, 140);
setMover(L.mil, T.mil, 140);
setMover(L.sea, T.sea, 1800);
const setEv = (l, start, dur = 1.8) => {
if (!l) return;
l.uniforms.uT.value = t;
l.uniforms.uReveal.value = reveal(start, dur, l.uniforms.uSpread.value);
l.uniforms.uAlpha.value = feedAlpha(t, start) * dens;
l.group.visible = t >= start && t < T.cut;
};
setEv(L.quake, T.quake, 1.6);
setEv(L.fire, T.fire, 1.5);
if (L.fire) L.fire.uniforms.uAlpha.value *= dens;
if (L.wind) {
const on = t >= T.wind && t < T.cut;
L.wind.group.visible = on;
if (on) {
L.wind.update(t - T.wind);
L.wind.uniforms.uAlpha.value = feedAlpha(t, T.wind) * 2.2 * dens;
}
}
if (cableL) {
const u = cableL.material.uniforms;
u.uT.value = t;
u.uDraw.value = ramp(t, T.cable, T.cable + 1.8);
u.uAlpha.value = feedAlpha(t, T.cable) * 0.9;
cableL.visible = t >= T.cable && t < T.cut;
}
// satellites + rings
const satOn = t >= T.orbit && t < T.cut;
const simDate = new Date(snapT.getTime() + Math.max(0, t - T.orbit) * 45 * 1000);
satL.group.visible = satOn;
rings.visible = satOn;
let gmst = satellite.gstime(simDate);
if (satOn) {
gmst = updateSatellites(t, simDate);
satL.uniforms.uReveal.value = reveal(T.orbit, 1.8, 1.3);
satL.uniforms.uAlpha.value = feedAlpha(t, T.orbit) * Math.max(0.45, dens);
rings.material.uniforms.uDraw.value = easeInOut(ramp(t, T.orbit + 0.3, T.orbit + 2.4));
rings.material.uniforms.uAlpha.value = feedAlpha(t, T.orbit) * 0.8;
}
ringsHigh.visible = t >= T.saturate + 1 && t < T.cut;
ringsHigh.material.uniforms.uDraw.value = easeInOut(ramp(t, T.saturate + 1, T.saturate + 3.5));
ringsHigh.material.uniforms.uAlpha.value = feedAlpha(t, T.saturate + 1) * 0.7;
// saturation: arcs + lattice
arcs.visible = t >= T.arcs && t < T.cut;
arcs.material.uniforms.uT.value = t - T.arcs;
arcs.material.uniforms.uAlpha.value = feedAlpha(t, T.arcs) * 0.9;
const twinOn = t >= T.twin && t < T.cut;
lattice.visible = twinOn;
if (twinOn) {
lattice.scale.setScalar(latticeScale(t, cam.dist));
lattice.rotation.y = t * 0.05;
const lu = lattice.material.uniforms;
lu.uBuild.value = ramp(t, T.twin, T.twin + 2.6);
lu.uT.value = t;
lu.uAlpha.value = smooth(t, T.twin, T.twin + 0.6) * (1 - smooth(t, T.cut - 0.4, T.cut)) * 0.7;
}
// ---- HUD
const S = {
t,
frame,
cam,
front,
gmst,
simDate,
project,
projectVec,
satPos: (norad) => {
const i = satByNorad.get(norad);
if (i === undefined) return null;
const p = satL.positions;
return [p[i * 3], p[i * 3 + 1], p[i * 3 + 2]];
},
limbR: limbRadiusPx(cam.dist),
center: project(...[cam.lon, cam.lat], 0), // screen position of the globe centre
flightPos: (row, accel) => {
const sim = Math.max(0, t - T.air) * accel;
return advance(row, sim);
},
shipPos: (row) => advance([row[0], row[1], 0, row[3], (row[2] || 0) * 0.5144], Math.max(0, t - T.sea) * 1800),
};
S.center = projectVec([0, 0, 0]);
S.ctx = hudCtx;
S.camPos = camera.position;
S.latticeR = latticePx(t, cam.dist);
S.ringR = ringR0;
let renderScene = scene;
let renderCam = camera;
if (cityNow) {
const lt = t - cityNow.t0;
const k = (t - cityNow.t0) / (cityNow.t1 - cityNow.t0);
const u = 1 - Math.pow(1 - k, 2.2);
const cc = camCurves[cityNow.slug];
cityCam.position.copy(cc.pos.getPoint(u));
const tg = cc.tgt.getPoint(u);
// a hair of handheld drift
cityCam.position.x += Math.sin(t * 0.7) * 0.01;
cityCam.position.y += Math.sin(t * 0.53) * 0.008;
cityCam.lookAt(tg);
const sh = impulse(t, 9) * 0.003;
cityCam.rotateZ(Math.sin(t * 91) * sh);
cityCam.updateMatrixWorld();
cityNow.built.update(lt, k, pxScale);
// Boston is the transit showcase: let the GTFS lines lead
if (cityNow.slug === 'boston' && cityNow.built.roads) cityNow.built.roads.mesh.material.uniforms.uAlpha.value = 0.45;
renderScene = cityNow.built.scene;
renderCam = cityCam;
S.city = { ...cityNow, lt, k, project: projectCity, built: cityNow.built };
S.cam = { ...S.cam, lat: cityNow.lat, lon: cityNow.lon, dist: 1 + cityCam.position.y / 6371 };
}
hud.draw(S);
// ---- post
const glitch =
win(t, T.collapse - 0.05, T.collapse + 0.35, 0.02, 0.2) * 0.8 +
win(t, T.substrate - 0.02, T.substrate + 0.25, 0.02, 0.2) * 0.5 +
win(t, T.indexed - 0.02, T.indexed + 0.3, 0.02, 0.25) * 0.7 +
win(t, T.cctvWall - 0.02, T.cctvWall + 0.2, 0.02, 0.15) * 0.5 +
win(t, T.cctvOut - 0.05, T.cctvOut + 0.3, 0.02, 0.25) * 0.8 +
win(t, T.dishBreak - 0.02, T.dishBreak + 0.4, 0.02, 0.3) * 1.0 +
win(t, T.cut - 0.25, T.cut, 0.1, 0.01) * 1.0 +
win(t, T.eyeOpen - 0.02, T.eyeOpen + 0.2, 0.02, 0.15) * 0.4 +
win(t, T.terrain - 0.35, T.terrain + 0.25, 0.25, 0.2) * 1.0 +
CITIES.slice(1).reduce((s, c) => s + win(t, c.t0 - 0.06, c.t0 + 0.22, 0.04, 0.16) * 0.8, 0);
const flash =
Math.exp(-Math.max(0, t - T.substrate) * 12) * 0.035 * (t >= T.substrate ? 1 : 0) +
Math.exp(-Math.max(0, t - T.indexed) * 12) * 0.05 * (t >= T.indexed ? 1 : 0) +
Math.exp(-Math.max(0, t - T.dishBreak) * 10) * 0.06 * (t >= T.dishBreak ? 1 : 0) +
Math.exp(-Math.max(0, t - T.eyeOpen) * 10) * 0.05 * (t >= T.eyeOpen ? 1 : 0);
let fade = smooth(t, 0, 0.6);
if (t >= T.cut && t < T.title) fade = 0;
fade *= 1 - smooth(t, T.fadeOut, DURATION_END);
post.render(renderScene, renderCam, {
time: t,
frame,
bloom: t < T.seed ? 0.7 : lerp(0.85, 1.15, smooth(t, T.saturate, T.cut)),
bloomThresh: 0.78,
exposure: 1.0,
aberr: titleOn ? 0.00025 + glitch * 0.004 : 0.0012 + 0.002 * smooth(t, T.saturate, T.cut) + glitch * 0.004,
grain: 0.05,
vignette: 0.6,
glitch,
scan: t < T.seed ? 0.7 : 0.12,
flash,
fade,
hudGain: 1.0,
});
}
const RING_R = Math.min(W, H) * (PORTRAIT ? 0.43 : 0.4);
function latticePx(t, dist) {
const limb = limbRadiusPx(dist) * 1.12;
if (t < T.dishBreak) return lerp(limb, RING_R, Math.pow(easeInOut(ramp(t, T.twin, T.dishBreak)), 1.2));
return RING_R * (1 + 1.6 * Math.pow(ramp(t, T.dishBreak, T.cut), 0.8));
}
function latticeScale(t, dist) {
const px = latticePx(t, dist);
const tanA = (px / (H / 2)) * Math.tan((FOV * D2R) / 2);
return dist * Math.sin(Math.atan(tanA));
}
function limbRadiusPx(dist) {
const tanA = Math.tan(Math.asin(1 / dist));
return (tanA / Math.tan((FOV * D2R) / 2)) * (H / 2);
}
function advance(row, sim) {
// row: [lon, lat, alt, track, vel(m/s)] -> [lon, lat] after sim seconds
const d = ((row[4] || 0) / (EARTH_KM * 1000)) * sim;
const b = (row[3] || 0) * D2R;
const la = row[1] * D2R;
const lo = row[0] * D2R;
const lat2 = Math.asin(Math.sin(la) * Math.cos(d) + Math.cos(la) * Math.sin(d) * Math.cos(b));
const lon2 = lo + Math.atan2(Math.sin(b) * Math.sin(d) * Math.cos(la), Math.cos(d) - Math.sin(la) * Math.sin(lat2));
return [lon2 * R2D, lat2 * R2D, row[2] || 0];
}
// the dish's second coming is sized so the twin breaches it exactly on cue
const ringR0 = RING_R;
window.GEV = { renderFrame, ready: true, W, H, frames: Math.round(DURATION_END * FPS), counts, heroes: heroes.length, cams: cams.length, capT };
status('ready');
if (qs.has('play')) startPlayback(renderFrame);
}
const DURATION_END = DURATION;
const hash01 = (n) => {
const s = Math.sin(n * 127.1 + 311.7) * 43758.5453;
return s - Math.floor(s);
};
// Modelled actors (always labelled MODELED in the HUD) where real feeds are thin.
function modeledFor(slug, d) {
const out = [];
if (slug === 'nyc') {
const realHelis = (d.air?.a || []).filter((p) => p[8] === 'A7').length;
if (realHelis < 3) {
// Hudson and East River corridors, and a hover orbit over Midtown
out.push({ name: 'HUDSON CORRIDOR', path: [[-1750, -3300, 300], [-1950, -1000, 330], [-1800, 1500, 350], [-1450, 3300, 320], [-1250, 1500, 300], [-1400, -1000, 290]], speed: 0.09, phase: 0.1, color: '#7dffd6' });
out.push({ name: 'EAST RIVER', path: [[1650, -3200, 260], [1900, -800, 280], [2150, 1800, 300], [1750, 2600, 300], [1450, 800, 270], [1350, -1500, 250]], speed: 0.08, phase: 0.55, color: '#7dffd6' });
out.push({ name: 'MIDTOWN ORBIT', path: loopPath(-150, 900, 420, 380, 450, 0.3, 40), speed: 0.16, phase: 0.2, color: '#7dffd6' });
}
}
if (slug === 'bragg') {
const rw = d.military?.runways?.[0];
let cx = 0, cy = 0, rot = 0;
if (rw) {
const x1 = rw[2], y1 = rw[3], x2 = rw[rw.length - 2], y2 = rw[rw.length - 1];
cx = (x1 + x2) / 2;
cy = (y1 + y2) / 2;
rot = Math.atan2(y2 - y1, x2 - x1);
}
out.push({ name: 'PATTERN', path: loopPath(cx, cy, 2600, 1100, 330, rot, 48), speed: 0.07, phase: 0.0, color: '#ffb547' });
out.push({ name: 'PATTERN', path: loopPath(cx, cy, 2600, 1100, 330, rot, 48), speed: 0.07, phase: 0.5, color: '#ffb547' });
out.push({ name: 'ROTARY', path: loopPath(cx - 3000, cy + 2500, 900, 700, 180, 0.4, 32), speed: 0.11, phase: 0.3, color: '#ffb547' });
}
return out;
}
// Real-time mode (?play): the same deterministic frames, driven by the audio
// clock, for use as a live loading sequence on a GPU. Browsers block audio
// until a gesture, so the first click starts it.
function startPlayback(renderFrame) {
const audio = new Audio('audio/score.wav');
const cv = document.getElementById('c');
cv.style.cssText = 'width:100vw;height:100vh;object-fit:contain;display:block';
const gate = document.createElement('div');
gate.textContent = 'CLICK TO INITIALISE';
gate.style.cssText = 'position:fixed;inset:0;display:flex;align-items:center;justify-content:center;color:#00f6ff;font:500 14px "JetBrains Mono";letter-spacing:6px;cursor:pointer;background:#000';
document.body.appendChild(gate);
gate.addEventListener('click', () => {
gate.remove();
audio.play().catch(() => {});
const t0 = performance.now();
const tick = () => {
const t = audio.currentTime > 0 ? audio.currentTime : (performance.now() - t0) / 1000;
if (t < DURATION_END) {
renderFrame(Math.min(DURATION_END * FPS - 1, Math.floor(t * FPS)));
requestAnimationFrame(tick);
}
};
requestAnimationFrame(tick);
});
}
function pickFlights(f) {
if (!f) return [];
// long-haul, high, fast, with recognisable callsigns
return f.rows
.filter((r) => r[5] && r[2] > 9000 && r[4] > 220 && /^[A-Z]{3}\d/.test(r[5]))
.sort((a, b) => b[4] - a[4])
.slice(0, 120);
}
init().catch((e) => {
status('error: ' + e.message);
console.error(e);
});
+218
View File
@@ -0,0 +1,218 @@
import * as THREE from 'three';
// HDR scene -> HUD composited in -> dual-filter bloom -> lens + film finish.
const QUAD_VS = /* glsl */ `
varying vec2 vUv;
void main() { vUv = uv; gl_Position = vec4(position.xy, 0.0, 1.0); }
`;
export function createPost(renderer, W, H, hudCanvas) {
const rtOpts = { type: THREE.HalfFloatType, format: THREE.RGBAFormat, depthBuffer: false };
const scene = new THREE.WebGLRenderTarget(W, H, { type: THREE.HalfFloatType, samples: 4 });
const merged = new THREE.WebGLRenderTarget(W, H, rtOpts);
const levels = [];
let w = W;
let h = H;
for (let i = 0; i < 6; i++) {
w = Math.max(2, Math.floor(w / 2));
h = Math.max(2, Math.floor(h / 2));
levels.push({ down: new THREE.WebGLRenderTarget(w, h, rtOpts), up: new THREE.WebGLRenderTarget(w, h, rtOpts) });
}
const hudTex = new THREE.CanvasTexture(hudCanvas);
hudTex.colorSpace = THREE.SRGBColorSpace;
hudTex.minFilter = THREE.LinearFilter;
hudTex.generateMipmaps = false;
const quad = new THREE.Mesh(new THREE.PlaneGeometry(2, 2));
const qScene = new THREE.Scene();
qScene.add(quad);
const qCam = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1);
const mergeMat = new THREE.ShaderMaterial({
uniforms: { tScene: { value: null }, tHud: { value: hudTex }, uHudGain: { value: 1.25 } },
vertexShader: QUAD_VS,
fragmentShader: /* glsl */ `
uniform sampler2D tScene, tHud;
uniform float uHudGain;
varying vec2 vUv;
void main() {
vec3 s = texture2D(tScene, vUv).rgb;
vec4 hud = texture2D(tHud, vUv);
// canvas uploads un-premultiplied: straight-alpha over-composite
vec3 c = s * (1.0 - hud.a) + hud.rgb * hud.a * uHudGain;
gl_FragColor = vec4(c, 1.0);
}
`,
depthTest: false,
});
const downMat = new THREE.ShaderMaterial({
uniforms: { tSrc: { value: null }, uTexel: { value: new THREE.Vector2() }, uThresh: { value: 0 }, uFirst: { value: 0 } },
vertexShader: QUAD_VS,
fragmentShader: /* glsl */ `
uniform sampler2D tSrc;
uniform vec2 uTexel;
uniform float uThresh, uFirst;
varying vec2 vUv;
vec3 pre(vec3 c) {
if (uFirst < 0.5) return c;
float l = max(c.r, max(c.g, c.b));
float k = max(l - uThresh, 0.0) / max(l, 1e-4);
return min(c * k, vec3(40.0));
}
void main() {
vec2 o = uTexel;
vec3 c = pre(texture2D(tSrc, vUv).rgb) * 4.0;
c += pre(texture2D(tSrc, vUv + vec2(-o.x, -o.y)).rgb);
c += pre(texture2D(tSrc, vUv + vec2( o.x, -o.y)).rgb);
c += pre(texture2D(tSrc, vUv + vec2(-o.x, o.y)).rgb);
c += pre(texture2D(tSrc, vUv + vec2( o.x, o.y)).rgb);
gl_FragColor = vec4(c / 8.0, 1.0);
}
`,
depthTest: false,
});
const upMat = new THREE.ShaderMaterial({
uniforms: { tSrc: { value: null }, tBase: { value: null }, uTexel: { value: new THREE.Vector2() } },
vertexShader: QUAD_VS,
fragmentShader: /* glsl */ `
uniform sampler2D tSrc, tBase;
uniform vec2 uTexel;
varying vec2 vUv;
void main() {
vec2 o = uTexel;
vec3 c = texture2D(tSrc, vUv + vec2(-o.x * 2.0, 0.0)).rgb;
c += texture2D(tSrc, vUv + vec2(-o.x, o.y)).rgb * 2.0;
c += texture2D(tSrc, vUv + vec2(0.0, o.y * 2.0)).rgb;
c += texture2D(tSrc, vUv + vec2(o.x, o.y)).rgb * 2.0;
c += texture2D(tSrc, vUv + vec2(o.x * 2.0, 0.0)).rgb;
c += texture2D(tSrc, vUv + vec2(o.x, -o.y)).rgb * 2.0;
c += texture2D(tSrc, vUv + vec2(0.0, -o.y * 2.0)).rgb;
c += texture2D(tSrc, vUv + vec2(-o.x, -o.y)).rgb * 2.0;
gl_FragColor = vec4(c / 12.0 + texture2D(tBase, vUv).rgb, 1.0);
}
`,
depthTest: false,
});
const finalMat = new THREE.ShaderMaterial({
uniforms: {
tSrc: { value: null },
tBloom: { value: null },
uBloom: { value: 0.9 },
uExposure: { value: 1 },
uTime: { value: 0 },
uFrame: { value: 0 },
uAberr: { value: 0.0015 },
uGrain: { value: 0.045 },
uVignette: { value: 0.55 },
uGlitch: { value: 0 },
uScan: { value: 0 },
uFlash: { value: 0 },
uFade: { value: 1 },
uRes: { value: new THREE.Vector2(W, H) },
},
vertexShader: QUAD_VS,
fragmentShader: /* glsl */ `
uniform sampler2D tSrc, tBloom;
uniform float uBloom, uExposure, uTime, uFrame, uAberr, uGrain, uVignette, uGlitch, uScan, uFlash, uFade;
uniform vec2 uRes;
varying vec2 vUv;
float h21(vec2 p) { p = fract(p * vec2(123.34, 456.21)); p += dot(p, p + 45.32); return fract(p.x * p.y); }
vec3 aces(vec3 x) {
const float a = 2.51, b = 0.03, c = 2.43, d = 0.59, e = 0.14;
return clamp((x * (a * x + b)) / (x * (c * x + d) + e), 0.0, 1.0);
}
void main() {
vec2 uv = vUv;
// block glitch: horizontal slices displaced on transitions
if (uGlitch > 0.0) {
float row = floor(uv.y * 48.0);
float blk = floor(uv.x * 6.0);
float r = h21(vec2(row, floor(uFrame * 0.5)));
float r2 = h21(vec2(blk, row + floor(uFrame)));
if (r < uGlitch * 0.55) uv.x += (r2 - 0.5) * 0.08 * uGlitch;
if (r2 < uGlitch * 0.12) uv.y += (r - 0.5) * 0.02;
}
vec2 d = uv - 0.5;
float rr = dot(d, d);
vec2 ab = d * rr * uAberr * 7.0 + vec2(uGlitch * 0.006, 0.0);
vec3 col;
col.r = texture2D(tSrc, uv + ab).r;
col.g = texture2D(tSrc, uv).g;
col.b = texture2D(tSrc, uv - ab).b;
vec3 bl;
bl.r = texture2D(tBloom, uv + ab * 1.5).r;
bl.g = texture2D(tBloom, uv).g;
bl.b = texture2D(tBloom, uv - ab * 1.5).b;
col += bl * uBloom;
// flashes punch exposure instead of adding a veil, so blacks stay black
col *= uExposure * (1.0 + uFlash * 6.0);
col = aces(col);
// vignette
float vig = smoothstep(0.95, 0.18, length(d * vec2(uRes.x / uRes.y, 1.0) * 0.82));
col *= mix(1.0, vig, uVignette);
// scanlines (boot / CRT moments)
col *= 1.0 - uScan * 0.22 * (0.5 + 0.5 * sin(gl_FragCoord.y * 3.14159 * 0.5));
col = pow(col, vec3(1.0 / 2.2));
// grain after encoding so it lives in the shadows too
float g = h21(gl_FragCoord.xy + fract(uFrame * 0.6180339) * 913.0) - 0.5;
col += g * uGrain * (1.0 - 0.6 * dot(col, vec3(0.33)));
col *= uFade;
gl_FragColor = vec4(col, 1.0);
}
`,
depthTest: false,
});
function pass(mat, target) {
quad.material = mat;
renderer.setRenderTarget(target);
renderer.render(qScene, qCam);
}
function render(sceneObj, camera, params) {
renderer.setRenderTarget(scene);
renderer.setClearColor(0x000000, 1);
renderer.clear();
renderer.render(sceneObj, camera);
hudTex.needsUpdate = true;
mergeMat.uniforms.tScene.value = scene.texture;
mergeMat.uniforms.uHudGain.value = params.hudGain ?? 1.2;
pass(mergeMat, merged);
let src = merged;
for (let i = 0; i < levels.length; i++) {
downMat.uniforms.tSrc.value = src.texture;
downMat.uniforms.uTexel.value.set(0.5 / src.width, 0.5 / src.height);
downMat.uniforms.uFirst.value = i === 0 ? 1 : 0;
downMat.uniforms.uThresh.value = params.bloomThresh ?? 0.55;
pass(downMat, levels[i].down);
src = levels[i].down;
}
let up = levels[levels.length - 1].down;
for (let i = levels.length - 2; i >= 0; i--) {
upMat.uniforms.tSrc.value = up.texture;
upMat.uniforms.tBase.value = levels[i].down.texture;
upMat.uniforms.uTexel.value.set(0.5 / up.width, 0.5 / up.height);
pass(upMat, levels[i].up);
up = levels[i].up;
}
const u = finalMat.uniforms;
u.tSrc.value = merged.texture;
u.tBloom.value = up.texture;
for (const k of ['bloom', 'exposure', 'aberr', 'grain', 'vignette', 'glitch', 'scan', 'flash', 'fade']) {
const key = 'u' + k[0].toUpperCase() + k.slice(1);
if (params[k] !== undefined) u[key].value = params[k];
}
u.uTime.value = params.time;
u.uFrame.value = params.frame;
pass(finalMat, null);
}
return { render };
}
+154
View File
@@ -0,0 +1,154 @@
// The film's clock. Everything visual and audible keys off these numbers, so the
// renderer and the score (audio/score.py reads cues.json, exported from here)
// stay frame-locked. Pure module: no DOM, safe to import from Node.
export const FPS = 30;
export const DURATION = 95;
export const FRAMES = FPS * DURATION;
export const T = {
// ACT 0 — the void
cursor: 0.9,
boot: 1.4,
collapse: 4.9,
seed: 5.35,
beat1: 6.1,
beat2: 7.35,
dishDraw: 6.3,
dishDone: 8.3,
aperture: 7.35,
// ACT 1 — the eye opens; the culture grows and turns out to be drawing the planet
growStart: 8.7,
coastReached: 13.4,
growEnd: 15.6,
substrate: 16.0,
// ACT 2 — indexing the surface, accelerating until it is whipping round the world
indexStart: 17.0,
zWave: [17.0, 18.5, 19.8], // z3, z4, z5 quadtree waves
capStart: 18.0,
capRapid: 22.6,
flurry: 23.6,
flurryEnd: 29.4,
capFull: 999, // v1's full-frame extraction now lives in the city act
indexed: 30.0,
// ACT 3 — global ingestion
air: 30.6,
sea: 32.0,
mil: 33.4,
orbit: 34.6,
quake: 36.6,
fire: 38.0,
wind: 39.4,
cable: 41.0,
descend: 42.6,
// ACT 4 — down into the world
terrain: 43.6,
nyc: 49.0,
boston: 57.0,
lax: 61.8,
bragg: 66.4,
cctv: 71.4,
cctvWall: 71.4,
rapid: 72.8,
cctvOut: 76.4,
// ACT 5 — saturation; the twin outgrows the dish
saturate: 76.6,
arcs: 77.3,
twin: 79.1,
dishBreak: 82.3,
omni: 82.9,
cut: 84.4,
// ACT 6 — resolve
title: 85.1,
eyeOpen: 85.9,
wordmark: 87.2,
tagline: 88.9,
online: 999,
fadeOut: 93.0,
};
// Feeds as they register in the stack (ACT 3). `key` matches a layer.
export const FEEDS = [
{ key: 'air', t: T.air, code: 'ADS-B', label: 'AIRCRAFT', src: 'OPENSKY NETWORK' },
{ key: 'sea', t: T.sea, code: 'AIS', label: 'VESSELS', src: 'KYSTVERKET · DIGITRAFFIC' },
{ key: 'mil', t: T.mil, code: 'ADS-B', label: 'MILITARY', src: 'ADSB.LOL · ADSB.FI' },
{ key: 'orbit', t: T.orbit, code: 'TLE', label: 'SATELLITES', src: 'CELESTRAK' },
{ key: 'quake', t: T.quake, code: 'SEIS', label: 'EARTHQUAKES', src: 'USGS' },
{ key: 'fire', t: T.fire, code: 'IR', label: 'ACTIVE FIRES', src: 'NASA FIRMS' },
{ key: 'wind', t: T.wind, code: 'NWP', label: 'WIND · CLOUD', src: 'NOAA GFS · GIBS' },
{ key: 'cable', t: T.cable, code: 'GEO', label: 'SUBSEA CABLES', src: 'TELEGEOGRAPHY' },
];
// ACT 4 — the cities, in order
export const CITIES = [
{ slug: 'everest', t0: T.terrain, t1: T.nyc, name: 'MOUNT EVEREST', sub: 'KHUMBU · NEPAL / TIBET', lat: 27.9881, lon: 86.925 },
{ slug: 'nyc', t0: T.nyc, t1: T.boston, name: 'NEW YORK', sub: 'MIDTOWN · MANHATTAN', lat: 40.74, lon: -73.99 },
{ slug: 'boston', t0: T.boston, t1: T.lax, name: 'BOSTON', sub: 'MBTA · GTFS REALTIME', lat: 42.3555, lon: -71.0605 },
{ slug: 'lax', t0: T.lax, t1: T.bragg, name: 'LOS ANGELES INTL', sub: 'KLAX · AIRSIDE', lat: 33.9425, lon: -118.4081 },
{ slug: 'bragg', t0: T.bragg, t1: T.cctv, name: 'FORT BRAGG', sub: 'NORTH CAROLINA · POPE FIELD', lat: 35.158, lon: -79.009 },
];
// Synthetic captures in ACT 2: the first few take the slow "enhance" path, the
// rest fire rapidly into the contact sheet, then one blows up full frame.
export const CAPTURE_SLOW = 3;
export function captureTimes(count) {
const out = [];
const slot = [T.capStart, T.capStart + 1.6, T.capStart + 3.1];
for (let i = 0; i < Math.min(CAPTURE_SLOW, count); i++) out.push(slot[i]);
const rapidN = count - CAPTURE_SLOW;
// Accelerating rapid-fire: intervals shrink from ~0.32 s to ~0.11 s.
let t = T.capRapid;
for (let i = 0; i < rapidN; i++) {
out.push(t);
const k = i / Math.max(1, rapidN - 1);
t += 0.12 - 0.08 * k;
}
return out;
}
// ------------------------------------------------------------------ helpers
export const clamp = (x, a = 0, b = 1) => Math.min(b, Math.max(a, x));
export const lerp = (a, b, t) => a + (b - a) * t;
export const ramp = (t, a, b) => clamp((t - a) / (b - a));
export const smooth = (t, a, b) => {
const x = ramp(t, a, b);
return x * x * (3 - 2 * x);
};
export const easeInOut = (x) => (x < 0.5 ? 4 * x * x * x : 1 - Math.pow(-2 * x + 2, 3) / 2);
export const easeOut = (x) => 1 - Math.pow(1 - x, 3);
export const easeIn = (x) => x * x * x;
export const expoOut = (x) => (x >= 1 ? 1 : 1 - Math.pow(2, -10 * x));
export const expoInOut = (x) =>
x <= 0 ? 0 : x >= 1 ? 1 : x < 0.5 ? Math.pow(2, 20 * x - 10) / 2 : (2 - Math.pow(2, -20 * x + 10)) / 2;
// Window: 0 before a, ramps up over `inDur`, holds, ramps down to 0 ending at b.
export function win(t, a, b, inDur = 0.3, outDur = 0.3) {
return Math.min(smooth(t, a, a + inDur), 1 - smooth(t, b - outDur, b));
}
export function mulberry32(seed) {
let a = seed >>> 0;
return function () {
a = (a + 0x6d2b79f5) >>> 0;
let t = a;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
export function hash1(n) {
const s = Math.sin(n * 127.1 + 311.7) * 43758.5453;
return s - Math.floor(s);
}
// Counter that eases toward `to` and keeps visibly ticking while it does.
export function count(t, a, b, from, to, curve = expoOut) {
return Math.round(lerp(from, to, curve(ramp(t, a, b))));
}
+79
View File
@@ -0,0 +1,79 @@
"""Render city/<slug>/check.png: ground imagery (left) and imagery + OSM overlay (right) for alignment QA.
usage: python3 -I tools/city/check.py nyc boston [--crop x y size]
"""
import sys, os, json
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from PIL import Image, ImageDraw
from geo import CITIES, OUT, CACHE
ROADC = [(255, 60, 60), (255, 150, 40), (255, 220, 40), (200, 255, 80), (255, 255, 255), (160, 160, 160)]
def render(slug, S=1400, crop=None):
d = os.path.join(OUT, slug)
h = CITIES[slug]['half']
cx, cy, cs = crop if crop else (0, 0, h) # center and half-size of view in meters
g = Image.open(os.path.join(d, 'ground.jpg')).convert('RGB')
G = g.size[0]
# crop region of ground in pixels
l = (cx - cs + h) / (2 * h) * G
t = (h - (cy + cs)) / (2 * h) * G
r = (cx + cs + h) / (2 * h) * G
b = (h - (cy - cs)) / (2 * h) * G
base = g.crop((int(l), int(t), int(r), int(b))).resize((S, S), Image.LANCZOS)
def P(x, y):
return ((x - (cx - cs)) / (2 * cs) * S, ((cy + cs) - y) / (2 * cs) * S)
ov = base.copy()
dr = ImageDraw.Draw(ov, 'RGBA')
wp = os.path.join(d, 'water.json')
if os.path.exists(wp):
for poly in json.load(open(wp))['polys']:
for k, ring in enumerate(poly):
pts = [P(ring[i], ring[i + 1]) for i in range(0, len(ring), 2)]
if len(pts) >= 3:
dr.polygon(pts, outline=(60, 160, 255, 255), fill=(30, 90, 255, 70) if k == 0 else None)
for bld in json.load(open(os.path.join(d, 'buildings.json')))['b']:
hgt = bld[0]
pts = [P(bld[i], bld[i + 1]) for i in range(2, len(bld), 2)]
a = min(255, 60 + int(hgt))
col = (0, 255, 200, 255) if hgt >= 100 else (255, 0, 220, 255)
dr.polygon(pts, outline=col, fill=(col[0], col[1], col[2], 40 if hgt < 100 else 90))
for rd in json.load(open(os.path.join(d, 'roads.json')))['roads']:
pts = [P(rd[i], rd[i + 1]) for i in range(3, len(rd), 2)]
dr.line(pts, fill=ROADC[rd[0]] + (230,), width=2 if rd[0] < 3 else 1)
rp = os.path.join(d, 'rail.json')
if os.path.exists(rp):
for ln in json.load(open(rp))['lines']:
c = ln[0]
col = tuple(int(c[i:i + 2], 16) for i in (1, 3, 5)) + (255,)
pts = [P(ln[i], ln[i + 1]) for i in range(1, len(ln), 2)]
dr.line(pts, fill=col, width=3)
ep = os.path.join(d, 'extra.json')
if os.path.exists(ep):
for x, y, nm in json.load(open(ep))['helipads']:
px, py = P(x, y)
dr.ellipse((px - 7, py - 7, px + 7, py + 7), outline=(255, 255, 0, 255), width=3)
out = Image.new('RGB', (S * 2 + 10, S), (0, 0, 0))
out.paste(base, (0, 0))
out.paste(ov, (S + 10, 0))
return out
if __name__ == '__main__':
args = [a for a in sys.argv[1:] if not a.startswith('--')]
crop = None
if '--crop' in sys.argv:
i = sys.argv.index('--crop')
crop = tuple(float(v) for v in sys.argv[i + 1:i + 4])
args = [a for a in args if a not in sys.argv[i + 1:i + 4]]
for slug in args or list(CITIES):
im = render(slug, crop=crop)
if crop:
p = os.path.join(CACHE, f'{slug}_check_crop_{int(crop[0])}_{int(crop[1])}_{int(crop[2])}.png')
else:
p = os.path.join(OUT, slug, 'check.png')
im.save(p, optimize=True)
print('wrote', p)
+107
View File
@@ -0,0 +1,107 @@
"""Esri World Imagery mosaic -> local ENU square, 4096^2 + 2048^2 JPEG.
usage: python3 -I tools/city/fetch_imagery.py nyc boston [--zoom 17]
"""
import sys, os, math, io, time
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from concurrent.futures import ThreadPoolExecutor
import numpy as np
import requests
from PIL import Image
from scipy.ndimage import map_coordinates
from geo import CITIES, OUT, CACHE, Frame
URL = 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}'
TS = 256
N = 4096
SS = 2 # supersample factor before Lanczos downsample
def merc_px(lat, lon, z):
s = TS * 2 ** z
x = (lon + 180.0) / 360.0 * s
lr = np.radians(lat)
y = (1.0 - np.log(np.tan(lr) + 1.0 / np.cos(lr)) / math.pi) / 2.0 * s
return x, y
sess = requests.Session()
sess.headers['User-Agent'] = 'gods-eye-view-intro/1.0 (film asset build)'
def get_tile(z, x, y):
d = os.path.join(CACHE, 'tiles', str(z), str(x))
os.makedirs(d, exist_ok=True)
p = os.path.join(d, f'{y}.jpg')
if os.path.exists(p) and os.path.getsize(p) > 0:
return p, 'cache'
last = None
for attempt in range(5):
try:
r = sess.get(URL.format(z=z, x=x, y=y), timeout=60)
if r.status_code == 200 and r.content[:2] in (b'\xff\xd8', b'\x89P'):
with open(p, 'wb') as f:
f.write(r.content)
return p, 'net'
last = f'HTTP {r.status_code} {r.headers.get("content-type")}'
except Exception as e: # noqa
last = str(e)[:120]
time.sleep(1.5 * (attempt + 1))
raise RuntimeError(f'tile {z}/{x}/{y} failed: {last}')
def build(slug, z):
c = CITIES[slug]
F = Frame(**c)
h = F.half
s, w, n, e = F.bbox(h * 1.02)
x0, y0 = merc_px(n, w, z)
x1, y1 = merc_px(s, e, z)
tx0, ty0 = int(x0 // TS), int(y0 // TS)
tx1, ty1 = int(x1 // TS), int(y1 // TS)
tiles = [(tx, ty) for ty in range(ty0, ty1 + 1) for tx in range(tx0, tx1 + 1)]
print(f'== {slug} z{z}: {tx1-tx0+1}x{ty1-ty0+1} = {len(tiles)} tiles')
with ThreadPoolExecutor(8) as ex:
res = list(ex.map(lambda t: get_tile(z, t[0], t[1]), tiles))
print(f' net {sum(1 for r in res if r[1]=="net")} cache {sum(1 for r in res if r[1]=="cache")}')
W, H = (tx1 - tx0 + 1) * TS, (ty1 - ty0 + 1) * TS
mos = np.zeros((H, W, 3), np.uint8)
for (tx, ty), (p, _) in zip(tiles, res):
im = np.asarray(Image.open(p).convert('RGB'))
mos[(ty - ty0) * TS:(ty - ty0 + 1) * TS, (tx - tx0) * TS:(tx - tx0 + 1) * TS] = im
M = N * SS
t = (np.arange(M) + 0.5) / M
xs = -h + 2 * h * t # east
ys = h - 2 * h * t # north, row 0 at top
lon = F.lon0 + xs / F.kx
lat = F.lat0 + ys / F.ky
px, _ = merc_px(np.full_like(lon, F.lat0), lon, z)
_, py = merc_px(lat, np.full_like(lat, F.lon0), z)
px = px - tx0 * TS - 0.5 # pixel centers
py = py - ty0 * TS - 0.5
PX, PY = np.meshgrid(px, py)
out = np.empty((M, M, 3), np.uint8)
for ch in range(3):
out[..., ch] = np.clip(map_coordinates(mos[..., ch].astype(np.float32), [PY, PX], order=1,
mode='nearest'), 0, 255).astype(np.uint8)
del PX, PY
img = Image.fromarray(out)
if SS != 1:
img = img.resize((N, N), Image.LANCZOS)
d = os.path.join(OUT, slug)
os.makedirs(d, exist_ok=True)
img.save(os.path.join(d, 'ground.jpg'), quality=90, optimize=True)
img.resize((2048, 2048), Image.LANCZOS).save(os.path.join(d, 'ground_2k.jpg'), quality=90, optimize=True)
img.resize((768, 768), Image.LANCZOS).save(os.path.join(CACHE, f'{slug}_ground_preview.jpg'), quality=85)
mpp_src = 156543.03392 * math.cos(math.radians(F.lat0)) / 2 ** z
print(f' wrote ground.jpg {os.path.getsize(os.path.join(d, "ground.jpg"))/1e6:.1f}MB; '
f'source {mpp_src:.2f} m/px, output {2*h/N:.2f} m/px')
if __name__ == '__main__':
args = [a for a in sys.argv[1:] if not a.startswith('--')]
z = 17
if '--zoom' in sys.argv:
z = int(sys.argv[sys.argv.index('--zoom') + 1])
for slug in args or list(CITIES):
build(slug, z)
+495
View File
@@ -0,0 +1,495 @@
"""Fetch OSM buildings / roads / water / extras for the city scenes and write city/<slug>/*.json.
usage: python3 -I tools/city/fetch_osm.py nyc boston
"""
import sys, os, math, re, json
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from geo import (CITIES, OUT, Frame, overpass, rdp, rdp_ring, area, centroid, open_ring,
assemble_rings, clip_polyline, clip_polygon, flat, r1, write_json, dedupe)
FT = 0.3048
def parse_len(v):
"""Parse an OSM length tag to meters. Returns None if unparseable."""
if v is None:
return None
s = str(v).strip().lower().replace(',', '.')
s = s.split(';')[0].strip()
m = re.match(r"^(\d+(?:\.\d+)?)\s*'\s*(?:(\d+(?:\.\d+)?)\s*\"?)?$", s) # 12'6"
if m:
return float(m.group(1)) * FT + (float(m.group(2)) * 0.0254 if m.group(2) else 0)
m = re.match(r'^(-?\d+(?:\.\d+)?)\s*(m|meters?|metres?|ft|feet|foot)?$', s)
if not m:
return None
val = float(m.group(1))
u = m.group(2) or 'm'
if u in ('ft', 'feet', 'foot'):
val *= FT
return val
def parse_num(v):
if v is None:
return None
m = re.match(r'^\s*(\d+(?:\.\d+)?)', str(v).replace(',', '.'))
return float(m.group(1)) if m else None
HOUSE = {'house', 'detached', 'semidetached_house', 'terrace', 'bungalow', 'cabin', 'shed', 'garage',
'garages', 'hut', 'carport', 'static_caravan'}
def building_height(t):
"""Returns (height, min_height, source) where source in h/lv/def."""
h = parse_len(t.get('height'))
src = 'h'
if h is None or h <= 0:
lv = parse_num(t.get('building:levels'))
if lv is not None and lv > 0:
h = lv * 3.2 + (parse_num(t.get('roof:levels')) or 0) * 3.2
src = 'lv'
else:
b = t.get('building', 'yes')
h = 7.0 if b in HOUSE else 12.0
if b in ('shed', 'garage', 'garages', 'carport', 'hut'):
h = 3.5
src = 'def'
mh = parse_len(t.get('min_height'))
if mh is None:
ml = parse_num(t.get('building:min_level'))
mh = ml * 3.2 if ml else 0.0
mh = max(0.0, mh)
if mh >= h:
mh = 0.0
return h, mh, src
def ll_list(geom):
return [(g['lat'], g['lon']) for g in geom if g is not None]
# ------------------------------------------------------------------ buildings
def buildings(slug, F):
h = F.half
s, w, n, e = F.bbox(h + 60)
# tile the bbox to keep each response manageable
T = 2 if h > 3000 else 1
elems = {}
for i in range(T):
for j in range(T):
ts = s + (n - s) * i / T
tn = s + (n - s) * (i + 1) / T
tw = w + (e - w) * j / T
te = w + (e - w) * (j + 1) / T
bb = f'{ts:.6f},{tw:.6f},{tn:.6f},{te:.6f}'
q = (f'[out:json][timeout:180];(way["building"]({bb});'
f'relation["building"]["type"="multipolygon"]({bb}););out geom;')
print(f' buildings tile {i},{j}')
d = overpass(q, f'{slug}_bld_{i}{j}')
for el in d['elements']:
elems[(el['type'], el['id'])] = el
print(f' {len(elems)} building elements')
out = []
stats = dict(h=0, lv=0, df=0, dropped_small=0, dropped_out=0, rel_rings=0)
for (typ, _id), el in elems.items():
t = el.get('tags', {})
if t.get('building') in ('no', 'construction_site'):
continue
rings = []
if typ == 'way':
if 'geometry' not in el:
continue
g = ll_list(el['geometry'])
if len(g) >= 4 and g[0] == g[-1]:
rings.append(g)
else:
outers = [ll_list(m['geometry']) for m in el.get('members', [])
if m['type'] == 'way' and m.get('role') == 'outer' and 'geometry' in m]
rings = assemble_rings(outers)
stats['rel_rings'] += len(rings)
hgt, mh, src = building_height(t)
for g in rings:
pts = open_ring([F.xy(la, lo) for la, lo in g])
pts = rdp_ring(pts, 0.5)
if len(pts) < 3 or abs(area(pts)) < 8:
stats['dropped_small'] += 1
continue
cx, cy = centroid(pts)
if abs(cx) > h or abs(cy) > h:
stats['dropped_out'] += 1
continue
stats[{'h': 'h', 'lv': 'lv', 'def': 'df'}[src]] += 1
out.append((cx * cx + cy * cy, [r1(hgt), r1(mh)] + flat(pts)))
out.sort(key=lambda a: a[0])
b = [o[1] for o in out]
write_json(os.path.join(OUT, slug, 'buildings.json'), {'count': len(b), 'b': b})
hs = sorted(x[0] for x in b)
print(f' buildings {len(b)} height-src tag={stats["h"]} levels={stats["lv"]} default={stats["df"]} '
f'dropped small={stats["dropped_small"]} outside={stats["dropped_out"]} tallest={hs[-5:]}')
return dict(count=len(b), height_from_tag=stats['h'], height_from_levels=stats['lv'],
height_default=stats['df'], tallest_m=hs[-1] if hs else None)
# ------------------------------------------------------------------ roads
CLS = {'motorway': 0, 'trunk': 0, 'primary': 1, 'secondary': 2, 'tertiary': 3,
'residential': 4, 'unclassified': 4, 'service': 5}
DEF_LANES = {0: 3, 1: 2, 2: 2, 3: 2, 4: 1, 5: 1}
def plen(pts):
return sum(math.hypot(pts[i + 1][0] - pts[i][0], pts[i + 1][1] - pts[i][1]) for i in range(len(pts) - 1))
def roads(slug, F):
h = F.half
s, w, n, e = F.bbox(h + 100)
bb = f'{s:.6f},{w:.6f},{n:.6f},{e:.6f}'
q = (f'[out:json][timeout:180];way["highway"~"^(motorway|trunk|primary|secondary|tertiary|residential|'
f'unclassified|service|motorway_link|trunk_link|primary_link|secondary_link|tertiary_link)$"]({bb});out geom;')
print(' roads')
d = overpass(q, f'{slug}_roads')
out = []
cnt = [0] * 6
tunnels = 0
short_service = 0
for el in d['elements']:
t = el.get('tags', {})
hw = t['highway']
if t.get('area') == 'yes':
continue
if t.get('tunnel') in ('yes', 'covered') or t.get('layer', '0').startswith('-') and t.get('tunnel'):
tunnels += 1
continue
pts = [F.xy(la, lo) for la, lo in ll_list(el.get('geometry', []))]
pts = dedupe(pts)
if len(pts) < 2:
continue
if hw.endswith('_link'):
c = 5
else:
c = CLS[hw]
if hw == 'service' and plen(pts) <= 80:
short_service += 1
continue
ow = t.get('oneway', '')
oneway = 0
if ow in ('yes', '1', 'true'):
oneway = 1
elif ow == '-1':
oneway = 1
pts = pts[::-1]
elif ow == 'no':
oneway = 0
elif hw in ('motorway', 'motorway_link') or t.get('junction') in ('roundabout', 'circular'):
oneway = 1
ln = parse_num(t.get('lanes'))
lanes = int(ln) if ln else DEF_LANES[c]
for piece in clip_polyline(pts, -h, -h, h, h):
piece = rdp(piece, 1.0)
if len(piece) < 2 or plen(piece) < 1:
continue
out.append([c, oneway, lanes] + flat(piece))
cnt[c] += 1
write_json(os.path.join(OUT, slug, 'roads.json'), {'roads': out})
print(f' roads {len(out)} by class {cnt} (dropped tunnels {tunnels}, short service {short_service})')
return dict(count=len(out), by_class=cnt, dropped_tunnel_ways=tunnels, dropped_short_service=short_service)
# ------------------------------------------------------------------ water
def coast_water(slug, F, h):
"""Build water polygons inside the square from natural=coastline (land left, water right)."""
s, w, n, e = F.bbox(h * 1.6)
bb = f'{s:.6f},{w:.6f},{n:.6f},{e:.6f}'
q = f'[out:json][timeout:180];way["natural"="coastline"]({bb});out geom;'
print(' coastline')
d = overpass(q, f'{slug}_coast')
ways = [[F.xy(la, lo) for la, lo in ll_list(el['geometry'])] for el in d['elements'] if 'geometry' in el]
# directional merge into chains
chains = []
pool = [wy for wy in ways if len(wy) >= 2]
while pool:
cur = pool.pop()
grown = True
while grown and cur[0] != cur[-1]:
grown = False
for i, wy in enumerate(pool):
if wy[0] == cur[-1]:
cur = cur + wy[1:]
elif wy[-1] == cur[0]:
cur = wy + cur[1:]
else:
continue
pool.pop(i)
grown = True
break
chains.append(cur)
eps = 1e-6
def on_edge(p):
return abs(abs(p[0]) - h) < 1e-3 or abs(abs(p[1]) - h) < 1e-3
def spos(p): # clockwise perimeter coordinate from top-left
x, y = p
if abs(y - h) < 1e-3:
return x + h
if abs(x - h) < 1e-3:
return 2 * h + (h - y)
if abs(y + h) < 1e-3:
return 4 * h + (h - x)
return 6 * h + (y + h)
corners = [(0.0, (-h, h)), (2 * h, (h, h)), (4 * h, (h, -h)), (6 * h, (-h, -h))]
pieces, islands, bad = [], [], 0
for ch in chains:
closed = ch[0] == ch[-1]
inside_all = all(abs(x) <= h and abs(y) <= h for x, y in ch)
if closed and inside_all:
islands.append(open_ring(ch))
continue
for pc in clip_polyline(ch, -h, -h, h, h):
if on_edge(pc[0]) and on_edge(pc[-1]):
pieces.append(pc)
else:
bad += 1
polys = []
if pieces:
used = [False] * len(pieces)
starts = [spos(p[0]) for p in pieces]
for k in range(len(pieces)):
if used[k]:
continue
ring = []
cur = k
guard = 0
while True:
used[cur] = True
ring += pieces[cur]
se = spos(pieces[cur][-1])
# next entry clockwise from se
best, bd = None, None
for m in range(len(pieces)):
dd = (starts[m] - se) % (8 * h)
if bd is None or dd < bd:
best, bd = m, dd
# add corners passed between se and se+bd
for cs, cp in sorted(corners, key=lambda c: (c[0] - se) % (8 * h)):
if 0 < (cs - se) % (8 * h) < bd:
ring.append(cp)
cur = best
guard += 1
if cur == k or guard > len(pieces) + 2:
break
if used[cur]:
break
polys.append([dedupe(open_ring(ring))])
# attach islands (land holes) to the containing water polygon
def pip(pt, ring):
x, y = pt
c = False
for i in range(len(ring)):
x1, y1 = ring[i]
x2, y2 = ring[i - 1]
if (y1 > y) != (y2 > y) and x < (x2 - x1) * (y - y1) / (y2 - y1) + x1:
c = not c
return c
for isl in islands:
if abs(area(isl)) < 50:
continue
for p in polys:
if pip(isl[0], p[0]):
p.append(isl)
break
print(f' coastline chains {len(chains)} pieces {len(pieces)} islands {len(islands)} '
f'water polys {len(polys)} (unclosed pieces skipped {bad})')
return polys, bad
def water(slug, F):
h = F.half
s, w, n, e = F.bbox(h + 50)
bb = f'{s:.6f},{w:.6f},{n:.6f},{e:.6f}'
q = (f'[out:json][timeout:180];(way["natural"="water"]({bb});relation["natural"="water"]({bb});'
f'way["waterway"="riverbank"]({bb});relation["waterway"="riverbank"]({bb}););out geom;')
print(' water areas')
d = overpass(q, f'{slug}_water')
polys = []
for el in d['elements']:
t = el.get('tags', {})
if t.get('water') in ('pond', 'reflecting_pool', 'fountain', 'basin', 'reservoir') and el['type'] == 'way':
pass # keep small ponds too; they are real water
if el['type'] == 'way':
if 'geometry' not in el:
continue
g = ll_list(el['geometry'])
if g[0] != g[-1]:
continue
outers, inners = [g], []
else:
mem = [m for m in el.get('members', []) if m['type'] == 'way' and 'geometry' in m]
outers = assemble_rings([ll_list(m['geometry']) for m in mem if m.get('role') == 'outer'])
inners = assemble_rings([ll_list(m['geometry']) for m in mem if m.get('role') == 'inner'])
inn_xy = [open_ring([F.xy(*p) for p in r]) for r in inners]
for o in outers:
ring = open_ring([F.xy(*p) for p in o])
ring = clip_polygon(ring, -h, -h, h, h)
ring = rdp_ring(open_ring(ring), 1.0)
if len(ring) < 3 or abs(area(ring)) < 20:
continue
p = [ring]
for ir in inn_xy:
ir2 = rdp_ring(open_ring(clip_polygon(ir, -h, -h, h, h)), 1.0)
if len(ir2) >= 3 and abs(area(ir2)) >= 20:
p.append(ir2)
polys.append(('water', p))
n_area = len(polys)
cpolys, bad = coast_water(slug, F, h)
for p in cpolys:
p2 = [rdp_ring(r, 1.0) for r in p]
p2 = [r for r in p2 if len(r) >= 3]
if p2:
polys.append(('coast', p2))
out = [[flat(r) for r in p] for _, p in polys]
src = [s_ for s_, _ in polys]
write_json(os.path.join(OUT, slug, 'water.json'),
{'count': len(out), 'format': 'polys[i] = [outerRing, holeRing...], rings flat x,y', 'src': src,
'polys': out})
print(f' water polys {len(out)} (osm water areas {n_area}, coastline-derived {len(cpolys)})')
return dict(count=len(out), water_areas=n_area, coastline_polys=len(cpolys), coastline_unclosed_pieces=bad)
# ------------------------------------------------------------------ extras
KNOWN_HELI = ['Downtown Manhattan Heliport', 'West 30th Street Heliport', 'East 34th Street Heliport']
def nyc_extra(slug, F):
s, w, n, e = F.bbox(F.half + 2000)
bb = f'{s:.6f},{w:.6f},{n:.6f},{e:.6f}'
q = (f'[out:json][timeout:180];(nwr["aeroway"~"^(helipad|heliport)$"]({bb});'
f'nwr["name"~"heliport|helistop|helipad",i]({bb}););out center tags;')
print(' helipads')
d = overpass(q, f'{slug}_heli2')
pads = []
for el in d['elements']:
t = el.get('tags', {})
if el['type'] == 'node':
la, lo = el['lat'], el['lon']
else:
c = el.get('center')
if not c:
continue
la, lo = c['lat'], c['lon']
x, y = F.xy(la, lo)
name = t.get('name') or t.get('operator') or ''
kind = t.get('aeroway') or ('heliport' if 'heliport' in name.lower() else 'named')
pads.append(dict(x=r1(x), y=r1(y), name=name, kind=kind, ref=t.get('ref', ''), osm=f"{el['type']}/{el['id']}",
tags={k: v for k, v in t.items() if k in ('aeroway', 'faa', 'operator', 'man_made', 'amenity')},
inside=abs(x) <= F.half and abs(y) <= F.half))
pads.sort(key=lambda p: (p['kind'] != 'heliport', p['x'] ** 2 + p['y'] ** 2))
for p in pads:
print(f" {p['kind']:9s} {p['x']:8.1f} {p['y']:8.1f} {'in ' if p['inside'] else 'OUT'} {p['name']}")
found = {k: [p for p in pads if p['kind'] == 'heliport' and k.split()[0].lower() in p['name'].lower()
and 'heliport' in p['name'].lower()] for k in KNOWN_HELI}
helipads = [[p['x'], p['y'], p['name'] or (f"{p['kind']} {p['ref']}".strip())] for p in pads]
write_json(os.path.join(OUT, slug, 'extra.json'),
{'helipads': helipads,
'detail': pads,
'note': 'aeroway=helipad|heliport from OSM in a bbox 2 km wider than the scene; '
'detail[].inside says whether the point is inside the scene square'})
return dict(helipad_points=len(pads), named_heliports={k: (v[0]['x'], v[0]['y'], v[0]['inside']) if v else None
for k, v in found.items()})
def boston_rail(slug, F):
h = F.half
s, w, n, e = F.bbox(h + 100)
bb = f'{s:.6f},{w:.6f},{n:.6f},{e:.6f}'
q = (f'[out:json][timeout:180];(relation["route"~"^(subway|light_rail|train|tram)$"]({bb});'
f'way["railway"~"^(subway|light_rail|rail|tram)$"]({bb}););out geom;')
print(' rail')
d = overpass(q, f'{slug}_rail')
ways = {el['id']: el for el in d['elements'] if el['type'] == 'way'}
rels = [el for el in d['elements'] if el['type'] == 'relation']
lines, seen = [], set()
rel_names = []
for r in rels:
t = r.get('tags', {})
col = (t.get('colour') or t.get('color') or '').strip()
if not re.match(r'^#?[0-9a-fA-F]{6}$', col):
col = {'subway': '#888888', 'light_rail': '#00843D', 'tram': '#00843D', 'train': '#80276C'}[t['route']]
if not col.startswith('#'):
col = '#' + col
col = col.upper()
rel_names.append(f"{t.get('route')}:{t.get('name', t.get('ref', ''))}:{col}")
for m in r.get('members', []):
if m['type'] != 'way' or 'geometry' not in m or m.get('role') in ('platform', 'stop'):
continue
wt = ways.get(m['ref'], {}).get('tags', {})
if wt.get('public_transport') or wt.get('railway') == 'platform':
continue
key = (m['ref'], col)
if key in seen:
continue
seen.add(key)
pts = [F.xy(la, lo) for la, lo in ll_list(m['geometry'])]
for pc in clip_polyline(dedupe(pts), -h, -h, h, h):
pc = rdp(pc, 1.0)
if len(pc) >= 2:
lines.append([col] + flat(pc))
covered = {k[0] for k in seen}
extra = 0
for wid, el in ways.items():
t = el.get('tags', {})
if wid in covered or t.get('service') or 'geometry' not in el:
continue
pts = [F.xy(la, lo) for la, lo in ll_list(el['geometry'])]
for pc in clip_polyline(dedupe(pts), -h, -h, h, h):
pc = rdp(pc, 1.0)
if len(pc) >= 2:
lines.append(['#7F7F7F'] + flat(pc))
extra += 1
write_json(os.path.join(OUT, slug, 'rail.json'),
{'lines': lines, 'routes': sorted(set(rel_names)),
'note': 'OSM route relations (subway/light_rail/train/tram) colour tags; #7F7F7F = railway way not in any route relation'})
print(f' rail lines {len(lines)} from {len(rels)} route relations (+{extra} unassigned way pieces)')
for nm in sorted(set(rel_names)):
print(' ', nm)
return dict(polylines=len(lines), route_relations=len(rels), unassigned_pieces=extra)
def main():
slugs = sys.argv[1:] or list(CITIES)
report = {}
for slug in slugs:
c = CITIES[slug]
F = Frame(**c)
print(f'== {slug}')
write_json(os.path.join(OUT, slug, 'meta.json'),
{'slug': slug, 'name': c['name'], 'lat0': c['lat0'], 'lon0': c['lon0'], 'half': c['half'],
'frame': 'ENU m',
'projection': 'x=(lon-lon0)*cos(lat0)*111320, y=(lat-lat0)*110540',
'source': 'OpenStreetMap contributors (ODbL) via Overpass; imagery Esri World Imagery'})
r = {}
r['buildings'] = buildings(slug, F)
r['roads'] = roads(slug, F)
try:
r['water'] = water(slug, F)
except Exception as ex: # noqa
print(' WATER FAILED', ex)
r['water'] = f'failed: {ex}'
if slug == 'nyc':
r['extra'] = nyc_extra(slug, F)
if slug == 'boston':
r['rail'] = boston_rail(slug, F)
report[slug] = r
print(json.dumps(report, indent=1))
with open(os.path.join(os.path.dirname(os.path.abspath(__file__)), 'cache', 'osm_report.json'), 'w') as f:
json.dump(report, f, indent=1)
if __name__ == '__main__':
main()
+288
View File
@@ -0,0 +1,288 @@
"""Shared helpers for the city scene geodata fetchers (ENU frame, Overpass, geometry)."""
import json, math, os, time, hashlib
import requests
HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.abspath(os.path.join(HERE, '..', '..'))
OUT = os.path.join(ROOT, 'city')
CACHE = os.path.join(HERE, 'cache')
os.makedirs(CACHE, exist_ok=True)
CITIES = {
'nyc': dict(name='New York — Midtown / Chelsea / Lower Manhattan', lat0=40.7400, lon0=-73.9900, half=3500),
'boston': dict(name='Boston — Downtown / Back Bay / Cambridge / Seaport', lat0=42.3555, lon0=-71.0605, half=3200),
}
OVERPASS = [
'https://overpass-api.de/api/interpreter',
'https://overpass.kumi.systems/api/interpreter',
'https://overpass.private.coffee/api/interpreter',
'https://maps.mail.ru/osm/tools/overpass/api/interpreter',
]
class Frame:
def __init__(self, lat0, lon0, half, **_):
self.lat0, self.lon0, self.half = lat0, lon0, half
self.kx = math.cos(math.radians(lat0)) * 111320.0
self.ky = 110540.0
def xy(self, lat, lon):
return ((lon - self.lon0) * self.kx, (lat - self.lat0) * self.ky)
def ll(self, x, y):
return (self.lat0 + y / self.ky, self.lon0 + x / self.kx)
def bbox(self, half=None):
h = self.half if half is None else half
s, w = self.ll(-h, -h)
n, e = self.ll(h, h)
return s, w, n, e
def overpass(query, tag):
"""Run an Overpass query, caching the raw JSON response under cache/osm_<tag>.json."""
path = os.path.join(CACHE, f'osm_{tag}.json')
if os.path.exists(path):
with open(path) as f:
return json.load(f)
last = None
for attempt in range(8):
for url in OVERPASS:
try:
t = time.time()
r = requests.post(url, data={'data': query}, timeout=400)
if r.status_code != 200:
last = f'{url} HTTP {r.status_code}'
print(' overpass fail', last)
continue
d = r.json()
if 'remark' in d and 'error' in d.get('remark', '').lower():
last = f'{url} remark {d["remark"][:200]}'
print(' overpass remark', last)
continue
print(f' overpass ok {url} {len(r.content)/1e6:.1f}MB {time.time()-t:.0f}s {len(d["elements"])} el')
with open(path, 'w') as f:
json.dump(d, f)
return d
except Exception as e: # noqa
last = f'{url} {type(e).__name__}: {str(e)[:200]}'
print(' overpass fail', last)
time.sleep(15 + 15 * attempt)
raise RuntimeError('all overpass endpoints failed: ' + str(last))
# ---------------- geometry ----------------
def rdp(pts, eps):
"""Douglas-Peucker on an open polyline (list of (x,y))."""
n = len(pts)
if n < 3:
return list(pts)
keep = [False] * n
keep[0] = keep[-1] = True
stack = [(0, n - 1)]
while stack:
a, b = stack.pop()
ax, ay = pts[a]
bx, by = pts[b]
dx, dy = bx - ax, by - ay
L = math.hypot(dx, dy)
dmax, idx = -1.0, -1
for i in range(a + 1, b):
px, py = pts[i]
if L < 1e-9:
d = math.hypot(px - ax, py - ay)
else:
d = abs(dx * (ay - py) - dy * (ax - px)) / L
if d > dmax:
dmax, idx = d, i
if dmax > eps and idx > 0:
keep[idx] = True
stack.append((a, idx))
stack.append((idx, b))
return [p for p, k in zip(pts, keep) if k]
def rdp_ring(ring, eps):
"""Douglas-Peucker for a closed ring given without the repeated closing vertex."""
n = len(ring)
if n < 4:
return list(ring)
# split at vertex 0 and the vertex farthest from it
x0, y0 = ring[0]
far = max(range(n), key=lambda i: (ring[i][0] - x0) ** 2 + (ring[i][1] - y0) ** 2)
a = rdp(ring[0:far + 1], eps)
b = rdp(ring[far:] + [ring[0]], eps)
return a[:-1] + b[:-1]
def area(ring):
s = 0.0
n = len(ring)
for i in range(n):
x1, y1 = ring[i]
x2, y2 = ring[(i + 1) % n]
s += x1 * y2 - x2 * y1
return s / 2.0
def centroid(ring):
a = area(ring)
if abs(a) < 1e-9:
xs = [p[0] for p in ring]
ys = [p[1] for p in ring]
return sum(xs) / len(xs), sum(ys) / len(ys)
cx = cy = 0.0
n = len(ring)
for i in range(n):
x1, y1 = ring[i]
x2, y2 = ring[(i + 1) % n]
c = x1 * y2 - x2 * y1
cx += (x1 + x2) * c
cy += (y1 + y2) * c
return cx / (6 * a), cy / (6 * a)
def dedupe(pts):
out = []
for p in pts:
if not out or p != out[-1]:
out.append(p)
return out
def open_ring(pts):
pts = dedupe(pts)
if len(pts) > 1 and pts[0] == pts[-1]:
pts = pts[:-1]
return pts
def assemble_rings(ways):
"""Join way coordinate lists (lists of (lat,lon) tuples) into closed rings by matching endpoints."""
rings = []
pool = [list(w) for w in ways if len(w) >= 2]
while pool:
cur = pool.pop()
changed = True
while cur[0] != cur[-1] and changed:
changed = False
for i, w in enumerate(pool):
if w[0] == cur[-1]:
cur = cur + w[1:]
elif w[-1] == cur[-1]:
cur = cur + w[::-1][1:]
elif w[-1] == cur[0]:
cur = w + cur[1:]
elif w[0] == cur[0]:
cur = w[::-1] + cur[1:]
else:
continue
pool.pop(i)
changed = True
break
if cur[0] == cur[-1] and len(cur) >= 4:
rings.append(cur)
return rings
def clip_polyline(pts, xmin, ymin, xmax, ymax):
"""Clip a polyline to a rectangle (Liang-Barsky per segment); returns list of pieces."""
pieces, cur = [], []
def inside(p):
return xmin <= p[0] <= xmax and ymin <= p[1] <= ymax
for i in range(len(pts) - 1):
(x1, y1), (x2, y2) = pts[i], pts[i + 1]
dx, dy = x2 - x1, y2 - y1
t0, t1 = 0.0, 1.0
ok = True
for p, q in ((-dx, x1 - xmin), (dx, xmax - x1), (-dy, y1 - ymin), (dy, ymax - y1)):
if p == 0:
if q < 0:
ok = False
break
else:
t = q / p
if p < 0:
if t > t1:
ok = False
break
t0 = max(t0, t)
else:
if t < t0:
ok = False
break
t1 = min(t1, t)
if not ok:
if cur:
pieces.append(cur)
cur = []
continue
a = (x1, y1) if t0 == 0.0 else (x1 + t0 * dx, y1 + t0 * dy)
b = (x2, y2) if t1 == 1.0 else (x1 + t1 * dx, y1 + t1 * dy)
if not cur:
cur = [a, b]
else:
if cur[-1] != a:
pieces.append(cur)
cur = [a, b]
else:
cur.append(b)
if t1 < 1.0:
pieces.append(cur)
cur = []
if cur:
pieces.append(cur)
return [dedupe(p) for p in pieces if len(dedupe(p)) >= 2]
def clip_polygon(ring, xmin, ymin, xmax, ymax):
"""Sutherland-Hodgman clip of a ring to a rectangle."""
def clip(poly, inside, inter):
out = []
n = len(poly)
for i in range(n):
cur, prev = poly[i], poly[i - 1]
if inside(cur):
if not inside(prev):
out.append(inter(prev, cur))
out.append(cur)
elif inside(prev):
out.append(inter(prev, cur))
return out
def ix(xc):
return lambda p, q: (xc, p[1] + (q[1] - p[1]) * (xc - p[0]) / (q[0] - p[0]))
def iy(yc):
return lambda p, q: (p[0] + (q[0] - p[0]) * (yc - p[1]) / (q[1] - p[1]), yc)
poly = list(ring)
for ins, inter in ((lambda p: p[0] >= xmin, ix(xmin)), (lambda p: p[0] <= xmax, ix(xmax)),
(lambda p: p[1] >= ymin, iy(ymin)), (lambda p: p[1] <= ymax, iy(ymax))):
if not poly:
break
poly = clip(poly, ins, inter)
return dedupe(poly)
def r1(v):
return round(v, 1)
def flat(pts):
out = []
for x, y in pts:
out.append(r1(x))
out.append(r1(y))
return out
def write_json(path, obj):
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, 'w') as f:
json.dump(obj, f, separators=(',', ':'))
print(f' wrote {os.path.relpath(path, ROOT)} {os.path.getsize(path)/1e6:.2f}MB')
+188
View File
@@ -0,0 +1,188 @@
"""Build real-geodata scene packs. usage: python3 build_scene.py lax|bragg|everest [steps...]
steps: osm ground elev check (default all)"""
import json
import os
import sys
import numpy as np
from PIL import Image, ImageDraw
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import common as C # noqa
SCENES = {
"lax": dict(name="Los Angeles International Airport", lat0=33.9425, lon0=-118.4081, half=3500, z=17),
"bragg": dict(name="Fort Bragg, North Carolina", lat0=35.1580, lon0=-79.0090, half=6000, z=16),
"everest": dict(name="Mount Everest / Khumbu", lat0=27.9881, lon0=86.9250, half=15000, z=15),
}
NT = {"lax": 4, "bragg": 6}
AERO_Q = '[out:json][timeout:600];nwr["aeroway"~"^(runway|taxiway|apron|terminal|gate|parking_position)$"]({bb});out geom;'
BLDG_Q = '[out:json][timeout:300];(way["building"]({bb});relation["building"]({bb}););out geom;'
ROAD_Q = ('[out:json][timeout:300];way["highway"~"^(motorway|trunk|primary|secondary|tertiary|'
'residential|unclassified|living_street|motorway_link|trunk_link|primary_link|secondary_link|tertiary_link|service)$"]({bb});out geom;')
MIL_Q = '[out:json][timeout:600];(way["landuse"="military"]({bb});relation["landuse"="military"]({bb});way["military"]({bb});relation["military"]({bb}););out geom;'
PEAK_Q = '[out:json][timeout:300];node["natural"="peak"]({bb});out;'
def step_osm(f, slug):
bb = f.bbox_str()
if slug in ("lax", "bragg"):
aero = C.build_aeroway(f, C.overpass(AERO_Q.format(bb=bb), f"{slug}_aero"))
if slug == "lax":
C.wjson(os.path.join(f.out, "aeroway.json"), aero)
else:
mil = C.overpass(MIL_Q.format(bb=bb), f"{slug}_mil")
polys = []
for el in mil["elements"]:
tags = el.get("tags", {})
for ring in C.outer_rings(el, f):
name = tags.get("name") or (f"[{tags['military']}]" if "military" in tags else "")
polys.append([name] + C.flat(C.dp_ring(ring, 2.0)))
names = sorted({p[0] for p in polys if p[0]})
print(f" military polygons: {len(polys)}; names: {names[:40]}")
out = {"military": polys, "runways": aero["runways"], "taxiways": aero["taxiways"],
"aprons": aero["aprons"], "apronNames": aero["apronNames"], "terminals": aero["terminals"],
"gates": aero["gates"], "parking": aero["parking"]}
C.wjson(os.path.join(f.out, "military.json"), out)
C.wjson(os.path.join(f.out, "buildings.json"), C.build_buildings(f, C.overpass_tiled(f, BLDG_Q, f"{slug}_bldg", NT[slug])))
C.wjson(os.path.join(f.out, "roads.json"), C.build_roads(f, C.overpass_tiled(f, ROAD_Q, f"{slug}_roads", NT[slug] // 2)))
else:
d = C.overpass(PEAK_Q.format(bb=bb), f"{slug}_peaks")
peaks = []
skipped = 0
for el in d["elements"]:
t = el.get("tags", {})
name = t.get("name:en") or t.get("name")
ele = t.get("ele")
if not name or not ele:
skipped += 1
continue
try:
s = ele.lower().replace(",", ".").strip()
v = float(s.replace("ft", "").replace("m", "").strip().split()[0].split(";")[0])
if "ft" in s:
v *= 0.3048
except ValueError:
skipped += 1
continue
x, y = f.xy(el["lon"], el["lat"])
peaks.append([C.r1(x), C.r1(y), C.r1(v), name])
peaks.sort(key=lambda p: -p[2])
print(f" peaks with name+ele: {len(peaks)} (skipped {skipped} lacking name/ele); top: {peaks[:6]}")
C.wjson(os.path.join(f.out, "peaks.json"), peaks)
def step_elev(f):
z = 12
url = "https://s3.amazonaws.com/elevation-tiles-prod/terrarium/{z}/{x}/{y}.png"
mos, ox, oy, nf = C.mosaic(f, z, url, "terrarium", "png", "RGB", pad_m=1500)
if nf:
print(f" WARNING: {nf} terrarium tiles missing")
m = mos.astype(np.float32)
h = m[..., 0] * 256 + m[..., 1] + m[..., 2] / 256 - 32768
N = 1024
e = C.reproject(f, h[..., None], ox, oy, z, N)[..., 0].astype("<f4")
e.tofile(os.path.join(f.out, "elev.f32"))
meta = {"size": N, "min": C.r1(e.min()), "max": C.r1(e.max()), "half": f.half,
"source": "AWS Terrain Tiles terrarium z12", "rowOrder": "row0=north", "dtype": "float32le"}
C.wjson(os.path.join(f.out, "elev_meta.json"), meta)
print(f" elev min {e.min():.1f} max {e.max():.1f}")
# hillshade
cell = 2 * f.half / N
gy, gx = np.gradient(e.astype(np.float64), cell) # gy: d/drow (southward)
dzdx, dzdy = gx, -gy
az, alt = np.radians(315), np.radians(45)
slope = np.arctan(np.hypot(dzdx, dzdy))
aspect = np.arctan2(-dzdx, -dzdy) # direction of steepest descent... simple form
lx, ly, lz = np.sin(az) * np.cos(alt), np.cos(az) * np.cos(alt), np.sin(alt)
nx, ny, nz = -dzdx, -dzdy, np.ones_like(dzdx)
nn = np.sqrt(nx * nx + ny * ny + 1)
shade = np.clip((nx * lx + ny * ly + nz * lz) / nn, 0, 1)
t = (e - e.min()) / max(1, e.max() - e.min())
rgb = np.stack([0.35 + 0.65 * t, 0.45 + 0.55 * t, 0.55 + 0.45 * t], -1) * shade[..., None]
Image.fromarray((np.clip(rgb, 0, 1) * 255).astype(np.uint8)).save(os.path.join(f.out, "elev_preview.png"))
print(" wrote elev_preview.png")
def step_check(f, slug):
N = 2048
im = C.check_canvas(f)
dr = ImageDraw.Draw(im, "RGBA")
P = lambda a: C.to_px(f, a, N) # noqa
if slug in ("lax", "bragg"):
roads = json.load(open(os.path.join(f.out, "roads.json")))["roads"]
col = {0: (255, 60, 60, 230), 1: (255, 160, 40, 220), 2: (255, 230, 60, 200), 3: (200, 255, 120, 180),
4: (160, 200, 255, 150), 5: (180, 180, 180, 120)}
for r in roads:
dr.line(P(r[3:]), fill=col[r[0]], width=2 if r[0] < 3 else 1)
bl = json.load(open(os.path.join(f.out, "buildings.json")))["b"]
for b in bl:
pts = P(b[2:])
if len(pts) >= 3:
dr.polygon(pts, outline=(0, 255, 255, 200))
aero = json.load(open(os.path.join(f.out, "aeroway.json" if slug == "lax" else "military.json")))
if slug == "bragg":
for m in aero["military"]:
dr.polygon(P(m[1:]), outline=(255, 0, 255, 255))
for a in aero["aprons"]:
dr.polygon(P(a), outline=(255, 255, 255, 200))
for t in aero["taxiways"]:
dr.line(P(t), fill=(255, 255, 0, 230), width=1)
for rw in aero["runways"]:
w = max(2, int(rw[0] / (2 * f.half) * N))
dr.line(P(rw[2:6]), fill=(255, 40, 40, 110), width=w)
dr.line(P(rw[2:6]), fill=(255, 255, 255, 255), width=1)
for t in aero["terminals"]:
dr.polygon(P(t[1:]), outline=(255, 120, 0, 255))
for g in aero["gates"]:
(x, y), = P(g[:2])
dr.ellipse([x - 2, y - 2, x + 2, y + 2], fill=(0, 255, 0, 255))
else:
peaks = json.load(open(os.path.join(f.out, "peaks.json")))
for x0, y0, ele, name in peaks:
(x, y), = P([x0, y0])
dr.ellipse([x - 4, y - 4, x + 4, y + 4], outline=(255, 60, 60, 255), width=2)
if ele > 6000:
dr.text((x + 6, y - 6), f"{name} {ele:.0f}", fill=(255, 255, 255, 255))
# contour-ish overlay from DEM to check imagery/DEM alignment
e = np.fromfile(os.path.join(f.out, "elev.f32"), "<f4").reshape(1024, 1024)
e2 = np.asarray(Image.fromarray(e).resize((N, N), Image.BILINEAR))
band = (np.floor(e2 / 500) % 2).astype(np.int8)
edge = (np.abs(np.diff(band, axis=0, prepend=band[:1])) + np.abs(np.diff(band, axis=1, prepend=band[:, :1]))) > 0
arr = np.asarray(im).copy()
arr[edge] = (0.4 * arr[edge] + 0.6 * np.array([0, 255, 255])).astype(np.uint8)
im = Image.fromarray(arr)
dr = ImageDraw.Draw(im, "RGBA")
for x0, y0, ele, name in peaks:
(x, y), = P([x0, y0])
dr.ellipse([x - 4, y - 4, x + 4, y + 4], outline=(255, 60, 60, 255), width=2)
if ele > 6500:
dr.text((x + 6, y - 6), f"{name} {ele:.0f}", fill=(255, 255, 255, 255))
p = os.path.join(f.out, "check.png")
im.save(p)
print(f" wrote {p}")
def main():
slug = sys.argv[1]
steps = sys.argv[2:] or ["osm", "ground", "elev", "check"]
s = SCENES[slug]
f = C.Frame(slug, s["name"], s["lat0"], s["lon0"], s["half"])
f.write_meta()
print(f"== {slug} bbox {f.bbox_str()}")
if "osm" in steps:
step_osm(f, slug)
if "ground" in steps:
C.build_ground(f, s["z"])
if "ground_s2" in steps:
# alternative uniform real imagery (EOX Sentinel-2 cloudless 2020) — Esri is a patchwork here
C.build_ground(f, 14, C.EOX_S2, "eox_s2", "ground_s2")
if "elev" in steps and slug == "everest":
step_elev(f)
if "check" in steps:
step_check(f, slug)
if __name__ == "__main__":
main()
+571
View File
@@ -0,0 +1,571 @@
"""Shared helpers for real-geodata scene builds (city2).
Local frame: ENU metres relative to (lat0, lon0):
x = (lon - lon0) * cos(lat0) * 111320
y = (lat - lat0) * 110540
"""
import hashlib
import io
import json
import math
import os
import sys
import time
from concurrent.futures import ThreadPoolExecutor
import numpy as np
import requests
from PIL import Image
HERE = os.path.dirname(os.path.abspath(__file__))
CACHE = os.path.join(HERE, "cache")
CITY = os.path.abspath(os.path.join(HERE, "..", "..", "city"))
os.makedirs(CACHE, exist_ok=True)
OVERPASS = [
"https://overpass.kumi.systems/api/interpreter",
"https://overpass.private.coffee/api/interpreter",
"https://maps.mail.ru/osm/tools/overpass/api/interpreter",
"https://overpass-api.de/api/interpreter",
]
UA = {"User-Agent": "gods-eye-view-intro/1.0 (cinematic geodata build)"}
Image.MAX_IMAGE_PIXELS = None
class Frame:
def __init__(self, slug, name, lat0, lon0, half):
self.slug, self.name, self.lat0, self.lon0, self.half = slug, name, lat0, lon0, half
self.kx = math.cos(math.radians(lat0)) * 111320.0
self.ky = 110540.0
self.out = os.path.join(CITY, slug)
os.makedirs(self.out, exist_ok=True)
def xy(self, lon, lat):
return (lon - self.lon0) * self.kx, (lat - self.lat0) * self.ky
def ll(self, x, y):
return self.lon0 + x / self.kx, self.lat0 + y / self.ky
def bbox(self, pad=0.0):
"""(south, west, north, east)"""
h = self.half + pad
return (self.lat0 - h / self.ky, self.lon0 - h / self.kx,
self.lat0 + h / self.ky, self.lon0 + h / self.kx)
def bbox_str(self, pad=0.0):
s, w, n, e = self.bbox(pad)
return f"{s:.6f},{w:.6f},{n:.6f},{e:.6f}"
def write_meta(self):
meta = {"slug": self.slug, "name": self.name, "lat0": self.lat0, "lon0": self.lon0,
"half": self.half, "frame": "ENU m"}
wjson(os.path.join(self.out, "meta.json"), meta)
def geom_xy(self, geom):
"""list of {lat,lon} -> Nx2 float array in local metres"""
a = np.array([[g["lon"], g["lat"]] for g in geom if g is not None], dtype=np.float64)
if len(a) == 0:
return np.zeros((0, 2))
return np.stack([(a[:, 0] - self.lon0) * self.kx, (a[:, 1] - self.lat0) * self.ky], 1)
def wjson(path, obj):
with open(path, "w") as f:
json.dump(obj, f, separators=(",", ":"))
print(f" wrote {path} ({os.path.getsize(path)/1e6:.2f} MB)")
def r1(v):
return round(float(v), 1)
def flat(arr):
return [r1(v) for v in np.asarray(arr).reshape(-1)]
# ---------------------------------------------------------------- Overpass
def overpass(query, tag):
key = hashlib.sha1(query.encode()).hexdigest()[:12]
path = os.path.join(CACHE, f"overpass_{tag}_{key}.json")
if os.path.exists(path):
with open(path) as f:
return json.load(f)
last = None
for attempt in range(10):
for url in OVERPASS:
try:
print(f" overpass {tag} -> {url}", flush=True)
t = time.time()
r = requests.get(url, params={"data": query}, headers=UA, timeout=900)
if r.status_code != 200:
last = f"{url}: HTTP {r.status_code} {r.text[:200]}"
print(" ", last)
continue
d = r.json()
rem = d.get("remark", "")
if rem and ("runtime error" in rem or "timed out" in rem or "out of memory" in rem):
last = f"{url}: remark {rem[:200]}"
print(" ", last)
continue
print(f" {len(d.get('elements', []))} elements in {time.time()-t:.0f}s")
with open(path, "w") as f:
json.dump(d, f)
return d
except Exception as e: # noqa
last = f"{url}: {e}"
print(" ", last)
time.sleep(30)
raise RuntimeError(f"overpass failed for {tag}: {last}")
def overpass_tiled(frame, qfmt, tag, n):
"""Run a query template ({bb} placeholder) over an n x n split of the frame bbox; dedupe elements."""
s, w, nn, e = frame.bbox()
seen, els = set(), []
for i in range(n):
for j in range(n):
bb = (f"{s + (nn - s) * i / n:.6f},{w + (e - w) * j / n:.6f},"
f"{s + (nn - s) * (i + 1) / n:.6f},{w + (e - w) * (j + 1) / n:.6f}")
d = overpass(qfmt.format(bb=bb), f"{tag}_{i}{j}")
for el in d["elements"]:
k = (el["type"], el["id"])
if k not in seen:
seen.add(k)
els.append(el)
print(f" {tag}: {len(els)} unique elements over {n}x{n} tiles")
return {"elements": els}
# ---------------------------------------------------------------- geometry
def assemble_rings(ways):
"""ways: list of Nx2 arrays (open or closed). Returns list of closed rings."""
rings = []
pending = [w for w in ways if len(w) >= 2]
while pending:
cur = pending.pop(0)
changed = True
while not np.allclose(cur[0], cur[-1], atol=1e-3) and changed:
changed = False
for i, w in enumerate(pending):
if np.allclose(cur[-1], w[0], atol=1e-3):
cur = np.vstack([cur, w[1:]])
elif np.allclose(cur[-1], w[-1], atol=1e-3):
cur = np.vstack([cur, w[::-1][1:]])
elif np.allclose(cur[0], w[-1], atol=1e-3):
cur = np.vstack([w, cur[1:]])
elif np.allclose(cur[0], w[0], atol=1e-3):
cur = np.vstack([w[::-1], cur[1:]])
else:
continue
pending.pop(i)
changed = True
break
if np.allclose(cur[0], cur[-1], atol=1e-3) and len(cur) >= 4:
rings.append(cur)
return rings
def outer_rings(el, frame):
"""Outer rings (local metres, closed) for a way or multipolygon relation with out geom."""
if el["type"] == "way":
g = el.get("geometry")
if not g:
return []
a = frame.geom_xy(g)
if len(a) >= 4 and np.allclose(a[0], a[-1], atol=1e-3):
return [a]
return []
if el["type"] == "relation":
ways = [frame.geom_xy(m["geometry"]) for m in el.get("members", [])
if m.get("type") == "way" and m.get("role", "outer") in ("outer", "") and m.get("geometry")]
return assemble_rings(ways)
return []
def dp(pts, eps):
"""Douglas-Peucker on Nx2 array (iterative)."""
n = len(pts)
if n < 3:
return pts
keep = np.zeros(n, bool)
keep[0] = keep[-1] = True
stack = [(0, n - 1)]
while stack:
a, b = stack.pop()
if b <= a + 1:
continue
p, q = pts[a], pts[b]
seg = pts[a + 1:b]
d = q - p
L = math.hypot(d[0], d[1])
if L < 1e-9:
dist = np.hypot(seg[:, 0] - p[0], seg[:, 1] - p[1])
else:
dist = np.abs(d[0] * (seg[:, 1] - p[1]) - d[1] * (seg[:, 0] - p[0])) / L
i = int(np.argmax(dist))
if dist[i] > eps:
m = a + 1 + i
keep[m] = True
stack.append((a, m))
stack.append((m, b))
return pts[keep]
def dp_ring(ring, eps):
"""DP for a closed ring: split at the vertex farthest from ring[0]."""
r = ring[:-1] if np.allclose(ring[0], ring[-1]) else ring
if len(r) < 4:
return r
far = int(np.argmax(np.hypot(*(r - r[0]).T)))
a = dp(np.vstack([r[:far + 1]]), eps)
b = dp(np.vstack([r[far:], r[:1]]), eps)
return np.vstack([a[:-1], b[:-1]]) # open ring (no repeated closing vertex)
def area(ring):
x, y = ring[:, 0], ring[:, 1]
return 0.5 * abs(np.dot(x, np.roll(y, -1)) - np.dot(y, np.roll(x, -1)))
def poly_len(a):
return float(np.sum(np.hypot(*np.diff(a, axis=0).T))) if len(a) > 1 else 0.0
def clip_polyline(a, lim):
"""Split a polyline into runs inside the square |x|,|y|<=lim, cutting segments at the edge."""
out, cur = [], []
inside = (np.abs(a[:, 0]) <= lim) & (np.abs(a[:, 1]) <= lim)
def cut(p, q):
# point on segment p->q at the square boundary (p inside, q outside)
t = 1.0
d = q - p
for k in range(2):
if d[k] > 0 and q[k] > lim:
t = min(t, (lim - p[k]) / d[k])
elif d[k] < 0 and q[k] < -lim:
t = min(t, (-lim - p[k]) / d[k])
return p + d * t
for i in range(len(a)):
if inside[i]:
if not cur and i > 0:
cur.append(cut(a[i], a[i - 1]))
cur.append(a[i])
else:
if cur:
cur.append(cut(a[i - 1], a[i]))
out.append(np.array(cur))
cur = []
if cur:
out.append(np.array(cur))
return [c for c in out if len(c) >= 2]
def tag_height(tags, default=10.0):
def num(s):
try:
s = str(s).strip().lower().replace(",", ".")
ft = s.endswith("ft") or s.endswith("'")
s = s.replace("m", "").replace("ft", "").replace("'", "").strip().split(";")[0].split()[0]
v = float(s)
return v * 0.3048 if ft else v
except Exception:
return None
h = num(tags["height"]) if "height" in tags else None
if h is None and "building:levels" in tags:
lv = num(tags["building:levels"])
if lv is not None:
h = lv * 3.2
if h is None or h <= 0:
h = default
mh = num(tags["min_height"]) if "min_height" in tags else None
if mh is None and "building:min_level" in tags:
lv = num(tags["building:min_level"])
mh = lv * 3.2 if lv is not None else None
mh = mh if (mh is not None and 0 <= mh < h) else 0.0
return h, mh
# ---------------------------------------------------------------- buildings / roads
def build_buildings(frame, data, min_area=6.0):
b = []
dropped = 0
lim = frame.half
for el in data["elements"]:
if el["type"] not in ("way", "relation"):
continue
tags = el.get("tags", {})
if "building" not in tags:
continue
h, mh = tag_height(tags)
for ring in outer_rings(el, frame):
c = ring[:-1].mean(0)
if abs(c[0]) > lim or abs(c[1]) > lim:
continue
s = dp_ring(ring, 0.5)
if len(s) < 3 or area(s) < min_area:
dropped += 1
continue
b.append([r1(h), r1(mh)] + flat(s))
print(f" buildings: {len(b)} kept, {dropped} tiny dropped")
return {"count": len(b), "b": b}
ROAD_CLASS = {
"motorway": 0, "trunk": 0,
"primary": 1, "secondary": 2, "tertiary": 3,
"residential": 4, "unclassified": 4, "living_street": 4,
"motorway_link": 5, "trunk_link": 5, "primary_link": 5, "secondary_link": 5,
"tertiary_link": 5, "service": 5,
}
DEFAULT_LANES = {0: 3, 1: 2, 2: 2, 3: 2, 4: 1, 5: 1}
def build_roads(frame, data):
roads = []
counts = {}
lim = frame.half + 50
for el in data["elements"]:
if el["type"] != "way" or not el.get("geometry"):
continue
tags = el.get("tags", {})
hw = tags.get("highway")
if hw not in ROAD_CLASS:
continue
cls = ROAD_CLASS[hw]
a = frame.geom_xy(el["geometry"])
if hw == "service" and poly_len(a) <= 80:
continue
ow = tags.get("oneway", "")
oneway = 0
if ow in ("yes", "1", "true") or (hw in ("motorway",) and ow != "no") or tags.get("junction") in ("roundabout", "circular"):
oneway = 1
elif ow == "-1":
oneway = 1
a = a[::-1]
try:
lanes = int(str(tags.get("lanes", "")).split(";")[0])
except ValueError:
lanes = DEFAULT_LANES[cls] * (1 if oneway else 2) if cls < 4 else (1 if oneway else 2)
for part in clip_polyline(a, lim):
if poly_len(part) < 2:
continue
roads.append([cls, oneway, lanes] + flat(part))
counts[cls] = counts.get(cls, 0) + 1
print(f" roads: {len(roads)} polylines, by class {dict(sorted(counts.items()))}")
return {"roads": roads}
# ---------------------------------------------------------------- aeroway
def build_aeroway(frame, data):
lim = frame.half + 200
runways, taxiways, aprons, terminals, gates, parking = [], [], [], [], [], []
apron_names = []
for el in data["elements"]:
tags = el.get("tags", {})
aw = tags.get("aeroway")
if el["type"] == "node":
x, y = frame.xy(el["lon"], el["lat"])
if abs(x) > lim or abs(y) > lim:
continue
if aw == "gate":
gates.append([r1(x), r1(y), tags.get("ref", tags.get("name", ""))])
elif aw == "parking_position":
parking.append([r1(x), r1(y), tags.get("ref", tags.get("name", ""))])
continue
if aw == "runway" and el["type"] == "way" and el.get("geometry"):
a = frame.geom_xy(el["geometry"])
closed = len(a) > 3 and np.allclose(a[0], a[-1], atol=1e-3)
try:
w = float(str(tags.get("width", "")).replace("m", "").strip())
except ValueError:
w = None
if closed or tags.get("area") == "yes":
# area runway: use its long axis (PCA) as centreline, width = short extent
c = a.mean(0)
u, s, vt = np.linalg.svd(a - c, full_matrices=False)
ax = vt[0]
t = (a - c) @ ax
n = (a - c) @ vt[1]
p1, p2 = c + ax * t.min(), c + ax * t.max()
w = w or float(n.max() - n.min())
else:
p1, p2 = a[0], a[-1]
runways.append([r1(w or 45.0), tags.get("ref", ""), r1(p1[0]), r1(p1[1]), r1(p2[0]), r1(p2[1])])
elif aw == "taxiway" and el["type"] == "way" and el.get("geometry"):
a = frame.geom_xy(el["geometry"])
if tags.get("area") == "yes":
continue
for part in clip_polyline(a, lim):
taxiways.append(flat(part))
elif aw == "apron":
for ring in outer_rings(el, frame):
c = ring.mean(0)
if abs(c[0]) > lim or abs(c[1]) > lim:
continue
aprons.append(flat(dp_ring(ring, 0.5)))
apron_names.append(tags.get("name", ""))
elif aw == "terminal":
h, _ = tag_height(tags)
for ring in outer_rings(el, frame):
c = ring.mean(0)
if abs(c[0]) > lim or abs(c[1]) > lim:
continue
terminals.append([r1(h)] + flat(dp_ring(ring, 0.5)))
# OSM often splits one runway into several ways (displaced thresholds, blast pads):
# merge segments sharing a ref into one centreline spanning their extreme endpoints.
merged, groups = [], {}
for rw in runways:
if rw[1]:
groups.setdefault(rw[1], []).append(rw)
else:
merged.append(rw)
for ref, segs in groups.items():
pts = np.array([[s[2], s[3]] for s in segs] + [[s[4], s[5]] for s in segs])
main = max(segs, key=lambda s: math.hypot(s[4] - s[2], s[5] - s[3]))
ax = np.array([main[4] - main[2], main[5] - main[3]])
ax /= np.linalg.norm(ax)
t = (pts - pts[0]) @ ax
p1, p2 = pts[int(np.argmin(t))], pts[int(np.argmax(t))]
merged.append([main[0], ref, r1(p1[0]), r1(p1[1]), r1(p2[0]), r1(p2[1])])
if len(merged) != len(runways):
print(f" runways: merged {len(runways)} OSM segments into {len(merged)}")
runways = merged
out = {"runways": runways, "taxiways": taxiways, "aprons": aprons, "apronNames": apron_names,
"terminals": terminals, "gates": gates, "parking": parking}
print(" aeroway: " + ", ".join(f"{k}={len(v)}" for k, v in out.items()))
return out
# ---------------------------------------------------------------- tiles / imagery
def merc_px(lon, lat, z, ts=256):
n = 2 ** z
x = (lon + 180.0) / 360.0 * n * ts
lat_r = np.radians(lat)
y = (1.0 - np.log(np.tan(lat_r) + 1.0 / np.cos(lat_r)) / math.pi) / 2.0 * n * ts
return x, y
def fetch_tiles(url_fmt, tiles, subdir, ext, workers=8):
d = os.path.join(CACHE, subdir)
os.makedirs(d, exist_ok=True)
sess = requests.Session()
sess.headers.update(UA)
fails = []
def get(t):
z, x, y = t
p = os.path.join(d, f"{z}_{x}_{y}.{ext}")
if os.path.exists(p) and os.path.getsize(p) > 0:
return p
for i in range(4):
try:
r = sess.get(url_fmt.format(z=z, x=x, y=y), timeout=60)
if r.status_code == 200 and len(r.content) > 100:
with open(p + ".tmp", "wb") as f:
f.write(r.content)
os.replace(p + ".tmp", p)
return p
if r.status_code == 404:
break
except Exception:
pass
time.sleep(1.5 * (i + 1))
fails.append(t)
return None
with ThreadPoolExecutor(workers) as ex:
res = list(ex.map(get, tiles))
return dict(zip(tiles, res)), fails
def mosaic(frame, z, url_fmt, subdir, ext, mode, pad_m=300.0):
s, w, n, e = frame.bbox(pad_m)
x0, y1 = merc_px(w, s, z)
x1, y0 = merc_px(e, n, z)
tx0, tx1 = int(x0 // 256), int(x1 // 256)
ty0, ty1 = int(y0 // 256), int(y1 // 256)
tiles = [(z, x, y) for y in range(ty0, ty1 + 1) for x in range(tx0, tx1 + 1)]
print(f" {subdir} z{z}: {tx1-tx0+1}x{ty1-ty0+1} = {len(tiles)} tiles", flush=True)
paths, fails = fetch_tiles(url_fmt, tiles, subdir, ext)
if fails:
print(f" WARNING: {len(fails)} tiles failed: {fails[:10]}")
W, H = (tx1 - tx0 + 1) * 256, (ty1 - ty0 + 1) * 256
nb = 3 if mode == "RGB" else 3
mos = np.zeros((H, W, nb), np.uint8)
for (zz, x, y), p in paths.items():
if p is None:
continue
im = Image.open(p).convert("RGB")
if im.size != (256, 256):
im = im.resize((256, 256))
mos[(y - ty0) * 256:(y - ty0 + 1) * 256, (x - tx0) * 256:(x - tx0 + 1) * 256] = np.asarray(im)
return mos, tx0 * 256, ty0 * 256, len(fails)
def bilinear(img, px, py):
"""img HxWxC (float or uint8), px/py float arrays in pixel coords (pixel centres at i+0.5)."""
H, W = img.shape[:2]
fx = np.clip(px - 0.5, 0, W - 1.000001)
fy = np.clip(py - 0.5, 0, H - 1.000001)
x0 = np.floor(fx).astype(np.int64)
y0 = np.floor(fy).astype(np.int64)
ax = (fx - x0)[..., None]
ay = (fy - y0)[..., None]
im = img if img.ndim == 3 else img[..., None]
a = im[y0, x0].astype(np.float32)
b = im[y0, x0 + 1].astype(np.float32)
c = im[y0 + 1, x0].astype(np.float32)
d = im[y0 + 1, x0 + 1].astype(np.float32)
out = (a * (1 - ax) + b * ax) * (1 - ay) + (c * (1 - ax) + d * ax) * ay
return out if img.ndim == 3 else out[..., 0]
def reproject(frame, img, ox, oy, z, N, rows_per=256):
"""Sample a Mercator mosaic (origin global px ox,oy at zoom z) onto the local NxN square."""
h = frame.half
out = np.zeros((N, N) + img.shape[2:], np.float32)
xs = -h + (np.arange(N) + 0.5) * (2 * h / N)
for r0 in range(0, N, rows_per):
r = np.arange(r0, min(N, r0 + rows_per))
ys = h - (r + 0.5) * (2 * h / N)
X, Y = np.meshgrid(xs, ys)
lon, lat = frame.ll(X, Y)
mx, my = merc_px(lon, lat, z)
out[r0:r0 + len(r)] = bilinear(img, mx - ox, my - oy)
return out
ESRI = "https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}"
EOX_S2 = "https://tiles.maps.eox.at/wmts/1.0.0/s2cloudless-2020_3857/default/g/{z}/{y}/{x}.jpg"
def build_ground(frame, z, url=ESRI, sub="esri", name="ground"):
mos, ox, oy, nf = mosaic(frame, z, url, sub, "jpg", "RGB")
out = reproject(frame, mos, ox, oy, z, 4096)
im = Image.fromarray(np.clip(out + 0.5, 0, 255).astype(np.uint8))
p = os.path.join(frame.out, f"{name}.jpg")
im.save(p, quality=90, optimize=True)
print(f" wrote {p} ({os.path.getsize(p)/1e6:.2f} MB)")
im2 = im.resize((2048, 2048), Image.LANCZOS)
p2 = os.path.join(frame.out, f"{name}_2k.jpg")
im2.save(p2, quality=88, optimize=True)
print(f" wrote {p2} ({os.path.getsize(p2)/1e6:.2f} MB)")
return nf
# ---------------------------------------------------------------- check overlay
def to_px(frame, a, N):
a = np.asarray(a, dtype=np.float64).reshape(-1, 2)
return [((x + frame.half) / (2 * frame.half) * N, (frame.half - y) / (2 * frame.half) * N) for x, y in a]
def check_canvas(frame, dark=0.45):
from PIL import ImageEnhance
im = Image.open(os.path.join(frame.out, "ground_2k.jpg")).convert("RGB")
im = ImageEnhance.Brightness(im).enhance(dark)
return im
+40
View File
@@ -0,0 +1,40 @@
// Export the film clock for the score: every visual event the audio locks to.
import fs from 'node:fs';
import { T, FEEDS, CITIES, DURATION, captureTimes, CAPTURE_SLOW } from '../src/timeline.js';
import { angDist } from '../src/geo.js';
const heroes = JSON.parse(fs.readFileSync('imagery/hero/index.json', 'utf8'));
const caps = captureTimes(heroes.length).map((t, i) => ({ t, slow: i < CAPTURE_SLOW, lon: heroes[i].lon }));
// tile reveal times — same formula as the globe shader and HUD
const f = (v) => v - Math.floor(v);
const hash3 = (x, y, z) => {
const px = f(x * 0.3183099 + 0.71) * 17, py = f(y * 0.3183099 + 0.113) * 17, pz = f(z * 0.3183099 + 0.419) * 17;
return f(px * py * pz * (px + py + pz));
};
const tiles = [];
[[3, T.zWave[0], 0.35], [4, T.zWave[1], 0.3], [5, T.zWave[2], 0.25]].forEach(([z, wave, jit]) => {
const n = 2 ** z;
for (let x = 0; x < n; x++)
for (let y = 0; y < n; y++) {
const lon = ((x + 0.5) / n) * 360 - 180;
const lat = (Math.atan(Math.sinh(Math.PI * (1 - 2 * ((y + 0.5) / n)))) * 180) / Math.PI;
// only the hemisphere facing the camera is heard
const d = angDist(0, 0, lon, lat);
tiles.push({ t: wave + (d / Math.PI) * 2.6 + hash3(x, y, z) * jit, z, pan: Math.max(-1, Math.min(1, lon / 90)), vis: angDist(10, 8, lon, lat) < 1.4 });
}
});
// boot console keystrokes
const boot = [];
[['GEV://OBSERVER-1 rev 0.2.1', T.boot, 55], ['allocating substrate ........ 1.0000 OK', T.boot + 0.75, 55], ['loading priors .............. NONE', T.boot + 1.35, 55], ['> seed --origin 0.0000°N 0.0000°E', T.boot + 2.1, 26]].forEach(([s, st, cps], line) => {
for (let i = 0; i < s.length; i++) {
const t = st + i / cps;
if (t < T.collapse) boot.push({ t, ch: s[i], line });
}
});
const status = [77.4, 78.5, 79.6, 80.7, 81.7];
const rapid = Array.from({ length: 7 }, (_, i) => T.rapid + i * 0.22);
fs.writeFileSync('audio/cues.json', JSON.stringify({ T, DURATION, feeds: FEEDS.map((x) => ({ key: x.key, t: x.t })), cities: CITIES.map((c) => ({ slug: c.slug, t0: c.t0, t1: c.t1 })), caps, tiles, boot, status, rapid }, null, 1));
console.log('cues:', caps.length, 'captures,', tiles.length, 'tiles,', boot.length, 'keystrokes');
+511
View File
@@ -0,0 +1,511 @@
#!/usr/bin/env python3
"""Count distinct cameras in every keyless CCTV catalog the app supports.
Fetches each source's FULL public catalog (no anchor selection, no
DEFAULT_*_MAX_SOURCES caps) and counts distinct cameras two ways:
raw - distinct camera ids listed in the catalog at all
valid - distinct ids passing the app's own validity filters (in service /
online / official still or stream URL / plausible coordinates),
i.e. what server/providers/cctv/* would accept before capping.
The headline number written to data/extra_counts.json is the conservative
`valid` count. Sources that fail are skipped, never estimated.
Usage: python3 -I cctv_count.py <out_json> [<detail_json>]
"""
import json, math, re, sys, time
from concurrent.futures import ThreadPoolExecutor
from urllib.parse import urlparse, parse_qs
import requests
UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36'
REPO = '/home/user/gods-eye-view'
def sess():
s = requests.Session()
s.headers.update({'User-Agent': UA, 'Digitraffic-User': 'gods-eye-view'})
return s
def get(url, timeout=60, **kw):
r = sess().get(url, timeout=timeout, **kw)
r.raise_for_status()
return r
def fnum(v):
try:
f = float(v)
return f if math.isfinite(f) else None
except (TypeError, ValueError):
return None
def inbox(lat, lon, la0, la1, lo0, lo1):
return lat is not None and lon is not None and la0 <= lat <= la1 and lo0 <= lon <= lo1
SRC = {}
def source(label, country):
def deco(fn):
SRC[label] = (fn, country)
return fn
return deco
@source('TfL JamCams', 'United Kingdom')
def tfl():
d = get('https://api.tfl.gov.uk/Place/Type/JamCam').json()
raw, ok = set(), set()
for p in d:
raw.add(p['id'])
props = {a['key']: a['value'] for a in p.get('additionalProperties', [])}
if str(props.get('available')).lower() != 'true':
continue
if not (props.get('imageUrl') or '').startswith('https://s3-eu-west-1.amazonaws.com/jamcams.tfl.gov.uk/'):
continue
if fnum(p.get('lat')) is None or fnum(p.get('lon')) is None:
continue
ok.add(p['id'])
return raw, ok, 'one Place per camera; available=true + official S3 still'
@source('NYC DOT', 'United States')
def nyc():
d = get('https://webcams.nyctmc.org/api/cameras').json()
raw = {c['id'] for c in d}
ok = {c['id'] for c in d if str(c.get('isOnline')).strip().lower() == 'true'
and inbox(fnum(c.get('latitude')), fnum(c.get('longitude')), 40.4, 41.0, -74.3, -73.6)}
return raw, ok, 'one row per camera; isOnline=true'
@source('Caltrans', 'United States')
def caltrans():
raw, ok = set(), set()
notes = []
def one(dist):
err = None
for attempt in range(5): # cwwp2 resets connections under parallel load
try:
return dist, get(f'https://cwwp2.dot.ca.gov/data/d{dist}/cctv/cctvStatusD{dist:02d}.json').json()
except Exception as e:
err = e
time.sleep(1 + attempt)
return dist, err
with ThreadPoolExecutor(3) as ex:
res = list(ex.map(one, range(1, 13)))
for dist, d in res:
if isinstance(d, Exception):
notes.append(f'D{dist} failed: {str(d)[:60]}')
continue
for i, row in enumerate(d.get('data', [])):
c = row.get('cctv') or {}
loc = c.get('location') or {}
name = (loc.get('locationName') or '').strip()
m = re.match(r'^([A-Za-z0-9_-]+)\s*--', name)
key = f"d{dist}-{(m.group(1) if m else c.get('index') or name or i)}".lower()
raw.add(key)
if str(c.get('inService')).lower() != 'true':
continue
if fnum(loc.get('latitude')) is None or fnum(loc.get('longitude')) is None:
continue
url = ((c.get('imageData') or {}).get('static') or {}).get('currentImageURL') or ''
if not url.startswith('https://cwwp2.dot.ca.gov/'):
continue
ok.add(key)
return raw, ok, 'all 12 districts; inService=true + cwwp2 still. ' + '; '.join(notes)
@source('WSDOT', 'United States')
def wsdot():
d = json.loads(get('https://data.wsdot.wa.gov/travelcenter/Cameras.json').content.decode('cp1252'))
raw, ok = set(), set()
for f in d['features']:
a = f.get('attributes') or {}
cid = a.get('CameraID')
if not isinstance(cid, int) or cid <= 0:
continue
raw.add(cid)
if (a.get('ImageURL') or '').strip().startswith('https://images.wsdot.wa.gov/') and fnum((f.get('geometry') or {}).get('x')) is not None:
ok.add(cid)
return raw, ok, 'one feature per CameraID; official images.wsdot.wa.gov still'
@source('King County DOT', 'United States')
def kingco():
d = get('https://services.arcgis.com/Ej0PsM5Aw677QF1W/arcgis/rest/services/TRAFFICCAMERA_POINT_2029/FeatureServer/0/query?where=1%3D1&outFields=AssetID,Location,ImageURL,Live,Model,Manufacturer,Owner,CurrentStatus&f=geojson').json()
raw, ok = set(), set()
for f in d['features']:
p = f.get('properties') or {}
aid = p.get('AssetID')
if not isinstance(aid, int) or aid <= 0:
continue
raw.add(aid)
if p.get('Live') != 1 or str(p.get('CurrentStatus') or '').strip() != 'Active':
continue
if str(p.get('Owner') or '').strip() == 'WSDOT':
continue # WSDOT-owned rows are WSDOT cameras (counted under WSDOT)
url = (p.get('ImageURL') or '').strip().replace('http://info.kingcounty.gov/', 'https://info.kingcounty.gov/')
if not url.startswith('https://info.kingcounty.gov/'):
continue
ok.add(aid)
note = 'Live=1 + Active, WSDOT-owned rows excluded (avoid double count)'
if d.get('exceededTransferLimit') or (d.get('properties') or {}).get('exceededTransferLimit'):
note += ' WARNING: transfer limit exceeded'
return raw, ok, note
@source('DriveBC', 'Canada')
def drivebc():
d = get('https://www.drivebc.ca/api/webcams/').json()
raw, ok = set(), set()
for c in d:
cid = c.get('id')
if not isinstance(cid, int) or cid <= 0:
continue
raw.add(cid)
if c.get('is_on') is not True or c.get('should_appear') is not True:
continue
coords = (c.get('location') or {}).get('coordinates') or [None, None]
if not inbox(fnum(coords[1]), fnum(coords[0]), 48.0, 60.5, -139.5, -113.5):
continue
ok.add(cid)
return raw, ok, 'one webcam per id; is_on + should_appear'
@source('Fintraffic Digitraffic', 'Finland')
def fintraffic():
d = get('https://tie.digitraffic.fi/api/weathercam/v1/stations', headers={'Accept-Encoding': 'gzip'}).json()
raw, ok, presets = set(), set(), 0
for f in d['features']:
p = f.get('properties') or {}
sid = str(p.get('id') or '')
if not sid:
continue
raw.add(sid)
if str(p.get('collectionStatus') or '').upper() != 'GATHERING':
continue
live = [pr for pr in p.get('presets') or [] if pr.get('inCollection') is True and re.fullmatch(r'C\d{7}', str(pr.get('id'))) and str(pr['id']).startswith(sid)]
if live:
ok.add(sid)
presets += len(live)
return raw, ok, f'counted per STATION (camera site), not per preset view; {presets} live presets (the app treats each preset as a camera)'
@source('Statens vegvesen', 'Norway')
def vegvesen():
d = get('https://ogckart-sn1.atlas.vegvesen.no/ogc/features/v1/collections/datex_3_1:CctvSimple/items?f=application/geo%2Bjson&limit=5000',
headers={'Accept': 'application/geo+json,application/json'}).json()
raw, ok = set(), set()
for f in d['features']:
p = f.get('properties') or {}
rid = str(p.get('cameraId') or '').strip()
if not re.fullmatch(r'[A-Za-z0-9_-]{1,40}', rid):
continue
raw.add(rid)
av = str(p.get('status.stillImageAvailability') or '').strip()
if av and av != 'videoOrImagesAvailable':
continue
if (p.get('stillImageUrl') or '') != f'https://kamera.atlas.vegvesen.no/api/images/{rid}':
continue
c = (f.get('geometry') or {}).get('coordinates') or [None, None]
if not inbox(fnum(c[1]), fnum(c[0]), 57.5, 71.5, 4.0, 31.5):
continue
ok.add(rid)
note = f"numberMatched={d.get('numberMatched')} numberReturned={d.get('numberReturned')}"
return raw, ok, 'stillImageAvailability ok + official still; ' + note
@source('City of Tallinn', 'Estonia')
def tallinn():
d = json.load(open(f'{REPO}/config/cctv_sources.tallinn.json'))
raw, ok = set(), set()
for c in d:
cid = str(c.get('id') or '').strip()
if not cid:
continue
raw.add(cid)
url = c.get('snapshotUrl') or c.get('url') or ''
if inbox(fnum(c.get('lat')), fnum(c.get('lon')), 59.2, 59.7, 24.3, 25.4) and url.startswith('https://ristmikud.tallinn.ee/'):
ok.add(url)
return raw, ok, 'curated repo catalog (config/cctv_sources.tallinn.json) of ristmikud.tallinn.ee stills; distinct frame URLs'
@source('Transpordiamet Tarktee', 'Estonia')
def tarktee():
lx = get('https://tarktee.transpordiamet.ee/api/v1/datex/roadCameraLocations', headers={'Accept': 'application/xml'}).text
ix = get('https://tarktee.transpordiamet.ee/api/v1/datex/roadCameraImages', headers={'Accept': 'application/xml'}).text
locs = {}
for m in re.finditer(r'<predefinedLocation\s+id="([^"]+)"[^>]*>([\s\S]*?)</predefinedLocation>', lx):
la = re.search(r'<latitude>\s*(-?\d+(?:\.\d+)?)\s*</latitude>', m.group(2), re.I)
lo = re.search(r'<longitude>\s*(-?\d+(?:\.\d+)?)\s*</longitude>', m.group(2), re.I)
if la and lo:
locs[m.group(1)] = (float(la.group(1)), float(lo.group(1)))
imgs = {}
for m in re.finditer(r'<trafficView\b[^>]*>([\s\S]*?)</trafficView>', ix):
r = re.search(r'<linearPredefinedLocationReference\b[^>]*\bid="([^"]+)"', m.group(1), re.I)
u = re.search(r'<urlLinkAddress>\s*([^<\s]+)\s*</urlLinkAddress>', m.group(1), re.I)
if r and u:
imgs[r.group(1)] = u.group(1)
raw = set(locs) | set(imgs)
ok = set()
for lid, (lat, lon) in locs.items():
u = imgs.get(lid, '')
if not u.startswith('https://tarktee.transpordiamet.ee/images/'):
continue
if not inbox(lat, lon, 57.4, 59.9, 21.5, 28.4):
continue
n = re.search(r'/images/(\d+)/', u)
ok.add(n.group(1) if n else lid)
return raw, ok, 'DATEX location joined to image; distinct camera numbers'
@source('Via Lietuva eismoinfo', 'Lithuania')
def lithuania():
rows = get('https://eismoinfo.lt/eismoinfo-backend/camera-info-table', headers={'Accept': 'application/json'}).json()
layers = get('https://eismoinfo.lt/eismoinfo-backend/layer-static-features/VKR', headers={'Accept': 'application/json'}).json()
feats = {}
for L in layers:
if L.get('layer') == 'VKR':
feats = {str(f.get('id')): f for f in L.get('features') or [] if f}
raw, ok = set(), set()
for r in rows:
rid = str(r.get('id') or '')
if not re.fullmatch(r'[1-9]\d{0,8}', rid):
continue
raw.add(rid)
f = feats.get(rid)
try:
lat, lon = f['points'][0]['point']
except Exception:
continue
if not inbox(fnum(lat), fnum(lon), 53.8, 56.5, 20.8, 26.9):
continue
u = urlparse(str(r.get('image') or ''))
if f'{u.scheme}://{u.netloc}' != 'https://eismoinfo.lt' or u.path != '/eismoinfo-backend/image-provider/camera/last' or parse_qs(u.query).get('id', [''])[0] != rid:
continue
ok.add(rid)
return raw, ok, 'camera table joined to VKR layer; official still endpoint'
@source('Live Traffic NSW', 'Australia')
def nsw():
d = get('https://data.livetraffic.com/cameras/traffic-cam.json').json()
raw, ok = set(), set()
for f in d['features']:
p = f.get('properties') or {}
fid = str(f.get('id') or p.get('id') or p.get('title') or '').strip()
if not fid:
continue
raw.add(fid)
href = (p.get('href') or '').replace('http://', 'https://')
c = (f.get('geometry') or {}).get('coordinates') or [None, None]
if href.startswith('https://webcams.transport.nsw.gov.au/') and inbox(fnum(c[1]), fnum(c[0]), -38.0, -28.0, 140.0, 154.5):
ok.add(fid)
return raw, ok, 'one feature per camera; official webcams.transport.nsw.gov.au still'
@source('City of Calgary', 'Canada')
def calgary():
d = get('https://data.calgary.ca/resource/k7p9-kppz.json?$limit=5000').json()
raw, ok = set(), set()
for c in d:
url = ((c.get('camera_url') or {}).get('url') or '').strip()
if not url:
continue
u = re.sub(r'^http://', 'https://', url)
m = re.search(r'loc(\d+)\.jpg$', urlparse(u).path, re.I)
key = m.group(1) if m else urlparse(u).path
raw.add(key)
coords = (c.get('point') or {}).get('coordinates') or [None, None]
if u.startswith('https://trafficcam.calgary.ca/') and inbox(fnum(coords[1]), fnum(coords[0]), 50.7, 51.4, -114.5, -113.7):
ok.add(key)
return raw, ok, f'{len(d)} rows ($limit=5000); distinct trafficcam.calgary.ca image ids'
@source('City of Toronto RESCU', 'Canada')
def toronto():
t = get('https://opendata.toronto.ca/transportation/tmc/rescucameraimages/Data/tmcearthcameras.json').text
d = json.loads(t[t.index('(') + 1: t.rindex(')')])
raw, ok = set(), set()
for c in d['Data']:
n = str(c.get('Number') or '').strip()
if not n:
continue
raw.add(n)
if re.fullmatch(r'\d{1,6}', n) and inbox(fnum(c.get('Latitude')), fnum(c.get('Longitude')), 43.4, 44.0, -79.8, -79.0):
ok.add(n)
return raw, ok, 'one row per numbered RESCU camera'
@source('Servei Català de Trànsit', 'Spain')
def catalonia():
x = get('https://www.gencat.cat/transit/opendata/cameres.xml').text
hosts = {'mct.gencat.cat': '/mct2bo/', 'www.bcn.cat': '/transit/imatges/', 'emap.terrassa.cat': '/it_terrassa/'}
raw, ok = set(), set()
for m in re.finditer(r'<gml:featureMember>([\s\S]*?)</gml:featureMember>', x):
b = m.group(1)
co = re.search(r'<gml:coordinates[^>]*>([^<]+)</gml:coordinates>', b)
lk = re.search(r'<cite:link>([^<]*)</cite:link>', b)
if not co or not lk:
continue
link = lk.group(1).replace('&amp;', '&').strip()
u = urlparse(link)
cam = parse_qs(u.query).get('sctidcam', [''])[0]
key = f'sct:{cam}' if u.hostname == 'mct.gencat.cat' and cam else f'{u.hostname}{u.path}'
raw.add(key)
lon, lat = (fnum(v) for v in co.group(1).split(',')[:2])
if not inbox(lat, lon, 40.4, 42.95, 0.1, 3.4):
continue
d = hosts.get(u.hostname or '')
if not d or not u.path.startswith(d) or u.username or u.password or u.port:
continue
f = u.path[len(d):]
if not f or '/' in f:
continue
if u.hostname == 'mct.gencat.cat' and (f != 'RenderService' or not re.fullmatch(r'[a-z0-9]+\.(gif|jpe?g)', cam, re.I)):
continue
ok.add(key)
return raw, ok, 'SCT + Barcelona IMI + Terrassa; keyed by frame file (feed fid is unstable)'
@source('City of Austin', 'United States')
def austin():
d = get('https://data.austintexas.gov/api/views/b4k4-adkb/rows.json?accessType=DOWNLOAD').json()
cols = [c['fieldName'] for c in d['meta']['view']['columns']]
raw, ok = set(), set()
for row in d['data']:
r = dict(zip(cols, row))
cid = str(r.get('camera_id') or '').strip()
if not cid:
continue
raw.add(cid)
st = (r.get('camera_status') or '').strip().upper()
if st and st != 'TURNED_ON':
continue
m = re.match(r'POINT \(([-\d.]+) ([-\d.]+)\)', r.get('location') or '')
if not m or not inbox(fnum(m.group(2)), fnum(m.group(1)), 29.9, 30.8, -98.2, -97.3):
continue
ok.add(cid)
return raw, ok, 'camera_status TURNED_ON'
TXDOT = 'ABL AMA ATL AUS BMT BWD BRY CHS CRP DAL ELP FTW HOU LRD LBB LFK ODA PAR PHR SJT SAT TYL WAC WFS YKM'.split()
@source('TxDOT ITS', 'United States')
def txdot():
def one(code):
for attempt in range(2):
try:
return code, get(f'https://its.txdot.gov/its/DistrictIts/GetCctvStatusListByDistrict?districtCode={code}',
headers={'Accept': 'application/json'}, timeout=60).json()
except Exception as e:
err = e
return code, err
with ThreadPoolExecutor(8) as ex:
res = list(ex.map(one, TXDOT))
raw, ok, notes, per = set(), set(), [], {}
for code, d in res:
if isinstance(d, Exception):
notes.append(f'{code} failed: {str(d)[:50]}')
continue
n = 0
for rows in (d.get('roadwayCctvStatuses') or {}).values():
for r in rows or []:
icd = str(r.get('icd_Id') or '').strip()
if not icd:
continue
raw.add(icd)
if r.get('statusDescription') != 'Device Online' or r.get('hasSnapshot') is False:
continue
lat, lon = r.get('latitude'), r.get('longitude')
if not (isinstance(lat, (int, float)) and isinstance(lon, (int, float)) and inbox(lat, lon, 25.5, 36.7, -106.8, -93.3)):
continue
if icd not in ok:
n += 1
ok.add(icd)
per[code] = n
return raw, ok, f'all 25 districts; Device Online + snapshot; per-district {per}. ' + '; '.join(notes)
@source('Ontario 511', 'Canada')
def ontario():
d = get('https://511on.ca/api/v2/get/cameras?format=json&lang=en', headers={'Accept': 'application/json'}).json()
raw, ok, views = set(), set(), 0
for r in d:
rid = str(r.get('Id') or r.get('id') or '').strip()
if not rid:
continue
raw.add(rid)
if not inbox(fnum(r.get('Latitude')), fnum(r.get('Longitude')), 41.0, 57.5, -95.6, -74.0):
continue
good = []
for v in r.get('Views') or r.get('views') or []:
if str(v.get('Status') or '').strip().lower() != 'enabled':
continue
u = urlparse(str(v.get('Url') or '').strip())
if u.scheme == 'https' and (u.hostname == '511on.ca' or (u.hostname or '').endswith('.traveliq.co')) and re.fullmatch(r'/map/Cctv/[A-Za-z0-9_.-]+', u.path):
good.append(v)
if good:
ok.add(rid)
views += len(good)
return raw, ok, f'counted per camera row, not per view; {views} enabled views'
@source('DelDOT', 'United States')
def deldot():
d = get('https://tmc.deldot.gov/json/videocamera.json', headers={'Accept': 'application/json'}).json()
raw, ok = set(), set()
for r in d.get('videoCameras') or []:
cid = str(r.get('id') or '').strip()
if not cid:
continue
raw.add(cid)
if str(r.get('status')).lower() != 'active':
continue
if not inbox(fnum(r.get('lat')), fnum(r.get('lon')), 38.4, 39.9, -75.85, -75.0):
continue
u = urlparse(str((r.get('urls') or {}).get('m3u8s') or ''))
if u.scheme != 'https' or u.hostname != 'video.deldot.gov' or u.port not in (None, 443) or u.username or not re.fullmatch(r'/live/[A-Za-z0-9_.-]+/playlist\.m3u8', u.path):
continue
if not re.fullmatch(r'[A-Za-z0-9_-]{1,80}', cid):
continue
ok.add(cid)
return raw, ok, 'status active + official HLS stream'
def run(label):
fn, country = SRC[label]
t0 = time.time()
try:
raw, ok, note = fn()
if not ok:
raise RuntimeError('catalog parsed to zero cameras')
return label, dict(country=country, raw=len(raw), valid=len(ok), note=note, secs=round(time.time() - t0, 1))
except Exception as e:
return label, dict(country=country, error=f'{type(e).__name__}: {str(e)[:200]}', secs=round(time.time() - t0, 1))
if __name__ == '__main__':
out = sys.argv[1]
detail = sys.argv[2] if len(sys.argv) > 2 else None
with ThreadPoolExecutor(8) as ex:
results = dict(ex.map(run, list(SRC)))
counted = {k: v for k, v in results.items() if 'valid' in v}
for k, v in results.items():
print(f"{k:28s} {v.get('country', ''):15s} raw={v.get('raw', '-'):>6} valid={v.get('valid', '-'):>6} {v.get('error') or v.get('note')}")
total = sum(v['valid'] for v in counted.values())
res = {
'cctv_catalog': total,
'cctv_sources': {k: v['valid'] for k, v in counted.items()},
'cctv_countries': len({v['country'] for v in counted.values()}),
'fetched_utc': time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime()),
}
print('TOTAL valid', total, 'raw', sum(v['raw'] for v in counted.values()), 'countries', res['cctv_countries'])
json.dump(res, open(out, 'w'), ensure_ascii=False, indent=2)
if detail:
json.dump(results, open(detail, 'w'), ensure_ascii=False, indent=2)
+406
View File
@@ -0,0 +1,406 @@
#!/usr/bin/env python3
"""Download real current still frames from keyless public traffic-camera feeds.
Stage 1 (this script): build candidate lists from each source's public catalog
(endpoints mirror server/providers/cctv/*), sample a spread of cameras, download
frames, compute quality metrics, write <work>/frames/*.jpg + <work>/candidates.json.
Stage 2 (cctv_select.py) filters, crops to 640x360 and writes cctv/index.json.
Usage: python3 -I cctv_fetch.py <work_dir>
"""
import base64, hashlib, io, json, math, random, re, sys, time
import xml.etree.ElementTree as ET
from concurrent.futures import ThreadPoolExecutor
import requests
from PIL import Image, ImageStat, ImageFilter
WORK = sys.argv[1]
import os
os.makedirs(f'{WORK}/frames', exist_ok=True)
UA_BROWSER = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36'
S = requests.Session()
S.headers.update({'User-Agent': UA_BROWSER, 'Digitraffic-User': 'gods-eye-intro'})
rng = random.Random(42)
def get(url, **kw):
kw.setdefault('timeout', 40)
r = S.get(url, **kw)
r.raise_for_status()
return r
def C(source, name, city, country, lat, lon, url, kind='image'):
return dict(source=source, name=name.strip(), city=city, country=country, lat=round(float(lat), 5), lon=round(float(lon), 5), url=url, kind=kind)
def nearest(cands, anchors, n):
"""Spread a pick across anchors: round-robin, nearest cameras to each anchor first, randomized a bit."""
buckets = []
for (alat, alon, label) in anchors:
b = sorted(cands, key=lambda c: (c['lat'] - alat) ** 2 + ((c['lon'] - alon) * math.cos(math.radians(alat))) ** 2)[:max(n * 3, 12)]
rng.shuffle(b)
for c in b:
c['city'] = label if c.get('city') in (None, '', '?') else c['city']
buckets.append(b)
out, seen = [], set()
while len(out) < n and any(buckets):
for b in buckets:
while b:
c = b.pop()
if c['url'] not in seen:
seen.add(c['url']); out.append(c); break
return out[:n]
def sample(cands, n):
cands = list(cands)
rng.shuffle(cands)
return cands[:n]
SOURCES = {}
def source(fn):
SOURCES[fn.__name__] = fn
return fn
@source
def tfl(n):
d = get('https://api.tfl.gov.uk/Place/Type/JamCam').json()
out = []
for p in d:
props = {a['key']: a['value'] for a in p.get('additionalProperties', [])}
if props.get('available') != 'true' or not props.get('imageUrl'):
continue
out.append(C('TfL JamCams', p['commonName'], 'London', 'United Kingdom', p['lat'], p['lon'], props['imageUrl']))
return nearest(out, [(51.5074, -0.1278, 'London')], n)
@source
def nyc(n):
d = get('https://webcams.nyctmc.org/api/cameras').json()
out = [C('NYC DOT', c['name'], f"New York ({c.get('area')})", 'United States', c['latitude'], c['longitude'],
f"https://webcams.nyctmc.org/api/cameras/{c['id']}/image") for c in d if str(c.get('isOnline')).lower() == 'true']
return nearest(out, [(40.758, -73.9855, 'New York'), (40.7128, -74.006, 'New York'), (40.6782, -73.9442, 'New York'), (40.7282, -73.7949, 'New York')], n)
@source
def caltrans(n):
out = []
for dist, city in [(4, 'San Francisco Bay Area'), (7, 'Los Angeles'), (11, 'San Diego'), (3, 'Sacramento')]:
try:
d = get(f'https://cwwp2.dot.ca.gov/data/d{dist}/cctv/cctvStatusD{dist:02d}.json').json()
except Exception as e:
print('caltrans', dist, e); continue
for row in d['data']:
c = row['cctv']
url = (c.get('imageData', {}).get('static') or {}).get('currentImageURL')
if c.get('inService') != 'true' or not url:
continue
loc = c['location']
out.append(C('Caltrans CCTV', loc['locationName'], loc.get('nearbyPlace') or city, 'United States', loc['latitude'], loc['longitude'], url))
return nearest(out, [(37.7793, -122.4193, 'San Francisco'), (34.0537, -118.2428, 'Los Angeles'), (32.7157, -117.1611, 'San Diego'), (38.5816, -121.4944, 'Sacramento')], n)
@source
def wsdot(n):
d = json.loads(get('https://data.wsdot.wa.gov/travelcenter/Cameras.json').content.decode('cp1252'))
out = []
for f in d['features']:
a, g = f['attributes'], f['geometry']
url = a.get('ImageURL') or ''
if not url.startswith('https://images.wsdot.wa.gov/'):
continue
lon = g['x'] / 6378137 * 180 / math.pi
lat = (2 * math.atan(math.exp(g['y'] / 6378137)) - math.pi / 2) * 180 / math.pi
out.append(C('WSDOT', a['CameraTitle'], '', 'United States', lat, lon, url))
return nearest(out, [(47.6062, -122.3321, 'Seattle'), (47.6588, -117.426, 'Spokane')], n)
@source
def kingco(n):
d = get('https://services.arcgis.com/Ej0PsM5Aw677QF1W/arcgis/rest/services/TRAFFICCAMERA_POINT_2029/FeatureServer/0/query?where=1%3D1&outFields=AssetID,Location,ImageURL,Live,Model,Manufacturer,Owner,CurrentStatus&f=geojson').json()
out = []
for f in d['features']:
p = f['properties']
url = (p.get('ImageURL') or '').replace('http://info.kingcounty.gov/', 'https://info.kingcounty.gov/')
if p.get('CurrentStatus') != 'Active' or not url.startswith('https://info.kingcounty.gov/'):
continue
lon, lat = f['geometry']['coordinates'][:2]
out.append(C('King County DOT', p['Location'], 'King County, WA', 'United States', lat, lon, url))
return sample(out, n)
@source
def drivebc(n):
d = get('https://www.drivebc.ca/api/webcams/').json()
out = []
for c in d:
if not c.get('is_on') or c.get('marked_stale') or not c.get('should_appear', True):
continue
lon, lat = c['location']['coordinates'][:2]
out.append(C('DriveBC', c['name'], c.get('region_name') or 'British Columbia', 'Canada', lat, lon, f"https://www.drivebc.ca/images/{c['id']}.jpg"))
return nearest(out, [(49.2827, -123.1207, 'Vancouver'), (48.4284, -123.3656, 'Victoria'), (50.1163, -122.9574, 'Whistler')], n)
@source
def calgary(n):
d = get('https://data.calgary.ca/resource/k7p9-kppz.json?$limit=500').json()
out = []
for c in d:
url = (c.get('camera_url') or {}).get('url', '').replace('http://', 'https://')
if not url.startswith('https://trafficcam.calgary.ca/'):
continue
lon, lat = c['point']['coordinates'][:2]
out.append(C('City of Calgary', c['camera_location'], 'Calgary', 'Canada', lat, lon, url))
return nearest(out, [(51.0461, -114.0626, 'Calgary')], n)
@source
def toronto(n):
t = get('https://opendata.toronto.ca/transportation/tmc/rescucameraimages/Data/tmcearthcameras.json').text
d = json.loads(t[t.index('(') + 1: t.rindex(')')])
out = [C('City of Toronto RESCU', c['Name'].title(), 'Toronto', 'Canada', c['Latitude'], c['Longitude'],
f"https://opendata.toronto.ca/transportation/tmc/rescucameraimages/CameraImages/loc{c['Number']}.jpg")
for c in d['Data'] if re.fullmatch(r'\d+', str(c['Number']))]
return nearest(out, [(43.6532, -79.3832, 'Toronto')], n)
@source
def txdot(n):
out = []
for code, city in [('HOU', 'Houston'), ('DAL', 'Dallas'), ('AUS', 'Austin'), ('SAT', 'San Antonio'), ('ELP', 'El Paso')]:
try:
d = get(f'https://its.txdot.gov/its/DistrictIts/GetCctvStatusListByDistrict?districtCode={code}').json()
except Exception as e:
print('txdot', code, e); continue
seen = set()
for rows in d['roadwayCctvStatuses'].values():
for r in rows:
if r.get('statusDescription') != 'Device Online' or r.get('hasSnapshot') is False or r['icd_Id'] in seen:
continue
if not isinstance(r.get('latitude'), (int, float)) or not r['latitude']:
continue
seen.add(r['icd_Id'])
url = 'https://its.txdot.gov/its/DistrictIts/GetCctvSnapshotByIcdId?' + requests.compat.urlencode({'icdId': r['icd_Id'], 'districtCode': code})
out.append(C('TxDOT ITS', r['name'], city, 'United States', r['latitude'], r['longitude'], url, kind='txdot'))
return nearest(out, [(29.7604, -95.3698, 'Houston'), (32.7767, -96.797, 'Dallas'), (29.4241, -98.4936, 'San Antonio'), (31.7619, -106.485, 'El Paso')], n)
@source
def austin(n):
d = get('https://data.austintexas.gov/api/views/b4k4-adkb/rows.json?accessType=DOWNLOAD').json()
cols = [c['fieldName'] for c in d['meta']['view']['columns']]
out = []
for row in d['data']:
r = dict(zip(cols, row))
if (r.get('camera_status') or '').strip().upper() != 'TURNED_ON':
continue
m = re.match(r'POINT \(([-\d.]+) ([-\d.]+)\)', r.get('location') or '')
if not m or not r.get('camera_id'):
continue
out.append(C('City of Austin', (r.get('location_name') or '').strip(), 'Austin', 'United States', m.group(2), m.group(1),
f"https://cctv.austinmobility.io/image/{r['camera_id']}.jpg"))
return nearest(out, [(30.2672, -97.7431, 'Austin')], n)
@source
def fintraffic(n):
d = get('https://tie.digitraffic.fi/api/weathercam/v1/stations').json()
out = []
for f in d['features']:
p = f['properties']
if p.get('collectionStatus') != 'GATHERING':
continue
lon, lat = f['geometry']['coordinates'][:2]
for pr in p.get('presets', []):
if pr.get('inCollection'):
out.append(C('Fintraffic Digitraffic', p['name'].replace('_', ' '), '', 'Finland', lat, lon, f"https://weathercam.digitraffic.fi/{pr['id']}.jpg"))
break
return nearest(out, [(60.1699, 24.9384, 'Helsinki'), (61.4978, 23.761, 'Tampere'), (65.0121, 25.4651, 'Oulu'), (66.5039, 25.7294, 'Rovaniemi'), (60.4518, 22.2666, 'Turku')], n)
@source
def vegvesen(n):
d = get('https://ogckart-sn1.atlas.vegvesen.no/ogc/features/v1/collections/datex_3_1:CctvSimple/items?f=application/geo%2Bjson&limit=5000').json()
out = []
for f in d['features']:
p = f['properties']
url = p.get('stillImageUrl') or ''
if not url.startswith('https://kamera.atlas.vegvesen.no/api/images/'):
continue
lon, lat = f['geometry']['coordinates'][:2]
name = ' - '.join(x for x in [p.get('description'), p.get('roadNumber')] if x)
out.append(C('Statens vegvesen', name, '', 'Norway', lat, lon, url))
return nearest(out, [(59.9139, 10.7522, 'Oslo'), (60.3913, 5.3221, 'Bergen'), (63.4305, 10.3951, 'Trondheim'), (69.6492, 18.9553, 'Tromsø'), (58.97, 5.7331, 'Stavanger')], n)
@source
def tallinn(n):
d = json.load(open('/home/user/gods-eye-view/config/cctv_sources.tallinn.json'))
out = [C('City of Tallinn', c['name'], 'Tallinn', 'Estonia', c['lat'], c['lon'], c['snapshotUrl']) for c in d if c.get('snapshotUrl', '').startswith('https://ristmikud.tallinn.ee/')]
return sample(out, n)
@source
def tarktee(n):
ns = {'d': 'http://datex2.eu/schema/2/2_0'}
loc = ET.fromstring(get('https://tarktee.transpordiamet.ee/api/v1/datex/roadCameraLocations').content)
locs = {}
for pl in loc.iter('{http://datex2.eu/schema/2/2_0}predefinedLocation'):
lat = pl.find('.//d:latitude', ns); lon = pl.find('.//d:longitude', ns); nm = pl.find('.//d:value', ns)
if lat is not None and lon is not None:
locs[pl.get('id')] = (float(lat.text), float(lon.text), nm.text if nm is not None else '')
img = ET.fromstring(get('https://tarktee.transpordiamet.ee/api/v1/datex/roadCameraImages').content)
out = []
for tv in img.iter('{http://datex2.eu/schema/2/2_0}linearTrafficView'):
ref = tv.find('d:linearPredefinedLocationReference', ns)
u = tv.find('.//d:urlLinkAddress', ns)
if ref is None or u is None or ref.get('id') not in locs:
continue
lat, lon, nm = locs[ref.get('id')]
out.append(C('Transpordiamet Tarktee', nm, '', 'Estonia', lat, lon, u.text))
return nearest(out, [(59.437, 24.753, 'Tallinn'), (58.378, 26.729, 'Tartu'), (58.3859, 24.4971, 'Pärnu')], n)
@source
def lithuania(n):
rows = get('https://eismoinfo.lt/eismoinfo-backend/camera-info-table').json()
layer = get('https://eismoinfo.lt/eismoinfo-backend/layer-static-features/VKR').json()
feats = {str(f.get('id')): f for L in layer for f in L.get('features', [])}
out = []
for r in rows:
f = feats.get(str(r.get('id')))
try:
lat, lon = f['points'][0]['point']
except Exception:
continue
out.append(C('Via Lietuva eismoinfo', r['name'], '', 'Lithuania', lat, lon, f"https://eismoinfo.lt/eismoinfo-backend/image-provider/camera/last?id={r['id']}"))
return nearest(out, [(54.6872, 25.2797, 'Vilnius'), (54.8985, 23.9036, 'Kaunas'), (55.7033, 21.1443, 'Klaipėda')], n)
@source
def catalonia(n):
x = get('https://www.gencat.cat/transit/opendata/cameres.xml').text
out = []
for m in re.finditer(r'<gml:coordinates[^>]*>([-\d.]+),([-\d.]+)</gml:coordinates>.*?<cite:carretera>(.*?)</cite:carretera>.*?<cite:municipi>(.*?)</cite:municipi>.*?<cite:link>(.*?)</cite:link>.*?<cite:font>(.*?)</cite:font>', x, re.S):
lon, lat, road, muni, link, font = m.groups()
link = link.replace('&amp;', '&').replace('http://', 'https://')
if font not in ('SCT', 'IMI', 'Terrassa'):
continue
city = 'Barcelona' if font == 'IMI' else ('Terrassa' if font == 'Terrassa' else muni)
out.append(C('Servei Català de Trànsit', f'{road} {muni}', city, 'Spain', lat, lon, link))
return nearest(out, [(41.3874, 2.1686, 'Barcelona'), (41.5636, 2.0111, 'Terrassa'), (41.45, 2.2, 'Barcelona area')], n)
@source
def nsw(n):
d = get('https://data.livetraffic.com/cameras/traffic-cam.json').json()
out = []
for f in d['features']:
p = f['properties']
href = (p.get('href') or '').replace('http://', 'https://')
if not href.startswith('https://webcams.transport.nsw.gov.au/'):
continue
lon, lat = f['geometry']['coordinates'][:2]
out.append(C('Live Traffic NSW', p['title'], '', 'Australia', lat, lon, href))
return nearest(out, [(-33.8688, 151.2093, 'Sydney'), (-32.9283, 151.7817, 'Newcastle'), (-34.4278, 150.8931, 'Wollongong'), (-33.8, 151.0, 'Western Sydney')], n)
@source
def singapore(n):
d = get('https://api.data.gov.sg/v1/transport/traffic-images').json()
out = [C('LTA Singapore (data.gov.sg)', f"Camera {c['camera_id']}", 'Singapore', 'Singapore', c['location']['latitude'], c['location']['longitude'], c['image'])
for c in d['items'][0]['cameras']]
return sample(out, n)
@source
def hongkong(n):
x = ET.fromstring(get('https://static.data.gov.hk/td/traffic-snapshot-images/code/Traffic_Camera_Locations_En.xml').content)
out = []
for im in x.iter('image'):
g = lambda k: (im.findtext(k) or '').strip()
if not g('url').startswith('https://tdcctv.data.one.gov.hk/'):
continue
out.append(C('HK Transport Department', re.sub(r'\s*\[.*?\]$', '', g('description')), f"Hong Kong ({g('district')})", 'Hong Kong', g('latitude'), g('longitude'), g('url')))
return sample(out, n)
@source
def nz(n):
d = get('https://trafficnz.info/service/traffic/rest/4/cameras/all', headers={'Accept': 'application/json'}).json()
out = []
for c in d['response']['camera']:
if c.get('offline') or c.get('underMaintenance'):
continue
out.append(C('NZTA Waka Kotahi', (c.get('name') or '').strip() + (' - ' + c['description'] if c.get('description') else ''),
(c.get('region') or {}).get('name', ''), 'New Zealand', c['latitude'], c['longitude'], 'https://trafficnz.info' + c['imageUrl']))
return nearest(out, [(-36.8485, 174.7633, 'Auckland'), (-41.2865, 174.7762, 'Wellington'), (-43.5321, 172.6362, 'Christchurch'), (-37.787, 175.279, 'Hamilton'), (-45.0312, 168.6626, 'Queenstown')], n)
TARGETS = dict(nsw=26, hongkong=24, singapore=18, nz=20, nyc=20, caltrans=18, wsdot=12, kingco=6, drivebc=14, calgary=8,
toronto=10, txdot=10, austin=8, tfl=14, fintraffic=10, vegvesen=10, tallinn=6, tarktee=6, lithuania=8, catalonia=10)
def fetch_frame(c):
try:
hdr = {}
if c['kind'] == 'txdot':
r = S.get(c['url'], timeout=30, headers={'Accept': 'application/json'})
r.raise_for_status()
js = r.json()
b64 = js.get('snippet') or js.get('Snippet') or ''
data = base64.b64decode(b64.split(',')[-1])
else:
r = S.get(c['url'], timeout=30)
r.raise_for_status()
data = r.content
im = Image.open(io.BytesIO(data))
im.load()
im = im.convert('RGB')
g = im.convert('L')
st = ImageStat.Stat(g)
edges = ImageStat.Stat(g.resize((320, 180)).filter(ImageFilter.FIND_EDGES)).mean[0]
sat = ImageStat.Stat(im.convert('HSV').split()[1]).mean[0]
small = g.resize((16, 9))
px = list(small.tobytes())
ahash = ''.join('1' if p > sum(px) / len(px) else '0' for p in px)
h = hashlib.md5(c['url'].encode()).hexdigest()[:12]
path = f'{WORK}/frames/{h}.jpg'
im.save(path, quality=95)
c.update(ok=True, file=path, w=im.width, h=im.height, mean=round(st.mean[0], 1), std=round(st.stddev[0], 1),
edges=round(edges, 2), sat=round(sat, 1), ahash=ahash, bytes=len(data), fetched=time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime()))
except Exception as e:
c.update(ok=False, err=str(e)[:160])
return c
if __name__ == '__main__':
only = sys.argv[2].split(',') if len(sys.argv) > 2 else list(SOURCES)
cands = []
for name in only:
try:
got = SOURCES[name](TARGETS.get(name, 8))
print(f'{name}: {len(got)} candidates')
for g in got:
g['src_key'] = name
cands += got
except Exception as e:
print(f'{name}: CATALOG FAILED {e}')
with ThreadPoolExecutor(24) as ex:
res = list(ex.map(fetch_frame, cands))
prev = []
pth = f'{WORK}/candidates.json'
if os.path.exists(pth):
prev = [p for p in json.load(open(pth)) if p['src_key'] not in only]
json.dump(prev + res, open(pth, 'w'), ensure_ascii=False, indent=0)
from collections import Counter
print('ok per source', Counter(c['src_key'] for c in res if c['ok']))
print('fail per source', Counter(c['src_key'] for c in res if not c['ok']))
for c in res:
if not c['ok']:
print(' FAIL', c['src_key'], c['url'][:90], c['err'][:100])
+42
View File
@@ -0,0 +1,42 @@
#!/usr/bin/env python3
"""Stage 2: pick a spread of good frames from cctv_fetch.py output, cover-crop to 640x360, write cctv/index.json.
Usage: python3 -I cctv_select.py <work_dir> <out_cctv_dir>
EXCLUDE lists frames rejected on visual review of the contact sheets (placeholders such as
'Temporarily Unavailable', 'Kamera er midlertidig ute av drift', 'NB! ÜHENDUS KAAMERAGA PUUDUB',
'Interrupció temporal de la imatge', and an all-black King County frame)."""
import json, os, sys
from collections import Counter
from PIL import Image
W, OUT = sys.argv[1], sys.argv[2]
ok = json.load(open(f'{W}/ok.json'))
EXCLUDE = {40, 43, 45, 46, 49, 66, 126, 139, 148, 150}
PREFER = {'hongkong': [187, 195, 197, 198, 201, 205, 206, 196, 200, 202]} # fewest privacy-mask polygons
QUOTA = dict(nsw=12, hongkong=10, singapore=6, nz=12, nyc=9, caltrans=6, wsdot=6, kingco=3, drivebc=6, calgary=4, toronto=6,
txdot=4, austin=4, tfl=8, fintraffic=4, vegvesen=4, tallinn=3, tarktee=3, lithuania=4, catalonia=3)
hash_count = Counter(x['ahash'] for x in ok)
good = [x for x in ok if x['i'] not in EXCLUDE and hash_count[x['ahash']] == 1 and x['w'] >= 320 and x['mean'] > 25 and x['std'] > 15]
pick = []
for src, q in QUOTA.items():
pool = [x for x in good if x['src_key'] == src]
pref = [x for i in PREFER.get(src, []) for x in pool if x['i'] == i]
rest = sorted([x for x in pool if x not in pref], key=lambda x: -(x['std'] * x['edges']))
pick += (pref + rest)[:q]
os.makedirs(OUT, exist_ok=True)
for f in os.listdir(OUT):
if f.startswith('cam_') and f.endswith('.jpg'):
os.remove(os.path.join(OUT, f))
index = []
for n, x in enumerate(pick, 1):
im = Image.open(x['file']).convert('RGB')
w, h = im.size
s = max(640 / w, 360 / h)
im = im.resize((max(640, round(w * s)), max(360, round(h * s))), Image.LANCZOS)
w, h = im.size
l, t = (w - 640) // 2, (h - 360) // 2
im = im.crop((l, t, l + 640, t + 360))
fn = f'cam_{n:03d}.jpg'
im.save(os.path.join(OUT, fn), quality=88)
index.append(dict(file=fn, name=x['name'], city=x['city'], country=x['country'], lat=x['lat'], lon=x['lon'], source=x['source'],
fetched_utc=x['fetched'], orig_size=[x['w'], x['h']], url=x['url']))
json.dump(index, open(os.path.join(OUT, 'index.json'), 'w'), ensure_ascii=False, indent=1)
print(len(index), Counter(i['country'] for i in index))
+33
View File
@@ -0,0 +1,33 @@
#!/usr/bin/env bash
# Re-fetch the raw keyless feeds used by process_basic.py / process_tle.py / process_vessels.py.
# Usage: fetch_basic.sh <raw_dir> then:
# python3 -I process_basic.py <raw_dir> ../../data
# python3 -I process_tle.py <raw_dir> ../../data
# python3 -I process_vessels.py <raw_dir> ../../data
# <venv with eccodes,numpy,requests>/python -I fetch_wind.py https://noaa-gfs-bdp-pds.s3.amazonaws.com/gfs.YYYYMMDD/HH/atmos/gfs.tHHz.pgrb2.1p00.f006 ../../data/wind.json
# python -I fetch_bonus.py ../../data <raw_dir>
# python -I cctv_fetch.py <work_dir> && python -I cctv_select.py <work_dir> ../../cctv (after rebuilding ok.json/contact sheets and re-reviewing EXCLUDE)
set -u
R="$1"; mkdir -p "$R"; cd "$R"
date -u +%Y-%m-%dT%H:%M:%SZ > fetch_start.txt
CT='gods-eye-view-celestrak-proxy/1.0 (+https://github.com/bilawalsidhu/gods-eye-view)' # CelesTrak 403s bulk groups without a descriptive UA
f() { curl -sS -m 300 --compressed "${@:3}" -o "$1" -w "$1 %{http_code} %{size_download}\n" "$2"; }
f opensky.json https://opensky-network.org/api/states/all &
f mil.json https://api.adsb.lol/v2/mil &
f mil_adsbfi.json https://opendata.adsb.fi/api/v2/mil &
f quakes.geojson https://earthquake.usgs.gov/earthquakes/feed/v1.0/summary/all_month.geojson &
f viirs_snpp.csv https://firms.modaps.eosdis.nasa.gov/data/active_fire/suomi-npp-viirs-c2/csv/SUOMI_VIIRS_C2_Global_24h.csv &
f viirs_n20.csv https://firms.modaps.eosdis.nasa.gov/data/active_fire/noaa-20-viirs-c2/csv/J1_VIIRS_C2_Global_24h.csv &
f modis.csv https://firms.modaps.eosdis.nasa.gov/data/active_fire/modis-c6.1/csv/MODIS_C6_1_Global_24h.csv &
f cables.json https://www.submarinecablemap.com/api/v3/cable/cable-geo.json &
f coast50.geojson https://raw.githubusercontent.com/nvkelso/natural-earth-vector/master/geojson/ne_50m_coastline.geojson &
f countries110.geojson https://raw.githubusercontent.com/nvkelso/natural-earth-vector/master/geojson/ne_110m_admin_0_countries.geojson &
f places.geojson https://raw.githubusercontent.com/nvkelso/natural-earth-vector/master/geojson/ne_10m_populated_places_simple.geojson &
f airports.csv https://davidmegginson.github.io/ourairports-data/airports.csv &
f kystverket_ais.json https://kystdatahuset.kystverket.no/ws/api/ais/realtime/geojson &
f dt_ais_from.json "https://meri.digitraffic.fi/api/ais/v1/locations?from=$(( ($(date +%s)-172800)*1000 ))" -H "Digitraffic-User: gods-eye-intro" &
f digitraffic_vessels.json https://meri.digitraffic.fi/api/ais/v1/vessels -H "Digitraffic-User: gods-eye-intro" &
wait
# CelesTrak: sequential (parallel requests get connection resets); FORMAT=3le endpoint answered when FORMAT=tle timed out
f tle_active3.txt "https://celestrak.org/NORAD/elements/gp.php?GROUP=active&FORMAT=3le" -A "$CT"
for g in stations starlink gps-ops geo weather; do sleep 3; f tle_$g.txt "https://celestrak.org/NORAD/elements/gp.php?GROUP=$g&FORMAT=tle" -A "$CT"; done
+133
View File
@@ -0,0 +1,133 @@
#!/usr/bin/env python3
"""Bonus layers: radio stations (Radio Browser), GBFS bike stations, launch pads (Launch Library 2),
power plants (WRI Global Power Plant Database). Usage: python3 -I fetch_bonus.py <out_data_dir> <raw_dir>"""
import csv, io, json, os, sys, time, zipfile
import requests
OUT, RAW = sys.argv[1], sys.argv[2]
only = sys.argv[3].split(',') if len(sys.argv) > 3 else ['radio', 'gbfs', 'pads', 'power']
S = requests.Session()
S.headers['User-Agent'] = 'GodsEyeView/1.0 intro-sequence data snapshot'
log = {}
def dump(name, obj):
with open(os.path.join(OUT, name), 'w') as f:
json.dump(obj, f, separators=(',', ':'), ensure_ascii=False)
if 'radio' in only:
rows, used = [], None
for host in ['https://de1.api.radio-browser.info', 'https://de2.api.radio-browser.info', 'https://nl1.api.radio-browser.info']:
try:
r = S.get(f'{host}/json/stations/search', params={'has_geo_info': 'true', 'hidebroken': 'true', 'limit': 100000}, timeout=180,
headers={'User-Agent': 'GodsEyeView/1.0 (Radio Browser directory client)'})
r.raise_for_status()
data = r.json(); used = host
break
except Exception as e:
print('radio', host, e)
seen = set()
for s in data:
lat, lon = s.get('geo_lat'), s.get('geo_long')
if lat is None or lon is None or (abs(lat) < 0.01 and abs(lon) < 0.01):
continue
key = (round(lon, 3), round(lat, 3), s['name'].strip())
if key in seen:
continue
seen.add(key)
rows.append([round(lon, 4), round(lat, 4), s['name'].strip(), s.get('countrycode') or ''])
dump('radio.json', {'cols': ['lon', 'lat', 'name', 'cc'], 'rows': rows})
log['radio'] = dict(url=f'{used}/json/stations/search?has_geo_info=true&hidebroken=true&limit=100000', rows=len(rows))
print('radio', len(rows))
if 'gbfs' in only:
SYSTEMS = {
'Citi Bike (New York)': 'https://gbfs.lyft.com/gbfs/2.3/bkn/en/station_information.json',
'Divvy (Chicago)': 'https://gbfs.lyft.com/gbfs/2.3/chi/en/station_information.json',
'Bay Wheels (SF Bay)': 'https://gbfs.lyft.com/gbfs/2.3/bay/en/station_information.json',
'Capital Bikeshare (DC)': 'https://gbfs.lyft.com/gbfs/2.3/dca-cabi/en/station_information.json',
'Bluebikes (Boston)': 'https://gbfs.bluebikes.com/gbfs/en/station_information.json',
'Bike Share Toronto': 'https://toronto.publicbikesystem.net/customer/gbfs/v2/en/station_information.json',
'BIXI (Montreal)': 'https://gbfs.velobixi.com/gbfs/en/station_information.json',
'Ecobici (Mexico City)': 'https://gbfs.mex.lyftbikes.com/gbfs/en/station_information.json',
'Velib Metropole (Paris)': 'https://velib-metropole-opendata.smovengo.cloud/opendata/Velib_Metropole/station_information.json',
'Bicing (Barcelona)': 'https://barcelona.publicbikesystem.net/customer/gbfs/v2/en/station_information.json',
'BiciMAD (Madrid)': 'https://madrid.publicbikesystem.net/customer/gbfs/v2/en/station_information.json',
'Bike Sampa (Sao Paulo)': 'https://saopaulo.publicbikesystem.net/customer/gbfs/v2/en/station_information.json',
'Bike Rio (Rio de Janeiro)': 'https://riodejaneiro.publicbikesystem.net/customer/gbfs/v2/en/station_information.json',
'Bike Santiago': 'https://santiago.publicbikesystem.net/customer/gbfs/v2/en/station_information.json',
'Tembici Bogota': 'https://bogota.publicbikesystem.net/customer/gbfs/v2/en/station_information.json',
'Indego (Philadelphia)': 'https://gbfs.bcycle.com/bcycle_indego/station_information.json',
'Metro Bike Share (LA)': 'https://gbfs.bcycle.com/bcycle_lametro/station_information.json',
'Oslo Bysykkel': 'https://gbfs.urbansharing.com/oslobysykkel.no/station_information.json',
}
rows, per = [], {}
for name, url in SYSTEMS.items():
try:
d = S.get(url, timeout=60, headers={'Client-Identifier': 'gods-eye-view'}).json()
st = d['data']['stations']
for s in st:
rows.append([round(float(s['lon']), 5), round(float(s['lat']), 5)])
per[name] = len(st)
except Exception as e:
per[name] = f'FAILED {str(e)[:80]}'
# nextbike: one keyless global live feed covering every nextbike city
try:
d = S.get('https://api.nextbike.net/maps/nextbike-live.json', timeout=120).json()
n = 0
for c in d['countries']:
for city in c.get('cities', []):
for p in city.get('places', []):
if p.get('spot'): # stations only, not free-floating bikes
rows.append([round(p['lng'], 5), round(p['lat'], 5)]); n += 1
per['nextbike (all cities, global)'] = n
except Exception as e:
per['nextbike'] = f'FAILED {e}'
dump('bikes.json', {'cols': ['lon', 'lat'], 'systems': per, 'rows': rows})
log['gbfs'] = dict(systems=per, urls=list(SYSTEMS.values()) + ['https://api.nextbike.net/maps/nextbike-live.json'], rows=len(rows))
print('bikes', len(rows), per)
if 'pads' in only:
url = 'https://ll.thespacedevs.com/2.3.0/pads/?limit=100&mode=list'
rows = []
while url:
r = S.get(url, timeout=60)
r.raise_for_status()
d = r.json()
for p in d['results']:
try:
lat, lon = float(p['latitude']), float(p['longitude'])
except (TypeError, ValueError, KeyError):
continue
loc = p.get('location') or {}
rows.append([round(lon, 4), round(lat, 4), p['name'], loc.get('name') if isinstance(loc, dict) else None,
p.get('total_launch_count') or 0, bool(p.get('active'))])
url = d.get('next')
time.sleep(1)
rows.sort(key=lambda x: -x[4])
dump('launch_sites.json', {'cols': ['lon', 'lat', 'pad', 'location', 'total_launches', 'active'], 'rows': rows})
log['pads'] = dict(url='https://ll.thespacedevs.com/2.3.0/pads/?limit=100&mode=list (paged)', rows=len(rows))
print('pads', len(rows))
if 'power' in only:
url = 'https://raw.githubusercontent.com/wri/global-power-plant-database/master/output_database/global_power_plant_database.csv'
r = S.get(url, timeout=180)
r.raise_for_status()
rows = []
for row in csv.DictReader(io.StringIO(r.content.decode('utf-8'))):
try:
rows.append([round(float(row['longitude']), 4), round(float(row['latitude']), 4), round(float(row['capacity_mw']), 1), row['primary_fuel'], row['name'], row['country']])
except ValueError:
pass
rows.sort(key=lambda x: -x[2])
dump('power_plants.json', {'cols': ['lon', 'lat', 'capacity_mw', 'fuel', 'name', 'iso3'], 'rows': rows})
log['power'] = dict(url=url, rows=len(rows))
print('power', len(rows))
prev = {}
p = os.path.join(RAW, 'bonus_log.json')
if os.path.exists(p):
prev = json.load(open(p))
prev.update(log)
json.dump(prev, open(p, 'w'), indent=1)
+28
View File
@@ -0,0 +1,28 @@
#!/usr/bin/env python3
"""GFS 1.0 deg 10 m wind U/V from NOAA's AWS Open Data bucket (noaa-gfs-bdp-pds) via .idx byte
ranges, same approach as server/providers/wind/gfs.js; decoded with python `eccodes` (pip).
Usage: python fetch_wind.py <gfs file base url (no .idx)> <out wind.json>"""
import json, sys, requests, numpy as np, eccodes
base, out = sys.argv[1], sys.argv[2]
lines = [l.split(':') for l in requests.get(base + '.idx', timeout=60).text.strip().split('\n')]
def field(var):
i = next(k for k, p in enumerate(lines) if p[3] == var and p[4] == '10 m above ground')
s, e = int(lines[i][1]), int(lines[i + 1][1]) - 1
b = requests.get(base, headers={'Range': f'bytes={s}-{e}'}, timeout=120).content
h = eccodes.codes_new_from_message(b)
g = {k: eccodes.codes_get(h, k) for k in ['Ni', 'Nj', 'dataDate', 'dataTime', 'step']}
g.update({k: eccodes.codes_get_double(h, k) for k in ['longitudeOfFirstGridPointInDegrees', 'latitudeOfFirstGridPointInDegrees', 'iDirectionIncrementInDegrees', 'jDirectionIncrementInDegrees']})
g['values'] = eccodes.codes_get_values(h).reshape(g['Nj'], g['Ni'])
eccodes.codes_release(h)
return g
U, V = field('UGRD'), field('VGRD')
assert U['longitudeOfFirstGridPointInDegrees'] == 0 and U['latitudeOfFirstGridPointInDegrees'] == 90
dx = U['iDirectionIncrementInDegrees']; shift = int(round(180 / dx))
u = np.roll(U['values'], -shift, axis=1); v = np.roll(V['values'], -shift, axis=1) # lon 0..359 -> -180..179
ny, nx = u.shape
json.dump({'source': 'NOAA GFS 1.0deg ' + base.split('/')[-1], 'refTime': f"{U['dataDate']} {U['dataTime']:04d}Z", 'forecastHour': U['step'],
'level': '10 m above ground', 'units': 'm/s', 'nx': nx, 'ny': ny, 'lo1': -180, 'la1': 90, 'dx': dx, 'dy': U['jDirectionIncrementInDegrees'],
'u': [round(float(x), 2) for x in u.ravel()], 'v': [round(float(x), 2) for x in v.ravel()]},
open(out, 'w'), separators=(',', ':'))
sp = np.hypot(u, v)
print(nx, ny, 'max', sp.max().round(1), 'mean', sp.mean().round(2), U['dataDate'], U['dataTime'], 'f', U['step'])
+172
View File
@@ -0,0 +1,172 @@
#!/usr/bin/env python3
"""Convert raw fetched feeds (see fetch_basic.sh) into compact intro-sequence JSON.
Usage: python3 -I process_basic.py <raw_dir> <out_data_dir>
All inputs are real downloads; this only filters, reprojects units and rounds.
"""
import csv, json, os, sys, time
RAW, OUT = sys.argv[1], sys.argv[2]
meta = {}
def r(x, n):
return None if x is None else round(float(x), n)
def dump(name, obj):
p = os.path.join(OUT, name)
with open(p, 'w') as f:
json.dump(obj, f, separators=(',', ':'), ensure_ascii=False)
print(f'{name}: {os.path.getsize(p)/1e6:.2f} MB')
def raw(name):
return os.path.join(RAW, name)
# 1. flights (OpenSky)
o = json.load(open(raw('opensky.json')))
rows = []
for s in o['states']:
lon, lat, on_ground = s[5], s[6], s[8]
if lon is None or lat is None or on_ground:
continue
alt = s[13] if s[13] is not None else s[7]
rows.append([r(lon, 4), r(lat, 4), r(alt, 0), r(s[10], 1), r(s[9], 1),
(s[1] or '').strip(), s[2]])
dump('flights.json', {'t': o['time'], 'cols': ['lon', 'lat', 'alt_m', 'track_deg', 'vel_ms', 'callsign', 'country'], 'rows': rows})
meta['flights'] = len(rows)
# 2. military (adsb.lol /v2/mil, unioned with adsb.fi /v2/mil, dedup by hex; adsb.lol wins)
FT, KT = 0.3048, 0.514444
seen, rows, tmax = set(), [], 0
for fname in ['mil.json', 'mil_adsbfi.json']:
if not os.path.exists(raw(fname)):
continue
d = json.load(open(raw(fname)))
tmax = max(tmax, int((d.get('now') or 0) / (1000 if (d.get('now') or 0) > 1e11 else 1)))
for a in d.get('ac') or d.get('aircraft') or []:
lat, lon = a.get('lat'), a.get('lon')
if lat is None or lon is None or a['hex'] in seen:
continue
alt = a.get('alt_geom', a.get('alt_baro'))
if a.get('alt_baro') == 'ground' or alt == 'ground':
continue # airborne only
seen.add(a['hex'])
track = a.get('track', a.get('true_heading'))
gs = a.get('gs')
rows.append([r(lon, 4), r(lat, 4), r(alt * FT, 0) if isinstance(alt, (int, float)) else None,
r(track, 1), r(gs * KT, 1) if gs is not None else None,
(a.get('flight') or '').strip(), None, a.get('t')])
dump('military.json', {'t': tmax, 'cols': ['lon', 'lat', 'alt_m', 'track_deg', 'vel_ms', 'callsign', 'country', 'type'], 'rows': rows,
'note': 'country not provided by adsb.lol/adsb.fi feeds (null)'})
meta['military'] = len(rows)
# 4. quakes
q = json.load(open(raw('quakes.geojson')))
rows = []
for f in q['features']:
c, p = f['geometry']['coordinates'], f['properties']
if p.get('mag') is None:
continue
rows.append([r(c[0], 3), r(c[1], 3), r(c[2], 1), r(p['mag'], 2), p['time'], p.get('place') or ''])
rows.sort(key=lambda x: x[4])
dump('quakes.json', {'t': q['metadata']['generated'], 'cols': ['lon', 'lat', 'depth_km', 'mag', 'time_ms', 'place'], 'rows': rows})
meta['quakes'] = len(rows)
# 5. fires (FIRMS 24h global: SNPP VIIRS, NOAA-20 VIIRS, MODIS)
rows, per = [], {}
for fname, bcol, tag in [('viirs_snpp.csv', 'bright_ti4', 'VIIRS_SNPP'), ('viirs_n20.csv', 'bright_ti4', 'VIIRS_NOAA20'), ('modis.csv', 'brightness', 'MODIS')]:
if not os.path.exists(raw(fname)):
continue
n = 0
with open(raw(fname), newline='') as fh:
for row in csv.DictReader(fh):
try:
rows.append([r(row['longitude'], 3), r(row['latitude'], 3), r(row['frp'], 1), r(row[bcol], 1)])
n += 1
except (KeyError, ValueError):
pass
per[tag] = n
dump('fires.json', {'cols': ['lon', 'lat', 'frp', 'brightness'], 'sources': per, 'rows': rows})
meta['fires'] = len(rows)
meta['fires_per'] = per
# 8. cables
c = json.load(open(raw('cables.json')))
lines = []
for f in c['features']:
g = f['geometry']
parts = g['coordinates'] if g['type'] == 'MultiLineString' else [g['coordinates']]
for part in parts:
pl = [[r(x, 2), r(y, 2)] for x, y in (pt[:2] for pt in part)]
if len(pl) >= 2:
lines.append(pl)
dump('cables.json', {'cables': lines})
meta['cables'] = (len(c['features']), len(lines))
# 9. coastline + borders
def lines_of(geo):
out = []
for f in geo['features']:
g = f['geometry']
if g is None:
continue
t, co = g['type'], g['coordinates']
if t == 'LineString':
parts = [co]
elif t == 'MultiLineString':
parts = co
elif t == 'Polygon':
parts = co
elif t == 'MultiPolygon':
parts = [ring for poly in co for ring in poly]
else:
parts = []
for part in parts:
pl, last = [], None
for x, y in (pt[:2] for pt in part):
pt = [r(x, 2), r(y, 2)]
if pt != last:
pl.append(pt)
last = pt
if len(pl) >= 2:
out.append(pl)
return out
coast = lines_of(json.load(open(raw('coast50.geojson'))))
dump('coastline.json', {'lines': coast})
borders = lines_of(json.load(open(raw('countries110.geojson'))))
dump('borders.json', {'lines': borders})
meta['coast'] = len(coast)
meta['borders'] = len(borders)
# 10. cities
pp = json.load(open(raw('places.geojson')))
rows = []
for f in pp['features']:
p = f['properties']
lon, lat = f['geometry']['coordinates'][:2]
rows.append([r(lon, 3), r(lat, 3), int(p.get('pop_max') or 0), p.get('name') or p.get('nameascii'), p.get('adm0name') or p.get('sov0name')])
rows.sort(key=lambda x: -x[2])
rows = rows[:3000]
dump('cities.json', {'cols': ['lon', 'lat', 'pop_max', 'name', 'country'], 'rows': rows})
meta['cities'] = len(rows)
# 11. airports (OurAirports large + medium)
rows = []
with open(raw('airports.csv'), newline='', encoding='utf-8') as fh:
for row in csv.DictReader(fh):
if row['type'] not in ('large_airport', 'medium_airport'):
continue
rows.append([r(row['longitude_deg'], 4), r(row['latitude_deg'], 4), row['iata_code'] or row['ident'], row['name'],
'L' if row['type'] == 'large_airport' else 'M'])
rows.sort(key=lambda x: x[4])
dump('airports.json', {'cols': ['lon', 'lat', 'iata', 'name', 'size'], 'rows': rows})
meta['airports'] = (len(rows), sum(1 for x in rows if x[4] == 'L'))
json.dump(meta, open(os.path.join(RAW, 'meta_basic.json'), 'w'))
print(meta)
+31
View File
@@ -0,0 +1,31 @@
#!/usr/bin/env python3
"""tle.txt (3-line TLEs, CelesTrak GROUP=active) + tle_groups.json (NORAD ids per group).
Usage: python3 -I process_tle.py <raw_dir> <out_dir>"""
import json, os, sys
RAW, OUT = sys.argv[1], sys.argv[2]
def parse(path):
L = [l.rstrip() for l in open(path) if l.strip()]
out = []
i = 0
while i + 2 < len(L) + 0 and i + 2 <= len(L) - 1:
if L[i+1].startswith('1 ') and L[i+2].startswith('2 '):
name = L[i][2:] if L[i].startswith('0 ') else L[i]
out.append((name.strip(), L[i+1], L[i+2])); i += 3
else:
i += 1
return out
act = parse(os.path.join(RAW, 'tle_active3.txt'))
with open(os.path.join(OUT, 'tle.txt'), 'w') as f:
for n, a, b in act:
f.write(f'{n}\n{a}\n{b}\n')
groups, notable = {}, {}
for g in ['stations', 'starlink', 'gps-ops', 'geo', 'weather']:
p = os.path.join(RAW, f'tle_{g}.txt')
if os.path.exists(p) and os.path.getsize(p) > 0:
sats = parse(p)
groups[g] = sorted(int(a[2:7]) for _, a, _ in sats)
for n, _, _ in sats[: (40 if g != 'starlink' else 3)]:
notable[n] = g
epochs = sorted(a[18:32] for _, a, _ in act)
json.dump({'groups_norad': groups, 'notable': notable}, open(os.path.join(OUT, 'tle_groups.json'), 'w'), separators=(',', ':'))
print(len(act), {k: len(v) for k, v in groups.items()}, 'epoch range', epochs[0], epochs[-1])
@@ -0,0 +1,38 @@
#!/usr/bin/env python3
"""vessels.json from keyless AIS: Fintraffic Digitraffic (Finland/Baltic) + Kystverket Kystdatahuset realtime (Norway).
Usage: python3 -I process_vessels.py <raw_dir> <out_dir>"""
import json, os, sys, time
RAW, OUT = sys.argv[1], sys.argv[2]
rows, src = {}, {}
# Norway (Kystverket) - LineString trails, last coordinate = latest position
k = json.load(open(os.path.join(RAW, 'kystverket_ais.json')))
for f in k['features']:
c, p = f['geometry']['coordinates'], f['properties']
if not c:
continue
lon, lat = c[-1]
cog = p.get('cog')
cog = None if cog is None or cog >= 360 else round(cog, 1)
sog = p.get('speed')
sog = None if sog is None or sog >= 102.3 else round(sog, 1)
rows[p['mmsi']] = [round(lon, 4), round(lat, 4), sog, cog, p['mmsi'], (p.get('ship_name') or '').strip() or None]
src[p['mmsi']] = 'NO'
# Finland / Baltic (Digitraffic)
names = {v['mmsi']: (v.get('name') or '').strip() or None for v in json.load(open(os.path.join(RAW, 'digitraffic_vessels.json')))}
d = json.load(open(os.path.join(RAW, 'dt_ais_from.json')))
for f in d['features']:
p = f['properties']
lon, lat = f['geometry']['coordinates'][:2]
if p['mmsi'] in rows:
continue
cog = p.get('cog'); cog = None if cog is None or cog >= 360 else round(cog, 1)
sog = p.get('sog'); sog = None if sog is None or sog >= 102.3 else round(sog, 1)
rows[p['mmsi']] = [round(lon, 4), round(lat, 4), sog, cog, p['mmsi'], names.get(p['mmsi'])]
src[p['mmsi']] = 'FI'
out = list(rows.values())
json.dump({'t': int(time.time()), 'cols': ['lon', 'lat', 'sog_kn', 'cog_deg', 'mmsi', 'name'],
'coverage': 'Norwegian coast/EEZ (Kystverket) + Finnish waters & Baltic Sea (Fintraffic Digitraffic); not global',
'counts': {'kystverket_norway': sum(1 for v in src.values() if v == 'NO'), 'digitraffic_finland_baltic': sum(1 for v in src.values() if v == 'FI')},
'rows': out}, open(os.path.join(OUT, 'vessels.json'), 'w'), separators=(',', ':'), ensure_ascii=False)
lons = [r[0] for r in out]; lats = [r[1] for r in out]
print(len(out), 'bbox', min(lons), max(lons), min(lats), max(lats))
+23
View File
@@ -0,0 +1,23 @@
#!/usr/bin/env bash
# v2: sound design only (made to sit under a music track). Normalise the SFX
# mix to -16 LUFS, keep it as a stem, and mux share + master cuts.
set -euo pipefail
cd "$(dirname "$0")/.."
node tools/export-cues.mjs
python3 audio/sfx.py
stats=$(ffmpeg -hide_banner -i audio/sfx.wav -af loudnorm=I=-16:TP=-1.5:LRA=14:print_format=json -f null - 2>&1 | sed -n '/^{/,/^}/p')
g() { echo "$stats" | python3 -c "import json,sys;print(json.load(sys.stdin)['$1'])"; }
ffmpeg -y -loglevel error -i audio/sfx.wav -af "loudnorm=I=-16:TP=-1.5:LRA=14:measured_I=$(g input_i):measured_TP=$(g input_tp):measured_LRA=$(g input_lra):measured_thresh=$(g input_thresh):linear=true,aresample=48000" -c:a pcm_s24le audio/sfx_norm.wav
mkdir -p out/final_v2
cp audio/sfx_norm.wav out/final_v2/GodsEyeView_FirstLight_v2_SFX_stem.wav
for tag in 16x9 9x16; do
v=out/v2_video_$tag.mp4
[ -s "$v" ] || { echo "missing $v"; continue; }
ffmpeg -y -loglevel error -i $v -i audio/sfx_norm.wav -map 0:v -map 1:a -c:v copy -c:a aac -b:a 320k -shortest -movflags +faststart out/final_v2/GodsEyeView_FirstLight_v2_${tag}_master.mp4
ffmpeg -y -loglevel error -i $v -c:v libx264 -preset slow -b:v 16M -maxrate 24M -bufsize 32M -pass 1 -passlogfile out/final_v2/p_$tag -an -f null /dev/null
ffmpeg -y -loglevel error -i $v -i audio/sfx_norm.wav -map 0:v -map 1:a -c:v libx264 -preset slow -b:v 16M -maxrate 24M -bufsize 32M -pass 2 -passlogfile out/final_v2/p_$tag \
-pix_fmt yuv420p -colorspace bt709 -color_primaries bt709 -color_trc bt709 -c:a aac -b:a 256k -shortest -movflags +faststart out/final_v2/GodsEyeView_FirstLight_v2_${tag}.mp4
rm -f out/final_v2/p_$tag*
echo "finalized $tag"
done
ls -la out/final_v2
+26
View File
@@ -0,0 +1,26 @@
#!/usr/bin/env bash
# Loudness-normalise the score (-14 LUFS, -1 dBTP) and mux it into both cuts.
# tools/finalize.sh (after tools/render-all.sh has produced out/video_*.mp4)
set -euo pipefail
cd "$(dirname "$0")/.."
python3 audio/score.py
# two-pass loudnorm
stats=$(ffmpeg -hide_banner -i audio/score.wav -af loudnorm=I=-14:TP=-1:LRA=11:print_format=json -f null - 2>&1 | sed -n '/^{/,/^}/p')
mi=$(echo "$stats" | python3 -c "import json,sys;d=json.load(sys.stdin);print(d['input_i'])")
mtp=$(echo "$stats" | python3 -c "import json,sys;d=json.load(sys.stdin);print(d['input_tp'])")
mlra=$(echo "$stats" | python3 -c "import json,sys;d=json.load(sys.stdin);print(d['input_lra'])")
mth=$(echo "$stats" | python3 -c "import json,sys;d=json.load(sys.stdin);print(d['input_thresh'])")
ffmpeg -y -loglevel error -i audio/score.wav -af "loudnorm=I=-14:TP=-1:LRA=11:measured_I=$mi:measured_TP=$mtp:measured_LRA=$mlra:measured_thresh=$mth:linear=true,aresample=48000" -c:a pcm_s24le audio/score_norm.wav
mkdir -p out/final
for tag in 16x9 9x16; do
[ -s out/video_$tag.mp4 ] || { echo "missing out/video_$tag.mp4"; continue; }
# master: the CRF 14 picture untouched + 320k AAC
ffmpeg -y -loglevel error -i out/video_$tag.mp4 -i audio/score_norm.wav -map 0:v -map 1:a -c:v copy -c:a aac -b:a 320k -ar 48000 -shortest -movflags +faststart \
-metadata title="God's Eye View — First Light ($tag)" out/final/GodsEyeView_FirstLight_${tag}_master.mp4
# delivery: lighter encode for posting/sharing
ffmpeg -y -loglevel error -i out/video_$tag.mp4 -i audio/score_norm.wav -map 0:v -map 1:a -c:v libx264 -preset slow -crf 19 -tune grain -pix_fmt yuv420p \
-colorspace bt709 -color_primaries bt709 -color_trc bt709 -c:a aac -b:a 256k -ar 48000 -shortest -movflags +faststart \
-metadata title="God's Eye View — First Light ($tag)" out/final/GodsEyeView_FirstLight_${tag}.mp4
echo "finalized $tag"
done
ls -la out/final
+175
View File
@@ -0,0 +1,175 @@
"""Build the flurry montage captures.
python3 select_cities.py # -> city_candidates.json (primary + backup per region)
python3 build.py # -> imagery/flurry/fx_###.jpg, index.json, tools/flurry/sheet.jpg
Each capture: Esri World Imagery, 3x3 tiles around the point, centred 512x512
crop, JPEG q86. Placeholder / low-variance captures are rejected automatically
(tiles.quality_ok); captures that passed the metrics but looked wrong on the
review sheets (forest, farmland, haze) are listed in VISUAL_REJECT. Rejected
items are replaced by the next backup of the same region (cities) or by the
next backup landmark.
"""
import glob
import json
import os
import sys
from concurrent.futures import ThreadPoolExecutor
from PIL import Image
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from landmarks import LANDMARKS # noqa: E402
from tiles import HERE, MAX_WORKERS, OUT, capture, quality_ok # noqa: E402
N_CITIES = 170
N_LANDMARKS = 90
# Natural Earth points that landed on a park/water/cemetery: moved to the dense centre.
CITY_OVERRIDES = {
"Lagos": (6.4541, 3.3947), # Lagos Island (was open lagoon)
"Cairns": (-16.9230, 145.7740), # CBD (was mangrove mudflat)
"Calgary": (51.0460, -114.0650), # downtown (was cemetery)
"Dubai": (25.0805, 55.1403), # Dubai Marina (was construction lot)
"Perth": (-31.9530, 115.8575), # CBD (was Kings Park bush)
"Dhaka": (23.7330, 90.4172), # Motijheel (was hazy open ground)
"Delhi": (28.6315, 77.2167), # Connaught Place
"Montréal": (45.5017, -73.5673), # downtown (was Mount Royal park)
"Busan": (35.1000, 129.0350), # Nampo / harbour (was forested hill)
"Jeddah": (21.4858, 39.1925), # Al-Balad old town (was empty lot)
"Tokyo": (35.6717, 139.7650), # Ginza (was Imperial Palace woods)
}
VISUAL_REJECT = {
"Venice": "blurry low-res imagery",
"Conakry": "heavy haze",
"Maracaibo": "farmland / scrub",
"Barquisimeto": "dry riverbed, no city",
"Natal": "solid forest canopy",
"João Pessoa": "forest canopy",
"Mbuji-Mayi": "forest/scrub",
"Huambo": "scrub, sparse",
"Port Harcourt": "haze",
"Qaraghandy": "dark featureless rail yard",
"Herat": "low-res blur",
"Chengdu": "smog haze",
"Xian": "washed-out industrial lot",
"Kaohsiung": "forested hill",
"Sapporo": "university farm fields",
"Isfahan": "low contrast blur",
"Stuttgart": "hazy woodland",
"Townsville": "airport apron, not city",
}
LANDMARK_BACKUPS = [
"Glen Canyon Dam", "Grand Coulee Dam", "Mount St. Helens", "Groom Lake", "Diavik Diamond Mine",
"Syncrude Mildred Lake oil sands", "Centre-pivot fields, Kansas", "Indianapolis Motor Speedway",
"Brooklyn Bridge", "Panama Canal, Miraflores Locks", "Escondida Mine", "ALMA Observatory",
"Meeting of the Waters, Manaus", "Brasilia, National Congress", "PS10 / PS20 Solar Towers", "Tower Bridge",
"Port of Tema", "Akosombo Dam", "Okavango Delta", "FNB Stadium, Soweto", "East Oweinat pivots",
"Kilimanjaro, Kibo crater", "Ras Laffan Industrial City", "Dubai International Airport", "Bagan temples",
"Jiuquan Launch Centre", "Mount Bromo", "Vostochny Cosmodrome", "Pine Gap", "Tanegashima Space Center",
"Teide", "Faa'a International Airport", "Hartsfield-Jackson Atlanta Airport", "Niagara Horseshoe Falls",
"Iguazu Falls, Devil's Throat", "Vandenberg SLC-4E", "Paranal Observatory", "Cerro Dominador Solar",
"Gibraltar Airport runway", "Erg Chebbi dunes", "Millau Viaduct", "Garzweiler Lignite Mine", "Inga Dams",
"Grand Ethiopian Renaissance Dam", "Ras Tanura refinery", "Udachnaya Pipe", "Busan New Port",
"Mount Taranaki", "Beijing National Stadium", "Port of Hamburg, Burchardkai", "Port of Santos",
"Jebel Ali Port", "Mont-Saint-Michel", "Angkor Wat", "Mount Vesuvius", "Horseshoe Bend",
"Benban Solar Park", "Borobudur", "Bhadla Solar Park", "Kariba Dam", "Wake Island Airfield",
"Diamond Head", "Port Hedland salt ponds",
]
def evaluate(cands):
def job(c):
try:
img, info = capture(c["lat"], c["lon"], c["zoom"])
ok, why = quality_ok(info)
except Exception as e: # network failure counts as a reject
img, info, ok, why = None, {}, False, f"fetch error: {e}"
if ok and c["name"] in VISUAL_REJECT and c.get("kind") == "city":
ok, why = False, "visual: " + VISUAL_REJECT[c["name"]]
return img, ok, why
with ThreadPoolExecutor(MAX_WORKERS) as ex:
return list(ex.map(job, cands))
def pick(cands, results, quota_by_group):
"""Primaries first in order; failures replaced by the group's backups in order."""
chosen, rejects = [], []
for g, quota in quota_by_group.items():
items = [(c, r) for c, r in zip(cands, results) if c["group"] == g]
prim = [x for x in items if x[0]["rank"] == "primary"]
back = [x for x in items if x[0]["rank"] == "backup"]
got = []
for c, r in prim + back:
if len(got) >= quota:
break
if r[1]:
got.append((c, r[0]))
elif c["rank"] == "primary":
rejects.append((c, r[2]))
if len(got) < quota:
print(f"!! group {g}: only {len(got)}/{quota}")
chosen += got
return chosen, rejects
def main():
cities = json.load(open(os.path.join(HERE, "city_candidates.json")))
for c in cities:
c["kind"], c["group"] = "city", "c:" + c["region"]
if c["name"] in CITY_OVERRIDES:
c["lat"], c["lon"] = CITY_OVERRIDES[c["name"]]
cquota = {}
for c in cities:
if c["rank"] == "primary":
cquota[c["group"]] = cquota.get(c["group"], 0) + 1
assert sum(cquota.values()) == N_CITIES, cquota
lms = [dict(name=n, country=co, lat=la, lon=lo, zoom=z, kind="landmark", group="landmark",
rank="backup" if n in LANDMARK_BACKUPS else "primary") for n, co, la, lo, z in LANDMARKS]
lms.sort(key=lambda c: c["rank"] != "primary") # stable: primaries first
nprim = sum(c["rank"] == "primary" for c in lms)
assert nprim == N_LANDMARKS, nprim
allc = cities + lms
print(f"evaluating {len(allc)} candidates ...", flush=True)
res = evaluate(allc)
quotas = dict(cquota)
quotas["landmark"] = N_LANDMARKS
chosen, rejects = pick(allc, res, quotas)
chosen.sort(key=lambda x: x[0]["lon"])
os.makedirs(OUT, exist_ok=True)
for f in glob.glob(os.path.join(OUT, "fx_*.jpg")):
os.remove(f)
index = []
for i, (c, img) in enumerate(chosen):
fn = f"fx_{i:03d}.jpg"
img.save(os.path.join(OUT, fn), quality=86, optimize=True)
index.append({"file": fn, "name": c["name"], "country": c["country"], "lat": round(c["lat"], 5),
"lon": round(c["lon"], 5), "zoom": c["zoom"], "kind": c["kind"]})
with open(os.path.join(OUT, "index.json"), "w") as f:
json.dump(index, f, indent=1, ensure_ascii=False)
# contact sheet: 16 x 17 grid of 96px thumbs, west->east reading order
cols, cell = 16, 96
rows = (len(chosen) + cols - 1) // cols
sheet = Image.new("RGB", (cols * cell, rows * cell), (10, 10, 10))
for i, (c, img) in enumerate(chosen):
sheet.paste(img.resize((cell, cell), Image.LANCZOS), ((i % cols) * cell, (i // cols) * cell))
sheet.save(os.path.join(HERE, "sheet.jpg"), quality=88)
from collections import Counter
print(f"wrote {len(index)} captures: {Counter(x['kind'] for x in index)}")
print("rejected primaries (replaced by backups):")
for c, why in rejects:
print(f" - {c['name']} ({c['country']}): {why}")
bands = Counter(int((x["lon"] + 180) // 30) for x in index)
print("30-degree longitude bands W->E:", [bands.get(i, 0) for i in range(12)])
json.dump([{"name": c["name"], "country": c["country"], "reason": why} for c, why in rejects],
open(os.path.join(HERE, "rejects.json"), "w"), indent=1, ensure_ascii=False)
if __name__ == "__main__":
main()
File diff suppressed because it is too large. Load diff
+205
View File
@@ -0,0 +1,205 @@
"""Hand-picked non-city landmarks for the flurry montage.
(name, country, lat, lon, zoom). zoom 16 for compact features, 15 for very large
ones (deltas, glaciers, big pits, pivot fields). Coordinates are verified
visually via tools/flurry/review.py sheets; entries that came back as featureless
terrain were corrected or dropped (see REJECTED at the bottom).
"""
LANDMARKS = [
# --- ports / container terminals ---
("Port of Rotterdam, Maasvlakte", "Netherlands", 51.9575, 4.0500, 16),
("Port of Singapore, Pasir Panjang", "Singapore", 1.2735, 103.7750, 16),
("Yangshan Deep-Water Port", "China", 30.6280, 122.0650, 16),
("Port of Los Angeles, Pier 400", "United States of America", 33.7350, -118.2550, 16),
("Jebel Ali Port", "United Arab Emirates", 25.0100, 55.0600, 16),
("Port of Santos", "Brazil", -23.9700, -46.3000, 16),
("Port of Hamburg, Burchardkai", "Germany", 53.5350, 9.9200, 16),
("Port Hedland salt ponds", "Australia", -20.3450, 118.6550, 15),
("Busan New Port", "South Korea", 35.0800, 128.8000, 16),
("Tanger Med Port", "Morocco", 35.8850, -5.5000, 16),
("Port of Tema", "Ghana", 5.6330, 0.0100, 16),
# --- airports ---
("Hartsfield-Jackson Atlanta Airport", "United States of America", 33.6407, -84.4277, 16),
("Dubai International Airport", "United Arab Emirates", 25.2528, 55.3644, 16),
("Hong Kong International Airport", "Hong Kong S.A.R.", 22.3080, 113.9185, 16),
("Denver International Airport", "United States of America", 39.8494, -104.6738, 16),
("Kansai International Airport", "Japan", 34.4320, 135.2304, 16),
("Beijing Daxing Airport", "China", 39.5098, 116.4105, 16),
("Davis-Monthan Aircraft Boneyard", "United States of America", 32.1500, -110.8300, 16),
("Princess Juliana Airport", "Sint Maarten", 18.0410, -63.1089, 16),
("Gibraltar Airport runway", "Gibraltar", 36.1512, -5.3497, 16),
("Faa'a International Airport", "French Polynesia", -17.5537, -149.6067, 16),
("Funafuti Airfield", "Tuvalu", -8.5250, 179.1960, 16),
("Midway Atoll, Sand Island", "United States of America", 28.2100, -177.3800, 16),
("Wake Island Airfield", "United States of America", 19.2820, 166.6470, 16),
# --- dams ---
("Hoover Dam", "United States of America", 36.0160, -114.7377, 16),
("Three Gorges Dam", "China", 30.8233, 111.0036, 16),
("Itaipu Dam", "Brazil", -25.4085, -54.5888, 16),
("Grand Coulee Dam", "United States of America", 47.9560, -118.9817, 16),
("Kariba Dam", "Zimbabwe", -16.5222, 28.7614, 16),
("Aswan High Dam", "Egypt", 23.9707, 32.8780, 16),
("Grand Ethiopian Renaissance Dam", "Ethiopia", 11.2150, 35.0900, 16),
("Glen Canyon Dam", "United States of America", 36.9375, -111.4844, 16),
("Akosombo Dam", "Ghana", 6.2999, 0.0592, 16),
("Inga Dams", "Congo (Kinshasa)", -5.5240, 13.6200, 16),
# --- open-pit mines ---
("Bingham Canyon Mine", "United States of America", 40.5230, -112.1510, 15),
("Chuquicamata Mine", "Chile", -22.2900, -68.9000, 15),
("Mir Diamond Mine", "Russia", 62.5275, 113.9936, 15),
("Escondida Mine", "Chile", -24.2700, -69.0700, 15),
("Kalgoorlie Super Pit", "Australia", -30.7775, 121.5040, 15),
("Udachnaya Pipe", "Russia", 66.4300, 112.3200, 15),
("Garzweiler Lignite Mine", "Germany", 51.0600, 6.4800, 15),
("Kimberley Big Hole", "South Africa", -28.7383, 24.7585, 16),
("Diavik Diamond Mine", "Canada", 64.4960, -110.2700, 15),
("Syncrude Mildred Lake oil sands", "Canada", 57.0400, -111.6000, 15),
# --- solar ---
("Noor Ouarzazate Solar", "Morocco", 31.0498, -6.8686, 16),
("Ivanpah Solar", "United States of America", 35.5525, -115.4680, 16),
("Crescent Dunes Solar", "United States of America", 38.2386, -117.3636, 15),
("Bhadla Solar Park", "India", 27.5400, 71.9100, 16),
("Gemasolar", "Spain", 37.5606, -5.3317, 16),
("PS10 / PS20 Solar Towers", "Spain", 37.4425, -6.2550, 16),
("Benban Solar Park", "Egypt", 24.4500, 32.7300, 16),
("Cerro Dominador Solar", "Chile", -22.7700, -69.4800, 16),
# --- centre-pivot irrigation ---
("Centre-pivot fields, Kansas", "United States of America", 37.9000, -100.9000, 15),
("Centre-pivot fields, Wadi As-Sirhan", "Saudi Arabia", 30.2000, 38.0800, 15),
("Kufra agricultural project", "Libya", 24.1400, 23.3500, 15),
("East Oweinat pivots", "Egypt", 22.6000, 28.6000, 15),
("Centre-pivot fields, Nebraska", "United States of America", 41.1000, -101.7000, 15),
# --- river deltas / water ---
("Lena Delta", "Russia", 72.8000, 126.5000, 15),
("Okavango Delta", "Botswana", -19.3000, 22.9000, 15),
("Betsiboka Estuary", "Madagascar", -15.7500, 46.3500, 15),
("Meeting of the Waters, Manaus", "Brazil", -3.1370, -59.9100, 15),
# --- salt / evaporation ---
("Salar de Atacama lithium ponds", "Chile", -23.5280, -68.3680, 16),
("Dead Sea evaporation ponds", "Israel", 31.1000, 35.4000, 15),
("San Francisco Bay salt ponds", "United States of America", 37.5000, -122.0500, 15),
("Lake Retba (Pink Lake)", "Senegal", 14.8400, -17.2300, 15),
("Hutt Lagoon", "Australia", -28.1700, 114.2500, 15),
# --- glaciers / ice ---
("Perito Moreno Glacier", "Argentina", -50.4800, -73.0500, 15),
("Aletsch Glacier, Konkordiaplatz", "Switzerland", 46.5000, 8.0300, 15),
("Hubbard Glacier", "United States of America", 60.0200, -139.5000, 15),
# Antarctica: Esri has only Landsat-class imagery here (z14+ is placeholder),
# so these use z11-12 where the source pixels are native rather than upsampled.
("Taylor Valley, Canada Glacier", "Antarctica", -77.6300, 162.9500, 12),
("McMurdo Dry Valleys, Wright Valley", "Antarctica", -77.5700, 161.8500, 11),
("Deception Island caldera", "Antarctica", -62.9600, -60.6300, 12),
("Mount Everest", "Nepal", 27.9881, 86.9250, 15),
# --- volcanoes / craters ---
("Mount Bromo", "Indonesia", -7.9420, 112.9530, 15),
("Erta Ale", "Ethiopia", 13.6065, 40.6655, 16),
("Kilauea, Halemaumau", "United States of America", 19.4060, -155.2830, 15),
("Mount Fuji summit crater", "Japan", 35.3628, 138.7307, 16),
("Nyiragongo", "Congo (Kinshasa)", -1.5200, 29.2500, 16),
("Meteor Crater", "United States of America", 35.0275, -111.0225, 15),
("Mount St. Helens", "United States of America", 46.2000, -122.1900, 15),
("Kawah Ijen", "Indonesia", -8.0580, 114.2420, 16),
("Teide", "Spain", 28.2725, -16.6425, 16),
("Mount Vesuvius", "Italy", 40.8216, 14.4263, 16),
("Rano Kau, Easter Island", "Chile", -27.1860, -109.4350, 15),
("Diamond Head", "United States of America", 21.2620, -157.8050, 15),
("Darvaza Gas Crater", "Turkmenistan", 40.2525, 58.4394, 16),
("Kilimanjaro, Kibo crater", "Tanzania", -3.0758, 37.3533, 15),
("Mount Taranaki", "New Zealand", -39.2968, 174.0634, 15),
("Whakaari / White Island", "New Zealand", -37.5200, 177.1800, 15),
("Dallol", "Ethiopia", 14.2400, 40.3000, 16),
# --- stadiums / venues ---
("Maracana Stadium", "Brazil", -22.9122, -43.2302, 16),
("Rungrado May Day Stadium", "North Korea", 39.0497, 125.7756, 16),
("Camp Nou", "Spain", 41.3809, 2.1228, 16),
("Beijing National Stadium", "China", 39.9925, 116.3910, 16),
("Melbourne Cricket Ground", "Australia", -37.8200, 144.9834, 16),
("Indianapolis Motor Speedway", "United States of America", 39.7950, -86.2350, 15),
("Estadio Azteca", "Mexico", 19.3029, -99.1505, 16),
("FNB Stadium, Soweto", "South Africa", -26.2348, 27.9825, 16),
# --- bridges / canals ---
("Golden Gate Bridge", "United States of America", 37.8100, -122.4770, 16),
("Sydney Harbour Bridge & Opera House", "Australia", -33.8550, 151.2120, 16),
("Tower Bridge", "United Kingdom", 51.5055, -0.0754, 16),
("Oresund Bridge, Peberholm", "Denmark", 55.5900, 12.7740, 16),
("Millau Viaduct", "France", 44.0794, 3.0225, 16),
("Brooklyn Bridge", "United States of America", 40.7061, -73.9969, 16),
("Panama Canal, Miraflores Locks", "Panama", 8.9970, -79.5915, 16),
("Panama Canal, Agua Clara Locks", "Panama", 9.2720, -79.9180, 16),
# --- launch sites ---
("Kennedy Space Center LC-39A", "United States of America", 28.6083, -80.6041, 16),
("Baikonur, Gagarin's Start", "Kazakhstan", 45.9203, 63.3422, 16),
("Vandenberg SLC-4E", "United States of America", 34.6320, -120.6110, 16),
("Starbase, Boca Chica", "United States of America", 25.9972, -97.1566, 16),
("Wenchang Launch Site", "China", 19.6145, 110.9510, 16),
("Jiuquan Launch Centre", "China", 40.9580, 100.2910, 16),
("Tanegashima Space Center", "Japan", 30.4009, 130.9749, 16),
("Vostochny Cosmodrome", "Russia", 51.8840, 128.3330, 16),
("Rocket Lab LC-1, Mahia", "New Zealand", -39.2615, 177.8649, 16),
# --- energy / industry ---
("Permian Basin well pads", "United States of America", 32.2100, -103.8300, 15),
("Ras Tanura refinery", "Saudi Arabia", 26.6400, 50.1600, 16),
("Jamnagar Refinery", "India", 22.3500, 69.8500, 16),
("Ras Laffan Industrial City", "Qatar", 25.9100, 51.5500, 16),
("Yamal LNG, Sabetta", "Russia", 71.2700, 72.0700, 16),
("Chernobyl Nuclear Power Plant", "Ukraine", 51.3890, 30.0990, 16),
# --- monuments / sites ---
("Jiayuguan Pass, Great Wall", "China", 39.8016, 98.2133, 16),
("Pyramids of Giza", "Egypt", 29.9792, 31.1342, 16),
("Palm Jumeirah", "United Arab Emirates", 25.1124, 55.1390, 15),
("Angkor Wat", "Cambodia", 13.4125, 103.8670, 16),
("The Pentagon", "United States of America", 38.8719, -77.0563, 16),
("Groom Lake", "United States of America", 37.2350, -115.8111, 15),
("FAST Radio Telescope", "China", 25.6529, 106.8566, 16),
("Very Large Array", "United States of America", 34.0784, -107.6184, 16),
("Uluru", "Australia", -25.3444, 131.0369, 15),
("Mont-Saint-Michel", "France", 48.6361, -1.5115, 16),
("Grand Prismatic Spring", "United States of America", 44.5251, -110.8382, 16),
("Masjid al-Haram, Mecca", "Saudi Arabia", 21.4225, 39.8262, 16),
("Forbidden City", "China", 39.9155, 116.3910, 16),
("Taj Mahal", "India", 27.1751, 78.0421, 16),
("Greenhouses of Almeria", "Spain", 36.7500, -2.7200, 16),
("Pine Gap", "Australia", -23.7990, 133.7370, 16),
("ALMA Observatory", "Chile", -23.0229, -67.7552, 16),
("Paranal Observatory", "Chile", -24.6275, -70.4044, 16),
("Brasilia, National Congress", "Brazil", -15.7998, -47.8645, 16),
("Iguazu Falls, Devil's Throat", "Argentina", -25.6950, -54.4367, 16),
("Niagara Horseshoe Falls", "Canada", 43.0773, -79.0757, 16),
("Victoria Falls", "Zambia", -17.9243, 25.8572, 16),
("Horseshoe Bend", "United States of America", 36.8791, -111.5103, 16),
("Sossusvlei dunes", "Namibia", -24.7300, 15.2900, 15),
("Erg Chebbi dunes", "Morocco", 31.1500, -3.9800, 15),
("Bora Bora", "French Polynesia", -16.4950, -151.7050, 15),
("Borobudur", "Indonesia", -7.6079, 110.2038, 16),
("Bagan temples", "Myanmar", 21.1700, 94.8600, 16),
("Svalbard, Longyearbyen", "Norway", 78.2200, 15.6500, 16),
("Edinburgh of the Seven Seas, Tristan da Cunha", "United Kingdom", -37.0670, -12.3100, 16),
("Diego Garcia", "United Kingdom", -7.3100, 72.4100, 15),
("Kwajalein Atoll", "Marshall Islands", 8.7200, 167.7300, 16),
]
REJECTED = {
'Pituffik Space Base': 'placeholder above z13; z13 too blurry',
'Ilulissat Icefjord': 'placeholder above z13; open water at z13',
'McMurdo Station': 'placeholder above z13; blurry ice at z13',
'Rothera Research Station': 'placeholder above z13; mostly sea',
'Mount Erebus crater': 'placeholder above z13; cloud-like blur',
'Port of Durban': "featureless/cloud/low-res/off-target at tested coords",
'Port of Mombasa': "featureless/cloud/low-res/off-target at tested coords",
'Sundarbans, Ganges Delta': "featureless/cloud/low-res/off-target at tested coords",
"Mississippi Bird's-Foot Delta": "featureless/cloud/low-res/off-target at tested coords",
'Volga Delta': "featureless/cloud/low-res/off-target at tested coords",
'Great Salt Lake causeway': "featureless/cloud/low-res/off-target at tested coords",
'Jokulsarlon Glacier Lagoon': "featureless/cloud/low-res/off-target at tested coords",
'Baltoro Glacier, Concordia': "featureless/cloud/low-res/off-target at tested coords",
'Cotopaxi': "featureless/cloud/low-res/off-target at tested coords",
'Guiana Space Centre ELA-3': "featureless/cloud/low-res/off-target at tested coords",
'Satish Dhawan Space Centre': "featureless/cloud/low-res/off-target at tested coords",
'Abqaiq processing plant': "featureless/cloud/low-res/off-target at tested coords",
'Prudhoe Bay oil field': "featureless/cloud/low-res/off-target at tested coords",
'Great Wall at Mutianyu': "featureless/cloud/low-res/off-target at tested coords",
'Machu Picchu': "featureless/cloud/low-res/off-target at tested coords",
'Baltra Island airport, Galapagos': "featureless/cloud/low-res/off-target at tested coords",
'Moynaq ship graveyard, Aral Sea': "featureless/cloud/low-res/off-target at tested coords",
}
+78
View File
@@ -0,0 +1,78 @@
"""Locator sheets: a wider mosaic around a guess with a lat/lon grid, to find exact coords.
usage: python3 locate.py out.jpg "name|lat|lon|z|ntiles" ...
Each panel is ntiles x ntiles tiles at zoom z (resized to 640px) with grid
lines every `step` degrees, labelled. Panels are stacked in a 2-column grid.
"""
import io
import math
import os
import sys
from concurrent.futures import ThreadPoolExecutor
from PIL import Image, ImageDraw
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from tiles import fetch_tile, pixel_xy # noqa: E402
P = 640
def panel(name, lat, lon, z, nt):
px, py = pixel_xy(lat, lon, z)
tx, ty = int(px // 256), int(py // 256)
h = nt // 2
coords = [(tx - h + i, ty - h + j) for j in range(nt) for i in range(nt)]
with ThreadPoolExecutor(8) as ex:
datas = list(ex.map(lambda c: fetch_tile(z, c[0], c[1]), coords))
m = Image.new("RGB", (nt * 256, nt * 256))
for (x, y), d in zip(coords, datas):
m.paste(Image.open(io.BytesIO(d)).convert("RGB"), ((x - tx + h) * 256, (y - ty + h) * 256))
ox, oy = (tx - h) * 256, (ty - h) * 256
s = P / (nt * 256)
m = m.resize((P, P), Image.LANCZOS)
d = ImageDraw.Draw(m)
n = 2 ** z * 256
def lon2x(lo):
return ((lo + 180) / 360 * n - ox) * s
def lat2y(la):
si = math.sin(math.radians(la))
return ((0.5 - math.log((1 + si) / (1 - si)) / (4 * math.pi)) * n - oy) * s
span = nt * 256 / n * 360
step = [0.001, 0.002, 0.005, 0.01, 0.02, 0.05, 0.1, 0.2, 0.5][
min(range(9), key=lambda k: abs(span / [0.001, 0.002, 0.005, 0.01, 0.02, 0.05, 0.1, 0.2, 0.5][k] - 6))]
lo0 = math.floor((lon - span) / step) * step
for k in range(40):
lo = lo0 + k * step
x = lon2x(lo)
if 0 <= x <= P:
d.line((x, 0, x, P), fill=(255, 255, 0), width=1)
d.text((x + 2, P - 12), f"{lo:.3f}", fill=(255, 255, 0))
la0 = math.floor((lat - span) / step) * step
for k in range(40):
la = la0 + k * step
y = lat2y(la)
if 0 <= y <= P:
d.line((0, y, P, y), fill=(0, 255, 255), width=1)
d.text((2, y + 1), f"{la:.3f}", fill=(0, 255, 255))
cx, cy = lon2x(lon), lat2y(lat)
d.ellipse((cx - 5, cy - 5, cx + 5, cy + 5), outline=(255, 0, 0), width=2)
d.rectangle((0, 0, P, 14), fill=(0, 0, 0))
d.text((3, 1), f"{name} z{z}", fill=(255, 255, 255))
return m
if __name__ == "__main__":
out = sys.argv[1]
specs = [a.split("|") for a in sys.argv[2:]]
ps = [panel(s[0], float(s[1]), float(s[2]), int(s[3]), int(s[4])) for s in specs]
cols = 2 if len(ps) > 1 else 1
rows = (len(ps) + cols - 1) // cols
sheet = Image.new("RGB", (cols * P, rows * P))
for i, p in enumerate(ps):
sheet.paste(p, ((i % cols) * P, (i // cols) * P))
sheet.save(out, quality=85)
print(out)
+32
View File
@@ -0,0 +1,32 @@
[
{
"name": "Venice",
"country": "Italy",
"reason": "visual: blurry low-res imagery"
},
{
"name": "Chengdu",
"country": "China",
"reason": "visual: smog haze"
},
{
"name": "Xian",
"country": "China",
"reason": "visual: washed-out industrial lot"
},
{
"name": "Maracaibo",
"country": "Venezuela",
"reason": "visual: farmland / scrub"
},
{
"name": "Barquisimeto",
"country": "Venezuela",
"reason": "visual: dry riverbed, no city"
},
{
"name": "Conakry",
"country": "Guinea",
"reason": "visual: heavy haze"
}
]
+69
View File
@@ -0,0 +1,69 @@
"""Fetch candidates and render labelled review sheets for visual verification.
usage: python3 review.py landmarks|cities [start] [count]
Writes tools/flurry/review/<kind>_NN.jpg (5x4 grid, 256px cells, labelled).
"""
import json
import os
import sys
from concurrent.futures import ThreadPoolExecutor
from PIL import Image, ImageDraw
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from tiles import HERE, MAX_WORKERS, capture, quality_ok # noqa: E402
REV = os.path.join(HERE, "review")
def load(kind):
if kind == "landmarks":
from landmarks import LANDMARKS
return [dict(name=n, country=c, lat=la, lon=lo, zoom=z) for n, c, la, lo, z in LANDMARKS]
return json.load(open(os.path.join(HERE, "city_candidates.json")))
def run(cands, prefix, cell=256, cols=5, rows=4):
os.makedirs(REV, exist_ok=True)
def job(c):
try:
img, info = capture(c["lat"], c["lon"], c["zoom"])
return img, info
except Exception as e:
return None, {"error": str(e)}
with ThreadPoolExecutor(MAX_WORKERS) as ex:
res = list(ex.map(job, cands))
per = cols * rows
report = []
for s in range(0, len(cands), per):
sheet = Image.new("RGB", (cols * cell, rows * cell), (20, 20, 20))
d = ImageDraw.Draw(sheet)
for k, (c, (img, info)) in enumerate(zip(cands[s:s + per], res[s:s + per])):
x, y = (k % cols) * cell, (k // cols) * cell
idx = s + k
if img is not None:
sheet.paste(img.resize((cell, cell), Image.LANCZOS), (x, y))
ok, why = quality_ok(info)
lab2 = f"s{info['std']:.0f} g{info['grad']:.1f}" + ("" if ok else " X " + why[:18])
else:
ok, lab2 = False, "ERR"
d.rectangle((x, y, x + cell, y + 24), fill=(0, 0, 0))
d.text((x + 3, y + 1), f"{idx} {c['name'][:36]}", fill=(255, 255, 0))
d.text((x + 3, y + 12), lab2, fill=(255, 120, 120) if not ok else (160, 255, 160))
report.append((idx, c["name"], ok, lab2))
out = os.path.join(REV, f"{prefix}_{s // per:02d}.jpg")
sheet.save(out, quality=85)
print(out)
for r in report:
if not r[2]:
print("REJECT", r)
if __name__ == "__main__":
kind = sys.argv[1]
cands = load(kind)
st = int(sys.argv[2]) if len(sys.argv) > 2 else 0
n = int(sys.argv[3]) if len(sys.argv) > 3 else len(cands)
run(cands[st:st + n], kind[:2] + (f"_{st}" if st else ""))
@@ -0,0 +1,109 @@
"""Pick ~170 city centres from data/cities.json with geographic spread.
Writes tools/flurry/city_candidates.json: ordered list of dicts with
rank ("primary"/"backup") and region. Backups replace rejected primaries of the
same region in build.py.
"""
import json
import math
import os
HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.abspath(os.path.join(HERE, "..", ".."))
AFRICA = {
"Nigeria", "Egypt", "Congo (Kinshasa)", "South Africa", "Algeria", "Sudan", "Morocco", "Tanzania",
"Cameroon", "Mozambique", "Libya", "Ethiopia", "Ivory Coast", "Tunisia", "Zambia", "Angola", "Ghana",
"Uganda", "Kenya", "Guinea", "Madagascar", "Senegal", "Zimbabwe", "Mali", "Somalia", "Chad", "Niger",
"Benin", "Eritrea", "Somaliland", "South Sudan", "Sierra Leone", "Mauritania", "Malawi", "Togo",
"Congo (Brazzaville)", "Burkina Faso", "Rwanda", "Mauritius", "Gabon", "Equatorial Guinea", "Liberia",
"Djibouti", "Central African Republic", "Guinea Bissau", "Lesotho", "Burundi", "Namibia", "Botswana",
"The Gambia", "Comoros", "Cape Verde", "eSwatini",
}
SOUTH_AMERICA = {"Brazil", "Argentina", "Colombia", "Venezuela", "Peru", "Chile", "Ecuador", "Bolivia",
"Paraguay", "Uruguay", "Guyana", "Suriname"}
OCEANIA = {"Australia", "New Zealand", "Papua New Guinea", "Fiji", "French Polynesia", "Guam"}
CENTRAL_ASIA = {"Kazakhstan", "Uzbekistan", "Turkmenistan", "Kyrgyzstan", "Tajikistan", "Afghanistan", "Mongolia"}
# (region, primary quota, backups, per-country cap, min spacing km)
PASSES = [
("africa", AFRICA, 31, 16, 3, 300),
("south_america", SOUTH_AMERICA, 22, 10, 5, 250),
("oceania", OCEANIA, 12, 4, 5, 120),
("central_asia", CENTRAL_ASIA, 9, 5, 3, 150),
("rest", None, 96, 40, 11, 200),
]
# Picked first (in "rest" unless their country is in a quota region): fill the
# Pacific / high-latitude longitude bands and add visually distinctive cities.
FORCED = [
"Honolulu", "Anchorage", "Papeete", "Agana", "Petropavlovsk-Kamchatsky", "Vancouver", "San Francisco",
"Las Vegas", "Seattle", "Phoenix", "Denver", "Calgary", "Guatemala City", "Havana", "Reykjavík",
"Dubai", "Doha", "Abu Dhabi", "Hong Kong", "Macau", "Venice", "Amsterdam", "København", "Oslo",
"Stockholm", "Helsinki", "Prague", "Kathmandu", "Colombo", "Vladivostok", "Novosibirsk", "Irkutsk",
"Yakutsk", "Norilsk", "Murmansk", "Manaus", "Mombasa", "Windhoek", "Nouakchott", "Marrakesh",
"Samarkand", "Cairns", "Muscat", "Québec",
]
# Natural Earth rows whose point lands in farmland/hills rather than a dense centre.
EXCLUDE = {"Amaravati", "Hechi", "Saidu", "Cranbourne", "Manukau", "Waitakere", "North Shore", "Wahiawa"}
COUNTRY_CAP_TOTAL = 12
def km(a, b):
la1, lo1, la2, lo2 = map(math.radians, (a[1], a[0], b[1], b[0]))
h = math.sin((la2 - la1) / 2) ** 2 + math.cos(la1) * math.cos(la2) * math.sin((lo2 - lo1) / 2) ** 2
return 6371 * 2 * math.asin(math.sqrt(h))
def main():
rows = json.load(open(os.path.join(ROOT, "data", "cities.json")))["rows"]
special = AFRICA | SOUTH_AMERICA | OCEANIA | CENTRAL_ASIA
chosen, out = [], []
byname = {}
for r in rows:
byname.setdefault(r[3], r) # first = most populous
per = {}
def region_of(country):
for reg, mem, *_ in PASSES:
if mem and country in mem:
return reg
return "rest"
for nm in FORCED:
lon, lat, pop, name, country = byname[nm]
chosen.append((lon, lat))
per[country] = per.get(country, 0) + 1
out.append(dict(name=name, country=country, lat=lat, lon=lon, zoom=16, pop=pop,
region=region_of(country), rank="primary", forced=True))
for region, members, quota, nback, cap, spacing in PASSES:
got = sum(1 for c in out if c["region"] == region)
for lon, lat, pop, name, country in rows:
if name in EXCLUDE or name in byname and any(c["name"] == name and c["country"] == country for c in out):
continue
inreg = (country in members) if members else (country not in special)
if not inreg:
continue
if per.get(country, 0) >= cap and got < quota or per.get(country, 0) >= COUNTRY_CAP_TOTAL:
continue
if any(km((lon, lat), (c[0], c[1])) < spacing for c in chosen):
continue
rank = "primary" if got < quota else "backup"
chosen.append((lon, lat))
per[country] = per.get(country, 0) + 1
out.append(dict(name=name, country=country, lat=lat, lon=lon, zoom=16, pop=pop,
region=region, rank=rank))
got += 1
if got >= quota + nback:
break
json.dump(out, open(os.path.join(HERE, "city_candidates.json"), "w"), indent=0, ensure_ascii=False)
prim = [c for c in out if c["rank"] == "primary"]
print(len(prim), "primary,", len(out) - len(prim), "backup")
from collections import Counter
print(Counter(c["region"] for c in prim))
print(Counter(c["country"] for c in prim).most_common(12))
bands = Counter(int((c["lon"] + 180) // 30) for c in prim)
print("30deg bands:", [bands.get(i, 0) for i in range(12)])
if __name__ == "__main__":
main()
+132
View File
@@ -0,0 +1,132 @@
"""Polite cached Esri World Imagery fetch + 512px centred capture + quality metrics."""
import hashlib
import io
import math
import os
import threading
import time
import numpy as np
import requests
from PIL import Image
HERE = os.path.dirname(os.path.abspath(__file__))
CACHE = os.path.join(HERE, "cache")
ROOT = os.path.abspath(os.path.join(HERE, "..", ".."))
OUT = os.path.join(ROOT, "imagery", "flurry")
ESRI = "https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}"
UA = "gods-eye-view-intro/1.0 (flurry montage prep)"
MAX_WORKERS = 8
PLACEHOLDER_MD5 = {"f27d9de7f80c13501f470595e327aa6d"} # "Map data not yet available"
_local = threading.local()
def _session():
s = getattr(_local, "s", None)
if s is None:
s = requests.Session()
s.headers["User-Agent"] = UA
_local.s = s
return s
def fetch_tile(z, x, y, retries=6):
n = 2 ** z
x %= n
path = os.path.join(CACHE, str(z), str(x), f"{y}.jpg")
if os.path.exists(path) and os.path.getsize(path) > 0:
with open(path, "rb") as f:
return f.read()
os.makedirs(os.path.dirname(path), exist_ok=True)
url = ESRI.format(z=z, x=x, y=y)
last = None
for attempt in range(retries):
try:
r = _session().get(url, timeout=45)
if r.status_code == 200 and r.content:
tmp = path + f".{threading.get_ident()}.part"
with open(tmp, "wb") as f:
f.write(r.content)
os.replace(tmp, path)
return r.content
last = f"HTTP {r.status_code}"
if r.status_code in (400, 404):
break
except Exception as e:
last = repr(e)
time.sleep(min(20, 1.0 * 2 ** attempt))
raise RuntimeError(f"failed {url}: {last}")
def is_placeholder(data):
if hashlib.md5(data).hexdigest() in PLACEHOLDER_MD5:
return True
a = np.asarray(Image.open(io.BytesIO(data)).convert("RGB")).astype(np.float32)
m = a.mean(axis=(0, 1))
return bool(abs(m - 204.7).max() < 4 and a.std() < 9)
def pixel_xy(lat, lon, z):
n = 2 ** z * 256
px = (lon + 180.0) / 360.0 * n
s = math.sin(math.radians(lat))
py = (0.5 - math.log((1 + s) / (1 - s)) / (4 * math.pi)) * n
return px, py
def capture(lat, lon, z, size=512):
"""Return (image, info). info has placeholder flag and quality metrics."""
px, py = pixel_xy(lat, lon, z)
tx, ty = int(px // 256), int(py // 256)
mosaic = Image.new("RGB", (768, 768))
ox, oy = (tx - 1) * 256, (ty - 1) * 256
left, top = int(round(px - ox - size / 2)), int(round(py - oy - size / 2))
ph = False
for j in range(3):
for i in range(3):
data = fetch_tile(z, tx - 1 + i, ty - 1 + j)
# does this tile overlap the crop window?
tl, tt = i * 256, j * 256
overlaps = tl < left + size and tl + 256 > left and tt < top + size and tt + 256 > top
if overlaps and is_placeholder(data):
ph = True
mosaic.paste(Image.open(io.BytesIO(data)).convert("RGB"), (tl, tt))
img = mosaic.crop((left, top, left + size, top + size))
info = metrics(img)
info["placeholder"] = ph
return img, info
def metrics(img):
a = np.asarray(img).astype(np.float32)
lum = a @ np.array([0.299, 0.587, 0.114], np.float32)
small = np.asarray(img.convert("L").resize((128, 128), Image.BILINEAR)).astype(np.float32)
gx = np.abs(np.diff(lum, axis=1)).mean()
gy = np.abs(np.diff(lum, axis=0)).mean()
# coarse-scale structure: std of 32px block means (catches flat ocean/cloud/ice)
blocks = lum.reshape(16, 32, 16, 32).mean(axis=(1, 3))
sat = (a.max(axis=2) - a.min(axis=2)).mean()
return {
"std": float(lum.std()),
"grad": float((gx + gy) / 2),
"block_std": float(blocks.std()),
"small_std": float(small.std()),
"sat": float(sat),
"mean": float(lum.mean()),
}
def quality_ok(info):
if info["placeholder"]:
return False, "placeholder"
if info["std"] < 12:
return False, f"low variance (std {info['std']:.1f})"
if info["grad"] < 1.3:
return False, f"featureless (grad {info['grad']:.1f})"
if info["mean"] > 225 and info["std"] < 25:
return False, "cloud/snow white-out"
if info["mean"] < 22:
return False, "too dark"
return True, ""
+1
View File
@@ -0,0 +1 @@
cache/
@@ -0,0 +1,76 @@
"""Deliverable 3: clouds.jpg (4096x2048) grayscale cloud opacity map for one recent day.
Source: NASA GIBS daily VIIRS Corrected Reflectance True Colour for the same UTC
date from three satellites (NOAA-20, NOAA-21, Suomi NPP), EPSG:4326 level 3
(10x5 tiles of 512px = 5120x2560), downsampled to 4096x2048.
Cloud metric per satellite:
whiteness = min(R,G,B) (clouds are bright in every channel)
greyness = 1 - saturation (clouds are colourless)
c = ramp(whiteness) * smoothstep(greyness)
The same metric computed on the Esri base map (earth_day_4k.jpg) is subtracted
to suppress bright deserts, salt flats, snow and ice that would otherwise read
as cloud. The three per-satellite results are combined with a per-pixel median
(nan-aware), which removes swath-edge haze / glint streaks that only one
satellite shows, and fills swath gaps. Remaining no-data (polar night) -> 0.
Usage: python3 build_clouds.py [YYYY-MM-DD]
"""
import os
import sys
import numpy as np
from PIL import Image
from common import OUT, gibs_mosaic
Image.MAX_IMAGE_PIXELS = None
DATE = sys.argv[1] if len(sys.argv) > 1 else "2026-10-15"
LAYERS = [
"VIIRS_NOAA20_CorrectedReflectance_TrueColor",
"VIIRS_NOAA21_CorrectedReflectance_TrueColor",
"VIIRS_SNPP_CorrectedReflectance_TrueColor",
]
W, H = 4096, 2048
def smooth(x, a, b):
t = np.clip((x - a) / (b - a), 0, 1)
return t * t * (3 - 2 * t)
def metric(rgb):
rgb = rgb.astype(np.float32) / 255.0
mn = rgb.min(2)
mx = rgb.max(2)
sat = (mx - mn) / (mx + 1e-4)
white = np.clip((mn - 0.14) / 0.86, 0, 1) ** 1.15 # linear-ish: keeps texture inside thick cloud
return white * smooth(1.0 - sat, 0.65, 0.95), mx
def main():
base = np.asarray(Image.open(os.path.join(OUT, "earth_day_4k.jpg")).convert("RGB"))
base_c, _ = metric(base)
# dilate the base metric a few px so small Esri/VIIRS coastline misregistration
# does not leave bright outlines along desert and ice coasts
from PIL import ImageFilter
base_c = np.asarray(Image.fromarray((base_c * 255).astype(np.uint8)).filter(ImageFilter.MaxFilter(3)).filter(ImageFilter.GaussianBlur(2))).astype(np.float32) / 255
stack = []
for L in LAYERS:
m = gibs_mosaic(L, DATE, "250m", 3, 5, 10, "jpg").resize((W, H), Image.LANCZOS)
c, mx = metric(np.asarray(m))
c = np.clip(c - base_c * 1.1, 0, 1)
c[mx < 0.03] = np.nan # no-data (black)
stack.append(c)
stack = np.stack(stack)
with np.errstate(all="ignore"):
import warnings
warnings.simplefilter("ignore")
cloud = np.nanmedian(stack, axis=0)
cloud = np.nan_to_num(cloud, nan=0.0)
cloud = np.clip((cloud - 0.06) / 0.94, 0, 1) # black point: kill faint surface/haze residue
img = Image.fromarray(np.clip(cloud * 255 + 0.5, 0, 255).astype(np.uint8), "L")
img.save(os.path.join(OUT, "clouds.jpg"), quality=92, optimize=True, progressive=True)
print("done", DATE, "mean opacity %.3f" % cloud.mean())
if __name__ == "__main__":
main()
+91
View File
@@ -0,0 +1,91 @@
"""Deliverable 1: earth_day.jpg (8192x4096) + earth_day_4k.jpg (4096x2048).
Esri World Imagery z5 (32x32 tiles, 8192x8192 Web Mercator) reprojected to
equirectangular with bilinear sampling. Because both grids are 8192 px wide and
linear in longitude, the horizontal axis maps 1:1 and only latitude needs
resampling (linear interpolation between Mercator rows = bilinear).
Polar caps beyond the Mercator limit (+-85.0511 deg) come from NASA GIBS
BlueMarble_ShadedRelief_Bathymetry (EPSG:4326, level 4 = 10240x5120; its shaded
bathymetry matches Esri's small-scale ocean rendering), colour-matched to Esri
with a least-squares affine colour transform fitted on co-registered overlap pixels and cross-faded between 80 and 84.5 deg.
"""
import os
import numpy as np
from PIL import Image
from common import OUT, esri_jobs, fetch_many, decode, gibs_mosaic
Image.MAX_IMAGE_PIXELS = None
W, H = 8192, 4096
Z = 5
N = 2 ** Z
TS = 256
MERC = N * TS # 8192
MERC_LIMIT = np.degrees(np.arctan(np.sinh(np.pi))) # 85.0511
FADE_A, FADE_B = 80.0, 84.5 # pure Esri below A, pure Blue Marble above B
CAP_LAYER = os.environ.get("CAP_LAYER", "BlueMarble_ShadedRelief_Bathymetry")
def esri_mercator():
coords = [(x, y) for y in range(N) for x in range(N)]
res = fetch_many(esri_jobs(Z, coords), desc="esri z5")
merc = np.zeros((MERC, MERC, 3), np.uint8)
for (x, y), b in res.items():
if b is None:
raise SystemExit(f"missing esri tile {x},{y}")
merc[y * TS:(y + 1) * TS, x * TS:(x + 1) * TS] = np.asarray(decode(b))
return merc
def main():
merc = esri_mercator()
print("reprojecting")
lat = 90.0 - (np.arange(H) + 0.5) * 180.0 / H
latc = np.clip(lat, -MERC_LIMIT + 1e-6, MERC_LIMIT - 1e-6)
yn = (1.0 - np.log(np.tan(np.pi / 4 + np.radians(latc) / 2)) / np.pi) / 2.0
v = yn * MERC - 0.5
v0 = np.clip(np.floor(v).astype(int), 0, MERC - 1)
v1 = np.clip(v0 + 1, 0, MERC - 1)
f = np.clip(v - np.floor(v), 0, 1).astype(np.float32)[:, None, None]
eq = np.empty((H, W, 3), np.float32)
for s in range(0, H, 256):
e = s + 256
eq[s:e] = merc[v0[s:e]].astype(np.float32) * (1 - f[s:e]) + merc[v1[s:e]].astype(np.float32) * f[s:e]
del merc
print("fetching polar caps:", CAP_LAYER)
bm = gibs_mosaic(CAP_LAYER, None, "500m", 4, 10, 20, "jpg")
bm = np.asarray(bm.resize((W, H), Image.LANCZOS)).astype(np.float32)
# colour-match Blue Marble to Esri per hemisphere: fit an affine 3x3+offset colour
# transform (least squares) on co-registered pixels in the 76-84.5 deg overlap band
alat = np.abs(lat)
band = (alat > 76) & (alat < 84.5)
t = np.clip((alat - FADE_A) / (FADE_B - FADE_A), 0, 1)
t = (t * t * (3 - 2 * t)).astype(np.float32)[:, None, None] # smoothstep
out = eq.copy()
for hemi in (lat > 0, lat < 0):
rows = band & hemi
a = eq[rows][:, ::4].reshape(-1, 3)
b = bm[rows][:, ::4].reshape(-1, 3)
X = np.hstack([b, np.ones((len(b), 1), np.float32)])
# ridge-regularised toward identity: near-grey ice makes the plain fit ill-conditioned
lam = len(b) * 50.0
D = np.diag([1.0, 1.0, 1.0, 0.0])
I0 = np.vstack([np.eye(3), np.zeros((1, 3))])
X64, a64 = X.astype(np.float64), a.astype(np.float64)
M = np.linalg.solve(X64.T @ X64 + lam * D, X64.T @ a64 + lam * D @ I0)
print(" colour transform\n", M.round(3))
h = bm[hemi]
bm[hemi] = (h.reshape(-1, 3) @ M[:3] + M[3]).reshape(h.shape)
out[hemi] = eq[hemi] * (1 - t[hemi]) + bm[hemi] * t[hemi]
img = Image.fromarray(np.clip(out + 0.5, 0, 255).astype(np.uint8))
os.makedirs(OUT, exist_ok=True)
img.save(os.path.join(OUT, "earth_day.jpg"), quality=92, optimize=True, progressive=True)
img.resize((4096, 2048), Image.LANCZOS).save(os.path.join(OUT, "earth_day_4k.jpg"), quality=92, optimize=True, progressive=True)
print("done")
if __name__ == "__main__":
main()
+133
View File
@@ -0,0 +1,133 @@
"""Deliverable 4: Esri World Imagery 'synthetic screenshots' of iconic places.
For each location: <slug>_wide.jpg (zoom 13) and <slug>_close.jpg (zoom 18,
falling back to 17/16 when Esri serves "Map data not yet available"
placeholder tiles), both 1536x1024 centred on the point. Writes index.json.
Usage: python3 build_hero.py [--contact DIR] (contact sheets for visual QA)
Per-location overrides: CLOSE_ZOOM_OVERRIDES below.
"""
import io
import json
import math
import os
import sys
import numpy as np
from PIL import Image
from common import OUT, esri_jobs, fetch_many
HERO = os.path.join(OUT, "hero")
CW, CH = 1536, 1024
TS = 256
LOCATIONS = [
("manhattan", "Midtown Manhattan", 40.7580, -73.9855),
("palm_jumeirah", "Palm Jumeirah", 25.1124, 55.1390),
("singapore_port", "Port of Singapore (Pasir Panjang Terminal)", 1.2730, 103.7690), # was 1.2650,103.8180 (Keppel Bay residential, no port),
("shibuya", "Shibuya Crossing", 35.6595, 139.7005),
("arc_de_triomphe", "Arc de Triomphe", 48.8738, 2.2950),
("giza", "Giza Pyramids", 29.9792, 31.1342),
("rotterdam_port", "Port of Rotterdam", 51.9500, 4.0500),
("lax", "Los Angeles International Airport", 33.9416, -118.4085),
("hong_kong", "Victoria Harbour, Hong Kong", 22.2930, 114.1700),
("panama_canal", "Panama Canal, Miraflores Locks", 8.9966, -79.5915), # was 9.0800,-79.6800 (blurry jungle/lake shore),
("sydney_opera", "Sydney Opera House", -33.8568, 151.2153),
("kennedy_lc39a", "Kennedy Space Center LC-39A", 28.6083, -80.6041),
("heathrow", "London Heathrow Airport", 51.4700, -0.4543),
("rio_maracana", "Maracana Stadium, Rio de Janeiro", -22.9122, -43.2302),
("venice", "Venice", 45.4340, 12.3388),
("las_vegas_strip", "Las Vegas Strip", 36.1147, -115.1728),
("vatican", "Vatican City", 41.9022, 12.4539),
("burj_khalifa", "Burj Khalifa, Dubai", 25.1972, 55.2744),
("golden_gate", "Golden Gate Bridge", 37.8199, -122.4783),
("kremlin", "Moscow Kremlin", 55.7520, 37.6175),
("tiananmen", "Tiananmen Square, Beijing", 39.9037, 116.3917), # was 39.9055,116.3976 (east of the square),
("mumbai_port", "Mumbai Port", 18.9400, 72.8400),
("suez_canal", "Suez Canal, Ismailia", 30.6141, 32.3228), # was 30.5850,32.2650 (Ismailia city blocks, canal off-frame),
("pentagon", "The Pentagon", 38.8719, -77.0563),
]
WIDE_ZOOM = 13
CLOSE_ZOOM = 18
CLOSE_ZOOM_OVERRIDES = {} # slug -> zoom, set after visual QA if needed
DROP = set() # slugs rejected after visual QA
def world_px(lat, lon, z):
n = TS * 2 ** z
x = (lon + 180.0) / 360.0 * n
s = math.sin(math.radians(lat))
y = (0.5 - math.log((1 + s) / (1 - s)) / (4 * math.pi)) * n
return x, y
def is_placeholder(tile):
a = np.asarray(tile).astype(np.float32)
m = a.reshape(-1, 3).mean(0)
return a.std() < 8 and abs(m[0] - m[1]) < 4 and abs(m[1] - m[2]) < 4
def render(lat, lon, z):
"""Return (image, placeholder_fraction)."""
cx, cy = world_px(lat, lon, z)
x0, y0 = int(round(cx - CW / 2)), int(round(cy - CH / 2))
tx0, ty0 = x0 // TS, y0 // TS
tx1, ty1 = (x0 + CW - 1) // TS, (y0 + CH - 1) // TS
n = 2 ** z
coords = [(tx % n, ty) for ty in range(ty0, ty1 + 1) for tx in range(tx0, tx1 + 1)]
data = fetch_many(esri_jobs(z, coords), desc=f"z{z}")
canvas = Image.new("RGB", ((tx1 - tx0 + 1) * TS, (ty1 - ty0 + 1) * TS))
ph = 0
for ty in range(ty0, ty1 + 1):
for tx in range(tx0, tx1 + 1):
b = data.get((tx % n, ty))
if b is None:
ph += 1
continue
t = Image.open(io.BytesIO(b)).convert("RGB")
if is_placeholder(t):
ph += 1
canvas.paste(t, ((tx - tx0) * TS, (ty - ty0) * TS))
crop = canvas.crop((x0 - tx0 * TS, y0 - ty0 * TS, x0 - tx0 * TS + CW, y0 - ty0 * TS + CH))
return crop, ph / len(coords)
def main():
os.makedirs(HERO, exist_ok=True)
contact = None
if "--contact" in sys.argv:
contact = sys.argv[sys.argv.index("--contact") + 1]
os.makedirs(contact, exist_ok=True)
index = []
for slug, name, lat, lon in LOCATIONS:
if slug in DROP:
for k in ("wide", "close"):
p = os.path.join(HERO, f"{slug}_{k}.jpg")
if os.path.exists(p):
os.remove(p)
print(f"{slug}: dropped")
continue
wide, _ = render(lat, lon, WIDE_ZOOM)
z = CLOSE_ZOOM_OVERRIDES.get(slug, CLOSE_ZOOM)
while True:
close, ph = render(lat, lon, z)
if ph == 0 or z <= 16:
break
print(f" {slug}: {ph:.0%} placeholder tiles at z{z}, falling back")
z -= 1
wide.save(os.path.join(HERO, f"{slug}_wide.jpg"), quality=92, optimize=True)
close.save(os.path.join(HERO, f"{slug}_close.jpg"), quality=92, optimize=True)
index.append({"slug": slug, "name": name, "lat": lat, "lon": lon, "close_zoom": z})
print(f"{slug}: close z{z}")
if contact:
sheet = Image.new("RGB", (1536, 512))
sheet.paste(wide.resize((768, 512), Image.LANCZOS), (0, 0))
sheet.paste(close.resize((768, 512), Image.LANCZOS), (768, 0))
sheet.save(os.path.join(contact, f"{slug}.jpg"), quality=85)
with open(os.path.join(HERO, "index.json"), "w") as f:
json.dump(index, f, indent=2)
print(f"{len(index)} locations written")
if __name__ == "__main__":
main()
@@ -0,0 +1,20 @@
"""Deliverable 2: earth_night.jpg (8192x4096) + earth_night_4k.jpg (4096x2048).
NASA Black Marble (layer VIIRS_Black_Marble, 2016 annual composite) from GIBS
EPSG:4326 level 4 (20x10 tiles of 512px = 10240x5120), Lanczos-downsampled.
Colour kept as served.
"""
import os
from PIL import Image
from common import OUT, gibs_mosaic
Image.MAX_IMAGE_PIXELS = None
LAYER = os.environ.get("NIGHT_LAYER", "VIIRS_Black_Marble")
TIME = os.environ.get("NIGHT_TIME", "2016-01-01")
EXT = "png" if LAYER == "VIIRS_Black_Marble" else "jpg"
m = gibs_mosaic(LAYER, TIME if LAYER == "VIIRS_Black_Marble" else None, "500m", 4, 10, 20, EXT)
big = m.resize((8192, 4096), Image.LANCZOS)
big.save(os.path.join(OUT, "earth_night.jpg"), quality=92, optimize=True, progressive=True)
big.resize((4096, 2048), Image.LANCZOS).save(os.path.join(OUT, "earth_night_4k.jpg"), quality=92, optimize=True, progressive=True)
print("done", LAYER, TIME)
@@ -0,0 +1,15 @@
"""Deliverable 5: Esri World Imagery z3 (8x8) and z4 (16x16) tiles for the quadtree indexing effect."""
import os, shutil
from common import OUT, esri_jobs, fetch_many, esri_tile_path
for z in (3, 4):
n = 2 ** z
d = os.path.join(OUT, f"tiles_z{z}")
os.makedirs(d, exist_ok=True)
coords = [(x, y) for y in range(n) for x in range(n)]
res = fetch_many(esri_jobs(z, coords), desc=f"esri z{z}")
bad = [k for k, v in res.items() if v is None]
for x, y in coords:
if res[(x, y)] is not None:
shutil.copyfile(esri_tile_path(z, x, y), os.path.join(d, f"z{z}_{x}_{y}.jpg"))
print(f"z{z}: {len(coords)-len(bad)} tiles written, {len(bad)} failed")
+116
View File
@@ -0,0 +1,116 @@
"""Shared helpers: polite cached tile fetching for Esri World Imagery and NASA GIBS."""
import io
import os
import sys
import time
import threading
from concurrent.futures import ThreadPoolExecutor, as_completed
import requests
from PIL import Image
HERE = os.path.dirname(os.path.abspath(__file__))
CACHE = os.path.join(HERE, "cache")
ROOT = os.path.abspath(os.path.join(HERE, "..", ".."))
OUT = os.path.join(ROOT, "imagery")
MAX_WORKERS = 8
UA = "gods-eye-view-intro/1.0 (texture prep; contact: project maintainer)"
ESRI = "https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}"
GIBS4326 = "https://gibs.earthdata.nasa.gov/wmts/epsg4326/best/{layer}/default/{time}/{tms}/{z}/{row}/{col}.{ext}"
_local = threading.local()
def _session():
s = getattr(_local, "s", None)
if s is None:
s = requests.Session()
s.headers["User-Agent"] = UA
_local.s = s
return s
def fetch(url, cache_path, retries=6):
"""Return raw bytes for url, caching to cache_path. Retries with backoff."""
if os.path.exists(cache_path) and os.path.getsize(cache_path) > 0:
with open(cache_path, "rb") as f:
return f.read()
os.makedirs(os.path.dirname(cache_path), exist_ok=True)
last = None
for attempt in range(retries):
try:
r = _session().get(url, timeout=60)
if r.status_code == 200 and r.content:
tmp = cache_path + ".part"
with open(tmp, "wb") as f:
f.write(r.content)
os.replace(tmp, cache_path)
return r.content
if r.status_code in (400, 404):
last = f"HTTP {r.status_code}"
break
last = f"HTTP {r.status_code}"
except Exception as e: # network hiccup
last = repr(e)
time.sleep(min(30, 1.5 * 2 ** attempt))
raise RuntimeError(f"failed {url}: {last}")
def fetch_many(jobs, desc=""):
"""jobs: list of (key, url, cache_path). Returns {key: bytes or None}."""
out = {}
done = 0
with ThreadPoolExecutor(MAX_WORKERS) as ex:
futs = {ex.submit(fetch, u, p): k for k, u, p in jobs}
for f in as_completed(futs):
k = futs[f]
try:
out[k] = f.result()
except Exception as e:
print(f" ! {e}", file=sys.stderr)
out[k] = None
done += 1
if done % 100 == 0 or done == len(jobs):
print(f" {desc} {done}/{len(jobs)}", flush=True)
return out
def esri_tile_path(z, x, y):
return os.path.join(CACHE, "esri", str(z), str(y), f"{x}.jpg")
def esri_jobs(z, coords):
return [((x, y), ESRI.format(z=z, x=x, y=y), esri_tile_path(z, x, y)) for x, y in coords]
def gibs_path(layer, time_, tms, z, row, col, ext):
return os.path.join(CACHE, "gibs", layer, time_ or "default", tms, str(z), str(row), f"{col}.{ext}")
def gibs_jobs(layer, time_, tms, z, rows, cols, ext):
t = time_ or "default"
return [((r, c), GIBS4326.format(layer=layer, time=t, tms=tms, z=z, row=r, col=c, ext=ext),
gibs_path(layer, time_, tms, z, r, c, ext)) for r in range(rows) for c in range(cols)]
def decode(b, mode="RGB"):
return Image.open(io.BytesIO(b)).convert(mode)
def gibs_mosaic(layer, time_, tms, z, rows, cols, ext, mode="RGB"):
"""Fetch and stitch a full-globe GIBS EPSG:4326 level (512px tiles)."""
jobs = gibs_jobs(layer, time_, tms, z, rows, cols, ext)
data = fetch_many(jobs, desc=f"{layer} z{z}")
W, H = cols * 512, rows * 512
canvas = Image.new(mode, (W, H))
missing = 0
for (r, c), b in data.items():
if b is None:
missing += 1
continue
canvas.paste(decode(b, mode), (c * 512, r * 512))
if missing:
print(f" {missing} tiles missing for {layer}", file=sys.stderr)
return canvas
@@ -0,0 +1,82 @@
"""MBTA static GTFS -> city/boston/transit.json (subway, Mattapan, commuter rail, bus).
usage: python3 build_transit.py <dir with routes.txt shapes.txt trips.txt>"""
import csv, json, math, os, sys
from collections import defaultdict
sys.path.insert(0, os.path.dirname(__file__))
from common import SCENES, enu
ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..'))
G = sys.argv[1]
H = SCENES['boston'][2]; M = H + 1000
def rdp(pts, eps):
if len(pts) < 3: return pts
keep = [False] * len(pts); keep[0] = keep[-1] = True
stack = [(0, len(pts) - 1)]
while stack:
a, b = stack.pop()
ax, ay = pts[a]; bx, by = pts[b]; dx, dy = bx - ax, by - ay; L = math.hypot(dx, dy)
best, bi = -1, -1
for i in range(a + 1, b):
px, py = pts[i]
d = abs(dy * (px - ax) - dx * (py - ay)) / L if L else math.hypot(px - ax, py - ay)
if d > best: best, bi = d, i
if best > eps:
keep[bi] = True; stack += [(a, bi), (bi, b)]
return [p for p, k in zip(pts, keep) if k]
def length(p): return sum(math.hypot(p[i+1][0]-p[i][0], p[i+1][1]-p[i][1]) for i in range(len(p)-1))
routes = {}
for r in csv.DictReader(open(os.path.join(G, 'routes.txt'), encoding='utf-8-sig')):
t = int(r['route_type'])
if t not in (0, 1, 2, 3): continue
if 'Replacement' in r['route_desc'] or r['route_id'].startswith('Shuttle'): continue
name = r['route_short_name'] or r['route_long_name']
if t == 3 and r['route_short_name'] and r['route_long_name']:
name = f"{r['route_short_name']} {r['route_long_name']}"
routes[r['route_id']] = {'name': name, 'color': '#' + (r['route_color'] or '888888').upper(), 'type': t}
shape_rd = {}
for t in csv.DictReader(open(os.path.join(G, 'trips.txt'), encoding='utf-8-sig')):
if t['route_id'] in routes and t['shape_id']:
shape_rd.setdefault(t['shape_id'], (t['route_id'], t['direction_id']))
shapes = defaultdict(list)
for s in csv.DictReader(open(os.path.join(G, 'shapes.txt'), encoding='utf-8-sig')):
if s['shape_id'] in shape_rd:
shapes[s['shape_id']].append((int(s['shape_pt_sequence']), float(s['shape_pt_lat']), float(s['shape_pt_lon'])))
best = {} # (route, dir) -> (len_in_square, segments)
for sid, pts in shapes.items():
pts.sort()
xy = [enu('boston', la, lo) for _, la, lo in pts]
if not any(abs(x) <= H and abs(y) <= H for x, y in xy): continue # must pass within the square
segs, cur = [], []
for i, (x, y) in enumerate(xy):
if abs(x) <= M and abs(y) <= M:
if not cur and i > 0: cur.append(xy[i-1]) # carry edge point
cur.append((x, y))
elif cur:
cur.append((x, y)); segs.append(cur); cur = []
if cur: segs.append(cur)
segs = [rdp(s, 2.0) for s in segs if len(s) > 1]
L = sum(length(s) for s in segs)
key = shape_rd[sid]
if key not in best or L > best[key][0]: best[key] = (L, segs)
out_shapes, seen = [], set()
for (rid, d), (L, segs) in sorted(best.items()):
for s in segs:
flat = tuple(round(v, 1) for p in s for v in p)
if (rid, flat) in seen: continue
seen.add((rid, flat))
out_shapes.append([rid] + list(flat))
used = {s[0] for s in out_shapes}
out = {'routes': {k: v for k, v in routes.items() if k in used}, 'shapes': out_shapes,
'source': 'MBTA GTFS ' + open(os.path.join(G, 'feed_info.txt')).read().splitlines()[1].split(',"')[1].split('"')[0]}
json.dump(out, open(os.path.join(ROOT, 'city', 'boston', 'transit.json'), 'w'), separators=(',', ':'))
bytype = defaultdict(int)
for k in used: bytype[routes[k]['type']] += 1
print('routes', len(used), dict(bytype), 'shapes', len(out_shapes), 'pts', sum((len(s)-1)//2 for s in out_shapes))
print('rail routes:', sorted(k for k in used if routes[k]['type'] != 3))
print('missing requested:', [k for k in ['Red','Orange','Blue','Green-B','Green-C','Green-D','Green-E','Mattapan'] if k not in used])
+15
View File
@@ -0,0 +1,15 @@
import math
SCENES = {
'nyc': (40.7400, -73.9900, 3500),
'boston': (42.3555, -71.0605, 3200),
'lax': (33.9425, -118.4081, 3500),
'bragg': (35.1580, -79.0090, 6000),
}
def enu(slug, lat, lon):
lat0, lon0, _ = SCENES[slug]
x = (lon - lon0) * math.cos(math.radians(lat0)) * 111320
y = (lat - lat0) * 110540
return round(x, 1), round(y, 1)
def inside(slug, x, y, margin=0.0):
h = SCENES[slug][2] + margin
return abs(x) <= h and abs(y) <= h
+76
View File
@@ -0,0 +1,76 @@
"""YOLOv8n (ONNX, CPU) over cctv/cam_*.jpg and cctv/nyc/*.jpg -> cctv/detections.json.
Model auto-downloads to tools/live/models/yolov8n.onnx (git-ignored)."""
import glob, json, os, sys, time, requests
import numpy as np, onnxruntime as ort
from PIL import Image, ImageDraw
from collections import Counter
HERE = os.path.dirname(os.path.abspath(__file__)); ROOT = os.path.abspath(os.path.join(HERE, '..', '..'))
MODEL = os.path.join(HERE, 'models', 'yolov8n.onnx')
MODEL_URL = 'https://huggingface.co/Kalray/yolov8/resolve/main/yolov8n.onnx'
KEEP = {0: 'person', 1: 'bicycle', 2: 'car', 3: 'motorcycle', 5: 'bus', 7: 'truck', 9: 'traffic light', 11: 'stop sign'}
CONF, IOU, S = 0.16, 0.45, 640
if not os.path.exists(MODEL):
os.makedirs(os.path.dirname(MODEL), exist_ok=True)
open(MODEL, 'wb').write(requests.get(MODEL_URL, timeout=120).content)
sess = ort.InferenceSession(MODEL, providers=['CPUExecutionProvider'])
inp = sess.get_inputs()[0].name
def nms(b, s, thr):
order = s.argsort()[::-1]; keep = []
while order.size:
i = order[0]; keep.append(i)
xx0 = np.maximum(b[i, 0], b[order[1:], 0]); yy0 = np.maximum(b[i, 1], b[order[1:], 1])
xx1 = np.minimum(b[i, 2], b[order[1:], 2]); yy1 = np.minimum(b[i, 3], b[order[1:], 3])
inter = np.clip(xx1 - xx0, 0, None) * np.clip(yy1 - yy0, 0, None)
a = (b[:, 2] - b[:, 0]) * (b[:, 3] - b[:, 1])
iou = inter / (a[i] + a[order[1:]] - inter + 1e-9)
order = order[1:][iou <= thr]
return keep
def detect(path):
im = Image.open(path).convert('RGB'); W, H = im.size
r = min(S / W, S / H); nw, nh = round(W * r), round(H * r); px, py = (S - nw) // 2, (S - nh) // 2
canvas = Image.new('RGB', (S, S), (114, 114, 114)); canvas.paste(im.resize((nw, nh), Image.BILINEAR), (px, py))
x = np.asarray(canvas, dtype=np.float32).transpose(2, 0, 1)[None] / 255.0
p = sess.run(None, {inp: x})[0][0].T # 8400 x 84
sc = p[:, 4:]; cls = sc.argmax(1); conf = sc.max(1)
m = (conf >= CONF) & np.isin(cls, list(KEEP))
p, cls, conf = p[m], cls[m], conf[m]
if not len(p): return []
b = np.stack([p[:, 0] - p[:, 2] / 2, p[:, 1] - p[:, 3] / 2, p[:, 0] + p[:, 2] / 2, p[:, 1] + p[:, 3] / 2], 1)
out = []
for c in np.unique(cls): # class-wise NMS
idx = np.where(cls == c)[0]
for k in nms(b[idx], conf[idx], IOU):
j = idx[k]; x0, y0, x1, y1 = b[j]
x0 = min(max((x0 - px) / r / W, 0), 1); x1 = min(max((x1 - px) / r / W, 0), 1)
y0 = min(max((y0 - py) / r / H, 0), 1); y1 = min(max((y1 - py) / r / H, 0), 1)
if x1 - x0 > 0 and y1 - y0 > 0:
out.append([round(float(x0), 4), round(float(y0), 4), round(float(x1), 4), round(float(y1), 4), round(float(conf[j]), 3), KEEP[int(c)]])
return sorted(out, key=lambda d: -d[4])
COL = {'person': (255, 60, 60), 'car': (60, 200, 255), 'truck': (255, 180, 0), 'bus': (255, 0, 255), 'bicycle': (0, 255, 120),
'motorcycle': (180, 255, 0), 'traffic light': (255, 255, 255), 'stop sign': (255, 0, 0)}
def render(path, dets, dst):
im = Image.open(path).convert('RGB'); W, H = im.size; d = ImageDraw.Draw(im)
for x0, y0, x1, y1, c, n in dets:
d.rectangle([x0 * W, y0 * H, x1 * W, y1 * H], outline=COL[n], width=2)
d.text((x0 * W + 2, max(0, y0 * H - 11)), f'{n} {c:.2f}', fill=COL[n])
im.save(dst, quality=90)
if __name__ == '__main__':
files = sorted(glob.glob(os.path.join(ROOT, 'cctv', 'cam_*.jpg'))) + sorted(glob.glob(os.path.join(ROOT, 'cctv', 'nyc', '*.jpg')))
res, tot, t0 = {}, Counter(), time.time()
for f in files:
rel = os.path.relpath(f, os.path.join(ROOT, 'cctv'))
res[rel] = detect(f); tot.update(d[5] for d in res[rel])
dt = time.time() - t0
json.dump(res, open(os.path.join(ROOT, 'cctv', 'detections.json'), 'w'), separators=(',', ':'))
print('images', len(files), 'secs', round(dt, 1), 'ms/img', round(1000 * dt / max(1, len(files))))
print('totals', dict(tot.most_common()), 'all', sum(tot.values()))
print('nyc totals', dict(Counter(d[5] for k, v in res.items() if k.startswith('nyc/') for d in v)))
picks = sys.argv[1:] or [max((k for k in res if k.startswith('nyc/')), key=lambda k: len(res[k])),
max((k for k in res if not k.startswith('nyc/')), key=lambda k: len(res[k])),
max(res, key=lambda k: sum(d[5] == 'person' for d in res[k]))]
for i, k in enumerate(picks, 1):
render(os.path.join(ROOT, 'cctv', k), res[k], os.path.join(HERE, f'det_check_{i}.jpg')); print('render', i, k, len(res[k]))
+72
View File
@@ -0,0 +1,72 @@
"""Poll live ADS-B (adsb.lol, fallback adsb.fi) around each scene 3x, 20 s apart.
Writes city/<slug>/air.json with latest sample per aircraft + short track history."""
import json, os, sys, time, requests
sys.path.insert(0, os.path.dirname(__file__))
from common import SCENES, enu
ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..'))
RAD = {'nyc': 25, 'lax': 25, 'bragg': 25, 'boston': 20}
HELI_TYPES = {'EC35','EC30','EC45','EC55','EC20','EC25','EC75','EC13','EC40','AS50','AS55','AS65','AS32','AS3B','H125','H130','H135','H145','H160','H175','B06','B06T','B407','B412','B427','B429','B430','B47G','B505','B212','B222','B230','B105','BK17','R22','R44','R66','S76','S92','S61','S64','S70','H60','UH60','MH60','HH60','H47','CH47','H53','A109','A119','A139','A169','A189','AW09','MI8','MI17','UH1','UH1Y','AH1','AH64','H64','V22','MV22','CABR','G2CA','EN28','EN48','H269','H500','MD52','MD60','NH90','LYNX','PUMA','GAZL','EH10','H1','B505'}
H = {'User-Agent': 'gods-eye-view-intro/1.0 (film research)'}
def fetch(slug):
lat, lon, _ = SCENES[slug]; nm = RAD[slug]
for url, key in ((f'https://api.adsb.lol/v2/point/{lat}/{lon}/{nm}', 'ac'),
(f'https://opendata.adsb.fi/api/v2/lat/{lat}/lon/{lon}/dist/{nm}', 'aircraft')):
try:
r = requests.get(url, headers=H, timeout=20); r.raise_for_status(); j = r.json()
ac = j.get('ac') or j.get('aircraft') or []
now = j.get('now') or time.time() * 1000
return url, now / 1000 if now > 1e11 else now, ac
except Exception as e:
print(slug, 'fail', url, e, file=sys.stderr)
return None, time.time(), []
def num(v):
try: return float(v)
except Exception: return None
def conv(slug, a):
lat, lon = num(a.get('lat')), num(a.get('lon'))
if lat is None or lon is None:
lp = a.get('lastPosition') or {}
lat, lon = num(lp.get('lat')), num(lp.get('lon'))
if lat is None: return None
x, y = enu(slug, lat, lon)
ab = a.get('alt_baro')
if ab == 'ground': alt = 0.0
else:
v = num(ab)
if v is None: v = num(a.get('alt_geom'))
alt = round(v * 0.3048, 1) if v is not None else None
trk = num(a.get('track')); trk = num(a.get('true_heading')) if trk is None else trk
gs = num(a.get('gs')); gs = round(gs * 0.514444, 1) if gs is not None else None
vr = num(a.get('baro_rate')); vr = num(a.get('geom_rate')) if vr is None else vr
vr = round(vr * 0.00508, 2) if vr is not None else None
return [x, y, alt, trk, gs, vr, (a.get('flight') or '').strip(), a.get('t') or '', a.get('category') or '']
def main():
polls = {s: [] for s in SCENES}
for i in range(3):
t0 = time.time()
for s in SCENES:
url, t, ac = fetch(s); polls[s].append((url, t, ac)); print(i, s, len(ac), url, flush=True)
if i < 2: time.sleep(max(0, 20 - (time.time() - t0)))
summary = {}
for s in SCENES:
latest, tracks, src = {}, {}, set()
tlast = 0
for url, t, ac in polls[s]:
if url: src.add(url.split('/')[2])
tlast = max(tlast, t)
for a in ac:
hx = a.get('hex'); row = conv(s, a)
if not hx or row is None: continue
if (num(a.get('seen_pos')) or 0) > 60: continue
latest[hx] = row
tr = tracks.setdefault(hx, [])
p = [row[0], row[1], row[2]]
if not tr or tr[-1][:2] != p[:2]: tr.append(p)
heli = [h for h, r in latest.items() if r[8] == 'A7' or r[7] in HELI_TYPES]
out = {'t': int(tlast), 'source': sorted(src), 'radius_nm': RAD[s], 'hex': list(latest.keys()),
'a': list(latest.values()), 'tracks': {h: v for h, v in tracks.items() if len(v) > 1}}
json.dump(out, open(os.path.join(ROOT, 'city', s, 'air.json'), 'w'), separators=(',', ':'))
summary[s] = {'aircraft': len(latest), 'with_track': len(out['tracks']), 'heli': len(heli),
'heli_list': [[latest[h][6], latest[h][7], latest[h][8], latest[h][2]] for h in heli]}
print(json.dumps(summary, indent=1))
if __name__ == '__main__': main()
@@ -0,0 +1,21 @@
"""Live MBTA vehicle positions -> city/boston/vehicles.json (square + 1 km)."""
import json, os, sys, requests
from collections import Counter
sys.path.insert(0, os.path.dirname(__file__))
from common import enu, inside
ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..'))
j = requests.get('https://cdn.mbta.com/realtime/VehiclePositions_enhanced.json', timeout=30).json()
rows, total = [], 0
for e in j['entity']:
v = e.get('vehicle') or {}; p = v.get('position') or {}
if 'latitude' not in p: continue
total += 1
x, y = enu('boston', p['latitude'], p['longitude'])
if not inside('boston', x, y, 1000): continue
rid = (v.get('trip') or {}).get('route_id', '')
lab = (v.get('vehicle') or {}).get('label') or (v.get('vehicle') or {}).get('id', '')
sp = p.get('speed'); rows.append([x, y, p.get('bearing'), round(sp, 2) if sp is not None else None, rid, lab])
out = {'t': int(j['header']['timestamp']), 'source': 'cdn.mbta.com VehiclePositions_enhanced.json', 'v': rows}
json.dump(out, open(os.path.join(ROOT, 'city', 'boston', 'vehicles.json'), 'w'), separators=(',', ':'))
c = Counter(r[4].split('-')[0] if r[4].startswith(('Green', 'CR')) else ('bus' if r[4][:1].isdigit() or r[4][:2] in ('SL','CT') else r[4]) for r in rows)
print('t', out['t'], 'feed total', total, 'in square+1km', len(rows), dict(c))
@@ -0,0 +1,53 @@
"""NYC DOT TMC cameras -> city/nyc/cameras.json + current stills for ~24 nearest cams."""
import hashlib, io, json, os, sys, time, requests
import numpy as np
from PIL import Image, ImageOps
from collections import Counter
sys.path.insert(0, os.path.dirname(__file__))
from common import enu, inside
ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..'))
OUT = os.path.join(ROOT, 'cctv', 'nyc'); DBG = os.path.join(os.path.dirname(__file__), 'nyc_rejects')
os.makedirs(OUT, exist_ok=True); os.makedirs(DBG, exist_ok=True)
H = {'User-Agent': 'gods-eye-view-intro/1.0'}
cat = requests.get('https://webcams.nyctmc.org/api/cameras', headers=H, timeout=30).json()
cams = []
for c in cat:
if str(c.get('isOnline')).lower() != 'true': continue
try: lat, lon = float(c['latitude']), float(c['longitude'])
except Exception: continue
x, y = enu('nyc', lat, lon)
if inside('nyc', x, y): cams.append((x, y, c['id'], c['name'].strip(), lat, lon))
json.dump([[x, y, i, n] for x, y, i, n, _, _ in cams], open(os.path.join(ROOT, 'city', 'nyc', 'cameras.json'), 'w'), separators=(',', ':'))
print('catalog', len(cat), 'online in square', len(cams))
cams.sort(key=lambda c: c[0]**2 + c[1]**2)
for f in os.listdir(OUT):
if f.startswith('cam_') and f.endswith('.jpg'): os.remove(os.path.join(OUT, f))
idx, rejects, hashes, raw_imgs = [], Counter(), Counter(), []
fetched_utc = time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime())
for x, y, cid, name, lat, lon in cams[:80]:
if len(raw_imgs) >= 40: break
try:
r = requests.get(f'https://webcams.nyctmc.org/api/cameras/{cid}/image', headers=H, timeout=20)
r.raise_for_status(); im = Image.open(io.BytesIO(r.content)).convert('RGB')
except Exception as e:
rejects['error'] += 1; continue
h = hashlib.md5(r.content).hexdigest(); hashes[h] += 1
g = np.asarray(im.convert('L'), dtype=np.float32)
raw_imgs.append(dict(x=x, y=y, id=cid, name=name, lat=lat, lon=lon, im=im, h=h, mean=float(g.mean()), std=float(g.std()), size=im.size, n=len(r.content)))
n = 0
for d in raw_imgs:
reason = None
if hashes[d['h']] > 1: reason = 'duplicate/placeholder'
elif d['mean'] < 14: reason = 'black'
elif d['std'] < 10: reason = 'flat'
elif d['size'][0] < 200: reason = 'tiny'
if reason:
rejects[reason] += 1; d['im'].save(os.path.join(DBG, f"{reason.split('/')[0]}_{d['id'][:8]}.jpg")); continue
if n >= 24: break
n += 1; fn = f'cam_{n:03d}.jpg'
ImageOps.fit(d['im'], (640, 360), Image.LANCZOS).save(os.path.join(OUT, fn), quality=88)
idx.append({'file': fn, 'id': d['id'], 'name': d['name'], 'x': d['x'], 'y': d['y'], 'lat': d['lat'], 'lon': d['lon'],
'orig_size': list(d['size']), 'fetched_utc': fetched_utc, 'source': 'NYC DOT TMC webcams.nyctmc.org'})
print(fn, d['name'], d['size'], round(d['mean']), round(d['std']), round((d['x']**2+d['y']**2)**.5))
json.dump(idx, open(os.path.join(OUT, 'index.json'), 'w'), indent=1)
print('saved', n, 'rejects', dict(rejects), 'tried', len(raw_imgs))
+15
View File
@@ -0,0 +1,15 @@
#!/usr/bin/env bash
# Render one format in 8 s chunks (skips chunks already on disk), then concat.
# tools/render-all.sh 1920 1080 16x9
set -u
W=$1; H=$2; TAG=$3
CH=240; TOTAL=${4:-2850}
mkdir -p out/${PREFIX:-}chunks_$TAG
for ((s=0; s<TOTAL; s+=CH)); do
e=$((s+CH)); f=out/${PREFIX:-}chunks_$TAG/c_$(printf %05d $s).mp4
[ -s "$f" ] && continue
e=$(( e > TOTAL ? TOTAL : e ))
node tools/render.mjs --port ${PORT:-8766} --w $W --h $H --from $s --to $e --video "$f.tmp.mp4" --crf 14 --preset slow && mv "$f.tmp.mp4" "$f"
done
ls out/${PREFIX:-}chunks_$TAG/c_*.mp4 | sed "s|^|file '$PWD/|; s|$|'|" > out/${PREFIX:-}chunks_$TAG/list.txt
ffmpeg -y -loglevel error -f concat -safe 0 -i out/${PREFIX:-}chunks_$TAG/list.txt -c copy out/${PREFIX:-}video_$TAG.mp4 && echo "CONCAT OK $TAG"
+91
View File
@@ -0,0 +1,91 @@
// Deterministic frame renderer: drives src/main.js frame by frame in headless
// Chromium and either writes stills or pipes straight into ffmpeg.
//
// node tools/render.mjs --w 1920 --h 1080 --frames 480,900,1500 --out out/stills
// node tools/render.mjs --w 1920 --h 1080 --from 0 --to 2160 --video out/video_16x9.mp4
import { chromium } from 'playwright-core';
import { spawn } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
const args = Object.fromEntries(
process.argv
.slice(2)
.join(' ')
.split('--')
.filter(Boolean)
.map((s) => {
const [k, ...v] = s.trim().split(' ');
return [k, v.join(' ') || true];
}),
);
const W = +args.w || 1920;
const H = +args.h || 1080;
const URL = `http://127.0.0.1:${args.port || 8765}/index.html?w=${W}&h=${H}`;
const CHROME = '/opt/pw-browsers/chromium-1194/chrome-linux/chrome';
const browser = await chromium.launch({
executablePath: CHROME,
args: ['--use-gl=angle', '--use-angle=swiftshader', '--enable-unsafe-swiftshader', '--ignore-gpu-blocklist', '--disable-gpu-vsync', '--js-flags=--max-old-space-size=8192'],
});
const page = await browser.newPage({ viewport: { width: W, height: H }, deviceScaleFactor: 1 });
page.on('console', (m) => {
const t = m.text();
if (/error|warn|missing|\[gev\]/i.test(t)) console.log(' page:', t.slice(0, 300));
});
page.on('pageerror', (e) => console.log(' PAGE ERROR:', e.message));
await page.goto(URL);
await page.waitForFunction(() => window.__status === 'ready' || String(window.__status).startsWith('error'), null, { timeout: 600000, polling: 500 });
const st = await page.evaluate(() => window.__status);
if (st !== 'ready') {
console.error('init failed:', st);
process.exit(1);
}
const info = await page.evaluate(() => ({ counts: GEV.counts, heroes: GEV.heroes, cams: GEV.cams, frames: GEV.frames }));
console.log('ready', JSON.stringify(info));
const cdp = await page.context().newCDPSession(page);
async function grab(format = 'png') {
const r = await cdp.send('Page.captureScreenshot', { format, quality: format === 'jpeg' ? 96 : undefined, fromSurface: true, captureBeyondViewport: false });
return Buffer.from(r.data, 'base64');
}
let frames = [];
if (args.frames) frames = String(args.frames).split(',').map((x) => (x.includes('s') ? Math.round(parseFloat(x) * 30) : +x));
else {
const from = +(args.from || 0);
const to = +(args.to || info.frames);
const step = +(args.step || 1);
for (let f = from; f < to; f += step) frames.push(f);
}
let ff = null;
if (args.video) {
fs.mkdirSync(path.dirname(args.video), { recursive: true });
ff = spawn('ffmpeg', ['-y', '-loglevel', 'error', '-f', 'image2pipe', '-framerate', '30', '-c:v', 'png', '-i', '-', '-c:v', 'libx264', '-preset', args.preset || 'slow', '-crf', String(args.crf || 14), '-pix_fmt', 'yuv420p', '-colorspace', 'bt709', '-color_primaries', 'bt709', '-color_trc', 'bt709', '-movflags', '+faststart', args.video], { stdio: ['pipe', 'inherit', 'inherit'] });
} else {
fs.mkdirSync(args.out || 'out/stills', { recursive: true });
}
const t0 = Date.now();
for (let i = 0; i < frames.length; i++) {
const f = frames[i];
await page.evaluate((fr) => window.GEV.renderFrame(fr), f);
const buf = await grab('png');
if (ff) {
if (!ff.stdin.write(buf)) await new Promise((r) => ff.stdin.once('drain', r));
} else {
fs.writeFileSync(path.join(args.out || 'out/stills', `${args.prefix || 'f'}_${String(f).padStart(5, '0')}.png`), buf);
}
if (i % 30 === 0 || i === frames.length - 1) {
const el = (Date.now() - t0) / 1000;
const per = el / (i + 1);
console.log(`frame ${f} (${i + 1}/${frames.length}) ${per.toFixed(2)} s/frame, eta ${((frames.length - i - 1) * per / 60).toFixed(1)} min`);
}
}
if (ff) {
ff.stdin.end();
await new Promise((r) => ff.on('close', r));
}
await browser.close();
console.log('done in', ((Date.now() - t0) / 60000).toFixed(1), 'min');
+19
View File
@@ -0,0 +1,19 @@
# usage: python3 tools/sheet.py <dir> <out_prefix> [per_sheet] [cell_w]
import sys, glob
from PIL import Image
d, outp = sys.argv[1], sys.argv[2]
per = int(sys.argv[3]) if len(sys.argv) > 3 else 4
cw = int(sys.argv[4]) if len(sys.argv) > 4 else 960
fs = sorted(glob.glob(d + '/*.png'))
for s in range(0, len(fs), per):
group = fs[s:s + per]
ims = [Image.open(f) for f in group]
w0, h0 = ims[0].size
ch = int(cw * h0 / w0)
cols = 2 if w0 > h0 else 4
rows = (len(ims) + cols - 1) // cols
sheet = Image.new('RGB', (cw * cols, ch * rows))
for i, im in enumerate(ims):
sheet.paste(im.resize((cw, ch), Image.LANCZOS), ((i % cols) * cw, (i // cols) * ch))
sheet.save(f'{outp}_{s // per:02d}.jpg', quality=88)
print(f'{outp}_{s // per:02d}.jpg', [f.split('/')[-1] for f in group])
+2 -2
View File
@@ -49,6 +49,7 @@
"test": "node scripts/run-unit-tests.mjs",
"test:track": "node scripts/track-regression.mjs",
"qa:map-source-tray": "node scripts/qa-map-source-tray.mjs",
"qa:panel-resize": "node scripts/qa-panel-resize.mjs",
"qa:street-level": "node scripts/qa-street-level.mjs",
"qa:street-level:fixtures": "node scripts/qa-street-level.mjs --fixtures",
"check:boundaries": "node scripts/check-import-directions.mjs && node scripts/check-package-boundaries.mjs",
@@ -276,7 +277,6 @@
"./tools/mcp": "./src/tools/mcp/index.js",
"./tools/panel": "./src/tools/panel.js",
"./tools/services": "./src/tools/services.js",
"./view": "./src/view/index.js",
"./agent/world-packet": "./src/agent/worldPacket.js"
"./view": "./src/view/index.js"
}
}
@@ -49,6 +49,11 @@ export function resetKartaViewFixture() {
nearestLookups = 0;
}
/** Nearest lookups answered since the last reset (the first got a 429). */
export function kartaViewLookups() {
return nearestLookups;
}
/** The drive's photos, south to north; every tenth is 360°, every odd one archived. */
export function kartaViewPhotos() {
const step = KV_SPACING_M / METRES_PER_DEG_LAT;
@@ -2,8 +2,8 @@ import { readFileSync } from 'node:fs';
/**
* Mapillary Graph API and image CDN answered from fixtures, covering every
* request the provider and MapillaryJS make while opening and closing a
* photo, so the gate needs no network and no real token.
* request the provider and MapillaryJS make while opening, stepping and
* closing a photo, so the gate needs no network and no real token.
*
* No `merge_cc` or `sfm_cluster` means MapillaryJS never asks for a mesh or
* cluster, and empty S2 cells mean no spatial edges, which the gate does not
@@ -21,7 +21,7 @@ export const PHOTO_LINE = Object.freeze({
north: 38.591,
});
/** Metres between consecutive photos on the line (≥ the 3 m cone thinning). */
const PHOTO_SPACING_M = 30;
export const PHOTO_SPACING_M = 30;
/** Fixture thumbnail host (answered, never reached). */
export const THUMB_HOST = 'qa-fixture.mapillary.com';
@@ -181,6 +181,18 @@ export function answerMapillaryRequest({ method, url, images }) {
: [];
return { ...json(200, { data }), known: true };
}
if (q.has('lat') && q.has('lng')) {
const at = { lat: Number(q.get('lat')), lon: Number(q.get('lng')) };
const radius = Math.min(50, Number(q.get('radius')) || 50);
const limit = Number(q.get('limit')) || 10;
// In no particular order, as the real API answers.
const data = photos
.filter((image) => metresApart(at, image) <= radius)
.reverse()
.slice(0, limit)
.map((image) => withFields(imageRecord(image), fields));
return { ...json(200, { data }), known: true };
}
if (q.has('s2')) return { ...json(200, { data: [] }), known: true };
return {
...json(400, { error: 'unsupported images query' }),
+1 -1
View File
@@ -28,7 +28,7 @@ export const PNX_PARK = Object.freeze({
height: 700,
pitch: -1.4,
});
const PNX_CREATOR = 'Fixture Mapper';
export const PNX_CREATOR = 'Fixture Mapper';
const PNX_SPACING_M = 10;
const METRES_PER_DEG_LON = 111_320 * Math.cos((PNX_LINE.lat * Math.PI) / 180);
const API = 'https://api.panoramax.xyz/api';
+33 -30
View File
@@ -46,6 +46,9 @@
"scripts/qa-voice-auth-focus.mjs",
"src/codexOauthRealtime.test.mjs",
"src/voice/cloudVoiceAuth.test.mjs",
"scripts/qa-panel-resize.mjs",
"scripts/qa-panelDrag.mjs",
"src/tooling/panelResizeQa.test.mjs",
"scripts/qa-terrain-429.mjs",
"src/app/application.js",
"src/app/application.test.mjs",
@@ -93,7 +96,6 @@
"server/providers/space/launch-library.js",
"src/data/spaceProviderRequests.js",
"src/tooling/spaceProviders.test.mjs",
"src/tooling/ll2Integration.test.mjs",
"server/providers/terrain.js",
"server/providers/traffic.js",
"server/providers/firms.js",
@@ -179,6 +181,7 @@
"src/ui/panelRails.js",
"src/ui/leftPanelRail.js",
"src/ui/rightPanelRail.js",
"src/ui/panelResize.test.mjs",
"src/ui/panelMeasurement.js",
"src/ui/panelRailGeometry.js",
"src/ui/panelRails.test.mjs",
@@ -281,7 +284,6 @@
"src/ui/applicationShell.js",
"src/ui/panelLayoutController.js",
"src/ui/panelPositionControls.js",
"src/ui/panelPositionControls.test.mjs",
"src/ui/recordingControls.js",
"src/ui/shellElements.js",
"src/ui/shellFeedback.js",
@@ -444,7 +446,6 @@
"src/data/rocketLaunches.js",
"src/layers/satellites/source.test.mjs",
"src/layers/launches/source.test.mjs",
"src/layers/launches/ingestion.test.mjs",
"src/layers/installations/lifecycle.js",
"src/layers/installations/viewport.js",
"src/layers/installations/controls.js",
@@ -554,8 +555,6 @@
"src/data/cctvNswSource.test.mjs",
"src/data/cctvTxdotSource.test.mjs",
"src/layers/cctv/source.test.mjs",
"src/layers/cctv/frames.test.mjs",
"src/layers/cctv/rendering.test.mjs",
"src/data/alprCameras.js",
"src/data/alprCameras.test.mjs",
"src/data/dataCredits.test.mjs",
@@ -636,7 +635,6 @@
"src/voice/control.js",
"src/voice/realtimeBackend.js",
"src/voice/realtimeBackend.test.mjs",
"src/agent/worldPacket.test.mjs",
"src/voice/layerSummary.js",
"build/application-html.js",
"src/app/sourceSlot.js",
@@ -692,7 +690,6 @@
"src/ui/shareRestoration.js",
"src/ui/visualSettings.js",
"src/ui/panelChrome.js",
"src/ui/panelChrome.radioOwnership.test.mjs",
"src/testSupport/readShellSource.mjs",
"src/ui/stateOwners.test.mjs",
"src/layers/flights/records.js",
@@ -909,32 +906,27 @@
"src/ui/streetLevelPresentation.test.mjs",
"src/app/layers/streetLevel.js",
"src/tooling/mapillaryProvider.test.mjs",
"src/app/layers/streetLevel.test.mjs",
"src/data/idSwitches.js",
"src/layers/streetLevel/credits.js",
"src/layers/streetLevel/providers/mapillary/index.js",
"src/layers/streetLevel/providers/mapillary/provider.test.mjs",
"src/layers/streetLevel/params.js",
"src/layers/streetLevel/params.test.mjs",
"src/layers/streetLevel/pickRouter.js",
"src/layers/streetLevel/pickRouter.test.mjs",
"src/layers/streetLevel/providerSwitches.test.mjs",
"src/layers/streetLevel/registry.js",
"src/layers/streetLevel/registry.test.mjs",
"src/layers/streetLevel/uiState.js",
"src/layers/streetLevel/uiState.test.mjs",
"src/layers/streetLevel/providers/mapillary/source.test.mjs",
"src/streetLevelMarkup.test.mjs",
"src/layers/streetLevel/index.js",
"src/layers/streetLevel/policy.js",
"src/layers/streetLevel/filter.js",
"src/layers/streetLevel/registry.js",
"src/layers/streetLevel/params.js",
"src/layers/streetLevel/pickRouter.js",
"src/layers/streetLevel/uiState.js",
"src/layers/streetLevel/groundCast.js",
"src/layers/streetLevel/meshSampler.js",
"src/layers/streetLevel/view.js",
"src/layers/streetLevel/marker.js",
"src/layers/streetLevel/cameraFraming.js",
"src/layers/streetLevel/cameraFollow.js",
"src/layers/streetLevel/credits.js",
"src/layers/streetLevel/viewerHost.js",
"src/layers/streetLevel/providers/mapillary/index.js",
"src/layers/streetLevel/state.js",
"src/layers/streetLevel/lineCoverage.js",
"src/layers/streetLevel/sequenceCones.js",
"src/layers/streetLevel/providers/mapillary/policy.js",
"src/layers/streetLevel/providers/mapillary/coverage.js",
"src/layers/streetLevel/providers/mapillary/sequences.js",
"src/layers/streetLevel/glyphs.js",
"src/layers/streetLevel/providers/mapillary/decode.js",
"src/layers/streetLevel/tileMath.js",
@@ -947,6 +939,7 @@
"src/layers/streetLevel/providers/google/viewer.js",
"src/layers/streetLevel/providers/google/mapsLoader.js",
"src/layers/streetLevel/groundClick.test.mjs",
"src/app/layers/streetLevel.test.mjs",
"src/layers/streetLevel/providers/google/provider.test.mjs",
"src/layers/streetLevel/providers/google/viewer.test.mjs",
"src/layers/streetLevel/providers/google/mapsLoader.test.mjs",
@@ -956,6 +949,8 @@
"src/layers/streetLevel/providers/kartaview/decode.js",
"src/layers/streetLevel/providers/kartaview/viewer.js",
"src/layers/streetLevel/panorama.js",
"src/layers/streetLevel/lineCoverage.js",
"src/layers/streetLevel/sequenceCones.js",
"src/layers/streetLevel/providers/kartaview/policy.test.mjs",
"src/layers/streetLevel/providers/kartaview/decode.test.mjs",
"src/layers/streetLevel/providers/kartaview/source.test.mjs",
@@ -984,6 +979,8 @@
"src/testSupport/kartaViewFakes.mjs",
"src/testSupport/googleStreetViewFakes.mjs",
"server/providers/mapillary.js",
"server/providers/mapillary/routes.js",
"server/providers/mapillary/constants.js",
"server/providers/mapillary/tiles.js",
"server/providers/mapillary/trim.js",
"src/layers/streetLevel/tileMath.test.mjs",
@@ -996,15 +993,23 @@
"scripts/fixtures/street-level/kartaView.mjs",
"scripts/fixtures/street-level/panoramax.mjs",
"src/layers/streetLevel/filter.test.mjs",
"src/layers/streetLevel/registry.test.mjs",
"src/layers/streetLevel/params.test.mjs",
"src/layers/streetLevel/pickRouter.test.mjs",
"src/layers/streetLevel/uiState.test.mjs",
"src/layers/streetLevel/groundCast.test.mjs",
"src/layers/streetLevel/meshSampler.test.mjs",
"src/layers/streetLevel/view.test.mjs",
"src/layers/streetLevel/providers/mapillary/mapillary.test.mjs",
"src/layers/streetLevel/providers/mapillary/provider.test.mjs",
"src/layers/streetLevel/providers/mapillary/decode.test.mjs",
"src/layers/streetLevel/providers/mapillary/coverage.test.mjs",
"src/layers/streetLevel/providers/mapillary/coverageFixture.mjs",
"src/layers/streetLevel/cameraFraming.test.mjs",
"src/layers/streetLevel/cameraFollow.test.mjs",
"src/ui/streetLevelControls.test.mjs",
"src/ui/panelDock.test.mjs",
"src/layers/streetLevel/marker.test.mjs",
"src/layers/streetLevel/viewerHost.test.mjs",
"src/layers/streetLevel/sequenceCones.test.mjs",
"src/layers/streetLevel/providers/mapillary/sequences.test.mjs",
"src/layers/streetLevel/selection.test.mjs",
"src/layers/streetLevel/index.test.mjs",
"scripts/qa-overpass-offload.mjs",
@@ -1021,7 +1026,6 @@
"scripts/qa-context-arrivals.mjs",
"src/tools/catalog.test.mjs",
"src/tools/area.test.mjs",
"src/tools/results.test.mjs",
"src/tools/queries/queries.test.mjs",
"src/tools/mcp/mcp.test.mjs",
"src/tools/localServer.test.mjs",
@@ -1058,6 +1062,5 @@
"src/ui/flyToDataSource.test.mjs",
"scripts/assemble-changelog.mjs",
"src/tooling/changelogFragments.test.mjs",
"src/data/gbfsProxyRateLimit.test.mjs",
"src/terrainHeightsBounds.test.mjs"
"src/data/gbfsProxyRateLimit.test.mjs"
]
+48 -68
View File
@@ -114,11 +114,7 @@
"exports": ["./server/providers/terrain"],
"modules": [
"server/providers/terrain.js",
"src/data/terrainHeightsProxy.js",
"server/providers/common/rate-limit.js",
"src/sources/rateLimit.js",
"server/providers/common/same-site.js",
"src/localRequestGate.mjs"
"src/data/terrainHeightsProxy.js"
],
"external": []
},
@@ -214,6 +210,8 @@
"exports": ["./server/providers/mapillary"],
"modules": [
"server/providers/mapillary.js",
"server/providers/mapillary/routes.js",
"server/providers/mapillary/constants.js",
"server/providers/mapillary/tiles.js",
"server/providers/mapillary/trim.js",
"server/providers/common/same-site.js",
@@ -722,6 +720,7 @@
"src/ui/panelLayoutController.js",
"src/ui/panelMeasurement.js",
"src/ui/panelPositionControls.js",
"src/ui/panelResize.js",
"src/ui/panelRailGeometry.js",
"src/ui/panelRails.js",
"src/ui/radio.js",
@@ -754,7 +753,6 @@
"src/ui/shellFacade.js",
"src/ui/streetLevelControls.js",
"src/ui/streetLevelPresentation.js",
"src/layers/streetLevel/policy.js",
"src/ui/localSdrControls.js",
"src/layers/localAdsb/feedNames.js",
"src/ui/localSdrPresentation.js",
@@ -1042,7 +1040,6 @@
"src/layers/vessels/tracking.js",
"src/data/iconOrientation.js",
"src/data/vesselLabels.js",
"src/data/vesselTypes.js",
"src/layers/vessels/records.js",
"src/layers/vessels/recordPolicy.js",
"src/layers/vessels/snapshotRenderer.js",
@@ -1381,13 +1378,15 @@
],
"modules": [
"src/layers/streetLevel/index.js",
"src/layers/streetLevel/credits.js",
"src/layers/streetLevel/providers/mapillary/index.js",
"src/layers/streetLevel/viewerHost.js",
"src/layers/streetLevel/cameraFraming.js",
"src/layers/streetLevel/credits.js",
"src/layers/streetLevel/cameraFollow.js",
"src/layers/streetLevel/marker.js",
"src/layers/streetLevel/view.js",
"src/layers/streetLevel/uiState.js",
"src/layers/streetLevel/groundCast.js",
"src/layers/streetLevel/meshSampler.js",
"src/layers/streetLevel/pickRouter.js",
"src/data/idSwitches.js",
"src/layers/streetLevel/params.js",
@@ -1395,8 +1394,9 @@
"src/layers/streetLevel/filter.js",
"src/layers/streetLevel/policy.js",
"src/layers/streetLevel/state.js",
"src/layers/streetLevel/lineCoverage.js",
"src/layers/streetLevel/sequenceCones.js",
"src/layers/streetLevel/providers/mapillary/policy.js",
"src/layers/streetLevel/providers/mapillary/coverage.js",
"src/layers/streetLevel/providers/mapillary/sequences.js",
"src/layers/streetLevel/glyphs.js",
"src/layers/streetLevel/providers/mapillary/decode.js",
"src/layers/streetLevel/providers/mapillary/coverageFixture.mjs",
@@ -1423,13 +1423,14 @@
"src/layers/streetLevel/imageSize.js",
"src/layers/streetLevel/tileCache.js",
"src/layers/streetLevel/panorama.js",
"src/layers/streetLevel/lineCoverage.js",
"src/layers/streetLevel/sequenceCones.js",
"src/layers/streetLevel/providers/mapillary/source.js",
"scripts/qa-street-level.mjs",
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/es.json",
"src/i18n/locales/ru.json",
"src/layers/streetLevel/providers/mapillary/policy.js"
"src/i18n/locales/ru.json"
],
"external": ["cesium", "pbf", "@mapbox/vector-tile", "mapillary-js"]
},
@@ -1627,15 +1628,7 @@
"src/i18n/locale.js",
"src/i18n/locales/es.json",
"src/i18n/locales/ru.json",
"src/ui/statusText.js",
"src/voice/providerCommands.js",
"src/voice/providerCapabilities.js",
"src/voice/geminiSession.js",
"src/voice/geminiAudio.js",
"src/voice/geminiCaptureWorklet.js",
"src/sources/httpBody.js",
"src/sources/requestDeadline.js",
"src/i18n/marker.js"
"src/ui/statusText.js"
],
"external": []
},
@@ -1848,7 +1841,6 @@
"src/data/trafficQueue.js",
"src/data/trailRenderer.js",
"src/data/vesselLabels.js",
"src/data/vesselTypes.js",
"src/firstRunExperience.js",
"src/hud.js",
"src/hudLocality.js",
@@ -2197,6 +2189,7 @@
"src/ui/panelLayoutController.js",
"src/ui/panelMeasurement.js",
"src/ui/panelPositionControls.js",
"src/ui/panelResize.js",
"src/ui/panelRailGeometry.js",
"src/ui/panelRails.js",
"src/ui/radio.js",
@@ -2222,7 +2215,6 @@
"src/voice/commands.js",
"src/voice/control.js",
"src/voice/gevActions.js",
"src/agent/worldPacket.js",
"src/voice/speech.js",
"src/ui/layerNames.js",
"src/voice/earcon.js",
@@ -2416,22 +2408,24 @@
"src/ui/streetLevelControls.js",
"src/ui/streetLevelPresentation.js",
"src/layers/streetLevel/index.js",
"src/layers/streetLevel/providers/mapillary/index.js",
"src/layers/streetLevel/viewerHost.js",
"src/layers/streetLevel/credits.js",
"src/layers/streetLevel/cameraFollow.js",
"src/layers/streetLevel/marker.js",
"src/layers/streetLevel/view.js",
"src/layers/streetLevel/uiState.js",
"src/layers/streetLevel/groundCast.js",
"src/layers/streetLevel/meshSampler.js",
"src/layers/streetLevel/pickRouter.js",
"src/layers/streetLevel/params.js",
"src/layers/streetLevel/registry.js",
"scripts/qa-street-level.mjs",
"src/layers/streetLevel/credits.js",
"src/layers/streetLevel/providers/mapillary/index.js",
"src/layers/streetLevel/viewerHost.js",
"src/layers/streetLevel/cameraFraming.js",
"src/layers/streetLevel/marker.js",
"src/layers/streetLevel/view.js",
"src/layers/streetLevel/filter.js",
"src/layers/streetLevel/policy.js",
"src/layers/streetLevel/state.js",
"src/layers/streetLevel/lineCoverage.js",
"src/layers/streetLevel/sequenceCones.js",
"src/layers/streetLevel/providers/mapillary/policy.js",
"src/layers/streetLevel/providers/mapillary/coverage.js",
"src/layers/streetLevel/providers/mapillary/sequences.js",
"src/layers/streetLevel/glyphs.js",
"src/layers/streetLevel/providers/mapillary/decode.js",
"src/layers/streetLevel/providers/mapillary/coverageFixture.mjs",
@@ -2458,6 +2452,8 @@
"src/layers/streetLevel/imageSize.js",
"src/layers/streetLevel/tileCache.js",
"src/layers/streetLevel/panorama.js",
"src/layers/streetLevel/lineCoverage.js",
"src/layers/streetLevel/sequenceCones.js",
"src/layers/streetLevel/providers/mapillary/source.js",
"src/app/layers/streetLevel.js",
"src/layers/alpr/tileRecords.js",
@@ -2470,6 +2466,7 @@
"src/data/cameraArrival.js",
"src/layers/alpr/floors.js",
"src/data/militaryNames.js",
"src/sources/requestDeadline.js",
"src/data/local_data/osm_military_names/names.json",
"src/layers/installations/namedMarkers.js",
"src/data/infrastructureOverlayEntry.js",
@@ -2493,13 +2490,7 @@
"src/layers/airQuality/index.js",
"src/layers/airQuality/model.js",
"src/layers/airQuality/source.js",
"src/ui/statusText.js",
"src/layers/streetLevel/providers/mapillary/policy.js",
"src/voice/providerCommands.js",
"src/voice/providerCapabilities.js",
"src/voice/geminiSession.js",
"src/voice/geminiAudio.js",
"src/voice/geminiCaptureWorklet.js"
"src/ui/statusText.js"
],
"external": [
"hls.js",
@@ -2638,7 +2629,6 @@
"src/data/trafficQueue.js",
"src/data/trailRenderer.js",
"src/data/vesselLabels.js",
"src/data/vesselTypes.js",
"src/keylessGeocoder.js",
"src/layers/aircraft/classification.js",
"src/layers/alpr/index.js",
@@ -2994,22 +2984,24 @@
"src/layers/recentImagery/thumbnails.js",
"src/maps/imageryHost.js",
"src/layers/streetLevel/index.js",
"src/layers/streetLevel/providers/mapillary/index.js",
"src/layers/streetLevel/viewerHost.js",
"src/layers/streetLevel/credits.js",
"src/layers/streetLevel/cameraFollow.js",
"src/layers/streetLevel/marker.js",
"src/layers/streetLevel/view.js",
"src/layers/streetLevel/uiState.js",
"src/layers/streetLevel/groundCast.js",
"src/layers/streetLevel/meshSampler.js",
"src/layers/streetLevel/pickRouter.js",
"src/layers/streetLevel/params.js",
"src/layers/streetLevel/registry.js",
"scripts/qa-street-level.mjs",
"src/layers/streetLevel/credits.js",
"src/layers/streetLevel/providers/mapillary/index.js",
"src/layers/streetLevel/viewerHost.js",
"src/layers/streetLevel/cameraFraming.js",
"src/layers/streetLevel/marker.js",
"src/layers/streetLevel/view.js",
"src/layers/streetLevel/filter.js",
"src/layers/streetLevel/policy.js",
"src/layers/streetLevel/state.js",
"src/layers/streetLevel/lineCoverage.js",
"src/layers/streetLevel/sequenceCones.js",
"src/layers/streetLevel/providers/mapillary/policy.js",
"src/layers/streetLevel/providers/mapillary/coverage.js",
"src/layers/streetLevel/providers/mapillary/sequences.js",
"src/layers/streetLevel/glyphs.js",
"src/layers/streetLevel/providers/mapillary/decode.js",
"src/layers/streetLevel/providers/mapillary/coverageFixture.mjs",
@@ -3036,6 +3028,8 @@
"src/layers/streetLevel/imageSize.js",
"src/layers/streetLevel/tileCache.js",
"src/layers/streetLevel/panorama.js",
"src/layers/streetLevel/lineCoverage.js",
"src/layers/streetLevel/sequenceCones.js",
"src/layers/streetLevel/providers/mapillary/source.js",
"src/app/layers/streetLevel.js",
"src/layers/alpr/tileRecords.js",
@@ -3048,6 +3042,7 @@
"src/data/cameraArrival.js",
"src/layers/alpr/floors.js",
"src/data/militaryNames.js",
"src/sources/requestDeadline.js",
"src/data/local_data/osm_military_names/names.json",
"src/layers/installations/namedMarkers.js",
"src/data/infrastructureOverlayEntry.js",
@@ -3065,8 +3060,7 @@
"src/layers/airQuality/model.js",
"src/layers/airQuality/source.js",
"src/ui/statusText.js",
"src/ui/layerNames.js",
"src/layers/streetLevel/providers/mapillary/policy.js"
"src/ui/layerNames.js"
],
"external": [
"hls.js",
@@ -3230,6 +3224,7 @@
"src/ui/panelLayoutController.js",
"src/ui/panelMeasurement.js",
"src/ui/panelPositionControls.js",
"src/ui/panelResize.js",
"src/ui/panelRailGeometry.js",
"src/ui/panelRails.js",
"src/ui/rightPanelRail.js",
@@ -3382,8 +3377,7 @@
"src/i18n/locale.js",
"src/i18n/locales/es.json",
"src/i18n/locales/ru.json",
"src/ui/statusText.js",
"src/voice/providerCapabilities.js"
"src/ui/statusText.js"
],
"external": []
},
@@ -3788,7 +3782,6 @@
"src/tools/queries/maritime.js",
"src/tools/queries/share.js",
"src/data/idSwitches.js",
"src/data/vesselTypes.js",
"src/data/layerState.js",
"src/data/layerStateTokenReservations.json",
"src/sources/gbfsStations.js",
@@ -3950,18 +3943,5 @@
"src/i18n/marker.js"
],
"external": []
},
"agent-world-packet": {
"exports": ["./agent/world-packet"],
"modules": [
"src/agent/worldPacket.js",
"src/data/layerSnapshot.js",
"src/data/feedState.js",
"src/contextModePolicy.js",
"src/loadingFeedback.js",
"src/data/installationFeedback.js",
"src/i18n/marker.js"
],
"external": ["cesium"]
}
}
+2 -2
View File
@@ -7,10 +7,10 @@ import fs from 'node:fs/promises';
import path from 'node:path';
/** Console messages and failed requests kept per page. */
const EVIDENCE_LIMIT = 200;
export const EVIDENCE_LIMIT = 200;
/** Cesium's console line when the render loop throws (CesiumWidget). */
const RENDER_ERROR_PATTERN = /An error occurred while rendering/i;
export const RENDER_ERROR_PATTERN = /An error occurred while rendering/i;
/** Mask tokens in a URL or message before it is printed or saved. */
export function redact(text) {
+481
View File
@@ -0,0 +1,481 @@
#!/usr/bin/env node
/**
* Browser QA for the floating CCTV panel: lift, resize from every handle,
* minimum size, reload, and dock again. `--fail-on-retry` (or
* QA_FAIL_ON_RETRY=1) fails on any header-press retry.
*/
import assert from 'node:assert/strict';
import { pathToFileURL } from 'node:url';
import { RESIZE_DIRECTIONS, resizeBox } from '../src/ui/panelResize.js';
import {
describePress,
dockPanelByDoubleClick,
dragPointer,
liftPanelByHeader,
nextFrames,
} from './qa-panelDrag.mjs';
import {
hookRenderErrors,
readRenderErrors,
saveFailureArtifacts,
watchPage,
} from './qa-browserEvidence.mjs';
export const PANEL_ID = 'cctv-panel';
export const STORAGE_KEY = `godsEyeView.v8.panelPos.${PANEL_ID}`;
export const MIN_SIZE = { width: 300, height: 160 };
export const ARTIFACT_DIR = 'qa-artifacts/panel-resize';
const VIEWPORT = { width: 1400, height: 900 };
const EDGE_BY_SIDE = { n: 'top', s: 'bottom', e: 'right', w: 'left' };
/** Box edges a resize in `dir` must leave exactly where they were. */
export function pinnedEdges(dir) {
return Object.entries(EDGE_BY_SIDE)
.filter(([side]) => !dir.includes(side))
.map(([, edge]) => edge);
}
/** Pointer travel that grows a panel by `amount` on every edge named in `dir`. */
export function growthDelta(dir, amount) {
return {
dx: dir.includes('e') ? amount : dir.includes('w') ? -amount : 0,
dy: dir.includes('s') ? amount : dir.includes('n') ? -amount : 0,
};
}
/**
* Viewport point inside the handle for `dir`, given the panel's client rect.
* Edge strips straddle the border by 3px; corners and the grip sit inside.
*/
export function handlePoint(rect, dir) {
const inset = dir.length === 2 ? 4 : 1;
const x = dir.includes('w')
? rect.left + inset
: dir.includes('e')
? rect.right - inset
: rect.left + rect.width / 2;
const y = dir.includes('n')
? rect.top + inset
: dir.includes('s')
? rect.bottom - inset
: rect.top + rect.height / 2;
return { x, y };
}
/** Pinned edges that moved during a resize in `dir`. */
export function driftedEdges(before, after, dir, tolerance = 1.5) {
const edges = (box) => ({
left: box.left,
top: box.top,
right: box.left + box.width,
bottom: box.top + box.height,
});
const a = edges(before);
const b = edges(after);
return pinnedEdges(dir).filter(
(edge) => Math.abs(a[edge] - b[edge]) > tolerance,
);
}
function near(actual, expected, tolerance, label) {
assert.ok(
Math.abs(actual - expected) <= tolerance,
`${label}: expected ${expected} ± ${tolerance}, got ${actual}`,
);
}
async function main() {
const { default: puppeteer } = await import('puppeteer');
const args = process.argv.slice(2);
const urlIndex = args.indexOf('--url');
const url = urlIndex >= 0 ? args[urlIndex + 1] : 'http://localhost:4173';
if (args.includes('--fail-on-retry')) process.env.QA_FAIL_ON_RETRY = '1';
const browser = await puppeteer.launch({
headless: true,
executablePath:
process.env.PUPPETEER_EXECUTABLE_PATH ||
(await puppeteer.executablePath()),
args: ['--no-sandbox', '--use-gl=angle', '--use-angle=swiftshader'],
});
const monitors = [];
try {
const page = await browser.newPage();
await page.setViewport(VIEWPORT);
const errors = [];
monitors.push(watchPage(page, { name: 'panel', errors }));
await hookRenderErrors(page);
const boot = async () => {
await page.goto(`${url}/`, { waitUntil: 'domcontentloaded' });
await page.waitForFunction(() => window.__godsEyeView?.viewer, {
timeout: 90_000,
});
await page.waitForSelector(`#${PANEL_ID} .panel-header`);
await page.evaluate(() => {
document.querySelector('.first-run-explore')?.click();
});
await page.keyboard.press('Escape');
await page.evaluate(() => {
document.getElementById('first-run-launcher')?.remove();
for (const node of document.querySelectorAll('[class*=first-run]'))
node.remove();
});
await page.mouse.click(VIEWPORT.width / 2, VIEWPORT.height / 2);
await nextFrames(page);
};
const readPanel = () =>
page.evaluate((id) => {
const panel = document.getElementById(id);
const rect = panel.getBoundingClientRect();
return {
left: rect.left,
top: rect.top,
width: rect.width,
height: rect.height,
// handlePoint() reads the far edges for s/e handles.
right: rect.right,
bottom: rect.bottom,
floating: panel.classList.contains('panel-floating'),
collapsed: panel.classList.contains('collapsed'),
inRail: panel.parentElement?.id === 'right-context-rail',
allocated: panel.style.getPropertyValue(
'--right-panel-allocated-height',
),
inlineWidth: panel.style.width,
inlineHeight: panel.style.height,
stored: localStorage.getItem(`godsEyeView.v8.panelPos.${id}`),
};
}, PANEL_ID);
/**
* Best effort: wait for the box to hold still for half a second. The lift
* is checked against the box at pointerdown, so a timeout only costs time.
*/
const settle = async () => {
try {
await page.waitForFunction(
(id) => {
const r = document.getElementById(id).getBoundingClientRect();
const box = [r.left, r.top, r.width, r.height]
.map(Math.round)
.join(',');
const now = performance.now();
if (window.__qaLastBox !== box) {
window.__qaLastBox = box;
window.__qaSince = now;
}
return now - window.__qaSince >= 500;
},
{ polling: 100, timeout: 20_000 },
PANEL_ID,
);
} catch (error) {
if (error?.name !== 'TimeoutError') throw error;
console.log('note: the CCTV panel was still moving; lifting anyway');
}
};
const headerPoint = async () => {
const rect = await page.evaluate((id) => {
const title = document.querySelector(
`#${id} .panel-header .panel-title`,
);
const r = title.getBoundingClientRect();
return { x: r.left + r.width / 2, y: r.top + r.height / 2 };
}, PANEL_ID);
return rect;
};
const drag = (from, dx, dy) => dragPointer(page, from, dx, dy);
await page.goto(`${url}/`, { waitUntil: 'domcontentloaded' });
await page.evaluate((key) => {
localStorage.removeItem(key);
localStorage.removeItem('godsEyeView.v8.panelFloatHintShown');
}, STORAGE_KEY);
await boot();
await page.evaluate((id) => {
const panel = document.getElementById(id);
if (panel.classList.contains('collapsed'))
document
.querySelector(`.panel-collapse-btn[data-collapse-target="${id}"]`)
.click();
}, PANEL_ID);
await page.waitForFunction(
(id) => !document.getElementById(id).classList.contains('collapsed'),
{},
PANEL_ID,
);
// The rail animates panel heights, and on a slow software renderer the
// panels above CCTV can still be moving after a fixed sleep.
await settle();
const docked = await readPanel();
assert.equal(docked.floating, false);
assert.equal(docked.inRail, true);
// Names whatever above CCTV in the rail shifted it, for a retry note.
const railAbove = () =>
page.evaluate((id) => {
const out = [];
for (const node of document.getElementById('right-context-rail')
?.children || []) {
if (node.id === id) break;
out.push(
`${node.id || node.className}:${Math.round(node.offsetHeight)}`,
);
}
return out.join(' ');
}, PANEL_ID);
const aboveAtDock = await railAbove();
// On CI a panel above CCTV grows after load and can shift the header out
// from under the press; the helper retries only such a provable miss.
const attempt = await liftPanelByHeader(page, PANEL_ID, {
dx: -200,
dy: -100,
note: async () =>
`rail above CCTV ${aboveAtDock} -> ${await railAbove()}`,
});
const { pressed } = attempt;
const lifted = await readPanel();
assert.equal(
lifted.floating,
true,
`a header drag lifts the panel out (${describePress(attempt)}; panel docked at ${docked.top}, at press ${pressed?.top})`,
);
assert.equal(
lifted.collapsed,
false,
'the drag-ending click must not collapse',
);
const moved = `docked at ${docked.left},${docked.top}; pressed at ${pressed.left},${pressed.top}`;
near(
lifted.left,
Math.max(6, pressed.left - 200),
2,
`lifted left (${moved})`,
);
near(
lifted.top,
Math.max(6, pressed.top - 100),
2,
`lifted top (${moved})`,
);
near(lifted.width, pressed.width, 2, 'lifted width');
// The rail lets go of the lifted panel on its next layout pass.
await page
.waitForFunction(
(id) =>
document
.getElementById(id)
.style.getPropertyValue('--right-panel-allocated-height') === '',
{ timeout: 5_000 },
PANEL_ID,
)
.catch(() => {});
const afterLayout = await readPanel();
assert.equal(afterLayout.allocated, '', 'the rail no longer allocates it');
const railExcludes = await page.evaluate((id) => {
const rail = document.getElementById('right-context-rail');
// Same rule as the rail: hidden panels take no room.
const counted = [...rail.children].filter((panel) =>
panel.matches(
'[data-panel-id]:not(.panel-floating):not(.collapsed):not([hidden])',
),
);
return {
expandedCount: rail.dataset.expandedCount,
counted: counted.length,
includesPanel: counted.some((panel) => panel.id === id),
};
}, PANEL_ID);
assert.equal(railExcludes.includesPanel, false);
if (railExcludes.expandedCount !== undefined)
assert.equal(Number(railExcludes.expandedCount), railExcludes.counted);
const storedAfterLift = JSON.parse(afterLayout.stored);
assert.equal(storedAfterLift.floating, true);
assert.equal(
'height' in storedAfterLift,
false,
'a drag alone must not freeze the measured height',
);
console.log('PASS: header drag lifts CCTV out of the rail');
// Park near the top: the voice dock would cover the south corners once
// the loop below has grown the window.
{
const header = await headerPoint();
await drag(header, 0, 120 - header.y);
}
const limits = {
minWidth: MIN_SIZE.width,
minHeight: MIN_SIZE.height,
viewportWidth: VIEWPORT.width,
viewportHeight: VIEWPORT.height,
};
for (const dir of RESIZE_DIRECTIONS) {
const before = await readPanel();
const { dx, dy } = growthDelta(dir, 40);
const point = handlePoint(before, dir);
const hit = await page.evaluate(({ x, y }) => {
const node = document.elementFromPoint(x, y);
if (!node) return 'nothing';
const dirAttr = node.dataset?.dir
? `[data-dir=${node.dataset.dir}]`
: '';
return `${node.tagName.toLowerCase()}${node.id ? `#${node.id}` : ''}.${[...node.classList].join('.')}${dirAttr}`;
}, point);
await drag(point, dx, dy);
const after = await readPanel();
const expected = resizeBox(before, dir, dx, dy, limits);
for (const key of ['left', 'top', 'width', 'height'])
near(after[key], expected[key], 2, `${dir} ${key} (pointer on ${hit})`);
assert.deepEqual(driftedEdges(before, after, dir, 2), [], `${dir} pins`);
}
console.log('PASS: eight resize handles keep the opposite edge pinned');
let before = await readPanel();
await drag(handlePoint(before, 'se'), -2000, -2000);
let after = await readPanel();
near(after.width, MIN_SIZE.width, 2, 'min width (se)');
near(after.height, MIN_SIZE.height, 2, 'min height (se)');
assert.deepEqual(driftedEdges(before, after, 'se', 2), []);
before = after;
await drag(handlePoint(before, 'nw'), 2000, 2000);
after = await readPanel();
near(after.width, MIN_SIZE.width, 2, 'min width (nw)');
near(after.height, MIN_SIZE.height, 2, 'min height (nw)');
assert.deepEqual(driftedEdges(before, after, 'nw', 2), []);
console.log('PASS: minimum size holds from both corners');
const saved = await readPanel();
const record = JSON.parse(saved.stored);
assert.equal(record.floating, true);
await boot();
const restored = await readPanel();
assert.equal(restored.floating, true, 'floating survives a reload');
for (const key of ['left', 'top', 'width', 'height'])
near(restored[key], record[key], 2, `restored ${key}`);
console.log('PASS: reload restores position and size');
// A collapsed window keeps the height chosen for it, through a drag of
// its header strip and a reload.
const setCollapsed = async (collapsed) => {
await page.evaluate(
(id, want) => {
const panel = document.getElementById(id);
if (panel.classList.contains('collapsed') !== want)
document
.querySelector(
`.panel-collapse-btn[data-collapse-target="${id}"]`,
)
.click();
},
PANEL_ID,
collapsed,
);
await page.waitForFunction(
(id, want) =>
document.getElementById(id).classList.contains('collapsed') === want,
{},
PANEL_ID,
collapsed,
);
await nextFrames(page);
};
// Grow it from the corner once the restored window holds still, with the
// pointer really on the handle (a press beside it selects page text).
let chosen = await readPanel();
const tried = [];
for (
let attempt = 0;
attempt < 3 && chosen.height <= MIN_SIZE.height + 150;
attempt++
) {
await settle();
const box = await readPanel();
const corner = handlePoint(box, 'se');
const hit = await page.evaluate(({ x, y }) => {
const node = document.elementFromPoint(x, y);
return {
dir: node?.dataset?.dir || null,
target: node
? `${node.tagName.toLowerCase()}${node.id ? `#${node.id}` : ''}.${[...node.classList].join('.')}`
: 'nothing',
};
}, corner);
tried.push(
`${Math.round(corner.x)},${Math.round(corner.y)} on ${hit.target} (box ${Math.round(box.left)},${Math.round(box.top)} ${Math.round(box.width)}x${Math.round(box.height)})`,
);
if (hit.dir === 'se') await drag(corner, 0, 200);
chosen = await readPanel();
}
assert.ok(
chosen.height > MIN_SIZE.height + 150,
`the window grew to ${chosen.height}px; tried ${tried.join('; ')}`,
);
await setCollapsed(true);
const strip = await readPanel();
assert.ok(strip.height < chosen.height - 100, 'collapsed to its header');
await drag(await headerPoint(), 40, 30);
const movedStrip = await readPanel();
assert.equal(movedStrip.floating, true, 'the strip stays a window');
near(
JSON.parse(movedStrip.stored).height,
chosen.height,
2,
'stored height while collapsed',
);
await boot();
await setCollapsed(false);
near(
(await readPanel()).height,
chosen.height,
2,
'height after expanding',
);
console.log(
'PASS: a collapsed window keeps its height through a drag and a reload',
);
const dock = await dockPanelByDoubleClick(page, PANEL_ID);
const snapped = await readPanel();
assert.equal(
snapped.floating,
false,
`double-click snaps back (${describePress(dock)})`,
);
assert.equal(snapped.inRail, true);
assert.equal(snapped.inlineWidth, '');
assert.equal(snapped.inlineHeight, '');
assert.equal(snapped.stored, null, 'the storage key is gone');
console.log('PASS: header double-click returns the panel to the rail');
const renderErrors = await readRenderErrors(page);
assert.deepEqual(
[...errors, ...renderErrors.map((text) => `scene.renderError: ${text}`)],
[],
'no page errors and no Cesium render-loop errors',
);
console.log('PASS: no page errors and no render-loop errors');
} catch (error) {
const dir = await saveFailureArtifacts({
dir: ARTIFACT_DIR,
browser,
monitors,
error,
}).catch(() => null);
if (dir) console.error(`failure evidence saved in ${dir}/`);
throw error;
} finally {
await browser.close();
}
}
if (
process.argv[1] &&
import.meta.url === pathToFileURL(process.argv[1]).href
) {
main().catch((error) => {
console.error(error.stack || error.message);
process.exit(1);
});
}
+319
View File
@@ -0,0 +1,319 @@
/**
* Header press-and-verify for the QA gates that lift a rail panel into a
* floating window and dock it again. The rail animates panel heights, so a
* point read once can be under another panel by the time the pointer goes
* down. Only a provable miss (a recorded press outside the header) is
* retried, with a GitHub `::warning::`. With QA_FAIL_ON_RETRY=1 a miss the
* panel's own movement cannot explain fails the run instead.
*/
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
/** Retries after the first attempt, and the pause before each. */
export const PRESS_RETRIES = 2;
const RETRY_PAUSE_MS = 1000;
/** Whether a retry fails the run (QA_FAIL_ON_RETRY=1 or --fail-on-retry). */
export function failOnRetryDefault(
env = process.env,
argv = process.argv.slice(2),
) {
return env.QA_FAIL_ON_RETRY === '1' || argv.includes('--fail-on-retry');
}
/**
* A failed attempt whose press landed outside the header. No recorded press
* is a failure, not a miss: a retry would only hide it.
* @param {{done: boolean, pressed: {inHeader: boolean}|null}} attempt
*/
export function pressMissed(attempt) {
return !attempt.done && attempt.pressed?.inHeader === false;
}
/** Whether the panel's box changed by more than 2 px between two reads. */
export function panelMoved(before, after) {
if (!before || !after) return false;
return ['left', 'top', 'width', 'height'].some(
(key) => Math.abs((after[key] ?? NaN) - (before[key] ?? NaN)) > 2,
);
}
/** The panel's box, rounded, as `left,top widthxheight`. */
export function formatBox(box) {
if (!box) return 'unknown';
const r = (value) => Math.round(value ?? NaN);
return `${r(box.left)},${r(box.top)} ${r(box.width)}x${r(box.height)}`;
}
function panelBox(page, panelId) {
return page.evaluate((id) => {
const r = document.getElementById(id).getBoundingClientRect();
return { left: r.left, top: r.top, width: r.width, height: r.height };
}, panelId);
}
export function headerPoint(page, panelId) {
return page.evaluate((id) => {
const title = document.querySelector(`#${id} .panel-header .panel-title`);
const r = title.getBoundingClientRect();
return { x: r.left + r.width / 2, y: r.top + r.height / 2 };
}, panelId);
}
/** Name the element at a point, and whether it is the panel's header. */
export function hitAt(page, point, panelId) {
return page.evaluate(
({ x, y, id }) => {
const node = document.elementFromPoint(x, y);
if (!node) return { target: 'nothing', inHeader: false };
return {
target: `${node.tagName.toLowerCase()}${node.id ? `#${node.id}` : ''}.${[...node.classList].join('.')}`,
inHeader: Boolean(node.closest(`#${id} .panel-header`)),
};
},
{ ...point, id: panelId },
);
}
/**
* Re-resolve the header point until it hits the header or the wait runs out
* (the press then records the miss).
*/
export async function headerTarget(
page,
panelId,
{ tries = 20, intervalMs = 500 } = {},
) {
let point = await headerPoint(page, panelId);
let hit = await hitAt(page, point, panelId);
for (let i = 0; i < tries && !hit.inHeader; i++) {
await delay(intervalMs);
point = await headerPoint(page, panelId);
hit = await hitAt(page, point, panelId);
}
const box = await panelBox(page, panelId);
return { point, hit, timedOut: !hit.inHeader, box };
}
/**
* Best effort: wait until the panel's box has held still for `stillMs`, so
* the header does not move between the hit check and the press.
*/
export async function waitForStill(
page,
panelId,
{ stillMs = 300, timeout = 5000 } = {},
) {
try {
await page.waitForFunction(
(id, ms) => {
const r = document.getElementById(id).getBoundingClientRect();
const box = [r.left, r.top, r.width, r.height].map(Math.round).join();
const now = performance.now();
if (window.__qaStillBox !== box) {
window.__qaStillBox = box;
window.__qaStillSince = now;
}
return now - window.__qaStillSince >= ms;
},
{ polling: 100, timeout },
panelId,
stillMs,
);
return true;
} catch (error) {
if (error?.name !== 'TimeoutError') throw error;
return false;
}
}
/** Record the next pointerdown's target and the panel's box at that moment. */
export function recordNextPress(page, panelId) {
return page.evaluate((id) => {
window.__qaPressBox = null;
window.addEventListener(
'pointerdown',
(event) => {
const r = document.getElementById(id).getBoundingClientRect();
const target = event.target;
window.__qaPressBox = {
left: r.left,
top: r.top,
width: r.width,
height: r.height,
target: `${target.tagName?.toLowerCase()}${target.id ? `#${target.id}` : ''}.${[...(target.classList || [])].join('.')}`,
inHeader: Boolean(target.closest?.(`#${id} .panel-header`)),
};
},
{ capture: true, once: true },
);
}, panelId);
}
function readPress(page) {
return page.evaluate(() => window.__qaPressBox ?? null);
}
export async function waitForFloating(
page,
panelId,
floating,
{ timeout = 3000 } = {},
) {
try {
await page.waitForFunction(
(id, want) =>
document.getElementById(id).classList.contains('panel-floating') ===
want,
{ polling: 50, timeout },
panelId,
floating,
);
return true;
} catch (error) {
if (error?.name !== 'TimeoutError') throw error;
return false;
}
}
/** Two paints: by then every layout change an input caused is on screen. */
export function nextFrames(page) {
return page.evaluate(
() =>
new Promise((resolve) =>
requestAnimationFrame(() => requestAnimationFrame(() => resolve())),
),
);
}
export async function dragPointer(page, from, dx, dy, { steps = 6 } = {}) {
await page.mouse.move(from.x, from.y);
await page.mouse.down();
await page.mouse.move(from.x + dx / 2, from.y + dy / 2, { steps });
await page.mouse.move(from.x + dx, from.y + dy, { steps });
await page.mouse.up();
await nextFrames(page);
}
export function describeRetry(
panelId,
attempt,
{ round, retries, extra = '' },
) {
const { hit, pressed, timedOut, still, box } = attempt;
return [
`retry ${round + 1}/${retries}: the press missed the ${panelId} header (on ${pressed?.target ?? hit.target})`,
`pre-press hit check ${timedOut ? `timed out (on ${hit.target})` : 'passed'}`,
`stillness ${still === false ? 'timed out' : 'held'}`,
`panel box at hit check ${formatBox(box)} -> at press ${formatBox(pressed)}`,
]
.concat(extra ? [extra] : [])
.join('; ');
}
/** Retry `press` only while it provably misses the header. */
async function pressUntilDecided(
page,
panelId,
press,
{
retries,
log,
note,
pauseMs = RETRY_PAUSE_MS,
failOnRetry = failOnRetryDefault(),
},
) {
for (let round = 0; ; round++) {
const still = await waitForStill(page, panelId);
await recordNextPress(page, panelId);
const { point, hit, timedOut, box } = await headerTarget(page, panelId);
const done = await press(point);
const attempt = {
point,
hit,
timedOut,
still,
box,
done,
pressed: await readPress(page),
retries: round,
};
if (round >= retries || !pressMissed(attempt)) return attempt;
const why = describeRetry(panelId, attempt, {
round,
retries,
extra: note ? await note() : '',
});
if (failOnRetry && !panelMoved(box, attempt.pressed))
throw new Error(
`QA_FAIL_ON_RETRY=1 turns this retry into a failure: ${why}`,
);
log(`note: ${why}; retrying`);
log(`::warning title=Panel press retried::${why}`);
await delay(pauseMs);
}
}
/**
* Lift a docked panel into a floating window with a header drag.
* @returns {Promise<{point: {x: number, y: number}, hit: {target: string, inHeader: boolean}, done: boolean, pressed: object|null}>}
*/
export function liftPanelByHeader(
page,
panelId,
{
dx,
dy,
retries = PRESS_RETRIES,
log = console.log,
note,
pauseMs,
failOnRetry,
} = {},
) {
return pressUntilDecided(
page,
panelId,
async (point) => {
await dragPointer(page, point, dx, dy);
return waitForFloating(page, panelId, true);
},
{ retries, log, note, pauseMs, failOnRetry },
);
}
/** Dock a floating panel with a header double-click; same result as lifting. */
export function dockPanelByDoubleClick(
page,
panelId,
{
retries = PRESS_RETRIES,
log = console.log,
note,
pauseMs,
failOnRetry,
} = {},
) {
return pressUntilDecided(
page,
panelId,
async (point) => {
await page.mouse.click(point.x, point.y, { clickCount: 1 });
await page.mouse.click(point.x, point.y, { clickCount: 2 });
return waitForFloating(page, panelId, false);
},
{ retries, log, note, pauseMs, failOnRetry },
);
}
/** Where an attempt pressed, for an assertion message. */
export function describePress(attempt) {
const { point, hit, pressed } = attempt;
const where = `pressed ${Math.round(point.x)},${Math.round(point.y)}`;
if (!pressed)
return `${where}: no pointerdown reached the page (hit check on ${hit.target}, in header: ${hit.inHeader})`;
const n = attempt.retries || 0;
const retried = n ? `, after ${n} ${n === 1 ? 'retry' : 'retries'}` : '';
return `${where} on ${pressed.target}, in header: ${pressed.inHeader}${retried}`;
}
+2781 -513
View File
File diff suppressed because it is too large. Load diff
+12 -2
View File
@@ -43,7 +43,11 @@ try {
const fpsReadout = document.querySelector('.frame-rate-readout');
const fpsWasVisible = fpsReadout && !fpsReadout.hidden;
const watchObserver = (name) => {
const observer = ui._panelLayout[name];
const owner =
name === '_draggableResizeObserver'
? ui._panelPosition
: ui._panelLayout;
const observer = owner[name];
if (!observer) return false;
const disconnect = observer.disconnect.bind(observer);
counts[name] = 0;
@@ -55,6 +59,7 @@ try {
};
const observed = [
'_commandDockTrayObserver',
'_draggableResizeObserver',
'_leftStackResizeObserver',
'_rightStackResizeObserver',
'_leftStackMutationObserver',
@@ -134,6 +139,7 @@ try {
const focusBefore = document.activeElement;
const stoppedBeforeRestoration =
ui._panelLayout.destroyed &&
ui._panelPosition.destroyed &&
ui._feedback.destroyed &&
ui._recording.destroyed &&
cockpit.destroyed &&
@@ -188,7 +194,11 @@ try {
counts[field] === 1 && ui._contextControls[field] === null,
),
observersReleased: observed.every(
(name) => counts[name] === 1 && ui._panelLayout[name] === null,
(name) =>
counts[name] === 1 &&
(name === '_draggableResizeObserver'
? ui._panelPosition
: ui._panelLayout)[name] === null,
),
subscriptionReleased:
counts.cctv === 1 && ui._cctvControls._cctvUnsubscribe === null,
-9
View File
@@ -25,7 +25,6 @@ import {
loadVegvesenSourcesFromOpenData,
loadNycSourcesFromOpenData,
loadTorontoSourcesFromOpenData,
loadFloridaSourcesFromOpenData,
} from './sources.js';
/** Env kill switch: unset or anything but "0" means enabled. */
@@ -145,14 +144,6 @@ const LIVE_PACKS = [
enabled: () => envOptIn('CCTV_LITHUANIA_ENABLED'),
load: loadLithuaniaSources,
},
{
// FL511's terms limit its content to individual, personal use (other use
// needs FDOT's written consent), so the pack is off unless the operator
// opts in (see DATA_SOURCES.md).
name: 'florida',
enabled: () => envOptIn('CCTV_FLORIDA_ENABLED'),
load: loadFloridaSourcesFromOpenData,
},
];
/**
* Load CCTV sources from a local JSON file (CCTV_SOURCES_FILE env or default).
-21
View File
@@ -113,27 +113,6 @@ export const CALTRANS_ANCHORS = [
{ lat: 32.7157, lon: -117.1611 }, // San Diego
{ lat: 38.5816, lon: -121.4944 }, // Sacramento
];
export const FLORIDA_CCTV_URL =
'https://services.arcgis.com/3wFbqsFPLeKqOlIK/arcgis/rest/services/FL511_Traffic_Cameras/FeatureServer/0/query';
export const FLORIDA_IMAGE_HOSTS = [
'images-dis.divas.cloud',
'snapshots.divas.cloud',
];
export const DEFAULT_FLORIDA_MAX_SOURCES = 500;
/** Byte cap per catalog page (2,000 rows, about 650 KiB measured). */
export const FLORIDA_MAX_CATALOG_BYTES = 2 * 1024 * 1024;
/** Catalog page ceiling: 40 pages covers 4,000+ cameras even at 100 rows a page. */
export const FLORIDA_MAX_CATALOG_PAGES = 40;
export const FLORIDA_ANCHORS = [
{ lat: 25.7617, lon: -80.1918 }, // Miami
{ lat: 28.5384, lon: -81.3789 }, // Orlando
{ lat: 27.9506, lon: -82.4572 }, // Tampa
{ lat: 30.3322, lon: -81.6557 }, // Jacksonville
{ lat: 30.4383, lon: -84.2807 }, // Tallahassee
{ lat: 26.1224, lon: -80.1373 }, // Fort Lauderdale
{ lat: 26.7153, lon: -80.0534 }, // West Palm Beach
{ lat: 30.4213, lon: -87.2169 }, // Pensacola (panhandle)
];
/** TfL JamCams: one keyless list endpoint; frames live on a public S3 bucket. */
export const TFL_JAMCAM_URL = 'https://api.tfl.gov.uk/Place/Type/JamCam';
export const TFL_IMAGE_ORIGIN =
-11
View File
@@ -377,17 +377,6 @@ export function isLikelyCalgaryCoordinate(lat, lon) {
);
}
/** Florida statewide, Key West to Georgia state line, with slack at each edge */
export function isLikelyFloridaCoordinate(lat, lon) {
return (
isPlausibleLatLon(lat, lon) &&
lat >= 24.3 &&
lat <= 31.1 &&
lon >= -87.7 &&
lon <= -79.8
);
}
/** New York City's five boroughs, with slack for the harbor and city line. */
export function isLikelyNycCoordinate(lat, lon) {
return (
-207
View File
@@ -55,12 +55,6 @@ import {
DEFAULT_CALGARY_MAX_SOURCES,
CALGARY_DOWNTOWN,
CALGARY_MAX_CATALOG_BYTES,
FLORIDA_CCTV_URL,
FLORIDA_IMAGE_HOSTS,
DEFAULT_FLORIDA_MAX_SOURCES,
FLORIDA_ANCHORS,
FLORIDA_MAX_CATALOG_BYTES,
FLORIDA_MAX_CATALOG_PAGES,
CATALONIA_CAMERAS_URL,
CATALONIA_MAX_CATALOG_BYTES,
CATALONIA_IMAGE_HOSTS,
@@ -119,7 +113,6 @@ import {
isLikelyTexasCoordinate,
isLikelyNswCoordinate,
isLikelyCalgaryCoordinate,
isLikelyFloridaCoordinate,
isLikelyCataloniaCoordinate,
decodeNumericEntities,
isLikelyNycCoordinate,
@@ -2791,203 +2784,3 @@ export async function loadTorontoSourcesFromOpenData() {
return [];
}
}
/** FL511 DIRECTION codes as headings; "NOT DIRECTIONAL" has none. */
const FLORIDA_DIRECTION_HEADINGS = Object.freeze({
N: 0,
E: 90,
S: 180,
W: 270,
});
/**
* Stable camera id from a frame URL. FL511's ID column repeats across regions
* (ID 1220 is both an I-75 and an I-95 camera), so the image channel is the
* key, as Calgary keys on its frame filename. Falls back to a path slug.
*
* @param {string} imageUrl - A host-pinned FL511 frame URL.
* @returns {?string} Provider-stable id, or null when underivable.
*/
export function floridaCameraId(imageUrl) {
let path;
try {
path = new URL(String(imageUrl ?? '')).pathname;
} catch {
return null;
}
const channel = /\/chan-(\d+)_[a-z]+\.jpg$/i.exec(path);
if (channel) return `fl-${channel[1]}`;
const slug = path
.replace(/^\/+|\.[a-z0-9]+$/gi, '')
.replace(/[^a-zA-Z0-9]+/g, '-')
.toLowerCase();
return slug ? `fl-${slug}` : null;
}
/**
* One FL511 ArcGIS feature -> one catalog source, or null. DIRECTION is a
* one-letter code the free-text parser won't read, so a table maps it.
*
* @param {object} feature - ArcGIS feature from the FL511 camera layer.
* @returns {?object}
*/
export function floridaCameraToSource(feature) {
const a = feature?.attributes;
if (!a || typeof a !== 'object') return null;
const lat = toFiniteNumber(a.LATITUDE);
const lon = toFiniteNumber(a.LONGITUDE);
if (!isLikelyFloridaCoordinate(lat, lon)) return null;
const imageUrl = String(a.IMAGE || '');
let image;
try {
image = new URL(imageUrl);
} catch {
return null;
}
// Official image hosts only, HTTPS, no embedded credentials.
if (
image.protocol !== 'https:' ||
!FLORIDA_IMAGE_HOSTS.includes(image.host) ||
image.username ||
image.password
)
return null;
const cameraId = floridaCameraId(imageUrl);
if (!cameraId) return null;
const heading =
FLORIDA_DIRECTION_HEADINGS[
String(a.DIRECTION || '')
.trim()
.toUpperCase()
];
const hasHeading = Number.isFinite(heading);
return {
id: cameraId,
name: String(a.DESCRIPT || '').trim() || `FL511 ${cameraId.slice(3)}`,
city: String(a.COUNTY ? `${a.COUNTY} County` : 'Florida'),
cityId: 'florida',
provider: 'FL511',
lat,
lon,
headingDeg: hasHeading ? heading : fallbackHeadingFromId(cameraId),
headingConfidence: hasHeading ? 'high' : 'low',
pitchDeg: hasHeading ? -24 : -18,
fovDeg: hasHeading ? 56 : 44,
rangeM: hasHeading ? 210 : 145,
mountHeightM: hasHeading ? 10 : 8,
groundElevationM: 5, // Coastal prior; the client's ground snap corrects.
feedType: 'image',
url: imageUrl,
snapshotUrl: imageUrl,
sourceKind: 'fl511-open-data',
license:
'FL511 (FDOT), individual personal use only; other use needs FDOT written consent',
};
}
/**
* Fetch FL511 (Florida DOT) cameras, keyless: an ArcGIS FeatureServer paged
* up to 2,000 rows at a time, ordered by the layer's object id so offsets are
* stable. Paging follows `exceededTransferLimit` and advances by the rows each
* page returned, so a lower server-side max record count still reads the whole
* layer. Frames are stills on the Divas Cloud hosts. A redirect on any page
* drops the pack; any other failed page keeps the rows already read.
*
* @returns {Promise<Array<object>>} Normalized camera source objects.
*/
export async function loadFloridaSourcesFromOpenData() {
try {
const cameras = [];
const seen = new Set();
const pageSize = 2000;
let offset = 0;
// Page ceiling, in case the service ignores resultOffset.
for (let page = 0; ; page += 1) {
if (page >= FLORIDA_MAX_CATALOG_PAGES) {
console.warn(
`[CCTV] Florida catalog stopped at the ${FLORIDA_MAX_CATALOG_PAGES}-page ceiling; the catalog may be incomplete`,
);
break;
}
// OBJECTID_1 is this layer's object id field (a plain OBJECTID is a 400).
const url = `${FLORIDA_CCTV_URL}?where=1%3D1&outFields=*&orderByFields=OBJECTID_1&f=json&resultRecordCount=${pageSize}&resultOffset=${offset}`;
let resp;
try {
resp = await fetch(url, {
headers: { Accept: 'application/json' },
redirect: 'manual',
signal: AbortSignal.timeout(CCTV_SOURCE_FETCH_TIMEOUT_MS),
});
} catch (error) {
console.warn(
'[CCTV] Florida catalog page failed:',
error?.message || error,
);
break;
}
// Release the body on every path that will not read it.
const discard = async () => {
try {
await resp.body?.cancel();
} catch {
/* no-op */
}
};
if (resp.status >= 300 && resp.status < 400) {
console.warn(
'[CCTV] Florida catalog redirected; redirects are not followed',
);
await discard();
return [];
}
if (!resp.ok) {
console.warn('[CCTV] Florida camera download failed:', resp.status);
await discard();
break;
}
let body;
try {
body = await readResponseJsonCapped(resp, FLORIDA_MAX_CATALOG_BYTES);
} catch (error) {
console.warn(
'[CCTV] Florida catalog page unreadable:',
error?.message || error,
);
break;
}
const feats = Array.isArray(body?.features) ? body.features : [];
for (const feature of feats) {
const camera = floridaCameraToSource(feature);
if (!camera || seen.has(camera.id)) continue;
seen.add(camera.id);
cameras.push(camera);
}
// ArcGIS sets exceededTransferLimit while more rows remain. A page may
// be shorter than pageSize when the layer's max record count is lower.
if (body?.exceededTransferLimit !== true || feats.length === 0) break;
offset += feats.length;
}
const maxRaw = Number(
process.env.CCTV_FLORIDA_MAX_SOURCES || DEFAULT_FLORIDA_MAX_SOURCES,
);
const maxCount = Number.isFinite(maxRaw)
? Math.max(8, Math.min(4200, Math.floor(maxRaw)))
: DEFAULT_FLORIDA_MAX_SOURCES;
const prioritized = prioritizeSources(cameras, maxCount, FLORIDA_ANCHORS);
console.log(
`[CCTV] Loaded Florida camera sources: ${cameras.length} (using nearest ${prioritized.length})`,
);
return prioritized;
} catch (error) {
console.warn(
'[CCTV] Florida camera download error:',
error?.message || error,
);
return [];
}
}
Loaded 100 of 289 files, more files were not shown because too many files have changed in this diff. Show more