Compare commits

..
676 changed files with 3309 additions and 114308 deletions

No files matched your search

+1 -36
View File
@@ -218,7 +218,6 @@ AISSTREAM_API_KEY=
# CCTV_TFL_MAX_SOURCES=250 # cap cameras loaded from TfL
# CCTV_ONTARIO_ENABLED=1 # set 0 to disable Ontario 511 cameras
# ONTARIO_511_API_KEY= # free developer key: sign up at 511on.ca/my511/register, then request it at 511on.ca/developers/doc
# OTTAWA_CAMERA_CERTIFICATE= # City of Ottawa Traffic Camera Images API certificate (free; request it via the form on traffic.ottawa.ca 'About the data'); without it the Ottawa pack is skipped
# CCTV_ONTARIO_MAX_SOURCES=1000 # cap cameras loaded from Ontario 511
# CCTV_FINTRAFFIC_ENABLED=1 # set 0 to drop the Fintraffic (Finland) pack
# CCTV_FINTRAFFIC_MAX_SOURCES=300 # cap cameras loaded from the Fintraffic catalog
@@ -239,8 +238,6 @@ AISSTREAM_API_KEY=
# CCTV_CALGARY_ROWS_URL= # override the Open Calgary catalog URL
# CCTV_CATALONIA_ENABLED=1 # set 0 to disable the Servei Català de Trànsit cameras
# CCTV_CATALONIA_MAX_SOURCES=150 # cap cameras loaded from Catalonia (~130 exist today)
# CCTV_ICELAND_ENABLED=1 # set 0 to disable the Vegagerðin (Iceland) road cameras
# CCTV_ICELAND_MAX_SOURCES=250 # cap cameras loaded from Iceland (~500 views exist today)
# CCTV_HK_ENABLED=1 # set 0 to disable the Hong Kong Transport Department cameras
# CCTV_HK_MAX_SOURCES=1100 # cap cameras loaded from Hong Kong (~1,013 exist today); min 8, max 1200
# CCTV_HK_CAMERAS_URL= # override the DATA.GOV.HK camera locations XML URL
@@ -271,12 +268,7 @@ AISSTREAM_API_KEY=
# CCTV_HOUSTON_CAMERAS_URL= # override the Houston TranStar catalog URL
# CCTV_A22_ENABLED=1 # set 0 to disable the Autostrada del Brennero (A22, Italy) webcams
# CCTV_A22_URL= # override the Open Data Hub A22 webcam catalog URL
# CCTV_OTTAWA_ENABLED=1 # set 0 to skip City of Ottawa cameras (also needs OTTAWA_CAMERA_CERTIFICATE)
# CCTV_OTTAWA_MAX_SOURCES=250 # cap Ottawa cameras (8-500, nearest downtown first)
# CCTV_QUEBEC_ENABLED=1 # set 0 to skip the Québec camera locations (MTQ, Montréal, scenic links)
# CCTV_QUEBEC_VIDEO_ENABLED= # off by default; set 1 to let viewers play Québec 511 clips in their browser after pressing Play (511 terms of use unconfirmed; see DATA_SOURCES.md)
# CCTV_WORLD_CAMERAS_ENABLED=1 # set 0 to skip the Oregon and MLIT (Japan) still cameras
# CCTV_MAX_SOURCES=7350 # catalog-wide ceiling: 4,000 slots shared round-robin, the rest reserved for Québec and World
# 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
@@ -325,30 +317,3 @@ GEMINI_CHAT_MODEL=gemini-3.5-flash-lite
# Each reserves up to 40 stations within the existing 750-station directory.
# Restart after changing. Leave blank for the existing global selection.
# RADIO_PRIORITY_COUNTRIES=IN,PL
# Optional: a news server for the News layer (its /api/news/hotspots, /feed
# and /outline routes), such as https://news.example. The local server
# forwards only the layer's own GET requests there, with a 15 s timeout. Set,
# it wins over the settings below. SERVER-SIDE ONLY.
# GEV_NEWS_ORIGIN=
#
# Without a news server Core reads the News layer itself. SERVER-SIDE ONLY.
# Conflicts need no key: the last 24 h of GDELT events (its 15-minute export
# files, about 100 KB each, about 10 MB a day; violence only, one report per
# story at GDELT's place for it), titled from GDELT's GKG files (about 6 MB
# each, about 23 MB an hour: the newest 3 h are read at start, then each new
# one). Optional: Liveuamap's mapped reports beside them (paid API,
# https://liveuamap.com/promo/api), read once every 5 minutes.
# LIVEUAMAP_API_KEY=
# Local: the day's news from the outlets of the country in view (free plan
# of 50 points a day, https://worldnewsapi.com/console/), read once per
# country every 30 minutes, about 1.5 points a read. Without it Local says
# "key required". Each country's news is kept in memory for at most 1 hour.
# World News API's terms (https://worldnewsapi.com/terms/) allow caching
# only with its prior written permission: get that before setting the key.
# The app's Data attribution popover credits World News API with a link.
# WORLDNEWSAPI_KEY=
# The most World News API reads a day (default 30, about 45 points, inside
# the free plan). After that Local says the daily limit is reached.
# Raise it on a paid plan.
# WORLDNEWSAPI_DAILY_READS=30
-40
View File
@@ -1,40 +0,0 @@
name: Announce merged PRs in Discord
on:
pull_request_target:
types: [closed]
permissions: {}
jobs:
announce:
if: github.repository == 'bilawalsidhu/gods-eye-view' && github.event.pull_request.merged == true
runs-on: ubuntu-latest
timeout-minutes: 2
steps:
# This uses the trusted base workflow and never checks out or runs PR code.
- name: Post to Discord contributions
shell: bash
env:
DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_CONTRIBUTIONS_WEBHOOK_URL }}
run: |
if [[ ! "$DISCORD_WEBHOOK_URL" =~ ^https://discord\.com/api/webhooks/[0-9]+/[A-Za-z0-9_-]+$ ]]; then
echo '::error::Set DISCORD_CONTRIBUTIONS_WEBHOOK_URL to the Discord channel webhook URL.'
exit 1
fi
payload=$(jq -ce '
.pull_request | {
allowed_mentions: {parse: []},
embeds: [{
title: ("Merged #" + (.number | tostring) + ": " + (.title | .[0:115])),
url: .html_url,
footer: {text: ("By " + .user.login + " · into " + (.base.ref | .[0:100]))}
}]
}
' "$GITHUB_EVENT_PATH")
curl --silent --show-error --fail --output /dev/null \
--connect-timeout 10 --max-time 20 --retry 3 --retry-max-time 50 \
--header 'Content-Type: application/json' --data-binary "$payload" \
--url "$DISCORD_WEBHOOK_URL?wait=true"
-4
View File
@@ -2,10 +2,6 @@
## [Unreleased]
- An application's own tool can take the pointer as Draw does, and its click
handlers can yield while a tool holds it, as the layers' do (`pointer` from
`createApplicationTools`) (samehkhamis, #1641).
## [0.3.0] — 2026-10-27 — Street Level, On Your Phone, in Your Language
- Show a Street Level view facing 359.5° or more as 0°. The viewer caption
+1 -1
View File
@@ -128,7 +128,7 @@ prints anything; never fix that by renaming or deleting PR A's published token.
The highest-leverage places to jump in:
- **🌆 Add a CCTV source pack.** Austin is the reference camera source. Follow the [CCTV integration workflow](docs/CCTV_INTEGRATION.md) for source review, provider registration, proxy security, tests, and attribution. The proxy only fetches server-registered URLs — never client-supplied ones; see [SECURITY.md](SECURITY.md).
- **🌆 Add a CCTV source pack.** Austin is the reference camera source. Adding another city means a clean public camera catalog with coordinates, attribution, and server-registered frame URLs (the proxy only fetches registered URLs — never client-supplied ones, see [SECURITY.md](SECURITY.md)). City packs are the best first lane.
- **🛰️ Add or improve a data layer.** Layer factories live in `src/layers/<family>/`, with source, record, controller and renderer owners implementing the layer interface (`init/enable/disable/update/destroy/getStats`, optional `getDetectableObjects`/`getStats`). Use an existing layer as a template, and see [Adding a new layer](#adding-a-new-layer) for what the PR needs.
- **🎙️ Extend voice control.** Voice arguments are defined in `src/voice/actionSchemas.js`, with server-side descriptions in `server/providers/openai/tools.js` and client-side execution in `src/voice/gevActions.js`. Keep the tool surface tight and the responses honest (confirm only what actually happened).
- **🎨 Add a visual style.** Styles are GLSL post-process shaders in `src/styles/`.
+9 -82
View File
@@ -30,9 +30,8 @@ How to read this:
| **adsb.lol point API** | Bounded live-flight fallback when OpenSky has no usable snapshot; GNSS Integrity layer (aircraft-reported NIC/NACp and GPS-loss integrity facts, via `/api/gnss-integrity`, 60 s server cache) | ODbL 1.0 | adsb.lol contributors; `api.adsb.lol/v2/lat/{lat}/lon/{lon}/dist/{radius}` |
| **adsb.lol** | Military flights + aircraft traces | ODbL 1.0 | "adsb.lol" (ODbL) |
| **adsbdb** (`api.adsbdb.com`) | Aircraft type, model name and registration by ICAO hex for enriched flights; airline and origin/destination airports for the tracked flight's callsign | No licence or rate limit is published for the service. Its own credits: aircraft data from PlaneBase, ICAO-to-N-number conversion by Guillaume Michel, and flight route data that "is the work of David Taylor, Edinburgh and Jim Mason, Glasgow, and may not be copied, published, or incorporated into other databases without the explicit permission of David J Taylor, Edinburgh" | "adsbdb — api.adsbdb.com" (courtesy); route data credited to David Taylor, Edinburgh, and Jim Mason, Glasgow |
| **Planespotters.net** (`api.planespotters.net/pub/photos/hex/{hex}`) | One thumbnail photo of the tracked aircraft, by ICAO hex, in the CONTEXT panel's CONTACTS view under the tracked flight's name | [Planespotters photo API terms](https://www.planespotters.net/photo/api); each photo remains the copyright of its photographer. Thumbnails may be hotlinked on the condition that the photographer is named next to the photo and the photo links to its Planespotters page with a followable link | "Photo © {photographer}" beside every photo that names one, linked to its Planespotters page; "Planespotters.net" in Data attribution |
| **AISStream.io** | Live vessels (AIS) | Free, beta, no formal ToS; AIS is a public broadcast | "AISStream.io" (courtesy) |
| **CelesTrak** | Satellite TLEs (SGP4) and SATCAT records (object type, operational status, owner) that file each satellite under a class | US-government-origin data, no license; citation requested | "CelesTrak (celestrak.org), Dr. T.S. Kelso" |
| **CelesTrak** | Satellite TLEs (SGP4) | US-government-origin data, no license; citation requested | "CelesTrak (celestrak.org), Dr. T.S. Kelso" |
| **The Space Devs — Launch Library 2 v2.3** | Recent launch, payload, stage, and recovery metadata for Space Missions (30d) | [The Space Devs terms of use](https://github.com/TheSpaceDevs/Tutorials/blob/main/faqs/faq_TSD.md#terms-of-use): data may be used and shared in any form; avoid forwarding it without added value; attribution is encouraged (not mandatory). [Official API limits](https://ll.thespacedevs.com/docs/): 15 unauthenticated calls/hour; optional token | "Launch Library 2 — The Space Devs" (courtesy attribution) |
| **Esri World Imagery** (ArcGIS Online tile service) | The keyless satellite basemap — the default landing when no Google/ion credential is configured, and the "Esri Satellite" map stack | [Esri Master Agreement](https://www.esri.com/en-us/legal/terms/full-master-agreement): the public World Imagery service is usable in public-facing apps with attribution; no key is required for this classic endpoint, but Esri governs and can change access — an app at scale should review current ArcGIS Location Platform terms | "Powered by Esri — Source: Esri, Maxar, Earthstar Geographics, and the GIS User Community" (provider carries the service's own credit line) |
| **USGS** | Earthquakes | U.S. public domain | "Data courtesy of the U.S. Geological Survey" |
@@ -40,12 +39,6 @@ How to read this:
| **NASA GIBS** (WMTS tiles + Worldview Snapshots) and **NASA CMR** (granule catalog) | Recent Imagery layer: HLS S30/L30 (Sentinel-2, Landsat 8/9) and VIIRS true-colour tiles draped on the map, per-day thumbnails and PNG exports; the HLS granule catalog (dates, cloud, footprints) for a selected box | U.S. public domain (NASA data); keyless, browser-direct (CORS `*`); no bulk use — one 30-day catalog query per box, tiles bounded to the box, six tile requests in flight | "We acknowledge the use of imagery provided by services from NASA's Global Imagery Browse Services (GIBS), part of NASA's Earth Science Data and Information System (ESDIS)." — [gibs.earthdata.nasa.gov](https://gibs.earthdata.nasa.gov), [HLS](https://lpdaac.usgs.gov/products/hlss30v002/) |
| **NIFC WFIGS** (Wildland Fire Interagency Geospatial Services, ArcGIS feature service) | Fire Perimeters layer: current interagency wildfire incident perimeters, containment, and incident facts | U.S. public domain (interagency wildland-fire data published through the [NIFC Open Data portal](https://data-nifc.opendata.arcgis.com/)); fetched keyless through `/api/fire-perimeters` (5-minute server cache) with `exceededTransferLimit` paging | "Wildfire perimeters: National Interagency Fire Center (WFIGS)" |
| **S2 Underground GhostMaps** (`github.com/s2underground/GhostMaps`) | S2 GhostMaps layer: the current edition of S2's KMZ intel products (Common Intelligence Picture, 30-day kinetic pull, Border Crisis Map, Iran and Yemen products, newest incident drop). Keyless. `/api/ghostmaps` reads the branch head from git's ref advertisement every 15 s (`GHOSTMAPS_POLL_SECONDS`, minimum 5 s), lists the tree through the GitHub REST API only when the head changes (optional `GITHUB_TOKEN`), downloads files by commit-pinned raw URLs into `.gev-cache/ghostmaps/` and pushes new catalogs to browsers over server-sent events | [CC BY-NC-SA 4.0](https://creativecommons.org/licenses/by-nc-sa/4.0/) (repository README: "S2 Underground © 2025 by S2A1"); non-commercial, attribution required; files are fetched at runtime, never stored in this repository | "S2 GhostMaps intel products: S2 Underground © by S2A1, CC BY-NC-SA 4.0" — shown in-app |
| **News server** (`GEV_NEWS_ORIGIN`, optional; none by default) | News layer, both sections: the last 24 h of reports from many publishers. Set, the local server forwards the layer's `/api/news/hotspots`, `/feed` and `/outline` GET requests to it and Core reads none of the three sources below. Which providers stand behind its feed is the news server's to state | Each report stays its publisher's: headlines and summaries link to the original article, and pictures are hotlinked from the publisher's site (no referrer), never stored | Per report: publisher name and article link on every card; picture cards name the picture's publisher. The Data attribution popover states that places, categories, severity and English headlines are derived automatically and can be wrong |
| **GDELT 2.0 events and GKG** (`data.gdeltproject.org/gdeltv2/`; no key; used only without a news server) | News layer, Conflicts: the last 24 h of GDELT's 15-minute event exports (`*.export.CSV.zip`, about 100 KB each), read server-side as they appear. Physical violence only (CAMEO roots 18–20); one report per story, copies of one agency story merged; placed at GDELT's location for it (a town the article's words name beats a bare country). Titled by the article's page title from the GKG export of the same quarter hour (`*.gkg.csv.zip`, about 6 MB each) for the newest 3 h, else by the words of the article's address. GDELT's actor coding is never shown. Categories, relevance and severity by bundled rules | [GDELT Terms of Use](https://www.gdeltproject.org/about.html#termsofuse): unrestricted academic, commercial and governmental use, with citation and a link to the GDELT Project. Coded events, page titles and article links only; linked articles keep their publishers' terms | "GDELT Project" with a link to gdeltproject.org in the Data attribution popover; "via GDELT Project", the publisher's domain and the article link on every card |
| **Liveuamap** (`a.liveuamap.com/api`, `LIVEUAMAP_API_KEY`, optional, paid; used only without a news server) | News layer, Conflicts, beside GDELT: Liveuamap's worldwide mapped items of the last 24 h (`a=mpts`), read server-side every 5 minutes; categories and severity are read from the headline by bundled rules | Liveuamap API terms of the operator's own subscription. Headlines, places and links only; item pictures and clips are hotlinked from Liveuamap, never stored | "Liveuamap" with a link in the Data attribution popover once its items show; "Liveuamap" and the item's Liveuamap link on every card |
| **World News API** (`api.worldnewsapi.com/search-news`, `WORLDNEWSAPI_KEY`, optional; used only without a news server) | News layer, Local: the last 24 h of news by the source country at the view centre (country from the bundled Natural Earth admin-0 pack; none in a disputed area), read server-side once per country every 30 minutes, about 1.5 points a read, kept in memory for at most 1 hour and then deleted (a read that fails is served from that copy only within the hour); at most `WORLDNEWSAPI_DAILY_READS` reads a day (default 30), after which Local says the daily limit is reached. Each story keeps World News API's own category (politics, business, else other) with no severity of its own; sport, entertainment, lifestyle and travel left out | [World News API terms](https://worldnewsapi.com/terms/) of the operator's own plan. The terms allow caching only with World News API's prior written permission, and then for at most 1 hour: getting that permission before setting the key is the operator's responsibility. Titles and links only; article text and summaries are not kept or shown; pictures are hotlinked from the publisher | The "World News API" link to worldnewsapi.com, always in the Data attribution popover (a credit; the terms require a backlink only of invited free plans); "via World News API", the publisher's domain and the article link on every card |
| **EMODnet Human Activities — Ship wrecks** (`ows.emodnet-humanactivities.eu/wfs`, layer `emodnet:heritageshipwrecks`) | Shipwrecks layer: about 7,000 wrecks in European waters (name, sinking year and text, vessel type, least depth and its quality, position accuracy, period, cargo, protected status). Keyless WFS GeoJSON, fetched by `/api/shipwrecks` once a day; rows sourced from wrecksite.eu are skipped, and free text that refers to a person by a title or role is dropped by a keyword filter (a bare name can still pass) | [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/) (EMODnet catalogue record `e965088b-a265-4517-84df-c49b156af8a7`; data originator AND-International, compiled from SHOM, National Monuments Service, Historic England and the Oxford Roman Economy Project); fetched at runtime, not stored in this repository | "Shipwrecks (Europe): EMODnet Human Activities (data originator AND-International, …), CC BY 4.0" — shown in-app |
| **Wikidata — shipwrecks** (`query.wikidata.org/sparql`) | Shipwrecks layer: shipwreck items (Q852190) with a coordinate and at least one Wikipedia article, plus sinking events (Q906512, where the death toll lives): name, ship type, sinking date with its precision, deaths and survivors (counts only), description and article link. Keyless, fetched by `/api/shipwrecks` once a day | [CC0](https://creativecommons.org/publicdomain/zero/1.0/); fetched at runtime | "Shipwrecks (worldwide): Wikidata (CC0)" — shown in-app |
| **InciWeb** (inciweb.wildfire.gov) | Per-incident "InciWeb ↗" card links to official incident pages (narratives, evacuation/closure notices) | U.S. government public incident information. Links are matched best-effort by incident name and state, with incident page origin and update times checked on selection. Same-origin `/api/fire-perimeters/inciweb` routes cache the catalog for 1 hour and publication metadata for 30 minutes; linked pages remain InciWeb content | "Incident information: InciWeb (inciweb.wildfire.gov)" |
| **FAA Special Use Airspace** (`services6.arcgis.com` FeatureServer `Special_Use_Airspace`) | Prohibited & Restricted Airspace layer: US prohibited (P) and restricted (R) special-use airspace polygons (not the UAS Facility Map or UAS flight restrictions), altitude limits from the `*_VAL`/`*_UOM`/`*_CODE` fields, times of use, and controlling agency | U.S. public domain (FAA aeronautical data published through the [FAA AIS Open Data portal](https://adds-faa.opendata.arcgis.com/)); fetched keyless through `/api/drone-airspace` (1-hour server cache). Informational only — not a substitute for official UAS flight-planning tools | "Prohibited & restricted airspace: FAA Special Use Airspace" |
| **iNaturalist API** (`api.inaturalist.org/v2/observations`, `/v1/grid/{z}/{x}/{y}.grid.json`) | Wildlife Sightings layer: verifiable wildlife observed within the last 48 h (whales 7 d), by observation time, in the current view, with taxon, observation time, quality grade and eligible photo | Per-record licences; observations default CC BY-NC. Photos CC0/BY/BY-SA/BY-NC/BY-NC-SA shown; ND and all-rights-reserved photos link-only. Keyless via `/api/inaturalist/observations` (5-min cache; global sample 10 min; stale ≤ 1 h; 60 requests/min and 8,000/day upstream caps). **NonCommercial content:** commercial redistributors must filter to CC0/BY/BY-SA or remove the layer. No AI training on this content | "Wildlife sightings: iNaturalist contributors (licences per record; photos credited on each card)" |
@@ -72,11 +65,9 @@ How to read this:
| **DriveBC** (Government of British Columbia) | CCTV camera catalog + frames, British Columbia highways | [Open Government Licence – British Columbia](https://www2.gov.bc.ca/gov/content/data/open-data/open-government-licence-bc) — attribution REQUIRED | "DriveBC. Contains information licensed under the Open Government Licence – British Columbia" |
| **Open Calgary** (`data.calgary.ca`) | CCTV camera catalog + frames, Calgary | [Open Government Licence – City of Calgary](https://data.calgary.ca/stories/s/Open-Calgary-Terms-of-Use/u45n-7awa) — attribution REQUIRED | "Contains information licensed under the Open Government Licence – City of Calgary" |
| **Servei Català de Trànsit** (+ Ajuntament de Barcelona, Ajuntament de Terrassa) | CCTV camera catalog + frames, Catalonia | [Llicència oberta d'ús d'informació - Catalunya](https://web.gencat.cat/ca/generalitat/dades-indicadors/dades-obertes/llicencies) — attribution REQUIRED | "Servei Català de Trànsit"; partner-supplied cameras also name their owner |
| **Icelandic Road and Coastal Administration (Vegagerðin, IRCA)** | CCTV camera catalog + frames, Iceland road cameras | [CC BY 4.0](https://www.vegagerdin.is/vegagerdin/gagnasafn/vefthjonustur/terms-and-conditions) (IRCA terms of use, Act 45/2018) — attribution REQUIRED; no suggestion of IRCA endorsement; IRCA and State logos are excluded; real-time data is provided as is | "Icelandic Road and Coastal Administration (Vegagerðin)", CC BY 4.0 |
| **King County Road Services** (`services.arcgis.com`, King County GIS) | CCTV camera catalog + frames, unincorporated King County WA road network | [KCGIS Center terms](https://kingcounty.gov/en/dept/kcit/data-information-services/gis-center/about/terms-conditions-copyrights) — attribution required; public ArcGIS layer | "King County Road Services" (partner-city cameras name their owner) |
| **WSDOT** (`data.wsdot.wa.gov`) | CCTV camera catalog + frames, Washington State highways | [WSDOT travel information disclaimer](https://wsdot.wa.gov/about/policies/travel-information-disclaimer) — public "low volume" feed; catalog cached, frames on WSDOT's own image host only | "Washington State Department of Transportation (Travel Center)" (courtesy) |
| **Hong Kong Transport Department / DATA.GOV.HK** | CCTV camera catalog + frames, Hong Kong traffic snapshot cameras | [DATA.GOV.HK Terms of Use](https://data.gov.hk/en/terms-and-conditions) — attribution REQUIRED | "Hong Kong Transport Department / DATA.GOV.HK" |
| **Webcam Romania — Giroc** (`webcamromania.ro`, `w4.webcamromania.ro`) | Optional Giroc camera metadata, JPEG snapshot and live HLS video | [Provider terms](https://webcamromania.ro/termeni-si-conditii/): video belongs to the original camera operator; no open video redistribution licence asserted. The [camera page](https://webcamromania.ro/webcam-orase/webcam-giroc/) offers an embed. Local opt-in via `CCTV_SOURCES_FILE`; media is fetched at runtime, never bundled. | "Webcam Romania · webcamromania.ro"; source link and rights notice in the Data attribution popover |
| **Statens vegvesen** (catalog `ogckart-sn1.atlas.vegvesen.no`, frames `kamera.atlas.vegvesen.no`, HLS `kamera.vegvesen.no`) | CCTV camera catalog + frames + live HLS video, Norway road cameras | [NLOD 2.0](https://data.norge.no/nlod/en/2.0) — attribution REQUIRED | "Contains data under the Norwegian licence for Open Government data (NLOD) distributed by Statens vegvesen" |
| **Kartverket** (`ws.geonorge.no/hoydedata/v1/punkt`) | Terrain heights (NN2000) used as the ground prior for Statens vegvesen cameras | [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/) — attribution REQUIRED | "© Kartverket", modified (heights over water set to 0 m, rounded to 0.1 m) |
| **NYC DOT Traffic Management Center** (`webcams.nyctmc.org`) | CCTV camera catalog + frames, New York City five boroughs | NYC DOT requires developers to sign a [data-sharing agreement](https://www.nyc.gov/html/dot/html/about/datafeeds.shtml) for the camera feed. **Off by default**; enable only under your own agreement | "NYC DOT Traffic Management Center" + NYC DOT's required application disclaimer (in the Data attribution popover) |
@@ -110,40 +101,6 @@ How to read this:
| **YRT/Viva** (rtu.york.ca) | Transit layer live vehicles, York Region ON | York Region Transit realtime data, first-party York Region infrastructure under the YRT Open Data Licence v2.0, which permits commercial use and makes attribution optional. Viva route ids are named by colour from York Region’s published GTFS. | "Contains public transit Information made available under YRT’s Open Data Licence" — the licence’s own wording, given though not required |
| **Burlington Transit** (opendata.burlington.ca) | Transit layer live vehicles, Burlington ON | Burlington Transit realtime data under the City of Burlington Open Data Terms of Use, which permit any lawful purpose. The terms are revocable and must travel with the data, so the credit links them. Route ids are internal keys and are joined to the City’s published GTFS. | Credit to the City of Burlington plus a link to its Open Data Terms of Use, which the licence requires |
| **SMTR Rio de Janeiro** (dados.mobilidade.rio) | Transit layer live vehicles, Rio de Janeiro: municipal buses (SPPO) and the Mobi-Rio BRT, as two feeds | CC BY 4.0, stated on both data.rio catalog pages. Keyless GTFS-Realtime published by the Secretaria Municipal de Transportes, labelled beta, with a gateway limit of 5 requests a second and 60 a minute. Line names come from SMTR’s GTFS `routes.txt` (dados.mobilidade.rio/gtfs/schedule), under the same licence. | Attribution to SMTR / Prefeitura da Cidade do Rio de Janeiro under CC BY 4.0 |
| **ZDiTM Szczecin** (zditm.szczecin.pl) | Transit layer live vehicles, Szczecin, Poland: buses and trams as one feed | CC0 1.0, stated on the ZDiTM developer page: “The data are provided free of charge under the CC0 1.0 licence, for use in any context, with no prior notification required.” Keyless GTFS-Realtime; the page says one update a minute, measured at about every 20 seconds. The feed carries no route id, so every vehicle takes the default bus mode. | None required (CC0); credited to ZDiTM Szczecin as a courtesy |
| **Carris Metropolitana** (api.carrismetropolitana.pt) | Transit layer live vehicles, Lisbon Metropolitan Area, Portugal | Keyless GTFS-Realtime documented in the operator’s public API repository (github.com/carrismetropolitana/api). No licence is stated for the realtime endpoint; the operator’s static GTFS is CC BY 4.0. Included on the same basis as the Metro Transit and OVapi feeds and credited as a courtesy; it can be switched off if Carris objects. Speeds are published in km/h and converted to m/s by the proxy. Route ids are read as the line number per the operator’s `/v2/routes`. | Courtesy credit to Carris Metropolitana |
| **Prague PID** (api.golemio.cz) | Transit layer live vehicles, Prague and Central Bohemia, Czechia | CC BY (https://pid.cz/o-systemu/opendata/). Stated: “Data, která lze stáhnout přímo zde z webu, jsou stejně jako celý obsah webu opatřena licencí CC-BY” The PID open-data page lists this realtime feed and licenses the data it publishes under CC BY, crediting the author and any changes. The same page says data served from other sources follows that source’s licence; the feed is served by Golemio, the city’s data platform, which states none of its own. Keyless since the catalogue moved it off the X-Access-Token endpoint. Speeds are m/s, checked against movement on 2026-11-07. Route ids are `L` plus a number whose range gives the mode and the line (metro `L991` is A, train `L1004` is S4, ferry `L1801` is P1), read from PID’s GTFS routes.txt. | Pražská integrovaná doprava (PID) / Golemio |
| **Warsaw buses and trams** (mkuran.pl) | Transit layer live vehicles, Warsaw, Poland | Warsaw Open Data Project Usage Terms (https://mkuran.pl/gtfs/). Stated: “The City Hall requires a credit by providing the name Miasto Stołeczne Warszawa, data download date (use the timestamp from the FeedHeader) and link to http://api.um.warszawa.pl.” Positions come from the Warsaw City Hall API, repackaged as GTFS-RT by Mikołaj Kuranowski’s unofficial feed. The credit names the city, links its API and carries the date of the feed timestamp, the three things the terms ask for. Vehicle timestamps ran about two minutes old on 2026-11-07, which the delayed playback absorbs. | Miasto Stołeczne Warszawa (api.um.warszawa.pl) |
| **Bordeaux TBM** (bdx.mecatran.com) | Transit layer live vehicles, Bordeaux, France | Licence Ouverte / Etalab 2.0 (https://transport.data.gouv.fr/datasets/offres-de-services-bus-tram-et-scolaire-au-format-gtfs-netex-gtfs-rt-siri-lite). The quote is the licence field of the national access point’s dataset record (fr-lo is the Licence Ouverte), which asks only for credit. The `apiKey` in the URL is not a credential: it is the same fixed string printed in the open-data listing for everyone. Speeds are m/s. The trams (A–F) and BAT³ boats ride numbered route ids (tram A is `59`), mapped from TBM’s static GTFS; other numbered lines are buses. | Bordeaux Métropole (TBM) |
| **Sendai City Bus** (api-public.odpt.org) | Transit layer live vehicles, Sendai, Japan | CC BY 4.0 (https://creativecommons.org/licenses/by/4.0/). Served by the Open Data Challenge for Public Transportation (ODPT) public API, which needs no key for this feed; the catalogue records CC BY 4.0 as its licence. Vehicle timestamps ran about two minutes old on 2026-11-07. Speeds are m/s. | 仙台市交通局 (Sendai City Transportation Bureau) / ODPT |
| **Poznań buses and trams** (ztm.poznan.pl) | Transit layer live vehicles, Poznań, Poland | ZTM Poznań open-data terms (https://www.ztm.poznan.pl/otwarte-dane/dla-deweloperow/). Stated: “Treści udostępniane są bezwnioskowo i bezpłatnie, na zasadach powszechnego dostępu i stanowią źródło informacji.” Reuse is free on three conditions: name the source, give the date of the last update, and say the data were processed. The credit names ZTM, says processed, and carries the date of the feed timestamp as the update date. The feed carries no bearing, so heading comes from movement between fixes. | Zarząd Transportu Miejskiego w Poznaniu (processed) |
| **Gdańsk buses and trams** (ckan2.multimediagdansk.pl) | Transit layer live vehicles, Gdańsk, Poland | CC BY 4.0 (https://dane.gov.pl/pl/dataset/38599,rozklady-jazdy-i-lokalizacja-przystankow-gtfs-rt). The national catalogue (dane.gov.pl) lists this dataset under CC BY 4.0. No bearing; heading comes from movement. Speeds are m/s. | Zarząd Transportu Miejskiego w Gdańsku |
| **Edmonton Transit Service** (gtfs.edmonton.ca) | Transit layer live vehicles, Edmonton, AB | City of Edmonton Open Data Agreement (https://www.edmonton.ca/transportation/Web-version2.1-OpenDataAgreement.pdf). The agreement is the same municipal template as Mississauga and Burlington: a licence to use, modify and distribute for any lawful purpose, in any medium. It was read from search excerpts; the PDF itself would not extract here. Route ids are zero-padded. Speeds are m/s. | Contains information licensed under the Open Data Agreement – City of Edmonton |
| **Halifax Transit** (gtfs.halifax.ca) | Transit layer live vehicles, Halifax, NS | Open Government Licence – Halifax v1.0 (https://www.halifax.ca/home/open-data/open-data-license), modelled on OGL – Canada 2.0. Stated: “The information provider grants you a worldwide, royalty-free, perpetual, non-exclusive licence to use the Information, including for commercial purposes, subject to the terms below.” No logos or implied endorsement. Speeds are m/s. | Contains information licenced under the Open Government Licence – Halifax |
| **Victoria Regional Transit** (bct.tmix.se) | Transit layer live vehicles, Victoria, BC | BC Transit Open Data Terms of Use (https://www.bctransit.com/open-data/terms-of-use). Stated: “BC Transit grants you a limited, revocable and non-exclusive license to use, reproduce, and redistribute the Data in accordance with these Terms.” The grant is revocable, like the Ontario municipal templates. Route ids carry a `-VIC` system suffix. Speeds are m/s. | BC Transit |
| **Montpellier TaM** (gtfsproxy.e-tam.fr) | Transit layer live vehicles, Montpellier, France | ODbL (https://transport.data.gouv.fr/datasets/offre-de-transport-tam-en-temps-reel-gtfs-rt-urbain-et-suburbain). The quote is the dataset record’s licence field. ODbL asks for credit and share-alike on a derived database; the layer redistributes no database, it draws the positions. Speeds are km/h: readings ran 3.3 times the measured movement on 2026-11-07, so the proxy converts them. | Montpellier Méditerranée Métropole (TaM) |
| **Kraków buses** (gtfs.ztp.krakow.pl) | Transit layer live vehicles, Kraków, Poland | No licence stated (https://gtfs.ztp.krakow.pl/). ZTP publishes this feed keyless on its own GTFS server and lists it in the Mobility Database, but states no licence there or on dane.gov.pl. Shipped on the same basis as Metro Transit and OVapi; switch it off if ZTP objects. | Zarząd Transportu Publicznego w Krakowie |
| **Kraków trams** (gtfs.ztp.krakow.pl) | Transit layer live vehicles, Kraków, Poland | No licence stated (https://gtfs.ztp.krakow.pl/). Same server and basis as the bus feed. | Zarząd Transportu Publicznego w Krakowie |
| **Vilnius public transport** (stops.lt) | Transit layer live vehicles, Vilnius, Lithuania | No licence stated (https://mobilitydatabase.org/feeds/gtfs_rt/mdb-2383). Published keyless by the city transport agency on stops.lt; no licence found. Shipped on the same basis as Metro Transit and OVapi. Answered 522 once on 2026-11-07 and normally since. | SĮ Susisiekimo paslaugos (stops.lt) |
| **Bucharest public transport** (gtfs.tpbi.ro) | Transit layer live vehicles, Bucharest, Romania | No licence stated (https://mobilitydatabase.org/feeds/gtfs_rt/mdb-2104). Keyless public endpoint from the metropolitan transport authority; no licence found. Same basis as Metro Transit and OVapi. | TPBI (Transport Public București–Ilfov) |
| **Rome public transport** (romamobilita.it) | Transit layer live vehicles, Rome, Italy | No licence stated (https://mobilitydatabase.org/feeds/gtfs_rt/mdb-1776). Keyless file on the agency’s own site; the catalogue’s older `/sites/default/files/` path now 404s. No licence found; same basis as Metro Transit and OVapi. Fewer than one vehicle in five carries a bearing, so most headings come from movement. | Roma Servizi per la Mobilità |
| **Turin GTT** (percorsieorari.gtt.to.it) | Transit layer live vehicles, Turin, Italy | No licence stated (https://mobilitydatabase.org/feeds/gtfs_rt/mdb-2696). Official keyless endpoint; no licence found. Same basis as Metro Transit and OVapi. Route ids append `U` for urban lines. | Gruppo Torinese Trasporti (GTT) |
| **Venice ACTV buses** (tpl.actv.it) | Transit layer live vehicles, Venice, Italy | ACTV open-data catalogue (https://actv.avmspa.it/it/content/catalogo-dei-dati-metadati-e-banche-dati-actv). ACTV lists this feed in its own open-data catalogue; that page would not render for a script, so its licence is not quoted. Same basis as Metro Transit and OVapi. Route ids append a two-letter network code (`_UM`, `_ES`, `_UN`). | ACTV S.p.A. |
| **Venice ACTV waterbuses** (tpl.actv.it) | Transit layer live vehicles, Venice, Italy | ACTV open-data catalogue (https://actv.avmspa.it/it/content/catalogo-dei-dati-metadati-e-banche-dati-actv). Same catalogue and basis as the ACTV bus feed. | ACTV S.p.A. |
| **Adelaide Metro** (gtfs.adelaidemetro.com.au) | Transit layer live vehicles, Adelaide, SA | No licence stated for the realtime feed (https://mobilitydatabase.org/feeds/gtfs_rt/mdb-1772). Official keyless endpoint; the state catalogue page refused automated reads, so no licence is quoted. Same basis as Metro Transit and OVapi. Speeds are m/s. The server picks its encoding from Accept, hence the override. | Adelaide Metro |
| **MARTA buses** (gtfs-rt.itsmarta.com) | Transit layer live vehicles, Atlanta, GA | No licence stated (https://mobilitydatabase.org/feeds/gtfs_rt/mdb-1601). Official keyless endpoint; no licence found. Same basis as Metro Transit and OVapi. Speeds are m/s. | MARTA |
| **Denver RTD** (open-data.rtd-denver.com) | Transit layer live vehicles, Denver, CO | RTD GTFS-realtime licence agreement (https://www.rtd-denver.com/business-center/open-spatial-data/gtfs-realtime-license-agreement). Stated: “RTD hereby grants you non-exclusive, limited and revocable rights to use, reproduce, and redistribute the Data”. Read 2026-11-09: a click-through agreement that RTD may change without notice; it permits redistribution, asks for no credit wording, and bars RTD trademarks from use with the data, so the credit stays plain text. The feed itself is keyless. Speeds are m/s. | Regional Transportation District (RTD) |
| **Cluj-Napoca CTP** (cluj-rt-feed.gtfs.ro) | Transit layer live vehicles, Cluj-Napoca, Romania | No licence stated (https://mobilitydatabase.org/feeds/gtfs_rt/mdb-2614). Keyless; no licence found. Same basis as Metro Transit and OVapi. | Compania de Transport Public Cluj-Napoca |
| **Iași CTP** (iasi-rt-feed.gtfs.ro) | Transit layer live vehicles, Iași, Romania | No licence stated (https://mobilitydatabase.org/feeds/gtfs_rt/mdb-2118). Keyless; no licence found. Same basis as Metro Transit and OVapi. | Compania de Transport Public Iași |
| **Rzeszów city transport** (mpkrzeszow.pl) | Transit layer live vehicles, Rzeszów, Poland | MPK Rzeszów open data (https://www.mpkrzeszow.pl/otwartedane/). Listed on the operator’s open-data page, which names no licence. No bearing; heading comes from movement. | MPK Rzeszów |
| **Málaga EMT** (gtfs.emtmalaga.es) | Transit layer live vehicles, Málaga, Spain | No licence stated (https://mobilitydatabase.org/feeds/gtfs_rt/mdb-2213). Keyless; no licence found. Same basis as Metro Transit and OVapi. No bearing; heading comes from movement. | EMT Málaga |
| **Carris (Lisbon city)** (gateway.carris.pt) | Transit layer live vehicles, Lisbon, Portugal | No licence stated (https://mobilitydatabase.org/feeds/gtfs_rt/mdb-2930). Keyless official gateway; no licence found. Same basis as Metro Transit and OVapi. Carries neither bearing nor speed, and vehicle reports run about a minute old. Route ids append a variant. | Carris |
| **BAS.MY Johor Bahru** (api.data.gov.my) | Transit layer live vehicles, Johor Bahru, Malaysia | CC BY 4.0 (https://developer.data.gov.my/realtime-api/gtfs-realtime). Stated: “All GTFS Realtime feeds are licensed under CC BY 4.0.” Served keyless by Malaysia’s national open-data API, which caps GTFS Realtime at 4 requests a minute and refreshes positions every 30 seconds, so the proxy caches this feed for 30 seconds. Without the trailing slash the API redirects. Route ids append an operator code. | BAS.MY / data.gov.my |
| **BAS.MY Kota Bharu** (api.data.gov.my) | Transit layer live vehicles, Kota Bharu, Malaysia | CC BY 4.0 (https://developer.data.gov.my/realtime-api/gtfs-realtime). Same API, licence and rate limit as Johor Bahru. | BAS.MY / data.gov.my |
| **Rouen Astuce** (api.mrn.cityway.fr) | Transit layer live vehicles, Rouen, France | Licence Ouverte 2.0 (https://transport.data.gouv.fr/datasets/donnees-statiques-et-temps-reel-du-reseau-astuce-metropole-rouen-normandie). The metropolis publishes its realtime data under the Licence Ouverte (dataset record quoted); and this is its own TCAR vehicle feed from that record; the suburban TAE and TNI networks are separate feeds and are not read. Route ids carry the network prefix (`TCAR:`); line 90 is the Métro (a street-running light rail, drawn as a tram) and 99 the Calypso boat. | Métropole Rouen Normandie (Astuce) |
| **Toulon Mistral** (feed-rdtpm-toulon.ratpdev.com) | Transit layer live vehicles, Toulon, France | Licence Ouverte 2.0 (https://transport.data.gouv.fr/datasets/reseau-de-transport-urbain-de-la-metropole-toulon-provence-mediterranee). Dataset record’s licence field quoted. Speeds are km/h: readings ran 3.5 times the measured movement on 2026-11-07, so the proxy converts them. Route ids are zero-padded. | Métropole Toulon Provence Méditerranée (Mistral) |
| **Le Havre LiA** (opendata.transports-lia.fr) | Transit layer live vehicles, Le Havre, France | ODbL (https://transport.data.gouv.fr/datasets/reseau-de-transport-en-commun-lia-gtfs-siri). The network publishes under ODbL (dataset record quoted); and this is its own GTFS-RT vehicle feed from that record; route ids carry the network prefix `8-`. The layer draws positions and redistributes no database. Lines A and B are the tramway. | Le Havre Seine Métropole (LiA) |
| **Tours Fil Bleu** (data.filbleu.fr) | Transit layer live vehicles, Tours, France | Licence Ouverte 2.0 (https://transport.data.gouv.fr/datasets/fil-bleu-syndicat-des-mobilites-gtfs-gtfs-rt). Dataset record’s licence field quoted. | Fil Bleu – Syndicat des Mobilités de Touraine |
| **Radio Browser** | Geolocated internet-radio station directory and station-level tags | Public-domain directory data under PDDL 1.0; individual broadcaster stream terms apply | "Radio Browser" plus a link to the selected broadcaster |
| **Re:Earth Terrain** (Mapterhorn) | Terrain (keyless globe stacks — OSM etc. — + `/api/terrain/heights` ellipsoidal-height lookups) | Terrain mesh: CC BY 4.0; geoid: EGM2008 (NGA, public domain) | "Terrain (keyless globe stacks): Re:Earth Terrain / Mapterhorn (CC BY 4.0) / EGM2008 (NGA)" |
| **OpenSeaMap** (`tiles.openseamap.org`) | Sea Marks layer: raster overlay of buoys, lighthouses and ports (#10) | Chart tiles CC BY-SA 2.0; data ODbL 1.0 via OpenStreetMap | "Sea marks © OpenSeaMap contributors (CC BY-SA 2.0) · Data © OpenStreetMap contributors (ODbL)" |
@@ -165,8 +122,7 @@ The optional Local ADS-B layer and the broadcast-FM receiver use radio you recei
**Quebec is thinly represented**, and that is upstream rather than a filter here: the province runs its own Info-Smog program, so only a handful of Quebec stations appear in ECCC's national feed. The reporting station count also varies hour to hour as stations report or fall silent.
- **Via Lietuva / Eismoinfo (Lithuania).** Public road-camera catalogs and periodically updated still images from [Eismoinfo](https://eismoinfo.lt/), credited to Via Lietuva / eismoinfo.lt in the app. **Off by default.** The camera endpoints are the eismoinfo.lt website backend and are not among the services listed in Via Lietuva's [open-data statement](https://eismoinfo.lt/eismoinfo-backend/content?id=44), whose conditions are attribution and reuse on the same terms. Until the statement is confirmed to cover the camera photos, the pack loads only when an operator sets `CCTV_LITHUANIA_ENABLED=1`; with the flag unset, empty or any other value, the server makes no request to Eismoinfo. `eismoinfo-backend/camera-info-table` is joined by ID to `layer-static-features/VKR`. Without `lks=true`, the latter returns WGS84 `[latitude, longitude]`; the table's `x/y` instead use LKS-94 metres. Only the official HTTPS `/eismoinfo-backend/image-provider/camera/last?id=…` image endpoint is registered. Catalogs use the shared 15-minute cache; frames are fetched on demand and never bundled or archived. These are periodic snapshots, not continuous video. Camera headings are low-confidence estimates. No API key is required. `CCTV_LITHUANIA_MAX_SOURCES` defaults to 400.
- **Freeway Bureau and Highway Bureau, MOTC (Taiwan).** National freeway cameras use the Freeway Bureau’s keyless [MOTC 2.0 CCTV table](https://tisvcloud.freeway.gov.tw/history/motc20/CCTV.xml), refreshed daily and fetched at runtime (never bundled). Freeway Bureau data follows the [Open Government Data License, Taiwan v1.0](https://data.gov.tw/license); the app credits the Bureau, names the dataset and links the licence. If that catalog is unreachable and no last-good source list exists, the app falls back to the Highway Bureau’s free [provincial-road CCTV XML feed](https://thbapp.thb.gov.tw/opendata/), with attribution as requested by the Bureau. Its MJPEG streams are accepted only from cctv-ss01 through cctv-ss08.thb.gov.tw over HTTPS; the frame proxy extracts one bounded JPEG per request. This fallback covers provincial roads, not national freeways. Neither feed requires an API key. CCTV_TAIWAN_ENABLED=0 disables both; CCTV_TAIWAN_MAX_SOURCES defaults to 600 (bounded to 1–2000).
- **Québec CCTV (MTQ / Québec 511, Montréal and scenic links).** `public/quebec-cameras.json` contains 680 MTQ positions from the official georeferenced WFS (CC BY 4.0), 426 Montréal camera locations derived from OSM (ODbL 1.0), and 14 scenic records with per-row provenance and accuracy. The MTQ video is **opt-in** (`CCTV_QUEBEC_VIDEO_ENABLED=1`): the CC BY 4.0 licence covers the camera locations only, and Québec 511's terms for embedding its clips on another site are unconfirmed, so by default MTQ cameras link to the Québec 511 viewer page instead. With the flag set, the 680 MTQ cameras play the operator's five-second MP4 clip directly in the viewer's browser from `www.quebec511.info/Carte/Fenetres/camera.ashx?id=<id>&format=mp4`, only after the viewer presses Play, so Québec 511 sees the viewer's IP address; Québec 511 refreshes those clips every few minutes. A second browser request supplies an approximate map still. The app does not proxy, store, or read video pixels, and no camera images are bundled. The selected-camera credit names both sources: locations MTQ under CC BY 4.0, video Québec 511. Montréal and scenic entries have provider-page links rather than direct frame URLs; their source-page terms apply. The video remains hosted by Québec 511 under its service terms. No API key is required; `CCTV_QUEBEC_ENABLED=0` disables the pack. Confirm Québec 511's terms of use before setting `CCTV_QUEBEC_VIDEO_ENABLED=1` on a public deployment.
- **Freeway Bureau, MOTC (Taiwan).** National freeway cameras from the Freeway Bureau's keyless [CCTV static table](https://tisvcloud.freeway.gov.tw/history/motc20/CCTV.xml) in the MOTC 2.0 standard, refreshed daily and fetched at runtime (never bundled). Freeway Bureau traffic data is published under the [Open Government Data License, Taiwan (Version 1.0)](https://data.gov.tw/license), which permits reuse, including commercial reuse, with attribution; the app credits the Freeway Bureau, MOTC, names the dataset and links the licence, as its attribution exhibit asks. The table publishes each camera's MJPEG stream on one of four official camera servers (`cctvn`, `cctvn5`, `cctvc`, `cctvs` under `freeway.gov.tw`). The frame proxy relays single images, so each published stream URL is mapped to the same server's single-JPEG path for the same camera number (`/abs2mjpg/bmjpg` → `/abs2mjpg/jpg`, `/live-view/mjpg/video.cgi` → `/live-view/jpg/image.cgi`); only those exact HTTPS pairs are registered, and the still path is not itself listed in the table. These are on-demand snapshots, not continuous video. Camera headings are low-confidence estimates. No API key is required. The table host does not answer from some networks outside Taiwan (the camera servers do); there the pack is empty, logs one warning and retries hourly. On by default; `CCTV_TAIWAN_ENABLED=0` skips the pack, and `CCTV_TAIWAN_MAX_SOURCES` defaults to 600 (bounded to 1–2000).
- **Google Maps Platform.** You supply your own API key and are bound by [Google's ToS](https://cloud.google.com/maps-platform/terms). Google Maps Content (tiles, geocodes, places) **may not be cached, stored, rehosted, or committed** — this app only ever uses it live, which is the compliant pattern. The "Google" attribution is displayed on the globe and must stay visible. Restrict your key (see [SECURITY.md](SECURITY.md)).
- **Sea Marks and Ski Pistes (#10).** Two keyless raster overlay layers under Infrastructure, OpenSeaMap sea marks and OpenSnowMap ski pistes, requested tile by tile as the camera views them, with no prefetch, bulk download or caching on our side (OpenSnowMap forbids bulk tile download without prior agreement). They drape on the globe on the OSM and Bing stacks and on the Google Photorealistic tileset on Google 3D (see `src/maps/imageryHost.js`). OpenRailwayMap and OpenInfraMap, also requested in #10, are not included: OpenRailwayMap's tile server only answers requests whose Referer is its own site, and OpenInfraMap serves vector tiles that need a vector rendering pipeline.
@@ -207,16 +163,13 @@ The optional Local ADS-B layer and the broadcast-FM receiver use radio you recei
contributors, ODbL 1.0; GPSJAM for aggregation only.
- **adsbdb enrichment.** `/api/adsbdb` resolves an ICAO hex to aircraft type, model name and registration, and the tracked flight's callsign to an airline and an origin/destination pair. Fan-out is deliberately small because adsbdb is a free community API that publishes no quota: at most 4 requests in flight, dispatches dripped to roughly 5/s, and each key asked for at most once per session (for the flights layer's enrichment, a lookup that failed temporarily, such as an outage, a timeout, a rate limit or an authorization refusal that may clear, may be asked again after a 60-second cool-down; an answer, found or not, and a permanent refusal such as a 404 are final; Local ADS-B lookups are asked once), with route lookups reserved for the tracked aircraft. The dev-server proxy caches each key for 24 hours in `.gev-cache/adsbdb.json`, negative answers included, so fresh entries survive server restarts. That gitignored cache remains local to the deployment and is not bundled with this repo. adsbdb publishes no general service license; its README says the route data may not be copied, published, or incorporated into other databases without the explicit permission of David J Taylor, Edinburgh. Deployers are responsible for obtaining any permission their use requires. Enrichment is best-effort and fail-silent — a flight that gets no answer keeps what the flight feed already gave it.
- **Planespotters photos.** When you track an aircraft, the browser asks `https://api.planespotters.net/pub/photos/hex/{hex}` directly (the API answers cross-origin, so no key and no server proxy are involved) for that aircraft only, once per hex per session, through the same enrichment queue as adsbdb, with an 8-second timeout. A miss is remembered for the session; a request that failed temporarily (an outage, the timeout or a rate limit) may be asked again after a 60-second cool-down while the aircraft stays tracked, as for the flights layer's other enrichment. Either way no photo shows meanwhile. The first photo whose image is an `https://` URL on `*.plnspttrs.net`, and whose page is an `https://www.planespotters.net` URL is shown in the CONTEXT panel's CONTACTS view, under the tracked flight's name (`src/ui/views/panels/AwarenessPanel.vue`), and nowhere else; a vessel, an installation, a military aircraft or a converted TR-3B has none, and a military aircraft or TR-3B is never looked up. Anything else is dropped, so the app never shows or links outside Planespotters (`src/data/planespottersPhoto.js`, also exported as `gods-eye-view/sources/planespotters` for servers that cache the lookup). The photo follows the attribution rules: the thumbnail is hotlinked from the URL the API gives (never copied or re-hosted), the photographer's name, when the answer gives one, is visible text next to it ("Photo © {photographer}"; a photo whose answer names no one shows no credit line), and the photo and credit are a plain link to the photo's Planespotters page with `target="_blank" rel="noopener"` and never `rel="nofollow"`. If the image fails to load, the photo and its credit are hidden.
- **adsbdb enrichment.** `/api/adsbdb` resolves an ICAO hex to aircraft type, model name and registration, and the tracked flight's callsign to an airline and an origin/destination pair. Fan-out is deliberately small because adsbdb is a free community API that publishes no quota: at most 4 requests in flight, dispatches dripped to roughly 5/s, and each key asked for at most once per session, with route lookups reserved for the tracked aircraft. The dev-server proxy caches each key for 24 hours in `.gev-cache/adsbdb.json`, negative answers included, so fresh entries survive server restarts. That gitignored cache remains local to the deployment and is not bundled with this repo. adsbdb publishes no general service license; its README says the route data may not be copied, published, or incorporated into other databases without the explicit permission of David J Taylor, Edinburgh. Deployers are responsible for obtaining any permission their use requires. Enrichment is best-effort and fail-silent — a flight that gets no answer keeps what the flight feed already gave it.
- **Launch Library 2.** `/api/launches` makes a server-side rolling-30-day query against the supported v2.3 detailed launch endpoint, caches successful responses for 15 minutes in memory and on disk, and serves the last successful response during a throttle or transient outage. Anonymous access is limited to 15 calls/hour; deployments can provide `LL2_API_TOKEN` for authenticated access. The Space Devs' published terms permit using and sharing the API data in any form, ask users not to forward it without adding value, disclaim complete accuracy, and encourage—but do not require—attribution. This app keeps a courtesy credit. Payload and stage/recovery records are shown only when supplied. Failed launches expose their source status and never receive fallback orbit geometry or a live/estimated marker. LL2 supplies launch context and event timing, not continuous ascent telemetry or live orbital state.
- **TfL JamCams.** The camera list comes from the keyless `api.tfl.gov.uk` endpoint (an optional `TFL_APP_KEY` raises its rate limit); frames come from TfL's public S3 bucket. The "Powered by TfL Open Data" attribution is required by TfL's terms and is registered in the Data attribution popover.
- **Ontario 511 CCTV.** The camera list comes from the `511on.ca/api/v2/get/cameras` endpoint, which now requires a free developer key (`ONTARIO_511_API_KEY`: sign up at 511on.ca/my511/register, then request it while logged in at 511on.ca/developers/doc; throttled to 10 calls a minute). Without the key the pack is skipped. Frames are server-registered still images under `511on.ca/map/Cctv/`. The source is live-fetched at runtime and attributed under the Open Government Licence - Ontario. Access is governed by Ontario 511's Developer's Access Agreement (511on.ca/developers/resources): the data stays under the Open Government Licence - Ontario unless otherwise specified; key requests are reviewed case by case and ask for the intended use and any third parties that will receive the data; attribution by link to 511on.ca is encouraged, and the Ontario 511 logo may not be altered or used to imply endorsement (this app links and shows no logo); and the data is provided "as is", a disclaimer distributors should pass on, which the credit does.
- **DelDOT live video cameras.** The keyless `tmc.deldot.gov/json/videocamera.json` catalog supplies active cameras and official `https://video.deldot.gov/live/…/playlist.m3u8` links. The local server transiently buffers bounded MPEG-TS segments in memory and deletes them on session release or idle expiry. No recordings are retained. `CCTV_DELDOT_ENABLED=0` disables the pack. Public availability is not a blanket redistribution license; deployment beyond personal local viewing needs confirmation of applicable DelDOT terms.
- **Fintraffic weathercams.** The camera list is one keyless GeoJSON request to `tie.digitraffic.fi/api/weathercam/v1/stations` (809 stations / 2,275 presets), sent with the `Digitraffic-User: gods-eye-view` header the service asks clients to identify themselves with and with gzip; frames are stills on `weathercam.digitraffic.fi`, refreshed on each station's 600 s collection interval, which is also the client's ambient-still refresh cadence for this pack (the active camera keeps the usual 10-second refresh). One preset (a fixed camera view) is one camera; stations not `GATHERING` and presets not `inCollection` are dropped. Attribution is required by the CC BY 4.0 licence and is registered in the Data attribution popover. `CCTV_FINTRAFFIC_ENABLED=0` is the kill switch.
- **DriveBC highway cameras.** The camera list comes from the keyless `https://www.drivebc.ca/api/webcams/` endpoint served by the DriveBC.ca site ([bcgov/DriveBC.ca](https://github.com/bcgov/DriveBC.ca)); frames come from `https://www.drivebc.ca/images/<id>.jpg`, built from the numeric camera id. DataBC's [DriveBC HighwayCams](https://catalogue.data.gov.bc.ca/dataset/bc-highwaycams) catalogue entry publishes the same cameras under the Open Government Licence – British Columbia, but its CSV still lists the retired `images.drivebc.ca` frame URLs, which now return a placeholder. The licence's attribution statement is required and is registered in the Data attribution popover. By default the 250 cameras nearest Vancouver and Victoria load; `CCTV_DRIVEBC_MAX_SOURCES` changes the cap and `CCTV_DRIVEBC_ENABLED=0` turns the pack off. Some cameras in the feed are supplied by partners (TransLink, the City of Vancouver, the City of Surrey, Parks Canada / Alberta Motor Association and others); the feed's per-camera credit is carried onto the camera record and shown beside the provider in the CCTV panel.
- **City of Ottawa traffic cameras.** Camera images come from the city's [Traffic Camera Images API](https://traffic.ottawa.ca/en/traffic-map-data-lists-and-resources/about-the-data), which requires an access certificate requested through the city's registration form and allows one certificate and user id to fetch a camera at most once every 60 seconds. Set `OTTAWA_CAMERA_CERTIFICATE`; without it the pack is skipped and makes no request. The camera list is the documented keyless `traffic.ottawa.ca/map/service/camera` JSON (camera number, English and French names, coordinates, owner). Frames are `traffic.ottawa.ca/opendata/camera?c=<number>`; the server adds the certificate and the user id `gev` at fetch time, so no catalog response carries the key, and serves each camera from one shared in-memory frame per minute. MTO cameras carried on the city feed keep their owner as a per-camera credit. The feed publishes no bearing, so headings use the shared id-hash fallback at low confidence, and no status field, so a camera the city takes offline still registers. Frames are never stored on disk. The city states no redistribution licence for the images; the certificate request asks how they will be used, so describe the deployment there. `CCTV_OTTAWA_MAX_SOURCES` caps the pack (default 250, nearest downtown) and `CCTV_OTTAWA_ENABLED=0` turns it off.
- **World cams image subset.** The normal CCTV catalog adds 1,176 Oregon TripCheck stills and 1,054 MLIT river cameras from the curated `config/cctv_sources.world-image-cameras.json` export; disable it with `CCTV_WORLD_CAMERAS_ENABLED=0`. The export's Vegagerðin rows are left out because the Iceland pack already loads those cameras. The server validates and pins each image URL to its provider path, fetches a frame only when a camera is viewed, and never stores or redistributes it. [ODOT allows businesses, private citizens and organizations to use TripCheck images and asks for credit](https://www.tripcheck.com/Pages/Frequently-Asked-Questions) ("Camera courtesy of ODOT"); TripCheck also carries partner cameras run by Oregon counties and cities, Caltrans and WSDOT, which keep their owner's name in the camera label. MLIT's [site terms](https://www.river.go.jp/kawabou/kwb_apend/html/caution.html) follow the Government of Japan Standard Terms of Use 2.0 (CC BY 4.0 compatible, commercial use allowed, source credit required) and ask tools not to collect data periodically, pointing regular collection to the river information data service; frames are therefore fetched only on view and refresh at most every ten minutes per camera. Both credits are registered in the Data attribution popover.
- **TxDOT ITS cameras.** The catalog comes from the keyless `its.txdot.gov/its/DistrictIts/GetCctvStatusListByDistrict` endpoint, one call per district. Frames come from the per-camera `GetCctvSnapshotByIcdId` endpoint, which answers JSON carrying a base64 JPEG rather than an image body; the proxy decodes it only for responses from the official TxDOT origin and validates the JPEG header before serving. Only cameras reporting `Device Online` are registered. Districts default to Austin and San Antonio (`CCTV_TXDOT_DISTRICTS`; 25 codes exist statewide); `CCTV_TXDOT_MAX_SOURCES` caps the pack and `CCTV_TXDOT_ENABLED=0` turns it off. Frames are fetched live at request time and are never stored or redistributed.
- **Tallinn ristmikud.** Intersection camera stills are fetched live from `ristmikud.tallinn.ee` (`/last/camNNN.jpg`). The curated catalog (`config/cctv_sources.tallinn.json`) supplies coordinates and heading priors; only server-registered ristmikud HTTPS URLs are proxied. Frames are fetched at request time and never stored or redistributed. Disable with `CCTV_TALLINN_ENABLED=0`.
- **Tarktee / Transpordiamet.** Estonia road-weather camera locations and current JPEG URLs come from keyless DATEX2 endpoints on `tarktee.transpordiamet.ee` (`/api/v1/datex/roadCameraLocations` + `roadCameraImages`). Frame URLs rotate with each capture; the 15-minute CCTV source cache refreshes them. Only `https://tarktee.transpordiamet.ee/images/…` URLs are registered. Disable with `CCTV_TARKTEE_ENABLED=0`.
@@ -225,7 +178,6 @@ The optional Local ADS-B layer and the broadcast-FM receiver use radio you recei
- **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`.
- **Iceland (Vegagerðin / IRCA) cameras.** The camera list is the keyless `https://gagnaveita.vegagerdin.is/api/vefmyndavelar2014_1` JSON feed (about 500 camera views at roughly 170 stations), fetched over https with redirects refused and the body read capped at 2 MiB, since the list decides ids, coordinates and which frame path is proxied. Frames are JPEG stills in `https://www.vegagerdin.is/vgdata/vefmyndavelar/`; only links inside that directory are registered (userinfo, explicit ports and encoded path separators are rejected). Camera ids come from the frame file name. The feed's `Skyring` view description is the camera label, and a stated facing ("til vesturs", "í suður", "séð austur") sets a high-confidence heading; a view that states none, or two different ones ("niður á veg", a bare road direction), takes the id-hash low-confidence pose. The feed has no elevation, so cameras carry a flat prior that the client's ground snap corrects. IRCA publishes road camera images under CC BY 4.0 and requires the licensor to be acknowledged without implying endorsement; the pack shows the attribution above (licensor, dataset, CC BY 4.0, terms link and no-endorsement note) in the Data attribution popover, and every camera record carries the licence string. IRCA describes the images as fetched several times per hour (a live frame's `Last-Modified` was about four minutes old on 2026-11-06) and the real-time data as provided as is, without warranty. Frames are fetched at request time and never stored or redistributed. Disable with `CCTV_ICELAND_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.
- **Hong Kong Transport Department traffic snapshot cameras.** Keyless. The English camera list is the public DATA.GOV.HK XML at `static.data.gov.hk/td/traffic-snapshot-images/code/Traffic_Camera_Locations_En.xml`; frames are stills on `tdcctv.data.one.gov.hk`, and only that host is registered. Frame URLs are rebuilt from each camera key (`https://tdcctv.data.one.gov.hk/{KEY}.JPG`) rather than taken verbatim from the catalog. Stills were probed to answer directly on that origin with no redirect hop. Frames are fetched at request time and never stored or redistributed. The dataset publishes no camera facing, so headings use the shared id-hash fallback at low confidence and are corrected with the in-app calibration gizmo. By default nearly the full catalog loads (~1,013; pack cap 1,100), prioritized nearest Central, Tsim Sha Tsui, Sha Tin, Tsuen Wan, Tai Po, Fanling, Tuen Mun, Yuen Long and Tseung Kwan O so a lowered cap still spans Hong Kong Island, Kowloon and the New Territories. When the sum of packs exceeds the catalog-wide ceiling, shared round-robin keeps every lane represented. `CCTV_HK_MAX_SOURCES` (8–1200) changes the cap and `CCTV_HK_ENABLED=0` turns the pack off. The DATA.GOV.HK Terms of Use require attribution to the Government / Transport Department / DATA.GOV.HK.
@@ -236,7 +188,7 @@ The optional Local ADS-B layer and the broadcast-FM receiver use radio you recei
- **Houston TranStar cameras.** Keyless. The camera list is the public `traffic.houstontranstar.org/data/layers/cctvSnapshots_json.js` catalog (~1,000 cameras across Greater Houston); frames are stills at `www.houstontranstar.org/snapshots/cctv/<id>.jpg`, synthesized from a validated numeric id rather than taken from the payload, and only that host is registered. Cameras whose catalog id is `0` (a different, non-snapshot class) and cameras outside Greater Houston are dropped. TranStar's `dir` field is the side of the freeway the camera sits on, not the way it faces: TranStar's own map shows it as "Traffic closest to the camera is traveling …" (South → eastbound, North → westbound, West → southbound, East → northbound). That travel direction (90°, 270°, 180°, 0°) seeds the heading as a low-confidence prior with the low-confidence pose; `None` or an unknown value uses the shared id-hash fallback at low confidence. Headings are corrected with the in-app calibration gizmo. Off by default. TranStar's [Content Usage terms](https://traffic.houstontranstar.org/disclaimer.aspx) prohibit copying or distributing the site's information, and external framing or linking to specific images, without TxDOT's prior written permission; its FAQ grants permission-free use only for the RSS feeds. This project does not hold that permission for downstream installations, so the pack loads only when an operator who holds TxDOT's written permission sets `CCTV_HOUSTON_ENABLED=1`; with the flag unset, empty or any other value, the server makes no request to TranStar. Once enabled, it loads the 250 cameras nearest downtown, Uptown, the Medical Center, IAH and Galveston; `CCTV_HOUSTON_MAX_SOURCES` (8–400) changes the cap. Snapshots update about every 3 minutes, which is also the ambient still cadence. Frames are fetched from TranStar and relayed to the browser on demand, buffered only transiently per request, sent with `Cache-Control: no-store`, and not archived.
- **Autostrada del Brennero (A22) webcams.** Keyless. The camera list is one request to the Open Data Hub tourism API (`tourism.api.opendatahub.com/v1/WebcamInfo?source=a22`), run by NOI Techpark in South Tyrol, which republishes Autobrennero's own list of 13 motorway webcams with coordinates and kilometre markers. Frames are the operator's stills on `www.autobrennero.it/WebCamImg/km<N>.jpg`, refreshed about once a minute, and only the exact image for each camera's kilometre is registered. The feed names no bearing, so headings use the id-hash fallback at low confidence. The road climbs from about 20 m in the Po valley to about 1,370 m at the Brenner Pass, so each camera's ground elevation comes from a per-kilometre table sampled from the Copernicus GLO-90 DEM (via Open-Meteo's elevation API); a camera not in the table uses a 200 m prior. The Open Data Hub tags every record CC0 (`ClosedData: false`, Autobrennero as licence holder), but that tag is the aggregator's metadata and Autobrennero publishes no terms of its own for the images, so they are credited to the operator as a courtesy and are not redistributed. Frames are fetched at request time and never stored. Disable with `CCTV_A22_ENABLED=0`.
- **Camera frame content.** Public camera frames can contain people, vehicles and license plates, and the camera layer does nothing with them beyond putting them on the map. A successful upstream response is relayed as the provider served it: nothing in the camera pipeline blurs, redacts, enhances or restores it, and nothing there runs face, plate or object detection or otherwise recognises or classifies what is in the picture. (The ALPR layer described below maps where plate-reader cameras are, from locations contributors tagged in OpenStreetMap; it reads nothing from any camera frame.) When an upstream has no frame for a camera, what you see instead is a Google Street View still or the app's own placeholder card; the panel's source badge names which of them you are looking at, and a refresh that fails after a frame has arrived leaves the last good picture on screen rather than replacing it. The browser resamples each frame for display: onto a fixed 1920x1080 surface for the camera's projection plane in the scene, and onto a 192x108 canvas for the small ambient card. That scaling is the only change made to the picture, and the only pixels the camera layer reads back are a 64x36 downscale hashed to tell whether the picture has changed. Live streams are piped through; the still path reads the image into memory to pass it on — TxDOT wraps its JPEG in a JSON envelope, which is decoded there — and no frame is written to disk (the disk cache, `.gev-cache/`, holds feed JSON for other layers and never camera imagery). Outside that pipeline, the voice assistant is the one feature that sends imagery anywhere: when it answers a question about what is in view it may attach a downscaled screenshot of the rendered scene — including any camera plane on screen — to the request it sends to the voice model. Frames are fetched only from server-registered camera URLs — a client cannot hand the proxy a URL (see [SECURITY.md](SECURITY.md)). What the app does stays inside the line README draws: no named-person search, no face recognition, no tracking individuals.
- **Transit (GTFS-Realtime).** The proxy requests only registered operator feeds, validates redirects, bounds responses, and uses conditional requests, short snapshot caching and failure backoff. All fifty-two registered feeds, covering forty-nine regions, are live-enabled; each can be disabled independently. Vehicles use delayed playback between reports, with report age and waiting states shown separately. Session-local selected trails are available for every feed. MBTA additionally retains up to 15 minutes of recently observed positions in the running proxy, so available history can survive a browser reload; server restart clears it. History reads never fetch upstream, and other feeds have no proxy retention. Nothing is archived to disk or collected in the background. Operator text credits are included as a courtesy and do not imply endorsement. Heights are aligned to work with Google 3D tiles; subway markers are shown at street level because these feeds do not supply depth. The TTC feed is BusTime, the surface system: it carries buses and streetcars and no subway at all. About four in ten of its vehicles report no route at all (unassigned or deadheading), and those are shown with the feed default rather than a route-established mode. It sends no ETag or Last-Modified, so its conditional requests fall through to the ordinary snapshot cache. Three Golden Horseshoe neighbours run alongside it — MiWay, Hamilton HSR and Durham Region Transit — each bus-only and each polled only near its own region. Durham Region Transit publishes no bearing on any vehicle, so those sprites take their heading from observed movement like any other feed that omits it. Guelph, Kingston and Barrie extend the same pattern eastward and north. Hamilton, Guelph, Burlington, YRT and Rio’s SMTR publish route ids that are not the number on the vehicle, so those entries carry a route-name table taken from each operator’s own published GTFS; any id missing from a table renders unchanged rather than blank. A label is never invented: YRT runs a route `9101` that appears nowhere in its published routes, so it stays as the feed sends it. Burlington’s is the one that cannot be shortcut: `351` is route 1 while `3510` is route 10, so the table is a real join rather than a trimmed prefix. GO Transit / Metrolinx is deliberately absent: its REALTIME feed requires an API key issued by manual review, which the keyless admission rule excludes (its static schedule download is keyless, but carries no vehicle positions). Barrie and YRT/Viva each invite acceptance of their licence through a form, and each licence also states that using the data is itself acceptance; this deployment relies on that clause, which is recorded in both entries. Grand River Transit in Waterloo is absent for a different reason: it answers, but its feed header was months stale and it publishes no per-vehicle timestamps, so it would render vehicles that look live and are not. Ottawa’s OC Transpo falls to the same keyless admission rule as GO Transit — its realtime feed requires an API key. London Transit and Thunder Bay Transit publish vehicle positions over plain HTTP with no HTTPS endpoint available. Every other Ontario system — Brampton, Oakville, Niagara, Milton, Windsor, Greater Sudbury, UP Express and a dozen smaller operators — publishes static GTFS schedules only, with no realtime vehicle positions to register. Between the nine included and these exclusions, every Ontario realtime vehicle-position feed catalogued in the Mobility Database is accounted for.
- **Transit (GTFS-Realtime).** The proxy requests only registered operator feeds, validates redirects, bounds responses, and uses conditional requests, short snapshot caching and failure backoff. All eighteen registered feeds, covering seventeen regions, are live-enabled; each can be disabled independently. Vehicles use delayed playback between reports, with report age and waiting states shown separately. Session-local selected trails are available for every feed. MBTA additionally retains up to 15 minutes of recently observed positions in the running proxy, so available history can survive a browser reload; server restart clears it. History reads never fetch upstream, and other feeds have no proxy retention. Nothing is archived to disk or collected in the background. Operator text credits are included as a courtesy and do not imply endorsement. Heights are aligned to work with Google 3D tiles; subway markers are shown at street level because these feeds do not supply depth. The TTC feed is BusTime, the surface system: it carries buses and streetcars and no subway at all. About four in ten of its vehicles report no route at all (unassigned or deadheading), and those are shown with the feed default rather than a route-established mode. It sends no ETag or Last-Modified, so its conditional requests fall through to the ordinary snapshot cache. Three Golden Horseshoe neighbours run alongside it — MiWay, Hamilton HSR and Durham Region Transit — each bus-only and each polled only near its own region. Durham Region Transit publishes no bearing on any vehicle, so those sprites take their heading from observed movement like any other feed that omits it. Guelph, Kingston and Barrie extend the same pattern eastward and north. Hamilton, Guelph, Burlington, YRT and Rio’s SMTR publish route ids that are not the number on the vehicle, so those entries carry a route-name table taken from each operator’s own published GTFS; any id missing from a table renders unchanged rather than blank. A label is never invented: YRT runs a route `9101` that appears nowhere in its published routes, so it stays as the feed sends it. Burlington’s is the one that cannot be shortcut: `351` is route 1 while `3510` is route 10, so the table is a real join rather than a trimmed prefix. GO Transit / Metrolinx is deliberately absent: its REALTIME feed requires an API key issued by manual review, which the keyless admission rule excludes (its static schedule download is keyless, but carries no vehicle positions). Barrie and YRT/Viva each invite acceptance of their licence through a form, and each licence also states that using the data is itself acceptance; this deployment relies on that clause, which is recorded in both entries. Grand River Transit in Waterloo is absent for a different reason: it answers, but its feed header was months stale and it publishes no per-vehicle timestamps, so it would render vehicles that look live and are not. Ottawa’s OC Transpo falls to the same keyless admission rule as GO Transit — its realtime feed requires an API key. London Transit and Thunder Bay Transit publish vehicle positions over plain HTTP with no HTTPS endpoint available. Every other Ontario system — Brampton, Oakville, Niagara, Milton, Windsor, Greater Sudbury, UP Express and a dozen smaller operators — publishes static GTFS schedules only, with no realtime vehicle positions to register. Between the nine included and these exclusions, every Ontario realtime vehicle-position feed catalogued in the Mobility Database is accounted for.
- **Radio country coverage (optional).** Set `RADIO_PRIORITY_COUNTRIES=IN,PL` in the local `.env` and restart to include stations beyond the global click charts. The operator may choose up to eight unique ISO alpha-2 country codes; no key is needed. Each country adds one bounded query (at most 200 rows) and reserves up to 40 unique, usable stations inside the existing 750-station catalog. Country seeds precede specialist-tag seeds and global popularity fill; unused slots return to the shared pool. This is a coverage preference, not an exhaustive national directory or a guarantee of stations in every city. Countries with no eligible HTTPS MP3/AAC geolocated stations remain empty. An unset list preserves global selection. Country requests share the directory cache and three-worker pool; failed country queries are reported in coverage without blocking a healthy global refresh. Failed/degraded refreshes back off for one minute, with at most three mirrors per query.
- **Radio Browser.** In every installation, including when `RADIO_PRIORITY_COUNTRIES` is unset, each request tries at most three mirror candidates (later fallback candidates are skipped), and failed or partial catalog refreshes back off for 60 seconds. A cold-start failure therefore continues returning 503 during that delay even if upstream recovers. `/api/radio/stations` discovers official API mirrors, makes bounded and coalesced healthy/geolocated HTTPS-station queries, caches the normalized public-domain directory for 45 minutes, and may serve the last good catalog for up to seven days during an outage. Refreshes must meet minimum accepted-query and station coverage before replacing a warm catalog; broad/specialist responses whose rows all fail the product's health policy do not count as successful queries. Optional priority-country outcomes are advisory: valid, in-limit responses with no eligible stations succeed as empty results, while failed or malformed country queries report unsuccessful coverage without degrading the global catalog. A usable partial cold catalog is explicitly `DEGRADED`, and non-array or empty broad/specialist payloads are rejected atomically. Individual structurally malformed rows are skipped rather than failing the whole query; the response carries their bounded `rejectedStationCount` (at most 10,000), which the client adds to its own rejected rows and reports as degraded coverage without discarding the accepted generation. Every directory and click-count request rejects redirects, validates all resolved addresses as globally routable (including reserved/documentation IPv4 and special/non-global IPv6 exclusions), and pins the TLS connection to a validated address. Only MP3/AAC non-HLS directory rows with public HTTPS stream targets are returned; favicons are intentionally omitted. Pressing play connects one browser audio element directly to the selected broadcaster and calls the directory's click counter through known-ID-only `POST /api/radio/click/:uuid`. GEV never proxies, caches, records, bundles, or redistributes audio. Radio Browser supplies station-level tags, not dependable current-song or upcoming-program metadata, so Radio filtering never claims either. Direct playback exposes the listener's IP address to the broadcaster, whose own stream terms apply.
@@ -265,19 +217,13 @@ The [Bhote Koshi event pack](public/events/bhote-koshi-2026/README.md), under `p
| Dataset | Folder | License | Commercial use? | Attribution |
| --------------------------------------------------------------------------- | --------------------------------- | --------------------------------------------------------------------------------------------------------- | ------------------------------------------------ | --------------------------------------------------------------------------- |
| **Datacenters** (4,352) | `datacenters/` | **ODbL 1.0** (OpenStreetMap extract) | ✅ (attribution + share-alike on data) | © OpenStreetMap contributors (shared credit) |
| **Datacenters** (4,351) | `datacenters/` | **ODbL 1.0** (OpenStreetMap extract) | ✅ (attribution + share-alike on data) | © OpenStreetMap contributors (shared credit) |
| **Dams** (704) | `dams/` | **ODbL 1.0** (OpenInfraMap / OSM extract) | ✅ (attribution + share-alike on data) | © OpenStreetMap contributors (shared credit) + Open Infrastructure Map |
| **Military area names** (36,466) | `osm_military_names/` | **ODbL 1.0** (OpenStreetMap, via Overture Maps) | ✅ (attribution + share-alike on data) | © OpenStreetMap contributors (shared credit) + Overture Maps Foundation |
| **Québec CCTV source catalog** (1,120 locations) | `public/quebec-cameras.json` | Mixed: MTQ locations CC BY 4.0; Montréal camera nodes ODbL 1.0; scenic records retain per-record provenance. No video files are bundled or cached. | Location data is per-record; video remains hosted by Québec 511 and its platform terms apply; scenic source pages may have separate terms | MTQ / Données Québec CC BY 4.0; © OpenStreetMap contributors (ODbL); scenic operator and position source shown in the selected-camera panel |
| **World camera image subset** (2,230 locations) | `config/cctv_sources.world-image-cameras.json` | Mixed: ODOT TripCheck public images (ODOT permits use with credit) and MLIT site content under terms compatible with CC BY 4.0. Frames are not bundled or persisted; the server holds a bounded in-memory cache. | Per-source terms apply; NZTA Journey Planner web-cache rows are excluded; Vegagerðin rows are served by the Iceland pack instead | Oregon Department of Transportation (Camera courtesy of ODOT) and Japan Ministry of Land, Infrastructure, Transport and Tourism (出典:国土交通省 川の防災情報) |
| **TeleGeography Submarine Cable Map** (712 cables + 1,917 landing points) | `telegeography_submarine_cables/` | **CC BY-NC-SA 3.0** | ❌ **NonCommercial — remove for commercial use** | "© TeleGeography — submarinecablemap.com" |
| **UCS Satellite Database** UCS-Satellite-Database 5-1-2023 (3,564 records; operator, owner country, purpose) | `ucs_satellites/` | **Free, unrestricted use** (UCS database manual, section 3) | ✅ (acknowledgement requested) | "UCS Satellite Database (UCS-Satellite-Database 5-1-2023), https://www.ucsusa.org/satellite_database" |
| **Imaging sensor pack** (46 Earth-observation platforms: swath, resolution) | `imaging_sensors/` | Individual specification facts from operator and agency pages, cited per entry | ✅ (facts only) | "Imaging sensor swaths: operator and agency specifications" |
| **Natural Earth physical regions** (1,046 land + 292 marine named polygons) | `natural_earth/` | **Public domain** | ✅ (no restrictions) | "Made with Natural Earth" (courtesy credit — not legally required) |
| **DataSF Analysis Neighborhoods** (41 SF neighborhood polygons) | `neighborhoods/` | **PDDL 1.0** (public domain) | ✅ (no restrictions) | "City & County of San Francisco — DataSF" (courtesy — not legally required) |
| **Natural Earth countries, states and provinces** (260 admin-0/map units + 4,587 admin-1 polygons) | `natural_earth/` | **Public domain** | ✅ (no restrictions) | "Made with Natural Earth" (courtesy credit — not legally required) |
| **Natural Earth disputed areas** (11 of the source's 99 features, hand-picked; [list](src/data/local_data/natural_earth/README.md#disputed-areas-disputed_areasjson)) | `natural_earth/` | **Public domain** | ✅ (no restrictions) | "Made with Natural Earth" (courtesy credit — not legally required) |
| **Natural Earth populated places** (7,342 points: name, position, rank) | `natural_earth/` | **Public domain** | ✅ (no restrictions) | "Made with Natural Earth" (courtesy credit — not legally required) |
| **US Census Bureau counties** (3,235 county polygons) | `us_census_counties/` | **Public domain** | ✅ (no restrictions) | "U.S. Census Bureau" (courtesy — not legally required) |
| **US Census Bureau places** (32,629 city, town and CDP polygons) | `us_census_places/` | **Public domain** | ✅ (no restrictions) | "U.S. Census Bureau" (courtesy — not legally required) |
| **Who's On First neighborhoods** (61,441 polygons) | `wof_neighborhoods/` | Per source: CC BY, ODC-By, Open Government licences, CC0, PDDL, public domain (folder `ATTRIBUTION.md`) | ✅ (attribution) | "Neighborhood boundaries: Who's On First and contributors"; per-source credits in the folder |
@@ -333,20 +279,11 @@ Bundled OSM-derived data is distributed under [ODbL 1.0](https://opendatacommons
> We acknowledge the use of data and/or imagery from NASA's Fire Information for Resource Management System (FIRMS) (https://earthdata.nasa.gov/firms), part of NASA's Earth Observing System Data and Information System (EOSDIS).
FIRMS active fires are **fetched live at runtime** (CC0 / U.S. public domain data): the
`/api/firms` server-side proxy merges the two VIIRS NRT sources (NOAA-20, NOAA-21)
and MODIS NRT (Terra + Aqua), clamped to the trailing 24 h, cached 30 min to respect the shared MAP_KEY
`/api/firms` server-side proxy merges the three VIIRS NRT sources (NOAA-20, NOAA-21,
Suomi-NPP) and MODIS NRT (Terra + Aqua), clamped to the trailing 24 h, cached 30 min to respect the shared MAP_KEY
transaction quota. Requires a free `FIRMS_MAP_KEY`
(https://firms.modaps.eosdis.nasa.gov/api/map_key/); the layer is empty without it.
The former bundled 2026-05-25 snapshot was removed 2026-07-16. Suomi NPP (`VIIRS_SNPP_NRT`)
is not requested: NASA stopped delivering it on 2026-11-02.
### UCS Satellite Database (`ucs_satellites/`)
Operator, owner country and purpose for active satellites, keyed by NORAD number, from the Union of Concerned Scientists Satellite Database release **UCS-Satellite-Database 5-1-2023** (data current through 2023-05-01; https://www.ucsusa.org/satellite_database). The satellites layer joins it to CelesTrak positions and SATCAT records to file each satellite as a station, imaging, telecom, Starlink, probe or debris object, and to name its operator and owner. Section 3 of the UCS database manual: "The Database is free and its use is unrestricted. We request that its use be acknowledged and referenced in written materials. References should include the version of the Database that was used, which is indicated by the name of the Excel file, and a link to or URL for the webpage www.ucsusa.org/satellite_database." The app acknowledges it in the Data attribution popover. UCS has paused updates since this release; Starlink rows are dropped (Starlink is classed by name). Build: `scripts/build-ucs-satellites.py`; curation in the folder README.
### Imaging sensor pack (`imaging_sensors/`)
Swath widths and ground resolutions for 46 Earth-observation platforms (Landsat, Sentinel, MODIS, VIIRS, GOES, Himawari, Gaofen, CBERS, RADARSAT, TerraSAR-X, COSMO-SkyMed, ALOS, KOMPSAT, SPOT, Pléiades, WorldView, SkySat, PlanetScope, ICEYE, Capella, Umbra and others), compiled 2026-11-04 from the operator and agency pages cited per entry. The orbital view draws each satellite's sensor swath to scale and caps its zoom at that swath. Facts only; no text or images copied. A swath is confirmed on an operator, agency (ESA's eoPortal included) or academic page; resellers are not cited. The CEOS MIM Database (CC BY 4.0) is not yet used. Sources and unverified values: the folder README.
The former bundled 2026-05-25 snapshot was removed 2026-07-16.
### Natural Earth physical regions (`natural_earth/`)
@@ -367,16 +304,6 @@ https://www.naturalearthdata.com/about/terms-of-use/). We credit anyway: "Made w
Earth". Registration in the in-app `dataCredits.js` attribution list ships with the resolver
wiring (see below).
### Natural Earth populated places (`natural_earth/populated_places.json`)
Natural Earth 10m populated places (`ne_10m_populated_places_simple`, the same pinned
`nvkelso/natural-earth-vector` commit and SHA-256 as the admin-1 build reads), reduced to one
row per place: name, latitude and longitude rounded to 3 decimals, `scalerank` and `rank_max`
(7,342 places, 229 KB). From 8x the orbital view names the most prominent place within half
its width ("Sichuan, China · near Chengdu"), offline, with the states and provinces pack giving
the division. Public domain; credited "Made with Natural Earth" with the other packs. Rebuild:
`node scripts/build-admin-packs.mjs --only places`; provenance in the folder README.
### Sky background (`sky/`)
Sources, licences, the changes CC BY asks to state, hashes and build commands
@@ -547,4 +474,4 @@ NOAA density imagery with the raw GLM product.
## In-app attribution
The required Google Maps / Cesium credit renders on the on-globe credit line (`#cesium-credits`, bottom-left) and must stay visible — including in clean-view and recording modes (the whole line, logo + "Google Maps" + the "Data attribution" link, stays on screen; only the GEV panels/HUD fade). The layer-specific credits (adsb.lol, adsbdb, Planespotters.net, TeleGeography, OSM datacenters/dams/roads, NASA FIRMS, CelesTrak, USGS, City of Austin, Fintraffic, GBFS, Radio Browser, OpenSky, AISStream) are registered into the expandable **"Data attribution"** popover on that credit line via `viewer.creditDisplay.addStaticCredit(new Cesium.Credit(html, /* showOnScreen */ false))` — see `src/data/dataCredits.js`. When you add a new data source, add its license and attribution to this file **and** append an entry to `DATA_CREDITS` in `src/data/dataCredits.js` so it surfaces in the app.
The required Google Maps / Cesium credit renders on the on-globe credit line (`#cesium-credits`, bottom-left) and must stay visible — including in clean-view and recording modes (the whole line, logo + "Google Maps" + the "Data attribution" link, stays on screen; only the GEV panels/HUD fade). The layer-specific credits (adsb.lol, adsbdb, TeleGeography, OSM datacenters/dams/roads, NASA FIRMS, CelesTrak, USGS, City of Austin, Fintraffic, GBFS, Radio Browser, OpenSky, AISStream) are registered into the expandable **"Data attribution"** popover on that credit line via `viewer.creditDisplay.addStaticCredit(new Cesium.Credit(html, /* showOnScreen */ false))` — see `src/data/dataCredits.js`. When you add a new data source, add its license and attribution to this file **and** append an entry to `DATA_CREDITS` in `src/data/dataCredits.js` so it surfaces in the app.
+16 -21
View File
@@ -212,7 +212,7 @@ Keep the `env`: bash treats `UID` as read-only, so a plain `UID=1026` in front o
On SELinux hosts (Fedora, RHEL), compose mounts the checkout with `:z` so the container may read it; Docker may relabel the checkout for container access, which can take a moment on a large checkout. Without that, the start fails with `Cannot find module '/app/scripts/docker-start.mjs'`.
Behind a reverse proxy, put your keys in `.env` or pass them as environment variables - Provider Settings and the News layer refuse proxied requests on purpose - and add the hostname you serve it on, for example `GEV_ALLOWED_HOSTS=globe.example.lan` (see [Sharing an instance](#-sharing-an-instance)). The map routes compare the browser's `Origin` with the `Host` the server sees, so the proxy must pass the original `Host` and, when it terminates TLS, `X-Forwarded-Proto`. Caddy does both by default; for nginx add `proxy_set_header Host $http_host;`, `proxy_set_header X-Forwarded-Proto $scheme;` and `proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;`. On an install outside Docker, that forwarding header also rate-limits the proxy's users apart from your own browser. Under the default Docker bridge network every client on the host, your browser included, reaches the container from its gateway address and shares one Overpass bucket.
Behind a reverse proxy, put your keys in `.env` or pass them as environment variables - Provider Settings refuses proxied requests on purpose - and add the hostname you serve it on, for example `GEV_ALLOWED_HOSTS=globe.example.lan` (see [Sharing an instance](#-sharing-an-instance)). The map routes compare the browser's `Origin` with the `Host` the server sees, so the proxy must pass the original `Host` and, when it terminates TLS, `X-Forwarded-Proto`. Caddy does both by default; for nginx add `proxy_set_header Host $http_host;`, `proxy_set_header X-Forwarded-Proto $scheme;` and `proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;`. On an install outside Docker, that forwarding header also rate-limits the proxy's users apart from your own browser. Under the default Docker bridge network every client on the host, your browser included, reaches the container from its gateway address and shares one Overpass bucket.
</details>
@@ -236,8 +236,8 @@ the container.
### Path 4 — GitHub Codespaces (cloud, no local install)
Codespaces requires a GitHub account. Overpass queries, the News layer and OpenAI
voice token requests return HTTP 403 through its forwarded HTTPS URL, even with API keys
Codespaces requires a GitHub account. Overpass queries and OpenAI voice token
requests return HTTP 403 through its forwarded HTTPS URL, even with API keys
in `.env` and `GEV_ALLOWED_HOSTS` configured. Use a local setup for these features.
The codespace runs the dev server in a cloud workspace, so Node and the dependencies
@@ -340,7 +340,7 @@ Choose a first-run mission, or try these in order. The GIFs show Google Photorea
7. **Talk to it** _(needs an OpenAI key)_: _"Take me to LAX and select the nearest airborne aircraft."_
8. **Come home.** Hit **Reset Globe** — or just say _"zoom out to a globe view."_
**Keyboard:** `1`–`6` visual styles · `H` HUD · `D` detection · `C` CCTV · `` ` `` frame rate · `Esc` out. Trackpad pinch zooms the globe.
**Keyboard:** `1`–`6` visual styles · `H` HUD · `D` detection · `C` cockpit · `` ` `` frame rate · `Esc` out. Trackpad pinch zooms the globe.
---
@@ -426,7 +426,7 @@ _Ask for radio near anywhere and the globe starts broadcasting — every station
## 🛰️ What's on the Globe
Twenty-three layers and map sources. **Twenty-one have a keyless path.** Some offer additional capabilities with a provider key. (🟢 no key · 🟡 free key · 🔴 metered.)
Twenty-two layers and map sources. **Twenty have a keyless path.** Some offer additional capabilities with a provider key. (🟢 no key · 🟡 free key · 🔴 metered.)
| Layer | What you get | Source | Auth |
| --------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------- | --------------------------------------------------------------------------------------------------- |
@@ -436,16 +436,15 @@ Twenty-three layers and map sources. **Twenty-one have a keyless path.** Some of
| 📡 **GNSS Integrity** | Regional navigation accuracy scores; anomalies do not confirm interference | adsb.lol, GPSJAM aggregation | 🟢 |
| 🔥 **Fire Perimeters** | Incident boundaries and reported containment | WFIGS / NIFC | 🟢 |
| 🚢 **Live Vessels** | Thousands of ships worldwide | AISStream | 🟡 |
| 🛰️ **Satellites** | About 1,300 objects in six classes (Stations, Imaging, Telecom, Starlink, Probes, Debris) with a toggle and a live count each; Starlink loads its whole shell | CelesTrak | 🟢 |
| 🛰️ **Satellites** | 838-object catalog, color-coded by class with a live legend — the **DENSE** chip drops in the whole Starlink shell | CelesTrak | 🟢 |
| 🌍 **Earthquakes** | Global seismic activity, last 24h | USGS | 🟢 |
| 📰 **News** | The last 24 hours of news in the right-rail **NEWS** panel, narrowed to 12, 6, 3 or 1 hours, with picture cards and clips. **Conflicts**: physical violence worldwide from GDELT's event files, one report per story at GDELT's place for it, plus Liveuamap's mapped items with a key. **Local**: the news of the country at the view centre, its outline and name on the globe; a disputed area of the bundled list, or a territory without a country code, is named as itself. Places and categories are machine-coded and can be wrong | GDELT · Liveuamap · World News API, or your own news server | 🟢 Conflicts · 🟡 Local (World News API) · 🔴 Liveuamap items |
| ◍ **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** | up to 7,350 camera locations (4,000 shared round-robin slots, plus room for 1,120 Québec and 2,230 World image cameras) projected _into_ the 3D space — Austin · Texas (TxDOT) · California (Caltrans) · London (TfL) · Ontario (511, free developer key) · Ottawa (City of Ottawa, free access certificate) · 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) · Iceland (Vegagerðin) · Hong Kong (Transport Department) · Taiwan (Freeway Bureau, MOTC) · Québec (MTQ locations with Québec 511 links, Montréal locations, regional webcams) · Oregon (TripCheck) · Japan (MLIT river cameras) · Italy (A22 Brennero motorway), plus opt-in New York City (NYC DOT TMC, needs your own NYC DOT agreement), opt-in Lithuania (Via Lietuva / Eismoinfo, photo licence unconfirmed), opt-in Québec 511 in-browser video (embedding terms unconfirmed), opt-in Florida (FL511, individual personal use only; other use needs FDOT's written consent) and opt-in Houston (TranStar, enable only with TxDOT's written permission per TranStar's Content Usage terms). Positions are published or attributed; 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, free developer key) · 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) · Hong Kong (Transport Department) · Taiwan (Freeway Bureau, MOTC) · Italy (A22 Brennero motorway), plus opt-in New York City (NYC DOT TMC, needs your own NYC DOT agreement), opt-in Lithuania (Via Lietuva / Eismoinfo, photo licence unconfirmed), opt-in Florida (FL511, individual personal use only; other use needs FDOT's written consent) and opt-in Houston (TranStar, enable only with TxDOT's written permission per TranStar's Content Usage terms). 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 |
| 📻 **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, Rio de Janeiro (buses and BRT), Szczecin, Lisbon (Carris Metropolitana and the city’s Carris), Prague, Warsaw, Kraków, Poznań, Gdańsk, Rzeszów, Vilnius, Bucharest, Cluj-Napoca, Iași, Rome, Turin, Venice (buses and waterbuses), Bordeaux, Montpellier, Rouen, Toulon, Le Havre, Tours, Málaga, Sendai, Johor Bahru, Kota Bharu, Adelaide, Atlanta, Denver, Edmonton, Halifax, Victoria, 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 | 🟢 |
| 🚌 **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, Rio de Janeiro (buses and BRT), 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) | 🟢 |
| ⌖ **OSM Places** | Voice place search ("hospitals in Kathmandu") from OpenStreetMap, drawn as numbered points that follow-up questions can filter. Needs your own Overpass server (`OVERPASS_UPSTREAMS`); off otherwise |
@@ -474,7 +473,7 @@ In the app, **Data Layers** groups them as Movement, Cameras, Infrastructure, Ev
_The Space Missions layer replaying a Falcon 9 ascent — labeled `RECONSTRUCTED ESTIMATE`, scrubbable 0.25×–4×._
**Also on the globe:** neighborhood overlays · an optional cockpit WX cloud effect. **Bundled static infrastructure:** Datacenters (4,352), Dams (704), and Submarine Cables (712).
**Also on the globe:** neighborhood overlays · an optional cockpit WX cloud effect. **Bundled static infrastructure:** Datacenters (4,351), Dams (704), and Submarine Cables (712).
![Diving into the Bahamas and revealing labeled submarine cable routes beneath the globe](docs/media/09-undersea-cables.gif)
@@ -486,9 +485,9 @@ _The Space Missions layer replaying a Falcon 9 ascent — labeled `RECONSTRUCTED
> Street-level photos from any provider in one panel. This build ships **Mapillary** (a free client token), **Google Street View** (your Google Maps key), **KartaView** and **Panoramax** (no key).
**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 Street Level starts on **KartaView** instead, and the Mapillary chip says which key to add. **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.
**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 when its token is set; otherwise KartaView is. 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 beside the SINCE value (ANY DATE (by upload date)); 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. 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 beside the SINCE value (ANY DATE (by upload date)); 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.
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`.
@@ -568,14 +567,12 @@ and configuration details.
No key needed for Wind, Rain radar, Satellite clouds, Lightning density,
Cyclone advisories, Mapped ALPR Cameras, Transit, Directions, Delaware live
video, the News layer's Conflicts section, and coordinate or bundled-place
search. A layer row waiting on a key
video, and coordinate or bundled-place search. A layer row waiting on a key
names it.
### Choose the capabilities you want
Nine keys. Six have a free tier. Of the three 🔴 ones, Google Maps and OpenAI are
metered and Liveuamap is a paid subscription:
Seven keys. Five have a free tier, and the two 🔴 ones are metered:
| | Key | Why | Get it |
| --- | --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
@@ -586,8 +583,6 @@ metered and Liveuamap is a paid subscription:
| 🟡 | **NASA FIRMS** | 🔥 Live active fires | [firms.modaps.eosdis.nasa.gov](https://firms.modaps.eosdis.nasa.gov/api/map_key/) — free |
| 🟡 | **TomTom** | 🚦 Live flow speeds and congestion colors for the simulated traffic layer | [my.tomtom.com](https://my.tomtom.com/keys) — free tier available |
| 🟡 | **Mapillary** | 📷 Street Level coverage and photos. A public client token by design (see SECURITY.md) | [mapillary.com/dashboard/developers](https://www.mapillary.com/dashboard/developers) — register an app, copy its Client Token |
| 🟡 | **World News API** | 📰 News layer, Local: the news of the country in view. The free plan allows 50 points a day; `WORLDNEWSAPI_DAILY_READS` (default 30) keeps Core inside it. Core keeps each country's news for up to 1 hour, and World News API's [terms](https://worldnewsapi.com/terms/) allow caching only with its prior written permission, which is yours to get. The Data attribution popover credits it with a link | [worldnewsapi.com/console](https://worldnewsapi.com/console/) — free plan |
| 🔴 | **Liveuamap** | 📰 News layer, Conflicts: Liveuamap's mapped items beside GDELT's reports (🟢 Conflicts works without) | [liveuamap.com/promo/api](https://liveuamap.com/promo/api) — paid API |
![Diving from city-scale live congestion straight into an intersection's public camera](docs/media/05-traffic-to-cctv.gif)
@@ -644,7 +639,7 @@ and panel. DelDOT uses its official HTTPS HLS catalog links. Disable that
pack with `CCTV_DELDOT_ENABLED=0`.
The server allows two concurrent sessions. Each retains at most 12 segments
and 24 MiB in memory; individual downloads are capped at 8 MiB with a ten
and 24 MiB in memory; individual downloads are capped at 4 MiB with a ten
second deadline. There are no segment files or ffmpeg processes. Redirects,
off-origin references, encrypted playlists and non-MPEG-TS segments are refused.
Each decoder has its own client lease (at most eight per session), including
@@ -661,8 +656,8 @@ Honest numbers, roughly, as of mid-2026 — always check the provider pricing pa
| | Cost reality |
| ------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **🟢 Most layers** | **$0, no signup.** OpenSky anon, USGS, CelesTrak, adsb.lol, city CCTV, Radio Browser, GBFS, Launch Library 2, transit feeds, OSRM routing, NOAA and ECMWF weather, OpenStreetMap ALPR mapping, GDELT news events, bundled datasets. |
| **🟡 The free-key tier** | **$0 with a signup.** AISStream, FIRMS, TomTom, OpenSky, World News API, plus Cesium ion for eligible personal/non-commercial use. Provider quotas and eligibility still apply. |
| **🟢 Most layers** | **$0, no signup.** OpenSky anon, USGS, CelesTrak, adsb.lol, city CCTV, Radio Browser, GBFS, Launch Library 2, transit feeds, OSRM routing, NOAA and ECMWF weather, OpenStreetMap ALPR mapping, bundled datasets. |
| **🟡 The free-key tier** | **$0 with a signup.** AISStream, FIRMS, TomTom, OpenSky, plus Cesium ion for eligible personal/non-commercial use. Provider quotas and eligibility still apply. |
| **🗺️ Google 3D tiles** | **Free through an eligible Cesium ion Community account within its quota; metered through a direct Google key.** Use the direct route for Google place search or commercial deployment, verify current provider terms, and set budget alerts where billing is enabled. |
| **🔴 OpenAI voice** | API-key sessions keep the live spend readout, STD/MINI model toggle, $2 warning, and **$5 hard cap**. ChatGPT OAuth is an explicit choice in Provider Settings. Its usage is tracked without an API-dollar estimate or spend cap. The voice context window stays deliberately short. |
+3 -4
View File
@@ -69,20 +69,19 @@ browser-key fallback for existing single-key setups.
The data proxies under `server/providers/` are written so the browser cannot turn the server into an open relay:
- **No arbitrary-URL fetching.** The CCTV frame proxy fetches only server-registered camera/frame URLs — clients cannot pass an upstream URL to fetch (SSRF mitigation). Other proxies target fixed upstream hosts.
- **Live HLS is bounded and origin-pinned.** Registered HTTP(S) HLS sources use a Node puller that rejects redirects and off-origin playlist references, bounds playlists to 256 KiB and segment bodies to 8 MiB while streaming, and aborts timed-out or released sessions. At most two sessions retain 12 segments/24 MiB each, plus bounded download buffers. Stores are memory-only and idle sessions expire after 15 seconds. RTMP/ffmpeg execution is not enabled. Locally configured source URLs remain an operator trust boundary.
- **Live HLS is bounded and origin-pinned.** Registered HTTP(S) HLS sources use a Node puller that rejects redirects and off-origin playlist references, bounds playlists and segment bodies while streaming, and aborts timed-out or released sessions. At most two sessions retain 12 segments/24 MiB each, plus bounded download buffers. Stores are memory-only and idle sessions expire after 15 seconds. RTMP/ffmpeg execution is not enabled. Locally configured source URLs remain an operator trust boundary.
- **Radio is not an audio relay.** `/api/radio/stations` contacts only allowlisted Radio Browser HTTPS hosts and paths, rejects redirects, rejects any hostname with a loopback/private/link-local/metadata/non-public A or AAAA result, and pins each TLS connection to a validated address. It returns normalized public HTTPS stream URLs; `/api/radio/click/:uuid` applies the same destination policy and accepts only station IDs from the current bounded catalog. The browser then connects directly to the broadcaster after an explicit playback action, so the broadcaster sees the listener's IP address. GEV never proxies, caches, records, or redistributes audio.
- **Québec 511 video plays in the browser, opt-in and after Play.** With `CCTV_QUEBEC_VIDEO_ENABLED=1`, `/api/cctv/sources` lists, for each MTQ camera, one URL built server-side from its numeric id on `https://www.quebec511.info/Carte/Fenetres/camera.ashx`; the overlay accepts no other host, path or format. The browser loads that clip only after the viewer presses Play for the selected camera, so Québec 511 sees the viewer's IP address, and the panel says so before Play. Selecting a camera by click, auto-hop, a scene or voice never loads it, and switching cameras stops playback. GEV never proxies, caches or reads the video's pixels. With the flag unset (the default) the browser makes no request to Québec 511.
- **No verbatim client headers upstream.** The CCTV media route is the one route that relays a request header (`Range`, for video seeking). It is parsed and canonicalized before it is forwarded: one `bytes=` range only, with every accepted form — explicit span, open-ended and suffix — bounded to the same 64 MiB ceiling the relay applies to a declared response body. Multi-range, malformed, inverted and non-`bytes` values are dropped and the request proceeds without a `Range`, as RFC 7233 §3.1 prescribes. Bounding the request bounds what is asked for: a response that declares no length — live streamed media, or a chunked body from an upstream that ignores the `Range` — has no ceiling. An upstream request the browser has stopped waiting for is cancelled rather than left running, whether the viewer leaves before the headers arrive or during the body.
- **Transit fetches registered feeds only.** `/api/transit/vehicles/<id>` resolves the id against `src/data/transitFeeds.js`; the browser never supplies a URL, and a feed that is registered but disabled does not resolve at all. Redirects are followed manually and each hop is validated against the feed's own https origin before it is requested, so an off-origin or downgraded hop is refused rather than contacted. Bodies are capped at 8 MB, the protobuf is decoded server-side under entity-count and string-length ceilings, a differential feed is refused, and snapshots live 15 s in memory with no disk cache. A per-feed admission limiter and a failure cooldown ladder bound what this process can ask of any operator.
- **Local receiver feeds are operator-configured and local-only.** `/api/local-receivers/aircraft` reads only the `aircraft.json` URLs in the server's `LOCAL_RECEIVER_FEEDS`; the browser never supplies an address. Each host must pass the shared tap address rule (`src/data/tapAddress.js`: loopback, RFC1918, `localhost`, `*.local`; no link-local, IPv6 or other names), the scheme must be http(s), the path must end in `aircraft.json`, and credentials, queries and fragments are refused. Invalid entries are logged at startup and never fetched. Reads use a 2 s timeout, refuse redirects, cap bodies at 2 MB and share one read per second; responses name each feed by band only and never carry upstream error text.
- **Voice place search runs only on the operator's Overpass.** `/api/osm/features` answers only when `OVERPASS_UPSTREAMS` is set and never falls back to a public instance. The client sends a curated preset id, a box, a mode and a limit; the server writes and quotes the Overpass query itself, so no client or model text reaches it. Boxes are capped (36 deg² for a count, 4 deg² for a listing), as are the feature limit, the Overpass timeout and memory, and the response bytes read. Each client (IPv6 clients keyed by /64, as on `/api/overpass`) runs one search at a time and two run at once in all, under a per-client rate limit (12/min) and a global one (30/min); a search gets 22 s across every configured endpoint in turn (Overpass itself is told 20 s), which ends before the voice answer stops waiting at 25 s. The route sits behind the cross-site gate below. Answers are cached in memory and on disk (`.gev-cache/osm-features/`, at most 400 entries and 256 MB, kept a day), keyed to the configured instance so a repointed `OVERPASS_UPSTREAMS` never serves the old one's answers; a malformed answer is never cached.
- **Validated response formats.** Proxied bodies are relayed only in the format the route serves. CCTV frames and the Street View fallback accept JPEG, PNG, GIF, WebP and AVIF only, sent under their canonical type; any other type falls through to the next fallback. The one exception is a source registered as an MJPEG still (the Taiwan Highway Bureau feeds): its `multipart/x-mixed-replace` answer is read only up to its first JPEG part, capped at 2 MB, and sent as `image/jpeg`; a multipart answer from any other camera falls through. A camera whose API needs an operator credential (the City of Ottawa certificate, `OTTAWA_CAMERA_CERTIFICATE`) has a keyless catalog URL; the frame proxy adds the credential only at fetch time, only for a URL inside that camera's pinned path, so `/api/cctv/sources` and `/api/cctv/stream` never carry it. Live CCTV media must match its feed kind (`video/mp4`, `video/webm`, or `multipart/x-mixed-replace` with a boundary) before it is relayed or the camera counts as healthy, and its redirects stay on the registered origin. GBFS bodies must parse as JSON and are re-serialized as `application/json; charset=utf-8`. Camera catalog URLs are pinned by parsed URL — scheme, exact host, no credentials, default port, and a normalized path inside the agency's image directory — and a redirect on a pinned host must stay inside the same directory. Every `/api/cctv` route uses the keyless cross-site checks described below, so cameras stay reachable behind a reverse proxy; a request carrying forwarding headers never reaches the key-bearing Street View fallback and gets the placeholder frame instead, as when no key is configured.
- **Validated response formats.** Proxied bodies are relayed only in the format the route serves. CCTV frames and the Street View fallback accept JPEG, PNG, GIF, WebP and AVIF only, sent under their canonical type; any other type falls through to the next fallback. Live CCTV media must match its feed kind (`video/mp4`, `video/webm`, or `multipart/x-mixed-replace` with a boundary) before it is relayed or the camera counts as healthy, and its redirects stay on the registered origin. GBFS bodies must parse as JSON and are re-serialized as `application/json; charset=utf-8`. Camera catalog URLs are pinned by parsed URL — scheme, exact host, no credentials, default port, and a normalized path inside the agency's image directory — and a redirect on a pinned host must stay inside the same directory. Every `/api/cctv` route uses the keyless cross-site checks described below, so cameras stay reachable behind a reverse proxy; a request carrying forwarding headers never reaches the key-bearing Street View fallback and gets the placeholder frame instead, as when no key is configured.
- **Consistent API response headers.** Every `/api` response, including errors, partial content, Host refusals and unknown routes, carries `Content-Security-Policy: default-src 'none'; sandbox` and `X-Content-Type-Options: nosniff` (`apiResponseHeadersPlugin` in `build/vite.js`, installed ahead of the Host check). A route that sets its own policy adds it as a further policy; it never replaces this one.
- **Response-size caps and timeouts** on proxied responses.
- **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, `/api/tomtom/*`, backed by its persistent daily tile budget, and `/api/news/*`, which spends the World News API daily reads, Liveuamap reads and GDELT downloads) 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 map routes (`/api/terrain/heights`, `/api/drone-airspace`, `/api/inaturalist/observations`, `/api/acartia/sightings`, `/api/overpass` and voice place search's `/api/osm/features`, which spends the same operator Overpass) use the same cross-site checks, minus the forwarding-header refusal: they guard only per-client quotas and caches, not a key or a bill, and the map and voice need them to work behind a reverse proxy. `/api/overpass` also admits at most 300 POSTs a minute per peer (IPv6 peers keyed by /64) and 900 a minute across all remote peers before it reads a body. A direct loopback request — a loopback socket with none of the forwarding headers `hasProxySignals` checks (`Forwarded`, `Via`, `X-Forwarded-For`, `X-Forwarded-Host`, `X-Forwarded-Port`, `X-Forwarded-Proto`, `X-Real-IP`, `CF-Connecting-IP`, `CF-Ray`) — draws on its own per-peer admission window, so remote peers cannot exhaust the owner's admission. A reverse proxy on the same machine must add at least one of those headers (Caddy and Cloudflare do by default; nginx needs `proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;`, plus `proxy_set_header Host $http_host;` and `proxy_set_header X-Forwarded-Proto $scheme;` so the `Origin` check matches), or its traffic is indistinguishable from the owner's and shares the owner's window. With one, its requests are then counted as remote, in one bucket per proxy socket — the forwarded client address is never trusted as identity — so all users behind one proxy share 300/min. The isolation needs a loopback socket: under the default Docker bridge network every host-side client, the owner included, arrives from the container gateway and is one remote peer. **Limit:** this isolates admission only. The upstream budget (90 requests/min per client key, 300/min across all clients) is shared, so once remote traffic has used the global upstream budget, an owner request that misses the cache can still get `429`, or `503` when remote misses hold every upstream concurrency slot; cache hits are unaffected. The CCTV routes (`/api/cctv/*`) use the same keyless checks; their one key-bearing step, the Street View fallback, is skipped for any request with forwarding headers.
- **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 map routes (`/api/terrain/heights`, `/api/drone-airspace`, `/api/inaturalist/observations`, `/api/acartia/sightings`, `/api/overpass` and voice place search's `/api/osm/features`, which spends the same operator Overpass) use the same cross-site checks, minus the forwarding-header refusal: they guard only per-client quotas and caches, not a key or a bill, and the map and voice need them to work behind a reverse proxy. `/api/overpass` also admits at most 300 POSTs a minute per peer (IPv6 peers keyed by /64) and 900 a minute across all remote peers before it reads a body. A direct loopback request — a loopback socket with none of the forwarding headers `hasProxySignals` checks (`Forwarded`, `Via`, `X-Forwarded-For`, `X-Forwarded-Host`, `X-Forwarded-Port`, `X-Forwarded-Proto`, `X-Real-IP`, `CF-Connecting-IP`, `CF-Ray`) — draws on its own per-peer admission window, so remote peers cannot exhaust the owner's admission. A reverse proxy on the same machine must add at least one of those headers (Caddy and Cloudflare do by default; nginx needs `proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;`, plus `proxy_set_header Host $http_host;` and `proxy_set_header X-Forwarded-Proto $scheme;` so the `Origin` check matches), or its traffic is indistinguishable from the owner's and shares the owner's window. With one, its requests are then counted as remote, in one bucket per proxy socket — the forwarded client address is never trusted as identity — so all users behind one proxy share 300/min. The isolation needs a loopback socket: under the default Docker bridge network every host-side client, the owner included, arrives from the container gateway and is one remote peer. **Limit:** this isolates admission only. The upstream budget (90 requests/min per client key, 300/min across all clients) is shared, so once remote traffic has used the global upstream budget, an owner request that misses the cache can still get `429`, or `503` when remote misses hold every upstream concurrency slot; cache hits are unaffected. The CCTV routes (`/api/cctv/*`) use the same keyless checks; their one key-bearing step, the Street View fallback, is skipped for any request with forwarding headers.
- **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.
-3
View File
@@ -1,3 +0,0 @@
- Keep outside text in voice and MCP tool results to one line of 180
characters: ALPR tags, station names and other display fields (asturksever,
#1280).
@@ -1,8 +0,0 @@
- Expand CCTV coverage with a keyless Taiwan Highway Bureau fallback, 1,120
Québec camera locations, City of Ottawa traffic cameras (needs a free city
access certificate, `OTTAWA_CAMERA_CERTIFICATE`), and 2,230 curated
image-backed cameras from Oregon and Japan (InoukT, #1506).
- Québec 511 in-browser video is opt-in (`CCTV_QUEBEC_VIDEO_ENABLED=1`) and
loads only after the viewer presses Play; enforce provider-specific frame
refresh limits, and report recognized MLIT maintenance images as degraded
instead of healthy camera snapshots.
-5
View File
@@ -1,5 +0,0 @@
- One world clock now drives everything with a time of its own: the sky,
the HUD clock, satellites, launches and weather. A timeline along the
bottom of the screen, shown while one of those layers is on, plays, pauses
and scrubs it, with a tick for each launch or frame and a way back to
live. Share links made away from live carry the time (samehkhamis, #1512).
@@ -1,7 +0,0 @@
- The timeline brings launches up as the time passes them, offers play
speeds from real time to a week a second, and plays smoothly, holding only
when the next weather frame has yet to load, with a spinner by the time
while it waits. The time rides in a bubble over the handle. The track zooms
with a scroll or a pinch, drags along, and goes to the time under a tap, on
a phone as on a desktop. Zooming out on the globe stops while the globe is
still in view instead of leaving it a speck (samehkhamis, #1513).
-3
View File
@@ -1,3 +0,0 @@
- The agent and voice query tools wrap whole-degree directions into 0–359, so
a vessel course or heading, aircraft course, transit bearing, camera heading
or satellite azimuth that rounds to 360 reads 0 (@quanticsoul4772, #1524).
@@ -1,4 +0,0 @@
- Add an adjustable event-age filter across S2 GhostMaps products, defaulting
to seven days with 1-, 3-, 14- and 30-day choices and an All markers option.
Preserve undated features, save the selected window and expire old events
while the map stays open. Thanks @dylanmaniatakes (#1528).
@@ -1,3 +0,0 @@
- The Nominatim disk-cache test checks the cache directory and file modes
only where POSIX modes exist, so it no longer always fails on Windows
(quanticsoul4772, #1547).
-4
View File
@@ -1,4 +0,0 @@
- Stop requesting Suomi NPP in Active Fires. NASA stopped delivering its data
on November 2, 2026, so `VIIRS_SNPP_NRT` returned no rows while still costing
a MAP_KEY transaction on every refresh; the layer now merges VIIRS NOAA-20,
VIIRS NOAA-21 and MODIS (IMBrandao, #1552).
-4
View File
@@ -1,4 +0,0 @@
- Ride the selected AIS vessel from its bridge: an eye height by vessel type,
dead reckoning between fixes, a bridge HUD with a 3–24 NM radar, look-around
with the arrow keys, and a waterline read from the scene so river boats sit on
the river rather than at sea level (Denis Groshev, @ZloyS, #1557).
-4
View File
@@ -1,4 +0,0 @@
- File satellites under six classes, Stations, Imaging, Telecom, Starlink,
Probes and Debris, each with a toggle; all but Starlink show by default.
Classes come from CelesTrak SATCAT, the UCS Satellite Database and name rules
(Denis Groshev, @ZloyS, #1558).
-4
View File
@@ -1,4 +0,0 @@
- Ride a tracked satellite in an orbital view: a track-up nadir camera, 1–8×
zoom, off-nadir tilt with an approximate swath, a CURRENTLY OVER line, and the
owner's flag, operator and TLE identity. `C` no longer enters or leaves first
person; double-click and the entry buttons do (Denis Groshev, @ZloyS, #1559).
-4
View File
@@ -1,4 +0,0 @@
- Zoom imaging satellites past 8×, up to 128×, as far as their sensor's
confirmed swath allows at the live altitude, with a to-scale swath strip, a
SENSOR step, mouse-wheel zoom, imagery pre-loaded ahead of the ground track and
the TLE's age (Denis Groshev, @ZloyS, #1560).
@@ -1,3 +0,0 @@
- Show a fire perimeter under one acre at its reported size. The incident card
rounded WFIGS sizes to whole acres, so a 0.25-acre fire read "0 ac"
(Rudy Celekli, @rudycelekli, #1569).
-5
View File
@@ -1,5 +0,0 @@
- Give transit vehicle icons a readable front: the bus, tram, metro and unknown
glyphs now carry a nose or cab in their outline, so the direction of travel
shows at fleet size and survives the sensor profiles. A vehicle with no known
course gets a symmetric outline instead of one that points north, and
switches to the pointed glyph once a course arrives (IMBrandao, #1574).
@@ -1,4 +0,0 @@
- Say why Transit and Bike Share are empty outside coverage. Over a city with
no feed or system (London, Tokyo) their Layers rows say so and count the
covered regions or systems, or say how low to fly, instead of `never`;
leaving coverage clears the last-update time (German, @gekh, #1576).
@@ -1,3 +0,0 @@
- A vessel whose ambient card is on the map no longer gets a Detection callout
showing through the card and covering its speed and course. A vessel whose
card the overlay did not paint keeps its callout (German, @gekh, #1577).
@@ -1,4 +0,0 @@
- Space Missions no longer locks the workspace when its mission index fails to
load (429, timeout or bad payload): entry rolls back to the previous layers
and camera view and a toast says the index could not load (German, @gekh,
#1578).
-4
View File
@@ -1,4 +0,0 @@
- Hold the orbital camera on a mapped site as the satellite passes over it,
from a click on its marker or its Live Signals row, and list nearby sites
and satellites in orbit's Live Signals, where a row focuses a site or rides
a satellite (Denis Groshev, @ZloyS, #1580).
@@ -1,4 +0,0 @@
- Fix the translations of the voice area cards, the fire confidence filter,
the drone airspace layer and Sea Marks: one word for "landmark" per
language, the airspace layer names the airspace again, and Traditional
Chinese follows Simplified Chinese again (German, @gekh, #1581).
-4
View File
@@ -1,4 +0,0 @@
- New Shipwrecks layer (Events): about 8,000 wrecks from EMODnet (CC BY 4.0)
and Wikidata (CC0). Each has a card with deaths, date, vessel, cause, depth,
position quality and a source link. Markers grow with the death toll, and
a start and end year picker filters by period (IMBrandao, #1583).
@@ -1,3 +0,0 @@
- Keep keyless place search on a nearby exact match for names written in
non-Latin scripts instead of discarding the match and retrying without its
proximity ranking (mertkayacs, #1585).
-29
View File
@@ -1,29 +0,0 @@
- Add the News layer (Events ▸ News) and its right-rail NEWS panel: the last
24 hours of news, narrowed in the panel to the last 12, 6, 3 or 1 hours with
the hotspot counts read for that window. Markers are coloured by severity
only, with hotspot beacons, outlines with disputed areas, picture cards for
major events and a continuous feed whose cards fly the globe to the event.
An open card plays the post's clip on request. The panel has two sections.
Conflicts lists the hotspots' news. Local, below 3,000 km, lists every
report in the area at the view centre, newest first, names and outlines that area on the
globe, and shows those reports on the map instead while it is chosen; a
disputed area is named as itself and read from no country's outlets. Set
`GEV_NEWS_ORIGIN` to a news server and the local server forwards the
layer's three read routes there. Unset, Core answers them itself:
Conflicts from the last 24 hours of GDELT 2.0 event exports with no key
(physical violence only, one report per story at GDELT's place for it,
titled from GDELT's GKG files for the newest 3 hours), plus Liveuamap's
mapped items with `LIVEUAMAP_API_KEY`; Local from World News API with
`WORLDNEWSAPI_KEY`, at most `WORLDNEWSAPI_DAILY_READS` reads a day
(default 30), each country's news kept for at most 1 hour. The routes
refuse cross-site and proxied requests. A section says when its key is
required, refused or out of its daily allowance. The Data attribution popover credits the GDELT
Project, World News API and, once its items show, Liveuamap
(Denis Groshev, @ZloyS, #1593).
- Cockpit Regional News holds its list to the last 24 hours, newest first, on
the server and in the client, and labels its four states apart: fresh
results with no banner and a `LAST 24 H` footer, earlier results kept after
a failed refresh near the same place as `CACHED · REFRESH FAILED`, `NO NEWS IN THE LAST 24 H`,
and `REGIONAL NEWS UNAVAILABLE`. The banner is now really hidden over a
populated list, and the footer no longer reverts to a static `RECENT` on a
page change (Denis Groshev, @ZloyS, #1593).
-5
View File
@@ -1,5 +0,0 @@
- Add Hebrew (he), the second right-to-left language: the title, rails, dock
and corner row mirror, numbers keep Western digits, and plurals use the
one/two/other Hebrew forms. Noto Sans Hebrew joins the font stacks and the
style, plural and glossary notes arrive in docs/translations/he.md
(Hebrew, @meirm, #1594).
@@ -1,4 +0,0 @@
- Without `OPENAI_API_KEY`, the mic now says the key is missing and to add it in
Provider Settings, instead of showing the raw server error with a hint to
check microphone permissions. `/api/realtime/token` returns
`code: "API_KEY_MISSING"` with its 503 (asturksever, #1599).
@@ -0,0 +1,4 @@
- A layer that is only waiting for a key now ends the load chip on an amber
`KEY REQUIRED` instead of a red `LOAD FAILED`, so a keyless Environmental
first run no longer reports a failure while earthquakes load. A real error
still wins over a missing key (asturksever, #1600).
@@ -1,4 +0,0 @@
- Without a Mapillary token, Street Level starts on the first keyless source
(KartaView) instead of opening on Mapillary's `KEY REQUIRED` with nothing on
the globe. A share link that turns a source off is respected
(asturksever, #1601).
@@ -1,4 +0,0 @@
- Street Traffic offers separate Lines and Dots toggles while live flow is on:
Lines draws each road with flow in green, amber or red, and Dots shows or
hides the moving dots. A host can choose which road modes it offers; without
OpenStreetMap the layer never draws simulated roads (#1609).
-2
View File
@@ -1,2 +0,0 @@
- French catalog review: missing narrow no-break spaces, apostrophes, one
mistranslated message and the Starlink shell wording (abiyeenzo, #1611).
@@ -1,4 +0,0 @@
- Typed chat on OpenAI answers again: OpenAI refused every message because
`resolve_area`'s area-id pattern uses Unicode classes Chat Completions does
not accept. Chat leaves those patterns out; area ids are still checked
before the tool runs (samehkhamis, #1616).
-12
View File
@@ -1,12 +0,0 @@
- Add Icelandic road cameras from the Icelandic Road and Coastal Administration
(Vegagerðin) as a CCTV pack. The camera list is one keyless JSON request to
`gagnaveita.vegagerdin.is/api/vefmyndavelar2014_1` (about 500 views at 166
stations); frames are JPEG stills in `www.vegagerdin.is/vgdata/vefmyndavelar/`,
and only links inside that directory are registered. The catalog is fetched
over https with redirects refused and a 2 MiB body cap. An explicit facing in
the view description ("til vesturs", "í suður", "séð austur") sets a
high-confidence heading; other views keep the low-confidence fallback pose.
IRCA publishes the images under CC BY 4.0, credited in the Data attribution
popover. Loads the 250 views nearest Reykjavík, Akureyri, Egilsstaðir,
Ísafjörður and Selfoss; `CCTV_ICELAND_MAX_SOURCES` changes that cap and
`CCTV_ICELAND_ENABLED=0` turns the pack off (daikaginza, #1617).
@@ -1,5 +0,0 @@
- Add an optional Giroc / Webcam Romania CCTV source pack with corrected
coordinates, live HLS, a snapshot fallback and provider attribution. Allow
HLS segments up to 8 MiB so high-resolution cameras whose segments exceed
4 MiB can play, while retaining the 24 MiB per-session cache and existing
origin and session limits (NeoXrated23, #1619).
-3
View File
@@ -1,3 +0,0 @@
- Turning CCTV on opens the camera nearest you instead of the first camera in
the catalog, which showed a Sydney camera in London. A share link opens on
the camera nearest the shared view once its flight lands (samehkhamis, #1620).
-14
View File
@@ -1,14 +0,0 @@
- Add 34 keyless feeds to the Transit layer, the first in Central and Eastern
Europe, Italy, Spain, France, Japan and Malaysia: Szczecin, Lisbon (Carris
Metropolitana and the city's Carris), Prague, Warsaw, Kraków, Poznań,
Gdańsk, Rzeszów, Vilnius, Bucharest, Cluj-Napoca, Iași, Rome, Turin, Venice
(buses and waterbuses), Bordeaux, Montpellier, Rouen, Toulon, Le Havre,
Tours, Málaga, Sendai, Johor Bahru, Kota Bharu, Adelaide, Atlanta, Denver,
Edmonton, Halifax and Victoria. Each feed records its licence, or that none
is stated, in the registry and DATA_SOURCES.md. Speeds published in km/h
(Lisbon, Montpellier, Toulon) are converted to m/s by the proxy; route ids
read as the line on the vehicle; Prague metro, trains, trams and ferries,
Bordeaux trams and boats, the Rouen Métro, Le Havre trams and Venice
waterbuses take their own mode. The Warsaw and Poznań credits carry the
data date their terms ask for, and the data.gov.my feeds are fetched at
most twice a minute to stay inside the API's rate limit (IMBrandao).
@@ -1,3 +0,0 @@
- An application built with fewer layers no longer restores, saves or shares a
layer it does not have when a link or saved state from a full build names it
(samehkhamis, #1634).
-4
View File
@@ -1,4 +0,0 @@
- Draw rows take an application's own actions: `createApplicationTools({
drawMarkActions })` adds buttons to drawn areas' and lines' rows, each given
the shape's `[lon, lat]` geometry, with what it answers shown under the row.
The standalone build passes none and is unchanged (samehkhamis, #1636).
@@ -1,4 +0,0 @@
- Keep the orbital cockpit's sensor strip steady through a zoom step, load the
satellite owner flags at startup from one generated 15 KB sheet, and name
"Place names" in the loading status while the place-name packs load on first
lookup (Sameh Khamis, @samehkhamis, #1637).
@@ -1,5 +0,0 @@
- Voice on OpenAI connects again: OpenAI Realtime refused every session, so
the mic failed with "Failed to create Realtime token", because
`resolve_area`'s area-id pattern uses Unicode classes it no longer accepts.
Voice leaves those patterns out, as typed chat does; area ids are still
checked before the tool runs (avaldini, #1638).
@@ -1,7 +0,0 @@
- Keep buses and trains off rooftops and out of bridge decks on the photoreal
3D tiles: the ground floor probes the rendered mesh where each vehicle is,
keeps each reading at the point it was taken (up to sixteen a cell) and
answers from the reading nearest the vehicle, instead of one probe at each
110 m cell's centre that lifted every vehicle in the cell onto whatever lay
there. Transit probes each vehicle's latest point, and grounded flights read
the floor at their own position (Sameh Khamis, @samehkhamis, #1642).
@@ -1,3 +0,0 @@
- Street Traffic Lines build one set at a time while a city loads, instead of
one per paint, so panning with Lines on no longer piles up line builds
(Sameh Khamis, @samehkhamis, #1645).
-6
View File
@@ -1,6 +0,0 @@
- Show a photo of the tracked aircraft. Tracking a civil flight asks
Planespotters for that aircraft's photo, and the CONTEXT panel's CONTACTS view
shows it with the photographer's credit and a link to the photo's page. A
flight lookup (type, route or photo) that failed temporarily is now asked
again after a minute instead of staying empty for the session, and a callsign
change drops the previous callsign's route (Sameh Khamis, @samehkhamis, #1647).
-4
View File
@@ -1,4 +0,0 @@
- A FIRMS snapshot that ends at a set time (`at`, epoch ms, from a source that
serves one) is no longer labelled live: the Active Fires row reads
`NASA FIRMS · 24 h to 10-21 15:00 UTC`, and `get_active_fires` says "in the
24 hours to" that time and reports it as `window_end` (samehkhamis, #1650).
@@ -1,7 +0,0 @@
- A tracked flight's trail splits at feed gaps, impossible jumps and reports
without altitude instead of joining a day of history into one line, keeps
every history point, and follows the path the aircraft was drawn along so it
no longer cuts across turns. A tracked aircraft that stops reporting shows
STALE, keeps flying on its last heading for five minutes, then stops dimmed
as SIGNAL LOST and is kept ten minutes after its last report; aircraft on or
near the ground hold instead (Sameh Khamis, @samehkhamis, #1654).
-4
View File
@@ -1,4 +0,0 @@
- A long flight keeps its origin and destination when weather or airways take
it far off the great circle: the route check now allows 200 km or 15% of the
route's length, whichever is larger (623 km for JFK–SFO), while a short hop
still allows 200 km (Sameh Khamis, @samehkhamis, #1655).
@@ -1,3 +0,0 @@
- A tracked aircraft is dimmed as STALE while the whole flight feed is
stalled, as it is after a missed update, instead of staying at full
brightness (Sameh Khamis, @samehkhamis, #1656).
-3
View File
@@ -1,3 +0,0 @@
- A tracked aircraft's Planespotters photo is shown even when the answer names
no photographer, linked to its Planespotters page with no credit line,
instead of no photo at all (Sameh Khamis, @samehkhamis, #1657).
@@ -1,5 +0,0 @@
- Hide street-vehicle brackets and callouts behind buildings and terrain. A
Street Traffic contact behind a building, a 3D tile, a hill or a tunnel roof
now loses its bracket and "VEH-nnnn" callout along with its dot, instead of
having them painted over the facade; aircraft, vessels and satellites keep
their labels over the scene as before (Sameh Khamis, @samehkhamis, #1659).
@@ -1,4 +0,0 @@
- Keyless Gemini voice no longer blames the microphone. Without
`GEMINI_API_KEY`, the voice control shows only "Add a Gemini API key in
Provider Settings", without the microphone-permission hint, as OpenAI voice
already does (Sameh Khamis, @samehkhamis, #1660).
@@ -1,4 +0,0 @@
- Gemini voice messages follow a language change. A Gemini Live status or
error, such as "Gemini quota is unavailable", is shown in the new interface
language after you switch it, instead of staying in the old one, as OpenAI
voice messages already do (Sameh Khamis, @samehkhamis, #1661).
@@ -1,36 +0,0 @@
- Flights, Military Flights and AIS Vessels follow the world clock when their
source declares a timeline, that is, can answer a snapshot at a given time
(`source.timeline`, read by `readSnapshotTimeline` from
`gods-eye-view/sources/live`). Away from live they show the snapshot at the
clock's time and move on with it as it plays; back at live they poll as
before. A source's `timeline.subscribe` may also tell of a better answer
for a time it has answered; paused, the layer loads the time shown again.
A source with no timeline keeps them live, unchanged (Sameh Khamis,
@samehkhamis, #1662).
- AIS Vessels are keyed by the stable id their live source gives (`id` on a
vessel record), not by MMSI: vessels can share an MMSI, through spoofing,
reused numbers or defaults such as 0, and a number can move to another
hull. Two vessels sharing an MMSI are now two vessels, each selected,
followed and carded on its own. The MMSI is an attribute (`mmsi`), shown
when known and `--` otherwise, up to ten digits; looking a vessel up or
selecting it by MMSI (an older link, say) still finds a vessel with it, and
a vessel with no name is no longer named by its id. `vesselSnapshot` takes
`idFor(row)` for a source whose rows carry an id of their own. Tool and voice results carry the vessel's id beside its MMSI
(`id`, or `vesselId` where `id` is a display label), and `get_vessel_track`
also takes an `id`. AISStream has only MMSIs, so its vessels are keyed by
MMSI and nothing changes there (Sameh Khamis, @samehkhamis, #1662).
- A share link made while a vessel is selected (and the camera follows
nothing) now opens that vessel: the link names it, opens with the camera
where it was, looking at the vessel, and selects the vessel once the feed
has it, at the link's world time when it has one. A vessel id that is a
source's stable id, such as a UUID, travels as its 16 bytes in a new thing
field (13), so the link stays short (62 characters where a followed
aircraft's is 44). MMSI and other ids, and links already shared, are
unchanged; an app that predates the field opens such a link at the same
camera, without selecting the vessel. A camera that no follow offset can
carry (below the vessel's horizon, say) still makes a place view.
A shared flight, military flight, vessel or satellite that is not reporting
now says so in a sentence ("The shared vessel isn't reporting now — showing
where it was at 14:05"), with its id on a second line under it (ICAO,
NORAD, MMSI or vessel UUID) instead of at the head of the sentence (Sameh
Khamis, @samehkhamis, #1662).
-4
View File
@@ -1,4 +0,0 @@
- Move the tile budget helpers `utcDayKey`, `normalizeBudget` and
`isOverBudget` out of the TomTom module into `src/data/tileBudget.js`, so
other keyed tile proxies can share them. `src/data/tomtomTiles.js`
re-exports them; behaviour does not change (monwiss, #762).
@@ -1 +0,0 @@
- Run the existing GC-bracketed allocation regression gates on Node 26 as well as Node 24. Keep the current byte budgets so CI catches allocation regressions on both supported runtimes (BreezeDelegate, #766; fixes #39).
-4
View File
@@ -1,4 +0,0 @@
- Answer `405 Method Not Allowed` with `Allow: GET` to anything but `GET` on
`/api/flights`, `/api/military` and `/api/route`, as the other query proxies
do. They answered every verb with the same body. The refusal comes before
rate limiting and any upstream call (daikaginza, #794).
@@ -1,5 +0,0 @@
- Compress the live flight snapshot on the wire. `/api/flights` gzips its
cached global snapshot and its regional adsb.lol fallback once per refresh
and sends that frame to clients that offer gzip: about 860 KB becomes about
250 KB. Other clients get the same bytes uncompressed, and both answers carry
`Vary: Accept-Encoding` (daikaginza, #795).
@@ -1,7 +0,0 @@
- The voice debug log now says why OpenAI Realtime did or did not get a
viewport screenshot or pointer crop. When an entity-context answer reaches
the image step, the log records a reason-coded decision and, when an image
was due, one outcome under the same id: sent, or why it was dropped (a stale
pointer, a camera move, a newer turn). What is captured, sent and kept is
unchanged, and no image data or user text is logged (Kevin Rajan, @kvnloo,
Manjunath, @manjunath22466, #835).
@@ -1,3 +0,0 @@
- Let the voice assistant read the storm you select in Cyclone advisories:
its official advisory, position, intensity and movement. Clearing the
selection and the storm leaving the feed are reported apart (kvnloo, #847).
-4
View File
@@ -1,4 +0,0 @@
- Reduce dead-reckoning work for flights already hidden behind the horizon.
Hidden contacts update on a staggered one-in-13-ticks rotation (about once a
second) while visible contacts and contacts with fleet models retain the full
update path (Wilmer Alzate, #870).
@@ -1,2 +0,0 @@
- Add Troll Housing to the Datacenters layer (4,352 features) by refreshing
Norway from OpenStreetMap data of 2026-10-05 (trygvehhh, #953).
-23
View File
@@ -1,23 +0,0 @@
[
{
"id": "ro-giroc-webcamromania",
"name": "Giroc — Webcam Romania",
"city": "Giroc, Timiș",
"cityId": "giroc",
"provider": "Webcam Romania · webcamromania.ro",
"sourceKind": "configured",
"feedType": "hls",
"url": "https://w4.webcamromania.ro/giroc/index.m3u8",
"snapshotUrl": "https://webcamromania.ro/media/snapshots/01a0267e-d893-7203-a7eb-7ce3c8c99703/latest.jpg",
"lat": 45.70028947266781,
"lon": 21.233675914004227,
"headingDeg": 0,
"headingConfidence": "low",
"pitchDeg": -17,
"fovDeg": 74,
"rangeM": 700,
"mountHeightM": 24,
"groundElevationM": 87,
"license": "Public camera: https://webcamromania.ro/webcam-orase/webcam-giroc/ . Provider terms: https://webcamromania.ro/termeni-si-conditii/ . Video rights remain with the camera operator. Coordinates corrected by the contributor; direction, height, elevation and field of view are uncalibrated estimates."
}
]
File diff suppressed because it is too large. Load diff
-155
View File
@@ -1,155 +0,0 @@
# CCTV integration workflow
Use this workflow when adding a camera provider or repairing an existing feed.
The CCTV catalog comes through the local server; media normally does too. Keep
provider-specific URL policy on the server, including any validated browser-only
media URL permitted by the exception below.
## 1. Check the source before coding
- Identify the catalog endpoint and the actual frame or live-media endpoint.
They may be different hosts or use different formats.
- Read the provider's license, terms, attribution, rate limits, and access
requirements. Publicly reachable data is not automatically licensed for
reuse. Record whether the app may display it and whether any use is
restricted.
- Prefer a public, keyless feed. If credentials are required, keep them in a
server-only environment variable; never put a secret in browser code or a
Vite-exposed variable. A pack that needs a key is opt-in by that key: with
the variable unset it makes no request and logs once (Ontario 511 and
Ottawa). Keep the key out of catalog URLs and add it only at fetch time. Use
the provider's documented API, not a website endpoint that skips its key. If
access terms require an agreement or remain unconfirmed, make the pack
opt-in and ensure it makes no request by default.
- Confirm the feed can be fetched from the app's Node server environment. A URL
opening in a browser does not prove the server can reach it, and browser CORS
behavior does not diagnose a server-side fetch.
- Treat camera directories and aggregators such as [TW Live](https://tw.live/)
as discovery aids. Trace each listing to the original agency or camera owner;
their data and media terms govern reuse. For example, SkylineWebcams says
only camera hosts may embed its live feed and its terms restrict copying or
retransmitting footage, so do not proxy its stream without written
authorization ([FAQ](https://www.skylinewebcams.com/support/faq.html),
[terms](https://www.skylinewebcams.com/en/terms-of-use.html)).
- Decide whether the provider supplies still snapshots or continuous video.
Prefer stills unless live video is necessary and supported by the existing
proxy path.
## 2. Add and register the provider pack
Use the smallest existing provider as a template. Current owners are:
- `server/providers/cctv/sources.js` for standard open-data loaders.
- `server/providers/cctv/<provider>.js` for a provider with its own parser,
fallback, or more involved URL policy.
- `server/providers/cctv/catalog.js` to import the loader and add it to
`LIVE_PACKS`. Each pack is independently gated and the shared catalog cap
allocates space across packs.
- `server/providers/cctv/constants.js` for provider endpoints, caps, and
timeouts.
Return normalized camera records with stable unique IDs, readable names,
`city`/`cityId`, provider attribution, valid `lat`/`lon`, and the correct
`feedType`. Include camera heading only when the source actually provides a
bearing; descriptive direction text is not a numeric heading. Mark uncertain
heading data with the existing low-confidence conventions. Set `sourceKind` so
health and source diagnostics identify the provider. Apply a bounded per-pack
camera limit and geographic filtering where appropriate; avoid one provider
silently consuming the whole shared catalog cap.
Camera coordinates need their own verification. Prefer the original provider's
camera coordinates, then corroborate them against its location description,
route/mile marker, or official map. An aggregator's map dot, a town/county name,
or a geocoded attraction name is not a precise camera position. Do not pin a
camera to a city center or nearby landmark; if its actual location cannot be
verified, leave it out until it can. For manually curated coordinates, record
the evidence in the provider data or an adjacent comment. For example, TW Live
camera pages identify the originating agency; use that agency's catalog for
the camera point when available.
If the source becomes opt-in, use the catalog's opt-in gate (`envOptIn`) and
document the flag and reason in `DATA_SOURCES.md`. Otherwise use the normal
enabled-by-default flag pattern and document how to disable the pack.
## 3. Keep frame and media requests server-registered
The browser should request the local `/api/cctv/frame/:id` or
`/api/cctv/media/:id` endpoint. Do not pass an arbitrary upstream URL from the
browser, and do not fetch an unvalidated URL supplied by a catalog record.
Every provider must have a server-side rule that pins frame and media URLs to
the expected scheme, host, and path; use HTTPS where the provider supports it.
Use `pinnedCameraUrl()` for URLs that belong under a fixed provider directory,
or a stricter provider-specific validator when hosts or paths vary. Register
any new permitted image/media origin in the existing
`server/providers/cctv` media policy.
Catalog and media loaders must also:
- refuse unsafe schemes, credentials, unexpected ports, redirects outside the
registered origin/path, and malformed identifiers;
- set request deadlines and cap catalog and buffered frame bodies;
- parse only the expected format and validate coordinates before returning a
camera;
- handle offline cameras and partial provider failures without failing the
entire merged catalog; and
- avoid persistent frame storage unless the provider's terms explicitly allow
it and the design has been reviewed.
For live media, prefer the existing proxy and supported feed type (`mjpeg`,
`mp4`, `webm`, or `hls`). A direct browser-only video is a narrow fallback for
a source whose terms allow embedding when the browser can play it but the app
server cannot fetch it. Unconfirmed embedding terms make it opt-in like any
other pack, and the browser loads it only after an explicit play action, never
on selection, because the provider sees the viewer's IP address. Build its URL
from a validated provider ID on the server; never accept a browser-supplied
media URL. Render it in a native DOM
`<video>` without `crossOrigin`, and do not read its pixels into canvas, WebGL,
or a Cesium texture. If a map preview is needed, a second video request may be
paused as a DOM overlay at the camera location; label it as an approximate
still and scale it with map distance. If terms disallow embedding, use a source
link instead. Read [SECURITY.md](../SECURITY.md) before changing URL
validation, redirects, or proxy behavior.
When an image provider sets a minimum refresh interval, enforce it at the
server frame boundary as well as in the client. A bounded in-memory cache and
single-flight request per camera prevent projection, panel, and re-selection
paths from multiplying upstream requests; do not persist frames unless the
provider's terms explicitly allow it.
## 4. Add focused regression coverage
Add provider tests under `src/data/` following the existing CCTV test naming
and fixtures. Cover at least:
- representative valid catalog rows and their normalized output;
- missing or invalid IDs, coordinates, and frame URLs;
- duplicate records and the pack's cap/filter behavior;
- unsafe host/path, credentials, and redirect cases;
- timeout, oversized, malformed, and unavailable upstream responses; and
- the frame or live-media content type the provider actually serves.
Use `src/data/cctvProxy.test.mjs` for shared proxy/media behavior and
`src/data/cctvCatalogCap.test.mjs` for shared catalog allocation. For a
browser-only source, verify native playback, denied canvas pixel access, the
paused second-player overlay, and its cleanup separately from proxy tests. Run
the focused provider and proxy tests, then the standard checks from
[CONTRIBUTING.md](../CONTRIBUTING.md) that apply to the change. For runtime
verification, inspect `/api/cctv/sources`, `/api/cctv/health`, and the camera's
`/api/cctv/frame/:id` or `/api/cctv/stream/:id` response locally. Confirm a
successful frame is from the upstream camera by checking its content type and
`X-CCTV-Source`; a fallback image is not proof that the upstream feed works.
## 5. Update source documentation and submit
- Add the provider, endpoint, terms/license, required attribution, key status,
caching behavior, and any restrictions to `DATA_SOURCES.md`.
- Update the CCTV source section in `docs/CURRENT-STATE.md` when runtime
behavior, flags, caps, or fallback behavior changes.
- Register in-app attribution through the existing source/provider credit
fields; update `src/data/dataCredits.js` when the source needs a layer-level
credit.
- Add the required `changelog.d/<pr-number>-<slug>.md` entry for a runtime
change, and include the source, license, attribution, key/runtime-fetch
details, and verification evidence in the PR description.
- For a new layer, also follow the camera/source requirements in the
[new-layer PR checklist](../CONTRIBUTING.md#adding-a-new-layer).
+3 -8
View File
@@ -136,9 +136,6 @@ socket/watchdog on server close and re-reads configuration after restart.
The portable `gods-eye-view/sources/adsb-lol` export normalizes existing aircraft
records without importing Node middleware or a renderer. Browser layer/controller
separation is outside this server extraction.
The portable `gods-eye-view/sources/planespotters` export reads a Planespotters
photo answer (upstream or a server's cached shape) into one host-checked,
credited thumbnail; it has no fetch, DOM or Node dependency.
## Place-search and routing providers
@@ -166,8 +163,7 @@ local composition mounts them in the same order. Importing the entry performs
no acquisition; factory calls create independent cache state.
`gods-eye-view/sources/space` owns only fixed upstream URL construction: the
CelesTrak group/TLE and SATCAT record queries and Launch Library's recent
30-day detailed feed.
CelesTrak group/TLE query and Launch Library's recent 30-day detailed feed.
Callers supply the group or end date and own validation, credentials, transport,
response limits and cache policy. The boundary gate checks this portable entry
separately from the Node providers. Satellite rendering and launch replay remain
@@ -634,9 +630,8 @@ source-time fallback and model-owned ground policy. The renderer supplies that
ownership fact and handles stale-ground lifting, Cesium history and primitives;
record reconciliation does not import the engine or application.
`layers/vessels/records` owns plain AIS metadata, identity by the stable id the
source gives (an MMSI only for a feed that has nothing else; the MMSI itself is
an attribute) and bounded retention. `layers/vessels/ingestion` owns source requests and feed state through
`layers/vessels/records` owns plain AIS metadata, stable MMSI identity and bounded
retention. `layers/vessels/ingestion` owns source requests and feed state through
explicit operations. Both exports exclude Cesium, DOM and application assembly.
The snapshot renderer applies record changes; rendering owns a weak map of
geometry and billboard resources used by cards, picking and trails.
+114 -806
View File
File diff suppressed because it is too large. Load diff
+1 -1
View File
@@ -57,6 +57,6 @@ Consumers use the package exports, which do not import standalone application
globals.
Dataset files and source/license notices remain under `src/data/local_data/`.
Their input counts are 4,352 datacenter features and 704 dam features. Cesium may
Their input counts are 4,351 datacenter features and 704 dam features. Cesium may
expand multipart geometries into multiple entities; entity and stem counts are
different measurements. This change does not refresh or relicense the datasets.
+1 -44
View File
@@ -1,6 +1,6 @@
# KNOWN ISSUES
Updated: November 5, 2026
Updated: September 22, 2026
This file tracks active runtime issues only.
@@ -145,49 +145,6 @@ Status: Open (limits)
---
### News layer without a news server
Status: Open (limits)
- GDELT's places and categories are machine-coded and approximate. A report can
be placed in the wrong town of the same name, or at a country an article only
names as a nationality ("Syrian refugees" placed in Syria).
- Copies of one agency story are merged, but the same event under different
wording stays as separate reports.
- Only the 11 disputed areas in the bundled list
(`src/data/local_data/natural_earth/README.md`) and the territories Natural
Earth gives no country code (Somaliland, Northern Cyprus, Siachen Glacier,
the Spratly Islands and a few more) are named as themselves; everywhere else
Natural Earth's default country lines apply, so a place in Western Sahara,
for example, is named with the country those lines put it in. GDELT places a whole city at one point: Jerusalem's falls
inside East Jerusalem, so it is named "Jerusalem" alone.
- A country's own stories in Local keep World News API's category (politics as
political, business as economy, else other) and carry no severity; fighting
there shows through the Conflicts reports placed in the area.
- When World News API takes 10 to 20 s to answer a country's first read, Local
reads unavailable until it is asked again (the next refresh, every 2 minutes
while the tab is shown, or the camera settling in another half-degree cell,
or a section switch), which is served from that read. A read over 20 s fails
and is tried again with the first ask a minute or more later.
- Local needs a World News API key. Its free plan allows 50 points a day; Core
stops at `WORLDNEWSAPI_DAILY_READS` reads (default 30) and Local then says the
daily limit is reached. A request that never reached World News API (offline)
is not counted; any answer from it is, a failing one too, and so is a
request that timed out.
- A country's news is kept for at most 1 hour (World News API's terms, which
also ask the operator for its written permission to cache at all). While
World News API cannot be read, Local in that country therefore reads
unavailable from an hour after its last good read.
- A story GDELT codes as violence whose address has no words, and whose page
title is not in yet (titles are read for the newest 3 hours only), is not
listed under Conflicts or counted in its hotspot: GDELT's coding alone is
wrong too often. It still shows in Local for its area, as "… reported in
<place>".
- `/api/news/*` refuses cross-site browser requests and requests carrying
reverse-proxy forwarding headers, like the other key-spending routes
(SECURITY.md): behind a reverse proxy the News layer reads unavailable.
---
## Closed / Intentional (for clarity)
### Proxy SSRF and error-surface hardening gaps
+14 -46
View File
@@ -28,11 +28,7 @@ Following an aircraft is 42–50: 42 when the link is made from the app's own
follow camera with the fix taken as the link is made, 44 with a fix four
seconds older, and 50 after orbiting to another heading and pitch. A view in
any other style that keeps the style's own look adds only the style (two
bytes): the 42-character followed aircraft in Noir is 44. A selected vessel
named by its MMSI costs what an aircraft does (46 characters for the
44-character aircraft above); one named by a UUID costs 13 bytes more, as its
16 bytes travel raw (62 characters where the aircraft is 44, rather than about
88 with the UUID as text).
bytes): the 42-character followed aircraft in Noir is 44.
## Opening a short link
@@ -51,22 +47,9 @@ best effort: until the entity is found, and if it is not found in time, the
camera is where the link was made, looking at the entity's last-known fix,
and the app says that the entity isn't reporting and when the fix was taken.
The share button makes a thing view while the camera follows an aircraft,
military aircraft or satellite. While the camera follows nothing and a vessel
is selected, it makes a vessel thing view: the fix is the vessel's position
as drawn (as of the world time while the vessel layer follows the world
clock, else as of its latest position report), and the offset is the one whose derived camera is
where the user's camera is (`followOffsetFor` in `src/share/follow.js`, the
inverse of `followCameraFor`, taken from the fix at the format's precision).
The opened camera is then within the offset's whole metres and degrees of
where the link was made, looking at the vessel. When no offset can carry the
camera, because it is not above the vessel's local horizontal (it would look
up at the vessel, or through the earth) or its range is outside 1 m to
10⁸ m, the link is a place view as before. A vessel is selected, not
followed, so the camera stays where the user puts it. Following an entity
takes priority over a selected vessel, and a followed entity the format
cannot name still makes a place view. Opening a vessel thing view with a
world time waits for the vessel layer's snapshot at that time and selects the
vessel once it has it.
military aircraft or satellite. A selected vessel is not followed (the camera
stays where the user puts it), so a link made then is a place view; vessel
thing views open, but the app does not make them yet.
## Payload
@@ -136,7 +119,6 @@ probability of about 255 in 256 (many of them already fail as base64url).
| 25 | `scopeFeatherPct` | uint | 100 | 0–200 |
| 26 | `scopeTerminusPct` | uint | none | 94–100; absent is the adaptive terminus |
| 27 | `styleParams` | LEN | style | packed `(token, value × 100)` pairs |
| 28 | `worldTime` | epoch | none (live) | zigzag seconds since 2026-01-01T00:00:00Z |
Field 1 absent means `thing` when field 13 is present and `place` otherwise,
so v1 never writes it; a later view kind writes its code.
@@ -150,10 +132,6 @@ style also extends the pinned style-defaults table.
`hudVariant` code 1 (`operator`) is retired: the app no longer offers that
layout and restores it as `tactical`. The code stays reserved.
Field 28 is the world time the link was made at, written only when it was away
from live. A link without it opens live. Unlike field 8 it may lie ahead of the
link's creation (a forecast moment), and it opens paused at that time.
Flag bits (field 9): 0 `bloom`, 1 `sharpen`, 2 `hudVisible`, 3
`celestialRing` (each defaulting by style), 4 `scopeEnabled` (default on).
@@ -291,23 +269,14 @@ sub-message:
| 10 | fix alt | sint metres | 0 |
| 11 | fix age | sint seconds the fix predates the created-at time | 0 |
| 12 | observed-at | epoch, when the fix was taken; only without a created-at | none |
| 13 | vessel id | UUID, 16 bytes (LEN) | |
The entity kind is the field that carries the id (its code in
`SHARE_VIEW_ENTITY_KINDS`, or field 13 for a vessel), so it costs nothing;
exactly one of fields 1–4 and 13 is present (two is malformed). Entity ids are
the ones the layers look up: an aircraft's ICAO 24-bit address (six hex
digits, stored as a number; other forms such as TIS-B `~abc123` as text), a
vessel's id (its MMSI, or a source's stable id, such as a UUID) and a
satellite's NORAD catalog number (decimal numbers; an MMSI with a leading
zero stays text). Ids are lowercase and match `[0-9a-z~_-]{1,16}`, except a
vessel id that is a UUID (`xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx`, lowercase
hex): it is written as its 16 bytes in field 13 rather than as 36 characters
of text in field 3, after the other thing fields, and decodes as the
lowercase dashed UUID. Field 13 of any other length is malformed. A decoder
that predates field 13 skips it, finds no entity and opens the view as a
place at its derived camera (`unknown-entity-kind`), as for a new entity kind,
so the field needed no new version.
`SHARE_VIEW_ENTITY_KINDS`), so it costs nothing; exactly one of fields 1–4 is
present (two is malformed). Entity ids are the ones the layers look up: an
aircraft's ICAO 24-bit address (six hex digits, stored as a number; other
forms such as TIS-B `~abc123` as text), a vessel's MMSI and a satellite's
NORAD catalog number (decimal numbers; an MMSI with a leading zero stays
text). Ids are lowercase and match `[0-9a-z~_-]{1,16}`.
Fields 5–7 are the follow offset: the camera looks along (heading, pitch) at
the entity from `range` metres away, heading clockwise from north, in the
@@ -345,9 +314,8 @@ bytes, a check byte that does not match (`checksum`), an unknown version byte,
a truncated value, a varint over 8 bytes or above 2⁵³ − 1, an
overlong varint, a LEN longer than what remains (at any level, including
inside the thing sub-message), a reserved wire type, field number 0, a known
field with the wrong wire type or repeated, two entity ids in one thing
(field 13 counts as one), a UUID vessel id that is not 16 bytes
(`bad-length`), a duplicate panel/option/style entry, non-ASCII text, and a latitude or
field with the wrong wire type or repeated, two entity ids in one thing, a
duplicate panel/option/style entry, non-ASCII text, and a latitude or
longitude (camera or fix) beyond ±90/±180.
Tolerated and reported in `unknownFields`: unknown fields at any level
@@ -385,8 +353,8 @@ the app that can open it.
boolean takes the next flag bit instead, which costs nothing when it is at
its default.
6. **New view kinds and entity kinds** get the next code. A new entity kind's
id takes the next free thing field (14 onwards; 13 is a vessel id that is
a UUID), so older apps skip it and fall back to the camera. A thing that needs more than an id (for example a
id takes the next free thing field (13 onwards), so older apps skip it and
fall back to the camera. A thing that needs more than an id (for example a
CCTV camera, a radio station or a transit vehicle) adds further thing
fields the same way.
7. **A new layer option** needs an entry in `SHARE_VIEW_LAYER_OPTION_ABSENT`
+1 -1
View File
@@ -270,7 +270,7 @@ and the development server's internal routes. See SECURITY.md.
| `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 |
| `find_vessel` | `vessels` | Ships anywhere by MMSI, IMO number or name |
| `get_vessel_track` | `vessels` | Recent positions of one ship by vessel id or MMSI, thinned to 200 points |
| `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 |
| `satellites_overhead` | `satellites` | Satellites in a CelesTrak group above a place or point now, highest first |
| `find_cctv_cameras` | `cctv` | Public cameras in an area, nearest first |
+4 -5
View File
@@ -1,6 +1,6 @@
# Translating God's Eye View
Shipped languages: **English** (source and fallback), **Arabic** (`ar`), **German** (`de`), **Spanish** (`es`), **French** (`fr`), **Hebrew** (`he`), **Italian** (`it`), **Japanese** (`ja`), **Brazilian Portuguese** (`pt`), **Russian** (`ru`), **Ukrainian** (`uk`), **Simplified Chinese** (`zh-CN`) and **Traditional Chinese** (`zh-Hant`).
Shipped languages: **English** (source and fallback), **Arabic** (`ar`), **German** (`de`), **Spanish** (`es`), **French** (`fr`), **Italian** (`it`), **Japanese** (`ja`), **Brazilian Portuguese** (`pt`), **Russian** (`ru`), **Ukrainian** (`uk`), **Simplified Chinese** (`zh-CN`) and **Traditional Chinese** (`zh-Hant`).
## How it works
@@ -45,10 +45,9 @@ Edit `src/i18n/locales/<code>.json` and run `npm run i18n`. To see your change,
1. Create `src/i18n/locales/<code>.json`, fill it from `npm run i18n -- --missing <code>` (start from `{}`), and translate every entry.
2. Import it in `src/i18n/index.js` and add it to `CATALOGS`.
3. Add the code to `LOCALES` and its own-language name to `LOCALE_NAMES` in `src/i18n/locale.js`. Right-to-left languages also go in `RTL_LOCALES`; the chrome mirrors through logical CSS properties (`inset-inline-*`, `margin-inline-*`), and `src/ui/styles/rtl.css` holds what those cannot express (see the Arabic notes). If the language should keep Western digits, map it in `INTL_TAGS` in `locale.js`.
4. Add `src/i18n/locales/<code>.json` next to `ru.json` in `scripts/package-boundaries.json` and in `scripts/format-scope.json`.
4. Add `src/i18n/locales/<code>.json` next to `ru.json` in `scripts/package-boundaries.json`.
5. Write `docs/translations/<code>.md`: glossary, plural forms, units and style (copy the structure of `ru.md`).
6. If the language is not written in Latin letters, also add it to `NON_LATIN_LOCALES` in `src/hudSummaryResponse.js` — that guard falls back to the translated text when a summary provider answers in English.
7. Run `npm run i18n` and `npm test`, then check the panels in the app at narrow widths.
6. Run `npm run i18n` and `npm test`, then check the panels in the app at narrow widths.
## Rules
@@ -64,4 +63,4 @@ These are contracts, not copy: layer ids and registry keys, status enums and oth
## Language notes
Each language keeps its glossary, plural forms, units and style in its own file, `docs/translations/<code>.md`: [Arabic](./translations/ar.md), [German](./translations/de.md), [Spanish](./translations/es.md), [French](./translations/fr.md), [Hebrew](./translations/he.md), [Italian](./translations/it.md), [Japanese](./translations/ja.md), [Brazilian Portuguese](./translations/pt.md), [Russian](./translations/ru.md), [Ukrainian](./translations/uk.md), [Simplified Chinese](./translations/zh-CN.md), [Traditional Chinese](./translations/zh-Hant.md). Read it before translating, and add a term there when you settle on one.
Each language keeps its glossary, plural forms, units and style in its own file, `docs/translations/<code>.md`: [Arabic](./translations/ar.md), [German](./translations/de.md), [Spanish](./translations/es.md), [French](./translations/fr.md), [Italian](./translations/it.md), [Japanese](./translations/ja.md), [Brazilian Portuguese](./translations/pt.md), [Russian](./translations/ru.md), [Ukrainian](./translations/uk.md), [Simplified Chinese](./translations/zh-CN.md), [Traditional Chinese](./translations/zh-Hant.md). Read it before translating, and add a term there when you settle on one.
-64
View File
@@ -1,64 +0,0 @@
# Hebrew (`he`)
Catalog: `src/i18n/locales/he.json`. General rules: [TRANSLATORS.md](../TRANSLATORS.md).
## Style
- Address the user with a masculine imperative, Google style: הסתר, הצג, נסה שוב, התקרב. Menu items and tooltips take the noun form: הסרת {name}, הרחבת {name}, שינוי הצבע של {name}.
- Sentence case: Hebrew has no case forms, so UPPERCASE English is normal sentence style here; short HUD labels stay short, favor an abbreviation over a wrapped line.
- Units follow the catalog: ק"מ, מ׳, קמ"ש, שנ׳, דק׳, שע׳; an abbreviation always ends in the geresh (׳). Instrument readouts and formatted shared-unit labels keep the Latin units (km, NM, FT, KTS, MW, KM/S); catalog prose outside those readouts keeps ק"מ/מ׳. Codes like HDG, FOV, CAL, SRC, REC stay exactly as in English — see `same-as-english.json`.
## Digits and direction
- Western digits 0–9 only, like every catalog: "5 מטוסים", "26.08.2026". Dates in scene labels use dd.mm.yyyy.
- `{direction}` values are adverbs (שמאלה, ימינה, ישר); maneuver templates are written to place them after the verb: פנה {direction} אל {road}.
- Values inserted by t() are isolated (U+2068…U+2069); do not add manual RTL marks — write the sentence as natural Hebrew and let isolation do the rest.
## Plurals
A `tn()` entry needs three forms under `Intl.PluralRules('he')`:
| Form | Counts | Example |
| ------- | ---------------------------- | -------------- |
| `one` | 1 and fractions below 1 | 1 מצלמה · 0.5 |
| `two` | 2 | 2 מצלמות |
| `other` | 0, 3, 10, 100, integers… | 5 מצלמות |
With {count} shown as a digit, `two` and `other` share the plural noun (2 מצלמות, 5 מצלמות); `one` takes the singular (1 מצלמה). For constructs that resist agreement with a noun (אין, הכול, זמין), phrase around the number: "{count} תחנות מדווחות" is fine, but prefer "ממתין: {count}" over a mismatched genitive chain.
## Glossary
| English | עברית |
| ---------------------------------------------- | ----------------------------------------------------- |
| layer / data layer | שכבה / שכבת נתונים |
| feed | פיד |
| source / data source / Provider Settings | מקור / מקור נתונים / הגדרות הספקים |
| LIVE / live data / LIVE TRACK | חי / נתונים חיים / מעקב חי |
| SIMULATED | מדומה |
| stale / degraded / unavailable | מיושן / מופחת / לא זמין |
| key (API key) / POWER UP | מפתח / שדרוג |
| globe / map / map source / basemap | גלובוס / מפה / מקור מפה / מפת בסיס |
| aircraft / flight / military flight | מטוס (כלי טיס in class lists) / טיסה / טיסה צבאית |
| vessel / ship / ships | אונייה / אוניות |
| satellite | לוויין |
| launch (rocket) / space mission | שיגור / משימת חלל |
| contact / tracking | מגע / עקיבה |
| installation / site | מתקן / אתר |
| camera / CCTV camera / ALPR camera | מצלמה / מצלמת CCTV / מצלמת ALPR |
| earthquake / active fire / cyclone | רעידת אדמה / שריפה פעילה / ציקלון |
| scene / shot | סצנה / שוט |
| briefing / panel / dock | תדריך / פאנל / עגינה |
| visual preset / style | ערכת ראייה / סגנון |
| Scope (circular viewport mask) / keyhole | סקופ / חור המנעול |
| heading (compass °) / course | כיוון / מהלך |
| HDG / BRG / CRS / FOV / DEST / SPD / RANGE | בלי שינוי — קודי מכשיר |
| advisory (NHC/CPHC cyclone) | עדכון; Advisory center — מרכז הסערה לפי העדכון |
| Street View (Google) | Street View, בלי תרגום |
| Street Level / Street Level layer | Street Level, בלי תרגום (שם שכבה) |
| WHOT/BHOT, Ironbow, CRT, NVG, FLIR | בלי שינוי — קודים ושמות מצבים |
| PREV / NEXT | הקודם / הבא |
| anchor / data pack / asset bundle (scene) | עוגן / חבילת נתונים / חבילת נכסים |
| forecast (weather) | תחזית |
| witness / geolocated source | עד ראייה / מקור מאוכן |
| contained (wildland fire) / FRP | הכלה / FRP, בלי שינוי |
+2 -2
View File
@@ -21,12 +21,12 @@
<link rel="stylesheet" href="/style.css" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@300;400;500;600;700&family=Inter:wght@300;400;500;600&family=Noto+Sans+Arabic:wght@400;500;600;700&family=Noto+Sans+Hebrew:wght@400;500;600;700&display=swap" rel="stylesheet" />
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@300;400;500;600;700&family=Inter:wght@300;400;500;600&family=Noto+Sans+Arabic:wght@400;500;600;700&display=swap" rel="stylesheet" />
<!-- Icon font subset to the glyphs this app renders. Keep `icon_names` in sync
with the sources: src/materialSymbolsSubset.test.mjs fails when a source
names a glyph this list is missing, because a missing glyph does not draw
a box — the ligature never forms and the element renders the literal word. -->
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20,400,0,0&icon_names=adjust,arrow_drop_down,arrow_forward,arrow_left,arrow_right,bolt,bug_report,category,chevron_left,chevron_right,close,close_fullscreen,dark_mode,directions_boat,draw,east,eco,egg_alt,flare,flight,handyman,layers,layers_clear,light_mode,local_fire_department,location_on,more_horiz,movie,my_location,navigation,normal,on,open_in_full,partly_cloudy_day,pause,pentagon,pest_control,pets,play_arrow,public,question_mark,radar,radio,raven,right_panel_close,right_panel_open,rocket_launch,satellite_alt,search,set_meal,settings,skip_next,skip_previous,streetview,timeline,traffic,tune,umbrella,upload_file,videocam,view_in_ar,visibility,visibility_off,water_drop" rel="stylesheet" />
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20,400,0,0&icon_names=adjust,arrow_drop_down,arrow_forward,arrow_left,arrow_right,bolt,bug_report,category,chevron_left,chevron_right,close,close_fullscreen,dark_mode,draw,east,eco,egg_alt,flare,flight,handyman,layers,layers_clear,light_mode,local_fire_department,location_on,more_horiz,movie,my_location,navigation,normal,on,open_in_full,partly_cloudy_day,pentagon,pest_control,pets,public,question_mark,radar,radio,raven,right_panel_close,right_panel_open,rocket_launch,satellite_alt,search,set_meal,settings,skip_next,skip_previous,streetview,timeline,tune,umbrella,upload_file,videocam,view_in_ar,visibility,visibility_off,water_drop" rel="stylesheet" />
</head>
<body>
<div class="gev-view" data-gev-view="scene-chrome"></div>
-8
View File
@@ -28,7 +28,6 @@
"@happy-dom/global-registrator": "^20.14.6",
"@vitejs/plugin-vue": "^5.2.4",
"@vue/test-utils": "^2.5.1",
"flag-icons": "7.5.0",
"happy-dom": "^20.14.6",
"prettier": "3.9.6",
"puppeteer": "^25.16.0",
@@ -2412,13 +2411,6 @@
}
}
},
"node_modules/flag-icons": {
"version": "7.5.0",
"resolved": "https://registry.npmjs.org/flag-icons/-/flag-icons-7.5.0.tgz",
"integrity": "sha512-kd+MNXviFIg5hijH766tt+3x76ele1AXlo4zDdCxIvqWZhKt4T83bOtxUOOMlTx/EcFdUMH5yvQgYlFh1EqqFg==",
"dev": true,
"license": "MIT"
},
"node_modules/fresh": {
"version": "0.5.2",
"resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
-7
View File
@@ -76,7 +76,6 @@
"@happy-dom/global-registrator": "^20.14.6",
"@vitejs/plugin-vue": "^5.2.4",
"@vue/test-utils": "^2.5.1",
"flag-icons": "7.5.0",
"happy-dom": "^20.14.6",
"prettier": "3.9.6",
"puppeteer": "^25.16.0",
@@ -91,7 +90,6 @@
"./infrastructure/lod": "./src/data/localGeojsonLod.js",
"./application": "./src/app/application.js",
"./application/viewer": "./src/app/viewer.js",
"./time": "./src/time/worldClock.js",
"./build/vite": {
"node": "./build/vite.js"
},
@@ -99,7 +97,6 @@
"node": "./server/providers/live.js"
},
"./sources/adsb-lol": "./src/data/adsbLolFallback.js",
"./sources/planespotters": "./src/data/planespottersPhoto.js",
"./server/providers/places": {
"node": "./server/providers/places.js"
},
@@ -138,9 +135,6 @@
"./server/providers/regional": {
"node": "./server/providers/regional.js"
},
"./server/providers/news": {
"node": "./server/providers/news.js"
},
"./server/providers/openai": {
"node": "./server/providers/openai.js"
},
@@ -264,7 +258,6 @@
"./layers/gnss/source": "./src/layers/gnss/source.js",
"./layers/ghostmaps/source": "./src/layers/ghostMaps/source.js",
"./layers/drone-airspace/source": "./src/layers/droneAirspace/source.js",
"./layers/news/source": "./src/layers/news/source.js",
"./layers/firms/source": "./src/layers/firms/source.js",
"./layers/installations/source": "./src/layers/installations/source.js",
"./layers/launches/source": "./src/layers/launches/source.js",
-1
View File
@@ -1 +0,0 @@
{"source":"Revos addons/Qc cams/quebec_all_cameras.geojson","count":1120,"cameras":[{"id":"4103","kind":"traffic","name":"Route 117 à la jonction de la route 101 et à la hauteur du carrefour giratoire (Rouyn-Noranda)","nameEn":"Junction of route 117 and route 101 and at roundabout (Rouyn-Noranda)","category":"Highway traffic","operator":"Ministere des Transports et de la Mobilite durable (MTQ) - Quebec 511","region":"Abitibi-Témiscamingue","lat":48.26461,"lon":-79.01808,"accuracyM":0,"positionSource":"MTQ georeferenced point (WFS, EPSG:4326, 5 decimals)","viewerUrl":"https://www.quebec511.info/Carte/Fenetres/FenetreVideo.html?id=4103","notes":"Live since 2025/11/26"},{"id":"3820","kind":"traffic","name":"Aut. 20 à la hauteur de la montée Industrielle-et-Commerciale","nameEn":"Aut. 20 at montée Industrielle-et-Commerciale","category":"Highway traffic","operator":"Ministere des Transports et de la Mobilite durable (MTQ) - Quebec 511","region":"Bas-Saint-Laurent—Gaspésie—Îles-de-la-Madeleine","lat":48.43993,"lon":-68.47784,"accuracyM":0,"positionSource":"MTQ georeferenced point (WFS, EPSG:4326, 5 decimals)","viewerUrl":"https://www.quebec511.info/Carte/Fenetres/FenetreVideo.html?id=3820","notes":"Live since 2018/12/12"},{"id":"3821","kind":"traffic","name":"Montée Industrielle-et-Commerciale à Rimouski","nameEn":"Montée Industrielle-et-Commerciale at Rimouski","category":"Highway traffic","operator":"Ministere des Transports et de la Mobilite durable (MTQ) - Quebec 511","region":"Bas-Saint-Laurent—Gaspésie—Îles-de-la-Madeleine","lat":48.44639,"lon":-68.48528,"accuracyM":0,"positionSource":"MTQ georeferenced point (WFS, EPSG:4326, 5 decimals)","viewerUrl":"https://www.quebec511.info/Carte/Fenetres/FenetreVideo.html?id=3821","notes":"Live since 2018/12/12"},{"id":"3822","kind":"traffic","name":"Route 132 à la hauteur du Bic à la jonction de l'Aut. 20","nameEn":"Route 132 at Bic at the junction of Aut. 20","category":"Highway traffic","operator":"Ministere des Transports et de la Mobilite durable (MTQ) - Quebec 511","region":"Bas-Saint-Laurent—Gaspésie—Îles-de-la-Madeleine","lat":48.3775,"lon":-68.68694,"accuracyM":0,"positionSource":"MTQ georeferenced point (WFS, EPSG:4326, 5 decimals)","viewerUrl":"https://www.quebec511.info/Carte/Fenetres/FenetreVideo.html?id=3822","notes":"Live since 2018/12/12"},{"id":"3834","kind":"traffic","name":"Route 132 à la hauteur de Saint-Fabien","nameEn":"Route 132 at Saint-Fabien","category":"Highway traffic","operator":"Ministere des Transports et de la Mobilite durable (MTQ) - Quebec 511","region":"Bas-Saint-Laurent—Gaspésie—Îles-de-la-Madeleine","lat":48.32927,"lon":-68.80262,"accuracyM":0,"positionSource":"MTQ georeferenced point (WFS, EPSG:4326, 5 decimals)","viewerUrl":"https://www.quebec511.info/Carte/Fenetres/FenetreVideo.html?id=3834","notes":"Live since 2019/02/07"},{"id":"3835","kind":"traffic","name":"Route 132 à la hauteur de Cap-Chat (secteur Capucins)","nameEn":"Route 132 at Cap-Chat (Capucins sector)","category":"Highway traffic","operator":"Ministere des Transports et de la Mobilite durable (MTQ) - Quebec 511","region":"Bas-Saint-Laurent—Gaspésie—Îles-de-la-Madeleine","lat":49.04153,"lon":-66.86847,"accuracyM":0,"positionSource":"MTQ georeferenced point (WFS, EPSG:4326, 5 decimals)","viewerUrl":"https://www.quebec511.info/Carte/Fenetres/FenetreVideo.html?id=3835","notes":"Live since 2019/02/07"},{"id":"3856","kind":"traffic","name":"Route 132 à l'est de Gros-Morne","nameEn":"Route 132, east of Gros-Morne","category":"Highway traffic","operator":"Ministere des Transports et de la Mobilite durable (MTQ) - Quebec 511","region":"Bas-Saint-Laurent—Gaspésie—Îles-de-la-Madeleine","lat":49.25302,"lon":-65.48355,"accuracyM":0,"positionSource":"MTQ georeferenced point (WFS, EPSG:4326, 5 decimals)","viewerUrl":"https://www.quebec511.info/Carte/Fenetres/FenetreVideo.html?id=3856","notes":"Live since 2019/12/06"},{"id":"3836","kind":"traffic","name":"Route 132 à la hauteur de la baie du Petit Pabos","nameEn":"Route 132 at baie du Petit Pabos","category":"Highway traffic","operator":"Ministere des Transports et de la Mobilite durable (MTQ) - Quebec 511","region":"Bas-Saint-Laurent—Gaspésie—Îles-de-la-Madeleine","lat":48.37216,"lon":-64.58866,"accuracyM":0,"positionSource":"MTQ georeferenced point (WFS, EPSG:4326, 5 decimals)","viewerUrl":"https://www.quebec511.info/Carte/Fenetres/FenetreVideo.html?id=3836","notes":"Live since 2019/02/07"},{"id":"3837","kind":"traffic","name":"Route 132 à la hauteur de Bonaventure","nameEn":"Route 132 at Bonaventure","category":"Highway traffic","operator":"Ministere des Transports et de la Mobilite durable (MTQ) - Quebec 511","region":"Bas-Saint-Laurent—Gaspésie—Îles-de-la-Madeleine","lat":48.03448,"lon":-65.46953,"accuracyM":0,"positionSource":"MTQ georeferenced point (WFS, EPSG:4326, 5 decimals)","viewerUrl":"https://www.quebec511.info/Carte/Fenetres/FenetreVideo.html?id=3837","Line truncated
+1 -57
View File
@@ -7,16 +7,13 @@
* Natural Earth 10m admin-1 states and provinces (public domain).
* src/data/local_data/us_census_counties/counties.json
* US Census Bureau cartographic boundary counties (public domain).
* src/data/local_data/natural_earth/populated_places.json
* Natural Earth 10m populated places as points, for the orbital view's
* "near <city>" (`src/data/placeBelow.js`; public domain).
*
* Both sources are pinned by URL and SHA-256, so a rerun reproduces the packs
* byte for byte. Downloads are cached outside the repository (default: the
* system temp directory) and are never committed.
*
* Usage:
* node scripts/build-admin-packs.mjs [--cache <dir>] [--only countries|admin1|counties|places]
* node scripts/build-admin-packs.mjs [--cache <dir>] [--only countries|admin1|counties]
*
* The provenance READMEs beside each pack record these parameters.
*/
@@ -104,9 +101,6 @@ export const PARAMS = Object.freeze({
elsewhereMinPopulation: 50_000,
cityStateMaxKm2: 1_000,
},
// Every populated place, kept as a point: name, position and the two
// prominence ranks the orbital view picks "near <city>" by.
places: { decimals: 3 },
});
const args = process.argv.slice(2);
@@ -694,47 +688,6 @@ async function buildAdmin1() {
};
}
async function buildPlaces() {
const params = PARAMS.places;
const source = await fetchPinned('places');
const factor = 10 ** params.decimals;
const round = (value) => Math.round(value * factor) / factor;
const places = JSON.parse(source.bytes.toString('utf8'))
.features.map((f) => f.properties)
.filter(
(p) =>
p.name &&
Number.isFinite(p.latitude) &&
Number.isFinite(p.longitude) &&
Number.isFinite(p.scalerank),
)
.map((p) => [
p.name,
round(p.latitude),
round(p.longitude),
p.scalerank,
Number.isFinite(p.rank_max) ? p.rank_max : 0,
])
.sort((a, b) => a[3] - b[3] || b[4] - a[4] || a[0].localeCompare(b[0]));
return {
meta: {
title: 'Natural Earth populated places',
source: 'ne_10m_populated_places_simple',
url: SOURCES.places.url,
sha256: source.digest,
commit: NE_COMMIT,
license:
'Public domain (https://www.naturalearthdata.com/about/terms-of-use/)',
schema:
'places[]: [name, latitude, longitude, scalerank (0 most prominent .. 10), rank_max (population class, 0..14; higher is larger)]',
decimals: params.decimals,
curation: params,
script: 'scripts/build-admin-packs.mjs',
},
places,
};
}
async function buildCounties() {
const params = PARAMS.counties;
const source = await fetchPinned('counties');
@@ -815,13 +768,4 @@ if (isMain) {
'src/data/local_data/us_census_counties/counties.json',
await buildCounties(),
);
if (!only || only === 'places') {
const relative = 'src/data/local_data/natural_earth/populated_places.json';
const pack = await buildPlaces();
const text = JSON.stringify(pack) + '\n';
await writeFile(path.join(ROOT, relative), text);
console.log(
`${relative}: ${pack.places.length} places, ${Buffer.byteLength(text)} bytes`,
);
}
}
-101
View File
@@ -1,101 +0,0 @@
#!/usr/bin/env node
/**
* Build the owner flag sheet the orbital Cockpit marks a satellite's owner
* country with (`src/ui/ownerFlag.js`): every flag in OWNER_COUNTRY_CODES
* (`src/layers/satellites/classes.js`), drawn from the flag-icons 4:3 SVGs
* at twice the readout's 20 × 15 px, in one PNG the app loads at startup.
*
* src/data/local_data/owner_flags/owner_flags.png the sheet
* src/ui/ownerFlagSheet.js its layout (generated)
*
* Flag i (in the sorted code list) sits at column i % COLUMNS, row
* floor(i / COLUMNS), each cell followed by a GUTTER of transparent pixels. A rerun with the same flag-icons and sharp versions
* reproduces both files.
*
* Usage: node scripts/build-owner-flags.mjs
*/
import { readFile, writeFile } from 'node:fs/promises';
import { createRequire } from 'node:module';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import * as prettier from 'prettier';
import sharp from 'sharp';
import { OWNER_COUNTRY_CODES } from '../src/layers/satellites/classes.js';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const require = createRequire(import.meta.url);
const FLAGS_DIR = path.join(
path.dirname(require.resolve('flag-icons/package.json')),
'flags/4x3',
);
const FLAG_ICONS_VERSION = require('flag-icons/package.json').version;
/** One flag's cell, CSS pixels × 2. */
export const CELL = Object.freeze({ width: 40, height: 30 });
/**
* Transparent sheet pixels after each flag, so a browser smoothing a scaled
* flag (a 125% display) never reaches into its neighbour.
*/
export const GUTTER = 2;
export const COLUMNS = 16;
const SHEET = path.join(
ROOT,
'src/data/local_data/owner_flags/owner_flags.png',
);
const LAYOUT = path.join(ROOT, 'src/ui/ownerFlagSheet.js');
async function flagCell(code) {
const svg = await readFile(path.join(FLAGS_DIR, `${code.toLowerCase()}.svg`));
return sharp(svg, { density: 300 })
.resize(CELL.width, CELL.height, { fit: 'fill' })
.png()
.toBuffer();
}
const codes = [...OWNER_COUNTRY_CODES];
const rows = Math.ceil(codes.length / COLUMNS);
const pitchX = CELL.width + GUTTER;
const pitchY = CELL.height + GUTTER;
const cells = await Promise.all(codes.map(flagCell));
const sheet = await sharp({
create: {
width: COLUMNS * pitchX,
height: rows * pitchY,
channels: 4,
background: { r: 0, g: 0, b: 0, alpha: 0 },
},
})
.composite(
cells.map((input, i) => ({
input,
left: (i % COLUMNS) * pitchX,
top: Math.floor(i / COLUMNS) * pitchY,
})),
)
.png({ compressionLevel: 9, palette: true, quality: 100, effort: 10 })
.toBuffer();
await writeFile(SHEET, sheet);
const layout = `// Generated by scripts/build-owner-flags.mjs from flag-icons ${FLAG_ICONS_VERSION}; do not edit.
/** The owner flag sheet's layout: flag i at column i % columns, row i / columns. */
export const OWNER_FLAG_SHEET = Object.freeze({
cellWidthPx: ${CELL.width},
cellHeightPx: ${CELL.height},
gutterPx: ${GUTTER},
columns: ${COLUMNS},
rows: ${rows},
codes: Object.freeze(${JSON.stringify(codes)}),
});
`;
await writeFile(
LAYOUT,
await prettier.format(layout, {
...(await prettier.resolveConfig(LAYOUT)),
filepath: LAYOUT,
}),
);
console.log(
`Wrote ${codes.length} flags (${COLUMNS} × ${rows}), ${(sheet.length / 1024).toFixed(1)} KB.`,
);
-118
View File
@@ -1,118 +0,0 @@
#!/usr/bin/env python3
'''Maintainer-only UCS Satellite Database pack build (openpyxl).
Reads the UCS Excel release and writes the compact pack the satellites layer
loads: one row per satellite, keyed by NORAD number, with string-table indexes
for the repeated text columns. Starlink rows are left out: the layer files
every STARLINK object under its own class and operator without them.
Usage: python scripts/build-ucs-satellites.py "<path>/UCS-Satellite-Database 5-1-2023.xlsx"
'''
import argparse
import json
import logging
from pathlib import Path
import re
import openpyxl
LOG = logging.getLogger('build-ucs-satellites')
ROOT = Path(__file__).resolve().parent.parent
PACK = ROOT / 'src/data/local_data/ucs_satellites'
VERSION = 'UCS-Satellite-Database 5-1-2023'
DATA_CURRENT_TO = '2023-05-01'
URL = 'https://www.ucsusa.org/satellite_database'
# Output column -> spreadsheet header.
COLUMNS = {
'operator': 'Operator/Owner',
'operatorCountry': 'Country of Operator/Owner',
'purpose': 'Purpose',
}
STARLINK = re.compile(r'\bstarlink\b', re.I)
def clean(value: object) -> str:
'''One cell as tidy text; UCS's placeholders for nothing become ''.'''
text = re.sub(r'\s+', ' ', str(value or '')).strip()
return '' if text.lower() in ('', 'none', 'n/a') else text
def build(xlsx: Path) -> dict:
'''Read the release's first sheet into the pack the layer loads.'''
sheet = openpyxl.load_workbook(xlsx, read_only=True, data_only=True).worksheets[0]
rows = sheet.iter_rows(values_only=True)
header = {str(h).strip(): i for i, h in enumerate(next(rows)) if h}
names = [header['Current Official Name of Satellite'], header['Name of Satellite, Alternate Names']]
norad_col = header['NORAD Number']
tables = {key: [''] for key in COLUMNS}
index = {key: {'': 0} for key in COLUMNS}
records = {}
source_rows = skipped_starlink = invalid_norad = 0
for row in rows:
if not any(row):
continue
source_rows += 1
if any(STARLINK.search(clean(row[i])) for i in names):
skipped_starlink += 1
continue
try:
norad = int(float(row[norad_col]))
except (TypeError, ValueError):
invalid_norad += 1
continue
if norad <= 0:
invalid_norad += 1
continue
record = [norad]
for key, column in COLUMNS.items():
text = clean(row[header[column]])
if text not in index[key]:
index[key][text] = len(tables[key])
tables[key].append(text)
record.append(index[key][text])
# A duplicate NORAD number keeps the first row, as UCS lists it.
records.setdefault(norad, record)
ordered = [records[n] for n in sorted(records)]
return {
'meta': {
'source': 'Union of Concerned Scientists Satellite Database',
'version': VERSION,
'url': URL,
'dataCurrentTo': DATA_CURRENT_TO,
'license': 'Free, unrestricted use; acknowledgement requested (UCS database manual, section 3)',
'curation': {
'sourceRows': source_rows,
'starlinkRowsDropped': skipped_starlink,
'invalidNoradDropped': invalid_norad,
'duplicateNoradDropped': source_rows - skipped_starlink - invalid_norad - len(ordered),
'columns': COLUMNS,
},
'recordCount': len(ordered),
},
'fields': ['noradId', *COLUMNS],
'strings': tables,
'records': ordered,
}
def main() -> None:
'''Build the pack from the release given on the command line.'''
logging.basicConfig(level=logging.INFO, format='%(message)s')
parser = argparse.ArgumentParser(description='Build the UCS satellite pack from the UCS Excel release.')
parser.add_argument('xlsx', type=Path, help='UCS-Satellite-Database <version>.xlsx')
pack = build(parser.parse_args().xlsx)
PACK.mkdir(parents=True, exist_ok=True)
out = PACK / 'ucs_satellites.json'
# LF on every platform: the repository stores text with LF endings.
out.write_text(
json.dumps(pack, ensure_ascii=False, separators=(',', ':')) + '\n',
encoding='utf-8',
newline='\n',
)
LOG.info('%s: %d records, %d bytes', out.relative_to(ROOT), pack['meta']['recordCount'], out.stat().st_size)
if __name__ == '__main__':
main()
+1 -1
View File
@@ -44,7 +44,7 @@ CCTV_NSW_ENABLED="${CCTV_NSW_ENABLED:-1}"
CCTV_NSW_MAX_SOURCES="${CCTV_NSW_MAX_SOURCES:-250}"
CCTV_CALGARY_ENABLED="${CCTV_CALGARY_ENABLED:-1}"
CCTV_CALGARY_MAX_SOURCES="${CCTV_CALGARY_MAX_SOURCES:-220}"
CCTV_MAX_SOURCES="${CCTV_MAX_SOURCES:-7350}"
CCTV_MAX_SOURCES="${CCTV_MAX_SOURCES:-4000}"
# Capture which provider credentials genuinely came from the parent shell
# before this launcher resolves dotenv and Keychain fallbacks. Only names are
+1 -29
View File
@@ -112,8 +112,6 @@
"src/data/terrainHeightsProxy.test.mjs",
"src/data/tomtomTiles.js",
"src/data/tomtomTiles.test.mjs",
"src/data/tileBudget.js",
"src/data/tileBudget.test.mjs",
"src/data/firmsCsv.js",
"src/data/firmsCsv.test.mjs",
"src/tooling/environmentProviders.test.mjs",
@@ -330,12 +328,6 @@
"src/ui/styles/responsive.css",
"src/ui/styles/voice-cost.css",
"src/ui/styles/cockpit.css",
"src/ui/styles/cockpitBridge.css",
"src/ui/styles/cockpitRadar.css",
"src/ui/styles/cockpitOrbital.css",
"src/layers/satellites/sensors.js",
"src/layers/satellites/sensors.test.mjs",
"src/data/imagingSensors.js",
"src/ui/styles/command-dock-compact.css",
"src/app/stateChannel.js",
"src/app/stateChannel.test.mjs",
@@ -355,7 +347,6 @@
"src/layers/flights/tracking.js",
"src/layers/flights/rendering.js",
"src/layers/flights/testing.js",
"src/layers/flights/hiddenContactRotation.test.mjs",
"src/layers/flights/evidence.js",
"src/layers/flights/ingestion.js",
"src/layers/flights/queries.js",
@@ -483,7 +474,6 @@
"src/layers/satellites/stale.test.mjs",
"src/layers/launches/source.test.mjs",
"src/layers/launches/ingestion.test.mjs",
"src/layers/launches/failedEntry.test.mjs",
"src/layers/installations/lifecycle.js",
"src/layers/installations/viewport.js",
"src/layers/installations/controls.js",
@@ -591,7 +581,6 @@
"src/data/cctvHk.test.mjs",
"src/data/cctvVegvesen.test.mjs",
"src/data/cctvA22.test.mjs",
"src/data/cctvIceland.test.mjs",
"src/data/cctvNyc.test.mjs",
"src/data/cctvHouston.test.mjs",
"src/data/cctvNswSource.test.mjs",
@@ -679,7 +668,6 @@
"src/voice/control.js",
"src/voice/realtimeBackend.js",
"src/voice/realtimeBackend.test.mjs",
"src/voice/realtimeViewportPolicy.test.mjs",
"src/agent/worldPacket.test.mjs",
"src/voice/layerSummary.js",
"src/app/sources.js",
@@ -1078,7 +1066,6 @@
"src/layers/traffic/roadModes.test.mjs",
"scripts/qa-traffic-motion.mjs",
"src/layers/traffic/motion.test.mjs",
"src/layers/traffic/lineRebuild.test.mjs",
"scripts/qa-journey-recorder.mjs",
"scripts/qa-alpr-journey.mjs",
"scripts/qa-context-arrivals.mjs",
@@ -1130,7 +1117,6 @@
"src/sky/starrySky.test.mjs",
"src/tooling/skyAssets.test.mjs",
"src/tooling/skyAssets.paintOut.test.mjs",
"src/tooling/responseCompression.test.mjs",
"scripts/qa-anime-sky.mjs",
"scripts/qa-sky-model-lighting.mjs",
"server/providers/overpass/requestAdmission.test.mjs",
@@ -1231,7 +1217,6 @@
"src/data/firmsIntensity.js",
"src/data/firmsIntensity.test.mjs",
"src/ui/layerPanel/RowSwitch.vue",
"src/ui/layerPanel/RowRange.vue",
"src/ui/layerPanel/RowSlider.vue",
"src/sources/phaseTiming.test.mjs",
"scripts/build-census-places.mjs",
@@ -1286,13 +1271,6 @@
"src/voice/panelLayout.js",
"src/voice/textChat.js",
"src/voice/textProtocol.js",
"src/layers/shipwrecks/records.test.mjs",
"src/layers/shipwrecks/cards.test.mjs",
"src/layers/shipwrecks/eras.test.mjs",
"src/layers/shipwrecks/period.test.mjs",
"src/layers/shipwrecks/index.test.mjs",
"src/layers/shipwrecks/source.test.mjs",
"src/data/shipwrecksProxy.test.mjs",
"src/app/sourceComposition.js",
"src/app/sourceComposition.test.mjs",
"src/data/dataCredits.js",
@@ -1303,7 +1281,6 @@
"src/i18n/locales/de.json",
"src/i18n/locales/es.json",
"src/i18n/locales/fr.json",
"src/i18n/locales/he.json",
"src/i18n/locales/it.json",
"src/i18n/locales/ja.json",
"src/i18n/locales/pt.json",
@@ -1377,10 +1354,5 @@
"src/layers/wildlifeSightings/cache.integration.test.mjs",
"src/layers/wildlifeSightings/density.test.mjs",
"src/layers/wildlifeSightings/selection.test.mjs",
"server/providers/inaturalist/proxy.fixture.mjs",
"scripts/build-owner-flags.mjs",
"src/ui/ownerFlagSheet.js",
"src/data/placePacksLoading.test.mjs",
"src/ui/placePacksLoading.js",
"src/ui/placePacksLoading.test.mjs"
"server/providers/inaturalist/proxy.fixture.mjs"
]
File diff suppressed because it is too large. Load diff
-3
View File
@@ -4,7 +4,6 @@ import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import puppeteer from 'puppeteer';
import { stubPlanespotters } from './qa-photo-stub.mjs';
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const shotsDir = path.join(repoRoot, 'qa-shots', 'cockpit-utility');
@@ -30,7 +29,6 @@ const browser = await puppeteer.launch({
],
});
const page = await browser.newPage();
await stubPlanespotters(page);
const failures = [];
const consoleErrors = [];
const localHttpErrors = [];
@@ -827,7 +825,6 @@ try {
// a legitimate retained snapshot cannot satisfy or invalidate a FIRST-fetch
// assertion when camera motion supersedes the manager's initial update.
const readinessPage = await browser.newPage();
await stubPlanespotters(readinessPage);
let deferredInstallationReadiness;
try {
await readinessPage.setViewport({ width: 1440, height: 900 });
-2
View File
@@ -28,7 +28,6 @@
import fs from 'node:fs';
import path from 'node:path';
import puppeteer from 'puppeteer';
import { stubPlanespotters } from './qa-photo-stub.mjs';
import { createScreencast, moveCamera } from './qa-journey-recorder.mjs';
const argv = process.argv.slice(2);
@@ -78,7 +77,6 @@ const browser = await puppeteer.launch({
async function openPage({ aircraft = false } = {}) {
const page = await browser.newPage();
await stubPlanespotters(page);
await page.setViewport({ width: 1440, height: 900 });
page.on('console', (message) => {
if (message.type() === 'error') result.consoleErrors.push(message.text());
-2
View File
@@ -27,7 +27,6 @@
*/
import puppeteer from 'puppeteer';
import { stubPlanespotters } from './qa-photo-stub.mjs';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
@@ -224,7 +223,6 @@ async function main() {
const errors = [];
try {
const page = await browser.newPage();
await stubPlanespotters(page);
await page.setViewport({ width: 1440, height: 900 });
page.on('pageerror', (e) => errors.push(e.message));
const { lon, lat } = CENTER;
-2
View File
@@ -21,7 +21,6 @@ import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import puppeteer from 'puppeteer';
import { stubPlanespotters } from './qa-photo-stub.mjs';
const argv = process.argv.slice(2);
const getOpt = (name, fallback = null) => {
@@ -652,7 +651,6 @@ async function main() {
],
});
const page = await browser.newPage();
await stubPlanespotters(page);
await page.setViewport(VIEWPORT);
const consoleMessages = [];
page.on('console', (message) => {
-73
View File
@@ -28,8 +28,6 @@
* (vii) FAR SIDE — pins skip the depth test, so they are horizon-culled:
* seen from over the Indian Ocean no pin clearly behind the planet is
* shown; back over North America the near-side pins show again.
* (viii) AGE — seven days is the default; shorter/longer windows and All
* markers update the globe, and the age choice survives a reload.
*
* Screenshots go to qa-shots/ghostmaps-*.png (gitignored).
*
@@ -226,77 +224,6 @@ async function main() {
record('LIVE: the /api/ghostmaps/events stream is open', stream);
await page.screenshot({ path: path.join(SHOTS_DIR, 'ghostmaps-live.png') });
const ageChips = await page.evaluate(
(id) =>
[
...document.querySelectorAll(
`[data-layer-id="${id}"] [data-segmented-id="age"] [role="radio"]`,
),
].map((option) => ({
id: option.dataset.chipId,
active: option.getAttribute('aria-checked') === 'true',
})),
LAYER_ID,
);
record(
'AGE: seven days is the default with an All markers option',
live.ageDays === 7 &&
ageChips.length === 6 &&
ageChips
.filter((chip) => chip.active)
.map((chip) => chip.id)
.join() === 'age-7' &&
ageChips.some((chip) => chip.id === 'age-all'),
);
const selectAge = async (days) => {
await showRow(page);
const selector = `[data-layer-id="${LAYER_ID}"] [data-chip-id="age-${days ?? 'all'}"]`;
await page.waitForSelector(selector, { timeout: 15000 });
// Dispatch through the rendered button, matching the product-chip gate.
await page.$eval(selector, (button) => button.click());
await page.waitForFunction(
(id, age) => {
const s = window.__godsEyeView.dataManager.layers
.get(id)
.module.getStats();
return s.ageDays === age && !s.loading;
},
{ timeout: TIMEOUT_MS },
LAYER_ID,
days,
);
return stats(page);
};
const all = await selectAge(null);
record(
'AGE: All markers restores old events without duplicate data sources',
all.count > live.count &&
(await sources(page)).length === live.products.length,
`${live.count} → ${all.count}`,
);
await page.reload({ waitUntil: 'domcontentloaded' });
await page.waitForFunction(() => window.__godsEyeView?.dataManager, {
timeout: TIMEOUT_MS,
});
await enableAndSettle(page);
const persistedAge = await stats(page);
record(
'AGE: All markers survives a reload',
persistedAge.ageDays === null && persistedAge.count === all.count,
);
const thirty = await selectAge(30);
const one = await selectAge(1);
const seven = await selectAge(7);
record(
'AGE: counts follow the window order and seven days restores the default',
one.count <= live.count &&
thirty.count >= live.count &&
thirty.count <= all.count &&
seven.count === live.count &&
seven.ageDays === 7,
`1d=${one.count}, 7d=${seven.count}, 30d=${thirty.count}, all=${all.count}`,
);
// ---- (ii) PICK ----------------------------------------------------------
const target = await page.evaluate((id) => {
const gev = window.__godsEyeView;
-2
View File
@@ -99,7 +99,6 @@
import fs from 'node:fs';
import path from 'node:path';
import puppeteer from 'puppeteer';
import { stubPlanespotters } from './qa-photo-stub.mjs';
// ---------------------------------------------------------------------------
// Args (same shape as track-regression.mjs)
@@ -467,7 +466,6 @@ async function main() {
const failedResponses = [];
try {
const page = await browser.newPage();
await stubPlanespotters(page);
await page.setViewport({ width: 1280, height: 800 });
page.on('console', (msg) => {
if (msg.type() === 'error') {
+12 -12
View File
@@ -239,11 +239,11 @@ function satisfiesEngines(version, range) {
* re-resolve the interpreter from PATH and hand the gate a different Node than
* the one the check believes it selected.
* @param {string} version e.g. "v24.19.0"
* @returns {boolean} true for a parseable supported Node 24 or 26.
* @returns {boolean} true only for a parseable Node 24.
*/
function isCalibratedAllocationRuntime(version) {
const [maj] = String(version || '').trim().replace(/^v/, '').split('.').map((n) => Number.parseInt(n, 10));
return maj === 24 || maj === 26;
return maj === 24;
}
/**
@@ -546,7 +546,7 @@ const check = (spec) => { CHECKS.push(spec); };
// ─── A · REPO GATES ───────────────────────────────────────────────────────
check({
id: 'A1', group: 'A', desc: 'Node runtime satisfies package.json engines (allocation budgets cover supported Node majors)',
id: 'A1', group: 'A', desc: 'Node runtime satisfies package.json engines (allocation budgets are pinned to Node 24)',
run: async () => {
const pkg = JSON.parse(readFileSync(resolve(REPO_ROOT, 'package.json'), 'utf8'));
const range = pkg.engines?.node || '(unset)';
@@ -557,7 +557,7 @@ check({
if (satisfied) return pass(`node ${process.versions.node} satisfies "${range}"`);
// An off-range runtime is an environment problem, not a product defect —
// but it silently disables the allocation gate, so say so loudly. A3 then
// runs that gate under a discovered calibrated Node 24 fallback.
// runs that gate under a discovered Node 24.
return env.node24
? skip(`node ${process.versions.node} is OUTSIDE "${range}"; A3 runs the allocation gate under ${env.node24.label}, but prefer running the whole L9 pass on Node 24`, 'ENV')
: fail(`node ${process.versions.node} is OUTSIDE "${range}" and no Node 24 runtime was found — the allocation gate cannot run at all`);
@@ -579,13 +579,13 @@ check({
});
check({
id: 'A3', group: 'A', desc: 'Allocation microbenchmarks actually EXECUTE on supported Node majors',
id: 'A3', group: 'A', desc: 'Allocation microbenchmarks actually EXECUTE (they silently skip off Node 24)',
run: async () => {
// npm test prints "[unit] SKIPPED n allocation microbenchmarks" and still
// exits 0 when the runtime is outside the calibrated supported majors. A green
// suite is therefore NOT proof the gate ran. Force it under a calibrated runtime.
// exits 0 when the runtime is not Node 24. A green suite is therefore NOT
// proof the gate ran. Force it, under Node 24 when one is discoverable.
//
// ALWAYS invoke the calibrated Node binary directly — never `npm test`. npm
// ALWAYS invoke the Node 24 BINARY directly — never `npm test`. npm
// re-resolves the interpreter from PATH and can land back on the system
// Node even when THIS process is already 24: running the whole matrix under
// `mise exec [email protected] --`, the npm shell-out still re-execed system
@@ -597,10 +597,10 @@ check({
const v = await sh(bin, ['--version'], { timeoutMs: 60000 });
const version = (v.out || '').trim();
if (!isCalibratedAllocationRuntime(version)) {
// run-unit-tests.mjs refuses to measure calibrated budgets off supported Node majors,
// run-unit-tests.mjs refuses to measure calibrated budgets off Node 24,
// so a wrong binary means the gate never ran. That is this check failing
// to establish its own claim — not evidence about the product.
return crash(`the runtime selected for the allocation gate (${label}) reports ${version || 'no parseable version'}, not Node 24 or 26 — the gate would refuse or silently skip, so this check verified nothing`);
return crash(`the runtime selected for the allocation gate (${label}) reports ${version || 'no parseable version'}, not Node 24 — the gate would refuse or silently skip, so this check verified nothing`);
}
const r = await sh(bin, [resolve(REPO_ROOT, 'scripts/run-unit-tests.mjs')], {
timeoutMs: 600000, env: { GEV_REQUIRE_ALLOCATION_GATE: '1' },
@@ -611,9 +611,9 @@ check({
};
const [maj] = process.versions.node.split('.').map(Number);
if (maj === 24 || maj === 26) return runGate(process.execPath, 'this runtime');
if (maj === 24) return runGate(process.execPath, 'this runtime');
if (env.node24) return runGate(env.node24.bin, env.node24.label);
return skip(`no calibrated Node 24/26 runtime found (running ${process.versions.node}); the gate SKIPS silently — install a supported Node runtime and re-run`, 'OWNER-RUN');
return skip(`no Node 24 runtime found (running ${process.versions.node}); the gate SKIPS silently — install Node 24 and re-run`, 'OWNER-RUN');
},
});
-78
View File
@@ -1,78 +0,0 @@
/**
* Keep browser QA off third-party aircraft photo services.
*
* Tracking a civil flight asks Planespotters for the aircraft's photo. QA
* runs track synthetic hexes, so a real request only adds a third-party
* dependency and, when the service refuses a headless browser, a console
* error that fails the "no console errors" checks. Every photo lookup is
* answered with no photos instead, without leaving the browser.
*/
/** Origin of the aircraft photo lookup. */
export const PLANESPOTTERS_ORIGIN = 'https://api.planespotters.net';
/** The answer every stubbed lookup gets: no photos for this aircraft. */
export const PLANESPOTTERS_EMPTY_BODY = JSON.stringify({ photos: [] });
/** Whether a URL is a photo lookup the stub answers. */
export function isPlanespottersRequest(url) {
try {
return new URL(String(url)).origin === PLANESPOTTERS_ORIGIN;
} catch {
return false;
}
}
/**
* Answer an intercepted photo lookup from a page that already uses request
* interception. Returns false for any other request, which the caller
* handles as before.
* @param {import('puppeteer').HTTPRequest} request
* @returns {boolean} Whether the request was answered.
*/
export function respondToPlanespotters(request) {
if (!isPlanespottersRequest(request.url())) return false;
request.respond({
status: 200,
contentType: 'application/json',
headers: { 'Access-Control-Allow-Origin': '*' },
body: PLANESPOTTERS_EMPTY_BODY,
});
return true;
}
/**
* Stub photo lookups in a page that does not intercept requests: wraps
* `fetch` before any app code runs, so a lookup never reaches the network.
* Composes with other `fetch` shims in either order.
* @param {import('puppeteer').Page} page
* @returns {Promise<void>}
*/
export async function stubPlanespotters(page) {
await page.evaluateOnNewDocument(
(origin, body) => {
const original = window.fetch.bind(window);
window.fetch = (input, init) => {
const href =
typeof input === 'string' ? input : input?.url || String(input);
let target = '';
try {
target = new URL(href, location.href).origin;
} catch {
/* not a URL */
}
if (target === origin) {
return Promise.resolve(
new Response(body, {
status: 200,
headers: { 'Content-Type': 'application/json' },
}),
);
}
return original(input, init);
};
},
PLANESPOTTERS_ORIGIN,
PLANESPOTTERS_EMPTY_BODY,
);
}
-2
View File
@@ -32,7 +32,6 @@
import fs from 'node:fs';
import path from 'node:path';
import puppeteer from 'puppeteer';
import { stubPlanespotters } from './qa-photo-stub.mjs';
import { classifyAircraft, CLASS_SCALE_2D, CLASS_SCALE_3D, CLASS_MODEL_REAL } from '../src/data/aircraftClass.js';
// Hangar fleet (2026-08-16): real per-class GLBs render at scale 1; military
// heavies (airliner/quadjet/glider) render airplane.glb at 1 x class; only
@@ -187,7 +186,6 @@ async function main() {
const httpErrors = [];
try {
const page = await browser.newPage();
await stubPlanespotters(page);
await page.setViewport({ width: 1280, height: 800 });
page.on('console', (msg) => {
if (msg.type() === 'error') {
+1 -33
View File
@@ -296,22 +296,6 @@ async function expandPanel(page) {
);
}
/** A keyless bundle opens on the first keyless source with live controls. */
async function assertKeylessDefault(page) {
await page.evaluate(() =>
window.__godsEyeView.dataManager.setEnabled('street-level', true, {
origin: 'user',
}),
);
await uiUntil(page, litAlone, 'kartaview');
const shown = await page.evaluate(() => ({
status: document.getElementById('sl-status').textContent,
disabled: document.getElementById('sl-controls').disabled,
}));
assert.notEqual(shown.status, 'KEY REQUIRED');
assert.equal(shown.disabled, false, 'controls stay live');
}
async function assertKeyRequired(page) {
await page.evaluate(() =>
window.__godsEyeView.dataManager.setEnabled('street-level', true, {
@@ -544,25 +528,11 @@ async function main() {
);
await expandPanel(page);
if (!configured && litAlone(await getUI(page), 'mapillary')) {
// A keyed bundle behind a server without the token keeps Mapillary.
if (!configured) {
await step('keyless server: KEY REQUIRED gates the controls', () =>
assertKeyRequired(page),
);
skip('no Mapillary key', 'the keyed steps');
} else if (!configured) {
await step('keyless server: opens on KartaView, not KEY REQUIRED', () =>
assertKeylessDefault(page),
);
await step(
'keyless server: the MAPILLARY chip explains its key instead of switching',
async () => {
await press(page, '#sl-provider-chips [data-chip-id="mapillary"]');
await new Promise((resolve) => setTimeout(resolve, 500));
assert.ok(litAlone(await getUI(page), 'kartaview'));
},
);
skip('no Mapillary key', 'the keyed steps');
} else {
await park(page);
await step(
@@ -781,8 +751,6 @@ async function main() {
await park(page);
await fixtureStep(
// The fixture bundle carries a token, so this covers a keyless
// server behind a keyed bundle; the keyless default is checked above.
'keyless server (second page): KEY REQUIRED gates the controls',
async () => {
const keyless = await open(
-241
View File
@@ -1,241 +0,0 @@
#!/usr/bin/env node
/**
* Trail freeze gate: freezing a trail chunk never shows a blank frame.
*
* A tracked aircraft's trail is cut into chunks; every chunk but the newest
* is frozen into static geometry, which Cesium builds asynchronously. Until
* that geometry is in the scene, the chunk keeps being drawn dynamically.
*
* This gate drives the real app and a real Cesium scene: it draws a trail of
* CHUNK_POINTS points (all dynamic), then adds one point so the first chunk
* freezes, and on every rendered frame after that it checks two things:
* 1. every frozen chunk is either still drawn by a dynamic polyline or its
* static geometry is in the scene (DataSourceDisplay reports DONE);
* 2. the number of trail-coloured pixels never drops below 90% of the
* count measured just before the freeze.
* It also checks that clearing the trail releases every chunk entity.
*
* The camera is held still while the gate runs.
*
* Needs a running dev server:
* node scripts/qa-trail-freeze.mjs [--url http://localhost:4173]
*/
import puppeteer from 'puppeteer';
import { chromeExecutablePath } from './qa-chrome.mjs';
const urlFlag = process.argv.indexOf('--url');
const APP_URL =
urlFlag > 0 ? process.argv[urlFlag + 1] : 'http://localhost:4173';
const browser = await puppeteer.launch({
headless: 'new',
protocolTimeout: 300_000,
executablePath: chromeExecutablePath(),
args: [
'--no-sandbox',
'--disable-setuid-sandbox',
'--use-gl=angle',
'--use-angle=swiftshader',
'--disable-dev-shm-usage',
'--disable-background-timer-throttling',
'--disable-renderer-backgrounding',
'--window-size=960,640',
],
});
let failed = false;
const report = (name, ok, detail) => {
if (!ok) failed = true;
console.log(
` [${ok ? 'PASS' : 'FAIL'}] ${name}${detail ? ` — ${detail}` : ''}`,
);
};
try {
const page = await browser.newPage();
await page.setViewport({ width: 960, height: 640 });
await page.goto(APP_URL, { waitUntil: 'domcontentloaded', timeout: 120_000 });
await page.waitForFunction(
() =>
window.__godsEyeView?.viewer &&
document.getElementById('loading-screen')?.classList.contains('hidden'),
{ timeout: 180_000 },
);
const result = await page.evaluate(async () => {
// The exact Cesium module the renderer uses, so classes are shared.
const source = await (await fetch('/src/data/trailRenderer.js')).text();
const cesiumUrl = source.match(/from\s+["']([^"']*cesium[^"']*)["']/)[1];
const Cesium = await import(cesiumUrl);
const { createTrail, CHUNK_POINTS } =
await import('/src/data/trailRenderer.js');
const viewer = window.__godsEyeView.viewer;
const scene = viewer.scene;
const nextFrame = () =>
new Promise((resolve) => {
const remove = scene.postRender.addEventListener(() => {
remove();
resolve();
});
scene.requestRender();
});
// A line across the middle of the current view, in mid-air in front of
// the camera, so terrain and buildings never hide it.
viewer.camera.cancelFlight?.();
const camera = viewer.camera;
const ahead = Cesium.Cartesian3.add(
camera.positionWC,
Cesium.Cartesian3.multiplyByScalar(
camera.directionWC,
3_000,
new Cesium.Cartesian3(),
),
new Cesium.Cartesian3(),
);
const points = Array.from({ length: CHUNK_POINTS + 1 }, (_, i) =>
Cesium.Cartesian3.add(
ahead,
Cesium.Cartesian3.multiplyByScalar(
camera.rightWC,
-1_200 + (2_400 * i) / CHUNK_POINTS,
new Cesium.Cartesian3(),
),
new Cesium.Cartesian3(),
),
);
const added = [];
const removed = [];
const add = viewer.entities.add.bind(viewer.entities);
const remove = viewer.entities.remove.bind(viewer.entities);
viewer.entities.add = (definition) => {
const entity = add(definition);
if (String(entity.id).startsWith('gev-trail:')) added.push(entity);
return entity;
};
viewer.entities.remove = (entity) => {
if (String(entity?.id).startsWith('gev-trail:')) removed.push(entity);
return remove(entity);
};
const gl = scene.context._gl;
const width = gl.drawingBufferWidth;
const height = gl.drawingBufferHeight;
const pixels = new Uint8Array(width * height * 4);
// Magenta trail: count pixels that are clearly magenta.
const countTrailPixels = () => {
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
gl.readPixels(0, 0, width, height, gl.RGBA, gl.UNSIGNED_BYTE, pixels);
let count = 0;
for (let p = 0; p < pixels.length; p += 4) {
const r = pixels[p];
const g = pixels[p + 1];
const b = pixels[p + 2];
if (r > 150 && b > 150 && g < 110) count++;
}
return count;
};
const trail = createTrail(viewer, { color: '#ff00ff', width: 8 });
trail.setPositions(points.slice(0, CHUNK_POINTS));
for (let i = 0; i < 20; i++) await nextFrame();
const before = trail._chunksForTest();
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
gl.readPixels(0, 0, width, height, gl.RGBA, gl.UNSIGNED_BYTE, pixels);
let lit = 0;
for (let p = 0; p < pixels.length; p += 4)
if (pixels[p] || pixels[p + 1] || pixels[p + 2]) lit++;
const cam = viewer.camera.positionCartographic;
const diagnostics = {
lit,
size: [width, height],
camera: [
Cesium.Math.toDegrees(cam.longitude),
Cesium.Math.toDegrees(cam.latitude),
cam.height,
],
preserveDrawingBuffer: gl.getContextAttributes().preserveDrawingBuffer,
};
let baseline = 0;
for (let i = 0; i < 5; i++) {
await nextFrame();
baseline = Math.max(baseline, countTrailPixels());
}
trail.setPositions(points);
const frames = [];
const sphere = new Cesium.BoundingSphere();
let resolvedAt = null;
for (let i = 0; i < 400; i++) {
await nextFrame();
const state = trail._chunksForTest();
const covered = state.frozen.every(
(chunk) =>
chunk.standIn ||
viewer.dataSourceDisplay.getBoundingSphere(
chunk.entity,
false,
sphere,
) === Cesium.BoundingSphereState.DONE,
);
frames.push({ covered, pixels: countTrailPixels() });
if (resolvedAt == null && state.frozen.every((c) => !c.standIn))
resolvedAt = i;
if (resolvedAt != null && i > resolvedAt + 30) break;
}
trail.clear();
for (let i = 0; i < 3; i++) await nextFrame();
const frozenAfterClear = trail._chunksForTest().frozen.length;
trail.destroy();
const leaked = added.filter((entity) => !removed.includes(entity)).length;
viewer.entities.add = add;
viewer.entities.remove = remove;
return {
chunkPoints: CHUNK_POINTS,
diagnostics,
frozenBefore: before.frozen.length,
baseline,
frames,
resolvedAt,
frozenAfterClear,
entities: added.length,
leaked,
};
});
if (process.env.GEV_TEST_VERBOSE)
console.log(JSON.stringify(result.diagnostics));
const minPixels = Math.min(...result.frames.map((frame) => frame.pixels));
report(
'the trail draws before the freeze',
result.baseline > 200 && result.frozenBefore === 0,
`baseline=${result.baseline} trail pixels, frozen chunks=${result.frozenBefore}`,
);
report(
'the frozen chunk reaches the scene as static geometry',
result.resolvedAt != null,
`stand-in dropped after ${result.resolvedAt} frames`,
);
report(
'every frame after the freeze draws the frozen chunk (dynamic copy or static geometry)',
result.frames.every((frame) => frame.covered),
`${result.frames.length} frames checked`,
);
report(
'no blank frame: trail pixels never drop below 90% of the pre-freeze count',
minPixels >= 0.9 * result.baseline,
`min=${minPixels} baseline=${result.baseline}`,
);
report(
'clearing and destroying release every trail entity',
result.frozenAfterClear === 0 && result.leaked === 0,
`entities created=${result.entities} leaked=${result.leaked}`,
);
} catch (error) {
report('gate ran', false, error.message);
} finally {
await browser.close();
}
console.log(failed ? 'RESULT: FAIL' : 'RESULT: PASS');
process.exit(failed ? 1 : 0);
+1 -1
View File
@@ -801,7 +801,7 @@ try {
);
check(
`${preset.name}: the icon raster cache stays bounded`,
styling.iconCacheSize <= 6 * 2 * 3 * 2,
styling.iconCacheSize <= 6 * 2 * 3,
`${styling.iconCacheSize} rasters`,
);
}
+11 -11
View File
@@ -8,16 +8,15 @@ export const ALLOCATION_TEST_FILES = Object.freeze([
'src/overlays/worldOverlayAllocation.test.mjs',
]);
/** Whether this runtime matches a supported major the allocation budgets are calibrated on. */
/** Whether this runtime matches the one the allocation budgets were calibrated on. */
export function isCalibratedAllocationRuntime(version = process.versions.node) {
const major = Number.parseInt(String(version).split('.')[0], 10);
return major === 24 || major === 26;
return Number.parseInt(String(version).split('.')[0], 10) === 24;
}
/** Require a runtime on which allocation budgets are calibrated. */
export function assertCalibratedAllocationRuntime(version = process.versions.node) {
/** Require the runtime on which allocation budgets were calibrated. */
export function assertNode24AllocationRuntime(version = process.versions.node) {
if (!isCalibratedAllocationRuntime(version)) {
throw new Error(`Allocation budgets require calibrated Node 24 or 26; received ${version}`);
throw new Error(`Allocation budgets require the calibrated Node 24 runtime; received ${version}`);
}
return version;
}
@@ -93,18 +92,19 @@ export function runUnitTests() {
const parallelStatus = runTests(['--test', ...plan.parallel]);
if (parallelStatus !== 0) return parallelStatus;
// The GC-bracketed budgets are calibrated on every supported Node major.
// Unsupported runtimes may still run ordinary tests, so uncalibrated runtimes skip the
// The GC-bracketed budgets are calibrated on Node 24 and are meaningless on
// other allocators. A contributor's suite must stay green on any supported
// engine (package.json permits >=24), so uncalibrated runtimes skip the
// probes with a warning. Set GEV_REQUIRE_ALLOCATION_GATE=1 (pinned CI /
// release batteries) to make an uncalibrated runtime a hard failure.
if (!isCalibratedAllocationRuntime()) {
if (process.env.GEV_REQUIRE_ALLOCATION_GATE === '1') {
assertCalibratedAllocationRuntime();
assertNode24AllocationRuntime();
}
console.warn(
`[unit] SKIPPED ${ALLOCATION_TEST_FILES.length} allocation microbenchmarks: `
+ `budgets are calibrated for Node 24 and 26, running ${process.versions.node}. `
+ 'Run under Node 24 or 26 (or set GEV_REQUIRE_ALLOCATION_GATE=1 to fail instead).',
+ `budgets are calibrated for Node 24, running ${process.versions.node}. `
+ 'Run under Node 24 (or set GEV_REQUIRE_ALLOCATION_GATE=1 to fail instead).',
);
return 0;
}
+21 -165
View File
@@ -42,9 +42,8 @@
* and a real mouse click on the tracked plane is a
* NO-OP (no deselect, no camera spike).
* M3. AGE-OUT RELEASE — when the tracked plane's fixes stop arriving
* it stays tracked (STALE, then SIGNAL LOST) until
* 10 min after its newest fix; then tracking clears
* and the camera is RELEASED IN PLACE: viewer.trackedEntity
* (3 missed polls via the shim), tracking clears and
* the camera is RELEASED IN PLACE: viewer.trackedEntity
* undefined, NO jump (owner decision 2026-07-02 —
* the old ~80 km overview flyTo is gone).
*
@@ -95,7 +94,6 @@
import fs from 'node:fs';
import puppeteer from 'puppeteer';
import { respondToPlanespotters } from './qa-photo-stub.mjs';
import { arrivalAngleViolated, arrivalRotationPassed } from '../src/tooling/trackArrivalObservation.mjs';
import { classifyAircraft, CLASS_SCALE_3D, CLASS_MODEL_REAL } from '../src/data/aircraftClass.js';
import { ensureGeoidReady, geoidHeight } from '../src/data/geoid.js';
@@ -289,8 +287,6 @@ async function main() {
});
return;
}
// Aircraft photo lookups never reach the third party: no photos.
if (respondToPlanespotters(request)) return;
if (url.origin === APP_ORIGIN && url.pathname === '/api/openai/hud-summary') {
request.respond({
status: 200,
@@ -363,12 +359,7 @@ async function main() {
'Synthetica', // 2 origin_country
Math.floor(Date.now() / 1000), // 3 time_position
Math.floor(Date.now() / 1000), // 4 last_contact
// A plane given `moveFromMs` flies east at its reported speed
// (the trail-head check needs a moving icon); others hold still.
Number.isFinite(f.moveFromMs)
? f.lon + (f.vel * (Date.now() - f.moveFromMs)) / 1000
/ (111320 * Math.cos((f.lat * Math.PI) / 180))
: f.lon, // 5 longitude
f.lon, // 5 longitude
f.lat, // 6 latitude
f.alt, // 7 baro_altitude (m)
f.onGround === true, // 8 on_ground (ground-traffic phase flips this live)
@@ -418,15 +409,6 @@ async function main() {
status: 'ready',
}));
}
// CelesTrak SATCAT, which the satellite classes read beside the TLEs.
// An empty CSV (header only) keeps the run hermetic: the classes fall
// back to names and groups, as they do when SATCAT is unavailable.
if (isAppRequest && url.pathname.startsWith('/api/celestrak-satcat/')) {
return Promise.resolve(new Response(
'OBJECT_NAME,OBJECT_ID,NORAD_CAT_ID,OBJECT_TYPE,OPS_STATUS_CODE,LAUNCH_DATE\n',
{ status: 200, headers: { 'Content-Type': 'text/csv' } },
));
}
// CelesTrak TLE groups. Served from a fixed two-satellite catalog so
// the satellite assertions are deterministic and the run never depends
// on an upstream that rate-limits (it was answering 403 the night this
@@ -1504,110 +1486,6 @@ async function main() {
`max=${maxDelta.toFixed(3)} m over ${samples.length} frames (tol ${JITTER_TOL_M} m, mode=${modelUp ? 'model-matrix' : 'display-contract'})`);
}
// ============================================================
// TRAIL HEAD ENDS AT THE DRAWN ICON, every rendered frame.
// The head's last vertex is where the tracked plane is drawn (the
// display position, or the model's tail anchor while the model is the
// visual). getTrackedInfo reports the dead-reckoned position without
// the brief post-report easing; its gap is logged, not asserted.
// ============================================================
console.log('\nTrail head — ends at the drawn icon on every frame');
// The fixture planes repeat one position, so the icon holds and draws no
// head. Fly the tracked one east for this check; the display runs 30 s
// behind, so give it time to reach the moving reports.
await evalPage((icao) => {
const f = window.__SYNTH.flights.find((p) => p.icao === icao);
f.moveFromMs = Date.now();
}, trackedIcao);
await sleep(55_000);
const headSamples = await sampleFrames(page, 40, (icao) => {
const viewer = window.__godsEyeView.viewer;
const fl = window.__godsEyeView.dataManager.layers.get('flights').module;
const ent = viewer.trackedEntity;
const time = viewer.clock.currentTime;
const head = viewer.entities.values.find((e) => String(e.id).startsWith('gev-trail:fl-head-'));
const line = head?.polyline?.positions?.getValue(time);
const drawn = ent?.position?.getValue(time);
if (!line?.length || !drawn) return null;
const end = line[line.length - 1];
const dist = (a, b) => Math.hypot(a.x - b.x, a.y - b.y, a.z - b.z);
const ti = fl.getTrackedInfo();
let infoGap = null;
if (ti && ti.icao24 === icao) {
// WGS84 lat/lon → ECEF on the ellipsoid; the angle at the centre to
// the drawn position, times the mean radius, is the ground gap.
const a = 6378137, e2 = 6.69437999014e-3;
const lat = (ti.latitude * Math.PI) / 180, lon = (ti.longitude * Math.PI) / 180;
const n = a / Math.sqrt(1 - e2 * Math.sin(lat) ** 2);
const p = {
x: n * Math.cos(lat) * Math.cos(lon),
y: n * Math.cos(lat) * Math.sin(lon),
z: n * (1 - e2) * Math.sin(lat),
};
const dot = p.x * drawn.x + p.y * drawn.y + p.z * drawn.z;
const cos = dot / (Math.hypot(p.x, p.y, p.z) * Math.hypot(drawn.x, drawn.y, drawn.z));
infoGap = 6371008.8 * Math.acos(Math.min(1, cos));
}
// While the 3D model is the visual the head ends at the model's tail
// anchor, which sits on the drawn (pixel-size-enlarged) model.
const model = window.__findTrackedModel ? window.__findTrackedModel() : null;
const sphere = model && model.ready && model.show ? model.boundingSphere : null;
// boundingSphere leaves out the minimum-pixel-size enlargement that
// computedScale carries; the drawn model is that much larger.
const enlarge = sphere && Number.isFinite(model.computedScale) && model.scale > 0
? Math.max(1, model.computedScale / model.scale)
: 1;
const radius = sphere ? sphere.radius * enlarge : null;
return {
end: dist(end, drawn),
onModel: sphere ? dist(end, sphere.center) <= radius + 50 : false,
modelRadius: radius,
infoGap,
vertices: line.length,
};
}, trackedIcao);
// The same reading between frames, the way a page script polls it: the
// head holds the last rendered frame while getTrackedInfo dead-reckons
// the current instant. Logged only, to tell the two apart.
const betweenFrames = [];
for (let i = 0; i < 8; i++) {
await sleep(600);
betweenFrames.push(await evalPage((icao) => {
const viewer = window.__godsEyeView.viewer;
const head = viewer.entities.values.find((e) => String(e.id).startsWith('gev-trail:fl-head-'));
const line = head?.polyline?.positions?.getValue(viewer.clock.currentTime);
const drawn = viewer.trackedEntity?.position?.getValue(viewer.clock.currentTime);
if (!line?.length || !drawn) return null;
const end = line[line.length - 1];
return Math.hypot(end.x - drawn.x, end.y - drawn.y, end.z - drawn.z);
}, trackedIcao));
}
console.log(` (between frames: head end vs entity position, m: ${
betweenFrames.map((v) => (v == null ? '-' : v.toFixed(1))).join(' ')})`);
// Freeze the plane where it got to, so later checks see a still contact.
await evalPage((icao) => {
const f = window.__SYNTH.flights.find((p) => p.icao === icao);
f.lon += (f.vel * (Date.now() - f.moveFromMs)) / 1000
/ (111320 * Math.cos((f.lat * Math.PI) / 180));
delete f.moveFromMs;
}, trackedIcao);
const headUsable = headSamples.values.filter((v) => v != null);
if (headUsable.length < 10) {
skip('trail head: ends at the drawn icon on every frame',
`only ${headUsable.length} frames with a drawn head (timedOut=${headSamples.timedOut})`);
} else {
const worst = Math.max(...headUsable.map((v) => v.end));
const off = headUsable.filter((v) => !(v.end <= 50 || v.onModel));
const onModel = headUsable.filter((v) => v.end > 50 && v.onModel).length;
const radii = headUsable.map((v) => v.modelRadius).filter((v) => v != null);
const gaps = headUsable.map((v) => v.infoGap).filter((v) => v != null);
record('trail head: last vertex on the drawn icon (within 50 m, or on the drawn 3D model) on every frame',
off.length === 0,
`${off.length} of ${headUsable.length} frames off; max end-to-centre=${worst.toFixed(1)} m, ${onModel} frames ending on the model's tail (model radius ${
radii.length ? Math.max(...radii).toFixed(0) : 'n/a'} m); getTrackedInfo vs drawn icon max=${
gaps.length ? Math.max(...gaps).toFixed(1) : 'n/a'} m (logged only)`);
}
// ============================================================
// INVARIANT 2: NO PULL-OUT on a track switch
// tracking B while tracking A must not fly to ~80 km overview.
@@ -1854,8 +1732,7 @@ async function main() {
// ============================================================
// REGRESSION M3 (rewritten 2026-07-02 — deliberate behavior change):
// when the TRACKED plane ages out (10 min after its newest fix, past the
// 3 missed polls that remove an untracked plane), tracking must clear
// when the TRACKED plane ages out (3 missed polls), tracking must clear
// and the camera must be RELEASED IN PLACE — viewer.trackedEntity
// undefined and NO jump (the old contract flew an ~80 km overview; the
// owner field-ruled that wrong: "it randomly zooms way up and loses my
@@ -1876,48 +1753,27 @@ async function main() {
const beforeTracking = !!fl.getTrackedInfo();
// Simulate the age-out via the shim: the plane stops arriving.
window.__SYNTH.flights = window.__SYNTH.flights.filter((f) => f.icao !== icao);
// The tracked plane outlives MISSING_POLL_LIMIT (3 absent polls): it is
// kept until 10 min after its newest fix, so the camera keeps its target.
// MISSING_POLL_LIMIT = 3 consecutive absent polls → removal + clear.
// Capture the camera pose right BEFORE the removing poll — the follow
// camera drifts with the coasting plane until the instant of release,
// so this is the "where the user was looking" reference.
await fl.update(v);
await fl.update(v);
await fl.update(v);
const trackedThroughMisses = !!fl.getTrackedInfo();
// Jump the page clock past the 10 min retention instead of waiting.
const realNow = Date.now;
Date.now = () => realNow() + 11 * 60 * 1000;
try {
// Let the follow camera settle on the stopped plane, then capture the
// pose right BEFORE the removing poll: the "where the user was
// looking" reference.
await new Promise((res) => {
const stop = v.scene.postRender.addEventListener(() => {
stop();
res();
});
v.scene.requestRender();
});
const c0 = v.camera.positionCartographic;
const atAgeOut = {
lonDeg: (c0.longitude * 180) / Math.PI,
latDeg: (c0.latitude * 180) / Math.PI,
height: c0.height,
};
await fl.update(v); // past retention → removal + clear + in-place release
return {
beforeTracking,
trackedThroughMisses,
atAgeOut,
stillTrackedAfter: !!fl.getTrackedInfo(),
viewerStillTracking: !!v.trackedEntity,
};
} finally {
Date.now = realNow;
}
const c0 = v.camera.positionCartographic;
const atAgeOut = {
lonDeg: (c0.longitude * 180) / Math.PI,
latDeg: (c0.latitude * 180) / Math.PI,
height: c0.height,
};
await fl.update(v); // third miss → removal + clear + in-place release
return {
beforeTracking,
atAgeOut,
stillTrackedAfter: !!fl.getTrackedInfo(),
viewerStillTracking: !!v.trackedEntity,
};
}, m3Icao);
record('M3: the tracked plane stays tracked through 3 missed polls (kept 10 min after its newest fix)',
m3.trackedThroughMisses,
`trackedThroughMisses=${m3.trackedThroughMisses}`);
record('M3: tracking cleared after the tracked plane ages out (getTrackedInfo null)',
m3.beforeTracking && !m3.stillTrackedAfter,
`before=${m3.beforeTracking} trackedAfter=${m3.stillTrackedAfter}`);
-9
View File
@@ -83,15 +83,6 @@ export function adsbLolProxy() {
// This mount also matches its /track route, served by its own handler.
if (new URL(req.url || '/', 'http://localhost').pathname !== '/')
return next();
// After the /track hand-off, so /track keeps its own method handling.
if (req.method !== 'GET') {
res.writeHead(405, {
'Content-Type': 'application/json',
Allow: 'GET',
});
res.end(JSON.stringify({ error: 'Method Not Allowed' }));
return;
}
const now = Date.now();
if (_cache && now - _cacheAt < CACHE_MS) {
serve(res, 200, _cache, 'HIT');
Loaded 100 of 676 files, more files were not shown because too many files have changed in this diff. Show more